pr-shepherd 0.44.0 → 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 (100) 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/exit-codes.d.mts +1 -1
  43. package/bin/exit-codes.mjs +1 -1
  44. package/bin/github/api-telemetry-aggregate.d.mts +32 -0
  45. package/bin/github/api-telemetry-aggregate.mjs +84 -0
  46. package/bin/github/api-telemetry.d.mts +13 -0
  47. package/bin/github/api-telemetry.mjs +128 -0
  48. package/bin/github/check-annotations.d.mts +14 -1
  49. package/bin/github/check-annotations.mjs +29 -2
  50. package/bin/github/errors.d.mts +8 -0
  51. package/bin/github/errors.mjs +29 -1
  52. package/bin/github/gql/batch-pr-page.gql +8 -0
  53. package/bin/github/gql/batch-pr.gql +8 -0
  54. package/bin/github/gql/check-run-annotations.gql +8 -0
  55. package/bin/github/gql/commit-check-contexts.gql +8 -0
  56. package/bin/github/gql/get-pr-body.gql +8 -0
  57. package/bin/github/gql/get-pr-head-sha.gql +8 -0
  58. package/bin/github/gql/pr-number-by-branch.gql +8 -0
  59. package/bin/github/gql/review-thread-comments.gql +8 -0
  60. package/bin/github/gql/suggestion-threads.gql +8 -0
  61. package/bin/github/graphql-http.mjs +66 -10
  62. package/bin/github/graphql-internal-retry.d.mts +2 -0
  63. package/bin/github/graphql-internal-retry.mjs +50 -0
  64. package/bin/github/graphql-response.mjs +11 -1
  65. package/bin/github/http-auth.d.mts +9 -1
  66. package/bin/github/http-auth.mjs +35 -14
  67. package/bin/github/http-intermediate.d.mts +9 -0
  68. package/bin/github/http-intermediate.mjs +20 -0
  69. package/bin/github/http-request.d.mts +1 -1
  70. package/bin/github/http-request.mjs +1 -1
  71. package/bin/github/http-utils.d.mts +6 -0
  72. package/bin/github/http-utils.mjs +10 -1
  73. package/bin/github/rest-http.d.mts +16 -1
  74. package/bin/github/rest-http.mjs +71 -8
  75. package/bin/github/rest-text.mjs +41 -5
  76. package/bin/index.mjs +2 -1
  77. package/bin/log/session.d.mts +11 -0
  78. package/bin/log/session.mjs +22 -0
  79. package/bin/mcp/server.mjs +16 -2
  80. package/bin/quota-warning.d.mts +2 -0
  81. package/bin/quota-warning.mjs +6 -0
  82. package/bin/state/graphql-quota-claims.d.mts +2 -0
  83. package/bin/state/graphql-quota-claims.mjs +61 -0
  84. package/bin/state/graphql-quota-policy.d.mts +17 -0
  85. package/bin/state/graphql-quota-policy.mjs +43 -0
  86. package/bin/state/graphql-quota-warnings.d.mts +6 -0
  87. package/bin/state/graphql-quota-warnings.mjs +105 -0
  88. package/bin/state/rest-cache.d.mts +48 -0
  89. package/bin/state/rest-cache.mjs +91 -0
  90. package/bin/types/api-usage.d.mts +31 -0
  91. package/bin/types/api-usage.mjs +1 -0
  92. package/bin/types/iterate.d.mts +4 -1
  93. package/bin/types/report.d.mts +2 -0
  94. package/bin/types.d.mts +1 -0
  95. package/bin/types.mjs +1 -0
  96. package/package.json +1 -1
  97. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  98. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  99. package/plugins/pr-shepherd/.mcp.json +1 -1
  100. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
@@ -24,6 +24,17 @@ interface HttpResponseEntry {
24
24
  contentLength?: number;
25
25
  /** Set on 401-retry invocations. */
26
26
  attempt?: number;
27
+ authSource?: string;
28
+ rateLimit?: {
29
+ resource?: string;
30
+ limit: number;
31
+ used?: number;
32
+ remaining: number;
33
+ resetAt: number;
34
+ cost?: number;
35
+ nodeCount?: number;
36
+ };
37
+ retryAfterSeconds?: number;
27
38
  }
28
39
  export declare function formatRequestEntry(entry: HttpRequestEntry): string;
