pr-shepherd 0.44.1 → 0.45.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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -4
  3. package/bin/checks/conclusions.d.mts +24 -1
  4. package/bin/checks/conclusions.mjs +39 -0
  5. package/bin/checks/triage.d.mts +3 -2
  6. package/bin/checks/triage.mjs +44 -15
  7. package/bin/cli/api-usage-formatter.d.mts +3 -0
  8. package/bin/cli/api-usage-formatter.mjs +36 -0
  9. package/bin/cli/error-format.d.mts +13 -0
  10. package/bin/cli/error-format.mjs +41 -0
  11. package/bin/cli/fix-formatter.mjs +12 -1
  12. package/bin/cli/iterate-activity-formatter.d.mts +2 -0
  13. package/bin/cli/iterate-activity-formatter.mjs +26 -0
  14. package/bin/cli/iterate-formatter.mjs +11 -28
  15. package/bin/cli/iterate-instructions.mjs +14 -1
  16. package/bin/cli/iterate-lean.mjs +1 -0
  17. package/bin/cli/mutate-formatter.mjs +2 -0
  18. package/bin/commands/check-annotations.d.mts +2 -1
  19. package/bin/commands/check-annotations.mjs +7 -7
  20. package/bin/commands/check.mjs +6 -4
  21. package/bin/commands/iterate/api-usage.d.mts +2 -0
  22. package/bin/commands/iterate/api-usage.mjs +42 -0
  23. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  24. package/bin/commands/iterate/check-instructions.mjs +27 -8
  25. package/bin/commands/iterate/fix-code.mjs +42 -8
  26. package/bin/commands/iterate/index.mjs +6 -3
  27. package/bin/commands/iterate/merge-state.mjs +5 -2
  28. package/bin/commands/iterate/render.d.mts +1 -1
  29. package/bin/commands/iterate/render.mjs +9 -5
  30. package/bin/commands/iterate/run.d.mts +2 -0
  31. package/bin/commands/iterate/run.mjs +8 -0
  32. package/bin/commands/mark-files-as-viewed.mjs +8 -0
  33. package/bin/commands/poll-run.d.mts +2 -0
  34. package/bin/commands/poll-run.mjs +8 -0
  35. package/bin/commands/poll.d.mts +1 -2
  36. package/bin/commands/poll.mjs +29 -10
  37. package/bin/comments/rate-limit.d.mts +4 -0
  38. package/bin/comments/rate-limit.mjs +6 -0
  39. package/bin/config/load.d.mts +6 -1
  40. package/bin/config/load.mjs +33 -1
  41. package/bin/config.json +6 -1
  42. package/bin/github/api-telemetry-aggregate.d.mts +32 -0
  43. package/bin/github/api-telemetry-aggregate.mjs +84 -0
  44. package/bin/github/api-telemetry.d.mts +13 -0
  45. package/bin/github/api-telemetry.mjs +128 -0
  46. package/bin/github/check-annotations.d.mts +14 -1
  47. package/bin/github/check-annotations.mjs +29 -2
  48. package/bin/github/errors.d.mts +2 -0
  49. package/bin/github/errors.mjs +2 -0
  50. package/bin/github/gql/batch-pr-page.gql +8 -0
  51. package/bin/github/gql/batch-pr.gql +8 -0
  52. package/bin/github/gql/check-run-annotations.gql +8 -0
  53. package/bin/github/gql/commit-check-contexts.gql +8 -0
  54. package/bin/github/gql/get-pr-body.gql +8 -0
  55. package/bin/github/gql/get-pr-head-sha.gql +8 -0
  56. package/bin/github/gql/pr-number-by-branch.gql +8 -0
  57. package/bin/github/gql/review-thread-comments.gql +8 -0
  58. package/bin/github/gql/suggestion-threads.gql +8 -0
  59. package/bin/github/graphql-http.mjs +63 -8
  60. package/bin/github/http-auth.d.mts +9 -1
  61. package/bin/github/http-auth.mjs +35 -14
  62. package/bin/github/http-intermediate.d.mts +9 -0
  63. package/bin/github/http-intermediate.mjs +20 -0
  64. package/bin/github/http-request.d.mts +1 -1
  65. package/bin/github/http-request.mjs +1 -1
  66. package/bin/github/http-utils.d.mts +6 -0
  67. package/bin/github/http-utils.mjs +10 -1
  68. package/bin/github/rest-http.d.mts +16 -1
  69. package/bin/github/rest-http.mjs +71 -8
  70. package/bin/github/rest-text.mjs +41 -5
  71. package/bin/index.mjs +2 -1
  72. package/bin/log/session.d.mts +11 -0
  73. package/bin/log/session.mjs +22 -0
  74. package/bin/mcp/server.mjs +16 -2
  75. package/bin/quota-warning.d.mts +2 -0
  76. package/bin/quota-warning.mjs +6 -0
  77. package/bin/state/graphql-quota-claims.d.mts +2 -0
  78. package/bin/state/graphql-quota-claims.mjs +61 -0
  79. package/bin/state/graphql-quota-policy.d.mts +17 -0
  80. package/bin/state/graphql-quota-policy.mjs +43 -0
  81. package/bin/state/graphql-quota-warnings.d.mts +6 -0
  82. package/bin/state/graphql-quota-warnings.mjs +105 -0
  83. package/bin/state/rest-cache.d.mts +48 -0
  84. package/bin/state/rest-cache.mjs +91 -0
  85. package/bin/types/api-usage.d.mts +31 -0
  86. package/bin/types/api-usage.mjs +1 -0
  87. package/bin/types/iterate.d.mts +4 -1
  88. package/bin/types/report.d.mts +2 -0
  89. package/bin/types.d.mts +1 -0
  90. package/bin/types.mjs +1 -0
  91. package/package.json +1 -1
  92. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  93. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  94. package/plugins/pr-shepherd/.mcp.json +1 -1
  95. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
