pr-shepherd 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +17 -12
  3. package/bin/cli/help-command-pages.d.mts +1 -1
  4. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  5. package/bin/cli/help-iterate-poll-pages.mjs +4 -3
  6. package/bin/cli/help-top-page.d.mts +1 -1
  7. package/bin/cli/help-top-page.mjs +3 -2
  8. package/bin/cli/help.d.mts +2 -2
  9. package/bin/cli/iterate-instructions.mjs +22 -0
  10. package/bin/cli/iterate-lean.mjs +1 -0
  11. package/bin/cli/poll-summary-formatter.mjs +4 -3
  12. package/bin/cli/runner.d.mts +1 -0
  13. package/bin/cli/runner.mjs +3 -1
  14. package/bin/commands/check.mjs +13 -4
  15. package/bin/commands/clean.mjs +7 -13
  16. package/bin/commands/iterate/api-usage.mjs +2 -2
  17. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  18. package/bin/commands/iterate/check-instructions.mjs +11 -7
  19. package/bin/commands/iterate/escalate.mjs +4 -0
  20. package/bin/commands/iterate/fix-code.mjs +6 -1
  21. package/bin/commands/iterate/helpers.d.mts +0 -1
  22. package/bin/commands/iterate/helpers.mjs +0 -15
  23. package/bin/commands/iterate/index.mjs +48 -27
  24. package/bin/commands/iterate/merge-state.mjs +6 -4
  25. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  26. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  27. package/bin/commands/iterate/parent-first.d.mts +6 -7
  28. package/bin/commands/iterate/parent-first.mjs +10 -54
  29. package/bin/commands/iterate/render.d.mts +1 -1
  30. package/bin/commands/iterate/render.mjs +7 -12
  31. package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
  32. package/bin/commands/iterate/stale-ancestry.mjs +10 -7
  33. package/bin/commands/iterate/stall.mjs +40 -3
  34. package/bin/commands/poll-progress.d.mts +5 -1
  35. package/bin/commands/poll-progress.mjs +7 -1
  36. package/bin/commands/poll-quota.mjs +2 -2
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +71 -122
  39. package/bin/commands/poll-summary.mjs +4 -2
  40. package/bin/commands/ready-delay.d.mts +9 -4
  41. package/bin/commands/ready-delay.mjs +34 -20
  42. package/bin/commands/shepherd-journal.mjs +4 -1
  43. package/bin/commands/stack-drain.d.mts +35 -0
  44. package/bin/commands/stack-drain.mjs +138 -0
  45. package/bin/commands/stack-layer-readiness.d.mts +6 -0
  46. package/bin/commands/stack-layer-readiness.mjs +34 -0
  47. package/bin/commands/stack-stall.d.mts +14 -0
  48. package/bin/commands/stack-stall.mjs +68 -0
  49. package/bin/commands/stack-work.d.mts +32 -0
  50. package/bin/commands/stack-work.mjs +38 -0
  51. package/bin/config/load.d.mts +2 -0
  52. package/bin/config/load.mjs +10 -0
  53. package/bin/config.json +1 -0
  54. package/bin/github/api-telemetry-aggregate.d.mts +1 -0
  55. package/bin/github/api-telemetry-aggregate.mjs +8 -2
  56. package/bin/github/api-telemetry.d.mts +4 -0
  57. package/bin/github/api-telemetry.mjs +12 -0
  58. package/bin/github/batch-parsers.mjs +1 -1
  59. package/bin/github/batch-raw-rules.d.mts +0 -3
  60. package/bin/github/batch-raw-types.d.mts +2 -0
  61. package/bin/github/errors.d.mts +5 -0
  62. package/bin/github/errors.mjs +4 -0
  63. package/bin/github/gql/batch-pr.gql +1 -0
  64. package/bin/github/gql/poll-stack-summary.gql +8 -2
  65. package/bin/github/gql/poll-stack-topology.gql +38 -0
  66. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  67. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  68. package/bin/github/gql/poll-summary-fragment.gql +5 -62
  69. package/bin/github/gql/pr-merge-policy.gql +0 -3
  70. package/bin/github/graphql-http.mjs +6 -0
  71. package/bin/github/http-auth.d.mts +3 -0
  72. package/bin/github/http-auth.mjs +6 -0
  73. package/bin/github/http-intermediate.d.mts +1 -0
  74. package/bin/github/http-intermediate.mjs +3 -0
  75. package/bin/github/merge-queue-checks.mjs +10 -1
  76. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  77. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  78. package/bin/github/poll-summary-fingerprint.mjs +24 -4
  79. package/bin/github/poll-summary-projector.mjs +8 -7
  80. package/bin/github/poll-summary-queue-removal.mjs +10 -1
  81. package/bin/github/poll-summary-raw.d.mts +19 -31
  82. package/bin/github/poll-summary-route.mjs +8 -5
  83. package/bin/github/poll-summary.mjs +16 -73
  84. package/bin/github/queries.d.mts +7 -0
  85. package/bin/github/queries.mjs +9 -1
  86. package/bin/github/queue-removal-freshness.d.mts +16 -0
  87. package/bin/github/queue-removal-freshness.mjs +26 -0
  88. package/bin/github/stack-read.d.mts +34 -0
  89. package/bin/github/stack-read.mjs +92 -0
  90. package/bin/log/log-file.d.mts +1 -1
  91. package/bin/log/log-file.mjs +4 -17
  92. package/bin/state/base.d.mts +18 -1
  93. package/bin/state/base.mjs +65 -13
  94. package/bin/state/fix-attempts.d.mts +1 -1
  95. package/bin/state/fix-attempts.mjs +1 -1
  96. package/bin/state/graphql-quota-policy.d.mts +7 -1
  97. package/bin/state/graphql-quota-policy.mjs +41 -14
  98. package/bin/state/graphql-quota-warnings.mjs +4 -6
  99. package/bin/state/iterate-stall.d.mts +7 -15
  100. package/bin/state/iterate-stall.mjs +6 -64
  101. package/bin/state/rest-cache.d.mts +1 -1
  102. package/bin/state/rest-cache.mjs +1 -1
  103. package/bin/state/stack-stall.d.mts +16 -0
  104. package/bin/state/stack-stall.mjs +12 -0
  105. package/bin/state/stall-state-store.d.mts +37 -0
  106. package/bin/state/stall-state-store.mjs +74 -0
  107. package/bin/types/api-usage.d.mts +2 -0
  108. package/bin/types/escalate.d.mts +1 -1
  109. package/bin/types/github.d.mts +1 -1
  110. package/bin/types/iterate.d.mts +2 -1
  111. package/bin/types/merge-requirements.d.mts +9 -0
  112. package/bin/types/poll-summary.d.mts +5 -2
  113. package/bin/types/report.d.mts +1 -1
  114. package/package.json +1 -1
  115. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  116. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  117. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,4 +1,6 @@
