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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -4
- package/bin/checks/conclusions.d.mts +24 -1
- package/bin/checks/conclusions.mjs +39 -0
- package/bin/checks/triage.d.mts +3 -2
- package/bin/checks/triage.mjs +44 -15
- package/bin/cli/api-usage-formatter.d.mts +3 -0
- package/bin/cli/api-usage-formatter.mjs +36 -0
- package/bin/cli/error-format.d.mts +13 -0
- package/bin/cli/error-format.mjs +41 -0
- package/bin/cli/fix-formatter.mjs +12 -1
- package/bin/cli/iterate-activity-formatter.d.mts +2 -0
- package/bin/cli/iterate-activity-formatter.mjs +26 -0
- package/bin/cli/iterate-formatter.mjs +11 -28
- package/bin/cli/iterate-instructions.mjs +14 -1
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/mutate-formatter.mjs +2 -0
- package/bin/commands/check-annotations.d.mts +2 -1
- package/bin/commands/check-annotations.mjs +7 -7
- package/bin/commands/check.mjs +6 -4
- package/bin/commands/iterate/api-usage.d.mts +2 -0
- package/bin/commands/iterate/api-usage.mjs +42 -0
- package/bin/commands/iterate/check-instructions.d.mts +2 -2
- package/bin/commands/iterate/check-instructions.mjs +27 -8
- package/bin/commands/iterate/fix-code.mjs +42 -8
- package/bin/commands/iterate/index.mjs +6 -3
- package/bin/commands/iterate/merge-state.mjs +5 -2
- package/bin/commands/iterate/render.d.mts +1 -1
- package/bin/commands/iterate/render.mjs +9 -5
- package/bin/commands/iterate/run.d.mts +2 -0
- package/bin/commands/iterate/run.mjs +8 -0
- package/bin/commands/mark-files-as-viewed.mjs +8 -0
- package/bin/commands/poll-run.d.mts +2 -0
- package/bin/commands/poll-run.mjs +8 -0
- package/bin/commands/poll.d.mts +1 -2
- package/bin/commands/poll.mjs +29 -10
- package/bin/comments/rate-limit.d.mts +4 -0
- package/bin/comments/rate-limit.mjs +6 -0
- package/bin/config/load.d.mts +6 -1
- package/bin/config/load.mjs +33 -1
- package/bin/config.json +6 -1
- package/bin/exit-codes.d.mts +1 -1
- package/bin/exit-codes.mjs +1 -1
- package/bin/github/api-telemetry-aggregate.d.mts +32 -0
- package/bin/github/api-telemetry-aggregate.mjs +84 -0
- package/bin/github/api-telemetry.d.mts +13 -0
- package/bin/github/api-telemetry.mjs +128 -0
- package/bin/github/check-annotations.d.mts +14 -1
- package/bin/github/check-annotations.mjs +29 -2
- package/bin/github/errors.d.mts +8 -0
- package/bin/github/errors.mjs +29 -1
- package/bin/github/gql/batch-pr-page.gql +8 -0
- package/bin/github/gql/batch-pr.gql +8 -0
- package/bin/github/gql/check-run-annotations.gql +8 -0
- package/bin/github/gql/commit-check-contexts.gql +8 -0
- package/bin/github/gql/get-pr-body.gql +8 -0
- package/bin/github/gql/get-pr-head-sha.gql +8 -0
- package/bin/github/gql/pr-number-by-branch.gql +8 -0
- package/bin/github/gql/review-thread-comments.gql +8 -0
- package/bin/github/gql/suggestion-threads.gql +8 -0
- package/bin/github/graphql-http.mjs +66 -10
- package/bin/github/graphql-internal-retry.d.mts +2 -0
- package/bin/github/graphql-internal-retry.mjs +50 -0
- package/bin/github/graphql-response.mjs +11 -1
- package/bin/github/http-auth.d.mts +9 -1
- package/bin/github/http-auth.mjs +35 -14
- package/bin/github/http-intermediate.d.mts +9 -0
- package/bin/github/http-intermediate.mjs +20 -0
- package/bin/github/http-request.d.mts +1 -1
- package/bin/github/http-request.mjs +1 -1
- package/bin/github/http-utils.d.mts +6 -0
- package/bin/github/http-utils.mjs +10 -1
- package/bin/github/rest-http.d.mts +16 -1
- package/bin/github/rest-http.mjs +71 -8
- package/bin/github/rest-text.mjs +41 -5
- package/bin/index.mjs +2 -1
- package/bin/log/session.d.mts +11 -0
- package/bin/log/session.mjs +22 -0
- package/bin/mcp/server.mjs +16 -2
- package/bin/quota-warning.d.mts +2 -0
- package/bin/quota-warning.mjs +6 -0
- package/bin/state/graphql-quota-claims.d.mts +2 -0
- package/bin/state/graphql-quota-claims.mjs +61 -0
- package/bin/state/graphql-quota-policy.d.mts +17 -0
- package/bin/state/graphql-quota-policy.mjs +43 -0
- package/bin/state/graphql-quota-warnings.d.mts +6 -0
- package/bin/state/graphql-quota-warnings.mjs +105 -0
- package/bin/state/rest-cache.d.mts +48 -0
- package/bin/state/rest-cache.mjs +91 -0
- package/bin/types/api-usage.d.mts +31 -0
- package/bin/types/api-usage.mjs +1 -0
- package/bin/types/iterate.d.mts +4 -1
- package/bin/types/report.d.mts +2 -0
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
package/bin/log/session.d.mts
CHANGED
|
@@ -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;
|
package/bin/log/session.mjs
CHANGED
|
@@ -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`;
|
package/bin/mcp/server.mjs
CHANGED
|
@@ -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
|
|
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: {
|
|
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,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,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 {};
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -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 {};
|
package/bin/types/report.d.mts
CHANGED
|
@@ -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
package/bin/types.mjs
CHANGED
package/package.json
CHANGED