diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a04dff5..777958b 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 installer + run: node --test tests/install-labels.test.js + history-immutability: if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 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 7d16e41..aa1a996 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 @@ -85,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 @@ -137,5 +166,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 old mode 100644 new mode 100755 index feecc87..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 * @@ -5,18 +8,26 @@ * 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. + * .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', @@ -60,50 +71,275 @@ 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}...`); + 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.'); +} + +/** 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); +} - for (const label of LABELS) { +/** 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 { - 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, - }); + repos = ghPaged('installation/repositories?per_page=100', (page) => page.repositories); } 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; + 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(', ')}`); +} - console.log('All labels installed successfully.'); +if (require.main === module) { + main(process.argv.slice(2)).catch((error) => { + console.error(`install-labels: ${error.message}`); + process.exitCode = 1; + }); } -module.exports = { LABELS, installLabels }; +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 new file mode 100644 index 0000000..1e955b1 --- /dev/null +++ b/tests/install-labels.test.js @@ -0,0 +1,210 @@ +'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, WORKFLOWS_DIR, candidateRepositories, describePlan, installLabels, isPipelineRepository, parseArgs, planLabelChanges, syncOrganization, +} = 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()); + } +}); + +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 }], + ]); +}); + +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)); + } +});