1
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
1
2
  import { graphql } from "./client.mjs";
3
+ import { queueRemovalAppliesToHead } from "./queue-removal-freshness.mjs";
2
4
  import { requireContextNodes } from "./batch-response.mjs";
3
5
  import { COMMIT_CHECK_CONTEXTS_QUERY } from "./queries.mjs";
4
6
  function omittedCursorError(oid) {
@@ -60,8 +62,15 @@ function currentRemovalCommit(raw) {
60
62
  if (addition && Date.parse(removal.createdAt) < Date.parse(addition.createdAt))
61
63
  return undefined;
62
64
  const parentOids = removal.beforeCommit.parents?.nodes.map((node) => node.oid);
63
- if (!parentOids?.includes(raw.headRefOid))
65
+ const headCommittedAt = raw.commits?.nodes[0]?.commit.committedDate;
66
+ if (!queueRemovalAppliesToHead({
67
+ parentOids,
68
+ headOid: raw.headRefOid,
69
+ ...(headCommittedAt && { headCommittedAtUnix: parseCreatedAt(headCommittedAt) }),
70
+ removedAtUnix: Math.floor(Date.parse(removal.createdAt) / 1000),
71
+ })) {
64
72
  return undefined;
73
+ }
65
74
  return removal.beforeCommit;
66
75
  }
67
76
  /** Hydrate all status contexts for the active or most recently removed queue commit. */
@@ -0,0 +1,12 @@
1
+ import { type RepoInfo } from "./client.mts";
2
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
+ /**
4
+ * Complete the compact summary's `last: 100` status-context windows in place.
5
+ *
6
+ * Without this, a PR with more than 100 contexts always reports incomplete
7
+ * checks, so it can never earn or match a READY receipt. Every summary fetch
8
+ * path runs this before anything fingerprints `commits`, so one-PR receipts and
9
+ * aggregate stack reads hash the same evidence. A window that cannot be
10
+ * completed stays incomplete, which already fails readiness closed.
11
+ */
12
+ export declare function hydratePollSummaryChecks(raw: RawSummaryPr, repo: RepoInfo): Promise<void>;
@@ -0,0 +1,55 @@
1
+ import { graphqlWithRateLimit } from "./client.mjs";
2
+ import { POLL_SUMMARY_CHECK_PAGE_QUERY } from "./queries.mjs";
3
+ /**
4
+ * Complete the compact summary's `last: 100` status-context windows in place.
5
+ *
6
+ * Without this, a PR with more than 100 contexts always reports incomplete
7
+ * checks, so it can never earn or match a READY receipt. Every summary fetch
8
+ * path runs this before anything fingerprints `commits`, so one-PR receipts and
9
+ * aggregate stack reads hash the same evidence. A window that cannot be
10
+ * completed stays incomplete, which already fails readiness closed.
11
+ */
12
+ export async function hydratePollSummaryChecks(raw, repo) {
13
+ const headCommit = raw.commits.nodes[0]?.commit;
14
+ if (headCommit)
15
+ await hydrateCommitContexts(headCommit, repo);
16
+ const queueCommit = raw.mergeQueueEntry?.headCommit;
17
+ if (queueCommit)
18
+ await hydrateCommitContexts(queueCommit, repo);
19
+ }
20
+ async function hydrateCommitContexts(commit, repo) {
21
+ const contexts = commit.statusCheckRollup?.contexts;
22
+ if (!contexts)
23
+ return;
24
+ const nodes = [...contexts.nodes];
25
+ let pageInfo = contexts.pageInfo;
26
+ while (pageInfo.hasPreviousPage && pageInfo.startCursor) {
27
+ const older = await fetchOlderContexts(commit.oid, pageInfo.startCursor, repo);
28
+ if (!older)
29
+ break;
30
+ nodes.unshift(...older.nodes);
31
+ pageInfo = older.pageInfo;
32
+ }
33
+ // A window that shifted between pages can repeat or skip contexts; the
34
+ // first page's total is the snapshot the hydrated list must account for.
35
+ const complete = !pageInfo.hasPreviousPage && nodes.length === contexts.totalCount;
36
+ commit.statusCheckRollup = {
37
+ contexts: {
38
+ totalCount: contexts.totalCount,
39
+ pageInfo: { hasPreviousPage: !complete },
40
+ nodes,
41
+ },
42
+ };
43
+ }
44
+ async function fetchOlderContexts(oid, before, repo) {
45
+ const { data } = await graphqlWithRateLimit(POLL_SUMMARY_CHECK_PAGE_QUERY, {
46
+ owner: repo.owner,
47
+ repo: repo.name,
48
+ oid,
49
+ before,
50
+ });
51
+ const object = data.repository?.object;
52
+ if (object?.__typename !== "Commit" || object.oid !== oid)
53
+ return null;
54
+ return object.statusCheckRollup?.contexts ?? null;
55
+ }
@@ -19,10 +19,30 @@ export function fingerprintRawSummaryPr(raw) {
19
19
  reviewDecision: raw.reviewDecision,
20
20
  reviewRequests: raw.reviewRequests,
21
21
  latestReviews: raw.latestReviews,
22
- comments: raw.comments,
23
- reviews: raw.reviews,
24
- reviewThreads: raw.reviewThreads,
25
- commits: raw.commits,
22
+ comments: hideBodies(raw.comments),
23
+ reviews: hideBodies(raw.reviews),
24
+ reviewThreads: {
25
+ ...raw.reviewThreads,
26
+ nodes: raw.reviewThreads.nodes.map((thread) => ({
27
+ ...thread,
28
+ rootComments: thread.rootComments && hideBodies(thread.rootComments),
29
+ comments: hideBodies(thread.comments),
30
+ })),
31
+ },
32
+ commits: {
33
+ ...raw.commits,
34
+ nodes: raw.commits.nodes.map((node) => ({
35
+ ...node,
36
+ commit: { ...node.commit, committedDate: undefined },
37
+ })),
38
+ },
26
39
  };
27
40
  return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
28
41
  }
