diff --git a/.github/workflows/sync-automation-template.yml b/.github/workflows/sync-automation-template.yml new file mode 100644 index 0000000..d1fd0a5 --- /dev/null +++ b/.github/workflows/sync-automation-template.yml @@ -0,0 +1,46 @@ +name: Sync automation template +run-name: Open a pull request in every consumer repo whose automation.yml has drifted +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + inputs: + dry_run: + description: 'Report drift without opening pull requests' + type: boolean + default: false + push: + branches: + - main + paths: + - automation-template.yml +permissions: + contents: read +concurrency: + group: sync-automation-template + cancel-in-progress: false +jobs: + sync: + name: Sync consumers + runs-on: ubuntu-latest + steps: + - name: Generate App Token + id: generate-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.LE_BOT_APP_ID }} + private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + owner: learningequality + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'yarn' + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Sync consumers + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + run: node scripts/sync-automation-template.js ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/docs/automation.md b/docs/automation.md index 3f07e8d..ed516b0 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -47,8 +47,9 @@ No edits required. Then set the secrets: | `CONTRIBUTIONS_SHEET_NAME` | no | Sheet name within the spreadsheet | | `GH_UPLOADER_GCP_SA_CREDENTIALS` | no | GCP service account credentials for Sheets access | -Every automation except `resolve-bot-pr-threads` authenticates as the bot, so the two required -secrets must be set. `resolve-bot-pr-threads` uses the default `GITHUB_TOKEN` instead. +Every automation except `resolve-bot-pr-threads` authenticates as `learning-equality-bot[bot]`, the +GitHub App behind `LE_BOT_APP_ID`, so the two required secrets must be set. `resolve-bot-pr-threads` +uses the default `GITHUB_TOKEN` instead. Optional means that you accept losing the automations that use the secret. It does not mean that they degrade gracefully. The generated caller forwards every key, so an absent secret reaches the @@ -68,3 +69,57 @@ leaf workflow as an empty string, and every automation that needs it fails at ru picks up the wider `on:` block the next time they re-copy `automation-template.yml` - existing copies keep running on their current `on:` block until then, since GitHub workflow triggers are evaluated from the file checked into the consumer repo itself, not from this repo. + +## Keeping the copies in sync + +Most registry changes reach consumers automatically because their copied file only says: +`uses: learningequality/.github/.github/workflows/automation.yml@main`. A consumer needs its file +updated whenever its copy no longer matches the template, which happens in two ways. + +The template changes here, such as when a new event or activity type is added to the `on:` union, +permissions are widened, or the secret list changes. Or the consumer's own tooling rewrites its +copy, which is reported as a `toolchain-conflict` below. + +`.github/workflows/sync-automation-template.yml` handles this. It runs weekly, on manual dispatch, +and whenever `automation-template.yml` changes on `main`. + +It discovers the consumers by walking the org's repos, skipping archived ones and forks, and keeping +every repo whose `.github/workflows/automation.yml` calls this repo's `automation.yml`. Each pull +request targets that repo's default branch, which is the only branch GitHub evaluates workflow +triggers from. + +For each consumer it compares the copy with the template and opens a pull request when they differ. +A repo that is already in sync gets nothing, while a repo with an existing sync pull request has +that pull request updated rather than a second one opened. + +The workflow only proposes changes. It opens pull requests on a branch, never commits to a default +branch, and never merges, approves, or enables auto-merge. A core maintainer in each consumer repo +gives the final review and merges under that repo's own rules. + +Run it with `dry_run` to see which repos have drifted without opening any pull requests. + +Two results turn the run red and require action: + +- `error` means the repo could not be read or written. The app already has the permissions required + for syncing, so this is usually a transient API failure or the app not being installed on that + repo. Check the installation first for a recently added repo. +- `toolchain-conflict` means a sync pull request was merged, the template has not changed since, and + the file has drifted again. The repo's own tooling is rewriting the copy, so the template is not + stable under that toolchain. Fix the template rather than reopening the pull request. + +A third result, `declined`, keeps the run green and requires no action. It means a core maintainer +closed the last sync pull request without merging it, so the workflow leaves that repo alone until +the template changes again. The repo remains drifted in the meantime. To restore it sooner, copy the +template in by hand. + +To onboard a repo, copy the template in and make sure the `learning-equality-bot[bot]` app is +installed on it. The next run picks it up. A repo on which the app is not installed stays invisible +to the sync, so the installation is what enrols it. + +The pull request body is a short explanation of what changed and why the file is generated. + +`kolibri-design-system` is the exception. Its `check-description` job fails unless the body carries +a Changelog section whose Description is not the placeholder its own template ships with, and a +plain explanation has no such section. For that repo the sync reads its template and fills each +field, so the body is not a half-filled form. Nothing about that template is stored here, so it +stays current as they change it. diff --git a/scripts/sync-automation-template.js b/scripts/sync-automation-template.js new file mode 100644 index 0000000..58b1ac0 --- /dev/null +++ b/scripts/sync-automation-template.js @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Opens one pull request per consumer repo whose copy of automation.yml has + * drifted from automation-template.yml. + * + * Usage: + * node scripts/sync-automation-template.js open or update pull requests + * node scripts/sync-automation-template.js --dry-run report only, change nothing + * + * Requires GITHUB_TOKEN with contents:write and pull-requests:write on each + * consumer repo. It never commits to a default branch and never merges. + */ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const TEMPLATE_PATH = path.join(ROOT, 'automation-template.yml'); + +// SYNC_ORG points the whole run at another org, so the write path can be +// exercised somewhere other than production. +const ORG = process.env.SYNC_ORG || 'learningequality'; +const TARGET_PATH = '.github/workflows/automation.yml'; +const BRANCH = 'automation-template-sync'; +const TITLE = 'Refresh automation.yml from the shared template'; +const API = 'https://api.github.com'; +const CONSUMER_MARKER = 'workflows/automation.yml@'; + +const PROBLEM_STATES = ['error', 'toolchain-conflict']; + +function httpApi(token) { + return async function api(method, url, body) { + const res = await fetch(url.startsWith('http') ? url : `${API}${url}`, { + method, + headers: { + authorization: `Bearer ${token}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'content-type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let data = null; + // A gateway error answers with an HTML page, so parsing has to be able to fail. + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { message: text.slice(0, 200) }; + } + return { ok: res.ok, status: res.status, data }; + }; +} + +const EXPLANATION = [ + `This replaces \`${TARGET_PATH}\` with the current \`automation-template.yml\` from`, + `[${ORG}/.github](https://github.com/${ORG}/.github).`, + '', + 'The file is generated. Do not edit this copy: edit `automation-registry.yml` upstream', + 'and regenerate, or the next sync will overwrite the change.', + '', + 'Opened automatically. A core maintainer reviews and merges it.', +].join('\n'); + +const SUMMARY = 'Internal: refresh the copied automation.yml so it matches the current shared template'; +const FIELD = /^([ \t]*-[ \t]*\*\*([^*]+?):\*\*).*$/gm; + +// Splits on level-two headings only, so a deeper heading stays inside its section. +function sections(markdown) { + const out = []; + for (const line of markdown.split('\n')) { + if (/^## [^#]/.test(line)) out.push({ heading: line.trim(), lines: [] }); + else if (out.length) out[out.length - 1].lines.push(line); + } + return out; +} + +function prBody(prTemplate, { keep, describe, answers }) { + if (!prTemplate) return `${EXPLANATION}\n`; + const kept = sections(prTemplate).filter((s) => keep.includes(s.heading)); + const parts = kept.map((s) => { + if (s.heading === describe) return `${s.heading}\n\n${EXPLANATION}`; + const filled = s.lines.join('\n').replace(FIELD, (line, prefix, field) => { + const answer = answers[field.trim().toLowerCase()]; + return `${prefix} ${answer === undefined ? '-' : answer}`; + }); + return `${s.heading}\n${filled.trimEnd()}`; + }); + // A renamed heading would otherwise leave the pull request unexplained, or empty. + if (!kept.some((s) => s.heading === describe)) parts.unshift(EXPLANATION); + return `${parts.join('\n\n').trimEnd()}\n`; +} + +function detail(r) { + return `${r.status} ${(r.data && r.data.message) || ''}`.trim(); +} + +// kolibri-design-system's check-description job needs a Changelog section whose +// Description is not the placeholder its template ships with. +// Its template opens with "Please remove any unused sections", so only the two +// that carry content are kept. +const KDS_REPO = 'kolibri-design-system'; +const KDS_TEMPLATE = { + keep: ['## Description', '## Changelog'], + describe: '## Description', + answers: { + description: SUMMARY, + 'products impact': 'none', + addresses: '-', + components: '-', + breaking: '-', + 'impacts a11y': '-', + guidance: '-', + }, +}; + +const PR_TEMPLATE_PATHS = ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md']; + +async function readPrTemplate(api, repo, ref) { + if (repo !== KDS_REPO) return null; + for (const p of PR_TEMPLATE_PATHS) { + const r = await api('GET', `/repos/${ORG}/${repo}/contents/${p}?ref=${ref}`); + if (r.ok) return Buffer.from(r.data.content, 'base64').toString('utf8'); + } + return null; +} + +async function readCopy(api, repo, ref) { + const r = await api('GET', `/repos/${ORG}/${repo}/contents/${TARGET_PATH}?ref=${ref}`); + if (r.ok) return { sha: r.data.sha, content: Buffer.from(r.data.content, 'base64').toString('utf8') }; + if (r.status === 404) return { missing: true }; + return { error: `read failed (${detail(r)})` }; +} + +/** + * Finds every repo in the org holding a copy of the template. Archived repos are + * skipped because Actions do not run on them, and forks because their copy + * belongs to the upstream repo. + */ +async function findConsumers(api) { + const repos = []; + for (let page = 1; ; page += 1) { + const r = await api('GET', `/orgs/${ORG}/repos?per_page=100&type=all&page=${page}`); + if (!r.ok) throw new Error(`could not list the org's repos (${detail(r)})`); + repos.push(...r.data); + if (r.data.length < 100) break; + } + + const consumers = []; + for (const repo of repos) { + if (repo.archived || repo.fork) continue; + let copy; + try { + copy = await readCopy(api, repo.name, repo.default_branch); + } catch (err) { + copy = { error: err.message }; + } + if (copy.missing) continue; + if (copy.error) { + consumers.push({ repo: repo.name, base: repo.default_branch, unreadable: copy.error }); + continue; + } + // This repo's own reusable automation.yml sits at the same path and does not + // call the shared workflow, so this excludes it without a special case. + if (!copy.content.includes(CONSUMER_MARKER)) continue; + consumers.push({ repo: repo.name, base: repo.default_branch }); + } + return consumers; +} + +async function openSyncPr(api, repo) { + const r = await api('GET', `/repos/${ORG}/${repo}/pulls?state=open&head=${ORG}:${BRANCH}`); + if (!r.ok) return { error: `could not list pull requests (${detail(r)})` }; + return { pr: r.data.length ? r.data[0] : null }; +} + +async function lastClosedSyncPr(api, repo) { + const r = await api( + 'GET', + `/repos/${ORG}/${repo}/pulls?state=closed&head=${ORG}:${BRANCH}&sort=updated&direction=desc&per_page=1` + ); + if (!r.ok) return { error: `could not list closed pull requests (${detail(r)})` }; + return { pr: r.data.length ? r.data[0] : null }; +} + +/** + * Decides what a drifted copy means, given what the last closed sync pull + * request left behind. `proposed` is the file as that pull request had it. + * + * Matching the template means nothing new has been published since, so the + * difference came from the consumer: a merge they then reverted, or a pull + * request a maintainer declined. Otherwise the template has moved on. + * + * Content rather than dates, because a commit carries the date it was written + * rather than the date it reached main, and a branch can be merged long after. + */ +function classifyDrift(closedPr, proposed, template) { + if (!closedPr || proposed !== template) return 'drift'; + return closedPr.merged_at ? 'toolchain-conflict' : 'declined'; +} + +// The reference the pull request left behind: its merge commit once merged, +// otherwise its head, which stays readable after the branch is reset. +function closedPrRef(closedPr) { + return closedPr.merged_at ? closedPr.merge_commit_sha : closedPr.head && closedPr.head.sha; +} + +async function syncRepo(api, consumer, template, { dryRun }) { + const { repo, base } = consumer; + if (consumer.unreadable) return { repo, state: 'error', detail: consumer.unreadable }; + + const copy = await readCopy(api, repo, base); + if (copy.error) return { repo, state: 'error', detail: copy.error }; + if (copy.missing) return { repo, state: 'error', detail: 'the copy disappeared during the run' }; + if (copy.content === template) return { repo, state: 'in-sync' }; + + const open = await openSyncPr(api, repo); + if (open.error) return { repo, state: 'error', detail: open.error }; + + if (!open.pr) { + const closed = await lastClosedSyncPr(api, repo); + if (closed.error) return { repo, state: 'error', detail: closed.error }; + if (closed.pr) { + const ref = closedPrRef(closed.pr); + const proposed = ref ? await readCopy(api, repo, ref) : { error: 'no reference on the closed pull request' }; + if (proposed.error) return { repo, state: 'error', detail: proposed.error }; + const verdict = classifyDrift(closed.pr, proposed.content, template); + if (verdict !== 'drift') return { repo, state: verdict, pr: closed.pr.number }; + } + } + + if (dryRun) { + return { repo, state: open.pr ? 'would-update' : 'would-open', pr: open.pr && open.pr.number }; + } + + const baseRef = await api('GET', `/repos/${ORG}/${repo}/git/ref/heads/${base}`); + if (!baseRef.ok) return { repo, state: 'error', detail: `base ${base} not found (${detail(baseRef)})` }; + const baseSha = baseRef.data.object.sha; + + if (!open.pr) { + const made = await api('POST', `/repos/${ORG}/${repo}/git/refs`, { ref: `refs/heads/${BRANCH}`, sha: baseSha }); + if (!made.ok && made.status === 422) { + // The branch outlives a closed pull request, so start it again from base + // rather than carrying commits a maintainer already saw. + const reset = await api('PATCH', `/repos/${ORG}/${repo}/git/refs/heads/${BRANCH}`, { sha: baseSha, force: true }); + if (!reset.ok) return { repo, state: 'error', detail: `branch reset failed (${detail(reset)})` }; + } else if (!made.ok) { + return { repo, state: 'error', detail: `branch failed (${detail(made)})` }; + } + } + + const onBranch = await readCopy(api, repo, BRANCH); + if (onBranch.error) return { repo, state: 'error', detail: onBranch.error }; + + const put = await api('PUT', `/repos/${ORG}/${repo}/contents/${TARGET_PATH}`, { + message: TITLE, + content: Buffer.from(template, 'utf8').toString('base64'), + branch: BRANCH, + ...(onBranch.sha ? { sha: onBranch.sha } : {}), + }); + if (!put.ok) return { repo, state: 'error', detail: `write failed (${detail(put)})` }; + + if (open.pr) return { repo, state: 'updated', pr: open.pr.number, url: open.pr.html_url }; + + const pr = await api('POST', `/repos/${ORG}/${repo}/pulls`, { + title: TITLE, + head: BRANCH, + base, + body: prBody(await readPrTemplate(api, repo, base), KDS_TEMPLATE), + }); + if (!pr.ok) return { repo, state: 'error', detail: `pull request failed (${detail(pr)})` }; + return { repo, state: 'opened', pr: pr.data.number, url: pr.data.html_url }; +} + +async function run(api, template, options) { + const consumers = await findConsumers(api); + + const results = []; + for (const consumer of consumers) { + try { + results.push(await syncRepo(api, consumer, template, options)); + } catch (err) { + results.push({ repo: consumer.repo, state: 'error', detail: err.message }); + } + } + return results; +} + +function report(results) { + for (const r of results) { + const extra = r.url || r.detail || (r.pr ? `#${r.pr}` : ''); + console.log(`${r.repo.padEnd(26)} ${r.state.padEnd(20)} ${extra}`); + } + const problems = results.filter((r) => PROBLEM_STATES.includes(r.state)); + const drifted = results.filter((r) => r.state !== 'in-sync'); + console.log(`\n${results.length} consumers, ${drifted.length} not in sync, ${problems.length} needing attention.`); + for (const p of problems) { + console.log(`::error title=${p.repo}::${p.state}${p.detail ? `: ${p.detail}` : ''}`); + } + return problems.length; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + if (!token) { + console.error('GITHUB_TOKEN is not set.'); + process.exit(1); + } + const template = fs.readFileSync(TEMPLATE_PATH, 'utf8'); + const results = await run(httpApi(token), template, { dryRun: process.argv.includes('--dry-run') }); + process.exit(report(results) ? 1 : 0); +} + +if (require.main === module) main(); + +module.exports = { run, syncRepo, classifyDrift, findConsumers, report, PROBLEM_STATES, BRANCH, TARGET_PATH }; diff --git a/scripts/sync-automation-template.test.js b/scripts/sync-automation-template.test.js new file mode 100644 index 0000000..0d99dc4 --- /dev/null +++ b/scripts/sync-automation-template.test.js @@ -0,0 +1,388 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { run, classifyDrift, findConsumers, report, PROBLEM_STATES, BRANCH } = require('./sync-automation-template'); + +const USES = 'jobs:\n automation:\n uses: learningequality/.github/.github/workflows/automation.yml@main\n'; +const TEMPLATE = `name: Automation\non: {}\n${USES}`; +const STALE = `name: Automation\non: {old: true}\n${USES}`; +// Shaped like kolibri-design-system's, including the placeholder its own +// check-description job rejects. +const PR_TEMPLATE = [ + '', + '', + '## Description', + '', + '', + '', + '#### Issue addressed', + '', + 'Addresses #*PR# HERE*', + '', + '## Changelog', + '', + ' - **Description:** Summary of change(s)', + ' - **Products impact:** Choose from - none / bugfix / new API', + ' - **Breaking:** Choose from: yes / no', + '', + '## Steps to test', + '', + '1. Step 1', + '2. Step 2', + '', +].join('\n'); +// Not "main": a default branch that differs is the only way to catch a hardcoded base. +const BASE = 'develop'; +const encode = (s) => Buffer.from(s, 'utf8').toString('base64'); +const ok = (data) => ({ ok: true, status: 200, data }); +const fail = (status, message) => ({ ok: false, status, data: { message } }); +const repo = (over = {}) => ({ name: 'demo', default_branch: BASE, archived: false, fork: false, ...over }); + +/** + * Routes calls by method and a fragment of the path. Later entries win, so a + * test can override one route and inherit the rest. + */ +function makeApi(routes, calls = []) { + const ordered = [...routes].reverse(); + return async function api(method, url, body) { + calls.push({ method, url, body }); + const route = ordered.find(([match]) => { + const [m, fragment] = match.split(' '); + if (m !== method) return false; + // A leading "=" matches the whole path, so a repo route cannot also swallow + // the longer URLs underneath it. + return fragment.startsWith('=') ? url === fragment.slice(1) : url.includes(fragment); + }); + if (!route) return fail(404, 'unrouted'); + return typeof route[1] === 'function' ? route[1](url, body) : route[1]; + }; +} + +const baseRoutes = (copy, repos = [repo()]) => [ + ['GET /repos/learningequality/.github/commits', ok([{ commit: { committer: { date: '2026-01-02T00:00:00Z' } } }])], + ['GET /orgs/learningequality/repos', ok(repos)], + ['GET contents/.github/workflows/automation.yml', ok({ sha: 'file-sha', content: encode(copy) })], + ['GET /repos/learningequality/demo/pulls?state=open', ok([])], + ['GET /repos/learningequality/demo/pulls?state=closed', ok([])], + [`GET /repos/learningequality/demo/git/ref/heads/${BASE}`, ok({ object: { sha: 'base-sha' } })], + ['POST /repos/learningequality/demo/git/refs', ok({})], + ['PUT /repos/learningequality/demo/contents', ok({})], + ['POST /repos/learningequality/demo/pulls', ok({ number: 7, html_url: 'https://example.test/7' })], +]; + +const only = async (routes, options = {}) => (await run(makeApi(routes), TEMPLATE, options))[0]; + +test('a matching copy is in sync', async () => { + assert.equal((await only(baseRoutes(TEMPLATE))).state, 'in-sync'); +}); + +test('a drifted copy opens a pull request', async () => { + const result = await only(baseRoutes(STALE)); + assert.equal(result.state, 'opened'); + assert.equal(result.pr, 7); +}); + +test('the write targets the sync branch and carries the template', async () => { + const calls = []; + await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, {}); + const put = calls.find((c) => c.method === 'PUT'); + assert.equal(put.body.branch, BRANCH, 'must never write to a default branch'); + assert.equal(Buffer.from(put.body.content, 'base64').toString('utf8'), TEMPLATE); +}); + +test('the pull request targets the discovered default branch', async () => { + const calls = []; + await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, {}); + const pr = calls.find((c) => c.method === 'POST' && c.url.endsWith('/pulls')); + assert.equal(pr.body.base, BASE); + assert.equal(pr.body.head, BRANCH); +}); + +// The repo whose check-description job forces the template path. +const CHECKED = 'kolibri-design-system'; + +const checkedRoutes = (copy) => [ + ...baseRoutes(copy, [repo({ name: CHECKED })]), + [`GET /repos/learningequality/${CHECKED}/pulls?state=open`, ok([])], + [`GET /repos/learningequality/${CHECKED}/pulls?state=closed`, ok([])], + [`GET /repos/learningequality/${CHECKED}/git/ref/heads/${BASE}`, ok({ object: { sha: 'base-sha' } })], + [`POST /repos/learningequality/${CHECKED}/git/refs`, ok({})], + [`PUT /repos/learningequality/${CHECKED}/contents`, ok({})], + [`POST /repos/learningequality/${CHECKED}/pulls`, ok({ number: 7, html_url: 'https://example.test/7' })], + ['GET contents/.github/pull_request_template.md', ok({ content: encode(PR_TEMPLATE) })], +]; + +const bodyOf = (calls) => calls.find((c) => c.method === 'POST' && c.url.endsWith('/pulls')).body.body; + +test('a repo that checks the description gets its own template, with every field answered', async () => { + const calls = []; + await run(makeApi(checkedRoutes(STALE), calls), TEMPLATE, {}); + const body = bodyOf(calls); + assert.ok(body.startsWith('## Description'), 'the repo template decides the shape'); + assert.ok(!body.includes('Summary of change(s)'), 'the placeholder fails check-description'); + assert.match(body, /- \*\*Description:\*\* Internal: refresh the copied automation\.yml/); + assert.ok(body.includes('- **Breaking:** -'), 'a field we have no answer for gets a dash'); + assert.ok(!body.includes('yes / no'), 'instructions for a human author do not survive'); + assert.ok(body.includes('automation-template.yml'), 'our explanation is still there'); +}); + +test('a renamed description heading still leaves the change explained', async () => { + const calls = []; + const renamed = PR_TEMPLATE.replace('## Description', '## Overview'); + const routes = [ + ...checkedRoutes(STALE), + ['GET contents/.github/pull_request_template.md', ok({ content: encode(renamed) })], + ]; + await run(makeApi(routes, calls), TEMPLATE, {}); + const body = bodyOf(calls); + assert.ok(body.startsWith('This replaces'), 'the explanation must not go missing'); + assert.ok(body.includes('## Changelog'), 'a section that survived is kept, so their check still passes'); +}); + +test('a template with none of the kept headings still explains itself', async () => { + const calls = []; + const routes = [ + ...checkedRoutes(STALE), + ['GET contents/.github/pull_request_template.md', ok({ content: encode('## Notes\n\nnothing here\n') })], + ]; + await run(makeApi(routes, calls), TEMPLATE, {}); + const body = bodyOf(calls); + assert.ok(body.includes('automation-template.yml'), 'the body must never be empty'); + assert.ok(body.trim().length, 'a bare newline is not a pull request body'); +}); + +test('sections that would arrive unfilled are dropped', async () => { + const calls = []; + await run(makeApi(checkedRoutes(STALE), calls), TEMPLATE, {}); + const body = bodyOf(calls); + assert.ok(!body.includes('## Steps to test'), 'their template asks for unused sections to go'); + assert.ok(!body.includes('1. Step 1')); + assert.ok(!body.includes('Addresses #')); + assert.deepEqual(body.match(/^## .*/gm), ['## Description', '## Changelog']); +}); + +test('any other repo gets the plain explanation, template or not', async () => { + const calls = []; + const routes = [ + ...baseRoutes(STALE), + ['GET contents/.github/pull_request_template.md', ok({ content: encode(PR_TEMPLATE) })], + ]; + await run(makeApi(routes, calls), TEMPLATE, {}); + const body = bodyOf(calls); + assert.ok(!body.includes('## Description'), 'a template is not fetched for a repo that does not need it'); + assert.ok(body.includes('automation-template.yml')); +}); + +test('a repo with no pull request template gets the plain explanation', async () => { + const calls = []; + await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, {}); + const { body } = calls.find((c) => c.method === 'POST' && c.url.endsWith('/pulls')).body; + assert.ok(!body.includes('## ')); + assert.ok(body.includes('automation-template.yml')); +}); + +test('an open sync pull request is updated in place, keeping the file sha', async () => { + const calls = []; + const routes = [ + ...baseRoutes(STALE), + ['GET /repos/learningequality/demo/pulls?state=open', ok([{ number: 3, html_url: 'https://example.test/3' }])], + ]; + const results = await run(makeApi(routes, calls), TEMPLATE, {}); + assert.equal(results[0].state, 'updated'); + assert.equal(calls.filter((c) => c.method === 'POST' && c.url.endsWith('/pulls')).length, 0); + const put = calls.find((c) => c.method === 'PUT'); + assert.equal(put.body.sha, 'file-sha', 'an update without the sha fails with a 422'); +}); + +test('discovery skips archived repos and forks', async () => { + const repos = [repo({ name: 'old', archived: true }), repo({ name: 'mirror', fork: true }), repo()]; + const results = await run(makeApi(baseRoutes(TEMPLATE, repos)), TEMPLATE, {}); + assert.deepEqual( + results.map((r) => r.repo), + ['demo'] + ); +}); + +test('a repo with no copy is not a consumer', async () => { + const routes = [...baseRoutes(TEMPLATE), ['GET contents/.github/workflows/automation.yml', fail(404, 'Not Found')]]; + const results = await run(makeApi(routes), TEMPLATE, {}); + assert.deepEqual(results, []); +}); + +test('a file that does not call the shared workflow is not a consumer', async () => { + const routes = [ + ...baseRoutes(TEMPLATE), + ['GET contents/.github/workflows/automation.yml', ok({ sha: 'x', content: encode('name: Something else\n') })], + ]; + const results = await run(makeApi(routes), TEMPLATE, {}); + assert.deepEqual(results, []); +}); + +test('a repo that cannot be read is reported, not skipped', async () => { + const routes = [...baseRoutes(TEMPLATE), ['GET contents/.github/workflows/automation.yml', fail(500, 'Server Error')]]; + const result = await only(routes); + assert.equal(result.state, 'error'); + assert.match(result.detail, /read failed/); +}); + +test('a failed repo listing stops the run loudly', async () => { + const routes = [...baseRoutes(TEMPLATE), ['GET /orgs/learningequality/repos', fail(403, 'Forbidden')]]; + await assert.rejects(() => run(makeApi(routes), TEMPLATE, {}), /could not list the org's repos/); +}); + +test('a failed pull request listing is an error, not a missing pull request', async () => { + const routes = [...baseRoutes(STALE), ['GET /repos/learningequality/demo/pulls?state=open', fail(403, 'Forbidden')]]; + const result = await only(routes); + assert.equal(result.state, 'error'); + assert.match(result.detail, /could not list/); +}); + +test('a thrown request during the sync is contained and reported per repo', async () => { + const routes = [ + ...baseRoutes(STALE), + [ + 'GET /repos/learningequality/demo/pulls?state=open', + () => { + throw new Error('socket hang up'); + }, + ], + ]; + const result = await only(routes); + assert.equal(result.state, 'error'); + assert.equal(result.detail, 'socket hang up'); +}); + +test('a thrown request during discovery is contained, not fatal', async () => { + const routes = [ + ...baseRoutes(STALE), + [ + 'GET contents/.github/workflows/automation.yml', + () => { + throw new Error('socket hang up'); + }, + ], + ]; + const result = await only(routes); + assert.equal(result.state, 'error', 'discovery runs before the per-repo try, so it needs its own'); + assert.equal(result.detail, 'socket hang up'); +}); + +test('dry run reports without writing', async () => { + const calls = []; + const results = await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, { dryRun: true }); + assert.equal(results[0].state, 'would-open'); + assert.equal( + calls.filter((c) => c.method !== 'GET').length, + 0 + ); +}); + +const MERGED = { merged_at: '2026-03-01T00:00:00Z', merge_commit_sha: 'm1' }; +const CLOSED = { merged_at: null, head: { sha: 'h1' } }; + +test('classifyDrift: no prior pull request is ordinary drift', () => { + assert.equal(classifyDrift(null, undefined, TEMPLATE), 'drift'); +}); + +test('classifyDrift: a template published since the merge is ordinary drift', () => { + assert.equal(classifyDrift(MERGED, STALE, TEMPLATE), 'drift'); +}); + +test('classifyDrift: drift with the template unchanged since the merge is a toolchain conflict', () => { + assert.equal(classifyDrift(MERGED, TEMPLATE, TEMPLATE), 'toolchain-conflict'); +}); + +test('classifyDrift: a pull request closed unmerged is declined until the template moves', () => { + assert.equal(classifyDrift(CLOSED, TEMPLATE, TEMPLATE), 'declined'); + assert.equal(classifyDrift(CLOSED, STALE, TEMPLATE), 'drift'); +}); + +const mergedSync = (sha) => [ + 'GET /repos/learningequality/demo/pulls?state=closed', + ok([{ number: 9, merged_at: '2026-03-01T00:00:00Z', merge_commit_sha: sha }]), +]; + +// The copy at a given ref, so a test can say what the closed pull request left. +const atRef = (sha, content) => [ + `GET contents/.github/workflows/automation.yml?ref=${sha}`, + ok({ sha: 'x', content: encode(content) }), +]; + +test('a repo that reverted a merged sync is reported, and no pull request is opened', async () => { + const calls = []; + const routes = [...baseRoutes(STALE), mergedSync('m1'), atRef('m1', TEMPLATE)]; + const results = await run(makeApi(routes, calls), TEMPLATE, {}); + assert.equal(results[0].state, 'toolchain-conflict', 'the merge left the template, so the consumer changed it'); + assert.equal(calls.filter((c) => c.method !== 'GET').length, 0); +}); + +test('a template published after the merge is drift, whatever the commit dates say', async () => { + const calls = []; + // The case dates get wrong: a template whose commits predate the sync merge, + // but which reached main after it, because the branch was merged later. + const routes = [...baseRoutes(STALE), mergedSync('m1'), atRef('m1', STALE)]; + const results = await run(makeApi(routes, calls), TEMPLATE, {}); + assert.equal(results[0].state, 'opened', 'the merge left something older, so the template moved on'); +}); + +test('a closed unmerged pull request is read at its head', async () => { + const calls = []; + const routes = [ + ...baseRoutes(STALE), + ['GET /repos/learningequality/demo/pulls?state=closed', ok([{ number: 9, merged_at: null, head: { sha: 'h1' } }])], + atRef('h1', TEMPLATE), + ]; + const results = await run(makeApi(routes, calls), TEMPLATE, {}); + assert.equal(results[0].state, 'declined'); + assert.ok( + calls.some((c) => c.url.includes('ref=h1')), + 'the head stays readable after the branch is reset' + ); +}); + +test('report counts only the states that stop a merge', () => { + const quiet = console.log; + console.log = () => {}; + try { + const problems = report([ + { repo: 'a', state: 'in-sync' }, + { repo: 'b', state: 'opened', pr: 1 }, + { repo: 'c', state: 'declined', pr: 2 }, + { repo: 'd', state: 'error', detail: 'no access' }, + { repo: 'e', state: 'toolchain-conflict', pr: 3 }, + ]); + assert.equal(problems, 2, 'error and toolchain-conflict each need attention'); + } finally { + console.log = quiet; + } +}); + +test('the set of problem states is closed', () => { + assert.deepEqual( + [...PROBLEM_STATES].sort(), + ['error', 'toolchain-conflict'], + 'adding a state here turns the weekly run red for repos that were passing, so change it deliberately and cover it in the report test above' + ); +}); + +test('a stale branch is reset to base when no pull request is open', async () => { + const calls = []; + const routes = [...baseRoutes(STALE), ['POST /repos/learningequality/demo/git/refs', fail(422, 'Reference exists')]]; + await run(makeApi(routes, calls), TEMPLATE, {}); + const reset = calls.find((c) => c.method === 'PATCH' && c.url.endsWith(`/git/refs/heads/${BRANCH}`)); + assert.ok(reset, 'expected the branch to be reset'); + assert.equal(reset.body.sha, 'base-sha'); + assert.equal(reset.body.force, true); +}); + +test('findConsumers pages through the org listing', async () => { + const calls = []; + const many = Array.from({ length: 100 }, (_, i) => repo({ name: `r${i}` })); + const routes = [ + ...baseRoutes(TEMPLATE, many), + ['GET /orgs/learningequality/repos', (url) => ok(url.includes('page=2') ? [repo()] : many)], + ]; + const consumers = await findConsumers(makeApi(routes, calls)); + assert.equal(consumers.length, 101); + assert.ok(calls.some((c) => c.url.includes('page=2'))); +});