From a06244e5ae629ab09b74bf45cbbae23daf484a09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:09:46 +0000 Subject: [PATCH 1/3] feat: add merge-report command for cross-branch fix propagation Detects commits on a source branch (e.g. support/1.x) whose patch content has no equivalent on one or more target branches (develop, or a peer support/* branch) -- catching the case where a bugfix lands on a maintenance branch but never reaches develop, which becomes a regression for a customer upgrading past that branch. Uses git cherry -v (patch-id comparison) rather than ancestry diffing, so a cherry-picked/re-landed commit under a new sha is correctly recognized as already propagated. Filters candidates against the same exclude-labels config and cached, event-sourced Driver entries the changelog command already reads (dependencies, wontfix, etc. -- no second API surface), plus a checked-in, sha-keyed .gitflow-changelog-merge-ignore.yml for residual human-judgment cases. Reports everything, unfiltered, sorted oldest-first; fail-on-outstanding-after-days (default 14) governs pass/fail without touching what's shown. Ships as a sibling action (merge-report/action.yml) and a `merge-report` CLI subcommand, sharing git.ts/cache/Driver infrastructure with the existing changelog generator. Closes #38. --- .github/workflows/release.yml | 8 +- .gitignore | 8 +- README.md | 101 +++++++++++++++++-- merge-report/action.yml | 91 +++++++++++++++++ package.json | 3 +- src/cli.ts | 10 ++ src/drivers/github.ts | 20 ++++ src/git.ts | 30 ++++++ src/merge-ignore.ts | 36 +++++++ src/merge-report-action.ts | 49 ++++++++++ src/merge-report-cli.ts | 42 ++++++++ src/merge-report-config.ts | 67 +++++++++++++ src/merge-report.ts | 65 +++++++++++++ src/render/merge-report.ts | 40 ++++++++ test/git-cherry.test.ts | 70 +++++++++++++ test/github-driver.test.ts | 47 ++++++++- test/merge-ignore.test.ts | 43 ++++++++ test/merge-report.test.ts | 178 ++++++++++++++++++++++++++++++++++ 18 files changed, 893 insertions(+), 15 deletions(-) create mode 100644 merge-report/action.yml create mode 100644 src/merge-ignore.ts create mode 100644 src/merge-report-action.ts create mode 100644 src/merge-report-cli.ts create mode 100644 src/merge-report-config.ts create mode 100644 src/merge-report.ts create mode 100644 src/render/merge-report.ts create mode 100644 test/git-cherry.test.ts create mode 100644 test/merge-ignore.test.ts create mode 100644 test/merge-report.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a45c99f..cadaf5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,11 +48,13 @@ jobs: VERSION="${{ inputs.version }}" MAJOR="v$(echo "$VERSION" | cut -d. -f1)" - # dist/ is gitignored on the branch itself — force-add it into this - # one release commit, which is reachable only via the tags below, - # never pushed onto the branch. develop/main stay dist-free forever. + # dist/ and merge-report/index.js are gitignored on the branch + # itself — force-add them into this one release commit, which is + # reachable only via the tags below, never pushed onto the branch. + # develop/main stay build-artifact-free forever. git add package.json package-lock.json git add -f dist + git add -f merge-report/index.js git commit -m "chore(release): v${VERSION}" git tag -a "v${VERSION}" -m "v${VERSION}" git tag -f "$MAJOR" HEAD diff --git a/.gitignore b/.gitignore index 3b244a0..e1bd7e7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ build/ .DS_Store coverage/ -# dist/index.js and dist/cli.js are built and committed only by the Release -# workflow, into a release-only commit that a version tag points at — never -# on the develop/main branch itself. Do not build-and-commit these locally. +# dist/index.js, dist/cli.js, and merge-report/index.js are built and +# committed only by the Release workflow, into a release-only commit that a +# version tag points at — never on the develop/main branch itself. Do not +# build-and-commit these locally. dist/ +merge-report/index.js diff --git a/README.md b/README.md index 04483da..fe791ec 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ This design keeps the door open for other platforms (GitLab, etc.) later — they'd only need to implement the driver contract; placement and rendering are already platform-agnostic. +[`merge-report`](#merge-report-did-a-fix-propagate-everywhere-it-needs-to) +is a second consumer of the Driver's cached, event-sourced entries — it +skips Placement and Renderer entirely (it isn't about which release +something shipped in) and instead cross-references entry labels against +`git cherry` candidates. + ## Usage ### As a GitHub Action @@ -198,6 +204,86 @@ Resolution order, highest precedence first: tier: the entry is dropped and a warning names the PR/issue and the unresolvable SHA, so a human can add an explicit override. +## merge-report: did a fix propagate everywhere it needs to? + +`gitflow-changelog` answers "which release did this fix ship in." A related, +separate question: **did a fix that landed on one branch actually make it to +every other branch that needs it?** A bug fix on `support/1.x` that never +reaches `develop` becomes a regression for a customer upgrading past +`1.x` — they had the fix, then lost it. The same risk exists between peer +maintenance branches (`support/1.x` and `support/2.x` both need a fix that +only landed on one of them). This isn't a "backport" (mainline → older +branch) — it's the reverse, or a sideways case between peers — hence +**merge-report**: did this change actually merge across the branches that +needed it, regardless of direction. + +### As a GitHub Action + +```yaml +- uses: actions/checkout@v4 + with: + fetch-depth: 0 # required — patch-content comparison needs full history + +- uses: actions/cache@v4 + with: + path: .gitflow-changelog-cache.json + key: gitflow-changelog-v1-${{ github.repository }} # same cache the changelog action uses + +- uses: aklivity/gitflow-changelog/merge-report@v1 + with: + source: support/1.x + targets: develop +``` + +Run this on a schedule (weekly, say) plus `workflow_dispatch`, not on every +push or PR merge to `source` — at the exact moment a fix lands on `source` +it is *definitionally* not yet on `targets`, so a merge-triggered run would +only ever report a guaranteed, contentless "not yet." The scheduled sweep is +where real signal — something that's been outstanding for a while — shows +up. See [`merge-report/action.yml`](./merge-report/action.yml) for the full +list of inputs, including `fail-on-outstanding-after-days` (default 14): +the report itself always lists everything, unfiltered, oldest-first; this +input only controls whether the run exits non-zero, which is what actually +surfaces the finding to a human via GitHub's default scheduled-workflow- +failure notification — a job summary alone isn't pushed to anyone. + +### As a CLI + +```bash +npx gitflow-changelog merge-report --owner aklivity --repo zilla-plus --token "$GITHUB_TOKEN" \ + --source support/1.x --targets develop +``` + +### How it detects a gap + +Comparing branches by ancestry (`git log target..source`) doesn't work: it +flags every cherry-picked or independently re-landed commit as "missing" +purely because its SHA differs, which is the normal shape of a real +forward-port. `merge-report` uses `git cherry -v target source` instead — +patch-id comparison — so a commit re-applied under a new SHA on `source` is +correctly recognized as already present on `target`. + +What's left after that still needs two more filters: + +- **`exclude-labels`** — the same list read from `.gitflow-changelog.yml` + for changelog categorization. A candidate commit whose originating PR/issue + carries one of these labels (e.g. `dependencies`) is dropped the same way + it's excluded from the changelog — no second API sweep, just a second + consumer of the event-sourced label state the Driver already fetches and + caches. +- **`.gitflow-changelog-merge-ignore.yml`** — a checked-in, sha-keyed list + for the residual cases the label sweep can't resolve, each with a required + human-written reason: + + ```yaml + merge-ignore: + a18bdc1c3db328f6f66f53ac84e1eec4f360ce38: "branch-scoped SNAPSHOT reset, not applicable to develop" + ``` + + Keyed by sha rather than a commit-message convention (`build(deps):`, + "backport of #NNNN") — message conventions aren't consistent enough to + filter on reliably; a human's explicit, reviewed judgment is. + ## Known limitations - A label applied without generating a discrete GitHub event (rare, e.g. @@ -219,15 +305,16 @@ Resolution order, highest precedence first: npm install npm run typecheck npm test -npm run build # bundles src/cli.ts -> dist/cli.js and src/action.ts -> dist/index.js +npm run build # bundles src/cli.ts -> dist/cli.js, src/action.ts -> dist/index.js, + # and src/merge-report-action.ts -> merge-report/index.js ``` -`dist/` is gitignored and never committed on `develop`/`main` — GitHub -Actions does not install dependencies for JavaScript actions at run time, so -a real, working action still needs `dist/index.js` to exist somewhere, but -that somewhere is a release tag, not the development branch (see -"Releasing" below). Don't build-and-commit `dist/` locally; `npm run build` -is for local verification only. +`dist/` and `merge-report/index.js` are gitignored and never committed on +`develop`/`main` — GitHub Actions does not install dependencies for +JavaScript actions at run time, so a real, working action still needs its +compiled entrypoint to exist somewhere, but that somewhere is a release tag, +not the development branch (see "Releasing" below). Don't build-and-commit +these locally; `npm run build` is for local verification only. ## Releasing diff --git a/merge-report/action.yml b/merge-report/action.yml new file mode 100644 index 0000000..9b7e395 --- /dev/null +++ b/merge-report/action.yml @@ -0,0 +1,91 @@ +name: gitflow-changelog merge-report +description: >- + Reports gitflow branch pairs where a fix landed on one branch but its + content isn't present on another that should have it — by comparing + actual patch content, not merge-commit ancestry. +author: Aklivity +branding: + icon: git-merge + color: purple + +inputs: + owner: + description: Repository owner. + required: false + default: ${{ github.repository_owner }} + repo: + description: Repository name. + required: false + default: ${{ github.event.repository.name }} + token: + description: GitHub token used to read issues, pull requests, and their events. + required: false + default: ${{ github.token }} + source: + description: >- + Branch whose commits are checked for propagation, e.g. support/1.x. + required: true + targets: + description: >- + Comma-separated branches that should have every commit from `source`, + e.g. develop, or develop,support/2.x. Not assumed to be upstream of + `source` — a peer maintenance branch is a valid target too. + required: true + git-dir: + description: >- + Path to a full clone of the repository (requires fetch-depth 0 — + patch-content comparison needs full history). Defaults to the current + working directory. + required: false + cache-path: + description: >- + Path to the incremental events cache file. Shared with the + gitflow-changelog action — pair with actions/cache using the same + stable key so a warm changelog cache means this action makes no new + API calls of its own. + required: false + default: .gitflow-changelog-cache.json + merge-ignore-path: + description: >- + Path to a YAML file (in the consuming repo) listing commit shas that + are known not to need propagating, keyed by sha with a required + human-written reason. Auto-loaded from this default path if present — + no workflow changes needed to start using it. + required: false + default: .gitflow-changelog-merge-ignore.yml + config-path: + description: >- + Path to the same .gitflow-changelog.yml the changelog command reads — + only exclude-labels is consulted here, so a PR/issue labeled e.g. + "dependencies" is skipped the same way it's excluded from the + changelog, without a second label vocabulary to keep in sync. + required: false + default: .gitflow-changelog.yml + exclude-labels: + description: >- + Comma-separated labels whose PR/issue, if matched to a candidate + commit, drops it from the report. Overrides exclude-labels in + config-path; defaults to "duplicate,invalid,wontfix" if set in + neither place. + required: false + output-path: + description: Path to write the generated report to. + required: false + default: merge-report.md + fail-on-outstanding-after-days: + description: >- + Fail the run if any entry in the report has been outstanding longer + than this many days. The report itself always lists everything, + unfiltered, sorted oldest-first — this only controls whether the run + exits non-zero (and therefore triggers GitHub's default scheduled- + workflow-failure notification). + required: false + default: '14' + +outputs: + merge-report-path: + description: Path to the generated merge report. + +runs: + using: node20 + main: index.js diff --git a/package.json b/package.json index a833a5d..861c549 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "gitflow-changelog": "./dist/cli.js" }, "scripts": { - "build": "npm run build:cli && npm run build:action", + "build": "npm run build:cli && npm run build:action && npm run build:merge-report-action", "build:cli": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\" --outfile=dist/cli.js", "build:action": "esbuild src/action.ts --bundle --platform=node --target=node20 --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\" --outfile=dist/index.js", + "build:merge-report-action": "esbuild src/merge-report-action.ts --bundle --platform=node --target=node20 --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\" --outfile=merge-report/index.js", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", diff --git a/src/cli.ts b/src/cli.ts index 9cb7220..080f2dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,9 +2,19 @@ import { parseArgs } from 'node:util'; import { writeFile } from 'node:fs/promises'; import { toRunOptions } from './config.js'; +import { runMergeReportCli } from './merge-report-cli.js'; import { run } from './run.js'; async function main(): Promise { + // `merge-report` is a sibling subcommand, not a flag — anything else + // (including no positional arg at all) keeps today's flat-flags + // changelog behavior unchanged, so existing callers see no difference. + if (process.argv[2] === 'merge-report') + { + await runMergeReportCli(process.argv.slice(3)); + return; + } + const { values } = parseArgs({ options: { owner: { type: 'string' }, diff --git a/src/drivers/github.ts b/src/drivers/github.ts index 1a9fd3f..385698e 100644 --- a/src/drivers/github.ts +++ b/src/drivers/github.ts @@ -203,6 +203,26 @@ function categorize(labels: string[], options: DriverOptions): Category { return 'issue'; } +// Same event-sourced label state entriesFromCache reads, but answering a +// different question: not "how should this entry be categorized" (which +// only ever runs against this repo's own enhancement/bug/exclude labels), +// but "which commits, wherever they came from, are labeled in a way that +// says they don't need to go anywhere else" — merge-report's use case, +// where the label check has to run before any category is assigned and +// doesn't care about enhancement vs. bug. Keyed by sha, not issue/PR +// number, since that's what a `git cherry` candidate is identified by. +export function excludedShas(cache: CacheFile, excludeLabels: string[]): Set { + const shas = new Set(); + for (const entry of Object.values(cache.entries)) + { + if (entry.sha && entry.labels.some((label) => excludeLabels.includes(label))) + { + shas.add(entry.sha); + } + } + return shas; +} + export function entriesFromCache(cache: CacheFile, options: DriverOptions): Entry[] { const entries: Entry[] = []; for (const [number, cacheEntry] of Object.entries(cache.entries)) diff --git a/src/git.ts b/src/git.ts index b008644..b599637 100644 --- a/src/git.ts +++ b/src/git.ts @@ -224,3 +224,33 @@ export async function filesChangedInCommit(sha: string, options: GitOptions): Pr .map((line) => line.trim()) .filter((line) => line.length > 0); } + +export interface CherryCommit { + sha: string; + subject: string; +} + +// Parses `git cherry -v ` and returns only the commits on +// `source` marked '+' — patch content with no equivalent anywhere in +// `target`'s history. Deliberately drops the '-' side (a patch-id match): +// a commit re-applied under a new SHA on `source` — the normal shape of a +// cherry-pick or an independently re-landed fix — is already present in +// `target` in every way that matters here, even though a plain ancestry +// diff (`git log target..source`) would still flag it as missing. +export async function unmatchedCommits(target: string, source: string, options: GitOptions): Promise { + const stdout = await run(['cherry', '-v', target, source], options); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('+ ')) + .map((line) => { + const withoutMarker = line.slice(2); + const spaceIndex = withoutMarker.indexOf(' '); + return { sha: withoutMarker.slice(0, spaceIndex), subject: withoutMarker.slice(spaceIndex + 1) }; + }); +} + +export async function commitDate(sha: string, options: GitOptions): Promise { + const stdout = await run(['show', '-s', '--format=%cI', sha], options); + return stdout.trim(); +} diff --git a/src/merge-ignore.ts b/src/merge-ignore.ts new file mode 100644 index 0000000..be2b73c --- /dev/null +++ b/src/merge-ignore.ts @@ -0,0 +1,36 @@ +import { readFile } from 'node:fs/promises'; +import { parse } from 'yaml'; +import { z } from 'zod'; + +const MergeIgnoreSchema = z.object({ + 'merge-ignore': z.record(z.string(), z.string()).default({}), +}); + +// Keyed by the broken/unmatched commit sha itself, value is a required +// human-written reason — a commit-message convention (`build(deps):`, +// "backport of #NNNN") isn't a reliable enough signal to filter on: some +// genuine backports carry no marker at all, and message conventions drift +// over time in ways a checked-in, reviewed file doesn't. +export type MergeIgnore = Map; + +export const EMPTY_MERGE_IGNORE: MergeIgnore = new Map(); + +export async function loadMergeIgnore(path: string | undefined): Promise { + if (!path) + { + return EMPTY_MERGE_IGNORE; + } + + let raw: string; + try + { + raw = await readFile(path, 'utf8'); + } + catch + { + return EMPTY_MERGE_IGNORE; + } + + const parsed = MergeIgnoreSchema.parse(parse(raw) ?? {}); + return new Map(Object.entries(parsed['merge-ignore'])); +} diff --git a/src/merge-report-action.ts b/src/merge-report-action.ts new file mode 100644 index 0000000..b854f36 --- /dev/null +++ b/src/merge-report-action.ts @@ -0,0 +1,49 @@ +import { writeFile } from 'node:fs/promises'; +import * as core from '@actions/core'; +import { toMergeReportOptions } from './merge-report-config.js'; +import { mergeReport } from './merge-report.js'; +import { renderMergeReport } from './render/merge-report.js'; + +async function main(): Promise { + const options = await toMergeReportOptions({ + owner: core.getInput('owner', { required: true }), + repo: core.getInput('repo', { required: true }), + token: core.getInput('token', { required: true }), + source: core.getInput('source', { required: true }), + targets: core.getInput('targets', { required: true }), + gitDir: core.getInput('git-dir') || undefined, + cachePath: core.getInput('cache-path') || undefined, + mergeIgnorePath: core.getInput('merge-ignore-path') || undefined, + configPath: core.getInput('config-path') || undefined, + excludeLabels: core.getInput('exclude-labels') || undefined, + }); + + const outputPath = core.getInput('output-path') || 'merge-report.md'; + const failAfterDays = Number(core.getInput('fail-on-outstanding-after-days') || '14'); + + const result = await mergeReport(options); + const markdown = renderMergeReport(result, { owner: options.owner, repo: options.repo, source: options.source }); + + await writeFile(outputPath, markdown, 'utf8'); + core.setOutput('merge-report-path', outputPath); + + // core.summary is the only channel here that's actually visible without a + // human going looking for it — a job summary alone still isn't pushed to + // anyone, but the run's own pass/fail *is* something GitHub notifies on + // by default for a scheduled workflow, so failing past the threshold is + // what turns "the report exists" into "someone finds out." + await core.summary.addHeading(`merge-report: ${options.source}`).addRaw(markdown).write(); + + const stale = result.outstanding.filter((entry) => entry.ageDays > failAfterDays); + if (stale.length > 0) + { + core.setFailed( + `${stale.length} commit(s) on "${options.source}" have been outstanding for more than ${failAfterDays} days ` + + `without an equivalent on their target branch — see the job summary for the full list.`, + ); + } +} + +main().catch((error: unknown) => { + core.setFailed(error instanceof Error ? error.message : String(error)); +}); diff --git a/src/merge-report-cli.ts b/src/merge-report-cli.ts new file mode 100644 index 0000000..9d4bdda --- /dev/null +++ b/src/merge-report-cli.ts @@ -0,0 +1,42 @@ +import { parseArgs } from 'node:util'; +import { writeFile } from 'node:fs/promises'; +import { toMergeReportOptions } from './merge-report-config.js'; +import { mergeReport } from './merge-report.js'; +import { renderMergeReport } from './render/merge-report.js'; + +export async function runMergeReportCli(argv: string[]): Promise { + const { values } = parseArgs({ + args: argv, + options: { + owner: { type: 'string' }, + repo: { type: 'string' }, + token: { type: 'string' }, + source: { type: 'string' }, + targets: { type: 'string' }, + 'git-dir': { type: 'string' }, + 'cache-path': { type: 'string' }, + 'merge-ignore-path': { type: 'string' }, + 'config-path': { type: 'string' }, + 'exclude-labels': { type: 'string' }, + output: { type: 'string', default: 'merge-report.md' }, + }, + }); + + const options = await toMergeReportOptions({ + owner: values.owner, + repo: values.repo, + token: values.token ?? process.env.GITHUB_TOKEN, + source: values.source, + targets: values.targets, + gitDir: values['git-dir'], + cachePath: values['cache-path'], + mergeIgnorePath: values['merge-ignore-path'], + configPath: values['config-path'], + excludeLabels: values['exclude-labels'], + }); + + const result = await mergeReport(options); + const markdown = renderMergeReport(result, { owner: options.owner, repo: options.repo, source: options.source }); + + await writeFile(values.output as string, markdown, 'utf8'); +} diff --git a/src/merge-report-config.ts b/src/merge-report-config.ts new file mode 100644 index 0000000..3b6cb30 --- /dev/null +++ b/src/merge-report-config.ts @@ -0,0 +1,67 @@ +import { loadRepoConfig } from './repo-config.js'; +import type { MergeReportOptions } from './merge-report.js'; + +export interface RawMergeReportInputs { + owner?: string; + repo?: string; + token?: string; + source?: string; + targets?: string; + gitDir?: string; + cachePath?: string; + mergeIgnorePath?: string; + configPath?: string; + excludeLabels?: string; +} + +function splitList(value: string | undefined): string[] { + if (!value) + { + return []; + } + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export async function toMergeReportOptions(raw: RawMergeReportInputs): Promise { + if (!raw.owner || !raw.repo) + { + throw new Error('owner and repo are required'); + } + if (!raw.token) + { + throw new Error('token is required'); + } + if (!raw.source) + { + throw new Error('source is required'); + } + const targets = splitList(raw.targets); + if (targets.length === 0) + { + throw new Error('targets is required'); + } + + const gitDir = raw.gitDir ?? process.cwd(); + // Same policy file the changelog command reads — exclude-labels is one + // shared vocabulary (e.g. `dependencies`, `wontfix`) rather than a + // second, merge-report-only config surface that could drift out of sync + // with it. + const fileConfig = await loadRepoConfig(gitDir, raw.configPath ?? '.gitflow-changelog.yml'); + + return { + owner: raw.owner, + repo: raw.repo, + token: raw.token, + source: raw.source, + targets, + gitDir, + cachePath: raw.cachePath ?? '.gitflow-changelog-cache.json', + mergeIgnorePath: raw.mergeIgnorePath ?? '.gitflow-changelog-merge-ignore.yml', + excludeLabels: splitList(raw.excludeLabels).length > 0 + ? splitList(raw.excludeLabels) + : fileConfig['exclude-labels'] ?? ['duplicate', 'invalid', 'wontfix'], + }; +} diff --git a/src/merge-report.ts b/src/merge-report.ts new file mode 100644 index 0000000..d3eb195 --- /dev/null +++ b/src/merge-report.ts @@ -0,0 +1,65 @@ +import { loadCache, saveCache } from './cache.js'; +import { excludedShas, updateCache } from './drivers/github.js'; +import { commitDate, unmatchedCommits } from './git.js'; +import { loadMergeIgnore } from './merge-ignore.js'; + +export interface MergeReportOptions { + owner: string; + repo: string; + token: string; + source: string; + targets: string[]; + gitDir: string; + cachePath: string; + mergeIgnorePath?: string; + excludeLabels: string[]; +} + +export interface MergeReportEntry { + target: string; + sha: string; + subject: string; + ageDays: number; +} + +export interface MergeReportResult { + // Every outstanding entry across every target, sorted oldest-first by + // ageDays. Deliberately unfiltered by age — a cutoff here would hide a + // real gap that's one day short of an arbitrary threshold. Age is only + // ever used downstream (fail-on-outstanding-after-days), never to decide + // what's visible in the report itself. + outstanding: MergeReportEntry[]; +} + +function daysSince(isoDate: string, now: Date): number { + const committed = new Date(isoDate); + const ms = now.getTime() - committed.getTime(); + return Math.floor(ms / (24 * 60 * 60 * 1000)); +} + +export async function mergeReport(options: MergeReportOptions, now: Date = new Date()): Promise { + const cache = await loadCache(options.cachePath); + await updateCache(cache, options); + await saveCache(options.cachePath, cache); + + const excluded = excludedShas(cache, options.excludeLabels); + const ignored = await loadMergeIgnore(options.mergeIgnorePath); + + const outstanding: MergeReportEntry[] = []; + for (const target of options.targets) + { + const candidates = await unmatchedCommits(target, options.source, { cwd: options.gitDir }); + for (const candidate of candidates) + { + if (excluded.has(candidate.sha) || ignored.has(candidate.sha)) + { + continue; + } + const date = await commitDate(candidate.sha, { cwd: options.gitDir }); + outstanding.push({ target, sha: candidate.sha, subject: candidate.subject, ageDays: daysSince(date, now) }); + } + } + + outstanding.sort((a, b) => b.ageDays - a.ageDays); + return { outstanding }; +} diff --git a/src/render/merge-report.ts b/src/render/merge-report.ts new file mode 100644 index 0000000..9161d0b --- /dev/null +++ b/src/render/merge-report.ts @@ -0,0 +1,40 @@ +import { escapeTitle } from './default.js'; +import type { MergeReportEntry, MergeReportResult } from '../merge-report.js'; + +export interface MergeReportRenderOptions { + owner: string; + repo: string; + source: string; +} + +function commitUrl(sha: string, options: MergeReportRenderOptions): string { + return `https://github.com/${options.owner}/${options.repo}/commit/${sha}`; +} + +function renderRow(entry: MergeReportEntry, options: MergeReportRenderOptions): string { + const shortSha = entry.sha.slice(0, 7); + const age = entry.ageDays === 1 ? '1 day' : `${entry.ageDays} days`; + return `| ${age} | ${entry.target} | [\`${shortSha}\`](${commitUrl(entry.sha, options)}) | ${escapeTitle(entry.subject)} |`; +} + +// No age cutoff, no truncation — every outstanding entry is listed, +// oldest-first, so the report is always the complete picture; any +// pass/fail behavior belongs to the caller (fail-on-outstanding-after-days), +// not to what's rendered here. +export function renderMergeReport(result: MergeReportResult, options: MergeReportRenderOptions): string { + const lines = [`# Merge report: ${options.source}`, '']; + + if (result.outstanding.length === 0) + { + lines.push(`Nothing outstanding — every commit on \`${options.source}\` has an equivalent on every target branch.`); + return `${lines.join('\n')}\n`; + } + + lines.push('| Age | Target | Commit | Subject |', '|---|---|---|---|'); + for (const entry of result.outstanding) + { + lines.push(renderRow(entry, options)); + } + + return `${lines.join('\n')}\n`; +} diff --git a/test/git-cherry.test.ts b/test/git-cherry.test.ts new file mode 100644 index 0000000..f48a013 --- /dev/null +++ b/test/git-cherry.test.ts @@ -0,0 +1,70 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { commitDate, unmatchedCommits } from '../src/git.js'; +import { createGitFixture, type GitFixture } from './git-fixture.js'; + +const execFileAsync = promisify(execFile); + +describe('unmatchedCommits', () => { + let fixture: GitFixture; + + beforeEach(async () => { + fixture = await createGitFixture(); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + it('flags a commit with no patch-content equivalent on the target', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.checkout('support/1.x'); + const sha = await fixture.commit('fix: only on support/1.x'); + + const candidates = await unmatchedCommits('develop', 'support/1.x', { cwd: fixture.dir }); + + expect(candidates.map((c) => c.sha)).toContain(sha); + }); + + it('does not flag a commit that was cherry-picked to the target under a new sha', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.checkout('support/1.x'); + const sha = await fixture.commit('fix: cherry-picked later'); + + await fixture.checkout('develop'); + await execFileAsync('git', ['cherry-pick', sha], { cwd: fixture.dir }); + + const candidates = await unmatchedCommits('develop', 'support/1.x', { cwd: fixture.dir }); + + expect(candidates.map((c) => c.sha)).not.toContain(sha); + }); + + it('parses the subject after the sha, regardless of hash width', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.checkout('support/1.x'); + const sha = await fixture.commit('fix: has a subject with spaces'); + + const [candidate] = await unmatchedCommits('develop', 'support/1.x', { cwd: fixture.dir }); + + expect(candidate.sha).toBe(sha); + expect(candidate.subject).toBe('fix: has a subject with spaces'); + }); +}); + +describe('commitDate', () => { + it('returns the commit date in ISO 8601 format', async () => { + const fixture = await createGitFixture(); + const sha = await fixture.commit('init'); + + const date = await commitDate(sha, { cwd: fixture.dir }); + + expect(date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/); + expect(new Date(date).getUTCFullYear()).toBe(2024); + + await fixture.cleanup(); + }); +}); diff --git a/test/github-driver.test.ts b/test/github-driver.test.ts index 0f96796..3469108 100644 --- a/test/github-driver.test.ts +++ b/test/github-driver.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { emptyCache } from '../src/cache.js'; -import { applyClosingReferences, applyEvent, entriesFromCache, fetchDefaultBranch, fetchPullRequestBaseRef, findSquashMergePr, GithubRateLimitError } from '../src/drivers/github.js'; +import { applyClosingReferences, applyEvent, entriesFromCache, excludedShas, fetchDefaultBranch, fetchPullRequestBaseRef, findSquashMergePr, GithubRateLimitError } from '../src/drivers/github.js'; import type { DriverOptions } from '../src/types.js'; const OPTIONS: DriverOptions = { @@ -140,6 +140,51 @@ describe('applyEvent + entriesFromCache', () => { }); }); +describe('excludedShas', () => { + it('collects the sha of any entry whose labels intersect excludeLabels', () => { + const cache = emptyCache(); + applyEvent(cache, { + id: 1, + event: 'merged', + commit_id: 'dep-sha', + issue: { + number: 900, + title: 'Bump some-lib', + user: issueUser('dependabot[bot]', true), + labels: [{ name: 'dependencies' }], + pull_request: { merged_at: '2024-01-01T00:00:00Z' }, + }, + }); + applyEvent(cache, { + id: 2, + event: 'merged', + commit_id: 'real-fix-sha', + issue: { + number: 901, + title: 'fix crash', + user: issueUser('octocat'), + labels: [{ name: 'bug' }], + pull_request: { merged_at: '2024-01-01T00:00:00Z' }, + }, + }); + + expect(excludedShas(cache, ['dependencies'])).toEqual(new Set(['dep-sha'])); + }); + + it('ignores an entry with no recorded sha', () => { + const cache = emptyCache(); + applyEvent(cache, { + id: 1, + event: 'labeled', + commit_id: null, + issue: { number: 900, title: 'still open', user: issueUser('octocat'), labels: [{ name: 'dependencies' }] }, + label: { name: 'dependencies' }, + }); + + expect(excludedShas(cache, ['dependencies'])).toEqual(new Set()); + }); +}); + describe('applyClosingReferences', () => { it('backfills an issue closed by a merged PR\'s closing keyword, whose own closed event has no commit_id', () => { const cache = emptyCache(); diff --git a/test/merge-ignore.test.ts b/test/merge-ignore.test.ts new file mode 100644 index 0000000..763df28 --- /dev/null +++ b/test/merge-ignore.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadMergeIgnore } from '../src/merge-ignore.js'; + +describe('loadMergeIgnore', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'gitflow-changelog-merge-ignore-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns an empty map when no path is given', async () => { + expect(await loadMergeIgnore(undefined)).toEqual(new Map()); + }); + + it('returns an empty map when the file does not exist', async () => { + expect(await loadMergeIgnore(join(dir, 'missing.yml'))).toEqual(new Map()); + }); + + it('parses sha-keyed entries with their reason', async () => { + const path = join(dir, '.gitflow-changelog-merge-ignore.yml'); + await writeFile( + path, + [ + 'merge-ignore:', + ' a18bdc1c3db328f6f66f53ac84e1eec4f360ce38: "branch-scoped SNAPSHOT reset, not applicable to develop"', + ].join('\n'), + 'utf8', + ); + + const ignore = await loadMergeIgnore(path); + + expect(ignore.get('a18bdc1c3db328f6f66f53ac84e1eec4f360ce38')).toBe( + 'branch-scoped SNAPSHOT reset, not applicable to develop', + ); + }); +}); diff --git a/test/merge-report.test.ts b/test/merge-report.test.ts new file mode 100644 index 0000000..d04b679 --- /dev/null +++ b/test/merge-report.test.ts @@ -0,0 +1,178 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as githubDriver from '../src/drivers/github.js'; +import { mergeReport } from '../src/merge-report.js'; +import { createGitFixture, type GitFixture } from './git-fixture.js'; + +const execFileAsync = promisify(execFile); + +// Deterministic "now" one calendar year past git-fixture's commit dates +// (2024, counter-based days) so ageDays is stable and easy to assert on, +// instead of depending on the real clock. +const NOW = new Date(2025, 0, 1); + +async function cherryPick(dir: string, sha: string): Promise { + await execFileAsync('git', ['cherry-pick', sha], { cwd: dir }); + const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: dir }); + return stdout.trim(); +} + +describe('mergeReport', () => { + let fixture: GitFixture; + let cachePath: string; + let cacheDir: string; + + beforeEach(async () => { + fixture = await createGitFixture(); + cacheDir = await mkdtemp(join(tmpdir(), 'gitflow-changelog-merge-report-cache-')); + cachePath = join(cacheDir, '.gitflow-changelog-cache.json'); + vi.spyOn(githubDriver, 'updateCache').mockImplementation(async (cache) => cache); + }); + + afterEach(async () => { + await fixture.cleanup(); + await rm(cacheDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('reports a genuinely unported commit but not one already cherry-picked to the target', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.checkout('support/1.x'); + const missingSha = await fixture.commit('fix: genuinely unported'); + const portedSha = await fixture.commit('fix: already ported'); + + await fixture.checkout('develop'); + await cherryPick(fixture.dir, portedSha); + + const result = await mergeReport( + { + owner: 'acme', + repo: 'widget', + token: 't', + source: 'support/1.x', + targets: ['develop'], + gitDir: fixture.dir, + cachePath, + excludeLabels: [], + }, + NOW, + ); + + const shas = result.outstanding.map((entry) => entry.sha); + expect(shas).toContain(missingSha); + expect(shas).not.toContain(portedSha); + }); + + it('drops a candidate whose originating PR/issue carries an exclude-label', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.checkout('support/1.x'); + const depSha = await fixture.commit('build(deps): bump some-lib'); + + vi.spyOn(githubDriver, 'updateCache').mockImplementation(async (cache) => { + cache.entries['1'] = { kind: 'pr', title: 'Bump some-lib', login: 'dependabot[bot]', bot: true, labels: ['dependencies'], sha: depSha }; + return cache; + }); + + const result = await mergeReport( + { + owner: 'acme', + repo: 'widget', + token: 't', + source: 'support/1.x', + targets: ['develop'], + gitDir: fixture.dir, + cachePath, + excludeLabels: ['dependencies'], + }, + NOW, + ); + + expect(result.outstanding.map((entry) => entry.sha)).not.toContain(depSha); + }); + + it('drops a candidate listed in the merge-ignore file', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.checkout('support/1.x'); + const ignoredSha = await fixture.commit('fix(support/1.x): branch-only version bump'); + + const ignorePath = join(cacheDir, '.gitflow-changelog-merge-ignore.yml'); + await writeFile(ignorePath, `merge-ignore:\n ${ignoredSha}: "branch-only, not applicable to develop"\n`, 'utf8'); + + const result = await mergeReport( + { + owner: 'acme', + repo: 'widget', + token: 't', + source: 'support/1.x', + targets: ['develop'], + gitDir: fixture.dir, + cachePath, + mergeIgnorePath: ignorePath, + excludeLabels: [], + }, + NOW, + ); + + expect(result.outstanding.map((entry) => entry.sha)).not.toContain(ignoredSha); + }); + + it('fans out across multiple targets, reporting each target independently', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.branch('support/2.x'); + await fixture.checkout('support/1.x'); + const onlyOnSource = await fixture.commit('fix: needed everywhere'); + + const result = await mergeReport( + { + owner: 'acme', + repo: 'widget', + token: 't', + source: 'support/1.x', + targets: ['develop', 'support/2.x'], + gitDir: fixture.dir, + cachePath, + excludeLabels: [], + }, + NOW, + ); + + const targets = result.outstanding.filter((entry) => entry.sha === onlyOnSource).map((entry) => entry.target); + expect(targets.sort()).toEqual(['develop', 'support/2.x']); + }); + + it('sorts outstanding entries oldest-first by commit age', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.checkout('support/1.x'); + const older = await fixture.commit('fix: older'); + const newer = await fixture.commit('fix: newer'); + + const result = await mergeReport( + { + owner: 'acme', + repo: 'widget', + token: 't', + source: 'support/1.x', + targets: ['develop'], + gitDir: fixture.dir, + cachePath, + excludeLabels: [], + }, + NOW, + ); + + const shas = result.outstanding.map((entry) => entry.sha); + expect(shas.indexOf(older)).toBeLessThan(shas.indexOf(newer)); + const olderEntry = result.outstanding.find((entry) => entry.sha === older); + const newerEntry = result.outstanding.find((entry) => entry.sha === newer); + expect(olderEntry!.ageDays).toBeGreaterThan(newerEntry!.ageDays); + }); +}); From a305c4fbef341bcf08d5b95b9b4c4a852f02300b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 21:44:44 +0000 Subject: [PATCH 2/3] refactor(merge-report): sweep the whole branch topology, not one source/targets pair Replace the source/targets shape with auto-discovery: list every branch matching the mainline branch (default develop) or a configured support-branch pattern (default support/(\d+)\.x), then derive each branch's sources purely from naming/version convention -- mainline's sources are every support branch that exists, support/N.x's sources are every support/M.x with M < N, and the lowest surviving support branch has none at all and is trivially clean by definition. This makes the report self-adjusting: cutting a new support branch from develop needs no config change anywhere, and a scheduled run and a manual workflow_dispatch run produce identical results since neither requires the caller to supply the current topology. An explicit target (+ optional sources override) still exists for on-demand narrowing. Adds src/topology.ts (pure computeTopology, no git involved) and git.ts's listBranches/resolveRef (prefers origin/, falls back to the bare name for a plain local clone). The renderer now produces one section per discovered branch, including an explicit "nothing to check"/"nothing outstanding" state rather than omitting a clean branch. Per the GitHub Actions constraint that a schedule trigger always runs the workflow file on the repo's default branch (no branch-selection equivalent to workflow_dispatch), this only works as one workflow living on the mainline branch -- README updated with the required `git fetch origin '+refs/heads/*:refs/remotes/origin/*'` step, since actions/checkout's default fetch refspec only brings full history for the one ref it checks out, even at fetch-depth 0. Updates #38 to match. --- README.md | 63 +++++++++++--- merge-report/action.yml | 66 +++++++++----- src/git.ts | 34 ++++++++ src/merge-report-action.ts | 12 +-- src/merge-report-cli.ts | 14 +-- src/merge-report-config.ts | 31 ++++--- src/merge-report.ts | 58 ++++++++++--- src/render/merge-report.ts | 40 ++++++--- src/repo-config.ts | 6 ++ src/topology.ts | 43 ++++++++++ test/git-cherry.test.ts | 64 +++++++++++++- test/merge-report.test.ts | 172 ++++++++++++++++++++----------------- test/topology.test.ts | 50 +++++++++++ 13 files changed, 491 insertions(+), 162 deletions(-) create mode 100644 src/topology.ts create mode 100644 test/topology.test.ts diff --git a/README.md b/README.md index fe791ec..630f749 100644 --- a/README.md +++ b/README.md @@ -217,12 +217,39 @@ branch) — it's the reverse, or a sideways case between peers — hence **merge-report**: did this change actually merge across the branches that needed it, regardless of direction. +### Branch topology, discovered automatically + +`merge-report` doesn't take a source/target pair — it discovers the whole +branch topology itself and sweeps it in one pass. The mainline branch +(`develop` by default) and every branch matching a support-branch pattern +(`support/(\d+)\.x` by default) are found by listing what actually exists +right now, then each one's *sources* — the branches it should have every +fix from — are derived purely from naming/version convention: + +- the mainline branch's sources are every support branch that exists +- `support/N.x`'s sources are every `support/M.x` that exists with `M < N` +- the lowest surviving support branch has no sources — it's trivially clean + by definition, and the report says so explicitly rather than omitting it + +This is what makes the report self-adjusting: cutting `support/3.x` from +`develop` doesn't require a check-in anywhere — the next run picks it up as +a new target with `support/1.x` and `support/2.x` as its sources +automatically, and `develop`'s own sweep gains a third source the same way. +It's also what makes a scheduled run and a manual `workflow_dispatch` run +produce identical results: neither one requires the caller to know or +supply the current branch topology, since there's nothing to supply. + ### As a GitHub Action ```yaml - uses: actions/checkout@v4 with: - fetch-depth: 0 # required — patch-content comparison needs full history + fetch-depth: 0 # full history — patch-content comparison needs it + +# actions/checkout's default fetch refspec only brings full history for the +# one ref it checks out, even at fetch-depth 0 — every other branch needs an +# explicit fetch, or discovery below finds nothing to sweep. +- run: git fetch origin '+refs/heads/*:refs/remotes/origin/*' - uses: actions/cache@v4 with: @@ -230,19 +257,27 @@ needed it, regardless of direction. key: gitflow-changelog-v1-${{ github.repository }} # same cache the changelog action uses - uses: aklivity/gitflow-changelog/merge-report@v1 - with: - source: support/1.x - targets: develop ``` Run this on a schedule (weekly, say) plus `workflow_dispatch`, not on every -push or PR merge to `source` — at the exact moment a fix lands on `source` -it is *definitionally* not yet on `targets`, so a merge-triggered run would -only ever report a guaranteed, contentless "not yet." The scheduled sweep is -where real signal — something that's been outstanding for a while — shows -up. See [`merge-report/action.yml`](./merge-report/action.yml) for the full -list of inputs, including `fail-on-outstanding-after-days` (default 14): -the report itself always lists everything, unfiltered, oldest-first; this +push or PR merge — at the exact moment a fix lands on some branch it is +*definitionally* not yet on anything downstream of it, so a merge-triggered +run would only ever report a guaranteed, contentless "not yet." The +scheduled sweep is where real signal — something that's been outstanding +for a while — shows up. GitHub's `schedule` trigger always runs the copy of +the workflow file on the repo's *default* branch, with no branch-selection +equivalent to what `workflow_dispatch` offers — so this only works as one +workflow living on the mainline branch, not as a workflow duplicated across +every branch expecting to infer "itself" as the target. `git cherry` +doesn't need any branch checked out, only present locally, which is exactly +what the fetch step above provides — no per-branch checkout required to +sweep the whole topology in a single job. + +See [`merge-report/action.yml`](./merge-report/action.yml) for the full +list of inputs, including `target`/`sources` (narrows the sweep to one +branch, for on-demand debugging — leave unset for the default full sweep) +and `fail-on-outstanding-after-days` (default 14): the report itself always +lists everything, unfiltered, oldest-first, one section per branch; this input only controls whether the run exits non-zero, which is what actually surfaces the finding to a human via GitHub's default scheduled-workflow- failure notification — a job summary alone isn't pushed to anyone. @@ -250,8 +285,12 @@ failure notification — a job summary alone isn't pushed to anyone. ### As a CLI ```bash +# full sweep — no branch args needed, topology is discovered +npx gitflow-changelog merge-report --owner aklivity --repo zilla-plus --token "$GITHUB_TOKEN" + +# narrowed to one target, for on-demand debugging npx gitflow-changelog merge-report --owner aklivity --repo zilla-plus --token "$GITHUB_TOKEN" \ - --source support/1.x --targets develop + --target support/2.x --sources support/1.x ``` ### How it detects a gap diff --git a/merge-report/action.yml b/merge-report/action.yml index 9b7e395..5cdef4f 100644 --- a/merge-report/action.yml +++ b/merge-report/action.yml @@ -1,8 +1,9 @@ name: gitflow-changelog merge-report description: >- - Reports gitflow branch pairs where a fix landed on one branch but its - content isn't present on another that should have it — by comparing - actual patch content, not merge-commit ancestry. + Sweeps a repo's gitflow branch topology (a mainline branch plus every + support/* maintenance branch) for commits that landed on one branch but + have no equivalent patch content on another that should have it — by + comparing actual patch content, not merge-commit ancestry. author: Aklivity branding: icon: git-merge @@ -21,21 +22,13 @@ inputs: description: GitHub token used to read issues, pull requests, and their events. required: false default: ${{ github.token }} - source: - description: >- - Branch whose commits are checked for propagation, e.g. support/1.x. - required: true - targets: - description: >- - Comma-separated branches that should have every commit from `source`, - e.g. develop, or develop,support/2.x. Not assumed to be upstream of - `source` — a peer maintenance branch is a valid target too. - required: true git-dir: description: >- - Path to a full clone of the repository (requires fetch-depth 0 — - patch-content comparison needs full history). Defaults to the current - working directory. + Path to a full clone of the repository, with every relevant branch + fetched — not just the checked-out one (requires fetch-depth 0 plus + an explicit fetch of every branch; actions/checkout's default fetch + refspec only brings full history for the one ref it checks out, even + at fetch-depth 0). Defaults to the current working directory. required: false cache-path: description: >- @@ -55,10 +48,12 @@ inputs: default: .gitflow-changelog-merge-ignore.yml config-path: description: >- - Path to the same .gitflow-changelog.yml the changelog command reads — - only exclude-labels is consulted here, so a PR/issue labeled e.g. + Path to the same .gitflow-changelog.yml the changelog command reads. + exclude-labels is shared with it (a PR/issue labeled e.g. "dependencies" is skipped the same way it's excluded from the - changelog, without a second label vocabulary to keep in sync. + changelog); mainline-branch and support-branch-pattern are read from + it too, even though they're merge-report-only, since they're repo- + wide policy in the same sense tag-pattern is. required: false default: .gitflow-changelog.yml exclude-labels: @@ -68,6 +63,32 @@ inputs: config-path; defaults to "duplicate,invalid,wontfix" if set in neither place. required: false + mainline-branch: + description: >- + The repo's mainline gitflow branch. Every support branch is one of + its sources. Overrides mainline-branch in config-path; defaults to + "develop" if set in neither place. + required: false + support-branch-pattern: + description: >- + Regular expression matching a maintenance branch name, with the + version as the first capture group (compared numerically, so + support/10.x correctly sorts after support/2.x). Overrides + support-branch-pattern in config-path; defaults to + "^support/(\d+)\.x$" if set in neither place. + required: false + target: + description: >- + Narrows the sweep to a single branch instead of the full topology — + for on-demand debugging. Leave unset for the default, parameter-free + full sweep, which is what makes a scheduled run and a manual + workflow_dispatch run produce identical results. + required: false + sources: + description: >- + Comma-separated branches to check `target` against, replacing its + auto-computed sources entirely. Only meaningful alongside `target`. + required: false output-path: description: Path to write the generated report to. required: false @@ -76,9 +97,10 @@ inputs: description: >- Fail the run if any entry in the report has been outstanding longer than this many days. The report itself always lists everything, - unfiltered, sorted oldest-first — this only controls whether the run - exits non-zero (and therefore triggers GitHub's default scheduled- - workflow-failure notification). + unfiltered, sorted oldest-first, one section per branch (including a + branch with nothing outstanding) — this only controls whether the + run exits non-zero (and therefore triggers GitHub's default + scheduled-workflow-failure notification). required: false default: '14' diff --git a/src/git.ts b/src/git.ts index b599637..6902a78 100644 --- a/src/git.ts +++ b/src/git.ts @@ -254,3 +254,37 @@ export async function commitDate(sha: string, options: GitOptions): Promise after a fetch — reading both refs/heads and +// refs/remotes/origin means merge-report's branch discovery works the same +// way against that shape and against a plain local clone (e.g. a test +// fixture with no remote at all, where every branch is local). +export async function listBranches(pattern: RegExp, options: GitOptions): Promise { + const stdout = await run(['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes/origin'], options); + const names = new Set( + stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line !== 'origin/HEAD') + .map((name) => name.replace(/^origin\//, '')), + ); + return [...names].filter((name) => pattern.test(name)); +} + +async function refExists(ref: string, options: GitOptions): Promise { + const result = await runAllowFailure(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], options); + return result.code === 0; +} + +// Resolves a branch's short name (as returned by listBranches) to a ref +// git commands can actually operate on. Prefers origin/ — the shape +// every branch except the checked-out one has in a normal CI checkout — and +// falls back to the bare name for a plain local clone (no origin remote at +// all, e.g. a test fixture) where the bare name is all that exists. +export async function resolveRef(name: string, options: GitOptions): Promise { + const withOrigin = `origin/${name}`; + return (await refExists(withOrigin, options)) ? withOrigin : name; +} diff --git a/src/merge-report-action.ts b/src/merge-report-action.ts index b854f36..a3cfeb0 100644 --- a/src/merge-report-action.ts +++ b/src/merge-report-action.ts @@ -9,20 +9,22 @@ async function main(): Promise { owner: core.getInput('owner', { required: true }), repo: core.getInput('repo', { required: true }), token: core.getInput('token', { required: true }), - source: core.getInput('source', { required: true }), - targets: core.getInput('targets', { required: true }), gitDir: core.getInput('git-dir') || undefined, cachePath: core.getInput('cache-path') || undefined, mergeIgnorePath: core.getInput('merge-ignore-path') || undefined, configPath: core.getInput('config-path') || undefined, excludeLabels: core.getInput('exclude-labels') || undefined, + mainlineBranch: core.getInput('mainline-branch') || undefined, + supportBranchPattern: core.getInput('support-branch-pattern') || undefined, + target: core.getInput('target') || undefined, + sources: core.getInput('sources') || undefined, }); const outputPath = core.getInput('output-path') || 'merge-report.md'; const failAfterDays = Number(core.getInput('fail-on-outstanding-after-days') || '14'); const result = await mergeReport(options); - const markdown = renderMergeReport(result, { owner: options.owner, repo: options.repo, source: options.source }); + const markdown = renderMergeReport(result, { owner: options.owner, repo: options.repo }); await writeFile(outputPath, markdown, 'utf8'); core.setOutput('merge-report-path', outputPath); @@ -32,13 +34,13 @@ async function main(): Promise { // anyone, but the run's own pass/fail *is* something GitHub notifies on // by default for a scheduled workflow, so failing past the threshold is // what turns "the report exists" into "someone finds out." - await core.summary.addHeading(`merge-report: ${options.source}`).addRaw(markdown).write(); + await core.summary.addHeading('merge-report').addRaw(markdown).write(); const stale = result.outstanding.filter((entry) => entry.ageDays > failAfterDays); if (stale.length > 0) { core.setFailed( - `${stale.length} commit(s) on "${options.source}" have been outstanding for more than ${failAfterDays} days ` + + `${stale.length} commit(s) have been outstanding for more than ${failAfterDays} days ` + `without an equivalent on their target branch — see the job summary for the full list.`, ); } diff --git a/src/merge-report-cli.ts b/src/merge-report-cli.ts index 9d4bdda..13ddba9 100644 --- a/src/merge-report-cli.ts +++ b/src/merge-report-cli.ts @@ -11,13 +11,15 @@ export async function runMergeReportCli(argv: string[]): Promise { owner: { type: 'string' }, repo: { type: 'string' }, token: { type: 'string' }, - source: { type: 'string' }, - targets: { type: 'string' }, 'git-dir': { type: 'string' }, 'cache-path': { type: 'string' }, 'merge-ignore-path': { type: 'string' }, 'config-path': { type: 'string' }, 'exclude-labels': { type: 'string' }, + 'mainline-branch': { type: 'string' }, + 'support-branch-pattern': { type: 'string' }, + target: { type: 'string' }, + sources: { type: 'string' }, output: { type: 'string', default: 'merge-report.md' }, }, }); @@ -26,17 +28,19 @@ export async function runMergeReportCli(argv: string[]): Promise { owner: values.owner, repo: values.repo, token: values.token ?? process.env.GITHUB_TOKEN, - source: values.source, - targets: values.targets, gitDir: values['git-dir'], cachePath: values['cache-path'], mergeIgnorePath: values['merge-ignore-path'], configPath: values['config-path'], excludeLabels: values['exclude-labels'], + mainlineBranch: values['mainline-branch'], + supportBranchPattern: values['support-branch-pattern'], + target: values.target, + sources: values.sources, }); const result = await mergeReport(options); - const markdown = renderMergeReport(result, { owner: options.owner, repo: options.repo, source: options.source }); + const markdown = renderMergeReport(result, { owner: options.owner, repo: options.repo }); await writeFile(values.output as string, markdown, 'utf8'); } diff --git a/src/merge-report-config.ts b/src/merge-report-config.ts index 3b6cb30..0ecdeec 100644 --- a/src/merge-report-config.ts +++ b/src/merge-report-config.ts @@ -5,13 +5,15 @@ export interface RawMergeReportInputs { owner?: string; repo?: string; token?: string; - source?: string; - targets?: string; gitDir?: string; cachePath?: string; mergeIgnorePath?: string; configPath?: string; excludeLabels?: string; + mainlineBranch?: string; + supportBranchPattern?: string; + target?: string; + sources?: string; } function splitList(value: string | undefined): string[] { @@ -34,34 +36,35 @@ export async function toMergeReportOptions(raw: RawMergeReportInputs): Promise 0 && !raw.target) + { + throw new Error('sources only applies alongside target — it replaces that one target\'s auto-computed sources'); + } + return { owner: raw.owner, repo: raw.repo, token: raw.token, - source: raw.source, - targets, gitDir, cachePath: raw.cachePath ?? '.gitflow-changelog-cache.json', mergeIgnorePath: raw.mergeIgnorePath ?? '.gitflow-changelog-merge-ignore.yml', excludeLabels: splitList(raw.excludeLabels).length > 0 ? splitList(raw.excludeLabels) : fileConfig['exclude-labels'] ?? ['duplicate', 'invalid', 'wontfix'], + mainline: raw.mainlineBranch || fileConfig['mainline-branch'] || 'develop', + supportPattern: new RegExp(raw.supportBranchPattern || fileConfig['support-branch-pattern'] || '^support/(\\d+)\\.x$'), + target: raw.target || undefined, + sources: sources.length > 0 ? sources : undefined, }; } diff --git a/src/merge-report.ts b/src/merge-report.ts index d3eb195..1d31d9e 100644 --- a/src/merge-report.ts +++ b/src/merge-report.ts @@ -1,28 +1,42 @@ import { loadCache, saveCache } from './cache.js'; import { excludedShas, updateCache } from './drivers/github.js'; -import { commitDate, unmatchedCommits } from './git.js'; +import { commitDate, listBranches, resolveRef, unmatchedCommits } from './git.js'; import { loadMergeIgnore } from './merge-ignore.js'; +import { computeTopology } from './topology.js'; +import type { BranchTopology } from './topology.js'; export interface MergeReportOptions { owner: string; repo: string; token: string; - source: string; - targets: string[]; gitDir: string; cachePath: string; mergeIgnorePath?: string; excludeLabels: string[]; + mainline: string; + supportPattern: RegExp; + // Narrows the sweep to a single target instead of the full topology — + // for on-demand debugging ("just check support/2.x"). `sources`, if also + // given, replaces that target's auto-computed sources entirely; without + // it, the target's normal auto-computed sources still apply. Leaving + // both unset is the default, parameter-free full sweep. + target?: string; + sources?: string[]; } export interface MergeReportEntry { target: string; + source: string; sha: string; subject: string; ageDays: number; } export interface MergeReportResult { + // Every discovered (target, sources) pair, even one with no outstanding + // entries — the renderer uses this to say a branch is explicitly clean + // rather than silently omitting it. + topology: BranchTopology[]; // Every outstanding entry across every target, sorted oldest-first by // ageDays. Deliberately unfiltered by age — a cutoff here would hide a // real gap that's one day short of an arbitrary threshold. Age is only @@ -37,6 +51,22 @@ function daysSince(isoDate: string, now: Date): number { return Math.floor(ms / (24 * 60 * 60 * 1000)); } +async function discoverTopology(options: MergeReportOptions): Promise { + const branches = await listBranches(/.*/, { cwd: options.gitDir }); + const relevant = branches.filter((name) => name === options.mainline || options.supportPattern.test(name)); + const full = computeTopology(relevant, options.mainline, options.supportPattern); + + if (!options.target) + { + return full; + } + if (options.sources) + { + return [{ target: options.target, sources: options.sources }]; + } + return [full.find((entry) => entry.target === options.target) ?? { target: options.target, sources: [] }]; +} + export async function mergeReport(options: MergeReportOptions, now: Date = new Date()): Promise { const cache = await loadCache(options.cachePath); await updateCache(cache, options); @@ -44,22 +74,28 @@ export async function mergeReport(options: MergeReportOptions, now: Date = new D const excluded = excludedShas(cache, options.excludeLabels); const ignored = await loadMergeIgnore(options.mergeIgnorePath); + const topology = await discoverTopology(options); const outstanding: MergeReportEntry[] = []; - for (const target of options.targets) + for (const { target, sources } of topology) { - const candidates = await unmatchedCommits(target, options.source, { cwd: options.gitDir }); - for (const candidate of candidates) + const targetRef = await resolveRef(target, { cwd: options.gitDir }); + for (const source of sources) { - if (excluded.has(candidate.sha) || ignored.has(candidate.sha)) + const sourceRef = await resolveRef(source, { cwd: options.gitDir }); + const candidates = await unmatchedCommits(targetRef, sourceRef, { cwd: options.gitDir }); + for (const candidate of candidates) { - continue; + if (excluded.has(candidate.sha) || ignored.has(candidate.sha)) + { + continue; + } + const date = await commitDate(candidate.sha, { cwd: options.gitDir }); + outstanding.push({ target, source, sha: candidate.sha, subject: candidate.subject, ageDays: daysSince(date, now) }); } - const date = await commitDate(candidate.sha, { cwd: options.gitDir }); - outstanding.push({ target, sha: candidate.sha, subject: candidate.subject, ageDays: daysSince(date, now) }); } } outstanding.sort((a, b) => b.ageDays - a.ageDays); - return { outstanding }; + return { topology, outstanding }; } diff --git a/src/render/merge-report.ts b/src/render/merge-report.ts index 9161d0b..6e3ed4e 100644 --- a/src/render/merge-report.ts +++ b/src/render/merge-report.ts @@ -4,7 +4,6 @@ import type { MergeReportEntry, MergeReportResult } from '../merge-report.js'; export interface MergeReportRenderOptions { owner: string; repo: string; - source: string; } function commitUrl(sha: string, options: MergeReportRenderOptions): string { @@ -14,27 +13,42 @@ function commitUrl(sha: string, options: MergeReportRenderOptions): string { function renderRow(entry: MergeReportEntry, options: MergeReportRenderOptions): string { const shortSha = entry.sha.slice(0, 7); const age = entry.ageDays === 1 ? '1 day' : `${entry.ageDays} days`; - return `| ${age} | ${entry.target} | [\`${shortSha}\`](${commitUrl(entry.sha, options)}) | ${escapeTitle(entry.subject)} |`; + return `| ${age} | ${entry.source} | [\`${shortSha}\`](${commitUrl(entry.sha, options)}) | ${escapeTitle(entry.subject)} |`; } // No age cutoff, no truncation — every outstanding entry is listed, // oldest-first, so the report is always the complete picture; any // pass/fail behavior belongs to the caller (fail-on-outstanding-after-days), -// not to what's rendered here. +// not to what's rendered here. Every discovered target gets its own +// section, including one with nothing outstanding — an explicit "clean" is +// the point (e.g. the lowest support branch, which has no sources at all +// and is trivially clean by definition), not something worth omitting. export function renderMergeReport(result: MergeReportResult, options: MergeReportRenderOptions): string { - const lines = [`# Merge report: ${options.source}`, '']; + const lines = ['# Merge report', '']; - if (result.outstanding.length === 0) + for (const { target, sources } of result.topology) { - lines.push(`Nothing outstanding — every commit on \`${options.source}\` has an equivalent on every target branch.`); - return `${lines.join('\n')}\n`; - } + const entries = result.outstanding.filter((entry) => entry.target === target); + lines.push(`## ${target}`, ''); - lines.push('| Age | Target | Commit | Subject |', '|---|---|---|---|'); - for (const entry of result.outstanding) - { - lines.push(renderRow(entry, options)); + if (sources.length === 0) + { + lines.push('No sources to check — nothing can be outstanding here.', ''); + continue; + } + if (entries.length === 0) + { + lines.push(`Nothing outstanding from ${sources.join(', ')}.`, ''); + continue; + } + + lines.push('| Age | Source | Commit | Subject |', '|---|---|---|---|'); + for (const entry of entries) + { + lines.push(renderRow(entry, options)); + } + lines.push(''); } - return `${lines.join('\n')}\n`; + return `${lines.join('\n').trimEnd()}\n`; } diff --git a/src/repo-config.ts b/src/repo-config.ts index fcdbfa6..f467c92 100644 --- a/src/repo-config.ts +++ b/src/repo-config.ts @@ -12,6 +12,12 @@ const RepoConfigSchema = z.object({ format: z.string().optional(), classification: ClassificationLevel.optional(), upstream: z.array(UpstreamConfig).optional(), + // merge-report's branch-topology discovery — repo-wide policy the same + // way tag-pattern is: which branch is the mainline, and what a + // maintenance branch's name looks like (must capture the version as the + // first group, compared numerically to order support branches). + 'mainline-branch': z.string().optional(), + 'support-branch-pattern': z.string().optional(), }); export type RepoConfig = z.infer; diff --git a/src/topology.ts b/src/topology.ts new file mode 100644 index 0000000..5ef5116 --- /dev/null +++ b/src/topology.ts @@ -0,0 +1,43 @@ +export interface BranchTopology { + target: string; + sources: string[]; +} + +function supportVersion(name: string, supportPattern: RegExp): number | undefined { + const match = name.match(supportPattern); + return match ? Number(match[1]) : undefined; +} + +// Pure function, no git involved — computeTopology only needs to know which +// branches currently exist, not their content, so it's testable with plain +// string arrays. The whole point of deriving this from names/versions +// rather than a checked-in list is that it self-adjusts the moment a branch +// is cut or removed, with nothing to edit anywhere. +// +// mainline's sources are every support branch that exists; support/N.x's +// sources are every support/M.x that exists with M < N (never a same-or- +// higher version, which avoids two branches redundantly checking each +// other); the lowest surviving support branch has no sources at all. +export function computeTopology(branches: string[], mainline: string, supportPattern: RegExp): BranchTopology[] { + const supportBranches = branches + .map((name) => ({ name, version: supportVersion(name, supportPattern) })) + .filter((branch): branch is { name: string; version: number } => branch.version !== undefined) + .sort((a, b) => a.version - b.version); + + const topology: BranchTopology[] = []; + + if (branches.includes(mainline)) + { + topology.push({ target: mainline, sources: supportBranches.map((branch) => branch.name) }); + } + + for (let index = 0; index < supportBranches.length; index += 1) + { + topology.push({ + target: supportBranches[index].name, + sources: supportBranches.slice(0, index).map((branch) => branch.name), + }); + } + + return topology; +} diff --git a/test/git-cherry.test.ts b/test/git-cherry.test.ts index f48a013..32d6c77 100644 --- a/test/git-cherry.test.ts +++ b/test/git-cherry.test.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { commitDate, unmatchedCommits } from '../src/git.js'; +import { commitDate, listBranches, resolveRef, unmatchedCommits } from '../src/git.js'; import { createGitFixture, type GitFixture } from './git-fixture.js'; const execFileAsync = promisify(execFile); @@ -55,6 +55,68 @@ describe('unmatchedCommits', () => { }); }); +describe('listBranches', () => { + let fixture: GitFixture; + + beforeEach(async () => { + fixture = await createGitFixture(); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + it('returns local branches matching pattern, without an origin remote', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.branch('support/2.x'); + await fixture.branch('feature/unrelated'); + + const branches = await listBranches(/^support\/\d+\.x$/, { cwd: fixture.dir }); + + expect(branches.sort()).toEqual(['support/1.x', 'support/2.x']); + }); + + it('strips the origin/ prefix and dedupes a name present as both local and remote-tracking', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + // Simulate a remote-tracking ref by copying the local branch under + // refs/remotes/origin — no real remote is configured in this fixture. + await execFileAsync('git', ['update-ref', 'refs/remotes/origin/support/1.x', 'refs/heads/support/1.x'], { cwd: fixture.dir }); + + const branches = await listBranches(/^support\/\d+\.x$/, { cwd: fixture.dir }); + + expect(branches).toEqual(['support/1.x']); + }); +}); + +describe('resolveRef', () => { + let fixture: GitFixture; + + beforeEach(async () => { + fixture = await createGitFixture(); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + it('falls back to the bare name when no origin/ ref exists', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + + expect(await resolveRef('support/1.x', { cwd: fixture.dir })).toBe('support/1.x'); + }); + + it('prefers origin/ when it exists', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await execFileAsync('git', ['update-ref', 'refs/remotes/origin/support/1.x', 'refs/heads/support/1.x'], { cwd: fixture.dir }); + + expect(await resolveRef('support/1.x', { cwd: fixture.dir })).toBe('origin/support/1.x'); + }); +}); + describe('commitDate', () => { it('returns the commit date in ISO 8601 format', async () => { const fixture = await createGitFixture(); diff --git a/test/merge-report.test.ts b/test/merge-report.test.ts index d04b679..661a492 100644 --- a/test/merge-report.test.ts +++ b/test/merge-report.test.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as githubDriver from '../src/drivers/github.js'; import { mergeReport } from '../src/merge-report.js'; +import type { MergeReportOptions } from '../src/merge-report.js'; import { createGitFixture, type GitFixture } from './git-fixture.js'; const execFileAsync = promisify(execFile); @@ -14,6 +15,7 @@ const execFileAsync = promisify(execFile); // (2024, counter-based days) so ageDays is stable and easy to assert on, // instead of depending on the real clock. const NOW = new Date(2025, 0, 1); +const SUPPORT_PATTERN = /^support\/(\d+)\.x$/; async function cherryPick(dir: string, sha: string): Promise { await execFileAsync('git', ['cherry-pick', sha], { cwd: dir }); @@ -21,6 +23,19 @@ async function cherryPick(dir: string, sha: string): Promise { return stdout.trim(); } +function baseOptions(fixture: GitFixture, cachePath: string): MergeReportOptions { + return { + owner: 'acme', + repo: 'widget', + token: 't', + gitDir: fixture.dir, + cachePath, + excludeLabels: [], + mainline: 'develop', + supportPattern: SUPPORT_PATTERN, + }; +} + describe('mergeReport', () => { let fixture: GitFixture; let cachePath: string; @@ -39,7 +54,7 @@ describe('mergeReport', () => { vi.restoreAllMocks(); }); - it('reports a genuinely unported commit but not one already cherry-picked to the target', async () => { + it('reports a genuinely unported commit but not one already cherry-picked to its target', async () => { await fixture.commit('init'); await fixture.branch('support/1.x'); await fixture.checkout('support/1.x'); @@ -49,25 +64,86 @@ describe('mergeReport', () => { await fixture.checkout('develop'); await cherryPick(fixture.dir, portedSha); - const result = await mergeReport( - { - owner: 'acme', - repo: 'widget', - token: 't', - source: 'support/1.x', - targets: ['develop'], - gitDir: fixture.dir, - cachePath, - excludeLabels: [], - }, - NOW, - ); + const result = await mergeReport(baseOptions(fixture, cachePath), NOW); const shas = result.outstanding.map((entry) => entry.sha); expect(shas).toContain(missingSha); expect(shas).not.toContain(portedSha); }); + it('sweeps the full topology with no target/sources given: mainline sees every support branch', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.branch('support/2.x'); + await fixture.checkout('support/1.x'); + const onOne = await fixture.commit('fix: on support/1.x only'); + await fixture.checkout('support/2.x'); + const onTwo = await fixture.commit('fix: on support/2.x only'); + + const result = await mergeReport(baseOptions(fixture, cachePath), NOW); + + const develop = result.outstanding.filter((entry) => entry.target === 'develop'); + expect(develop.map((entry) => entry.sha).sort()).toEqual([onOne, onTwo].sort()); + }); + + it('gives support/N.x only lower-numbered support branches as sources, not mainline or peers above it', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.branch('support/2.x'); + await fixture.checkout('support/1.x'); + const onOne = await fixture.commit('fix: on support/1.x only'); + await fixture.checkout('develop'); + const onDevelop = await fixture.commit('feat: mainline-only work'); + + const result = await mergeReport(baseOptions(fixture, cachePath), NOW); + + const supportTwo = result.outstanding.filter((entry) => entry.target === 'support/2.x'); + expect(supportTwo.map((entry) => entry.sha)).toEqual([onOne]); + expect(supportTwo.map((entry) => entry.sha)).not.toContain(onDevelop); + }); + + it('reports the lowest support branch as having no sources, trivially clean', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + + const result = await mergeReport(baseOptions(fixture, cachePath), NOW); + + const supportOne = result.topology.find((entry) => entry.target === 'support/1.x'); + expect(supportOne?.sources).toEqual([]); + expect(result.outstanding.some((entry) => entry.target === 'support/1.x')).toBe(false); + }); + + it('narrows to a single target with its auto-computed sources when target is given alone', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.branch('support/2.x'); + await fixture.checkout('support/1.x'); + const onOne = await fixture.commit('fix: on support/1.x only'); + await fixture.checkout('develop'); + await fixture.commit('feat: mainline-only work'); + + const result = await mergeReport({ ...baseOptions(fixture, cachePath), target: 'support/2.x' }, NOW); + + expect(result.topology).toEqual([{ target: 'support/2.x', sources: ['support/1.x'] }]); + expect(result.outstanding.map((entry) => entry.sha)).toEqual([onOne]); + }); + + it('replaces the auto-computed sources entirely when both target and sources are given', async () => { + await fixture.commit('init'); + await fixture.branch('support/1.x'); + await fixture.branch('support/2.x'); + await fixture.checkout('support/2.x'); + const onTwo = await fixture.commit('fix: on support/2.x only'); + + const result = await mergeReport( + { ...baseOptions(fixture, cachePath), target: 'develop', sources: ['support/2.x'] }, + NOW, + ); + + expect(result.topology).toEqual([{ target: 'develop', sources: ['support/2.x'] }]); + expect(result.outstanding.map((entry) => entry.sha)).toEqual([onTwo]); + }); + it('drops a candidate whose originating PR/issue carries an exclude-label', async () => { await fixture.commit('init'); await fixture.branch('support/1.x'); @@ -79,19 +155,7 @@ describe('mergeReport', () => { return cache; }); - const result = await mergeReport( - { - owner: 'acme', - repo: 'widget', - token: 't', - source: 'support/1.x', - targets: ['develop'], - gitDir: fixture.dir, - cachePath, - excludeLabels: ['dependencies'], - }, - NOW, - ); + const result = await mergeReport({ ...baseOptions(fixture, cachePath), excludeLabels: ['dependencies'] }, NOW); expect(result.outstanding.map((entry) => entry.sha)).not.toContain(depSha); }); @@ -105,49 +169,11 @@ describe('mergeReport', () => { const ignorePath = join(cacheDir, '.gitflow-changelog-merge-ignore.yml'); await writeFile(ignorePath, `merge-ignore:\n ${ignoredSha}: "branch-only, not applicable to develop"\n`, 'utf8'); - const result = await mergeReport( - { - owner: 'acme', - repo: 'widget', - token: 't', - source: 'support/1.x', - targets: ['develop'], - gitDir: fixture.dir, - cachePath, - mergeIgnorePath: ignorePath, - excludeLabels: [], - }, - NOW, - ); + const result = await mergeReport({ ...baseOptions(fixture, cachePath), mergeIgnorePath: ignorePath }, NOW); expect(result.outstanding.map((entry) => entry.sha)).not.toContain(ignoredSha); }); - it('fans out across multiple targets, reporting each target independently', async () => { - await fixture.commit('init'); - await fixture.branch('support/1.x'); - await fixture.branch('support/2.x'); - await fixture.checkout('support/1.x'); - const onlyOnSource = await fixture.commit('fix: needed everywhere'); - - const result = await mergeReport( - { - owner: 'acme', - repo: 'widget', - token: 't', - source: 'support/1.x', - targets: ['develop', 'support/2.x'], - gitDir: fixture.dir, - cachePath, - excludeLabels: [], - }, - NOW, - ); - - const targets = result.outstanding.filter((entry) => entry.sha === onlyOnSource).map((entry) => entry.target); - expect(targets.sort()).toEqual(['develop', 'support/2.x']); - }); - it('sorts outstanding entries oldest-first by commit age', async () => { await fixture.commit('init'); await fixture.branch('support/1.x'); @@ -155,19 +181,7 @@ describe('mergeReport', () => { const older = await fixture.commit('fix: older'); const newer = await fixture.commit('fix: newer'); - const result = await mergeReport( - { - owner: 'acme', - repo: 'widget', - token: 't', - source: 'support/1.x', - targets: ['develop'], - gitDir: fixture.dir, - cachePath, - excludeLabels: [], - }, - NOW, - ); + const result = await mergeReport(baseOptions(fixture, cachePath), NOW); const shas = result.outstanding.map((entry) => entry.sha); expect(shas.indexOf(older)).toBeLessThan(shas.indexOf(newer)); diff --git a/test/topology.test.ts b/test/topology.test.ts new file mode 100644 index 0000000..7d0e03b --- /dev/null +++ b/test/topology.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { computeTopology } from '../src/topology.js'; + +const SUPPORT_PATTERN = /^support\/(\d+)\.x$/; + +describe('computeTopology', () => { + it('gives mainline every support branch as a source', () => { + const topology = computeTopology(['develop', 'support/1.x', 'support/2.x'], 'develop', SUPPORT_PATTERN); + + expect(topology.find((entry) => entry.target === 'develop')?.sources).toEqual(['support/1.x', 'support/2.x']); + }); + + it('gives support/N.x only lower-numbered support branches as sources', () => { + const topology = computeTopology(['develop', 'support/1.x', 'support/2.x', 'support/3.x'], 'develop', SUPPORT_PATTERN); + + expect(topology.find((entry) => entry.target === 'support/2.x')?.sources).toEqual(['support/1.x']); + expect(topology.find((entry) => entry.target === 'support/3.x')?.sources).toEqual(['support/1.x', 'support/2.x']); + }); + + it('gives the lowest surviving support branch no sources at all', () => { + const topology = computeTopology(['develop', 'support/1.x', 'support/2.x'], 'develop', SUPPORT_PATTERN); + + expect(topology.find((entry) => entry.target === 'support/1.x')?.sources).toEqual([]); + }); + + it('sorts by numeric version, not lexicographically', () => { + const topology = computeTopology(['develop', 'support/2.x', 'support/10.x'], 'develop', SUPPORT_PATTERN); + + expect(topology.find((entry) => entry.target === 'support/10.x')?.sources).toEqual(['support/2.x']); + }); + + it('adjusts automatically when a branch no longer exists, with nothing to edit', () => { + // support/2.x removed — support/3.x's sources drop it without any config change. + const topology = computeTopology(['develop', 'support/1.x', 'support/3.x'], 'develop', SUPPORT_PATTERN); + + expect(topology.find((entry) => entry.target === 'support/3.x')?.sources).toEqual(['support/1.x']); + }); + + it('omits mainline entirely when it does not exist among the given branches', () => { + const topology = computeTopology(['support/1.x'], 'develop', SUPPORT_PATTERN); + + expect(topology.find((entry) => entry.target === 'develop')).toBeUndefined(); + }); + + it('ignores a branch that matches neither mainline nor the support pattern', () => { + const topology = computeTopology(['develop', 'support/1.x', 'feature/unrelated'], 'develop', SUPPORT_PATTERN); + + expect(topology.map((entry) => entry.target)).toEqual(['develop', 'support/1.x']); + }); +}); From 3424df57a18c5090c353a512a8ffd8b9889b3d36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 21:47:21 +0000 Subject: [PATCH 3/3] fix(test): accept both Z and +00:00 forms of a UTC ISO 8601 offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitDate's %cI format renders a UTC commit date as trailing "Z" on the CI runner's git version and "+00:00" locally — both are valid ISO 8601, so the assertion should accept either instead of assuming one. --- test/git-cherry.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/git-cherry.test.ts b/test/git-cherry.test.ts index 32d6c77..f20ea2e 100644 --- a/test/git-cherry.test.ts +++ b/test/git-cherry.test.ts @@ -124,7 +124,9 @@ describe('commitDate', () => { const date = await commitDate(sha, { cwd: fixture.dir }); - expect(date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/); + // %cI is strict ISO 8601 — a UTC offset renders as trailing "Z" on some + // git versions and "+00:00" on others; both are valid, so accept either. + expect(date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/); expect(new Date(date).getUTCFullYear()).toBe(2024); await fixture.cleanup();