42
+ /** Hidden bodies are not readiness evidence: bots keep editing hidden notices after a PR settles. */
43
+ function hideBodies(connection) {
44
+ return {
45
+ ...connection,
46
+ nodes: connection.nodes.map((node) => (node.isMinimized ? { ...node, body: undefined } : node)),
47
+ };
48
+ }
@@ -19,14 +19,16 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
19
19
  const removalEvent = currentQueueRemovalEvent(raw);
20
20
  let { action, reasons } = routePollSummary(raw, checks, review, opts);
21
21
  let remainingSeconds;
22
- if (raw.isDraft && blockingReviewerInProgress && action === "mark_ready") {
22
+ if (raw.isDraft &&
23
+ blockingReviewerInProgress &&
24
+ (action === "mark_ready" || reasons.includes("draft-auto-mark-ready-disabled"))) {
23
25
  action = "wait";
24
26
  reasons = ["blocking-reviewer-in-progress"];
25
27
  }
26
28
  const appearsReady = reasons.includes("appears-ready");
27
29
  if (opts.stackPrNumber === undefined) {
28
30
  const readyDelaySeconds = opts.readyDelaySeconds ?? (loadConfig().watch?.readyDelayMinutes ?? 10) * 60;
29
- const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, { retainElapsed: true });
31
+ const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, { headSha: raw.headRefOid });
30
32
  if (appearsReady && !readyState.shouldCancel) {
31
33
  action = "wait";
32
34
  reasons = ["ready-delay"];
@@ -93,9 +95,7 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
93
95
  ...((opts.stackPrNumber !== undefined && raw.state === "OPEN") ||
94
96
  (!["wait", "cancel"].includes(action) &&
95
97
  !(opts.stackPrNumber !== undefined && raw.stack && action === "merge"))
96
- ? {
97
- pollCommand: buildPollCommand(repoName, raw.number, raw.isDraft, opts),
98
- }
98
+ ? pollCommandFields(repoName, raw.number, raw.isDraft, opts)
99
99
  : {}),
100
100
  };
