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..630f749 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,125 @@ 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. + +### 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 # 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: + path: .gitflow-changelog-cache.json + key: gitflow-changelog-v1-${{ github.repository }} # same cache the changelog action uses + +- uses: aklivity/gitflow-changelog/merge-report@v1 +``` + +Run this on a schedule (weekly, say) plus `workflow_dispatch`, not on every +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. + +### 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" \ + --target support/2.x --sources support/1.x +``` + +### 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 +344,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..5cdef4f --- /dev/null +++ b/merge-report/action.yml @@ -0,0 +1,113 @@ +name: gitflow-changelog merge-report +description: >- + 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 + 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 }} + git-dir: + description: >- + 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: >- + 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. + exclude-labels is shared with it (a PR/issue labeled e.g. + "dependencies" is skipped the same way it's excluded from the + 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: + 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 + 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 + 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, 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' + +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..6902a78 100644 --- a/src/git.ts +++ b/src/git.ts @@ -224,3 +224,67 @@ 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(); +} + +// Every local branch and every origin remote-tracking branch, deduped down +// to short names (the "origin/" prefix stripped) and filtered to `pattern`. +// A typical CI checkout only has one local branch, with everything else +// present as origin/ 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-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..a3cfeb0 --- /dev/null +++ b/src/merge-report-action.ts @@ -0,0 +1,51 @@ +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 }), + 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 }); + + 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').addRaw(markdown).write(); + + const stale = result.outstanding.filter((entry) => entry.ageDays > failAfterDays); + if (stale.length > 0) + { + core.setFailed( + `${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.`, + ); + } +} + +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..13ddba9 --- /dev/null +++ b/src/merge-report-cli.ts @@ -0,0 +1,46 @@ +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' }, + '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' }, + }, + }); + + const options = await toMergeReportOptions({ + owner: values.owner, + repo: values.repo, + token: values.token ?? process.env.GITHUB_TOKEN, + 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 }); + + 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..0ecdeec --- /dev/null +++ b/src/merge-report-config.ts @@ -0,0 +1,70 @@ +import { loadRepoConfig } from './repo-config.js'; +import type { MergeReportOptions } from './merge-report.js'; + +export interface RawMergeReportInputs { + owner?: string; + repo?: string; + token?: 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[] { + 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'); + } + + 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. mainline-branch/support-branch-pattern are merge-report-only, + // but still repo-wide policy in the same sense tag-pattern is, so they + // live alongside it rather than in a third place. + const fileConfig = await loadRepoConfig(gitDir, raw.configPath ?? '.gitflow-changelog.yml'); + + const sources = splitList(raw.sources); + if (sources.length > 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, + 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 new file mode 100644 index 0000000..1d31d9e --- /dev/null +++ b/src/merge-report.ts @@ -0,0 +1,101 @@ +import { loadCache, saveCache } from './cache.js'; +import { excludedShas, updateCache } from './drivers/github.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; + 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 + // 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)); +} + +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); + await saveCache(options.cachePath, cache); + + const excluded = excludedShas(cache, options.excludeLabels); + const ignored = await loadMergeIgnore(options.mergeIgnorePath); + const topology = await discoverTopology(options); + + const outstanding: MergeReportEntry[] = []; + for (const { target, sources } of topology) + { + const targetRef = await resolveRef(target, { cwd: options.gitDir }); + for (const source of sources) + { + const sourceRef = await resolveRef(source, { cwd: options.gitDir }); + const candidates = await unmatchedCommits(targetRef, sourceRef, { 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, source, sha: candidate.sha, subject: candidate.subject, ageDays: daysSince(date, now) }); + } + } + } + + outstanding.sort((a, b) => b.ageDays - a.ageDays); + return { topology, outstanding }; +} diff --git a/src/render/merge-report.ts b/src/render/merge-report.ts new file mode 100644 index 0000000..6e3ed4e --- /dev/null +++ b/src/render/merge-report.ts @@ -0,0 +1,54 @@ +import { escapeTitle } from './default.js'; +import type { MergeReportEntry, MergeReportResult } from '../merge-report.js'; + +export interface MergeReportRenderOptions { + owner: string; + repo: 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.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. 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', '']; + + for (const { target, sources } of result.topology) + { + const entries = result.outstanding.filter((entry) => entry.target === target); + lines.push(`## ${target}`, ''); + + 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').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 new file mode 100644 index 0000000..f20ea2e --- /dev/null +++ b/test/git-cherry.test.ts @@ -0,0 +1,134 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { commitDate, listBranches, resolveRef, 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('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(); + const sha = await fixture.commit('init'); + + const date = await commitDate(sha, { cwd: fixture.dir }); + + // %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(); + }); +}); 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..661a492 --- /dev/null +++ b/test/merge-report.test.ts @@ -0,0 +1,192 @@ +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 type { MergeReportOptions } 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); +const SUPPORT_PATTERN = /^support\/(\d+)\.x$/; + +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(); +} + +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; + 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 its 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(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'); + 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({ ...baseOptions(fixture, 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({ ...baseOptions(fixture, cachePath), mergeIgnorePath: ignorePath }, NOW); + + expect(result.outstanding.map((entry) => entry.sha)).not.toContain(ignoredSha); + }); + + 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(baseOptions(fixture, cachePath), 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); + }); +}); 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']); + }); +});