29
40
  export declare function formatResponseEntry(entry: HttpResponseEntry): string;
@@ -70,6 +70,28 @@ export function formatResponseEntry(entry) {
70
70
  ? `restText response — ${entry.status}${attempt} · ${entry.durationMs}ms`
71
71
  : `REST response — ${entry.status}${attempt} · ${entry.durationMs}ms`;
72
72
  let out = `### #${entry.n} ${label} · ${ts}\n\n`;
73
+ if (entry.authSource !== undefined)
74
+ out += `auth-source: \`${entry.authSource}\`\n`;
75
+ if (entry.rateLimit !== undefined) {
76
+ const used = entry.rateLimit.used !== undefined ? ` · used ${entry.rateLimit.used}` : "";
77
+ const resource = entry.rateLimit.resource ?? "unknown";
78
+ out += `rate-limit: \`${resource}\` · remaining ${entry.rateLimit.remaining}/${entry.rateLimit.limit}${used} · reset ${new Date(entry.rateLimit.resetAt * 1000).toISOString()}\n`;
79
+ if (entry.rateLimit.cost !== undefined || entry.rateLimit.nodeCount !== undefined) {
80
+ const parts = [
81
+ entry.rateLimit.cost !== undefined ? `cost ${entry.rateLimit.cost}` : null,
82
+ entry.rateLimit.nodeCount !== undefined ? `nodes ${entry.rateLimit.nodeCount}` : null,
83
+ ].filter(Boolean);
84
+ out += `graphql-query: ${parts.join(" · ")}\n`;
85
+ }
86
+ }
87
+ if (entry.retryAfterSeconds !== undefined) {
88
+ out += `retry-after: ${entry.retryAfterSeconds}s\n`;
89
+ }
90
+ if (entry.authSource !== undefined ||
91
+ entry.rateLimit !== undefined ||
92
+ entry.retryAfterSeconds !== undefined) {
93
+ out += "\n";
94
+ }
73
95
  if (entry.kind === "restText") {
74
96
  if (entry.contentLength !== undefined) {
75
97
  out += `content-length: ${entry.contentLength} bytes (body not logged)\n\n`;
@@ -6,6 +6,7 @@ import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from
6
6
  import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
7
7
  import { formatJournalResult } from "../cli/journal-formatter.mjs";
8
8
  import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
9
+ import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
9
10
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
10
11
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
11
12
  const pr = z
@@ -166,16 +167,29 @@ function toolError(error) {
166
167
  : error instanceof PartialApplyError
167
168
  ? errorToExitCode(error.cause)
168
169
  : errorToExitCode(error);
170
+ const cause = error instanceof PartialApplyError ? error.cause : error;
171
+ const githubDetails = serializeGitHubRequestErrorDetails(cause);
169
172
  const details = error instanceof PartialApplyError
170
173
  ? { failedIndex: error.failedIndex, completed: redactValue(error.completed) }
171
174
  : error instanceof PrShepherdValidationError
172
175
  ? { validation: true }
173
176
  : {};
174
- const message = redactErrorMessage(error instanceof Error ? error.message : String(error));
177
+ const formattedCause = formatCliError(cause);
178
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
179
+ const message = redactErrorMessage(error instanceof PartialApplyError && formattedCause.startsWith(causeMessage)
180
+ ? `${error.message}${formattedCause.slice(causeMessage.length)}`
181
+ : formattedCause);
175
182
  return {
176
183
  isError: true,
177
184
  content: [{ type: "text", text: `pr-shepherd error (${code}): ${message}` }],
178
- structuredContent: { code, message, details },
185
+ structuredContent: {
186
+ code,
187
+ message,
188
+ details: {
189
+ ...details,
190
+ ...(githubDetails !== undefined && { github: redactValue(githubDetails) }),
191
+ },
192
+ },
179
193
  };
180
194
  }
181
195
  function redactErrorMessage(message) {
@@ -0,0 +1,2 @@
1
+ import type { GraphqlQuotaWarning } from "./types.mts";
2
+ export declare function buildQuotaAwareContinuation(warning: GraphqlQuotaWarning, prefix: string): string;
@@ -0,0 +1,6 @@
1
+ export function buildQuotaAwareContinuation(warning, prefix) {
2
+ const interval = `${warning.pollIntervalMinutes}m`;
3
+ const timeout = `${warning.pollTimeoutMinutes}m`;
4
+ const resetTime = new Date(warning.resetAt * 1000).toISOString();
5
+ return `${prefix} GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). For the time being, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr checks\`, \`gh pr review\`, \`gh api\` REST endpoints) for PR operations — they draw on the separate REST budget, not the depleted GraphQL pool. Resume pr-shepherd after the GraphQL quota resets at ${resetTime}. If you must keep polling before then, poll no more often than every ${warning.pollIntervalMinutes} minutes. With a polling CLI command, preserve the other options, replace any existing interval and timeout flags with \`--interval ${interval} --timeout ${timeout}\`, and omit \`--timeout\` when using \`--until-terminal\`. With a single-tick CLI, API, or MCP call, wait at least ${warning.pollIntervalMinutes} minutes before the next tick.`;
6
+ }
@@ -0,0 +1,2 @@
1
+ import type { GraphqlQuotaWarning } from "../types.mts";
2
+ export declare function claimWarning(path: string, warning: GraphqlQuotaWarning, now: number, rearmEpoch: number): Promise<boolean>;
@@ -0,0 +1,61 @@
1
+ import { mkdir, open, readdir, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function claimWarning(path, warning, now, rearmEpoch) {
4
+ const claimsDir = `${path}.claims`;
5
+ const claimPath = join(claimsDir, `${warning.resetAt}-${warning.limit}-${warning.thresholdPercent}-${rearmEpoch}.json`);
6
+ let handle;
7
+ try {
8
+ await mkdir(claimsDir, { recursive: true });
9
+ await sweepStaleClaims(claimsDir, now);
10
+ handle = await open(claimPath, "wx");
11
+ await handle.writeFile(JSON.stringify({
12
+ resource: warning.resource,
13
+ resetAt: warning.resetAt,
14
+ thresholdPercent: warning.thresholdPercent,
15
+ rearmEpoch,
16
+ }), "utf8");
17
+ return true;
18
+ }
19
+ catch (error) {
20
+ // When state storage is unavailable, surfacing the warning is safer than
21
+ // silently exhausting the credential. EEXIST alone means another process
22
+ // racing for this exact re-arm epoch already won the claim — a claim
23
+ // left over from an earlier epoch (e.g. before a credential switch) has
24
+ // a different filename and never collides here, so a re-armed warning
25
+ // is never suppressed by a stale epoch's claim.
26
+ return !isAlreadyExists(error);
27
+ }
28
+ finally {
29
+ try {
30
+ await handle?.close();
31
+ }
32
+ catch {
33
+ // Best-effort claim cleanup is unnecessary: existence is the claim.
34
+ }
35
+ }
36
+ }
37
+ async function sweepStaleClaims(claimsDir, now) {
38
+ try {
39
+ const entries = await readdir(claimsDir);
40
+ await Promise.all(entries.map(async (entry) => {
41
+ const resetAt = Number(entry.split("-")[0]);
42
+ if (!Number.isFinite(resetAt) || now < resetAt)
43
+ return;
44
+ try {
45
+ await unlink(join(claimsDir, entry));
46
+ }
47
+ catch {
48
+ // Best-effort sweep.
49
+ }
50
+ }));
51
+ }
52
+ catch {
53
+ // Best-effort sweep; a missing or unreadable directory is fine.
54
+ }
55
+ }
56
+ function isAlreadyExists(error) {
57
+ return (error !== null &&
58
+ typeof error === "object" &&
59
+ "code" in error &&
60
+ error.code === "EEXIST");
61
+ }
@@ -0,0 +1,17 @@
1
+ import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import type { GraphqlApiUsage, GraphqlQuotaWarning } from "../types.mts";
3
+ export interface GraphqlQuotaWarningState {
4
+ resource: string;
5
+ limit: number;
6
+ lastUsed?: number;
7
+ lastRemaining: number;
8
+ resetAt: number;
9
+ warnedThresholds: number[];
10
+ rearmEpoch?: number;
11
+ }
12
+ export declare function evaluateGraphqlQuotaWarning(bands: GraphqlQuotaWarningBand[], sample: Pick<GraphqlApiUsage, "resource" | "limit" | "used" | "remaining" | "resetAt">, previous: GraphqlQuotaWarningState | null, observedAt?: number): {
13
+ warning?: GraphqlQuotaWarning;
14
+ state: GraphqlQuotaWarningState & {
15
+ rearmEpoch: number;
16
+ };
17
+ };
@@ -0,0 +1,43 @@
1
+ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt = Date.now() / 1000) {
2
+ const windowRolled = previous !== null && sample.resetAt > previous.resetAt && observedAt >= previous.resetAt;
3
+ const rearm = previous === null ||
4
+ previous.resource !== sample.resource ||
5
+ previous.limit !== sample.limit ||
6
+ windowRolled ||
7
+ (sample.used !== undefined && previous.lastUsed !== undefined
8
+ ? sample.used < previous.lastUsed
9
+ : sample.remaining > previous.lastRemaining);
10
+ const warned = new Set(rearm ? [] : previous.warnedThresholds);
11
+ const rearmEpoch = rearm ? (previous?.rearmEpoch ?? 0) + 1 : (previous?.rearmEpoch ?? 0);
12
+ const crossed = bands.filter((band) => sample.remaining * 100 <= sample.limit * band.remainingPercent);
13
+ const newCrossed = crossed.filter((band) => !warned.has(band.remainingPercent));
14
+ for (const band of crossed)
15
+ warned.add(band.remainingPercent);
16
+ const active = newCrossed.at(-1);
17
+ const state = {
18
+ resource: sample.resource,
19
+ limit: sample.limit,
20
+ ...(sample.used !== undefined && { lastUsed: sample.used }),
21
+ lastRemaining: sample.remaining,
22
+ resetAt: sample.resetAt,
23
+ warnedThresholds: bands
24
+ .map((band) => band.remainingPercent)
25
+ .filter((threshold) => warned.has(threshold)),
26
+ rearmEpoch,
27
+ };
28
+ if (active === undefined)
29
+ return { state };
30
+ return {
31
+ warning: {
32
+ resource: "graphql",
33
+ thresholdPercent: active.remainingPercent,
34
+ remaining: sample.remaining,
35
+ limit: sample.limit,
36
+ ...(sample.used !== undefined && { used: sample.used }),
37
+ resetAt: sample.resetAt,
38
+ pollIntervalMinutes: active.pollIntervalMinutes,
39
+ pollTimeoutMinutes: active.pollIntervalMinutes * 2,
40
+ },
41
+ state,
42
+ };
43
+ }
@@ -0,0 +1,6 @@
1
+ import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import type { GraphqlQuotaWarning, GraphqlApiUsage } from "../types.mts";
3
+ export declare function evaluateWorktreeGraphqlQuotaWarning(key: {
4
+ owner: string;
5
+ repo: string;
6
+ }, bands: GraphqlQuotaWarningBand[], sample: GraphqlApiUsage, persist: boolean, now?: number): Promise<GraphqlQuotaWarning | undefined>;
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { resolveStateBase } from "./base.mjs";
5
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
6
+ import { getWorktreeKey } from "../util/worktree.mjs";
7
+ import { claimWarning } from "./graphql-quota-claims.mjs";
8
+ import { evaluateGraphqlQuotaWarning, } from "./graphql-quota-policy.mjs";
9
+ const pendingStateUpdates = new Map();
10
+ const sessionStates = new Map();
11
+ export async function evaluateWorktreeGraphqlQuotaWarning(key, bands, sample, persist, now = Date.now() / 1000) {
12
+ if (bands.length === 0)
13
+ return undefined;
14
+ const path = await warningStatePath(key);
15
+ if (path === undefined) {
16
+ const sessionKey = `${key.owner}/${key.repo}`;
17
+ if (!persist) {
18
+ return evaluateGraphqlQuotaWarning(bands, sample, sessionStates.get(sessionKey) ?? null, now)
19
+ .warning;
20
+ }
21
+ return serializeStateUpdate(`session:${sessionKey}`, async () => {
22
+ const evaluated = evaluateGraphqlQuotaWarning(bands, sample, sessionStates.get(sessionKey) ?? null, now);
23
+ sessionStates.set(sessionKey, evaluated.state);
24
+ return evaluated.warning;
25
+ });
26
+ }
27
+ if (!persist) {
28
+ const previous = await readState(path);
29
+ return evaluateGraphqlQuotaWarning(bands, sample, previous, now).warning;
30
+ }
31
+ return serializeStateUpdate(path, async () => {
32
+ const previous = await readState(path);
33
+ const evaluated = evaluateGraphqlQuotaWarning(bands, sample, previous, now);
34
+ const warning = evaluated.warning !== undefined &&
35
+ (await claimWarning(path, evaluated.warning, now, evaluated.state.rearmEpoch))
36
+ ? evaluated.warning
37
+ : undefined;
38
+ await writeState(path, evaluated.state);
39
+ return warning;
40
+ });
41
+ }
42
+ async function serializeStateUpdate(key, update) {
43
+ const previous = pendingStateUpdates.get(key) ?? Promise.resolve();
44
+ const result = previous.then(update);
45
+ const completion = result.then(() => undefined, () => undefined);
46
+ pendingStateUpdates.set(key, completion);
47
+ void completion.finally(() => {
48
+ if (pendingStateUpdates.get(key) === completion) {
49
+ pendingStateUpdates.delete(key);
50
+ }
51
+ });
52
+ return result;
53
+ }
54
+ async function warningStatePath(key) {
55
+ if (!SAFE_SEGMENT.test(key.owner) || !SAFE_SEGMENT.test(key.repo)) {
56
+ throw new Error(`Invalid repo key segments: ${key.owner}/${key.repo}`);
57
+ }
58
+ let worktreeKey;
59
+ try {
60
+ worktreeKey = await getWorktreeKey();
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ return join(resolveStateBase(), `${key.owner}-${key.repo}`, "worktrees", `${worktreeKey}-graphql-quota-warnings.json`);
66
+ }
67
+ async function readState(path) {
68
+ try {
69
+ const parsed = JSON.parse(await readFile(path, "utf8"));
70
+ if (typeof parsed.resource !== "string" ||
71
+ !Number.isFinite(parsed.limit) ||
72
+ !Number.isFinite(parsed.lastRemaining) ||
73
+ !Number.isFinite(parsed.resetAt) ||
74
+ !Array.isArray(parsed.warnedThresholds)) {
75
+ return null;
76
+ }
77
+ return parsed;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ async function writeState(path, state) {
84
+ let tmp;
85
+ try {
86
+ await mkdir(dirname(path), { recursive: true });
87
+ tmp = `${path}.${randomUUID()}.tmp`;
88
+ await writeFile(tmp, JSON.stringify(state), "utf8");
89
+ await rename(tmp, path);
90
+ tmp = undefined;
91
+ }
92
+ catch {
93
+ // Best-effort warning state must never fail the command.
94
+ }
95
+ finally {
96
+ if (tmp !== undefined) {
97
+ try {
98
+ await unlink(tmp);
99
+ }
100
+ catch {
101
+ // Best-effort cleanup.
102
+ }
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Cross-tick GitHub API response cache — reduces redundant requests across
3
+ * `pr-shepherd` poll ticks.
4
+ *
5
+ * Two entry kinds, both keyed by a caller-chosen logical `name` (hashed to a
6
+ * filesystem-safe filename, mirroring seen-comments.mts):
7
+ *
8
+ * - "etag" entries back conditional REST requests (`If-None-Match`). A 200
9
+ * response overwrites the entry; a 304 leaves it untouched. Used for list
10
+ * endpoints whose content can change from tick to tick (job lists, run
11
+ * lists).
12
+ * - "derived" entries cache a value computed from a response that is
13
+ * immutable once identified by its key — job-log excerpts once a job is
14
+ * terminal, check-run annotations once the check is COMPLETED. No ETag
15
+ * applies; the cache is keyed on an immutable identity instead.
16
+ *
17
+ * Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/rest-cache/`
18
+ * and are removed for free when `pr-shepherd clean` deletes the PR's state
19
+ * directory — there is no separate pruning routine.
20
+ */
21
+ export interface StateKey {
22
+ owner: string;
23
+ repo: string;
24
+ pr: number;
25
+ }
26
+ export interface EtagCacheEntry {
27
+ kind: "etag";
28
+ name: string;
29
+ etag: string;
30
+ body: unknown;
31
+ storedAt: number;
32
+ headSha?: string;
33
+ }
34
+ export interface DerivedCacheEntry<T = unknown> {
35
+ kind: "derived";
36
+ name: string;
37
+ value: T;
38
+ storedAt: number;
39
+ headSha?: string;
40
+ }
41
+ export declare function loadEtagEntry(key: StateKey, name: string): Promise<EtagCacheEntry | null>;
42
+ export declare function storeEtagEntry(key: StateKey, name: string, fields: {
43
+ etag: string;
44
+ body: unknown;
45
+ headSha?: string;
46
+ }): Promise<void>;
47
+ export declare function loadDerived<T = unknown>(key: StateKey, name: string): Promise<DerivedCacheEntry<T> | null>;
48
+ export declare function storeDerived<T = unknown>(key: StateKey, name: string, value: T, headSha?: string): Promise<void>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Cross-tick GitHub API response cache — reduces redundant requests across
3
+ * `pr-shepherd` poll ticks.
4
+ *
5
+ * Two entry kinds, both keyed by a caller-chosen logical `name` (hashed to a
6
+ * filesystem-safe filename, mirroring seen-comments.mts):
7
+ *
8
+ * - "etag" entries back conditional REST requests (`If-None-Match`). A 200
9
+ * response overwrites the entry; a 304 leaves it untouched. Used for list
10
+ * endpoints whose content can change from tick to tick (job lists, run
11
+ * lists).
12
+ * - "derived" entries cache a value computed from a response that is
13
+ * immutable once identified by its key — job-log excerpts once a job is
14
+ * terminal, check-run annotations once the check is COMPLETED. No ETag
15
+ * applies; the cache is keyed on an immutable identity instead.
16
+ *
17
+ * Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/rest-cache/`
18
+ * and are removed for free when `pr-shepherd clean` deletes the PR's state
19
+ * directory — there is no separate pruning routine.
20
+ */
21
+ import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
22
+ import { dirname } from "node:path";
23
+ import { createHash, randomUUID } from "node:crypto";
24
+ import { resolvePrStatePath } from "./base.mjs";
25
+ export async function loadEtagEntry(key, name) {
26
+ const entry = await readEntry(key, name);
27
+ return entry?.kind === "etag" ? entry : null;
28
+ }
29
+ export async function storeEtagEntry(key, name, fields) {
30
+ await writeEntry(key, name, {
31
+ kind: "etag",
32
+ name,
33
+ etag: fields.etag,
34
+ body: fields.body,
35
+ storedAt: Date.now(),
36
+ ...(fields.headSha !== undefined && { headSha: fields.headSha }),
37
+ });
38
+ }
39
+ export async function loadDerived(key, name) {
40
+ const entry = await readEntry(key, name);
41
+ return entry?.kind === "derived" ? entry : null;
42
+ }
43
+ export async function storeDerived(key, name, value, headSha) {
44
+ await writeEntry(key, name, {
45
+ kind: "derived",
46
+ name,
47
+ value,
48
+ storedAt: Date.now(),
49
+ ...(headSha !== undefined && { headSha }),
50
+ });
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Helpers
54
+ // ---------------------------------------------------------------------------
55
+ async function readEntry(key, name) {
56
+ try {
57
+ const raw = await readFile(resolvePath(key, name), "utf8");
58
+ return JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ async function writeEntry(key, name, entry) {
65
+ let tmp;
66
+ try {
67
+ const path = resolvePath(key, name);
68
+ await mkdir(dirname(path), { recursive: true });
69
+ tmp = `${path}.${randomUUID()}.tmp`;
70
+ await writeFile(tmp, JSON.stringify(entry), "utf8");
71
+ await rename(tmp, path);
72
+ tmp = undefined;
73
+ }
74
+ catch {
75
+ // best-effort — a failed cache write just means the next tick re-fetches
76
+ }
77
+ finally {
78
+ if (tmp !== undefined) {
79
+ try {
80
+ await unlink(tmp);
81
+ }
82
+ catch {
83
+ // best-effort cleanup
84
+ }
85
+ }
86
+ }
87
+ }
88
+ function resolvePath(key, name) {
89
+ const hash = createHash("sha256").update(name, "utf8").digest("hex");
90
+ return resolvePrStatePath(key, "rest-cache", `${hash}.json`);
91
+ }
@@ -0,0 +1,31 @@
1
+ export interface ApiResourceUsage {
2
+ resource: string;
3
+ requestCount: number;
4
+ limit: number;
5
+ used?: number;
6
+ remaining: number;
7
+ resetAt: number;
8
+ }
9
+ export interface GraphqlApiUsage extends ApiResourceUsage {
10
+ /** Exact sum reported by rateLimit.cost for GraphQL queries in this command. */
11
+ measuredQueryCost: number;
12
+ /** Requests without an exact cost, principally GraphQL mutations. */
13
+ unmeasuredRequestCount: number;
14
+ /** Exact sum reported by rateLimit.nodeCount for measured GraphQL queries. */
15
+ nodeCount: number;
16
+ }
17
+ export interface ApiUsage {
18
+ credentialSources: string[];
19
+ graphql?: GraphqlApiUsage;
20
+ rest?: ApiResourceUsage[];
21
+ }
22
+ export interface GraphqlQuotaWarning {
23
+ resource: "graphql";
24
+ thresholdPercent: number;
25
+ remaining: number;
26
+ limit: number;
27
+ used?: number;
28
+ resetAt: number;
29
+ pollIntervalMinutes: number;
30
+ pollTimeoutMinutes: number;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -5,6 +5,7 @@ import type { MergeRequirements } from "./merge-requirements.mts";
5
5
  import type { EscalateDetails } from "./escalate.mts";
6
6
  import type { MergeCommandPlan } from "./merge-action.mts";
7
7
  import type { ProtectedRun } from "./protected-run.mts";
8
+ import type { ApiUsage, GraphqlQuotaWarning } from "./api-usage.mts";
8
9
  export type ShepherdAction = "wait" | "fix_code" | "mark_ready" | "merge" | "cancel" | "escalate";
9
10
  export interface IterateResultSummary {
10
11
  passing: number;
@@ -41,6 +42,8 @@ export interface IterateResultBase {
41
42
  supersededNames?: string[];
42
43
  activity?: PrActivitySummary;
43
44
  mergeQueue?: import("./merge-queue.mts").MergeQueueReport;
45
+ apiUsage?: ApiUsage;
46
+ quotaWarning?: GraphqlQuotaWarning;
44
47
  }
45
48
  interface IterateResultWait extends IterateResultBase {
46
49
  action: "wait";
@@ -129,12 +132,12 @@ export interface IterateCommandOptions extends GlobalOptions {
129
132
  noAutoMarkReady?: boolean;
130
133
  /** Legacy no-op retained for API compatibility; workflow runs are never cancelled. */
131
134
  noAutoCancelActionable?: boolean;
132
- /** Override stall timeout seconds. Defaults to config.iterate.stallTimeoutMinutes * 60. */
133
135
  stallTimeoutSeconds?: number;
134
136
  /** Legacy per-invocation no-op retained for API compatibility. */
135
137
  neverCancelRuns?: string[];
136
138
  persistSeen?: boolean;
137
139
  /** Shepherd through readiness and emit the exact merge/queue command when ready. */
138
140
  merge?: boolean;
141
+ deferQuotaWarning?: boolean;
139
142
  }
140
143
  export {};
@@ -136,6 +136,8 @@ export interface AgentCheck {
136
136
  /** One-line status text shown in the GitHub UI (e.g. "67.68% of diff hit (target 85.00%)"). */
137
137
  summary?: string;
138
138
  logExcerpt?: string;
139
+ /** `gh run rerun` command, present only when the check has a runId and the viewer's repository role grants Actions rerun capability (WRITE+). */
140
+ rerunCommand?: string;
139
141
  annotations?: CheckAnnotation[];
140
142
  annotationOnly?: true;
141
143
  scope?: "merge_group";
package/bin/types.d.mts CHANGED
@@ -12,3 +12,4 @@ export * from "./types/merge-requirements.mts";
12
12
  export * from "./types/merge-action.mts";
13
13
  export * from "./types/escalate.mts";
14
14
  export * from "./types/merge-queue.mts";
15
+ export * from "./types/api-usage.mts";
package/bin/types.mjs CHANGED
@@ -12,3 +12,4 @@ export * from "./types/merge-requirements.mjs";
12
12
  export * from "./types/merge-action.mjs";
13
13
  export * from "./types/escalate.mjs";
14
14
  export * from "./types/merge-queue.mjs";
15
+ export * from "./types/api-usage.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.44.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.45.0", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.44.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.45.0", "pr-shepherd-mcp"]
5
5
  }
6
6
  }