101
101
  }
@@ -116,7 +116,7 @@ function detectBlockingReviewer(raw) {
116
116
  return ((raw.reviewRequests?.nodes ?? []).some((request) => matches(request.requestedReviewer)) ||
117
117
  (raw.latestReviews?.nodes ?? []).some((review) => review.state === "PENDING" && matches(review.author)));
118
118
  }
119
- function buildPollCommand(repo, pr, isDraft, opts) {
119
+ function pollCommandFields(repo, pr, isDraft, opts) {
120
120
  const autoMarkReadyDisabled = opts.noAutoMarkReady || loadConfig().actions.autoMarkReady === false;
121
121
  const boundedDraft = isDraft && autoMarkReadyDisabled;
122
122
  const args = boundedDraft
@@ -131,5 +131,6 @@ function buildPollCommand(repo, pr, isDraft, opts) {
131
131
  }
132
132
  if (opts.noAutoMarkReady && !boundedDraft)
133
133
  args.push("--no-auto-mark-ready");
134
- return buildPrShepherdCommand(args).text;
134
+ const pollCommand = buildPrShepherdCommand(args).text;
135
+ return boundedDraft ? { pollCommand, pollProbe: true } : { pollCommand };
135
136
  }
@@ -1,3 +1,5 @@
1
+ import { queueRemovalAppliesToHead } from "./queue-removal-freshness.mjs";
2
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
1
3
  /** Latest queue removal that still applies to this PR head, not an older attempt. */
2
4
  export function currentQueueRemovalEvent(raw) {
3
5
  const removal = raw.mergeQueueRemovals?.nodes[0];
@@ -10,7 +12,14 @@ export function currentQueueRemovalEvent(raw) {
10
12
  if (addition && Date.parse(addition.createdAt) > removalTime)
11
13
  return null;
12
14
  const parents = removal.beforeCommit?.parents?.nodes.map((parent) => parent.oid);
13
- if (!parents?.includes(raw.headRefOid))
15
+ const headCommittedAt = raw.commits?.nodes[0]?.commit.committedDate;
16
+ if (!queueRemovalAppliesToHead({
17
+ parentOids: parents,
18
+ headOid: raw.headRefOid,
19
+ ...(headCommittedAt && { headCommittedAtUnix: parseCreatedAt(headCommittedAt) }),
20
+ removedAtUnix: Math.floor(removalTime / 1000),
21
+ })) {
14
22
  return null;
23
+ }
15
24
  return removal;
16
25
  }
@@ -43,8 +43,23 @@ type RawCheckContext = {
43
43
  context: string;
44
44
  state: string;
45
45
  };
46
- interface RawCheckRollup {
47
- contexts: SummaryConnection<RawCheckContext>;
46
+ interface RawCheckContexts {
47
+ totalCount: number;
48
+ /** `startCursor` pages older contexts; hydration drops it so fingerprints never hash a cursor. */
49
+ pageInfo: {
50
+ hasPreviousPage: boolean;
51
+ startCursor?: string | null;
52
+ };
53
+ nodes: RawCheckContext[];
54
+ }
55
+ export interface RawCheckRollup {
56
+ contexts: RawCheckContexts;
57
+ }
58
+ /** A commit whose status contexts the compact summary reads. */
59
+ export interface RawSummaryCommit {
60
+ oid: string;
61
+ committedDate?: string;
62
+ statusCheckRollup: RawCheckRollup | null;
48
63
  }
49
64
  export interface RawSummaryPr {
50
65
  number: number;
@@ -103,9 +118,7 @@ export interface RawSummaryPr {
103
118
  }>;
104
119
  } | null;
105
120
  mergeQueueEntry: {
106
- headCommit: {
107
- statusCheckRollup: RawCheckRollup | null;
108
- } | null;
121
+ headCommit: RawSummaryCommit | null;
109
122
  } | null;
110
123
  stack: {
111
124
  number: number;
@@ -131,9 +144,7 @@ export interface RawSummaryPr {
131
144
  }>;
132
145
  commits: {
133
146
  nodes: Array<{
134
- commit: {
135
- statusCheckRollup: RawCheckRollup | null;
136
- };
147
+ commit: RawSummaryCommit;
137
148
  }>;
138
149
  };
139
150
  }
@@ -142,27 +153,4 @@ export interface RawExplicitResponse {
142
153
  viewerCanAdminister: boolean;
143
154
  } & Record<string, RawSummaryPr | boolean | null>) | null;
144
155
  }
145
- export interface RawStackResponse {
146
- repository: {
147
- viewerCanAdminister: boolean;
148
- pullRequest: {
149
- stack: {
150
- id: string;
151
- number: number;
152
- size: number;
153
- baseRefName: string;
154
- entries: {
155
- pageInfo: {
156
- hasNextPage: boolean;
157
- endCursor: string | null;
158
- };
159
- nodes: Array<{
160
- position: number;
161
- pullRequest: RawSummaryPr | null;
162
- }>;
163
- };
164
- } | null;
165
- } | null;
166
- } | null;
167
- }
168
156
  export {};
@@ -25,12 +25,15 @@ export function routePollSummary(raw, checks, review, opts) {
25
25
  return { action: "wait", reasons: ["pending-or-unknown"] };
26
26
  }
27
27
  if (raw.isDraft) {
28
- if (opts.noAutoMarkReady || actions.autoMarkReady === false) {
29
- return { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] };
28
+ const autoMarkReadyDisabled = opts.noAutoMarkReady || actions.autoMarkReady === false;
29
+ // The stack selector asks the agent to mark a disabled draft ready, so
30
+ // that transition needs the same capability as the automatic one.
31
+ if (!raw.viewerCanUpdate && (!autoMarkReadyDisabled || opts.stackPrNumber !== undefined)) {
32
+ return { action: "escalate", reasons: ["mark-ready-authorization-required"] };
30
33
  }
31
- return raw.viewerCanUpdate
32
- ? { action: "mark_ready", reasons: ["draft-appears-ready"] }
33
- : { action: "escalate", reasons: ["mark-ready-authorization-required"] };
34
+ return autoMarkReadyDisabled
35
+ ? { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] }
36
+ : { action: "mark_ready", reasons: ["draft-appears-ready"] };
34
37
  }
