pr-shepherd 0.55.2 → 0.56.1

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 (86) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/api.d.mts +5 -0
  4. package/bin/api.mjs +12 -1
  5. package/bin/checks/job-log.d.mts +9 -0
  6. package/bin/checks/job-log.mjs +30 -0
  7. package/bin/checks/jobs-types.d.mts +14 -0
  8. package/bin/checks/jobs-types.mjs +7 -0
  9. package/bin/checks/related-jobs.d.mts +11 -0
  10. package/bin/checks/related-jobs.mjs +32 -0
  11. package/bin/checks/triage-budget.d.mts +17 -0
  12. package/bin/checks/triage-budget.mjs +49 -0
  13. package/bin/checks/triage.d.mts +6 -3
  14. package/bin/checks/triage.mjs +89 -61
  15. package/bin/cli/api-usage-formatter.mjs +2 -2
  16. package/bin/cli/fix-formatter.mjs +2 -0
  17. package/bin/cli/iterate-checks-formatter.mjs +2 -0
  18. package/bin/cli/related-jobs-format.d.mts +3 -0
  19. package/bin/cli/related-jobs-format.mjs +16 -0
  20. package/bin/commands/check-execution-context.d.mts +12 -0
  21. package/bin/commands/check-execution-context.mjs +38 -0
  22. package/bin/commands/check-fingerprint.mjs +14 -8
  23. package/bin/commands/check-unreported.d.mts +3 -2
  24. package/bin/commands/check-unreported.mjs +4 -4
  25. package/bin/commands/check.d.mts +2 -1
  26. package/bin/commands/check.mjs +21 -6
  27. package/bin/commands/iterate/check-instructions.d.mts +6 -3
  28. package/bin/commands/iterate/check-instructions.mjs +6 -5
  29. package/bin/commands/iterate/escalate.mjs +3 -0
  30. package/bin/commands/iterate/fix-code.mjs +6 -2
  31. package/bin/commands/iterate/helpers.mjs +1 -0
  32. package/bin/commands/iterate/index.mjs +20 -8
  33. package/bin/commands/iterate/render.mjs +11 -3
  34. package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
  35. package/bin/commands/iterate/stale-ancestry.mjs +3 -2
  36. package/bin/commands/poll-quota.d.mts +2 -0
  37. package/bin/commands/poll-quota.mjs +18 -25
  38. package/bin/commands/poll-rate-limit-wait.mjs +13 -8
  39. package/bin/commands/ready-delay.d.mts +2 -0
  40. package/bin/commands/ready-delay.mjs +18 -0
  41. package/bin/commands/resolve-mutate.mjs +8 -9
  42. package/bin/github/batch-raw-types.d.mts +2 -0
  43. package/bin/github/batch-receipt-evidence.d.mts +5 -0
  44. package/bin/github/batch-receipt-evidence.mjs +61 -0
  45. package/bin/github/batch.d.mts +4 -0
  46. package/bin/github/batch.mjs +42 -8
  47. package/bin/github/errors.d.mts +3 -0
  48. package/bin/github/errors.mjs +15 -6
  49. package/bin/github/gql/batch-pr-page.gql +1 -0
  50. package/bin/github/gql/batch-pr.gql +1 -0
  51. package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
  52. package/bin/github/gql/reply-thread-comments.gql +25 -0
  53. package/bin/github/gql/reply-thread-transcripts.gql +31 -0
  54. package/bin/github/merge-queue-checks.d.mts +2 -1
  55. package/bin/github/merge-queue-checks.mjs +18 -8
  56. package/bin/github/merge-target-rules.d.mts +2 -1
  57. package/bin/github/merge-target-rules.mjs +4 -4
  58. package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
  59. package/bin/github/poll-summary-annotation-probe.mjs +7 -0
  60. package/bin/github/poll-summary-mergeability.d.mts +11 -0
  61. package/bin/github/poll-summary-mergeability.mjs +23 -0
  62. package/bin/github/poll-summary.mjs +7 -2
  63. package/bin/github/queries.d.mts +5 -0
  64. package/bin/github/queries.mjs +5 -0
  65. package/bin/github/rate-limit-kind.d.mts +13 -0
  66. package/bin/github/rate-limit-kind.mjs +25 -0
  67. package/bin/github/reply-thread-transcripts.d.mts +3 -0
  68. package/bin/github/reply-thread-transcripts.mjs +89 -0
  69. package/bin/github/rest-http.mjs +1 -0
  70. package/bin/github/rest-text.d.mts +2 -1
  71. package/bin/github/rest-text.mjs +5 -2
  72. package/bin/github/thread-comments.d.mts +5 -1
  73. package/bin/github/thread-comments.mjs +30 -6
  74. package/bin/mcp/server.mjs +32 -5
  75. package/bin/quota-warning.mjs +2 -2
  76. package/bin/reporters/agent.mjs +1 -0
  77. package/bin/threads/transcript.d.mts +1 -0
  78. package/bin/threads/transcript.mjs +4 -1
  79. package/bin/types/check-classification.d.mts +10 -0
  80. package/bin/types/report.d.mts +4 -1
  81. package/package.json +1 -1
  82. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  83. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  84. package/plugins/pr-shepherd/.mcp.json +1 -1
  85. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -1
  86. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +2 -1