@@ -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.44.1",
4
+ "version": "0.45.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -69,12 +69,12 @@ Conversations Resolved: No [Not Required]
69
69
  1. Review each item under `## Review threads` and `## Failing checks` and decide whether it needs a code change.
70
70
  2. Apply every warranted review fix in each file referenced above.
71
71
  3. Triage every failure under `## Failing checks`. See "CI failure triage" in the pr-shepherd skill for read-only inspection rules.
72
- 4. If you changed code, commit any remaining changes, then stop and hand off for a push whose authorization is established outside Shepherd; do not run review mutations or iterate until the remote PR head changes. If you did not change code, do not commit and continue.
72
+ 4. If you changed code, commit any remaining changes and push to the PR head branch, then run review mutations using the pushed commit SHA and iterate again with the same options. If you did not change code, do not commit and continue.
73
73
  5. Run the generated thread IDs unchanged. A latest comment beginning `<!-- pr-shepherd -->` is an earlier Shepherd reply: a marked viewer-authored human thread is emitted resolve-only when authorized, while a marked other-human thread is already acknowledged and has no further mutation.
74
- 6. If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. After changed code, wait for an authorized push and use its SHA.
74
+ 6. If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, commit and push to the PR head branch first, then replace `$HEAD_SHA` with the pushed commit SHA.
75
75
  7. Replace `$DISMISS_MESSAGE` with one sentence describing what changed.
76
76
  8. Run the `apply review:` command shown above. See "Review-mutation mechanics" in the pr-shepherd skill for dismiss-ID retention.
77
- 9. `[FIX_CODE]` is conditional: after changed code, stop until an authorized push changes the remote PR head; without code changes, complete the authorized review mutations and iterate again.
77
+ 9. `[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run review mutations using the pushed commit SHA and iterate again with the same options; without code changes, complete the authorized review mutations and iterate again.
78
78
  ```
79
79
 
80
80
  See [docs/actions.md](docs/actions.md) for the complete output contract. Iterate/poll PR outcomes use exit codes `0` and `10`–`15`; command and GitHub failures use `sysexits.h` codes — [docs/exit-codes.md](docs/exit-codes.md).
@@ -88,7 +88,7 @@ This system is opinionated and works best with PRs that use required status chec
88
88
  - Shepherd identifies its own latest reply only when that comment begins `<!-- pr-shepherd -->`, not from author equality. A marked viewer-authored thread can be resolved without another reply as a retry.
89
89
  - Every review thread/comment/review summary is surfaced at least once, even if already outdated, resolved, or minimized; edited items re-surface through seen markers.
90
90
  - Draft PRs can be marked ready automatically when clean; disable with `actions.autoMarkReady: false` or `--no-auto-mark-ready`.
91
- - The CLI never performs git mutations. It may emit local commit guidance, but it does not recommend a push because GitHub viewer fields cannot verify the local Git credential.
91
+ - The CLI never performs git mutations itself — it only emits commit/push instructions for the agent to run. It recommends pushing autonomously (own-repo PRs, and fork PRs where the viewer has push access to the fork) unless GitHub's viewer fields affirmatively report no head-branch push access, in which case it hands the push off to a human instead.
92
92
  - Every GitHub mutation is permission-aware. Shepherd uses raw viewer capability fields, omits unauthorized commands, and repeats authorization checks in direct `apply` commands. Missing capability data fails closed.
93
93
  - `build_suggestion_patches` turns one or more ordered GitHub suggestion threads into checked patches and commit metadata, but never edits the working tree or git history. Local HEAD may be ahead when the live PR head is its ancestor.
94
94
 