35
38
  if (opts.merge && raw.isInMergeQueue) {
36
39
  return { action: "wait", reasons: ["already-in-merge-queue"] };
@@ -1,8 +1,10 @@
1
1
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
2
  import { graphqlWithRateLimit } from "./client.mjs";
3
- import { GitHubRequestError } from "./errors.mjs";
3
+ import { missingRepositoryError } from "./errors.mjs";
4
+ import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
4
5
  import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
5
6
  import { POLL_STACK_SUMMARY_QUERY, POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
7
+ import { MAX_STACK_ENTRIES_PER_PAGE, readStack, readStackTopology, stackAncestryGaps, } from "./stack-read.mjs";
6
8
  const MAX_EXPLICIT_PRS_PER_QUERY = 50;
7
9
  export async function fetchPollSummary(opts, repo) {
8
10
  if (opts.stackPrNumber !== undefined)
@@ -42,93 +44,34 @@ async function fetchExplicitChunk(prs, repo) {
42
44
  ...variables,
43
45
  });
44
46
  if (!result.data.repository)
45
- throw missingRepository(repo);
47
+ throw missingRepositoryError(repo);
46
48
  const rawPrs = prs.map((pr, index) => {
47
49
  const raw = result.data.repository[`pr${index}`];
48
50
  if (!raw)
49
51
  throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
50
52
  return raw;
51
53
  });
54
+ for (const raw of rawPrs)
55
+ await hydratePollSummaryChecks(raw, repo);
52
56
  return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
53
57
  }
58
+ /**
59
+ * Read the stack's topology first so the hydrated page requests only as many
60
+ * entries as the stack holds: GitHub prices `first`, not the nodes returned.
61
+ */
54
62
  async function fetchStackSummary(opts, repo) {
55
63
  const anchor = opts.stackPrNumber;
56
- let after = null;
57
- let stackId = null;
58
- let stackNumber = 0;
59
- let stackSize = 0;
60
- let viewerCanAdminister = false;
61
- const entries = [];
62
- do {
63
- const response = (await graphqlWithRateLimit(POLL_STACK_SUMMARY_QUERY, {
64
- owner: repo.owner,
65
- repo: repo.name,
66
- anchor,
67
- after,
68
- }));
69
- const repository = response.data.repository;
70
- if (!repository)
71
- throw missingRepository(repo);
72
- viewerCanAdminister = repository.viewerCanAdminister;
73
- if (!repository.pullRequest) {
74
- throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
75
- }
76
- const stack = repository.pullRequest.stack;
77
- if (!stack) {
78
- throw new ShepherdError(`PR #${anchor} is not part of a native GitHub stack`, EXIT.UNAVAILABLE);
79
- }
80
- if (stackId !== null && stack.id !== stackId) {
81
- throw new ShepherdError("GitHub stack membership changed while it was being fetched; retry", EXIT.TEMPFAIL);
82
- }
83
- stackId = stack.id;
84
- stackNumber = stack.number;
85
- stackSize = stack.size;
86
- for (const entry of stack.entries.nodes) {
87
- if (!entry.pullRequest) {
88
- throw new ShepherdError("GitHub returned an incomplete pull-request stack entry", EXIT.TEMPFAIL);
89
- }
90
- entries.push({ position: entry.position, pullRequest: entry.pullRequest });
91
- }
92
- const pageInfo = stack.entries.pageInfo;
93
- if (pageInfo.hasNextPage && !pageInfo.endCursor) {
94
- throw new ShepherdError("GitHub stack pagination did not include an end cursor", EXIT.TEMPFAIL);
95
- }
96
- after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
97
- } while (after !== null);
98
- const unique = new Map();
99
- for (const entry of entries)
100
- unique.set(entry.pullRequest.number, entry);
101
- if (!unique.has(anchor) || unique.size !== stackSize) {
102
- throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
103
- }
104
- const ordered = [...unique.values()].sort((left, right) => left.position - right.position);
105
- const stackAncestry = [];
106
- for (let index = 1; index < ordered.length; index++) {
107
- const parent = ordered[index - 1].pullRequest;
108
- const child = ordered[index].pullRequest;
109
- if (parent.state !== "OPEN" || child.state !== "OPEN")
110
- continue;
111
- if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
112
- continue;
113
- }
114
- stackAncestry.push({
115
- parentPr: parent.number,
116
- parentHeadRefName: parent.headRefName,
117
- parentHeadRefOid: parent.headRefOid,
118
- childPr: child.number,
119
- childBaseRefName: child.baseRefName,
120
- childBaseRefOid: child.baseRefOid,
121
- });
122
- }
64
+ const topology = await readStackTopology(anchor, repo);
65
+ const { stackNumber, stackSize, viewerCanAdminister, ordered } = await readStack(POLL_STACK_SUMMARY_QUERY, anchor, repo, { first: Math.min(topology.stackSize, MAX_STACK_ENTRIES_PER_PAGE) });
66
+ for (const pr of ordered)
67
+ await hydratePollSummaryChecks(pr, repo);
68
+ const stackAncestry = stackAncestryGaps(ordered);
123
69
  return {
124
70
  selection: { kind: "stack", anchor, stackNumber, stackSize },
125
- prs: await Promise.all(ordered.map((entry) => summarizePollSummaryPr(entry.pullRequest, repo, opts, viewerCanAdminister))),
71
+ prs: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
126
72
  ...(stackAncestry.length > 0 && { stackAncestry }),
127
73
  };
128
74
  }
