pr-shepherd 0.55.1 → 0.56.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 (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -9
  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/help-command-pages.d.mts +1 -1
  18. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  19. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  20. package/bin/cli/help.d.mts +1 -1
  21. package/bin/cli/iterate-checks-formatter.mjs +2 -0
  22. package/bin/cli/iterate-instructions.mjs +1 -1
  23. package/bin/cli/related-jobs-format.d.mts +3 -0
  24. package/bin/cli/related-jobs-format.mjs +16 -0
  25. package/bin/commands/check-execution-context.d.mts +12 -0
  26. package/bin/commands/check-execution-context.mjs +38 -0
  27. package/bin/commands/check-fingerprint.mjs +14 -8
  28. package/bin/commands/check-unreported.d.mts +3 -2
  29. package/bin/commands/check-unreported.mjs +4 -4
  30. package/bin/commands/check.d.mts +2 -1
  31. package/bin/commands/check.mjs +21 -6
  32. package/bin/commands/commit-suggestion-instruction.mjs +2 -1
  33. package/bin/commands/iterate/check-instructions.d.mts +9 -5
  34. package/bin/commands/iterate/check-instructions.mjs +15 -35
  35. package/bin/commands/iterate/escalate.mjs +3 -0
  36. package/bin/commands/iterate/fix-code.mjs +6 -2
  37. package/bin/commands/iterate/helpers.mjs +1 -0
  38. package/bin/commands/iterate/index.mjs +20 -8
  39. package/bin/commands/iterate/native-stack-rebase.mjs +3 -2
  40. package/bin/commands/iterate/render.mjs +13 -5
  41. package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
  42. package/bin/commands/iterate/stale-ancestry.mjs +3 -2
  43. package/bin/commands/iterate/unreported-required.mjs +1 -1
  44. package/bin/commands/playbook-pointer.d.mts +2 -0
  45. package/bin/commands/playbook-pointer.mjs +4 -0
  46. package/bin/commands/poll-quota.d.mts +2 -0
  47. package/bin/commands/poll-quota.mjs +18 -25
  48. package/bin/commands/poll-rate-limit-wait.mjs +13 -8
  49. package/bin/commands/poll-summary-instructions.mjs +1 -1
  50. package/bin/commands/ready-delay.d.mts +2 -0
  51. package/bin/commands/ready-delay.mjs +18 -0
  52. package/bin/commands/resolve-mutate.mjs +8 -9
  53. package/bin/commands/shepherd-journal.d.mts +1 -1
  54. package/bin/commands/shepherd-journal.mjs +3 -2
  55. package/bin/commands/stack-drain.mjs +2 -1
  56. package/bin/github/batch-raw-types.d.mts +2 -0
  57. package/bin/github/batch-receipt-evidence.d.mts +5 -0
  58. package/bin/github/batch-receipt-evidence.mjs +61 -0
  59. package/bin/github/batch.d.mts +4 -0
  60. package/bin/github/batch.mjs +42 -8
  61. package/bin/github/errors.d.mts +3 -0
  62. package/bin/github/errors.mjs +15 -6
  63. package/bin/github/gql/batch-pr-page.gql +1 -0
  64. package/bin/github/gql/batch-pr.gql +1 -0
  65. package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
  66. package/bin/github/gql/reply-thread-comments.gql +25 -0
  67. package/bin/github/gql/reply-thread-transcripts.gql +31 -0
  68. package/bin/github/merge-queue-checks.d.mts +2 -1
  69. package/bin/github/merge-queue-checks.mjs +18 -8
  70. package/bin/github/merge-target-rules.d.mts +2 -1
  71. package/bin/github/merge-target-rules.mjs +4 -4
  72. package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
  73. package/bin/github/poll-summary-annotation-probe.mjs +7 -0
  74. package/bin/github/queries.d.mts +5 -0
  75. package/bin/github/queries.mjs +5 -0
  76. package/bin/github/rate-limit-kind.d.mts +13 -0
  77. package/bin/github/rate-limit-kind.mjs +25 -0
  78. package/bin/github/reply-thread-transcripts.d.mts +3 -0
  79. package/bin/github/reply-thread-transcripts.mjs +89 -0
  80. package/bin/github/rest-http.mjs +1 -0
  81. package/bin/github/rest-text.d.mts +2 -1
  82. package/bin/github/rest-text.mjs +5 -2
  83. package/bin/github/thread-comments.d.mts +5 -1
  84. package/bin/github/thread-comments.mjs +30 -6
  85. package/bin/mcp/server.mjs +32 -5
  86. package/bin/quota-warning.mjs +2 -2
  87. package/bin/reporters/agent.mjs +1 -0
  88. package/bin/threads/transcript.d.mts +1 -0
  89. package/bin/threads/transcript.mjs +4 -1
  90. package/bin/types/check-classification.d.mts +10 -0
  91. package/bin/types/report.d.mts +4 -1
  92. package/package.json +2 -2
  93. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  94. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  95. package/plugins/pr-shepherd/.mcp.json +1 -1
  96. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +57 -70
  97. package/plugins/pr-shepherd/skills/pr-shepherd/references/branch-update.md +9 -0
  98. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +21 -0
  99. package/plugins/pr-shepherd/skills/pr-shepherd/references/journal.md +7 -0
  100. package/plugins/pr-shepherd/skills/pr-shepherd/references/review-mutations.md +9 -0
  101. package/plugins/pr-shepherd/skills/pr-shepherd/references/stack-merge.md +7 -0
  102. package/plugins/pr-shepherd/skills/pr-shepherd/references/suggestion-patches.md +10 -0
@@ -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;
@@ -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
  /**
@@ -0,0 +1,13 @@
1
+ import type { RateLimitInfo } from "./http-utils.mts";
2
+ export type RateLimitKind = "primary" | "secondary";
3
+ /** Interpret GitHub's throttle signals independently of the quota resource header. */
4
+ export declare function rateLimitKind(input: {
5
+ status: number;
6
+ message: string;
7
+ responseMessage?: string;
8
+ rateLimit?: RateLimitInfo;
9
+ retryAfterSeconds?: number;
10
+ graphqlErrors?: Array<{
11
+ message: string;
12
+ }>;
13
+ }): RateLimitKind | null;
@@ -0,0 +1,25 @@
1
+ /** Interpret GitHub's throttle signals independently of the quota resource header. */
2
+ export function rateLimitKind(input) {
3
+ const exhausted = input.rateLimit !== undefined && input.rateLimit.remaining <= 0;
4
+ const responseText = input.responseMessage ?? input.message;
5
+ const graphqlMessages = (input.graphqlErrors ?? []).map((error) => error.message);
6
+ const textThrottleCapable = input.status === 403 || input.status === 429;
7
+ const explicitlySecondary = /secondary (?:rate )?limit|abuse detection/i;
8
+ if ((textThrottleCapable && explicitlySecondary.test(responseText)) ||
9
+ graphqlMessages.some((message) => explicitlySecondary.test(message))) {
10
+ return "secondary";
11
+ }
12
+ // A 429 with an empty primary bucket is an actual primary-limit response.
13
+ if (input.status === 429 && exhausted)
14
+ return "primary";
15
+ if (exhausted)
16
+ return "primary";
17
+ if (input.status === 429 ||
18
+ input.retryAfterSeconds !== undefined ||
19
+ (textThrottleCapable && /\brate limit\b/i.test(responseText)) ||
20
+ graphqlMessages.some((message) => /\brate limit\b/i.test(message))) {
21
+ // Without a measured empty bucket, do not spend REST core on a probe.
22
+ return "secondary";
23
+ }
24
+ return null;
25
+ }
@@ -0,0 +1,3 @@
1
+ import { type RepoInfo } from "./client.mts";
2
+ /** Best-effort transcript evidence; it never filters user-supplied mutation IDs. */
3
+ export declare function fetchReplyThreadTranscripts(pr: number, repo: RepoInfo, requestedIds: readonly string[]): Promise<Map<string, string>>;
@@ -0,0 +1,89 @@
1
+ import { graphqlWithRateLimit } from "./client.mjs";
2
+ import { GitHubRequestError } from "./errors.mjs";
3
+ import { rateLimitKind } from "./rate-limit-kind.mjs";
4
+ import { REPLY_THREAD_COMMENTS_QUERY, REPLY_THREAD_TRANSCRIPTS_QUERY } from "./queries.mjs";
5
+ import { threadTranscriptBodies } from "../threads/transcript.mjs";
6
+ import { mapPool } from "../util/pool.mjs";
7
+ const IDS_PER_REQUEST = 20;
8
+ const CONCURRENCY = 4;
9
+ const MAX_COMMENT_PAGES = 100;
10
+ /** Best-effort transcript evidence; it never filters user-supplied mutation IDs. */
11
+ export async function fetchReplyThreadTranscripts(pr, repo, requestedIds) {
12
+ const ids = [...new Set(requestedIds)];
13
+ if (ids.length === 0)
14
+ return new Map();
15
+ const expectedRepo = `${repo.owner}/${repo.name}`.toLowerCase();
16
+ const requested = new Set(ids);
17
+ const chunks = [];
18
+ for (let offset = 0; offset < ids.length; offset += IDS_PER_REQUEST) {
19
+ chunks.push(ids.slice(offset, offset + IDS_PER_REQUEST));
20
+ }
21
+ let stopped = false;
22
+ const firstPages = await mapPool(chunks, CONCURRENCY, async (chunk) => {
23
+ if (stopped)
24
+ return [];
25
+ try {
26
+ const result = await graphqlWithRateLimit(REPLY_THREAD_TRANSCRIPTS_QUERY, {
27
+ ids: chunk,
28
+ });
29
+ if (result.rateLimit?.remaining === 0)
30
+ stopped = true;
31
+ return result.data.nodes.filter((node) => node?.__typename === "PullRequestReviewThread" &&
32
+ node.id !== undefined &&
33
+ requested.has(node.id) &&
34
+ node.pullRequest?.number === pr &&
35
+ node.pullRequest.repository.nameWithOwner.toLowerCase() === expectedRepo &&
36
+ node.comments !== undefined);
37
+ }
38
+ catch (error) {
39
+ if (isThrottle(error))
40
+ stopped = true;
41
+ return [];
42
+ }
43
+ });
44
+ const threads = firstPages.flat();
45
+ const completed = await mapPool(threads, CONCURRENCY, async (thread) => {
46
+ try {
47
+ return await completeThread(thread, () => stopped, () => {
48
+ stopped = true;
49
+ });
50
+ }
51
+ catch (error) {
52
+ if (isThrottle(error))
53
+ stopped = true;
54
+ return null;
55
+ }
56
+ });
57
+ return new Map(completed.filter((entry) => entry !== null));
58
+ }
59
+ async function completeThread(thread, isStopped, stop) {
60
+ if (!thread.id || !thread.comments)
61
+ return null;
62
+ const bodies = thread.comments.nodes.map((comment) => comment.body);
63
+ let page = thread.comments;
64
+ const seenCursors = new Set();
65
+ let pages = 1;
66
+ while (page.pageInfo.hasNextPage) {
67
+ const cursor = page.pageInfo.endCursor;
68
+ if (!cursor || seenCursors.has(cursor) || pages >= MAX_COMMENT_PAGES || isStopped())
69
+ return null;
70
+ seenCursors.add(cursor);
71
+ const result = await graphqlWithRateLimit(REPLY_THREAD_COMMENTS_QUERY, {
72
+ id: thread.id,
73
+ cursor,
74
+ });
75
+ if (result.rateLimit?.remaining === 0)
76
+ stop();
77
+ const next = result.data.node;
78
+ if (next?.__typename !== "PullRequestReviewThread" || next.id !== thread.id || !next.comments) {
79
+ return null;
80
+ }
81
+ page = next.comments;
82
+ bodies.push(...page.nodes.map((comment) => comment.body));
83
+ pages += 1;
84
+ }
85
+ return bodies.length > 0 ? [thread.id, threadTranscriptBodies(bodies)] : null;
86
+ }
87
+ function isThrottle(error) {
88
+ return error instanceof GitHubRequestError && rateLimitKind(error) !== null;
89
+ }
@@ -98,6 +98,7 @@ export async function restWithRateLimit(method, path, body, opts) {
98
98
  rateLimit,
99
99
  retryAfterSeconds,
100
100
  authSource,
101
+ responseMessage: sanitizeBody(text),
101
102
  });