@@ -1,4 +1,27 @@
1
- import type { CheckConclusion } from "../types.mts";
1
+ import type { CheckConclusion, ViewerAuthorization } from "../types.mts";
2
+ /**
3
+ * True when the viewer's base-repo role grants GitHub's Actions rerun capability
4
+ * (`actions: write`, which rides with push access). The rerun runs against the base
5
+ * repo where the run lives, so `repositoryPermission` — not `headRepositoryPermission`,
6
+ * which covers fork push access — is the right field to gate on.
7
+ */
8
+ export declare function canRerunWorkflows(auth: ViewerAuthorization | undefined): boolean;
9
+ /**
10
+ * True when the viewer can push commits to the PR head branch — the fork, for a fork PR
11
+ * (own-fork PRs report `headRepositoryPermission: "ADMIN"`), or the base repo otherwise.
12
+ * `viewerCanEditFiles` is GitHub's direct "can the viewer commit to this PR" signal (true
13
+ * for fork authors and for maintainers with "allow edits from maintainers" enabled) and
14
+ * takes priority when present.
15
+ *
16
+ * pr-shepherd's premise is that the caller has push access to the PR it's iterating, so
17
+ * this defaults to pushable: it withholds the autonomous push only when GitHub
18
+ * affirmatively reports no head-branch access (`viewerCanEditFiles === false` and
19
+ * `headRepositoryPermission` in `NONE`/`READ`/`TRIAGE`). An unverifiable/unknown signal —
20
+ * matching the residual risk already accepted for CI reruns (see `canRerunWorkflows`) —
21
+ * is treated as pushable rather than pre-emptively handed off; a push without access
22
+ * simply fails when attempted.
23
+ */
24
+ export declare function canPushToHead(auth: ViewerAuthorization | undefined): boolean;
2
25
  /** Failing-check rows for formatter/instructions — excludes annotation-only carriers. */
3
26
  export declare function isFailingAgentCheck(check: {
4
27
  conclusion: CheckConclusion;
@@ -1,4 +1,43 @@
1
1
  const NON_FAILING_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]);
2
+ /** Repository roles GitHub grants `actions: write` to — the exact capability a workflow-run rerun needs. */
3
+ const RERUN_CAPABLE_PERMISSIONS = new Set(["WRITE", "MAINTAIN", "ADMIN"]);
4
+ /**
5
+ * True when the viewer's base-repo role grants GitHub's Actions rerun capability
6
+ * (`actions: write`, which rides with push access). The rerun runs against the base
7
+ * repo where the run lives, so `repositoryPermission` — not `headRepositoryPermission`,
8
+ * which covers fork push access — is the right field to gate on.
9
+ */
10
+ export function canRerunWorkflows(auth) {
11
+ const permission = auth?.repositoryPermission;
12
+ return permission != null && RERUN_CAPABLE_PERMISSIONS.has(permission);
13
+ }
14
+ /** Repository roles that carry push access to the head branch. */
15
+ const PUSH_CAPABLE_PERMISSIONS = new Set(["WRITE", "MAINTAIN", "ADMIN"]);
16
+ /**
17
+ * True when the viewer can push commits to the PR head branch — the fork, for a fork PR
18
+ * (own-fork PRs report `headRepositoryPermission: "ADMIN"`), or the base repo otherwise.
19
+ * `viewerCanEditFiles` is GitHub's direct "can the viewer commit to this PR" signal (true
20
+ * for fork authors and for maintainers with "allow edits from maintainers" enabled) and
21
+ * takes priority when present.
22
+ *
23
+ * pr-shepherd's premise is that the caller has push access to the PR it's iterating, so
24
+ * this defaults to pushable: it withholds the autonomous push only when GitHub
25
+ * affirmatively reports no head-branch access (`viewerCanEditFiles === false` and
26
+ * `headRepositoryPermission` in `NONE`/`READ`/`TRIAGE`). An unverifiable/unknown signal —
27
+ * matching the residual risk already accepted for CI reruns (see `canRerunWorkflows`) —
28
+ * is treated as pushable rather than pre-emptively handed off; a push without access
29
+ * simply fails when attempted.
30
+ */
31
+ export function canPushToHead(auth) {
32
+ if (auth === undefined)
33
+ return true;
34
+ if (auth.viewerCanEditFiles === true)
35
+ return true;
36
+ const permission = auth.headRepositoryPermission;
37
+ if (permission != null && !PUSH_CAPABLE_PERMISSIONS.has(permission))
38
+ return false;
39
+ return true;
40
+ }
2
41
  /** True for conclusions that belong under `## Failing checks` (not success/skipped/neutral). */
3
42
  function isFailingCheckConclusion(conclusion) {
4
43
  return conclusion == null || !NON_FAILING_CONCLUSIONS.has(conclusion);
@@ -1,4 +1,5 @@
1
1
  import type { CheckRun, ClassifiedCheck, TriagedCheck } from "../types.mts";
2
2
  import type { RepoInfo } from "../github/client.mts";
3
- export declare function triageFailingChecks(failingChecks: ClassifiedCheck[], repo: RepoInfo): Promise<TriagedCheck[]>;
4
- export declare function fetchStartupFailureChecks(repo: RepoInfo, headSha: string, prNumber: number): Promise<CheckRun[]>;
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[]>;
@@ -1,24 +1,27 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { restWithRateLimit, restText } from "../github/http.mjs";
3
+ import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
3
4
  const STARTUP_FAILURE_STATUS = "startup_failure";
4
5
  const LOG_EXCERPT_CONTEXT_LINES = 16;
5
6
  const LOG_EXCERPT_TAIL_LINES = 28;
6
7
  const LOG_EXCERPT_MAX_CHARS = 4_000;
7
8
  const TRUNCATED_SUFFIX = "\n[truncated]";
8
9
  const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
9
- export function triageFailingChecks(failingChecks, repo) {
10
+ export function triageFailingChecks(failingChecks, repo, stateKey) {
10
11
  const jobsCache = new Map();
11
- return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
12
+ return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, stateKey)));
12
13
  }
