From 0150b0c880b4e0020efd206d887b9861324f8b58 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 22 Sep 2026 21:23:19 -0400 Subject: [PATCH 1/3] install-labels: Test that the workflow copy of LABELS matches install-labels.yml has to inline the label list because gh aw add copies it into consumer repositories without the script, so the two copies are kept in sync by hand. Nothing checked that, and the pipeline-wide installer coming next applies the install-labels.js copy to every repository running the pipeline, so drift between the two would go unnoticed. Also document agent/flake-tracker, which the scripts README list was missing. Prep for installing labels on every pipeline repository. Generated-by: AI Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 11 +++++++++++ scripts/README.md | 7 +++++-- scripts/install-labels.js | 3 ++- tests/install-labels.test.js | 29 +++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/install-labels.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a04dff5..88486b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,17 @@ jobs: assert.ok(snapshot.coverage.aic.total === null || typeof snapshot.coverage.aic.total === 'number'); NODE + install-labels: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Test label definitions + run: node --test tests/install-labels.test.js + history-immutability: if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 diff --git a/scripts/README.md b/scripts/README.md index 7d16e41..5b7e118 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -68,6 +68,8 @@ The script creates or updates the following labels: - **`agent/workflow-edits-allowed`** (purple) — Pre-authorizes an agent run to edit protected files (workflows, README, etc.) without triggering the request_review gate. Apply this to an issue before labeling it `agent/code`, or to a PR before applying `agent/fixme`. +- **`agent/flake-tracker`** (blue) — Marks the CI flake tracker issue that the merge queue analyzer (`queue-triage.md`) maintains. + ### Usage #### Via GitHub Actions @@ -137,5 +139,6 @@ gh api repos/:owner/:repo/labels/agent/code -X PATCH \ ### Customizing Labels To customize the labels (change colors, descriptions, or add new ones), edit the `LABELS` array in -`install-labels.js` **and** the matching copy in `.github/workflows/install-labels.yml`, then rerun the -installation workflow to update the labels on your repository. +`install-labels.js` **and** the matching copy in `.github/workflows/install-labels.yml` +(`tests/install-labels.test.js` fails if they differ), then rerun the installation workflow to update +the labels on your repository. diff --git a/scripts/install-labels.js b/scripts/install-labels.js index feecc87..f752cb9 100644 --- a/scripts/install-labels.js +++ b/scripts/install-labels.js @@ -14,7 +14,8 @@ * `gh label create`, `gh api`). * * Keep LABELS here in sync with the copy in - * .github/workflows/install-labels.yml. + * .github/workflows/install-labels.yml; tests/install-labels.test.js fails + * if they differ. */ const LABELS = [ diff --git a/tests/install-labels.test.js b/tests/install-labels.test.js new file mode 100644 index 0000000..488c3b1 --- /dev/null +++ b/tests/install-labels.test.js @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); +const { LABELS } = require('../scripts/install-labels.js'); + +const workflow = path.join(__dirname, '..', '.github', 'workflows', 'install-labels.yml'); + +test('install-labels.yml inlines the same LABELS as scripts/install-labels.js', () => { + const source = fs.readFileSync(workflow, 'utf8'); + const match = /const LABELS = (\[[\s\S]*?\n\s*\]);/.exec(source); + assert.ok(match, `no LABELS array found in ${workflow}`); + const inlined = vm.runInNewContext(match[1]); + assert.deepEqual(JSON.parse(JSON.stringify(inlined)), JSON.parse(JSON.stringify(LABELS))); +}); + +test('LABELS are well-formed and unique', () => { + const names = new Set(); + for (const label of LABELS) { + assert.match(label.name, /^agent\/[a-z-]+$/); + assert.match(label.color, /^[0-9A-F]{6}$/); + assert.ok(label.description.length > 0 && label.description.length <= 100, `${label.name} description length`); + assert.ok(!names.has(label.name.toLowerCase()), `duplicate ${label.name}`); + names.add(label.name.toLowerCase()); + } +}); From 69d382a13d75f510b869e232323b4942ee363847 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 22 Sep 2026 23:07:05 -0400 Subject: [PATCH 2/3] install-labels: Plan label changes before applying them installLabels() blindly PATCHed every label it found and only created one after a getLabel 404, which means a request per label per run even when nothing changed, and no way to see what a run would do. Split it into a pure planLabelChanges() over the repository's current labels and a small apply step behind a transport interface, so the planned diff is unit tested, can be printed as a dry run, and the upcoming multi-repository installer can reuse the same code with the gh CLI instead of carrying its own copy of the create/update logic. Labels are matched case-insensitively like GitHub does, so a label that differs only in case is renamed rather than failing to create a duplicate. Prep for installing labels on every pipeline repository. Generated-by: AI Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 2 +- scripts/install-labels.js | 126 +++++++++++++++++++++++------------ tests/install-labels.test.js | 88 +++++++++++++++++++++++- 3 files changed, 170 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88486b1..777958b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Test label definitions + - name: Test label installer run: node --test tests/install-labels.test.js history-immutability: diff --git a/scripts/install-labels.js b/scripts/install-labels.js index f752cb9..46c45d2 100644 --- a/scripts/install-labels.js +++ b/scripts/install-labels.js @@ -5,13 +5,12 @@ * issue → PR → review → fix → merge pipeline, and a helper to create or * update them via the GitHub REST API. * - * `.github/workflows/install-labels.yml` embeds this same LABELS array and - * install loop directly in its actions/github-script step (github-script - * already injects `github`/`context` as globals there, so the workflow - * doesn't need to require() this file). This module exists so the same - * logic can be reused from your own scripts or workflows - see - * scripts/README.md for other installation methods (the Actions workflow, - * `gh label create`, `gh api`). + * `.github/workflows/install-labels.yml` embeds this same LABELS array and a + * simpler create-or-update loop directly in its actions/github-script step, + * because `gh aw add` copies that workflow into consumer repositories without + * this file. This module exists so the same logic can be reused from your own + * scripts or workflows - see scripts/README.md for other installation methods + * (the Actions workflow, `gh label create`, `gh api`). * * Keep LABELS here in sync with the copy in * .github/workflows/install-labels.yml; tests/install-labels.test.js fails @@ -61,50 +60,89 @@ const LABELS = [ }, ]; +/** + * Compute what is needed to make a repository's labels match `desired`. + * + * `existing` is the repository's current label list as returned by the REST + * API (`{ name, color, description }`, color without `#`). GitHub treats label + * names case-insensitively and returns colors lowercase, so both are compared + * that way; a label whose name differs only in case is renamed as part of its + * update. Labels not in `desired` are never touched. + * + * Returns `{ create: [label], update: [{ name, label, changes }] }` where + * `name` is the existing label's current name (needed to address it) and + * `changes` lists the differing fields, for logging. + */ +function planLabelChanges(desired, existing) { + const byName = new Map(existing.map((label) => [label.name.toLowerCase(), label])); + const create = []; + const update = []; + for (const label of desired) { + const current = byName.get(label.name.toLowerCase()); + if (!current) { + create.push(label); + continue; + } + const changes = []; + if (current.name !== label.name) changes.push('name'); + if (current.color.toLowerCase() !== label.color.toLowerCase()) changes.push('color'); + if ((current.description || '') !== label.description) changes.push('description'); + if (changes.length > 0) update.push({ name: current.name, label, changes }); + } + return { create, update }; +} + +/** One human-readable line per planned change; empty when nothing changes. */ +function describePlan(plan) { + return [ + ...plan.create.map((label) => ` create ${label.name} (#${label.color}: ${label.description})`), + ...plan.update.map(({ name, label, changes }) => ` update ${name}: ${changes.join(', ')}${name !== label.name ? ` -> ${label.name}` : ''}`), + ]; +} + +/** + * Bring one repository's labels in line with `labels` and return the plan. + * + * `api` is the transport, `{ listLabels(owner, repo), createLabel(owner, + * repo, label), updateLabel(owner, repo, currentName, label) }`, each + * returning a promise; see octokitLabelApi(). With `dryRun` only + * listLabels is called. + */ +async function syncRepositoryLabels(api, owner, repo, { labels = LABELS, dryRun = false } = {}) { + const plan = planLabelChanges(labels, await api.listLabels(owner, repo)); + if (dryRun) return plan; + for (const label of plan.create) await api.createLabel(owner, repo, label); + for (const { name, label } of plan.update) await api.updateLabel(owner, repo, name, label); + return plan; +} + +/** Adapt an Octokit client to the `api` syncRepositoryLabels() expects. */ +function octokitLabelApi(github) { + return { + listLabels: (owner, repo) => github.paginate(github.rest.issues.listLabelsForRepo, { owner, repo, per_page: 100 }), + createLabel: (owner, repo, { name, color, description }) => github.rest.issues.createLabel({ owner, repo, name, color, description }), + updateLabel: (owner, repo, name, label) => github.rest.issues.updateLabel({ + owner, repo, name, new_name: label.name, color: label.color, description: label.description, + }), + }; +} + /** * Create or update all LABELS on a repository. * - * `github` must be an Octokit-like client exposing `rest.issues.getLabel`, - * `rest.issues.createLabel`, and `rest.issues.updateLabel` (e.g. the - * `github` object actions/github-script injects, or the result of + * `github` must be an Octokit client with `paginate` and `rest.issues` (e.g. + * the `github` object actions/github-script injects, or the result of * `@actions/github`'s `getOctokit()`). `context` must expose - * `repo: { owner, repo }`. + * `repo: { owner, repo }`. Only labels that are missing or differ are + * written. */ async function installLabels(github, context) { const { owner, repo } = context.repo; - console.log(`Installing labels on ${owner}/${repo}...`); - - for (const label of LABELS) { - try { - await github.rest.issues.getLabel({ owner, repo, name: label.name }); - - console.log(`Updating label: ${label.name}`); - await github.rest.issues.updateLabel({ - owner, - repo, - name: label.name, - description: label.description, - color: label.color, - }); - } catch (error) { - if (error.status === 404) { - console.log(`Creating label: ${label.name}`); - await github.rest.issues.createLabel({ - owner, - repo, - name: label.name, - description: label.description, - color: label.color, - }); - } else { - console.error(`Error processing label ${label.name}:`, error.message); - throw error; - } - } - } - - console.log('All labels installed successfully.'); + const plan = await syncRepositoryLabels(octokitLabelApi(github), owner, repo); + const lines = describePlan(plan); + for (const line of lines) console.log(line); + console.log(lines.length === 0 ? 'All labels already up to date.' : 'All labels installed successfully.'); } -module.exports = { LABELS, installLabels }; +module.exports = { LABELS, describePlan, installLabels, octokitLabelApi, planLabelChanges, syncRepositoryLabels }; diff --git a/tests/install-labels.test.js b/tests/install-labels.test.js index 488c3b1..48b87e6 100644 --- a/tests/install-labels.test.js +++ b/tests/install-labels.test.js @@ -5,7 +5,7 @@ const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); -const { LABELS } = require('../scripts/install-labels.js'); +const { LABELS, describePlan, installLabels, planLabelChanges } = require('../scripts/install-labels.js'); const workflow = path.join(__dirname, '..', '.github', 'workflows', 'install-labels.yml'); @@ -27,3 +27,89 @@ test('LABELS are well-formed and unique', () => { names.add(label.name.toLowerCase()); } }); + +const DESIRED = [ + { name: 'agent/code', color: '0E8A16', description: 'Triggers' }, + { name: 'agent/lgtm', color: '0E8A16', description: 'Approved' }, + { name: 'agent/fixme', color: 'D93F0B', description: 'Fix it' }, + { name: 'agent/new', color: 'FBCA04', description: 'New' }, +]; + +// What a repository whose labels already match DESIRED returns (the API +// lowercases colors), plus an unrelated label that must be left alone. +const IN_SYNC = [ + ...DESIRED.map((label) => ({ ...label, color: label.color.toLowerCase() })), + { name: 'bug', color: 'ff0000', description: 'Unrelated' }, +]; + +const DRIFTED = [ + { name: 'agent/code', color: 'ffffff', description: 'Triggers' }, + { name: 'agent/lgtm', color: '0e8a16', description: null }, + { name: 'Agent/Fixme', color: '000000', description: 'stale' }, + { name: 'agent/obsolete', color: '000000', description: 'never deleted' }, +]; + +test('plans creates and updates without touching unrelated labels', () => { + for (const [name, existing, expected] of [ + ['empty repository creates everything', [], { create: DESIRED.map((label) => label.name), update: [] }], + ['already in sync', IN_SYNC, { create: [], update: [] }], + ['mixed drift', DRIFTED, { + create: ['agent/new'], + update: [ + { name: 'agent/code', changes: ['color'] }, + { name: 'agent/lgtm', changes: ['description'] }, + { name: 'Agent/Fixme', changes: ['name', 'color', 'description'] }, + ], + }], + ]) { + const plan = planLabelChanges(DESIRED, existing); + assert.deepEqual({ + create: plan.create.map((label) => label.name), + update: plan.update.map(({ name: current, changes }) => ({ name: current, changes })), + }, expected, name); + for (const { label } of plan.update) assert.ok(DESIRED.includes(label), `${name}: update carries the desired label`); + } +}); + +test('describes a plan one line per change', () => { + assert.deepEqual(describePlan(planLabelChanges(DESIRED, IN_SYNC)), []); + assert.deepEqual(describePlan(planLabelChanges(DESIRED, DRIFTED)), [ + ' create agent/new (#FBCA04: New)', + ' update agent/code: color', + ' update agent/lgtm: description', + ' update Agent/Fixme: name, color, description -> agent/fixme', + ]); +}); + +/** A minimal Octokit stand-in that records every write. */ +function fakeOctokit(existing) { + const calls = []; + const record = (method) => async (params) => { calls.push([method, params]); }; + const listLabelsForRepo = () => { throw new Error('listLabelsForRepo must go through paginate'); }; + return { + calls, + paginate: async (endpoint, params) => { + assert.equal(endpoint, listLabelsForRepo); + calls.push(['list', params]); + return existing; + }, + rest: { issues: { listLabelsForRepo, createLabel: record('create'), updateLabel: record('update') } }, + }; +} + +test('installLabels writes only what the plan says', async (t) => { + t.mock.method(console, 'log', () => {}); + const context = { repo: { owner: 'o', repo: 'r' } }; + const [code, lgtm, ...rest] = LABELS; + const existing = [ + { ...code, color: code.color.toLowerCase() }, + { ...lgtm, name: lgtm.name.toUpperCase(), description: 'stale' }, + ]; + const github = fakeOctokit(existing); + await installLabels(github, context); + assert.deepEqual(github.calls, [ + ['list', { owner: 'o', repo: 'r', per_page: 100 }], + ...rest.map(({ name, color, description }) => ['create', { owner: 'o', repo: 'r', name, color, description }]), + ['update', { owner: 'o', repo: 'r', name: lgtm.name.toUpperCase(), new_name: lgtm.name, color: lgtm.color, description: lgtm.description }], + ]); +}); From c69505cadc244603e19cdf9cdee0c258f0222e9f Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 22 Sep 2026 23:08:31 -0400 Subject: [PATCH 3/3] install-labels: Install the labels on every pipeline repository Each repository running the pipeline installs its labels from its own copy of install-labels.yml, which only changes when that repository runs gh aw update, so a new or renamed label can take a long time to arrive: bcvk still lacks agent/drafter-working after the rename, and its drafter silently skips setting its working label because gh issue edit --add-label refuses unknown labels. Add install-labels-org.yml, run on every push to main, that applies LABELS to every bootc-dev repository containing any of drafter.lock.yml, review.lock.yml or fix.lock.yml, the compiled workflows gh aw add installs, so a repository that adopted only part of the pipeline still counts. Today that is bootc, bcvk, infra and gh-agentic-workflows; the other 17 repositories don't run the pipeline and have no use for agent/* labels, so they are skipped rather than cluttered. With --installation it only considers repositories the pipeline's App is installed on, so an adopter the App doesn't cover yet is not tried (and failed) on every run. It runs as a postsubmit rather than on a schedule: label changes land here, so a push is when there is something to apply, and syncing on every push rather than only label changes also repairs drift (labels edited by hand, or reverted by a repository's outdated install-labels.yml) without polling, for one label listing per pipeline repository when nothing changed. bootc-dev/infra already syncs labels.toml to every bootc-dev and composefs repository. Putting the agent labels there was considered, but that sync is unconditional, so it would need a per-label repository filter it doesn't have, and it would add a third copy of LABELS in a different repository with nothing checking it against the two here that gh aw add distributes. Keeping the agent labels here means one repository owns their definition, distribution and sync, while infra keeps owning org-wide labels; the two sets don't overlap. Nothing here runs against the live organization in PR CI; the dry run is available locally or by dispatching the workflow with dry-run. Closes: #104 Generated-by: AI Signed-off-by: Colin Walters --- .github/workflows/install-labels-org.yml | 71 ++++++++ scripts/README.md | 27 +++ scripts/install-labels.js | 199 ++++++++++++++++++++++- tests/install-labels.test.js | 97 ++++++++++- 4 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/install-labels-org.yml mode change 100644 => 100755 scripts/install-labels.js diff --git a/.github/workflows/install-labels-org.yml b/.github/workflows/install-labels-org.yml new file mode 100644 index 0000000..b028b1b --- /dev/null +++ b/.github/workflows/install-labels-org.yml @@ -0,0 +1,71 @@ +name: Install labels on pipeline repositories + +# This is a plain (non-gh-aw) workflow: installs the pipeline's labels +# (scripts/install-labels.js LABELS) on every repository in the organization +# that runs the pipeline, i.e. contains any of drafter.lock.yml, +# review.lock.yml or fix.lock.yml in .github/workflows, so they pick up new or +# renamed labels without waiting for their own copy of install-labels.yml to +# be updated. It only creates labels and fixes their color, description and +# name case; it never deletes one. +# +# Labels every repository should have regardless of the pipeline are synced by +# bootc-dev/infra (labels.toml) instead; this workflow owns only the agent/* +# labels. It is not part of the gh-aw package (aw.yml): it's specific to the +# organization hosting this repository. + +on: + # Every push to main syncs, not only those changing the label set: a run + # that finds nothing to change costs one label listing per pipeline + # repository, and it repairs labels changed by hand or reverted by a + # repository's outdated install-labels.yml without polling for drift. + push: + branches: [main] + workflow_dispatch: + inputs: + dry-run: + description: "Only print the planned label changes (uncheck to apply them)" + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: install-labels-org + cancel-in-progress: false + +jobs: + install-labels: + # Forks have neither the App credentials nor any business editing the + # organization's labels. + if: github.repository == 'bootc-dev/gh-agentic-workflows' + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + # The pipeline's own App: it is installed wherever the pipeline runs, + # and --installation below only considers repositories it can access. + - name: Generate label token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.GH_AW_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_AW_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + # Labels are managed through the Issues API; contents: read is for + # detecting the pipeline marker file. + permission-issues: write + permission-contents: read + + - name: Install labels + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ORG: ${{ github.repository_owner }} + DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }} + run: | + set -euo pipefail + # shellcheck disable=SC2086 # DRY_RUN is empty or a single flag. + node scripts/install-labels.js --org "$ORG" --installation $DRY_RUN diff --git a/scripts/README.md b/scripts/README.md index 5b7e118..aa1a996 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -87,6 +87,33 @@ Alternatively, you can copy `.github/workflows/install-labels.yml` to your own r workflow inlines the LABELS array and install loop directly in its `actions/github-script` step, so it has no dependency on this file being checked out. +#### On every pipeline repository in bootc-dev + +`.github/workflows/install-labels-org.yml` runs this script on every push to `main` and on +dispatch. It installs the labels on every non-archived +bootc-dev repository that runs the pipeline, i.e. contains any of `drafter.lock.yml`, +`review.lock.yml` or `fix.lock.yml` in `.github/workflows`, and that the `GH_AW_APP_*` App +is installed on. Other repositories are left alone. It creates missing labels and fixes the color, +description and name case of existing ones, but never deletes a label. It is not part of the gh-aw +package and runs only from `bootc-dev/gh-agentic-workflows`. + +Each pipeline repository also keeps running its own weekly copy of `install-labels.yml`, +which only picks up label changes when that repository runs `gh aw update`. So after a +label's color or description changes here, that copy reverts it to the old value (and +recreates a renamed label's old name) every week until the repository updates it, and +the next push to `main` here applies the new value again. + +Only the `agent/*` labels are managed here. Labels every bootc-dev and composefs +repository should have regardless of the pipeline (e.g. `triaged`) are synced by +[bootc-dev/infra](https://github.com/bootc-dev/infra)'s `labels.toml`. + +To see what it would change without writing anything, run it with any authenticated +`gh` (read access is enough), or dispatch the workflow, which does a dry run unless `dry-run` is unchecked: + +```bash +node scripts/install-labels.js --org bootc-dev --dry-run +``` + #### Via github-script action If you want to integrate label installation into your own workflow, `install-labels.js` is a plain CommonJS module diff --git a/scripts/install-labels.js b/scripts/install-labels.js old mode 100644 new mode 100755 index 46c45d2..3d1654a --- a/scripts/install-labels.js +++ b/scripts/install-labels.js @@ -1,3 +1,6 @@ +#!/usr/bin/env node +'use strict'; + /** * Install gh-agentic-workflows labels * @@ -15,8 +18,16 @@ * Keep LABELS here in sync with the copy in * .github/workflows/install-labels.yml; tests/install-labels.test.js fails * if they differ. + * + * Run as a script, this installs LABELS on every repository of an + * organization that runs the pipeline (see `--help`), which + * `.github/workflows/install-labels-org.yml` does on every push to main. Labels every + * bootc-dev repository should have regardless of the pipeline belong in + * bootc-dev/infra's labels.toml instead. */ +const childProcess = require('child_process'); + const LABELS = [ { name: 'agent/code', @@ -145,4 +156,190 @@ async function installLabels(github, context) { console.log(lines.length === 0 ? 'All labels already up to date.' : 'All labels installed successfully.'); } -module.exports = { LABELS, describePlan, installLabels, octokitLabelApi, planLabelChanges, syncRepositoryLabels }; +/** Where `gh aw add` installs the pipeline's compiled workflows. */ +const WORKFLOWS_DIR = '.github/workflows'; + +/** + * Compiled pipeline workflows in WORKFLOWS_DIR. A repository with any of them + * has adopted (at least part of) the pipeline, and only those repositories get + * LABELS from the org-wide installer. + */ +const PIPELINE_MARKERS = ['drafter.lock.yml', 'review.lock.yml', 'fix.lock.yml']; + +/** Whether the file names found in WORKFLOWS_DIR include a PIPELINE_MARKERS entry. */ +function isPipelineRepository(workflowFiles) { + return workflowFiles.some((name) => PIPELINE_MARKERS.includes(name)); +} + +/** + * Select the repositories worth checking for PIPELINE_MARKERS: every + * non-archived one, skipping dot-named repositories such as `.github` the + * same way bootc-dev/actions' discover-repos does. Returned sorted for stable + * output. + */ +function candidateRepositories(repos) { + return repos + .filter((repo) => !repo.archived && !repo.name.startsWith('.')) + .map((repo) => repo.name) + .sort(); +} + +function ghApi(args) { + const stdout = childProcess.execFileSync('gh', ['api', ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + return stdout.trim() === '' ? null : JSON.parse(stdout); +} + +/** All pages of a paginated endpoint, each page passed through `items`. */ +function ghPaged(endpoint, items = (page) => page) { + return ghApi(['--paginate', '--slurp', endpoint]).flatMap(items); +} + +function isNotFound(error) { + return /HTTP 404/.test(String(error.stderr || '')); +} + +/** + * The organization-level client syncOrganization() uses, backed by the `gh` + * CLI so it works with whatever token `gh` is authenticated with. + */ +const ghCliClient = { + /** + * With `installation`, the repositories the App installation behind the + * token can access (so repositories it isn't installed on are never + * tried); otherwise every repository of `org` the token can see. + */ + async listRepositories(org, { installation = false } = {}) { + if (!installation) return ghPaged(`orgs/${org}/repos?type=all&per_page=100`); + let repos; + try { + repos = ghPaged('installation/repositories?per_page=100', (page) => page.repositories); + } catch (error) { + throw new Error(`listing the App installation's repositories failed (--installation needs a GitHub App installation token): ${String(error.stderr || error.message).trim()}`); + } + return repos.filter((repo) => repo.owner.login.toLowerCase() === org.toLowerCase()); + }, + /** Names of the entries in directory `path`, or [] if it doesn't exist. */ + async listDirectory(owner, repo, path) { + let entries; + try { + entries = ghApi([`repos/${owner}/${repo}/contents/${path}`]); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + return Array.isArray(entries) ? entries.map((entry) => entry.name) : []; + }, + async listLabels(owner, repo) { + return ghPaged(`repos/${owner}/${repo}/labels?per_page=100`); + }, + async createLabel(owner, repo, { name, color, description }) { + ghApi(['-X', 'POST', `repos/${owner}/${repo}/labels`, '-f', `name=${name}`, '-f', `color=${color}`, '-f', `description=${description}`]); + }, + async updateLabel(owner, repo, currentName, label) { + ghApi(['-X', 'PATCH', `repos/${owner}/${repo}/labels/${encodeURIComponent(currentName)}`, + '-f', `new_name=${label.name}`, '-f', `color=${label.color}`, '-f', `description=${label.description}`]); + }, +}; + +/** + * Install `labels` on every repository of `org` whose WORKFLOWS_DIR contains + * any of PIPELINE_MARKERS. + * + * `client` provides listRepositories() and listDirectory() plus the label methods + * syncRepositoryLabels() needs; see ghCliClient. With `dryRun`, only reads + * and logs the plan. A failure on one repository is logged and the rest still + * proceed; the returned `failed` list lets the caller exit non-zero. + */ +async function syncOrganization(client, org, { dryRun = false, installation = false, labels = LABELS, log = console.log } = {}) { + const candidates = candidateRepositories(await client.listRepositories(org, { installation })); + log(`${dryRun ? 'Dry run: planning' : 'Installing'} ${labels.length} labels on ${org} repositories with any of ${PIPELINE_MARKERS.join(', ')} (${candidates.length} candidates${installation ? ' from the App installation' : ''})`); + const pipeline = []; + const skipped = []; + const failed = []; + let changed = 0; + for (const repo of candidates) { + try { + if (!isPipelineRepository(await client.listDirectory(org, repo, WORKFLOWS_DIR))) { + skipped.push(repo); + continue; + } + pipeline.push(repo); + const lines = describePlan(await syncRepositoryLabels(client, org, repo, { labels, dryRun })); + if (lines.length === 0) { + log(`${org}/${repo}: up to date`); + continue; + } + changed++; + log(`${org}/${repo}: ${dryRun ? 'would change' : 'changed'} ${lines.length} label(s)`); + for (const line of lines) log(line); + } catch (error) { + failed.push(repo); + log(`${org}/${repo}: FAILED: ${String(error.stderr || error.message).trim()}`); + } + } + if (skipped.length > 0) log(`Skipped ${skipped.length} without the pipeline: ${skipped.join(', ')}`); + log(`${pipeline.length} pipeline repositories, ${changed} ${dryRun ? 'would change' : 'changed'}, ${failed.length} failed`); + return { pipeline, skipped, changed, failed }; +} + +function help() { + return `Usage: node scripts/install-labels.js --org ORG [--installation] [--dry-run] + +Create missing gh-agentic-workflows labels and fix the color, description and +name case of existing ones on every non-archived repository in ORG whose +${WORKFLOWS_DIR} contains any of ${PIPELINE_MARKERS.join(', ')}. Labels are +never deleted. + + --installation Only consider repositories the GitHub App installation behind + the token can access (requires an installation token). + --dry-run Only read, and print what would change. + +Requires an authenticated gh CLI; without --dry-run the token needs +issues: write on every pipeline repository.`; +} + +function parseArgs(argv) { + const args = { dryRun: false, installation: false }; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === '--help' || argument === '-h') args.help = true; + else if (argument === '--dry-run') args.dryRun = true; + else if (argument === '--installation') args.installation = true; + else if (argument === '--org') { + args.org = argv[++index]; + if (!args.org || args.org.startsWith('-')) throw new Error(`--org requires a value\n\n${help()}`); + } else throw new Error(`Unexpected argument ${argument}\n\n${help()}`); + } + if (!args.help && !args.org) throw new Error(`--org is required\n\n${help()}`); + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + if (args.help) return console.log(help()); + const { failed } = await syncOrganization(ghCliClient, args.org, { dryRun: args.dryRun, installation: args.installation }); + if (failed.length > 0) throw new Error(`failed on: ${failed.join(', ')}`); +} + +if (require.main === module) { + main(process.argv.slice(2)).catch((error) => { + console.error(`install-labels: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { + LABELS, + PIPELINE_MARKERS, + WORKFLOWS_DIR, + candidateRepositories, + describePlan, + ghCliClient, + installLabels, + isPipelineRepository, + octokitLabelApi, + parseArgs, + planLabelChanges, + syncOrganization, + syncRepositoryLabels, +}; diff --git a/tests/install-labels.test.js b/tests/install-labels.test.js index 48b87e6..1e955b1 100644 --- a/tests/install-labels.test.js +++ b/tests/install-labels.test.js @@ -5,7 +5,9 @@ const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); -const { LABELS, describePlan, installLabels, planLabelChanges } = require('../scripts/install-labels.js'); +const { + LABELS, WORKFLOWS_DIR, candidateRepositories, describePlan, installLabels, isPipelineRepository, parseArgs, planLabelChanges, syncOrganization, +} = require('../scripts/install-labels.js'); const workflow = path.join(__dirname, '..', '.github', 'workflows', 'install-labels.yml'); @@ -113,3 +115,96 @@ test('installLabels writes only what the plan says', async (t) => { ['update', { owner: 'o', repo: 'r', name: lgtm.name.toUpperCase(), new_name: lgtm.name, color: lgtm.color, description: lgtm.description }], ]); }); + +test('candidates are non-archived, non-dot repositories in stable order', () => { + assert.deepEqual(candidateRepositories([ + { name: 'zeta', archived: false }, + { name: 'old', archived: true }, + { name: '.github', archived: false }, + { name: 'alpha', archived: false }, + ]), ['alpha', 'zeta']); +}); + +test('a repository with any compiled pipeline workflow runs the pipeline', () => { + for (const [workflows, expected] of [ + [['drafter.lock.yml'], true], + [['review.lock.yml'], true], + [['ci.yml', 'fix.lock.yml'], true], + [['drafter.md', 'review.md', 'fix.md'], false], + [['ci-triage.lock.yml', 'ci.yml'], false], + [[], false], + ]) { + assert.equal(isPipelineRepository(workflows), expected, JSON.stringify(workflows)); + } +}); + +/** + * An organization client stand-in: `repos` maps each repository name to + * `{ workflows, labels }`, or to an Error its label listing throws (such a + * repository counts as running the pipeline). + */ +function fakeOrg(repos) { + const calls = []; + const labelsOf = (repo) => { + if (repos[repo] instanceof Error) throw repos[repo]; + return repos[repo].labels; + }; + return { + calls, + async listRepositories(org, options) { + calls.push(['repos', org, options]); + return Object.keys(repos).map((name) => ({ name, archived: false })); + }, + async listDirectory(owner, repo, path) { + assert.equal(path, WORKFLOWS_DIR); + return repos[repo] instanceof Error ? ['drafter.lock.yml'] : repos[repo].workflows; + }, + async listLabels(owner, repo) { return labelsOf(repo); }, + async createLabel(owner, repo, label) { calls.push(['create', repo, label.name]); }, + async updateLabel(owner, repo, name, label) { calls.push(['update', repo, name, label.name]); }, + }; +} + +test('syncOrganization only touches pipeline repositories', async () => { + const repos = { + fresh: { workflows: ['review.lock.yml'], labels: [] }, + synced: { workflows: ['drafter.lock.yml', 'drafter.md'], labels: IN_SYNC }, + drifted: { workflows: ['fix.lock.yml'], labels: DRIFTED }, + unrelated: { workflows: ['ci.yml'], labels: [] }, + broken: Object.assign(new Error('boom'), { stderr: 'HTTP 502' }), + }; + for (const [name, dryRun, installation, writes] of [ + ['dry run writes nothing', true, false, []], + ['apply writes to pipeline repositories only', false, true, [ + // Repositories are processed in sorted order. + ['create', 'drifted', 'agent/new'], + ['update', 'drifted', 'agent/code', 'agent/code'], + ['update', 'drifted', 'agent/lgtm', 'agent/lgtm'], + ['update', 'drifted', 'Agent/Fixme', 'agent/fixme'], + ...DESIRED.map((label) => ['create', 'fresh', label.name]), + ]], + ]) { + const client = fakeOrg(repos); + const log = []; + const result = await syncOrganization(client, 'org', { dryRun, installation, labels: DESIRED, log: (line) => log.push(line) }); + assert.deepEqual(client.calls, [['repos', 'org', { installation }], ...writes], name); + assert.deepEqual(result, { pipeline: ['broken', 'drifted', 'fresh', 'synced'], skipped: ['unrelated'], changed: 2, failed: ['broken'] }, name); + assert.ok(log.includes('org/broken: FAILED: HTTP 502'), `${name}: failure is logged`); + assert.ok(log.includes('org/synced: up to date'), `${name}: in-sync repository is logged`); + } +}); + +test('parses CLI arguments', () => { + const defaults = { dryRun: false, installation: false }; + for (const [argv, expected] of [ + [['--org', 'bootc-dev'], { ...defaults, org: 'bootc-dev' }], + [['--dry-run', '--org', 'bootc-dev'], { ...defaults, dryRun: true, org: 'bootc-dev' }], + [['--org', 'bootc-dev', '--installation'], { ...defaults, installation: true, org: 'bootc-dev' }], + [['--help'], { ...defaults, help: true }], + ]) { + assert.deepEqual(parseArgs(argv), expected, JSON.stringify(argv)); + } + for (const argv of [[], ['--org'], ['--org', '--dry-run'], ['--org', 'x', 'extra'], ['--bogus']]) { + assert.throws(() => parseArgs(argv), undefined, JSON.stringify(argv)); + } +});