From 61d7c06263520442b14e948baec7a15f27a82508 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Thu, 23 Jul 2026 17:43:20 +0200 Subject: [PATCH 1/2] Warn on unusable scan bases and send scan metadata with sync --- src/index.ts | 31 ++++++----- src/scan-base.test.ts | 122 +++++++++++++++++++++++++++++++++++++++++- src/scan-base.ts | 49 +++++++++++++++++ 3 files changed, 185 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 711877c..5a35aa0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,14 @@ import { resolveCommitRef, verifyAncestorReachable, } from "./git"; -import { assertBaseRefIsAncestor, ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan } from "./scan-base"; +import { + assertBaseRefIsAncestor, + getBroadScanWarning, + getScanMetadata, + ScanBase, + selectAutomaticScanBase, + shouldCreateReleaseForScan, +} from "./scan-base"; import { scanCommits } from "./scan"; import { Release, @@ -305,6 +312,10 @@ async function syncCommand(): Promise<{ includePaths: effectiveIncludePaths, inspectSingleCommit: scanBase.kind !== "base-ref", }); + const broadScanWarning = getBroadScanWarning(commits.length, scanBase); + if (broadScanWarning) { + warn(broadScanWarning); + } if (inspectingOnlyCurrentCommit) { if (commits.length === 0) { @@ -384,6 +395,7 @@ async function syncCommand(): Promise<{ links, documents, releaseNotes, + getScanMetadata(scanBase, recentReleases.length, commits.length), ); info( `Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, @@ -541,21 +553,6 @@ function getScanBase(candidates: Release[], currentSha: string): ScanBase { if (scanBase.candidatesConsidered === 0) { verbose("No recent releases found; assuming first sync"); - } else { - // The candidate list came back non-empty but no entry is reachable from - // HEAD. This usually means orphaned/stale commitShas, but can also mean - // the actual previous release is older than the recent-releases page — - // in which case we'll silently under-cover. Surface it at warn level so - // it's visible in CI logs. - // Don't promise "current commit only" here — the actual fallback is - // resolveFirstSyncBoundary, which uses HEAD^1 when HEAD is a merge commit. - // The follow-up verbose lines below print the boundary that was chosen. - warn( - `No recent release is an ancestor of ${currentSha} (${scanBase.candidatesConsidered} ${pluralize( - scanBase.candidatesConsidered, - "candidate", - )} considered); falling back to the first-sync scan boundary`, - ); } // For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD // itself, so HEAD-only would miss them. Non-merge HEAD carries its own key. @@ -594,6 +591,7 @@ async function syncRelease( releaseLinks: ReleaseLink[], releaseDocuments: ReleaseDocument[], releaseNotesValue: ReleaseNotes | undefined, + scanMetadata: ReturnType, ): Promise { const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -646,6 +644,7 @@ async function syncRelease( } : undefined, debugSink, + ...scanMetadata, }, }, ); diff --git a/src/scan-base.test.ts b/src/scan-base.test.ts index 19499b3..b01e1a6 100644 --- a/src/scan-base.test.ts +++ b/src/scan-base.test.ts @@ -2,14 +2,19 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { getCommitContextsBetweenShas, resolveCommitRef, verifyAncestorReachable } from "./git"; +import * as log from "./log"; import { assertBaseRefIsAncestor, + BROAD_SCAN_COMMIT_THRESHOLD, + getBroadScanWarning, + getScanMetadata, type ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan, } from "./scan-base"; +import type { Release } from "./types"; function runGit(args: string[], cwd: string): string { return execFileSync("git", args, { @@ -87,6 +92,10 @@ describe("scan base selection", () => { rmSync(repo.cwd, { recursive: true, force: true }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("resolves git refs to commit SHAs", () => { expect(resolveCommitRef("api-start", repo.cwd)).toBe(repo.commits.api1); expect(resolveCommitRef("main~1", repo.cwd)).toBe(repo.commits.web); @@ -135,6 +144,117 @@ describe("scan base selection", () => { expect(shouldCreateReleaseForScan(0, scanBase)).toBe(false); }); + it("builds scan metadata for each scan-base type", () => { + expect(getScanMetadata({ kind: "release", sha: repo.commits.api1 }, 5, 3)).toEqual({ + scanBaseType: "release", + scanBaseCandidateCount: 5, + scannedCommitCount: 3, + }); + expect(getScanMetadata({ kind: "first-sync", sha: repo.commits.api1, candidatesConsidered: 2 }, 5, 3)).toEqual({ + scanBaseType: "first-sync", + scanBaseCandidateCount: 2, + scannedCommitCount: 3, + }); + expect(getScanMetadata({ kind: "base-ref", sha: repo.commits.api1, ref: "api-start" }, 5, 3)).toEqual({ + scanBaseType: "base-ref", + scanBaseCandidateCount: 5, + scannedCommitCount: 3, + }); + }); + + it("warns when every SHA-bearing candidate is rejected", () => { + const warn = vi.spyOn(log, "warn"); + + selectAutomaticScanBase( + [ + { + id: "stale-release", + name: "stale release", + createdAt: new Date().toISOString(), + commitSha: repo.commits.stale, + }, + ], + repo.commits.head, + deps, + repo.cwd, + ); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("use one pipeline per repository")); + }); + + it("warns when candidates do not carry commit SHAs", () => { + const warn = vi.spyOn(log, "warn"); + + selectAutomaticScanBase( + [ + { + id: "manual-release", + name: "manual release", + createdAt: new Date().toISOString(), + }, + ], + repo.commits.head, + deps, + repo.cwd, + ); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("None of the last 1 releases carry a commit SHA")); + }); + + it("warns when GraphQL candidates have null commit SHAs", () => { + const warn = vi.spyOn(log, "warn"); + const manualRelease = { + id: "manual-release", + name: "manual release", + createdAt: new Date().toISOString(), + commitSha: null, + } as unknown as Release; + + selectAutomaticScanBase([manualRelease], repo.commits.head, deps, repo.cwd); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("they were likely created manually")); + }); + + it("does not warn on a genuine first sync", () => { + const warn = vi.spyOn(log, "warn"); + + selectAutomaticScanBase([], repo.commits.head, deps, repo.cwd); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not warn when it finds a reachable release anchor", () => { + const warn = vi.spyOn(log, "warn"); + + selectAutomaticScanBase( + [ + { + id: "reachable-release", + name: "reachable release", + createdAt: new Date().toISOString(), + commitSha: repo.commits.api1, + }, + ], + repo.commits.head, + deps, + repo.cwd, + ); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns only when scans exceed the broad-scan threshold", () => { + const scanBase: ScanBase = { kind: "release", sha: repo.commits.api1 }; + const baseRefScanBase: ScanBase = { kind: "base-ref", sha: repo.commits.api1, ref: "release-start" }; + + expect(getBroadScanWarning(BROAD_SCAN_COMMIT_THRESHOLD, scanBase)).toBeUndefined(); + expect(getBroadScanWarning(BROAD_SCAN_COMMIT_THRESHOLD + 1, scanBase)).toContain("Scanning 101 commits"); + expect(getBroadScanWarning(BROAD_SCAN_COMMIT_THRESHOLD + 1, baseRefScanBase)).toContain( + "linked to the target release. This range was explicitly requested.", + ); + expect(getBroadScanWarning(BROAD_SCAN_COMMIT_THRESHOLD + 1, baseRefScanBase)).not.toContain("Pass --version"); + }); + it("fails clearly for refs that do not resolve to a commit", () => { expect(() => resolveCommitRef("missing-ref", repo.cwd)).toThrow('Could not resolve "missing-ref"'); }); diff --git a/src/scan-base.ts b/src/scan-base.ts index 1bebe62..734e6d2 100644 --- a/src/scan-base.ts +++ b/src/scan-base.ts @@ -1,5 +1,6 @@ import { findBaseSha, FindBaseShaDeps } from "./base-sha"; import { resolveFirstSyncBoundary } from "./git"; +import { warn } from "./log"; import type { Release } from "./types"; export type ScanBase = @@ -7,6 +8,14 @@ export type ScanBase = | { kind: "first-sync"; sha: string; candidatesConsidered: number } | { kind: "base-ref"; sha: string; ref: string }; +export type ScanMetadata = { + scanBaseType: ScanBase["kind"]; + scanBaseCandidateCount: number; + scannedCommitCount: number; +}; + +export const BROAD_SCAN_COMMIT_THRESHOLD = 100; + export function selectAutomaticScanBase( candidates: Release[], currentSha: string, @@ -18,6 +27,17 @@ export function selectAutomaticScanBase( return { kind: "release", sha: result.sha }; } + const shaBearingCandidates = candidates.filter((candidate) => Boolean(candidate.commitSha)).length; + if (shaBearingCandidates > 0) { + warn( + `None of the last ${shaBearingCandidates} synced releases' commit SHAs exist in this repository's history. Falling back to a fresh scan — previously shipped issues may be re-linked. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.`, + ); + } else if (candidates.length > 0) { + warn( + `None of the last ${candidates.length} releases carry a commit SHA (they were likely created manually). Falling back to a fresh scan — previously shipped issues may be re-linked. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.`, + ); + } + return { kind: "first-sync", sha: resolveFirstSyncBoundary(currentSha, cwd), @@ -46,3 +66,32 @@ export function assertBaseRefIsAncestor( export function shouldCreateReleaseForScan(commitsLength: number, scanBase: ScanBase): boolean { return commitsLength > 0 || scanBase.kind === "base-ref"; } + +/** + * Builds scan metadata sent with a sync request. + */ +export function getScanMetadata( + scanBase: ScanBase, + recentReleaseCount: number, + scannedCommitCount: number, +): ScanMetadata { + return { + scanBaseType: scanBase.kind, + scanBaseCandidateCount: scanBase.kind === "first-sync" ? scanBase.candidatesConsidered : recentReleaseCount, + scannedCommitCount, + }; +} + +export function getBroadScanWarning(commitsLength: number, scanBase: ScanBase): string | undefined { + if (commitsLength <= BROAD_SCAN_COMMIT_THRESHOLD) { + return undefined; + } + + if (scanBase.kind === "base-ref") { + return `Scanning ${commitsLength} commits from --base-ref ${scanBase.ref} (${scanBase.sha.slice(0, 7)}). Issues referenced anywhere in this range, including work already shipped, will be linked to the target release. This range was explicitly requested.`; + } + + const range = scanBase.kind === "release" ? `release anchor ${scanBase.sha.slice(0, 7)}` : "first-sync fallback"; + + return `Scanning ${commitsLength} commits from ${range}. Issues referenced anywhere in this range, including work already shipped, will be linked to the target release. This range was selected automatically. Pass --version and verify the scan base.`; +} From 9ab85d59998a09f1d19fe79f8c5410ccf708ae3a Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Fri, 24 Jul 2026 08:52:57 +0200 Subject: [PATCH 2/2] Scan only the current commit when release anchors are unusable --- src/index.ts | 4 -- src/scan-base.test.ts | 91 ++++++++++++++++++++++++++++++++++--------- src/scan-base.ts | 33 +++++----------- 3 files changed, 82 insertions(+), 46 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5a35aa0..ae6a256 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,6 @@ import { import { assertBaseRefIsAncestor, getBroadScanWarning, - getScanMetadata, ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan, @@ -395,7 +394,6 @@ async function syncCommand(): Promise<{ links, documents, releaseNotes, - getScanMetadata(scanBase, recentReleases.length, commits.length), ); info( `Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, @@ -591,7 +589,6 @@ async function syncRelease( releaseLinks: ReleaseLink[], releaseDocuments: ReleaseDocument[], releaseNotesValue: ReleaseNotes | undefined, - scanMetadata: ReturnType, ): Promise { const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -644,7 +641,6 @@ async function syncRelease( } : undefined, debugSink, - ...scanMetadata, }, }, ); diff --git a/src/scan-base.test.ts b/src/scan-base.test.ts index b01e1a6..147547f 100644 --- a/src/scan-base.test.ts +++ b/src/scan-base.test.ts @@ -9,7 +9,6 @@ import { assertBaseRefIsAncestor, BROAD_SCAN_COMMIT_THRESHOLD, getBroadScanWarning, - getScanMetadata, type ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan, @@ -78,6 +77,25 @@ function createGitFlowHotfixRepo() { return { cwd, commits: { previousRelease, forkPoint, head } }; } +function createMergeRepo() { + const cwd = mkdtempSync(join(tmpdir(), "linear-release-scan-base-merge-")); + runGit(["init", "-q", "-b", "main"], cwd); + runGit(["config", "user.email", "test@example.com"], cwd); + runGit(["config", "user.name", "Test User"], cwd); + + const root = commit(cwd, "README.md", "root", "root"); + runGit(["checkout", "-q", "-b", "stale", root], cwd); + const stale = commit(cwd, "stale.txt", "stale", "stale release"); + runGit(["checkout", "-q", "-b", "feature", root], cwd); + commit(cwd, "feature.txt", "feature", "feature commit"); + runGit(["checkout", "-q", "main"], cwd); + const firstParent = commit(cwd, "main.txt", "main", "main commit"); + runGit(["merge", "-q", "--no-ff", "feature", "-m", "merge feature"], cwd); + const mergeCommit = runGit(["rev-parse", "HEAD"], cwd); + + return { cwd, commits: { stale, firstParent, mergeCommit } }; +} + describe("scan base selection", () => { let repo: ReturnType; const deps = { @@ -144,22 +162,53 @@ describe("scan base selection", () => { expect(shouldCreateReleaseForScan(0, scanBase)).toBe(false); }); - it("builds scan metadata for each scan-base type", () => { - expect(getScanMetadata({ kind: "release", sha: repo.commits.api1 }, 5, 3)).toEqual({ - scanBaseType: "release", - scanBaseCandidateCount: 5, - scannedCommitCount: 3, - }); - expect(getScanMetadata({ kind: "first-sync", sha: repo.commits.api1, candidatesConsidered: 2 }, 5, 3)).toEqual({ - scanBaseType: "first-sync", - scanBaseCandidateCount: 2, - scannedCommitCount: 3, - }); - expect(getScanMetadata({ kind: "base-ref", sha: repo.commits.api1, ref: "api-start" }, 5, 3)).toEqual({ - scanBaseType: "base-ref", - scanBaseCandidateCount: 5, - scannedCommitCount: 3, - }); + it("scans only a merge commit when release candidates cannot be used", () => { + const mergeRepo = createMergeRepo(); + const mergeDeps = { + verifyAncestorReachable: (sha: string, headSha: string) => verifyAncestorReachable(sha, headSha, mergeRepo.cwd), + }; + + try { + const scanBase = selectAutomaticScanBase( + [ + { + id: "stale-release", + name: "stale release", + createdAt: new Date().toISOString(), + commitSha: mergeRepo.commits.stale, + }, + ], + mergeRepo.commits.mergeCommit, + mergeDeps, + mergeRepo.cwd, + ); + + expect(scanBase).toEqual({ + kind: "first-sync", + sha: mergeRepo.commits.mergeCommit, + candidatesConsidered: 1, + }); + expect(scanBase.sha).not.toBe(mergeRepo.commits.firstParent); + } finally { + rmSync(mergeRepo.cwd, { recursive: true, force: true }); + } + }); + + it("uses the first-sync boundary when there are no release candidates", () => { + const mergeRepo = createMergeRepo(); + const mergeDeps = { + verifyAncestorReachable: (sha: string, headSha: string) => verifyAncestorReachable(sha, headSha, mergeRepo.cwd), + }; + + try { + expect(selectAutomaticScanBase([], mergeRepo.commits.mergeCommit, mergeDeps, mergeRepo.cwd)).toEqual({ + kind: "first-sync", + sha: mergeRepo.commits.firstParent, + candidatesConsidered: 0, + }); + } finally { + rmSync(mergeRepo.cwd, { recursive: true, force: true }); + } }); it("warns when every SHA-bearing candidate is rejected", () => { @@ -179,7 +228,9 @@ describe("scan base selection", () => { repo.cwd, ); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("use one pipeline per repository")); + expect(warn).toHaveBeenCalledWith( + "None of the last 1 synced releases' commit SHAs exist in this repository's history. Syncing only the current commit until a scan base can be established. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.", + ); }); it("warns when candidates do not carry commit SHAs", () => { @@ -198,7 +249,9 @@ describe("scan base selection", () => { repo.cwd, ); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("None of the last 1 releases carry a commit SHA")); + expect(warn).toHaveBeenCalledWith( + "None of the last 1 releases carry a commit SHA (they were likely created manually). Syncing only the current commit until a scan base can be established. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.", + ); }); it("warns when GraphQL candidates have null commit SHAs", () => { diff --git a/src/scan-base.ts b/src/scan-base.ts index 734e6d2..e8fe70a 100644 --- a/src/scan-base.ts +++ b/src/scan-base.ts @@ -8,12 +8,6 @@ export type ScanBase = | { kind: "first-sync"; sha: string; candidatesConsidered: number } | { kind: "base-ref"; sha: string; ref: string }; -export type ScanMetadata = { - scanBaseType: ScanBase["kind"]; - scanBaseCandidateCount: number; - scannedCommitCount: number; -}; - export const BROAD_SCAN_COMMIT_THRESHOLD = 100; export function selectAutomaticScanBase( @@ -30,14 +24,22 @@ export function selectAutomaticScanBase( const shaBearingCandidates = candidates.filter((candidate) => Boolean(candidate.commitSha)).length; if (shaBearingCandidates > 0) { warn( - `None of the last ${shaBearingCandidates} synced releases' commit SHAs exist in this repository's history. Falling back to a fresh scan — previously shipped issues may be re-linked. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.`, + `None of the last ${shaBearingCandidates} synced releases' commit SHAs exist in this repository's history. Syncing only the current commit until a scan base can be established. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.`, ); } else if (candidates.length > 0) { warn( - `None of the last ${candidates.length} releases carry a commit SHA (they were likely created manually). Falling back to a fresh scan — previously shipped issues may be re-linked. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.`, + `None of the last ${candidates.length} releases carry a commit SHA (they were likely created manually). Syncing only the current commit until a scan base can be established. If this pipeline receives syncs from multiple repositories, use one pipeline per repository; otherwise pass --base-ref to pin the scan range.`, ); } + if (candidates.length > 0) { + return { + kind: "first-sync", + sha: currentSha, + candidatesConsidered: candidates.length, + }; + } + return { kind: "first-sync", sha: resolveFirstSyncBoundary(currentSha, cwd), @@ -67,21 +69,6 @@ export function shouldCreateReleaseForScan(commitsLength: number, scanBase: Scan return commitsLength > 0 || scanBase.kind === "base-ref"; } -/** - * Builds scan metadata sent with a sync request. - */ -export function getScanMetadata( - scanBase: ScanBase, - recentReleaseCount: number, - scannedCommitCount: number, -): ScanMetadata { - return { - scanBaseType: scanBase.kind, - scanBaseCandidateCount: scanBase.kind === "first-sync" ? scanBase.candidatesConsidered : recentReleaseCount, - scannedCommitCount, - }; -} - export function getBroadScanWarning(commitsLength: number, scanBase: ScanBase): string | undefined { if (commitsLength <= BROAD_SCAN_COMMIT_THRESHOLD) { return undefined;