102
103
  }
103
104
  if (ct.includes("application/json")) {
@@ -1 +1,2 @@
1
- export declare function restText(path: string): Promise<string>;
1
+ import { type RateLimitInfo } from "./http-utils.mts";
2
+ export declare function restText(path: string, onRateLimit?: (rateLimit?: RateLimitInfo) => void): Promise<string>;
@@ -3,11 +3,11 @@ import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
3
  import { GitHubRequestError } from "./errors.mjs";
4
4
  import { makeAuthHeaders } from "./http-auth.mjs";
5
5
  import { requestWithTokenRetry } from "./http-request.mjs";
6
- import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody } from "./http-utils.mjs";
6
+ import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody, } from "./http-utils.mjs";
7
7
  import { recordApiTelemetry } from "./api-telemetry.mjs";
8
8
  import { recordIntermediateResponse } from "./http-intermediate.mjs";
9
9
  const BASE_URL = "https://api.github.com";
10
- export async function restText(path) {
10
+ export async function restText(path, onRateLimit) {
11
11
  const url = `${BASE_URL}${path}`;
12
12
  const n = nextEntry();
13
13
  appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
@@ -28,6 +28,7 @@ export async function restText(path) {
28
28
  }));
29
29
  const durationMs = Math.round(performance.now() - retryT0);
