pr-shepherd 0.55.2 → 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 (83) 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/queries.d.mts +5 -0
  61. package/bin/github/queries.mjs +5 -0
  62. package/bin/github/rate-limit-kind.d.mts +13 -0
  63. package/bin/github/rate-limit-kind.mjs +25 -0
  64. package/bin/github/reply-thread-transcripts.d.mts +3 -0
  65. package/bin/github/reply-thread-transcripts.mjs +89 -0
  66. package/bin/github/rest-http.mjs +1 -0
  67. package/bin/github/rest-text.d.mts +2 -1
  68. package/bin/github/rest-text.mjs +5 -2
  69. package/bin/github/thread-comments.d.mts +5 -1
  70. package/bin/github/thread-comments.mjs +30 -6
  71. package/bin/mcp/server.mjs +32 -5
  72. package/bin/quota-warning.mjs +2 -2
  73. package/bin/reporters/agent.mjs +1 -0
  74. package/bin/threads/transcript.d.mts +1 -0
  75. package/bin/threads/transcript.mjs +4 -1
  76. package/bin/types/check-classification.d.mts +10 -0
  77. package/bin/types/report.d.mts +4 -1
  78. package/package.json +1 -1
  79. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  80. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  81. package/plugins/pr-shepherd/.mcp.json +1 -1
  82. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -1
  83. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +2 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.55.2",
4
+ "version": "0.56.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -27,7 +27,7 @@ Full reference: [docs/README.md](docs/README.md). Feature matrix: [docs/features
27
27
 
28
28
  `pr-shepherd` moves deterministic PR orchestration into a local MCP server, with a CLI for shells and CI. Both interfaces fetch the same GitHub state, emit raw-enough context, and return a numbered plan for the calling agent to follow.
29
29
 
30
- The MCP server exposes canonical `iterate`, `apply`, and `build_suggestion_patches` tools. `apply` accepts ordered review mutations, file-view mutations, and journal entries; the deprecated singular suggestion tool remains temporarily as an adapter. Direct MCP calls require a repository-qualified `pr`: a GitHub PR URL or `owner/repo#N`; the explicit repository is the target for GitHub I/O, even when it differs from the local checkout. The CLI and programmatic API also retain bare-number and current-branch PR discovery. The shipped skills are thin dispatchers for those tools.
30
+ The MCP server exposes `iterate`, `apply`, `build_suggestion_patches`, `extract_journal`, and `get_journal`. `apply` accepts ordered review mutations, file-view mutations, and journal entries; the deprecated singular suggestion tool remains temporarily as an adapter. PR-targeted MCP calls require a repository-qualified `pr`: a GitHub PR URL or `owner/repo#N`; the explicit repository is the target for GitHub I/O, even when it differs from the local checkout. `extract_journal` takes a Markdown body string and performs no I/O. The CLI and programmatic API also retain bare-number and current-branch PR discovery. The shipped skills are thin dispatchers for those tools.
31
31
 
32
32
  Each tick returns exactly one action:
33
33
 
@@ -122,7 +122,7 @@ Grok:
122
122
  /pr-shepherd 42
123
123
  ```
124
124
 
125
- MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patches` for anchored suggestions. Every direct MCP call supplies the same repository-qualified PR reference. `iterate` returns the same structured action data as the CLI, including its review mutation arguments. The client owns recurrence, so this works consistently in Codex, Claude Code, Grok, and any other stdio MCP client.
125
+ MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patches` for anchored suggestions. To read journal entries, call `extract_journal` with a body already in hand or `get_journal` with a repository-qualified PR reference. `iterate` returns the same structured action data as the CLI, including its review mutation arguments. The client owns recurrence, so this works consistently in Codex, Claude Code, Grok, and any other stdio MCP client.
126
126
 
127
127
  The CLI remains useful for shell workflows. Its canonical polling form is:
128
128
 
@@ -189,6 +189,10 @@ complete Markdown list item with LF line endings. It fails closed for malformed
189
189
  containers and ignores journal-shaped examples hidden in Markdown constructs. The full journal API,
190
190
  including append and reconciliation helpers, is documented in [docs/api.md](docs/api.md).
191
191
 
192
+ MCP clients can call `extract_journal({ body: prBody })` for the same pure result without writing a
193
+ file, or `get_journal({ pr: "owner/repo#123" })` to fetch a PR body through GraphQL and extract it.
194
+ Both return the typed extraction JSON in `structuredContent` and `content`; neither mutates the PR.
195
+
192
196
  For shell automation that already has a PR body, use the equivalent local-only command:
193
197
 
194
198
  ```sh