@@ -1,8 +1,8 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
- import { fetchPrBatch } from "../github/batch.mjs";
3
+ import { fetchReplyThreadTranscripts } from "../github/reply-thread-transcripts.mjs";
4
4
  import { markReplySeen } from "../state/seen-comments.mjs";
5
- import { threadTranscriptBody } from "../threads/transcript.mjs";
5
+ import { threadTranscriptBodies } from "../threads/transcript.mjs";
6
6
  import { addPrShepherdMarker } from "../comments/marker.mjs";
7
7
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
8
8
  /** @deprecated Hidden implementation for `resolve`; use `apply review`. */
@@ -14,10 +14,10 @@ export async function runResolveMutate(opts) {
14
14
  }
15
15
  // Fetch only to retain the pre-reply transcript for successful-reply seen
16
16
  // markers. It never determines which user-supplied IDs are sent to GitHub.
17
- let threadById;
17
+ let transcriptById;
18
18
  if (opts.replyThreadIds?.length) {
19
19
  try {
20
- threadById = new Map((await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true })).data.reviewThreads.map((thread) => [thread.id, thread]));
20
+ transcriptById = await fetchReplyThreadTranscripts(prNumber, repo, opts.replyThreadIds);
21
21
  }
22
22
  catch {
23
23
  // Seen-marker bookkeeping is best-effort. A failed read must not block
@@ -35,14 +35,13 @@ export async function runResolveMutate(opts) {
35
35
  dismissMessage: opts.dismissMessage,
36
36
  requireSha: opts.requireSha,
37
37
  });
38
- if (opts.dismissMessage && threadById) {
38
+ if (opts.dismissMessage && transcriptById) {
39
39
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
40
40
  await Promise.all(result.repliedThreads.map((id) => {
41
- const thread = threadById.get(id);
42
- if (!thread)
41
+ const previousBody = transcriptById.get(id);
42
+ if (previousBody === undefined)
43
43
  return Promise.resolve();
44
- const previousBody = threadTranscriptBody(thread);
45
- return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBody(thread, [markedMessage]), markedMessage);
44
+ return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBodies([previousBody, markedMessage]), markedMessage);
46
45
  }));
47
46
  }
48
47
  return result;
@@ -11,6 +11,7 @@ export interface RawBatchResponse {
11
11
  squashMergeAllowed?: boolean;
12
12
  rebaseMergeAllowed?: boolean;
13
13
  pullRequest: RawPr | null;
14
+ receiptSummary?: import("./poll-summary-raw.mts").RawSummaryPr | null;
14
15
  } | null;
15
16
  }
