diff --git a/src/index.ts b/src/index.ts index 711877c..ae6a256 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,13 @@ import { resolveCommitRef, verifyAncestorReachable, } from "./git"; -import { assertBaseRefIsAncestor, ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan } from "./scan-base"; +import { + assertBaseRefIsAncestor, + getBroadScanWarning, + ScanBase, + selectAutomaticScanBase, + shouldCreateReleaseForScan, +} from "./scan-base"; import { scanCommits } from "./scan"; import { Release, @@ -305,6 +311,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) { @@ -541,21 +551,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. diff --git a/src/scan-base.test.ts b/src/scan-base.test.ts index 19499b3..147547f 100644 --- a/src/scan-base.test.ts +++ b/src/scan-base.test.ts @@ -2,14 +2,18 @@ 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, type ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan, } from "./scan-base"; +import type { Release } from "./types"; function runGit(args: string[], cwd: string): string { return execFileSync("git", args, { @@ -73,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 = { @@ -87,6 +110,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 +162,152 @@ describe("scan base selection", () => { expect(shouldCreateReleaseForScan(0, scanBase)).toBe(false); }); + 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", () => { + 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( + "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", () => { + 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( + "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", () => { + 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..e8fe70a 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,8 @@ export type ScanBase = | { kind: "first-sync"; sha: string; candidatesConsidered: number } | { kind: "base-ref"; sha: string; ref: string }; +export const BROAD_SCAN_COMMIT_THRESHOLD = 100; + export function selectAutomaticScanBase( candidates: Release[], currentSha: string, @@ -18,6 +21,25 @@ 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. 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). 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), @@ -46,3 +68,17 @@ export function assertBaseRefIsAncestor( export function shouldCreateReleaseForScan(commitsLength: number, scanBase: ScanBase): boolean { return commitsLength > 0 || scanBase.kind === "base-ref"; } + +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.`; +}