package/bin/api.d.mts CHANGED
@@ -2,6 +2,7 @@ import { type JournalResult } from "./commands/journal/index.mts";
2
2
  import { type MarkFilesAsViewedResult } from "./commands/mark-files-as-viewed.mts";
3
3
  import type { ResolveResult } from "./comments/resolve.mts";
4
4
  import type { BuildSuggestionPatchesResult, CommitSuggestionResult, IterateCommandOptions, IterateResult, PollSummaryResult } from "./types.mts";
5
+ import { type ShepherdJournalExtraction } from "./journal/index.mts";
5
6
  export interface CreatePrShepherdOptions {
6
7
  /** Working directory used for git, config, and classification-rule lookups. */
7
8
  cwd?: string;
@@ -81,12 +82,16 @@ export interface BuildSuggestionPatchesInput {
81
82
  pr?: PrReference;
82
83
  suggestions: SuggestionPatchInput[];
83
84
  }
85
+ export interface GetJournalInput {
86
+ pr: PrReference;
87
+ }
84
88
  export interface PrShepherd {
85
89
  iterate(input?: SingleIterateInput): Promise<IterateResult>;
86
90
  iterate(input: AggregateIterateInput): Promise<PollSummaryResult>;
87
91
  iterate(input: IterateInput): Promise<IterateResult | PollSummaryResult>;
88
92
  apply(input: ApplyInput): Promise<ApplyResult>;
89
93
  buildSuggestionPatches(input: BuildSuggestionPatchesInput): Promise<BuildSuggestionPatchesResult>;
94
+ getJournal(input: GetJournalInput): Promise<ShepherdJournalExtraction>;
90
95
  /** Compatibility adapter; prefer buildSuggestionPatches. */
91
96
  buildSuggestionPatch(input: BuildSuggestionPatchInput): Promise<CommitSuggestionResult>;
92
97
  }
package/bin/api.mjs CHANGED
@@ -10,7 +10,8 @@ import { runMarkFilesAsViewed, } from "./commands/mark-files-as-viewed.mjs";
10
10
  import { runResolveMutate } from "./commands/resolve-mutate.mjs";
11
11
  import { runWithExecutionCwd } from "./execution-context.mjs";
12
12
  import { parsePrReference, normalizeRepositoryIdentity, resolveParsedPrTarget, } from "./pr-reference.mjs";
13
- import { getRepoInfo } from "./github/client.mjs";
13
+ import { getPullRequestBody, getRepoInfo } from "./github/client.mjs";
14
+ import { extractShepherdJournal } from "./journal/index.mjs";
14
15
  /** Raised before any API mutation when an input cannot be validated. */
15
16
  export class PrShepherdValidationError extends Error {
16
17
  constructor(message) {
@@ -50,6 +51,16 @@ export function createPrShepherd(options = {}) {
50
51
  }
51
52
  return Object.freeze({
52
53
  iterate,
54
+ getJournal(input) {
55
+ return runWithExecutionCwd(cwd, async () => {
56
+ const { prNumber, targetRepository } = resolvePrReference(input.pr);
57
+ if (!prNumber)
58
+ throw new PrShepherdValidationError("getJournal requires a PR reference");
59
+ const { owner, name } = targetRepository ?? (await getRepoInfo());
60
+ const { body } = await getPullRequestBody(prNumber, owner, name);
61
+ return extractShepherdJournal(body);
62
+ });
63
+ },
53
64
  apply(input) {
54
65
  return runWithExecutionCwd(cwd, async () => {
55
66
  validateApplyInput(input);
@@ -0,0 +1,9 @@
1
+ import type { RepoInfo } from "../github/client.mts";
2
+ import { type StateKey } from "../state/rest-cache.mts";
3
+ import type { TriageBudget } from "./triage-budget.mts";
4
+ /**
5
+ * `cacheable` gates the cross-tick cache — only set once the matched job has
6
+ * a terminal conclusion, so an in-progress job's (possibly partial) log
7
+ * never gets frozen into the cache.
8
+ */
9
+ export declare function fetchJobLogExcerpt(jobId: number, repo: RepoInfo, stateKey?: StateKey, cacheable?: boolean, budget?: TriageBudget): Promise<string | undefined>;
@@ -0,0 +1,30 @@
1
+ import { restText } from "../github/http.mjs";
2
+ import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
3
+ import { buildLogExcerpt } from "./log-excerpt.mjs";
4
+ /**
5
+ * `cacheable` gates the cross-tick cache — only set once the matched job has
6
+ * a terminal conclusion, so an in-progress job's (possibly partial) log
7
+ * never gets frozen into the cache.
8
+ */
9
+ export async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false, budget) {
10
+ const cacheName = `joblog-v2-${jobId}`;
11
+ if (stateKey && cacheable) {
12
+ const cached = await loadDerived(stateKey, cacheName);
13
+ if (cached)
14
+ return cached.value ?? undefined;
15
+ }
16
+ const { owner, name } = repo;
17
+ try {
18
+ if (!budget?.canScheduleOptional())
19
+ return undefined;
20
+ const excerpt = buildLogExcerpt(await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`, (rateLimit) => budget?.observe(rateLimit)));
21
+ if (stateKey && cacheable) {
22
+ await storeDerived(stateKey, cacheName, excerpt ?? null);
23
+ }
24
+ return excerpt;
25
+ }
26
+ catch (error) {
27
+ budget?.observeError(error);
28
+ return undefined;
29
+ }
30
+ }
@@ -0,0 +1,14 @@
1
+ export interface ActionsJob {
2
+ id?: number;
3
+ name: string;
4
+ workflow_name?: string;
5
+ conclusion: string | null;
6
+ run_attempt?: number;
7
+ steps?: Array<{
8
+ name: string;
9
+ number: number;
10
+ conclusion: string | null;
11
+ }>;
12
+ }
13
+ /** Name of the first step with a non-success, non-skipped, non-neutral conclusion. */
14
+ export declare function pickFailedStep(job: ActionsJob): string | undefined;
@@ -0,0 +1,7 @@
1
+ /** Name of the first step with a non-success, non-skipped, non-neutral conclusion. */
2
+ export function pickFailedStep(job) {
3
+ return job.steps?.find((s) => s.conclusion !== null &&
4
+ s.conclusion !== "success" &&
5
+ s.conclusion !== "skipped" &&
6
+ s.conclusion !== "neutral")?.name;
7
+ }
@@ -0,0 +1,11 @@
1
+ import type { RepoInfo } from "../github/client.mts";
2
+ import type { StateKey } from "../state/rest-cache.mts";
3
+ import type { RelatedFailedJob } from "../types/check-classification.mts";
4
+ import { type ActionsJob } from "./jobs-types.mts";
5
+ import type { TriageBudget } from "./triage-budget.mts";
6
+ /**
7
+ * Other failed jobs in the same workflow run, excluding the matched job and any
8
+ * job already surfaced as its own failing check (`surfacedNames`).
9
+ */
10
+ export declare function pickRelatedFailedJobs(jobs: ActionsJob[], matchedJobId: number | undefined, surfacedNames: ReadonlySet<string>): ActionsJob[];
11
+ export declare function fetchRelatedJobs(jobs: ActionsJob[], repo: RepoInfo, stateKey?: StateKey, budget?: TriageBudget): Promise<RelatedFailedJob[]>;
@@ -0,0 +1,32 @@
1
+ import { fetchJobLogExcerpt } from "./job-log.mjs";
2
+ import { pickFailedStep } from "./jobs-types.mjs";
3
+ /** Max sibling failed jobs reported (and log-fetched) per workflow run. */
4
+ const MAX_RELATED_JOBS = 5;
5
+ const FAILED_JOB_CONCLUSIONS = new Set(["failure", "timed_out"]);
6
+ /**
7
+ * Other failed jobs in the same workflow run, excluding the matched job and any
8
+ * job already surfaced as its own failing check (`surfacedNames`).
9
+ */
10
+ export function pickRelatedFailedJobs(jobs, matchedJobId, surfacedNames) {
11
+ return jobs
12
+ .filter((j) => j.conclusion !== null &&
13
+ FAILED_JOB_CONCLUSIONS.has(j.conclusion) &&
14
+ (j.id === undefined || j.id !== matchedJobId) &&
15
+ !surfacedNames.has(j.name))
16
+ .slice(0, MAX_RELATED_JOBS);
17
+ }
18
+ export async function fetchRelatedJobs(jobs, repo, stateKey, budget) {
19
+ const logs = await Promise.all(jobs.map((job) => job.id === undefined
20
+ ? undefined
21
+ : fetchJobLogExcerpt(job.id, repo, stateKey, job.conclusion !== null, budget)));
22
+ return jobs.map((job, i) => {
23
+ const failedStep = pickFailedStep(job);
24
+ const logExcerpt = logs[i];
25
+ return {
26
+ name: job.name,
27
+ conclusion: (job.conclusion ?? "").toUpperCase(),
28
+ ...(failedStep !== undefined && { failedStep }),
29
+ ...(logExcerpt !== undefined && { logExcerpt }),
30
+ };
31
+ });
32
+ }
@@ -0,0 +1,17 @@
1
+ import type { RateLimitInfo } from "../github/client.mts";
2
+ /** One optional Actions-enrichment budget for the entire PR snapshot. */
3
+ export declare class TriageBudget {
4
+ private exhausted;
5
+ private secondaryError;
6
+ private omitted;
7
+ private omissionReported;
8
+ get canSchedule(): boolean;
9
+ get primaryExhausted(): boolean;
10
+ /** Call only when a needed optional request is about to be scheduled. */
11
+ canScheduleOptional(): boolean;
12
+ observe(rateLimit?: RateLimitInfo): void;
13
+ observeError(error: unknown): void;
14
+ throwIfSecondary(): void;
15
+ reportOmissionIfNeeded(): void;
16
+ private stopForPrimaryLimit;
17
+ }
@@ -0,0 +1,49 @@
1
+ import { pollRateLimitRetryAfterMs } from "../commands/poll-quota.mjs";
2
+ /** One optional Actions-enrichment budget for the entire PR snapshot. */
3
+ export class TriageBudget {
4
+ exhausted = false;
5
+ secondaryError;
6
+ omitted = false;
7
+ omissionReported = false;
8
+ get canSchedule() {
9
+ return !this.exhausted && this.secondaryError === undefined;
10
+ }
11
+ get primaryExhausted() {
12
+ return this.exhausted;
13
+ }
14
+ /** Call only when a needed optional request is about to be scheduled. */
15
+ canScheduleOptional() {
16
+ if (this.canSchedule)
17
+ return true;
18
+ if (this.exhausted)
19
+ this.omitted = true;
20
+ return false;
21
+ }
22
+ observe(rateLimit) {
23
+ if (rateLimit?.remaining === 0)
24
+ this.stopForPrimaryLimit();
25
+ }
26
+ observeError(error) {
27
+ const retry = pollRateLimitRetryAfterMs(error);
28
+ if (retry?.kind === "secondary") {
29
+ this.secondaryError ??= error;
30
+ }
31
+ else if (retry?.kind === "primary") {
32
+ this.stopForPrimaryLimit();
33
+ this.omitted = true;
34
+ }
35
+ }
36
+ throwIfSecondary() {
37
+ if (this.secondaryError !== undefined)
38
+ throw this.secondaryError;
39
+ }
40
+ reportOmissionIfNeeded() {
41
+ if (!this.omitted || this.omissionReported || this.secondaryError !== undefined)
42
+ return;
43
+ this.omissionReported = true;
44
+ process.stderr.write("pr-shepherd: REST core quota is exhausted; optional Actions job and log enrichment is incomplete\n");
45
+ }
46
+ stopForPrimaryLimit() {
47
+ this.exhausted = true;
48
+ }
49
+ }
@@ -1,5 +1,8 @@
1
1
  import type { CheckRun, ClassifiedCheck, TriagedCheck } from "../types.mts";
2
2
  import type { RepoInfo } from "../github/client.mts";
3
- import { type StateKey } from "../state/rest-cache.mts";
4
- export declare function triageFailingChecks(failingChecks: ClassifiedCheck[], repo: RepoInfo, stateKey?: StateKey): Promise<TriagedCheck[]>;
5
- export declare function fetchStartupFailureChecks(repo: RepoInfo, headSha: string, prNumber: number, stateKey?: StateKey): Promise<CheckRun[]>;
3
+ import type { StateKey } from "../state/rest-cache.mts";
4
+ import { TriageBudget } from "./triage-budget.mts";
5
+ export declare function triageFailingChecks(failingChecks: ClassifiedCheck[], repo: RepoInfo, stateKey?: StateKey, budget?: TriageBudget,
6
+ /** Non-failing checks (ignored, filtered, superseded, …) whose jobs must not resurface as related jobs. */
7
+ otherChecks?: ClassifiedCheck[]): Promise<TriagedCheck[]>;
8
+ export declare function fetchStartupFailureChecks(repo: RepoInfo, headSha: string, prNumber: number, stateKey?: StateKey, budget?: TriageBudget): Promise<CheckRun[]>;
@@ -1,22 +1,38 @@
1
1
  /* eslint-disable max-lines */
2
- import { restWithRateLimit, restText } from "../github/http.mjs";
3
- import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
4
- import { buildLogExcerpt } from "./log-excerpt.mjs";
2
+ import { restWithRateLimit } from "../github/http.mjs";
3
+ import { fetchJobLogExcerpt } from "./job-log.mjs";
4
+ import { pickFailedStep } from "./jobs-types.mjs";
5
+ import { pickRelatedFailedJobs, fetchRelatedJobs } from "./related-jobs.mjs";
6
+ import { TriageBudget } from "./triage-budget.mjs";
7
+ import { mapPool } from "../util/pool.mjs";
5
8
  const STARTUP_FAILURE_STATUS = "startup_failure";
6
- export function triageFailingChecks(failingChecks, repo, stateKey) {
9
+ export async function triageFailingChecks(failingChecks, repo, stateKey, budget = new TriageBudget(),
10
+ /** Non-failing checks (ignored, filtered, superseded, …) whose jobs must not resurface as related jobs. */
11
+ otherChecks = []) {
7
12
  const jobsCache = new Map();
8
- return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, stateKey)));
13
+ const surfaced = surfacedNamesByRun([...failingChecks, ...otherChecks]);
14
+ const relatedOwners = firstCheckPerRun(failingChecks);
15
+ const checks = await mapPool(failingChecks, 4, (check) => triageCheck(check, repo, jobsCache, stateKey, budget, relatedOwners.has(check) ? surfaced.get(check.runId ?? "") : undefined));
16
+ budget.throwIfSecondary();
17
+ budget.reportOmissionIfNeeded();
18
+ return checks;
9
19
  }
10
- async function triageCheck(check, repo, jobsCache, stateKey) {
20
+ async function triageCheck(check, repo, jobsCache, stateKey, budget, relatedSurfaced) {
11
21
  if (check.runId === null || check.conclusion === "STARTUP_FAILURE") {
12
22
  return { ...check };
13
23
  }
14
- const jobs = await fetchJobs(check.runId, repo, jobsCache, stateKey);
24
+ if (!jobsCache.has(check.runId) && !budget?.canScheduleOptional()) {
25
+ return { ...check };
26
+ }
27
+ const jobs = await fetchJobs(check.runId, repo, jobsCache, stateKey, budget);
15
28
  const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
16
29
  const runAttempt = jobs ? pickRunAttempt(jobs) : undefined;
17
30
  const logExcerpt = check.conclusion !== "CANCELLED" && jobInfo?.jobId
18
- ? await fetchJobLogExcerpt(jobInfo.jobId, repo, stateKey, jobInfo.jobConclusion != null)
31
+ ? await fetchJobLogExcerpt(jobInfo.jobId, repo, stateKey, jobInfo.jobConclusion != null, budget)
19
32
  : undefined;
33
+ const relatedJobs = jobs && relatedSurfaced && check.conclusion !== "CANCELLED"
34
+ ? await fetchRelatedJobs(pickRelatedFailedJobs(jobs, jobInfo?.jobId, relatedSurfaced), repo, stateKey, budget)
35
+ : [];
20
36
  return {
21
37
  ...check,
22
38
  ...(runAttempt !== undefined && { runAttempt }),
@@ -24,40 +40,79 @@ async function triageCheck(check, repo, jobsCache, stateKey) {
24
40
  ...(jobInfo?.jobName !== undefined && { jobName: jobInfo.jobName }),
25
41
  ...(jobInfo?.failedStep !== undefined && { failedStep: jobInfo.failedStep }),
26
42
  ...(logExcerpt !== undefined && { logExcerpt }),
43
+ ...(relatedJobs.length > 0 && { relatedJobs }),
27
44
  };
28
45
  }
29
- export async function fetchStartupFailureChecks(repo, headSha, prNumber, stateKey) {
46
+ function surfacedNamesByRun(checks) {
47
+ const byRun = new Map();
48
+ for (const check of checks) {
49
+ if (check.runId === null)
50
+ continue;
51
+ const names = byRun.get(check.runId) ?? new Set();
52
+ names.add(check.name);
53
+ byRun.set(check.runId, names);
54
+ }
55
+ return byRun;
56
+ }
57
+ /** Sibling failed jobs are reported once per run, on its first failing check. */
58
+ function firstCheckPerRun(checks) {
59
+ const seen = new Set();
60
+ const first = new Set();
61
+ for (const check of checks) {
62
+ if (check.runId === null || seen.has(check.runId))
63
+ continue;
64
+ if (check.conclusion === "CANCELLED" || check.conclusion === "STARTUP_FAILURE")
65
+ continue;
66
+ seen.add(check.runId);
67
+ first.add(check);
68
+ }
69
+ return first;
70
+ }
71
+ export async function fetchStartupFailureChecks(repo, headSha, prNumber, stateKey, budget = new TriageBudget()) {
30
72
  try {
31
- return await fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey);
73
+ return await fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey, budget);
32
74
  }
33
75
  catch (err) {
76
+ budget.observeError(err);
77
+ budget.throwIfSecondary();
78
+ if (budget.primaryExhausted)
79
+ return [];
34
80
  const msg = err instanceof Error ? err.message : String(err);
35
81
  process.stderr.write(`pr-shepherd: startup-failure run fetch failed for PR #${prNumber} at ${headSha} (ignored): ${msg}\n`);
36
82
  return [];
37
83
  }
38
84
  }
39
- async function fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey) {
85
+ async function fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey, budget) {
40
86
  const { owner, name } = repo;
41
87
  const perPage = 100;
42
88
  const MAX_RUN_PAGES = 10;
43
89
  const checks = [];
44
90
  for (let page = 1; page <= MAX_RUN_PAGES; page++) {
45
- const { data, rateLimit } = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(headSha)}&status=${STARTUP_FAILURE_STATUS}&per_page=${perPage}&page=${page}`, undefined, stateKey
46
- ? {
47
- conditional: {
48
- key: stateKey,
49
- name: `runs-startupfailure-${headSha}-p${page}`,
50
- headSha,
51
- },
52
- }
53
- : undefined);
91
+ if (!budget?.canScheduleOptional())
92
+ break;
93
+ let result;
94
+ try {
95
+ result = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(headSha)}&status=${STARTUP_FAILURE_STATUS}&per_page=${perPage}&page=${page}`, undefined, stateKey
96
+ ? {
97
+ conditional: {
98
+ key: stateKey,
99
+ name: `runs-startupfailure-${headSha}-p${page}`,
100
+ headSha,
101
+ },
102
+ }
103
+ : undefined);
104
+ }
105
+ catch (error) {
106
+ budget?.observeError(error);
107
+ if (budget?.primaryExhausted)
108
+ break;
109
+ throw error;
110
+ }
111
+ const { data, rateLimit } = result;
112
+ budget?.observe(rateLimit);
54
113
  checks.push(...data.workflow_runs
55
114
  .filter((run) => runBelongsToPr(run, prNumber, headSha))
56
115
  .map(workflowRunToCheckRun));
57
- if (rateLimit?.remaining === 0) {
58
- process.stderr.write(`pr-shepherd: REST rate limit remaining is 0 while listing startup-failure runs for ${headSha} — detection may be incomplete\n`);
59
- break;
60
- }
61
116
  if (data.workflow_runs.length < perPage)
62
117
  break;
63
118
  if (page === MAX_RUN_PAGES) {
@@ -95,15 +150,15 @@ function pickRunAttempt(jobs) {
95
150
  function runBelongsToPr(run, prNumber, headSha) {
96
151
  return (run.pull_requests ?? []).some((pr) => pr.number === prNumber && (pr.head?.sha ?? headSha) === headSha);
97
152
  }
98
- function fetchJobs(runId, repo, cache, stateKey) {
153
+ function fetchJobs(runId, repo, cache, stateKey, budget) {
99
154
  const cached = cache.get(runId);
100
155
  if (cached)
101
156
  return cached;
102
- const promise = fetchJobsUncached(runId, repo, stateKey);
157
+ const promise = fetchJobsUncached(runId, repo, stateKey, budget);
103
158
  cache.set(runId, promise);
104
159
  return promise;
105
160
  }
106
- async function fetchJobsUncached(runId, repo, stateKey) {
161
+ async function fetchJobsUncached(runId, repo, stateKey, budget) {
107
162
  const { owner, name } = repo;
108
163
  const perPage = 100;
109
164
  const MAX_JOB_PAGES = 20; // 2000 jobs max
@@ -111,6 +166,8 @@ async function fetchJobsUncached(runId, repo, stateKey) {
111
166
  const allJobs = [];
112
167
  try {
113
168
  for (let page = 1;; page++) {
169
+ if (!budget?.canScheduleOptional())
170
+ break;
114
171
  if (++pagesFetched > MAX_JOB_PAGES) {
115
172
  process.stderr.write(`pr-shepherd: job pagination cap (${MAX_JOB_PAGES * 100} jobs) reached for run ${runId} — triage may be incomplete\n`);
116
173
  break;
@@ -118,17 +175,15 @@ async function fetchJobsUncached(runId, repo, stateKey) {
118
175
  const { data, rateLimit } = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest&per_page=${perPage}&page=${page}`, undefined, stateKey
119
176
  ? { conditional: { key: stateKey, name: `jobs-run-${runId}-p${page}` } }
120
177
  : undefined);
178
+ budget?.observe(rateLimit);
121
179
  allJobs.push(...data.jobs);
122
- if (rateLimit?.remaining === 0) {
123
- process.stderr.write(`pr-shepherd: REST rate limit remaining is 0 while listing jobs for run ${runId} — triage may be incomplete\n`);
124
- break;
125
- }
126
180
  if (data.jobs.length < perPage)
127
181
  break;
128
182
  }
129
183
  }
130
- catch {
131
- return undefined;
184
+ catch (error) {
185
+ budget?.observeError(error);
186
+ return budget?.primaryExhausted ? allJobs : undefined;
132
187
  }
133
188
  return allJobs;
134
189
  }
@@ -140,10 +195,7 @@ function pickJobInfo(jobs, checkName) {
140
195
  matchedJobs[0];
141
196
  if (!job)
142
197
  return undefined;
143
- const failedStep = job.steps?.find((s) => s.conclusion !== null &&
144
- s.conclusion !== "success" &&
145
- s.conclusion !== "skipped" &&
146
- s.conclusion !== "neutral")?.name;
198
+ const failedStep = pickFailedStep(job);
147
199
  return {
148
200
  ...(job.id !== undefined && { jobId: job.id }),
149
201
  workflowName: job.workflow_name,
@@ -152,27 +204,3 @@ function pickJobInfo(jobs, checkName) {
152
204
  jobConclusion: job.conclusion,
153
205
  };
154
206
  }
155
- /**
156
- * `cacheable` gates the cross-tick cache — only set once the matched job has
157
- * a terminal conclusion, so an in-progress job's (possibly partial) log
158
- * never gets frozen into the cache.
159
- */
160
- async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
161
- const cacheName = `joblog-v2-${jobId}`;
162
- if (stateKey && cacheable) {
163
- const cached = await loadDerived(stateKey, cacheName);
164
- if (cached)
165
- return cached.value ?? undefined;
166
- }
167
- const { owner, name } = repo;
168
- try {
169
- const excerpt = buildLogExcerpt(await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`));
170
- if (stateKey && cacheable) {
171
- await storeDerived(stateKey, cacheName, excerpt ?? null);
172
- }
173
- return excerpt;
174
- }
175
- catch {
176
- return undefined;
177
- }
178
- }
@@ -31,9 +31,9 @@ function recommendation(warning) {
31
31
  return "- Recommendation: keep polling pr-shepherd at the cadence above; both GraphQL and REST core are below their warning thresholds, so do not shift work between them. Do not substitute `gh pr checks` or `gh pr watch`";
32
32
  }
33
33
  if (warning.resource === "core") {
34
- return "- Recommendation: keep polling pr-shepherd at the cadence above; do not add incidental REST `gh` calls (`gh pr view`, `gh pr review`, `gh api`) while REST core is low. Do not substitute `gh pr checks` or `gh pr watch`";
34
+ return "- Recommendation: keep polling pr-shepherd at the cadence above; do not add incidental REST `gh api repos/OWNER/REPO/pulls/PR` calls while REST core is low. Do not substitute `gh pr checks` or `gh pr watch`";
35
35
  }
36
- return "- Recommendation: keep polling pr-shepherd at the cadence above; for incidental PR operations prefer REST `gh` (`gh pr view`, `gh pr review`, `gh api`); do not substitute `gh pr checks` or `gh pr watch`";
36
+ return "- Recommendation: keep polling pr-shepherd at the cadence above; for incidental PR reads use explicit REST endpoints such as `gh api repos/OWNER/REPO/pulls/PR`; do not substitute `gh pr checks` or `gh pr watch`";
37
37
  }
38
38
  export function formatApiUsage(usage) {
39
39
  if (usage === undefined)
@@ -6,6 +6,7 @@ import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag
6
6
  import { BODY_TRUNCATE_MAX_CHARS } from "./body-truncate.mjs";
7
7
  import { numberInstructions } from "./iterate-instructions.mjs";
8
8
  import { renderCheckAnnotation, renderProtectedRun } from "./fix-formatter-extra.mjs";
9
+ import { renderRelatedJobLines } from "./related-jobs-format.mjs";
9
10
  import { isFailingAgentCheck } from "../checks/conclusions.mjs";
10
11
  import { renderMergeCommand } from "../commands/iterate/merge.mjs";
11
12
  import { partitionFixThreads } from "../commands/iterate/fix-instruction-threads.mjs";
@@ -81,6 +82,7 @@ export function formatFixCodeResult(header, result, opts = {}) {
81
82
  lines.push(` > ${ch.summary}`);
82
83
  if (ch.logExcerpt)
83
84
  lines.push(indentBlockquote(ch.logExcerpt, " "));
85
+ lines.push(...renderRelatedJobLines(ch.relatedJobs));
84
86
  }
85
87
  if (ch.rerunCommand && ch.runId && !seenRerunRunIds.has(ch.runId)) {
86
88
  seenRerunRunIds.add(ch.runId);
@@ -1,3 +1,4 @@
1
+ import { renderRelatedJobLines } from "./related-jobs-format.mjs";
1
2
  import { renderCheckAnnotation } from "./fix-formatter-extra.mjs";
2
3
  export function formatRelevantChecks(checks) {
3
4
  if (checks.length === 0)
@@ -14,6 +15,7 @@ function formatRelevantCheck(check) {
14
15
  const lines = [`- \`${workflow}${job}\` [conclusion: ${check.conclusion}]`];
15
16
  appendCheckFields(lines, check);
16
17
  appendLogExcerpt(lines, check.logExcerpt);
18
+ lines.push(...renderRelatedJobLines(check.relatedJobs));
17
19
  appendAnnotations(lines, check.annotations, check.logExcerpt);
18
20
  return lines;
19
21
  }
@@ -0,0 +1,3 @@
1
+ import type { RelatedFailedJob } from "../types/check-classification.mts";
2
+ /** Shared text rendering of sibling failed jobs under a failing check bullet. */
3
+ export declare function renderRelatedJobLines(jobs: RelatedFailedJob[] | undefined, indent?: string): string[];
@@ -0,0 +1,16 @@
1
+ /** Shared text rendering of sibling failed jobs under a failing check bullet. */
2
+ export function renderRelatedJobLines(jobs, indent = " ") {
3
+ if (!jobs || jobs.length === 0)
4
+ return [];
5
+ const lines = [`${indent}Other failed jobs in this run:`];
6
+ for (const job of jobs) {
7
+ lines.push(`${indent}- \`${job.name}\` [conclusion: ${job.conclusion}]`);
8
+ if (job.failedStep)
9
+ lines.push(`${indent} > failed step: ${job.failedStep}`);
10
+ if (job.logExcerpt) {
11
+ for (const line of job.logExcerpt.split("\n"))
12
+ lines.push(`${indent} > ${line}`);
13
+ }
14
+ }
15
+ return lines;
16
+ }
@@ -0,0 +1,12 @@
1
+ import type { RepoInfo } from "../github/client.mts";
2
+ import { readStackTopology } from "../github/stack-read.mts";
3
+ import type { RawSummaryPr } from "../github/poll-summary-raw.mts";
4
+ /** Fresh for each iterate tick; never persisted or exposed in CLI output. */
5
+ export declare function createCheckExecutionContext(readyDelaySeconds?: number): {
6
+ readStackTopology(pr: number, repo: RepoInfo): ReturnType<typeof readStackTopology>;
7
+ wantsReceiptSummary(pr: number, repo: RepoInfo): Promise<boolean>;
8
+ setReceiptSummary(raw: RawSummaryPr | null): void;
9
+ getReceiptSummary(): RawSummaryPr | null;
10
+ invalidateReceiptSummary(): void;
11
+ };
12
+ export type CheckExecutionContext = ReturnType<typeof createCheckExecutionContext>;
@@ -0,0 +1,38 @@
1
+ import { readStackTopology } from "../github/stack-read.mjs";
2
+ import { readReadyReceipt } from "../state/ready-receipts.mjs";
3
+ import { readyDelayElapsed } from "./ready-delay.mjs";
4
+ /** Fresh for each iterate tick; never persisted or exposed in CLI output. */
5
+ export function createCheckExecutionContext(readyDelaySeconds) {
6
+ const topologies = new Map();
7
+ let receiptSummary = null;
8
+ let receiptSummaryInvalidated = false;
9
+ return {
10
+ readStackTopology(pr, repo) {
11
+ const key = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}#${pr}`;
12
+ let pending = topologies.get(key);
13
+ if (!pending) {
14
+ pending = readStackTopology(pr, repo);
15
+ topologies.set(key, pending);
16
+ }
17
+ return pending;
18
+ },
19
+ async wantsReceiptSummary(pr, repo) {
20
+ if (readyDelaySeconds === undefined)
21
+ return false;
22
+ const key = { owner: repo.owner, repo: repo.name, pr };
23
+ if (await readReadyReceipt(key))
24
+ return true;
25
+ return readyDelayElapsed(pr, repo.owner, repo.name, readyDelaySeconds);
26
+ },
27
+ setReceiptSummary(raw) {
28
+ receiptSummary = raw;
29
+ },
30
+ getReceiptSummary() {
31
+ return receiptSummaryInvalidated ? null : receiptSummary;
32
+ },
33
+ invalidateReceiptSummary() {
34
+ receiptSummaryInvalidated = true;
35
+ receiptSummary = null;
36
+ },
37
+ };
38
+ }