30
30
  const rateLimit = parseRateLimit(res.headers) ?? undefined;
31
+ onRateLimit?.(rateLimit);
31
32
  const retryAfterSeconds = parseRetryAfter(res.headers);
32
33
  recordApiTelemetry({ kind: "REST", method: "GET", authSource, rateLimit });
33
34
  if ([301, 302, 307, 308].includes(res.status)) {
@@ -62,6 +63,7 @@ export async function restText(path) {
62
63
  rateLimit,
63
64
  retryAfterSeconds,
64
65
  authSource,
66
+ responseMessage: sanitizeBody(text),
65
67
  });
66
68
  }
67
69
  appendEntry(formatResponseEntry({
@@ -114,6 +116,7 @@ async function followRestTextRedirect(res, entry) {
114
116
  status: redirectRes.status,
115
117
  rateLimit: parseRateLimit(redirectRes.headers) ?? undefined,
116
118
  retryAfterSeconds: parseRetryAfter(redirectRes.headers),
119
+ responseMessage: "",
117
120
  });
118
121
  }
119
122
  return redirectRes.text();
@@ -1,2 +1,6 @@
1
+ import { type RateLimitInfo } from "./client.mts";
1
2
  import type { RawThread } from "./batch-raw-types.mts";
2
- export declare function hydrateThreadCommentPages(threads: RawThread[]): Promise<RawThread[]>;
3
+ export declare function hydrateThreadCommentPages(threads: RawThread[], initialRateLimit?: RateLimitInfo): Promise<{
4
+ threads: RawThread[];
5
+ rateLimit?: RateLimitInfo;
6
+ }>;
@@ -4,23 +4,47 @@ import { paginateForward } from "./pagination.mjs";
4
4
  import { REVIEW_THREAD_COMMENTS_QUERY } from "./queries.mjs";
5
5
  import { mapPool } from "../util/pool.mjs";
6
6
  const THREAD_COMMENT_PAGE_CONCURRENCY = 4;
7
- export async function hydrateThreadCommentPages(threads) {
8
- const gate = {};
9
- return mapPool(threads, THREAD_COMMENT_PAGE_CONCURRENCY, (thread) => hydrateThreadCommentPage(thread, gate));
7
+ export async function hydrateThreadCommentPages(threads, initialRateLimit) {
8
+ const gate = {
9
+ rateLimit: initialRateLimit,
10
+ exhausted: initialRateLimit?.remaining === 0,
11
+ };
12
+ const hydrated = await mapPool(threads, THREAD_COMMENT_PAGE_CONCURRENCY, async (thread) => {
13
+ if (gate.error !== undefined)
14
+ return thread;
15
+ try {
16
+ return await hydrateThreadCommentPage(thread, gate);
17
+ }
18
+ catch (error) {
19
+ gate.error ??= error;
20
+ return thread;
21
+ }
22
+ });
23
+ if (gate.error !== undefined)
24
+ throw gate.error;
25
+ return { threads: hydrated, rateLimit: gate.rateLimit };
10
26
  }
11
27
  async function hydrateThreadCommentPage(thread, gate) {
12
28
  const pageInfo = thread.comments.pageInfo;
13
29
  if (!pageInfo?.hasNextPage || !pageInfo.endCursor)
14
30
  return thread;
15
31
  const extra = await paginateForward(async (cursor) => {
16
- if (gate.remaining === 0) {
17
- throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; thread comment pagination incomplete", { status: 403 });
32
+ if (gate.error !== undefined)
33
+ throw gate.error;
34
+ if (gate.exhausted) {
35
+ throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; thread comment pagination incomplete", { status: 403, rateLimit: gate.rateLimit });
18
36
  }
19
37
  const res = await graphqlWithRateLimit(REVIEW_THREAD_COMMENTS_QUERY, {
20
38
  threadId: thread.id,
21
39
  ...(cursor ? { commentsCursor: cursor } : {}),
22
40
  });
23
- gate.remaining = res.rateLimit?.remaining;
41
+ if (res.rateLimit?.remaining === 0) {
42
+ gate.rateLimit = res.rateLimit;
43
+ gate.exhausted = true;
44
+ }
45
+ else if (!gate.exhausted) {
46
+ gate.rateLimit = res.rateLimit ?? gate.rateLimit;
47
+ }
24
48
  const node = res.data.node;
25
49
  if (!node?.comments) {
26
50
  const nodeType = node?.__typename ?? "null";
@@ -10,6 +10,7 @@ import { formatPollSummaryResult } from "../cli/poll-summary-formatter.mjs";
10
10
  import { projectStackOverview } from "../cli/stack-overview.mjs";
11
11
  import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
12
12
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
13
+ import { extractShepherdJournal } from "../journal/index.mjs";
13
14
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
14
15
  const pr = z
15
16
  .string()
@@ -87,7 +88,8 @@ const suggestionPatchesInputSchema = z.object({
87
88
  });
88
89
  /** Creates a local-only MCP server with Shepherd's public operations. */
89
90
  export function createPrShepherdMcpServer(options = {}) {
90
- const shepherd = options.shepherd ?? createPrShepherd({ cwd: options.cwd });
91
+ let shepherd = options.shepherd;
92
+ const getShepherd = () => (shepherd ??= createPrShepherd({ cwd: options.cwd }));
91
93
  const server = new McpServer({ name: "pr-shepherd", version: readPackageVersion() });
92
94
  server.registerTool("iterate", {
93
95
  description: "Inspect one pull request, an explicit same-repository set, or a native stack and return one Shepherd tick.",
@@ -103,7 +105,7 @@ export function createPrShepherdMcpServer(options = {}) {
103
105
  const opts = {
104
106
  readyDelaySuffix: input.readyDelaySeconds === undefined ? undefined : `${input.readyDelaySeconds}s`,
105
107
  };
106
- return runTool(() => runIterateSelector(shepherd, requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
108
+ return runTool(() => runIterateSelector(getShepherd(), requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
107
109
  ? formatPollSummaryResult(result)
108
110
  : formatIterateResult(result, opts), (result) => isPollSummary(result)
109
111
  ? result.selection.kind === "stack"
@@ -120,7 +122,32 @@ export function createPrShepherdMcpServer(options = {}) {
120
122
  idempotentHint: false,
121
123
  openWorldHint: true,
122
124
  },
123
- }, async (input) => runTool(() => shepherd.apply(requireRepositoryQualifiedPr(input)), formatApplyResult));
125
+ }, async (input) => runTool(() => getShepherd().apply(requireRepositoryQualifiedPr(input)), formatApplyResult));
126
+ server.registerTool("extract_journal", {
127
+ description: "Extract the validated Shepherd Journal from a supplied Markdown body without I/O.",
128
+ inputSchema: z.object({ body: z.string() }),
129
+ annotations: {
130
+ readOnlyHint: true,
131
+ destructiveHint: false,
132
+ idempotentHint: true,
133
+ openWorldHint: false,
134
+ },
135
+ }, async (input) => runTool(async () => {
136
+ if (typeof input.body !== "string") {
137
+ throw new PrShepherdValidationError("body must be a Markdown string");
138
+ }
139
+ return extractShepherdJournal(input.body);
140
+ }, JSON.stringify));
141
+ server.registerTool("get_journal", {
142
+ description: "Fetch one pull request body with GraphQL and extract its Shepherd Journal.",
143
+ inputSchema: z.object({ pr }),
144
+ annotations: {
145
+ readOnlyHint: true,
146
+ destructiveHint: false,
147
+ idempotentHint: true,
148
+ openWorldHint: true,
149
+ },
150
+ }, async (input) => runTool(() => getShepherd().getJournal(requireRepositoryQualifiedPr(input)), JSON.stringify));
124
151
  server.registerTool("build_suggestion_patches", {
125
152
  description: "Build, but never apply, an ordered list of eligible review suggestion patches.",
126
153
  inputSchema: suggestionPatchesInputSchema,
@@ -130,7 +157,7 @@ export function createPrShepherdMcpServer(options = {}) {
130
157
  idempotentHint: true,
131
158
  openWorldHint: true,
132
159
  },
133
- }, async (input) => runTool(() => shepherd.buildSuggestionPatches(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
160
+ }, async (input) => runTool(() => getShepherd().buildSuggestionPatches(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
134
161
  server.registerTool("build_suggestion_patch", {
135
162
  description: "Deprecated: use build_suggestion_patches with a one-item suggestions array.",
136
163
  inputSchema: suggestionPatchInputSchema,
@@ -140,7 +167,7 @@ export function createPrShepherdMcpServer(options = {}) {
140
167
  idempotentHint: true,
141
168
  openWorldHint: true,
142
169
  },
143
- }, async (input) => runTool(() => shepherd.buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
170
+ }, async (input) => runTool(() => getShepherd().buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
144
171
  return server;
145
172
  }
146
173
  function runIterateSelector(shepherd, input) {
@@ -5,8 +5,8 @@ export function buildQuotaAwareContinuation(warning, prefix) {
5
5
  const opening = warning.resource === "combined"
6
6
  ? "GitHub's GraphQL and REST core quotas are both low. Keep using pr-shepherd at the cadence below. Do not shift incidental calls between GraphQL and REST."
7
7
  : warning.resource === "core"
8
- ? `GitHub's REST core quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below. Do not add incidental REST \`gh\` calls (\`gh pr view\`, \`gh pr review\`, \`gh api\`) while REST core is below its warning threshold.`
9
- : `GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR operations that do not need Shepherd's full snapshot, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr review\`, \`gh api\` REST endpoints) — they draw on the separate REST budget, not the depleted GraphQL pool.`;
8
+ ? `GitHub's REST core quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below. Do not add incidental REST \`gh api repos/OWNER/REPO/pulls/PR\` calls while REST core is below its warning threshold.`
9
+ : `GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR reads that do not need Shepherd's full snapshot, use explicit REST endpoints such as \`gh api repos/OWNER/REPO/pulls/PR\` — they draw on the separate REST budget, not the depleted GraphQL pool.`;
10
10
  const resetLabel = warning.resource === "combined"
11
11
  ? "both quotas have reset"
12
12
  : warning.resource === "core"
@@ -66,6 +66,7 @@ export function toAgentCheck(c) {
66
66
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
67
67
  ...(c.summary !== undefined && { summary: c.summary }),
68
68
  ...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
69
+ ...(c.relatedJobs !== undefined && { relatedJobs: c.relatedJobs }),
69
70
  ...(c.annotations !== undefined && { annotations: c.annotations }),
70
71
  ...(c.scope !== undefined && { scope: c.scope }),
71
72
  ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
@@ -13,4 +13,5 @@ export declare function threadComments(thread: {
13
13
  viewerDidAuthor?: true;
14
14
  } & Partial<Pick<ReviewThreadComment, "isMinimized" | "createdAtUnix">>>;
15
15
  }): ReviewThreadComment[];
16
+ export declare function threadTranscriptBodies(bodies: string[]): string;
16
17
  export declare function threadTranscriptBody(thread: ReviewThread, appendedBodies?: string[]): string;
@@ -29,9 +29,12 @@ export function threadComments(thread) {
29
29
  ];
30
30
  }
31
31
  const THREAD_COMMENT_SEPARATOR = "\n\n--- thread comment ---\n\n";
32
+ export function threadTranscriptBodies(bodies) {
33
+ return bodies.join(THREAD_COMMENT_SEPARATOR);
34
+ }
32
35
  export function threadTranscriptBody(thread, appendedBodies = []) {
33
36
  const bodies = thread.comments && thread.comments.length > 0
34
37
  ? threadComments(thread).map((c) => c.body)
35
38
  : [thread.body];
36
- return [...bodies, ...appendedBodies].join(THREAD_COMMENT_SEPARATOR);
39
+ return threadTranscriptBodies([...bodies, ...appendedBodies]);
37
40
  }