diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b8ff3d098f..f02e928299 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1288,6 +1288,26 @@ jobs: - name: Workflow status-function guard run: pnpm check:workflow-status-functions + # Additive-label-write self-test (#10703). `pr-automation.yml` writes this + # PR's labels with `scripts/pr-labels.mjs`, whose whole contract is that it + # emits POST and targeted DELETE and NEVER a whole-set + # `PUT /issues/{n}/labels` -- the verb that erased a seat-applied + # `skip-changeset` one second after it was written on PR #10698, turning a + # PR that publishes nothing into a false `changeset-check` red. The + # self-test pins the pure write-plan builders (asserting no plan any input + # can produce carries that verb), the size buckets (`<`, not `<=`, matching + # the action it replaced) and the minimatch subset the path matcher + # implements -- and it parses the REAL checked-in `.github/labeler.yml`, so + # a pattern that drifts outside that subset fails here instead of silently + # mislabelling PRs. + # + # It runs in THIS job and not only in `Check PR Size` because that context + # is deliberately excluded from the required set (a `labeled` event + # republishes it as `skipped`, which washes green -- see + # check-required-contexts.mjs), so a red there blocks nothing. + - name: Additive label-write self-test + run: node scripts/pr-labels.mjs --self-test + # Cross-repo closer outcome contract (#9595, and #9575 before it). # `cross-repo-issue-closer.yml` carries ~150 lines of inline # github-script, and it is code nobody has ever seen run: over the 1176 diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 437e297e94..377c3c16f4 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -6,90 +6,158 @@ on: jobs: # =========================================================================== - # Both label-writing jobs below write this PR's label set with a WHOLE-SET PUT - # (`PUT /issues/{n}/labels`), never an additive POST. Read out of the pinned - # sources rather than inferred from the docs (#5649): + # LABEL WRITES IN THIS FILE ARE ADDITIVE. Nothing here may issue + # `PUT /issues/{n}/labels`. + # + # A whole-set PUT REPLACES a PR's label set, so every whole-set write is a + # read-modify-write across a network round trip and destroys any label that + # lands in between -- silently, with an `unlabeled` event nobody watches for. + # Only three verbs exist and only one is destructive: + # + # POST /issues/{n}/labels adds the named labels, touches nothing else + # DELETE /issues/{n}/labels/{name} removes ONE label, BY NAME + # PUT /issues/{n}/labels replaces the whole set -- DESTRUCTIVE + # + # Both jobs below used to reach the third verb through a third-party action. + # Read out of the pinned sources rather than inferred from the docs (#5649): # # * codelytv/pr-size-labeler@v1.10.4 -- src/github.sh:68-91 # (`github::add_label_to_pr`): GETs the PR, greps its OWN size family out # of the result, appends the new size label, then - # `curl -X PUT .../issues/$pr_number/labels` with the whole set. + # `curl -X PUT .../issues/$pr_number/labels` with the whole set. No + # mitigation of any kind: the window is the entire round trip. # * actions/labeler@v7.0.0 -- src/labeler.ts:56,111-133 plus # src/api/set-labels.ts: snapshots `preexistingLabels` at run start, - # unions in the config matches, re-reads the live label list once, then - # calls `client.rest.issues.setLabels` -- which IS the PUT. + # unions in the config matches, re-reads the live label list once and + # carries forward whatever appeared in between, then calls + # `client.rest.issues.setLabels` -- which IS the PUT. That re-read + # NARROWS the window to [re-read .. PUT]; it does not close it. + # + # Neither action exposed an input that made its write additive, and + # `sync-labels` was never that input: it only decided whether a label the + # CONFIG owns is dropped once its globs stop matching (labeler.ts:81-83). + # + # ## The measured loss (#10703) + # + # PR #10698, every label event from the timeline API: + # + # 09:05:29Z labeled skip-changeset claude[bot] (additive POST, HTTP 200) + # 09:05:30Z unlabeled skip-changeset github-actions[bot] <-- the size labeler's PUT + # 09:05:30Z labeled size/l github-actions[bot] + # 09:05:42Z labeled ci/cd github-actions[bot] + # 09:06:03Z labeled skip-changeset claude[bot] (re-applied after read-back) # - # Neither action exposes an input that makes its write additive, and - # `sync-labels` is NOT that input: it only decides whether a label the CONFIG - # owns is dropped once its globs stop matching (labeler.ts:81-83). It is - # pinned explicitly below for upgrade-drift protection only. It does not, and - # cannot, stop the clobbering described here. + # One second. The writer did everything right -- additive POST, HTTP 200, + # read-back confirmed -- and still lost the label. `skip-changeset` is the + # exemption for a PR that publishes nothing, so its erasure makes + # `changeset-check` demand a changeset from a PR that legitimately has none. + # #5533 lost the same label the same way, that time to the path labeler's PUT + # of `{size/m, tests}`. # - # A whole-set PUT only destroys someone else's label when that label lands - # inside the window between the writer's read and its PUT. What this file can - # therefore fix is the OVERLAP, and two changes below do exactly that: + # ## Why this is a fix and not another narrowing # - # 1. The two writers no longer run concurrently -- `auto-label` needs - # `pr-size`. They used to be started by the same event and overlapped - # exactly. Live specimen, PR #5650 run 31051251795 (the `opened` run): - # `Add size label` ran 22:03:47->22:03:49 and - # `Label based on changed files` ran 22:03:47->22:03:49, and the - # labeler's PUT emitted `unlabeled size/s` at 22:03:49 -- one second - # after the size job added it, for a label the labeler does not manage. - # 2. Neither writer runs on `labeled`/`unlabeled` any more. Their only input - # is the diff, which a label event cannot change, so such a run could - # only ever re-PUT the same set -- one more chance to erase a concurrent - # writer in exchange for no new information. Same PR, run 31051273625 - # (started by a label event): `Auto Label` recomputed and wrote nothing, - # `Check PR Size` re-PUT at 22:04:22. The two event types stay in `on:` - # because `changeset-check` genuinely needs them (#5580). + # Both steps now call `scripts/pr-labels.mjs`, which issues POST and targeted + # DELETE only. Neither verb carries a label the writer does not name, so + # neither can destroy a concurrent writer's label -- at ANY interleaving, with + # no ordering constraint between writers and no window left to narrow. + # Correctness no longer depends on timing, which is what every configuration + # change before it could only ever improve. The plan builders in that script + # are pure functions and its `--self-test` asserts they emit no PUT, so a + # future edit that reaches for a whole-set write goes red in lint before it + # can reach a PR. # - # NOT closed by either change, and deliberately recorded rather than implied: - # a writer OUTSIDE this workflow -- an agent or a human labelling the PR - # seconds after `gh pr create`, i.e. exactly while these jobs run -- can still - # land inside a PUT window and be erased. That is how #5533 lost its - # `skip-changeset` exemption for one second (15:46:44 applied, 15:46:45 erased - # by the labeler's PUT of `{size/m, tests}`). Closing that half needs the - # writes themselves to become additive, not merely better ordered; it is the - # open half of #5649 and no configuration here can stand in for it. + # Two earlier ordering changes are RETAINED below, now as belt-and-braces + # rather than as the mitigation: + # + # 1. `auto-label` still `needs: pr-size`. They used to be started by the + # same event and overlapped exactly -- live specimen, PR #5650 run + # 31051251795: `Add size label` ran 22:03:47->22:03:49 and `Label based + # on changed files` ran 22:03:47->22:03:49, and the labeler's PUT emitted + # `unlabeled size/s` at 22:03:49, one second after the size job added it, + # for a label the labeler does not manage. Additive writes make that + # overlap harmless; the edge is kept because removing it is an unrelated + # change to this file's job graph and it costs one job's queue time. + # 2. Neither writer runs on `labeled`/`unlabeled`. Their only input is the + # diff, which a label event cannot change, so such a run could only ever + # recompute the same answer. It no longer risks an erasure, but it still + # buys nothing. The two event types stay in `on:` because + # `changeset-check` genuinely needs them (#5580). + # + # ## What is still open + # + # This file no longer writes a whole set, but nothing MECHANICALLY stops a + # future workflow, action or agent from doing so -- a seat calling the labels + # endpoint with a `labels` array, or a re-introduced third-party labeler, + # reopens exactly this defect with no gate to catch it. There is no repo gate + # that bans the verb; until there is, this paragraph and the script's + # self-test are the whole guard. # =========================================================================== pr-size: name: Check PR Size # A `labeled`/`unlabeled` event cannot change this job's input (the diff), - # so running it there buys nothing and costs one whole-set PUT. See above. + # so running it there recomputes the same answer for a fee. See above. if: github.event.action != 'labeled' && github.event.action != 'unlabeled' runs-on: ubuntu-latest permissions: + # `contents: read` is for the checkout the label writer needs. Declaring + # any `permissions:` block drops every scope not listed, so it has to be + # spelled even though the default token would have had it. + contents: read pull-requests: write steps: - - name: Add size label - uses: codelytv/pr-size-labeler@v1.10.4 - with: + - name: Checkout repository + uses: actions/checkout@v7 + + # The self-test runs BEFORE the write, in the same job, so a matcher or a + # plan builder that has drifted fails without touching the PR. + - name: Self-test the additive label writer + run: node scripts/pr-labels.mjs --self-test + + # Every threshold and label below carries the SAME name and the SAME value + # the retired `codelytv/pr-size-labeler` input had, so this replacement is + # auditable value-for-value against the diff that introduced it. The + # comparison is `<` and not `<=`, matching that action's labeler.sh:50-60 + # (`-lt`): a 10-line PR is `size/s`, not `size/xs`. + # + # Two of its inputs are deliberately NOT carried over: + # * `fail_if_xl: 'false'` -- it selected the do-nothing branch. + # * `message_if_xl` -- DEAD as this workflow configured it. labeler.sh + # calls `add_label_to_pr` and only then asks + # `! github::has_label "$pr_number" "$xl_label"`, i.e. it tests for the + # label it has just written, so the guard is false and the comment + # never posts. Reimplementing it here would be adding a comment this + # repo has never actually seen, which is a feature request, not a + # port. Ask for it on its own card if it is wanted. + # + # Unlike the action, this paginates `pulls/{n}/files` (github.sh:23 caps at + # `per_page=100` and says so in its own NOTE), so a PR over 100 files is + # now sized on all of them and may land a larger, correct label. + - name: Add size label (additive POST, then a targeted DELETE) + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - xs_label: 'size/xs' - xs_max_size: '10' - s_label: 'size/s' - s_max_size: '100' - m_label: 'size/m' - m_max_size: '500' - l_label: 'size/l' - l_max_size: '1000' - xl_label: 'size/xl' - fail_if_xl: 'false' - message_if_xl: 'This PR is very large. Consider breaking it into smaller PRs for easier review.' - files_to_ignore: 'pnpm-lock.yaml package-lock.json yarn.lock' + PR_NUMBER: ${{ github.event.pull_request.number }} + XS_LABEL: 'size/xs' + XS_MAX_SIZE: '10' + S_LABEL: 'size/s' + S_MAX_SIZE: '100' + M_LABEL: 'size/m' + M_MAX_SIZE: '500' + L_LABEL: 'size/l' + L_MAX_SIZE: '1000' + XL_LABEL: 'size/xl' + FILES_TO_IGNORE: 'pnpm-lock.yaml package-lock.json yarn.lock' + run: node scripts/pr-labels.mjs --size auto-label: name: Auto Label - # ORDERING ONLY, not a dependency: this job wants `pr-size`'s PUT to be - # already done, so that the label set this one reads includes the size - # label and its own PUT carries it forward. `!cancelled()` is written out - # because GitHub would otherwise wrap this `if:` in an implicit `success()` - # -- a failed or skipped size job must not silently stop path labelling. - # (Same reasoning the check-workflow-status-functions gate exists to make - # explicit; that gate scans only `needs.*.outputs.*` reads, so this one is - # out of its scope and has to state its intent by hand.) + # ORDERING ONLY, not a dependency: see point 1 in the header. `!cancelled()` + # is written out because GitHub would otherwise wrap this `if:` in an + # implicit `success()` -- a failed or skipped size job must not silently + # stop path labelling. (Same reasoning the + # check-workflow-status-functions gate exists to make explicit; that gate + # scans only `needs.*.outputs.*` reads, so this one is out of its scope and + # has to state its intent by hand.) needs: pr-size if: >- !cancelled() @@ -104,19 +172,23 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Label based on changed files - uses: actions/labeler@v7.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - configuration-path: .github/labeler.yml - # Pinned at the value it already defaults to (action.yml), because a - # default is not a decision: an upgrade may move it, and `true` would - # make this step REMOVE a label of its own config whenever the globs - # stop matching -- on a `synchronize` that reverts a docs file, for - # instance. Pinning it is upgrade-drift protection and nothing more: - # `sync-labels` never governed foreign labels, so it is NOT the fix - # for the clobbering documented at the top of this file (#5649). - sync-labels: false + # Path labels are ADD-ONLY, which is what `sync-labels: false` meant for + # the retired `actions/labeler`: a label whose globs stop matching is left + # alone. So this half issues POST and has no DELETE at all. + # + # `.github/labeler.yml` stays the single source of truth. The script + # implements the minimatch subset that config actually uses -- `*`, `?`, + # `**` as a whole segment, `dot: true` semantics (which is what + # actions/labeler v7 defaults to) -- and REFUSES anything else with a + # non-zero exit naming the offending line. The self-test parses the real + # checked-in config, so a pattern that drifts outside the subset fails + # there rather than silently mislabelling PRs. + - name: Label based on changed files (additive POST) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + LABELER_CONFIG: .github/labeler.yml + run: node scripts/pr-labels.mjs --paths changeset-check: name: Check Changeset diff --git a/scripts/pr-labels.mjs b/scripts/pr-labels.mjs new file mode 100644 index 0000000000..c71988a992 --- /dev/null +++ b/scripts/pr-labels.mjs @@ -0,0 +1,783 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * pr-labels (#10703) -- this repo's automation writes a PR's labels + * ADDITIVELY, and never as a whole set. + * + * node scripts/pr-labels.mjs --self-test # prove the matcher, the bucketer + * # and the write PLANS (lint + CI) + * node scripts/pr-labels.mjs --size # write this PR's size/* label + * node scripts/pr-labels.mjs --paths # write this PR's path labels + * node scripts/pr-labels.mjs --size --dry-run # print the plan, write nothing + * + * ## The defect this replaces + * + * `PUT /issues/{n}/labels` REPLACES a PR's whole label set. Every whole-set + * write is therefore a read-modify-write across a network round trip, and any + * label that lands between the read and the PUT is destroyed -- silently, with + * an `unlabeled` event nobody is watching for. + * + * Both label writers this file replaces did exactly that, read out of their + * pinned sources rather than inferred from their docs: + * + * * codelytv/pr-size-labeler@v1.10.4 -- src/github.sh:68-91 + * (`github::add_label_to_pr`): GETs the PR, greps its OWN size family out + * of the result, appends the new size label, then + * `curl -X PUT .../issues/$pr_number/labels` with the whole set. No + * mitigation of any kind: the window is the entire round trip. + * * actions/labeler@v7.0.0 -- src/labeler.ts:56,111-133 plus + * src/api/set-labels.ts: snapshots `preexistingLabels` at run start, unions + * in the config matches, re-reads the live label list once and carries + * forward whatever appeared in between, then calls + * `client.rest.issues.setLabels` -- which IS the PUT. The re-read NARROWS + * the window to [re-read .. PUT]; it does not close it. + * + * Measured loss on PR #10698 (timeline API, `labeled`/`unlabeled` events): + * + * 09:05:29Z labeled skip-changeset claude[bot] (additive POST, HTTP 200) + * 09:05:30Z unlabeled skip-changeset github-actions[bot] <-- the size labeler's PUT + * 09:05:30Z labeled size/l github-actions[bot] + * 09:05:42Z labeled ci/cd github-actions[bot] + * 09:06:03Z labeled skip-changeset claude[bot] (re-applied after read-back) + * + * `skip-changeset` is the exemption for a PR that publishes nothing, so its + * erasure makes `changeset-check` demand a changeset from a PR that + * legitimately has none. The same shape cost #5533 its exemption too, that time + * to the path labeler's PUT of `{size/m, tests}`. + * + * ## The fix, and why it is a fix rather than a narrowing + * + * Only three label verbs exist, and exactly one of them is destructive: + * + * POST /issues/{n}/labels adds the named labels. Touches nothing else. + * DELETE /issues/{n}/labels/{name} removes ONE label, BY NAME. + * PUT /issues/{n}/labels replaces the whole set. Destructive. + * + * Neither POST nor DELETE carries a label this writer does not name, so neither + * can destroy a concurrent writer's label -- at any interleaving, with no + * ordering constraint between the writers, and with no window to narrow. That + * is the difference between this and every configuration change that came + * before it: correctness here does not depend on timing at all. + * + * The plan builders below are PURE and are asserted by `--self-test` to emit + * only POST and DELETE. That assertion is the contract this file exists for; if + * a future edit reaches for a whole-set write, the self-test goes red before + * the write ever reaches a PR. + * + * ## What is deliberately preserved from the retired actions + * + * * The size buckets compare with `<`, NOT `<=` -- `labeler.sh:50-60` uses + * `[ "$total" -lt "$max" ]`. A 10-line PR is `size/s`, not `size/xs`. The + * thresholds arrive in the same env names the action's inputs used, so the + * workflow diff is auditable value-for-value. + * * `files_to_ignore` is a space-separated list matched against the WHOLE + * path, the way `[[ $filename == $pattern ]]` did in github.sh:36. + * * Path labels are never removed, matching `sync-labels: false`. The path + * half issues POST only -- it has no DELETE at all. + * * The size family IS owned by this writer, so a stale `size/*` is removed + * by a targeted DELETE naming exactly that label. codelytv did the same + * thing by grepping the family out of its PUT payload; the difference is + * that its version also carried -- and could drop -- every bystander label. + * + * One behaviour deliberately DIVERGES: github.sh:23 reads + * `pulls/{n}/files?per_page=100` and never paginates ("NOTE: this code is not + * resilient to changes w/ > 100 files"), so a 400-file PR was sized off its + * first 100 files. This paginates. A PR over 100 files may therefore get a + * larger, and correct, size label than it used to. + * + * ## The labeler.yml subset, and why unsupported syntax is a hard error + * + * `actions/labeler` evaluates `.github/labeler.yml` with minimatch. This does + * not embed minimatch (the job that runs it does a bare checkout and installs + * no dependencies), so it implements the subset the config actually uses and + * REFUSES everything else with a non-zero exit. Silent mis-evaluation of a + * pattern nobody re-read is the failure mode worth engineering against: a + * refusal is a red job with the offending line quoted, while a permissive + * matcher that guesses wrong just mislabels PRs forever. + * + * Supported: `*`, `?`, `**` as a whole path segment, and literals -- with + * `dot: true` semantics, which is what actions/labeler v7 defaults to + * (action.yml `dot: default true`), so no dotfile special case exists. + * Refused: brace expansion, character classes, extglob, and negation. + * Refused config keys: anything but `changed-files` / `any-glob-to-any-file`. + * + * `--self-test` parses the REAL checked-in `.github/labeler.yml`, so a config + * that drifts outside the subset fails the lint gate rather than a PR run. + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; + +const REPO_ROOT = new URL('..', import.meta.url); +const DEFAULT_LABELER_CONFIG = fileURLToPath(new URL('.github/labeler.yml', REPO_ROOT)); + +/** The one verb this file must never emit. Named once so the self-test can quote it. */ +const FORBIDDEN_VERB = 'PUT'; + +class ConfigError extends Error {} + +// --------------------------------------------------------------------------- +// Glob matching -- the minimatch subset, with `dot: true` semantics. +// --------------------------------------------------------------------------- + +/** + * Characters that mean something to minimatch and nothing here. Refusing them + * is the whole point: see the header. `\\` is refused too, because a pattern + * that escapes a metacharacter is a pattern that expected the metacharacter to + * be live. + */ +const REFUSED_GLOB_CHARS = new Set(['{', '}', '[', ']', '(', ')', '!', '+', '@', '|', '\\']); + +/** Regex-special characters that survive as literals in a glob segment. */ +const REGEX_SPECIALS = /[.^$]/g; + +/** + * Compile ONE path segment (no `/`) to a regular expression source. + * Throws ConfigError on any syntax outside the supported subset. + */ +export function segmentToRegExpSource(segment, pattern) { + let source = ''; + for (const ch of segment) { + if (REFUSED_GLOB_CHARS.has(ch)) { + throw new ConfigError( + `glob '${pattern}' uses '${ch}', which is minimatch syntax this repo's ` + + `label matcher does not implement (see scripts/pr-labels.mjs). ` + + `Rewrite the pattern with only '*', '?', '**' and literals, or extend the matcher.` + ); + } + if (ch === '*') source += '[^/]*'; + else if (ch === '?') source += '[^/]'; + else source += ch.replace(REGEX_SPECIALS, (c) => `\\${c}`); + } + return source; +} + +/** + * Match one POSIX-ish path against one glob. + * + * `**` is only special as a WHOLE segment, where it matches zero or more path + * segments. So the pattern `content/` + `**` + `/` + `*` matches `content/a.md` + * (zero segments) as well as `content/a/b.md`, and a leading `**` segment lets + * the pattern `**` + `/` + `*.md` match a root-level `README.md`. That + * zero-segment case is the one a naive implementation gets wrong, and it is + * pinned in the self-test. (Those patterns are spelled as concatenations + * because the literal sequence would close this comment block -- do NOT + * "fix" it with an invisible separator character, which is unsearchable.) + */ +export function matchGlob(pattern, filePath) { + const pat = pattern.split('/'); + const parts = filePath.split('/'); + + // Compile once per call site; patterns are few and paths are many, so the + // cache below keeps this from recompiling per file. + const compiled = pat.map((seg) => (seg === '**' ? '**' : new RegExp(`^${segmentToRegExpSource(seg, pattern)}$`))); + + const seen = new Set(); + const walk = (i, j) => { + const key = i * (parts.length + 1) + j; + if (seen.has(key)) return false; + seen.add(key); + + if (i === compiled.length) return j === parts.length; + if (compiled[i] === '**') { + for (let k = j; k <= parts.length; k += 1) { + if (walk(i + 1, k)) return true; + } + return false; + } + if (j === parts.length) return false; + if (!compiled[i].test(parts[j])) return false; + return walk(i + 1, j + 1); + }; + + return walk(0, 0); +} + +// --------------------------------------------------------------------------- +// Size bucketing -- codelytv/pr-size-labeler src/labeler.sh:50-60, verbatim. +// --------------------------------------------------------------------------- + +/** + * @param {number} total additions + deletions across the non-ignored files + * @param {Array<{max: number|null, label: string}>} buckets ordered ascending; + * the final entry carries `max: null` and is the fallthrough (xl). + */ +export function sizeLabelFor(total, buckets) { + for (const bucket of buckets) { + // `-lt`, not `-le`. A PR of exactly `xs_max_size` lines is NOT xs. + if (bucket.max !== null && total < bucket.max) return bucket.label; + } + const fallthrough = buckets[buckets.length - 1]; + if (!fallthrough || fallthrough.max !== null) { + throw new ConfigError('size buckets must end with a max-less fallthrough bucket'); + } + return fallthrough.label; +} + +/** Total modifications, mirroring github.sh:5-52 with pagination added. */ +export function totalModifications(files, filesToIgnore) { + let total = 0; + for (const file of files) { + const ignored = filesToIgnore.some((pattern) => matchGlob(pattern, file.filename)); + if (ignored) continue; + total += (file.additions ?? 0) + (file.deletions ?? 0); + } + return total; +} + +// --------------------------------------------------------------------------- +// The labeler.yml subset parser. +// --------------------------------------------------------------------------- + +const SUPPORTED_MATCHER = 'any-glob-to-any-file'; +const SUPPORTED_SELECTOR = 'changed-files'; + +/** Strip one layer of matching quotes from a scalar, if present. */ +function unquote(raw) { + const value = raw.trim(); + if (value.length >= 2) { + const first = value[0]; + if ((first === "'" || first === '"') && value[value.length - 1] === first) { + return value.slice(1, -1); + } + } + return value; +} + +/** + * Parse the labeler config into `Map` of globs. + * + * A restricted, line-oriented reader for exactly the shape this repo's config + * uses. Every line it does not recognise is a hard error naming the line + * number -- see the header for why guessing is the worse failure. + */ +export function parseLabelerConfig(text, source = '') { + const labels = new Map(); + let current = null; + + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const where = `${source}:${i + 1}`; + const withoutComment = line.replace(/^\s*#.*$/, ''); + if (withoutComment.trim() === '') continue; + + const indent = line.length - line.trimStart().length; + const body = line.trim(); + + if (indent === 0) { + const m = /^(.+?):$/.exec(body); + if (!m) throw new ConfigError(`${where}: expected a top-level '