Skip to content

Commit c756b72

Browse files
committed
Walk recent releases to pick base SHA on sync
Replace `getLatestRelease` + verbatim-trust logic with `recentReleasesByAccessKey` (limit pinned at 20 via a GraphQL variable) and let findBaseSha pick the first ancestor of HEAD. Falls back to the existing first-sync boundary when no candidate matches; surface that fall-through at warn level when the candidate list was non-empty so the case shows up in CI logs.
1 parent 31c4ec1 commit c756b72

2 files changed

Lines changed: 41 additions & 22 deletions

File tree

src/index.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import { LinearClient, LinearClientOptions } from "@linear/sdk";
22
import {
33
assertGitAvailable,
4+
commitExists,
45
ensureCommitAvailable,
56
getCommitContextsBetweenShas,
67
getCurrentGitInfo,
78
getRepoInfo,
9+
isAncestor,
810
resolveFirstSyncBoundary,
911
} from "./git";
12+
import { findBaseSha } from "./base-sha";
1013
import { scanCommits } from "./scan";
1114
import {
1215
Release,
13-
AccessKeyLatestReleaseResponse,
1416
AccessKeyPipelineSettingsResponse,
17+
AccessKeyRecentReleasesResponse,
1518
AccessKeySyncReleaseResponse,
1619
AccessKeyCompleteReleaseResponse,
1720
AccessKeyUpdateByPipelineResponse,
@@ -316,47 +319,63 @@ async function updateCommand(): Promise<{
316319
: null;
317320
}
318321

319-
async function getLatestRelease(): Promise<Release | null> {
320-
const response = await apiRequest<AccessKeyLatestReleaseResponse>(
322+
async function getRecentReleases(): Promise<Release[]> {
323+
// Pin the limit explicitly rather than relying on the server default — the
324+
// walk's correctness depends on the right ancestor being in this page, so
325+
// the cap is a meaningful contract, not an implementation detail.
326+
const response = await apiRequest<AccessKeyRecentReleasesResponse>(
321327
`
322-
query latestReleaseByAccessKey {
323-
latestReleaseByAccessKey {
328+
query recentReleasesByAccessKey($limit: Int) {
329+
recentReleasesByAccessKey(limit: $limit) {
324330
id
325331
name
326332
createdAt
327333
commitSha
328334
}
329335
}
330336
`,
337+
{ limit: 20 },
331338
);
332339

333-
return response.data.latestReleaseByAccessKey;
340+
return response.data.recentReleasesByAccessKey;
334341
}
335342

336343
async function getLatestSha(): Promise<string> {
337-
const latestRelease = await getLatestRelease();
338-
const latestSha = latestRelease?.commitSha;
339-
if (latestSha) {
340-
return latestSha;
341-
}
342-
343-
if (!latestRelease) {
344-
verbose("Could not find latest release, assuming it's the first release");
345-
} else if (!latestRelease.commitSha) {
346-
verbose("Latest release has no commit SHA");
347-
}
348-
const currentSha = await getCurrentGitInfo().commit;
344+
const currentSha = getCurrentGitInfo().commit;
349345
if (!currentSha) {
350346
throw new Error("Could not get current commit");
351347
}
352348

349+
const candidates = await getRecentReleases();
350+
const result = findBaseSha(candidates, currentSha, { isAncestor, commitExists, ensureCommitAvailable });
351+
if (result.kind === "found") {
352+
return result.sha;
353+
}
354+
355+
if (candidates.length === 0) {
356+
verbose("No recent releases found; assuming first sync");
357+
} else {
358+
// The candidate list came back non-empty but no entry is reachable from
359+
// HEAD. This usually means orphaned/stale commitShas, but can also mean
360+
// the actual previous release is older than the recent-releases page —
361+
// in which case we'll silently under-cover. Surface it at warn level so
362+
// it's visible in CI logs.
363+
// Don't promise "current commit only" here — the actual fallback is
364+
// resolveFirstSyncBoundary, which uses HEAD^1 when HEAD is a merge commit.
365+
// The follow-up verbose lines below print the boundary that was chosen.
366+
warn(
367+
`No recent release is an ancestor of ${currentSha} (${candidates.length} candidate${
368+
candidates.length === 1 ? "" : "s"
369+
} considered); falling back to the first-sync scan boundary`,
370+
);
371+
}
353372
// For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD
354373
// itself, so HEAD-only would miss them. Non-merge HEAD carries its own key.
355374
const boundary = resolveFirstSyncBoundary(currentSha);
356375
if (boundary !== currentSha) {
357-
verbose(`First sync on merge HEAD: using HEAD^1 (${boundary}) as the scan boundary`);
376+
verbose(`Merge HEAD: using HEAD^1 (${boundary}) as the scan boundary`);
358377
} else {
359-
verbose("First sync: only inspecting current commit");
378+
verbose("Inspecting current commit only");
360379
}
361380
return boundary;
362381
}

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ export type Release = {
99
};
1010

1111
// Access key endpoint response types
12-
export type AccessKeyLatestReleaseResponse = {
12+
export type AccessKeyRecentReleasesResponse = {
1313
data: {
14-
latestReleaseByAccessKey: Release | null;
14+
recentReleasesByAccessKey: Release[];
1515
};
1616
};
1717

0 commit comments

Comments
 (0)