129
75
  function deduplicate(values) {
130
76
  return [...new Set(values)];
131
77
  }
132
- function missingRepository(repo) {
133
- return new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
134
- }
@@ -14,8 +14,15 @@ export declare const BATCH_PR_PAGE_QUERY: string;
14
14
  export declare const PR_FINGERPRINT_QUERY: string;
15
15
  /** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
16
16
  export declare const POLL_SUMMARY_FRAGMENT: string;
17
+ /**
18
+ * Pages older status contexts for one commit with the same node selection as
19
+ * the compact summary, so hydrated and first-page nodes fingerprint alike.
20
+ */
21
+ export declare const POLL_SUMMARY_CHECK_PAGE_QUERY: string;
17
22
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
18
23
  export declare const POLL_STACK_SUMMARY_QUERY: string;
24
+ /** Reads only native-stack membership and the refs that link adjacent entries. */
25
+ export declare const POLL_STACK_TOPOLOGY_QUERY: string;
19
26
  /** PR head fields plus a single review thread for `commit-suggestion`. */
20
27
  export declare const SUGGESTION_THREADS_QUERY: string;
21
28
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -16,10 +16,18 @@ export const BATCH_PR_QUERY = withSharedFragments(gql("batch-pr.gql"));
16
16
  export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
17
17
  /** Cheap PR fingerprint used to skip an unchanged BatchPr snapshot. */
18
18
  export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"));