13
- async function triageCheck(check, repo, jobsCache) {
14
+ async function triageCheck(check, repo, jobsCache, stateKey) {
14
15
  if (check.runId === null ||
15
16
  check.conclusion === "CANCELLED" ||
16
17
  check.conclusion === "STARTUP_FAILURE") {
17
18
  return { ...check };
18
19
  }
19
- const jobs = await fetchJobs(check.runId, repo, jobsCache);
20
+ const jobs = await fetchJobs(check.runId, repo, jobsCache, stateKey);
20
21
  const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
21
- const logExcerpt = jobInfo?.jobId ? await fetchJobLogExcerpt(jobInfo.jobId, repo) : undefined;
22
+ const logExcerpt = jobInfo?.jobId
23
+ ? await fetchJobLogExcerpt(jobInfo.jobId, repo, stateKey, jobInfo.jobConclusion != null)
24
+ : undefined;
22
25
  return {
23
26
  ...check,
24
27
  ...(jobInfo?.workflowName !== undefined && { workflowName: jobInfo.workflowName }),
@@ -27,9 +30,9 @@ async function triageCheck(check, repo, jobsCache) {
27
30
  ...(logExcerpt !== undefined && { logExcerpt }),
28
31
  };
29
32
  }
30
- export async function fetchStartupFailureChecks(repo, headSha, prNumber) {
33
+ export async function fetchStartupFailureChecks(repo, headSha, prNumber, stateKey) {
31
34
  try {
32
- return await fetchStartupFailureChecksUncached(repo, headSha, prNumber);
35
+ return await fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey);
33
36
  }
34
37
  catch (err) {
35
38
  const msg = err instanceof Error ? err.message : String(err);
@@ -37,13 +40,21 @@ export async function fetchStartupFailureChecks(repo, headSha, prNumber) {
37
40
  return [];
38
41
  }
39
42
  }
40
- async function fetchStartupFailureChecksUncached(repo, headSha, prNumber) {
43
+ async function fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey) {
41
44
  const { owner, name } = repo;
42
45
  const perPage = 100;
43
46
  const MAX_RUN_PAGES = 10;
44
47
  const checks = [];
45
48
  for (let page = 1; page <= MAX_RUN_PAGES; page++) {
46
- const { data, rateLimit } = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(headSha)}&status=${STARTUP_FAILURE_STATUS}&per_page=${perPage}&page=${page}`);
49
+ 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
50
+ ? {
51
+ conditional: {
52
+ key: stateKey,
53
+ name: `runs-startupfailure-${headSha}-p${page}`,
54
+ headSha,
55
+ },
56
+ }
57
+ : undefined);
47
58
  checks.push(...data.workflow_runs
48
59
  .filter((run) => runBelongsToPr(run, prNumber, headSha))
49
60
  .map(workflowRunToCheckRun));
@@ -75,15 +86,15 @@ function workflowRunToCheckRun(run) {
75
86
  function runBelongsToPr(run, prNumber, headSha) {
76
87
  return (run.pull_requests ?? []).some((pr) => pr.number === prNumber && (pr.head?.sha ?? headSha) === headSha);
77
88
  }
78
- function fetchJobs(runId, repo, cache) {
89
+ function fetchJobs(runId, repo, cache, stateKey) {
79
90
  const cached = cache.get(runId);
80
91
  if (cached)
81
92
  return cached;
82
- const promise = fetchJobsUncached(runId, repo);
93
+ const promise = fetchJobsUncached(runId, repo, stateKey);
83
94
  cache.set(runId, promise);
84
95
  return promise;
85
96
  }
86
- async function fetchJobsUncached(runId, repo) {
97
+ async function fetchJobsUncached(runId, repo, stateKey) {
87
98
  const { owner, name } = repo;
88
99
  const perPage = 100;
89
100
  const MAX_JOB_PAGES = 20; // 2000 jobs max
@@ -95,7 +106,9 @@ async function fetchJobsUncached(runId, repo) {
95
106
  process.stderr.write(`pr-shepherd: job pagination cap (${MAX_JOB_PAGES * 100} jobs) reached for run ${runId} — triage may be incomplete\n`);
96
107
  break;
97
108
  }
98
- const { data, rateLimit } = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest&per_page=${perPage}&page=${page}`);
109
+ const { data, rateLimit } = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest&per_page=${perPage}&page=${page}`, undefined, stateKey
110
+ ? { conditional: { key: stateKey, name: `jobs-run-${runId}-p${page}` } }
111
+ : undefined);
99
112
  allJobs.push(...data.jobs);
100
113
  if (rateLimit?.remaining === 0) {
101
114
  process.stderr.write(`pr-shepherd: REST rate limit remaining is 0 while listing jobs for run ${runId} — triage may be incomplete\n`);
@@ -127,12 +140,28 @@ function pickJobInfo(jobs, checkName) {
127
140
  workflowName: job.workflow_name,
128
141
  jobName: job.name,
129
142
  failedStep,
143
+ jobConclusion: job.conclusion,
130
144
  };
131
145
  }
132
- async function fetchJobLogExcerpt(jobId, repo) {
146
+ /**
147
+ * `cacheable` gates the cross-tick cache — only set once the matched job has
148
+ * a terminal conclusion, so an in-progress job's (possibly partial) log
149
+ * never gets frozen into the cache.
150
+ */
151
+ async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
152
+ const cacheName = `joblog-${jobId}`;
153
+ if (stateKey && cacheable) {
154
+ const cached = await loadDerived(stateKey, cacheName);
155
+ if (cached)
156
+ return cached.value ?? undefined;
157
+ }
133
158
  const { owner, name } = repo;
134
159
  try {
135
- return buildLogExcerpt(await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`));
160
+ const excerpt = buildLogExcerpt(await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`));
161
+ if (stateKey && cacheable) {
162
+ await storeDerived(stateKey, cacheName, excerpt ?? null);
163
+ }
164
+ return excerpt;
136
165
  }
137
166
  catch {
138
167
  return undefined;
@@ -0,0 +1,3 @@
1
+ import type { ApiUsage, GraphqlQuotaWarning } from "../types.mts";
2
+ export declare function formatQuotaWarning(warning: GraphqlQuotaWarning | undefined): string | null;
3
+ export declare function formatApiUsage(usage: ApiUsage | undefined): string | null;
@@ -0,0 +1,36 @@
1
+ function resetTime(resetAt) {
2
+ return new Date(resetAt * 1000).toISOString();
3
+ }
4
+ function formatResource(resource) {
5
+ const used = resource.used === undefined ? "" : ` · used ${resource.used}`;
6
+ return `- \`${resource.resource}\`: ${resource.remaining}/${resource.limit} remaining${used} · ${resource.requestCount} requests · resets ${resetTime(resource.resetAt)}`;
7
+ }
8
+ export function formatQuotaWarning(warning) {
9
+ if (warning === undefined)
10
+ return null;
11
+ const used = warning.used === undefined ? "" : ` · used ${warning.used}`;
12
+ return [
13
+ "## GitHub API quota warning",
14
+ "",
15
+ `- Resource: \`${warning.resource}\``,
16
+ `- Remaining: ${warning.remaining}/${warning.limit}${used}`,
17
+ `- Crossed threshold: ${warning.thresholdPercent}% remaining`,
18
+ `- Reset: ${resetTime(warning.resetAt)}`,
19
+ `- Recommended poll interval: ${warning.pollIntervalMinutes} minutes`,
20
+ `- Recommended bounded CLI timeout: ${warning.pollTimeoutMinutes} minutes`,
21
+ "- Recommendation: prefer non-GraphQL `gh` CLI commands for PR operations until the reset above, then resume pr-shepherd",
22
+ ].join("\n");
23
+ }
24
+ export function formatApiUsage(usage) {
25
+ if (usage === undefined)
26
+ return null;
27
+ const lines = ["## GitHub API usage", ""];
28
+ lines.push(`- Credential source: ${usage.credentialSources.map((source) => `\`${source}\``).join(", ")}`);
29
+ if (usage.graphql !== undefined) {
30
+ lines.push(formatResource(usage.graphql));
31
+ lines.push(`- GraphQL measured cost: ${usage.graphql.measuredQueryCost} · unmeasured requests: ${usage.graphql.unmeasuredRequestCount} · nodes: ${usage.graphql.nodeCount}`);
32
+ }
33
+ for (const resource of usage.rest ?? [])
34
+ lines.push(formatResource(resource));
35
+ return lines.join("\n");
36
+ }
@@ -0,0 +1,13 @@
1
+ /** Safe GitHub transport metadata suitable for user-facing structured error output. */
2
+ export declare function serializeGitHubRequestErrorDetails(err: unknown): {
3
+ rateLimit?: {
4
+ resource: string;
5
+ limit: number;
6
+ remaining: number;
7
+ used?: number | undefined;
8
+ resetAt: number;
9
+ } | undefined;
10
+ retryAfterSeconds?: number | undefined;
11
+ credential?: string | undefined;
12
+ } | undefined;
13
+ export declare function formatCliError(err: unknown): string;
@@ -0,0 +1,41 @@
1
+ import { GitHubRequestError } from "../github/errors.mjs";
2
+ /** Safe GitHub transport metadata suitable for user-facing structured error output. */
3
+ export function serializeGitHubRequestErrorDetails(err) {
4
+ if (!(err instanceof GitHubRequestError))
5
+ return undefined;
6
+ return {
7
+ ...(err.rateLimit !== undefined && {
8
+ rateLimit: {
9
+ resource: err.rateLimit.resource ?? "unknown",
10
+ limit: err.rateLimit.limit,
11
+ remaining: err.rateLimit.remaining,
12
+ ...(err.rateLimit.used !== undefined && { used: err.rateLimit.used }),
13
+ resetAt: err.rateLimit.resetAt,
14
+ },
15
+ }),
16
+ ...(err.retryAfterSeconds !== undefined && { retryAfterSeconds: err.retryAfterSeconds }),
17
+ ...(err.authSource !== undefined && { credential: err.authSource }),
18
+ };
19
+ }
20
+ export function formatCliError(err) {
21
+ const message = err instanceof Error ? err.message : String(err);
22
+ const githubDetails = serializeGitHubRequestErrorDetails(err);
23
+ if (githubDetails === undefined)
24
+ return message;
25
+ const details = [];
26
+ if (githubDetails.rateLimit !== undefined) {
27
+ details.push(`resource ${githubDetails.rateLimit.resource}`);
28
+ details.push(`remaining ${githubDetails.rateLimit.remaining}/${githubDetails.rateLimit.limit}`);
29
+ if (githubDetails.rateLimit.used !== undefined) {
30
+ details.push(`used ${githubDetails.rateLimit.used}`);
31
+ }
32
+ details.push(`reset ${new Date(githubDetails.rateLimit.resetAt * 1000).toISOString()}`);
33
+ }
34
+ if (githubDetails.retryAfterSeconds !== undefined) {
35
+ details.push(`retry after ${githubDetails.retryAfterSeconds}s`);
36
+ }
37
+ if (githubDetails.credential !== undefined) {
38
+ details.push(`credential ${githubDetails.credential}`);
39
+ }
40
+ return details.length === 0 ? message : `${message} (${details.join("; ")})`;
41
+ }
@@ -44,6 +44,10 @@ export function formatFixCodeResult(header, result) {
44
44
  const failingChecks = result.fix.checks.filter((ch) => isFailingAgentCheck(ch));
45
45
  if (failingChecks.length > 0) {
46
46
  sections.push("## Failing checks");
47
+ // Multiple failing CheckRuns (matrix jobs, etc.) can share one runId; the rerun command is
48
+ // identical for all of them, so print it once — after the first bullet for that runId —
49
+ // instead of repeating the same shell command on every row.
50
+ const seenRerunRunIds = new Set();
47
51
  const bullets = failingChecks.map((ch) => {
48
52
  const workflowPrefix = ch.workflowName ? `${ch.workflowName} › ` : "";
49
53
  const jobLabel = ch.jobName ? ch.jobName : ch.name;
@@ -56,7 +60,10 @@ export function formatFixCodeResult(header, result) {
56
60
  const scopeTag = ch.scope
57
61
  ? ` [scope: ${ch.scope}${ch.commitOid ? `, commit: ${ch.commitOid}` : ""}]`
58
62
  : "";
59
- const lines = [`- ${locator} \`${workflowPrefix}${jobLabel}\`${conclusionTag}${scopeTag}`];
63
+ const rerunTag = ch.rerunCommand ? " [rerun authorized]" : "";
64
+ const lines = [
65
+ `- ${locator} — \`${workflowPrefix}${jobLabel}\`${conclusionTag}${scopeTag}${rerunTag}`,
66
+ ];
60
67
  if (ch.conclusion !== "CANCELLED") {
61
68
  if (ch.failedStep)
62
69
  lines.push(` > ${ch.failedStep}`);
@@ -65,6 +72,10 @@ export function formatFixCodeResult(header, result) {
65
72
  if (ch.logExcerpt)
66
73
  lines.push(indentBlockquote(ch.logExcerpt, " "));
67
74
  }
75
+ if (ch.rerunCommand && ch.runId && !seenRerunRunIds.has(ch.runId)) {
76
+ seenRerunRunIds.add(ch.runId);
77
+ lines.push(` rerun: \`${ch.rerunCommand}\``);
78
+ }
68
79
  return lines.join("\n");
69
80
  });