16
17
  export interface RawPr extends RawPrMergeFields {
@@ -226,6 +227,7 @@ export type RawContextNode = {
226
227
  title: string | null;
227
228
  summary: string | null;
228
229
  annotations?: {
230
+ totalCount?: number;
229
231
  nodes: Array<{
230
232
  message: string;
231
233
  }>;
@@ -0,0 +1,5 @@
1
+ import type { RepoInfo } from "./client.mts";
2
+ import type { RawContextNode, RawPr } from "./batch-raw-types.mts";
3
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
4
+ /** Keep the exact PollSummaryPr shape used by v1 receipts; add only complete annotation totals. */
5
+ export declare function prepareBatchReceiptEvidence(summary: RawSummaryPr | null | undefined, batch: RawPr, checks: RawContextNode[], repo: RepoInfo): Promise<RawSummaryPr | null>;
@@ -0,0 +1,61 @@
1
+ import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
2
+ import { markReadyAnnotationProbeComplete } from "./poll-summary-annotation-probe.mjs";
3
+ /** Keep the exact PollSummaryPr shape used by v1 receipts; add only complete annotation totals. */
4
+ export async function prepareBatchReceiptEvidence(summary, batch, checks, repo) {
5
+ if (!summary ||
6
+ summary.number !== batch.number ||
7
+ summary.headRefOid !== batch.headRefOid ||
8
+ summary.baseRefOid !== batch.baseRefOid)
9
+ return null;
10
+ // Candidate evidence is best-effort. Wider windows need their own summary
11
+ // pagination, so use the existing standalone receipt path instead of issuing
12
+ // follow-ups from inside a full BatchPr read (which may have exhausted quota).
13
+ const headContexts = summary.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
14
+ const queueContexts = summary.mergeQueueEntry?.headCommit?.statusCheckRollup?.contexts;
15
+ if (headContexts?.pageInfo.hasPreviousPage || queueContexts?.pageInfo.hasPreviousPage)
16
+ return null;
17
+ try {
18
+ await hydratePollSummaryChecks(summary, repo);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ if (summary.mergeQueueEntry?.headCommit?.statusCheckRollup?.contexts.pageInfo.hasPreviousPage)
24
+ return null;
25
+ const summaryCommit = summary.commits.nodes[0]?.commit;
26
+ const batchCommit = batch.commits.nodes[0]?.commit;
27
+ if (!summaryCommit || summaryCommit.oid !== batchCommit?.oid)
28
+ return null;
29
+ const contexts = summaryCommit.statusCheckRollup?.contexts;
30
+ if (!contexts) {
31
+ if (batchCommit.statusCheckRollup !== null || checks.length > 0)
32
+ return null;
33
+ markReadyAnnotationProbeComplete(summary);
34
+ return summary;
35
+ }
36
+ if (contexts.pageInfo.hasPreviousPage || contexts.nodes.length !== contexts.totalCount)
37
+ return null;
38
+ if (checks.length !== contexts.totalCount)
39
+ return null;
40
+ const batchRuns = new Map();
41
+ for (const check of checks) {
42
+ if (check.__typename !== "CheckRun")
43
+ continue;
44
+ const count = check.annotations?.totalCount;
45
+ if (!Number.isSafeInteger(count) || count < 0 || batchRuns.has(check.id))
46
+ return null;
47
+ batchRuns.set(check.id, count);
48
+ }
49
+ const summaryRuns = contexts.nodes.filter((node) => node.__typename === "CheckRun");
50
+ if (summaryRuns.length !== batchRuns.size)
51
+ return null;
52
+ const seenSummaryIds = new Set();
53
+ for (const node of summaryRuns) {
54
+ if (!node.id || seenSummaryIds.has(node.id) || !batchRuns.has(node.id))
55
+ return null;
56
+ seenSummaryIds.add(node.id);
57
+ node.annotations = { totalCount: batchRuns.get(node.id) };
58
+ }
59
+ markReadyAnnotationProbeComplete(summary);
60
+ return summary;
61
+ }
@@ -1,5 +1,6 @@
1
1
  import { type RateLimitInfo, type RepoInfo } from "./client.mts";
2
2
  import type { WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
3
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
4
  import type { BatchPrData } from "../types.mts";
4
5
  import { type PrFingerprint } from "./fingerprint.mts";
5
6
  interface BatchResult {
@@ -12,6 +13,8 @@ interface BatchResult {
12
13
  headCheckSuitesEmpty?: true;
13
14
  /** Actions workflow suites on the head, excluding apps that have no workflow run. */
14
15
  headWorkflowSuites?: WorkflowSuiteSnapshot[];
16
+ /** Internal READY-receipt evidence from the same request, if complete. */
17
+ receiptSummary?: RawSummaryPr;
15
18
  }
16
19
  interface FetchPrBatchOptions {
17
20
  /**
@@ -23,6 +26,7 @@ interface FetchPrBatchOptions {
23
26
  * request — so there's no need to conditionally omit the field itself.
24
27
  */
25
28
  paginateApprovedReviews?: boolean;
29
+ includeReceiptSummary?: boolean;
26
30
  }
27
31
  /**
28
32
  * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
@@ -1,6 +1,9 @@
1
1
  import { graphqlWithRateLimit } from "./client.mjs";
2
2
  import { hydrateThreadCommentPages } from "./thread-comments.mjs";
3
- import { BATCH_PR_QUERY } from "./queries.mjs";
3
+ import { BATCH_PR_QUERY, BATCH_PR_RECEIPT_QUERY } from "./queries.mjs";
4
+ import { prepareBatchReceiptEvidence } from "./batch-receipt-evidence.mjs";
5
+ import { GitHubRequestError, isRetryableGraphQlResourceLimit, } from "./errors.mjs";
6
+ import { rateLimitKind } from "./rate-limit-kind.mjs";
4
7
  import { parseRawPr } from "./batch-parsers.mjs";
5
8
  import { parseCheckSuitesComplete, parseHeadCheckSuitesEmpty, parseHeadWorkflowSuites, parseSuiteStartupFailures, } from "./batch-parse-suites.mjs";
6
9
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
@@ -8,20 +11,50 @@ import { paginateBatchConnections } from "./batch-page.mjs";
8
11
  import { requireRawPr } from "./batch-response.mjs";
9
12
  import { hydrateMergeQueueChecks } from "./merge-queue-checks.mjs";
10
13
  import { fingerprintFromRaw } from "./fingerprint.mjs";
14
+ function onlyReceiptSummaryErrors(errors) {
15
+ return (!!errors?.length &&
16
+ errors.every((error) => Array.isArray(error.path) &&
17
+ error.path[0] === "repository" &&
18
+ error.path[1] === "receiptSummary"));
19
+ }
11
20
  /**
12
21
  * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
13
22
  */
14
23
  export async function fetchPrBatch(pr, repo, opts = {}) {
15
- const result = await graphqlWithRateLimit(BATCH_PR_QUERY, {
24
+ const variables = {
16
25
  owner: repo.owner,
17
26
  repo: repo.name,
18
27
  pr,
19
- });
28
+ };
29
+ let result;
30
+ try {
31
+ result = await graphqlWithRateLimit(opts.includeReceiptSummary ? BATCH_PR_RECEIPT_QUERY : BATCH_PR_QUERY, variables);
32
+ }
33
+ catch (error) {
34
+ if (!opts.includeReceiptSummary ||
35
+ !(error instanceof GitHubRequestError) ||
36
+ rateLimitKind(error) !== null ||
37
+ !(isRetryableGraphQlResourceLimit(error.graphqlErrors) ||
38
+ (error.status === 200 && onlyReceiptSummaryErrors(error.graphqlErrors))))
39
+ throw error;
40
+ // Receipt evidence is optional; a resource-limited combined query or an
41
+ // error confined to its sibling must not prevent the ordinary snapshot.
42
+ result = await graphqlWithRateLimit(BATCH_PR_QUERY, variables);
43
+ }
20
44
  const raw = requireRawPr(result.data, pr, repo);
21
- await hydrateMergeQueueChecks(raw, repo);
22
- const paged = await paginateBatchConnections(pr, repo, raw, opts, result.rateLimit);
23
- const rawThreadPages = await hydrateThreadCommentPages(paged.threads);
24
- const data = parseRawPr(raw, rawThreadPages, paged.comments, paged.changesRequested, paged.reviewSummaries, paged.approvedReviews, paged.checks, result.data.repository);
45
+ const queueRateLimit = await hydrateMergeQueueChecks(raw, repo, result.rateLimit);
46
+ const paged = await paginateBatchConnections(pr, repo, raw, opts, queueRateLimit);
47
+ const threadPages = await hydrateThreadCommentPages(paged.threads, paged.rateLimit);
48
+ let receiptSummary = null;
49
+ if (opts.includeReceiptSummary) {
50
+ try {
51
+ receiptSummary = await prepareBatchReceiptEvidence(result.data.repository?.receiptSummary, raw, paged.checks, repo);
52
+ }
53
+ catch {
54
+ // Malformed supplemental evidence must not discard a complete BatchPr.
55
+ }
56
+ }
57
+ const data = parseRawPr(raw, threadPages.threads, paged.comments, paged.changesRequested, paged.reviewSummaries, paged.approvedReviews, paged.checks, result.data.repository);
25
58
  const viewerLogin = result.data.viewer?.login;
26
59
  if (viewerLogin)
27
60
  data.viewerLogin = viewerLogin;
@@ -29,10 +62,11 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
29
62
  return {
30
63
  data,
31
64
  fingerprint: fingerprintFromRaw(raw, result.data.repository?.viewerPermission ?? null, result.data.viewer?.login ?? null),
32
- rateLimit: paged.rateLimit ?? result.rateLimit,
65
+ rateLimit: threadPages.rateLimit ?? paged.rateLimit ?? result.rateLimit,
33
66
  ...(parseCheckSuitesComplete(raw) && { checkSuitesComplete: true }),
34
67
  ...(parseHeadCheckSuitesEmpty(raw) && { headCheckSuitesEmpty: true }),
35
68
  ...workflowSuites(raw),
69
+ ...(receiptSummary && { receiptSummary }),
36
70
  };
37
71
  }
38
72
  function workflowSuites(raw) {
@@ -27,12 +27,15 @@ export declare class GitHubRequestError extends ShepherdError {
27
27
  readonly retryAfterSeconds?: number;
28
28
  readonly graphqlErrors?: GitHubGraphQlError[];
29
29
  readonly authSource?: string;
30
+ /** Response text without a request path; safe input for throttle classification. */
31
+ readonly responseMessage?: string;
30
32
  constructor(message: string, opts: {
31
33
  status: number;
32
34
  rateLimit?: RateLimitInfo;
33
35
  retryAfterSeconds?: number;
34
36
  graphqlErrors?: GitHubGraphQlError[];
35
37
  authSource?: string;
38
+ responseMessage?: string;
36
39
  /**
37
40
  * Bypasses status-based classification entirely — for callers that already
38
41
  * know the failure kind better than the HTTP status can express (e.g. a
@@ -1,4 +1,5 @@
1
1
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
+ import { rateLimitKind } from "./rate-limit-kind.mjs";
2
3
  // GitHub's GraphQL API reports field-level permission failures (e.g. a fine-grained
3
4
  // PAT missing a scope) as an `errors[].message` entry at HTTP 200, not as an HTTP
4
5
  // 401/403 — the transport-level request succeeded even though one field could not
@@ -47,16 +48,21 @@ export function isRetryableGraphQlResourceLimit(graphqlErrors) {
47
48
  return GRAPHQL_RESOURCE_LIMIT_MESSAGE.test(error.message);
48
49
  });
49
50
  }
50
- function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
51
+ function classifyStatus(status, message, responseMessage, rateLimit, retryAfterSeconds, graphqlErrors) {
51
52
  // Retry signals take priority over everything else: GitHub's secondary rate limit
52
53
  // returns 403 with a Retry-After header, which is a transient throttle — not the
53
54
  // permission-denied 403 a bad/missing token produces. Treat any retry signal as
54
55
  // TEMPFAIL first so it isn't shadowed by the checks below.
55
- const rateLimitExhausted = rateLimit !== undefined && rateLimit.remaining <= 0;
56
- if (status === 429 ||
56
+ const throttle = rateLimitKind({
57
+ status,
58
+ message,
59
+ responseMessage,
60
+ rateLimit,
61
+ retryAfterSeconds,
62
+ graphqlErrors,
63
+ });
64
+ if (throttle !== null ||
57
65
  status >= 500 ||
58
- retryAfterSeconds !== undefined ||
59
- rateLimitExhausted ||
60
66
  isRetryableGraphQlInternal(graphqlErrors) ||
61
67
  isRetryableGraphQlResourceLimit(graphqlErrors)) {
62
68
  return EXIT.TEMPFAIL;
@@ -75,14 +81,17 @@ export class GitHubRequestError extends ShepherdError {
75
81
  retryAfterSeconds;
76
82
  graphqlErrors;
77
83
  authSource;
84
+ /** Response text without a request path; safe input for throttle classification. */
85
+ responseMessage;
78
86
  constructor(message, opts) {
79
87
  super(message, opts.exitCodeOverride ??
80
- classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
88
+ classifyStatus(opts.status, message, opts.responseMessage, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
81
89
  this.name = "GitHubRequestError";
82
90
  this.status = opts.status;
83
91
  this.rateLimit = opts.rateLimit;
84
92
  this.retryAfterSeconds = opts.retryAfterSeconds;
85
93
  this.graphqlErrors = opts.graphqlErrors;
86
94
  this.authSource = opts.authSource;
95
+ this.responseMessage = opts.responseMessage;
87
96
  }
88
97
  }
@@ -174,6 +174,7 @@ query BatchPrPage(
174
174
  title
175
175
  summary
176
176
  annotations(first: 1) {
177
+ totalCount
177
178
  nodes {
178
179
  message
179
180
  }
@@ -279,6 +279,7 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
279
279
  title
280
280
  summary
281
281
  annotations(first: 1) {
282
+ totalCount
282
283
  nodes {
283
284
  message
284
285
  }
@@ -4,6 +4,14 @@ query PollSummaryAnnotationProbe(
4
4
  $oid: GitObjectID!
5
5
  $before: String
6
6
  ) {
7
+ _shepherdRateLimit: rateLimit {
8
+ cost
9
+ limit
10
+ nodeCount
11
+ remaining
12
+ resetAt
13
+ used
14
+ }
7
15
  repository(owner: $owner, name: $repo) {
8
16
  object(oid: $oid) {
9
17
  __typename
@@ -0,0 +1,25 @@
1
+ query ReplyThreadComments($id: ID!, $cursor: String!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
10
+ node(id: $id) {
11
+ __typename
12
+ ... on PullRequestReviewThread {
13
+ id
14
+ comments(first: 100, after: $cursor) {
15
+ pageInfo {
16
+ hasNextPage
17
+ endCursor
18
+ }
19
+ nodes {
20
+ body
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,31 @@
1
+ query ReplyThreadTranscripts($ids: [ID!]!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
10
+ nodes(ids: $ids) {
11
+ __typename
12
+ ... on PullRequestReviewThread {
13
+ id
14
+ pullRequest {
15
+ number
16
+ repository {
17
+ nameWithOwner
18
+ }
19
+ }
20
+ comments(first: 100) {
21
+ pageInfo {
22
+ hasNextPage
23
+ endCursor
24
+ }
25
+ nodes {
26
+ body
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
@@ -1,4 +1,5 @@
1
+ import { type RateLimitInfo } from "./client.mts";
1
2
  import type { RepoInfo } from "./client.mts";
2
3
  import type { RawPr } from "./batch-raw-types.mts";
3
4
  /** Hydrate all status contexts for the active or most recently removed queue commit. */
4
- export declare function hydrateMergeQueueChecks(raw: RawPr, repo: RepoInfo): Promise<void>;
5
+ export declare function hydrateMergeQueueChecks(raw: RawPr, repo: RepoInfo, initialRateLimit?: RateLimitInfo): Promise<RateLimitInfo | undefined>;
@@ -1,5 +1,6 @@
1
1
  import { parseCreatedAt } from "./batch-parser-helpers.mjs";
2
- import { graphql } from "./client.mjs";
2
+ import { graphqlWithRateLimit } from "./client.mjs";
3
+ import { GitHubRequestError } from "./errors.mjs";
3
4
  import { headPushUnixFromCheckNodes, queueRemovalAppliesToHead, } from "./queue-removal-freshness.mjs";
4
5
  import { requireContextNodes } from "./batch-response.mjs";
5
6
  import { COMMIT_CHECK_CONTEXTS_QUERY } from "./queries.mjs";
@@ -18,13 +19,20 @@ function initialQueueCursor(existing, oid) {
18
19
  return null;
19
20
  return nextPageCursor(existing, oid);
20
21
  }
21
- async function fetchQueuePage(oid, repo, cursor) {
22
- const result = await graphql(COMMIT_CHECK_CONTEXTS_QUERY, {
22
+ async function fetchQueuePage(oid, repo, cursor, gate) {
23
+ if (gate.rateLimit?.remaining === 0) {
24
+ throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; merge queue check pagination incomplete", {
25
+ status: 403,
26
+ rateLimit: gate.rateLimit,
27
+ });
28
+ }
29
+ const result = await graphqlWithRateLimit(COMMIT_CHECK_CONTEXTS_QUERY, {
23
30
  owner: repo.owner,
24
31
  repo: repo.name,
25
32
  oid,
26
33
  ...(cursor !== null && { cursor }),
27
34
  });
35
+ gate.rateLimit = result.rateLimit ?? gate.rateLimit;
28
36
  const object = result.data.repository?.object;
29
37
  if (object?.__typename !== "Commit" || object.oid !== oid) {
30
38
  if (cursor === null)
@@ -33,13 +41,13 @@ async function fetchQueuePage(oid, repo, cursor) {
33
41
  }
34
42
  return object.statusCheckRollup?.contexts ?? null;
35
43
  }
36
- async function hydrateCommitContexts(commit, repo) {
44
+ async function hydrateCommitContexts(commit, repo, gate) {
37
45
  const existing = commit.statusCheckRollup?.contexts;
38
46
  const nodes = existing ? [...requireContextNodes(existing.nodes)] : [];
39
47
  let cursor = initialQueueCursor(existing, commit.oid);
40
48
  while (cursor !== undefined) {
41
49
  // eslint-disable-next-line no-await-in-loop
42
- const next = await fetchQueuePage(commit.oid, repo, cursor);
50
+ const next = await fetchQueuePage(commit.oid, repo, cursor, gate);
43
51
  if (!next) {
44
52
  if (cursor === null) {
45
53
  cursor = undefined;
@@ -78,11 +86,13 @@ function currentRemovalCommit(raw) {
78
86
  return removal.beforeCommit;
79
87
  }
80
88
  /** Hydrate all status contexts for the active or most recently removed queue commit. */
81
- export async function hydrateMergeQueueChecks(raw, repo) {
89
+ export async function hydrateMergeQueueChecks(raw, repo, initialRateLimit) {
90
+ const gate = { rateLimit: initialRateLimit };
82
91
  const active = raw.mergeQueueEntry?.headCommit;
83
92
  const removed = currentRemovalCommit(raw);
84
93
  if (active)
85
- await hydrateCommitContexts(active, repo);
94
+ await hydrateCommitContexts(active, repo, gate);
86
95
  if (removed && removed.oid !== active?.oid)
87
- await hydrateCommitContexts(removed, repo);
96
+ await hydrateCommitContexts(removed, repo, gate);
97
+ return gate.rateLimit;
88
98
  }
@@ -1,3 +1,4 @@
1
+ import type { CheckExecutionContext } from "../commands/check-execution-context.mts";
1
2
  export interface MergeTargetStatus {
2
3
  contexts: string[];
3
4
  trunkBehindBy?: number;
@@ -17,7 +18,7 @@ export declare function loadMergeTargetStatus(input: {
17
18
  stack?: {
18
19
  baseRefName: string;
19
20
  } | null;
20
- }): Promise<MergeTargetStatus>;
21
+ }, context?: CheckExecutionContext): Promise<MergeTargetStatus>;
21
22
  /**
22
23
  * Commits on the PR base that `headRef` does not contain.
23
24
  * Pass the head commit OID. A fork's branch name can exist on the base
@@ -8,14 +8,14 @@ import { readStackTopology } from "./stack-read.mjs";
8
8
  * Required status contexts for the branch GitHub actually merges into.
9
9
  * A native stack uses the trunk ref, and `behindBy` is that trunk against the bottom open layer.
10
10
  */
11
- export async function loadMergeTargetStatus(input) {
11
+ export async function loadMergeTargetStatus(input, context) {
12
12
  const trunk = input.stack?.baseRefName;
13
13
  if (!trunk)
14
14
  return { contexts: [...input.localContexts] };
15
15
  let headRef = input.headRefName;
16
16
  let stackBottomPr = input.pr;
17
17
  if (trunk !== input.baseRefName) {
18
- const bottom = await bottomOpenLayer(input.pr, { owner: input.owner, name: input.name }, trunk);
18
+ const bottom = await bottomOpenLayer(input.pr, { owner: input.owner, name: input.name }, trunk, context);
19
19
  headRef = bottom.headRefName;
20
20
  stackBottomPr = bottom.number;
21
21
  }
@@ -26,8 +26,8 @@ export async function loadMergeTargetStatus(input) {
26
26
  stackBottomPr,
27
27
  };
28
28
  }
29
- async function bottomOpenLayer(pr, repo, trunk) {
30
- const topology = await readStackTopology(pr, repo);
29
+ async function bottomOpenLayer(pr, repo, trunk, context) {
30
+ const topology = await (context?.readStackTopology(pr, repo) ?? readStackTopology(pr, repo));
31
31
  const bottom = topology.ordered.find((member) => member.state === "OPEN" && member.baseRefName === trunk);
32
32
  if (!bottom) {
33
33
  throw new ShepherdError(`Native stack for PR #${pr} has no open layer based on ${trunk}`, EXIT.TEMPFAIL);
@@ -1,5 +1,7 @@
1
1
  import { type RepoInfo } from "./client.mts";
2
2
  import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
+ /** Annotation totals copied from the same full-snapshot request; no follow-up probe is needed. */
4
+ export declare function markReadyAnnotationProbeComplete(pr: RawSummaryPr): void;
3
5
  /**
4
6
  * True when a ready layer's annotation probe failed or stopped short of every
5
7
  * check page. Callers must not store or compare a fingerprint of that snapshot:
@@ -4,6 +4,11 @@ import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
4
4
  import { fingerprintRawSummaryPr } from "./poll-summary-fingerprint.mjs";
5
5
  import { POLL_SUMMARY_ANNOTATION_PROBE_QUERY } from "./queries.mjs";
6
6
  const PROBE_UNAVAILABLE = Symbol.for("prShepherd.annotationProbeUnavailable");
7
+ const COMPLETE_FROM_BATCH = new WeakSet();
8
+ /** Annotation totals copied from the same full-snapshot request; no follow-up probe is needed. */
9
+ export function markReadyAnnotationProbeComplete(pr) {
10
+ COMPLETE_FROM_BATCH.add(pr);
11
+ }
7
12
  /**
8
13
  * True when a ready layer's annotation probe failed or stopped short of every
9
14
  * check page. Callers must not store or compare a fingerprint of that snapshot:
@@ -24,6 +29,8 @@ export function readyFingerprint(raw, stored) {
24
29
  * a check gains an annotation.
25
30
  */
26
31
  export async function hydrateReadyAnnotationProbe(pr, repo, review) {
32
+ if (COMPLETE_FROM_BATCH.has(pr))
33
+ return;
27
34
  const checks = summarizePollSummaryChecks(pr);
28
35
  if (!isCurrentSummaryReady(pr, checks, review))
29
36
  return;
@@ -0,0 +1,11 @@
1
+ import { type RepoInfo } from "./client.mts";
2
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
+ /**
4
+ * Overlay REST mergeability onto an open summary PR whose GraphQL snapshot is `UNKNOWN`, in place.
5
+ *
6
+ * GraphQL keeps reporting `UNKNOWN` while GitHub recomputes mergeability after the
7
+ * base moves; REST `GET /pulls/{n}` triggers that computation and usually answers
8
+ * `dirty` at once. Without this, a layer that just gained a conflict routes as
9
+ * pending until a later tick, matching `iterate`'s `refreshUnknownMergeability`.
10
+ */
11
+ export declare function refreshUnknownSummaryMergeability(raw: RawSummaryPr, repo: RepoInfo): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import { getMergeableState } from "./client.mjs";
2
+ /**
3
+ * Overlay REST mergeability onto an open summary PR whose GraphQL snapshot is `UNKNOWN`, in place.
4
+ *
5
+ * GraphQL keeps reporting `UNKNOWN` while GitHub recomputes mergeability after the
6
+ * base moves; REST `GET /pulls/{n}` triggers that computation and usually answers
7
+ * `dirty` at once. Without this, a layer that just gained a conflict routes as
8
+ * pending until a later tick, matching `iterate`'s `refreshUnknownMergeability`.
9
+ */
10
+ export async function refreshUnknownSummaryMergeability(raw, repo) {
11
+ if (raw.state !== "OPEN")
12
+ return;
13
+ if (raw.mergeable !== "UNKNOWN" && raw.mergeStateStatus !== "UNKNOWN")
14
+ return;
15
+ const rest = await getMergeableState(raw.number, repo.owner, repo.name);
16
+ // REST can still be computing; keep a field GraphQL already knew rather than erase it.
17
+ if (rest.mergeable !== "UNKNOWN")
18
+ raw.mergeable = rest.mergeable;
19
+ if (rest.mergeStateStatus !== "UNKNOWN")
20
+ raw.mergeStateStatus = rest.mergeStateStatus;
21
+ if (rest.state !== undefined)
22
+ raw.state = rest.state;
23
+ }
@@ -2,6 +2,7 @@ import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
2
  import { graphqlWithRateLimit } from "./client.mjs";
3
3
  import { GitHubRequestError, isRetryableGraphQlResourceLimit, missingRepositoryError, } from "./errors.mjs";
4
4
  import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
5
+ import { refreshUnknownSummaryMergeability } from "./poll-summary-mergeability.mjs";
5
6
  import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
6
7
  import { trunkRequiredContexts } from "./poll-summary-unreported.mjs";
7
8
  import { POLL_STACK_SUMMARY_QUERY, POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
@@ -52,8 +53,10 @@ async function fetchExplicitChunk(prs, repo) {
52
53
  throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
53
54
  return raw;
54
55
  });
55
- for (const raw of rawPrs)
56
+ for (const raw of rawPrs) {
56
57
  await hydratePollSummaryChecks(raw, repo);
58
+ await refreshUnknownSummaryMergeability(raw, repo);
59
+ }
57
60
  return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
58
61
  }
59
62
  /**
@@ -64,8 +67,10 @@ async function fetchStackSummary(opts, repo) {
64
67
  const anchor = opts.stackPrNumber;
65
68
  const topology = await readStackTopology(anchor, repo);
66
69
  const { stackNumber, stackSize, viewerLogin, viewerCanAdminister, ordered, allowedMergeMethods } = await readStackSummary(anchor, repo, topology.stackSize);
67
- for (const pr of ordered)
70
+ for (const pr of ordered) {
68
71
  await hydratePollSummaryChecks(pr, repo);
72
+ await refreshUnknownSummaryMergeability(pr, repo);
73
+ }
69
74
  const stackAncestry = stackAncestryGaps(ordered);
70
75
  const required = trunkRequiredContexts(ordered);
71
76
  return {
@@ -14,6 +14,8 @@ 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
+ /** Exact compact summary sibling only when a one-PR READY receipt needs evidence. */
18
+ export declare const BATCH_PR_RECEIPT_QUERY: string;
17
19
  /** Trunk branch rules plus how far `headRef` is behind that branch. */
18
20
  export declare const REF_RULES_QUERY: string;
19
21
  /** How far a non-stack head is behind its PR base. One compare, no rules payload. */
@@ -41,6 +43,9 @@ export declare const UPPER_LAYER_CONFLICT_TARGET_QUERY: string;
41
43
  export declare const SUGGESTION_THREADS_QUERY: string;
42
44
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
43
45
  export declare const REVIEW_THREAD_COMMENTS_QUERY: string;
46
+ /** Requested review-thread transcripts for best-effort reply seen markers. */
47
+ export declare const REPLY_THREAD_TRANSCRIPTS_QUERY: string;
48
+ export declare const REPLY_THREAD_COMMENTS_QUERY: string;
44
49
  /** Fetch inline annotations for a single CheckRun by node ID. */
45
50
  export declare const CHECK_RUN_ANNOTATIONS_QUERY: string;
46
51
  /**
@@ -19,6 +19,8 @@ export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"
19
19
  const POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT = gql("poll-summary-check-contexts.gql");
20
20
  /** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
21
21
  export const POLL_SUMMARY_FRAGMENT = `${gql("ref-rules.gql")}\n${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}`;
22
+ /** Exact compact summary sibling only when a one-PR READY receipt needs evidence. */
23
+ export const BATCH_PR_RECEIPT_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}\n${BATCH_PR_QUERY.replace(" pullRequest(number: $pr) {", " receiptSummary: pullRequest(number: $pr) { ...PollSummaryPr }\n pullRequest(number: $pr) {")}`;
22
24
  /** Trunk branch rules plus how far `headRef` is behind that branch. */
23
25
  export const REF_RULES_QUERY = `${gql("ref-rules.gql")}\n${gql("ref-rules-query.gql")}`;
24
26
  /** How far a non-stack head is behind its PR base. One compare, no rules payload. */
@@ -46,6 +48,9 @@ export const UPPER_LAYER_CONFLICT_TARGET_QUERY = gql("upper-layer-conflict-targe
46
48
  export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
47
49
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
48
50
  export const REVIEW_THREAD_COMMENTS_QUERY = gql("review-thread-comments.gql");
51
+ /** Requested review-thread transcripts for best-effort reply seen markers. */
52
+ export const REPLY_THREAD_TRANSCRIPTS_QUERY = gql("reply-thread-transcripts.gql");
53
+ export const REPLY_THREAD_COMMENTS_QUERY = gql("reply-thread-comments.gql");
49
54
  /** Fetch inline annotations for a single CheckRun by node ID. */
50
55
  export const CHECK_RUN_ANNOTATIONS_QUERY = gql("check-run-annotations.gql");
51
56
  /**