19
+ const POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT = gql("poll-summary-check-contexts.gql");
19
20
  /** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
20
- export const POLL_SUMMARY_FRAGMENT = gql("poll-summary-fragment.gql");
21
+ export const POLL_SUMMARY_FRAGMENT = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}`;
22
+ /**
23
+ * Pages older status contexts for one commit with the same node selection as
24
+ * the compact summary, so hydrated and first-page nodes fingerprint alike.
25
+ */
26
+ export const POLL_SUMMARY_CHECK_PAGE_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-check-page.gql")}`;
21
27
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
22
28
  export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-stack-summary.gql")}`;
29
+ /** Reads only native-stack membership and the refs that link adjacent entries. */
30
+ export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
23
31
  /** PR head fields plus a single review thread for `commit-suggestion`. */
24
32
  export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
25
33
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Whether a removed merge-queue commit still belongs to the current PR head.
3
+ *
4
+ * A merge-commit queue lists that head as a parent. Squash and rebase queues
5
+ * build a one-parent commit on the base (or on the previous group commit), so
6
+ * the PR head is not an ancestor. Missing parents are unverifiable: GitHub
7
+ * keeps returning the latest removal after the synthetic commit is gone.
8
+ * A one-parent removal is stale when the current head's committer time is
9
+ * later than the removal, which is a push after the ejection.
10
+ */
11
+ export declare function queueRemovalAppliesToHead(input: {
12
+ parentOids: readonly string[] | null | undefined;
13
+ headOid: string;
14
+ headCommittedAtUnix?: number;
15
+ removedAtUnix?: number;
16
+ }): boolean;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a removed merge-queue commit still belongs to the current PR head.
3
+ *
4
+ * A merge-commit queue lists that head as a parent. Squash and rebase queues
5
+ * build a one-parent commit on the base (or on the previous group commit), so
6
+ * the PR head is not an ancestor. Missing parents are unverifiable: GitHub
7
+ * keeps returning the latest removal after the synthetic commit is gone.
8
+ * A one-parent removal is stale when the current head's committer time is
9
+ * later than the removal, which is a push after the ejection.
10
+ */
11
+ export function queueRemovalAppliesToHead(input) {
12
+ const parents = input.parentOids ?? [];
13
+ if (parents.length === 0)
14
+ return false;
15
+ if (parents.includes(input.headOid))
16
+ return true;
17
+ if (parents.length > 1)
18
+ return false;
19
+ if (input.headCommittedAtUnix !== undefined &&
20
+ input.removedAtUnix !== undefined &&
21
+ input.removedAtUnix > 0 &&
22
+ input.headCommittedAtUnix > input.removedAtUnix) {
23
+ return false;
24
+ }
25
+ return true;
26
+ }
@@ -0,0 +1,34 @@
1
+ import type { PollSummaryStackAncestry } from "../types.mts";
2
+ import { type RepoInfo } from "./client.mts";
3
+ /** GitHub's `first` ceiling for one native-stack entries page. */
4
+ export declare const MAX_STACK_ENTRIES_PER_PAGE = 50;
5
+ /** Fields every native-stack read needs to validate membership and adjacent ancestry. */
6
+ export interface StackMemberRefs {
7
+ number: number;
8
+ state: string;
9
+ headRefName: string;
10
+ headRefOid: string;
11
+ baseRefName: string;
12
+ baseRefOid: string;
13
+ }
14
+ /** One complete, validated native-stack membership read. */
15
+ export interface StackRead<Pr extends StackMemberRefs> {
16
+ stackNumber: number;
17
+ stackSize: number;
18
+ viewerCanAdminister: boolean;
19
+ /** Every member, bottom-to-top, each observed in the same paged read. */
20
+ ordered: Pr[];
21
+ }
22
+ /**
23
+ * Page through one native stack and fail closed on anything short of the full
24
+ * membership: a missing repository, PR, or stack; a changed stack identity; a
25
+ * null entry; a page without a cursor; or fewer unique members than `size`.
26
+ */
27
+ export declare function readStack<Pr extends StackMemberRefs>(query: string, anchor: number, repo: RepoInfo, variables?: Record<string, unknown>): Promise<StackRead<Pr>>;
28
+ /** Membership and linking refs only — no per-PR CI or review hydration. */
29
+ export declare function readStackTopology(anchor: number, repo: RepoInfo): Promise<StackRead<StackMemberRefs>>;
30
+ /**
31
+ * Adjacent entries, both open, whose child base no longer matches the parent
32
+ * head. Both sides come from the same read, so each gap was observed together.
33
+ */
34
+ export declare function stackAncestryGaps(ordered: StackMemberRefs[]): PollSummaryStackAncestry[];
@@ -0,0 +1,92 @@
1
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
+ import { graphqlWithRateLimit } from "./client.mjs";
3
+ import { missingRepositoryError } from "./errors.mjs";
4
+ import { POLL_STACK_TOPOLOGY_QUERY } from "./queries.mjs";
5
+ /** GitHub's `first` ceiling for one native-stack entries page. */
6
+ export const MAX_STACK_ENTRIES_PER_PAGE = 50;
7
+ /**
8
+ * Page through one native stack and fail closed on anything short of the full
9
+ * membership: a missing repository, PR, or stack; a changed stack identity; a
10
+ * null entry; a page without a cursor; or fewer unique members than `size`.
11
+ */
12
+ export async function readStack(query, anchor, repo, variables = {}) {
13
+ let after = null;
14
+ let stackId = null;
15
+ let stackNumber = 0;
16
+ let stackSize = 0;
17
+ let viewerCanAdminister = false;
18
+ const entries = [];
19
+ do {
20
+ const response = await graphqlWithRateLimit(query, { owner: repo.owner, repo: repo.name, anchor, after, ...variables });
21
+ const repository = response.data.repository;
22
+ if (!repository)
23
+ throw missingRepositoryError(repo);
24
+ viewerCanAdminister = repository.viewerCanAdminister;
25
+ if (!repository.pullRequest) {
26
+ throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
27
+ }
28
+ const stack = repository.pullRequest.stack;
29
+ if (!stack) {
30
+ throw new ShepherdError(`PR #${anchor} is not part of a native GitHub stack`, EXIT.UNAVAILABLE);
31
+ }
32
+ if (stackId !== null && stack.id !== stackId) {
33
+ throw new ShepherdError("GitHub stack membership changed while it was being fetched; retry", EXIT.TEMPFAIL);
34
+ }
35
+ stackId = stack.id;
36
+ stackNumber = stack.number;
37
+ stackSize = stack.size;
38
+ for (const entry of stack.entries.nodes) {
39
+ if (!entry.pullRequest) {
40
+ throw new ShepherdError("GitHub returned an incomplete pull-request stack entry", EXIT.TEMPFAIL);
41
+ }
42
+ entries.push({ position: entry.position, pullRequest: entry.pullRequest });
43
+ }
44
+ const pageInfo = stack.entries.pageInfo;
45
+ if (pageInfo.hasNextPage && !pageInfo.endCursor) {
46
+ throw new ShepherdError("GitHub stack pagination did not include an end cursor", EXIT.TEMPFAIL);
47
+ }
48
+ if (pageInfo.hasNextPage && pageInfo.endCursor === after) {
49
+ throw new ShepherdError("GitHub stack pagination returned a repeated end cursor", EXIT.TEMPFAIL);
50
+ }
51
+ after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
52
+ } while (after !== null);
53
+ const unique = new Map();
54
+ for (const entry of entries)
55
+ unique.set(entry.pullRequest.number, entry);
56
+ if (!unique.has(anchor) || unique.size !== stackSize) {
57
+ throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
58
+ }
59
+ const ordered = [...unique.values()]
60
+ .sort((left, right) => left.position - right.position)
61
+ .map((entry) => entry.pullRequest);
62
+ return { stackNumber, stackSize, viewerCanAdminister, ordered };
63
+ }
64
+ /** Membership and linking refs only — no per-PR CI or review hydration. */
65
+ export function readStackTopology(anchor, repo) {
66
+ return readStack(POLL_STACK_TOPOLOGY_QUERY, anchor, repo);
67
+ }
68
+ /**
69
+ * Adjacent entries, both open, whose child base no longer matches the parent
70
+ * head. Both sides come from the same read, so each gap was observed together.
71
+ */
72
+ export function stackAncestryGaps(ordered) {
73
+ const gaps = [];
74
+ for (let index = 1; index < ordered.length; index++) {
75
+ const parent = ordered[index - 1];
76
+ const child = ordered[index];
77
+ if (parent.state !== "OPEN" || child.state !== "OPEN")
78
+ continue;
79
+ if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
80
+ continue;
81
+ }
82
+ gaps.push({
83
+ parentPr: parent.number,
84
+ parentHeadRefName: parent.headRefName,
85
+ parentHeadRefOid: parent.headRefOid,
86
+ childPr: child.number,
87
+ childBaseRefName: child.baseRefName,
88
+ childBaseRefOid: child.baseRefOid,
89
+ });
90
+ }
91
+ return gaps;
92
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Append-only per-worktree markdown log.
3
3
  *
4
- * Log path: $PR_SHEPHERD_STATE_DIR/<owner>-<repo>/worktrees/<basename>-<sha8>.md
4
+ * Log path: $PR_SHEPHERD_STATE_DIR/<owner>/<repo>/worktrees/<basename>-<sha8>.md
5
5
  *
6
6
  * Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
7
7
  * Write failures flip an internal disabled flag so the CLI never crashes because