70
81
  sections.push(bullets.join("\n\n"));
@@ -0,0 +1,2 @@
1
+ import type { IterateResult } from "../types.mts";
2
+ export declare function formatActivityLine(result: IterateResult): string | null;
@@ -0,0 +1,26 @@
1
+ export function formatActivityLine(result) {
2
+ const activity = result.activity ?? {
3
+ commitCount: 0,
4
+ reviewRoundCount: 0,
5
+ latestCommitCommittedAtUnix: null,
6
+ reviewItemsSinceLatestCommit: [],
7
+ };
8
+ const hasActiveChecks = (result.inProgressChecks?.length ?? 0) > 0;
9
+ if (activity.commitCount === 0 &&
10
+ activity.reviewRoundCount === 0 &&
11
+ activity.reviewItemsSinceLatestCommit.length === 0 &&
12
+ !hasActiveChecks) {
13
+ return null;
14
+ }
15
+ const parts = [`${activity.commitCount} commits`, `${activity.reviewRoundCount} review rounds`];
16
+ if (activity.reviewItemsSinceLatestCommit.length > 0) {
17
+ parts.push(`${activity.reviewItemsSinceLatestCommit.length} review items since latest commit`);
18
+ }
19
+ if (hasActiveChecks) {
20
+ parts.push(`active: ${result
21
+ .inProgressChecks.slice(0, 5)
22
+ .map((check) => `\`${check.name}\``)
23
+ .join(", ")}`);
24
+ }
25
+ return `**activity** ${parts.join(" · ")}`;
26
+ }
@@ -3,32 +3,8 @@ import { joinSections } from "../util/markdown.mjs";
3
3
  import { adaptIterateLog, buildSimpleIterateInstructions, numberInstructions, } from "./iterate-instructions.mjs";
4
4
  import { formatMergeRequirementLines } from "../merge-status/requirements-format.mjs";
5
5
  import { appendMergeQueueHeader, formatMergeAction } from "./iterate-merge-formatter.mjs";
6
- function formatActivityLine(result) {
7
- const activity = result.activity ?? {
8
- commitCount: 0,
9
- reviewRoundCount: 0,
10
- latestCommitCommittedAtUnix: null,
11
- reviewItemsSinceLatestCommit: [],
12
- };
13
- const hasActiveChecks = (result.inProgressChecks?.length ?? 0) > 0;
14
- if (activity.commitCount === 0 &&
15
- activity.reviewRoundCount === 0 &&
16
- activity.reviewItemsSinceLatestCommit.length === 0 &&
17
- !hasActiveChecks) {
18
- return null;
19
- }
20
- const parts = [`${activity.commitCount} commits`, `${activity.reviewRoundCount} review rounds`];
21
- if (activity.reviewItemsSinceLatestCommit.length > 0) {
22
- parts.push(`${activity.reviewItemsSinceLatestCommit.length} review items since latest commit`);
23
- }
24
- if (hasActiveChecks) {
25
- parts.push(`active: ${result
26
- .inProgressChecks.slice(0, 5)
27
- .map((c) => `\`${c.name}\``)
28
- .join(", ")}`);
29
- }
30
- return `**activity** ${parts.join(" · ")}`;
31
- }
6
+ import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
7
+ import { formatActivityLine } from "./iterate-activity-formatter.mjs";
32
8
  /**
33
9
  * Format an IterateResult as human-readable Markdown.
34
10
  *
@@ -132,21 +108,26 @@ export function formatIterateResult(result, opts) {
132
108
  if (activityLine)
133
109
  headerLines.push(activityLine);
134
110
  const header = headerLines.join("\n");
111
+ const quotaWarning = formatQuotaWarning(result.quotaWarning);
112
+ const apiUsage = verbose ? formatApiUsage(result.apiUsage) : null;
113
+ const telemetrySections = [quotaWarning, apiUsage];
135
114
  switch (result.action) {
136
115
  case "wait":
137
116
  return joinSections([
138
117
  header,
118
+ ...telemetrySections,
139
119
  adaptIterateLog(result.log),
140
120
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
141
121
  ]);
142
122
  case "mark_ready":
143
123
  return joinSections([
144
124
  header,
125
+ ...telemetrySections,
145
126
  adaptIterateLog(result.log),
146
127
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
147
128
  ]);
148
129
  case "merge":
149
- return formatMergeAction(header, result);
130
+ return formatMergeAction(joinSections([header, ...telemetrySections]), result);
150
131
  case "cancel": {
151
132
  const cancelHeaderLines = [`${heading} — ${result.reason}`, "", baseLine, summaryLine];
152
133
  if (result.mergeRequirements) {
@@ -167,6 +148,7 @@ export function formatIterateResult(result, opts) {
167
148
  cancelHeaderLines.push(activityLine);
168
149
  return joinSections([
169
150
  cancelHeaderLines.join("\n"),
151
+ ...(apiUsage ? [apiUsage] : []),
170
152
  adaptIterateLog(result.log),
171
153
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
172
154
  ]);
@@ -174,10 +156,11 @@ export function formatIterateResult(result, opts) {
174
156
  case "escalate":
175
157
  return joinSections([
176
158
  header,
159
+ ...(apiUsage ? [apiUsage] : []),
177
160
  result.escalate.humanMessage,
178
161
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
179
162
  ]);
180
163
  case "fix_code":
181
- return formatFixCodeResult(header, result);
164
+ return formatFixCodeResult(joinSections([header, ...telemetrySections]), result);
182
165
  }
183
166
  }
@@ -1,12 +1,23 @@
1
1
  import { renderMergeCommand } from "../commands/iterate/merge.mjs";
2
2
  import { inlineCode } from "../util/markdown.mjs";
3
+ import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
3
4
  export function buildSimpleIterateInstructions(result) {
4
5
  switch (result.action) {
5
6
  case "wait":
7
+ if (result.quotaWarning) {
8
+ return [
9
+ buildQuotaAwareContinuation(result.quotaWarning, "Non-terminal — no action needed this tick."),
10
+ ];
11
+ }
6
12
  return [
7
13
  "Non-terminal — no action needed this tick. Iterate again with the same options to continue.",
8
14
  ];
9
15
  case "mark_ready":
16
+ if (result.quotaWarning) {
17
+ return [
18
+ buildQuotaAwareContinuation(result.quotaWarning, "The CLI marked the PR ready for review."),
19
+ ];
20
+ }
10
21
  return [
11
22
  "The CLI marked the PR ready for review. Iterate again with the same options to continue.",
12
23
  ];
@@ -20,7 +31,9 @@ export function buildSimpleIterateInstructions(result) {
20
31
  else if (result.merge.fallbackCommand) {
21
32
  instructions.push(`Only if GitHub reports that auto-merge is unavailable, run the \`plain merge fallback\` command: ${inlineCode(renderMergeCommand(result.merge.fallbackCommand))}.`);
