|
1 | 1 | import { LinearClient, LinearClientOptions } from "@linear/sdk"; |
2 | 2 | import { |
3 | 3 | assertGitAvailable, |
| 4 | + commitExists, |
4 | 5 | ensureCommitAvailable, |
5 | 6 | getCommitContextsBetweenShas, |
6 | 7 | getCurrentGitInfo, |
7 | 8 | getRepoInfo, |
| 9 | + isAncestor, |
8 | 10 | resolveFirstSyncBoundary, |
9 | 11 | } from "./git"; |
| 12 | +import { findBaseSha } from "./base-sha"; |
10 | 13 | import { scanCommits } from "./scan"; |
11 | 14 | import { |
12 | 15 | Release, |
13 | | - AccessKeyLatestReleaseResponse, |
14 | 16 | AccessKeyPipelineSettingsResponse, |
| 17 | + AccessKeyRecentReleasesResponse, |
15 | 18 | AccessKeySyncReleaseResponse, |
16 | 19 | AccessKeyCompleteReleaseResponse, |
17 | 20 | AccessKeyUpdateByPipelineResponse, |
@@ -316,47 +319,63 @@ async function updateCommand(): Promise<{ |
316 | 319 | : null; |
317 | 320 | } |
318 | 321 |
|
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>( |
321 | 327 | ` |
322 | | - query latestReleaseByAccessKey { |
323 | | - latestReleaseByAccessKey { |
| 328 | + query recentReleasesByAccessKey($limit: Int) { |
| 329 | + recentReleasesByAccessKey(limit: $limit) { |
324 | 330 | id |
325 | 331 | name |
326 | 332 | createdAt |
327 | 333 | commitSha |
328 | 334 | } |
329 | 335 | } |
330 | 336 | `, |
| 337 | + { limit: 20 }, |
331 | 338 | ); |
332 | 339 |
|
333 | | - return response.data.latestReleaseByAccessKey; |
| 340 | + return response.data.recentReleasesByAccessKey; |
334 | 341 | } |
335 | 342 |
|
336 | 343 | 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; |
349 | 345 | if (!currentSha) { |
350 | 346 | throw new Error("Could not get current commit"); |
351 | 347 | } |
352 | 348 |
|
| 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 | + } |
353 | 372 | // For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD |
354 | 373 | // itself, so HEAD-only would miss them. Non-merge HEAD carries its own key. |
355 | 374 | const boundary = resolveFirstSyncBoundary(currentSha); |
356 | 375 | 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`); |
358 | 377 | } else { |
359 | | - verbose("First sync: only inspecting current commit"); |
| 378 | + verbose("Inspecting current commit only"); |
360 | 379 | } |
361 | 380 | return boundary; |
362 | 381 | } |
|
0 commit comments