22
33
  }
23
- instructions.push("Then iterate again with the same options to monitor until the PR merges or needs work.");
34
+ instructions.push(result.quotaWarning
35
+ ? buildQuotaAwareContinuation(result.quotaWarning, "After running the merge command.")
36
+ : "Then iterate again with the same options to monitor until the PR merges or needs work.");
24
37
  return instructions;
25
38
  }
26
39
  case "cancel":
@@ -64,6 +64,7 @@ export function projectIterateLean(result, opts) {
64
64
  ...((result.supersededNames?.length ?? 0) > 0 && {
65
65
  supersededNames: result.supersededNames,
66
66
  }),
67
+ ...(result.quotaWarning && { quotaWarning: result.quotaWarning }),
67
68
  };
68
69
  switch (result.action) {
69
70
  case "wait":
@@ -6,12 +6,14 @@ function formatRateLimit(result) {
6
6
  const rateLimit = result.rateLimit;
7
7
  if (rateLimit) {
8
8
  const details = [
9
+ typeof rateLimit.resource === "string" ? `resource ${rateLimit.resource}` : null,
9
10
  typeof rateLimit.retryAfterSeconds === "number"
10
11
  ? `retry after ${rateLimit.retryAfterSeconds}s`
11
12
  : null,
12
13
  typeof rateLimit.remaining === "number" && typeof rateLimit.limit === "number"
13
14
  ? `remaining ${rateLimit.remaining}/${rateLimit.limit}`
14
15
  : null,
16
+ typeof rateLimit.used === "number" ? `used ${rateLimit.used}` : null,
15
17
  typeof rateLimit.resetAt === "number"
16
18
  ? `reset at ${new Date(rateLimit.resetAt * 1000).toISOString()}`
17
19
  : null,
@@ -1,3 +1,4 @@
1
+ import { type AnnotationCacheOptions } from "../github/check-annotations.mts";
1
2
  import type { CheckAnnotation, ClassifiedCheck, ShepherdReport, TriagedCheck } from "../types.mts";
2
3
  export declare function checksWithActionableAnnotations(report: ShepherdReport): TriagedCheck[];
3
4
  export declare function attachAndMergeCheckAnnotations(buckets: {
@@ -8,7 +9,7 @@ export declare function attachAndMergeCheckAnnotations(buckets: {
8
9
  ignored: ClassifiedCheck[];
9
10
  }, seenMap: Map<string, {
10
11
  seenAt: number;
11
- }>, prNumber: number): Promise<{
12
+ }>, prNumber: number, cacheOpts?: AnnotationCacheOptions): Promise<{
12
13
  passing: ClassifiedCheck[];
13
14
  failing: TriagedCheck[];
14
15
  skipped: ClassifiedCheck[];
@@ -1,4 +1,4 @@
1
- import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
1
+ import { fetchCheckRunAnnotations, } from "../github/check-annotations.mjs";
2
2
  function shouldFetchCheckAnnotations(check) {
3
3
  return check.id != null && check.status === "COMPLETED" && check.hasAnnotations === true;
4
4
  }
@@ -11,7 +11,7 @@ export function checksWithActionableAnnotations(report) {
11
11
  ...(report.checks.ignored ?? []),
12
12
  ].filter((c) => c.conclusion !== "SUCCESS" && (c.annotations?.length ?? 0) > 0);
13
13
  }
14
- export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber) {
14
+ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber, cacheOpts) {
15
15
  const candidates = [
16
16
  ...buckets.failing,
17
17
  ...buckets.passing,
@@ -19,7 +19,7 @@ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber)
19
19
  ...buckets.filtered,
20
20
  ...buckets.ignored,
21
21
  ].filter(shouldFetchCheckAnnotations);
22
- const annotated = await attachUnseenCheckAnnotations(candidates, seenMap, prNumber);
22
+ const annotated = await attachUnseenCheckAnnotations(candidates, seenMap, prNumber, cacheOpts);
23
23
  const byId = new Map(annotated.flatMap((c) => (c.id != null ? [[c.id, c]] : [])));
24
24
  const apply = (list) => list.map((c) => {
25
25
  if (c.id == null)
@@ -35,20 +35,20 @@ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber)
35
35
  ignored: apply(buckets.ignored),
36
36
  };
37
37
  }
38
- async function attachUnseenCheckAnnotations(checks, seenMap, prNumber) {
38
+ async function attachUnseenCheckAnnotations(checks, seenMap, prNumber, cacheOpts) {
39
39
  const checksWithAnnotations = [];
40
40
  for (const check of checks) {
41
41
  // eslint-disable-next-line no-await-in-loop
42
- checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber));
42
+ checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber, cacheOpts));
43
43
  }
44
44
  return checksWithAnnotations;
45
45
  }
46
- async function attachForCheck(check, seenMap, prNumber) {
46
+ async function attachForCheck(check, seenMap, prNumber, cacheOpts) {
47
47
  if (check.id == null)
48
48
  return check;
49
49
  let annotations;
50
50
  try {
51
- annotations = await fetchCheckRunAnnotations(check.id);
51
+ annotations = await fetchCheckRunAnnotations(check.id, cacheOpts);
52
52
  }
53
53
  catch (err) {
54
54
  const msg = err instanceof Error ? err.message : String(err);