pr-shepherd 0.53.0 → 0.53.1
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/bin/cli/api-usage-formatter.mjs +17 -3
- package/bin/commands/iterate/api-usage.mjs +5 -4
- package/bin/commands/poll-quota.d.mts +18 -6
- package/bin/commands/poll-quota.mjs +57 -19
- package/bin/commands/poll-summary.mjs +6 -6
- package/bin/commands/poll.mjs +7 -7
- package/bin/commands/quota-selection.d.mts +7 -0
- package/bin/commands/quota-selection.mjs +20 -0
- package/bin/quota-budgets.d.mts +16 -0
- package/bin/quota-budgets.mjs +65 -0
- package/bin/quota-warning.mjs +11 -1
- package/bin/state/graphql-quota-policy.mjs +1 -1
- package/bin/state/graphql-quota-warnings.d.mts +2 -2
- package/bin/state/graphql-quota-warnings.mjs +5 -4
- package/bin/types/api-usage.d.mts +14 -3
- 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
|
@@ -9,7 +9,7 @@ export function formatQuotaWarning(warning) {
|
|
|
9
9
|
if (warning === undefined)
|
|
10
10
|
return null;
|
|
11
11
|
const used = warning.used === undefined ? "" : ` · used ${warning.used}`;
|
|
12
|
-
|
|
12
|
+
const lines = [
|
|
13
13
|
"## GitHub API quota warning",
|
|
14
14
|
"",
|
|
15
15
|
`- Resource: \`${warning.resource}\``,
|
|
@@ -18,8 +18,22 @@ export function formatQuotaWarning(warning) {
|
|
|
18
18
|
`- Reset: ${resetTime(warning.resetAt)}`,
|
|
19
19
|
`- Recommended poll interval: ${warning.pollIntervalMinutes} minutes`,
|
|
20
20
|
`- Recommended bounded CLI timeout: ${warning.pollTimeoutMinutes} minutes`,
|
|
21
|
-
|
|
22
|
-
]
|
|
21
|
+
recommendation(warning),
|
|
22
|
+
];
|
|
23
|
+
for (const budget of warning.budgets ?? []) {
|
|
24
|
+
const budgetUsed = budget.used === undefined ? "" : ` · used ${budget.used}`;
|
|
25
|
+
lines.push(`- Budget \`${budget.resource}\`: ${budget.remaining}/${budget.limit} remaining${budgetUsed} · crossed ${budget.thresholdPercent}% · resets ${resetTime(budget.resetAt)}`);
|
|
26
|
+
}
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|
|
29
|
+
function recommendation(warning) {
|
|
30
|
+
if (warning.resource === "combined") {
|
|
31
|
+
return "- Recommendation: keep polling pr-shepherd at the cadence above; both GraphQL and REST core are below their warning thresholds, so do not shift work between them. Do not substitute `gh pr checks` or `gh pr watch`";
|
|
32
|
+
}
|
|
33
|
+
if (warning.resource === "core") {
|
|
34
|
+
return "- Recommendation: keep polling pr-shepherd at the cadence above; do not add incidental REST `gh` calls (`gh pr view`, `gh pr review`, `gh api`) while REST core is low. Do not substitute `gh pr checks` or `gh pr watch`";
|
|
35
|
+
}
|
|
36
|
+
return "- Recommendation: keep polling pr-shepherd at the cadence above; for incidental PR operations prefer REST `gh` (`gh pr view`, `gh pr review`, `gh api`); do not substitute `gh pr checks` or `gh pr watch`";
|
|
23
37
|
}
|
|
24
38
|
export function formatApiUsage(usage) {
|
|
25
39
|
if (usage === undefined)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
-
import { summarizeApiTelemetry
|
|
3
|
-
import {
|
|
2
|
+
import { summarizeApiTelemetry } from "../../github/api-telemetry.mjs";
|
|
3
|
+
import { selectQuotaWarning } from "../quota-selection.mjs";
|
|
4
4
|
import { buildQuotaAwareContinuation } from "../../quota-warning.mjs";
|
|
5
5
|
function shouldWarn(result) {
|
|
6
6
|
return ["wait", "mark_ready", "merge", "fix_code"].includes(result.action);
|
|
@@ -10,14 +10,15 @@ export async function attachApiUsage(result, persistWarning, preservePersistedWa
|
|
|
10
10
|
if (apiUsage === undefined)
|
|
11
11
|
return result;
|
|
12
12
|
let quotaWarning = preservePersistedWarning ? result.quotaWarning : undefined;
|
|
13
|
-
|
|
13
|
+
const core = apiUsage.rest?.find((item) => item.resource === "core");
|
|
14
|
+
if (quotaWarning === undefined && shouldWarn(result) && (apiUsage.graphql || core)) {
|
|
14
15
|
const [owner, repo] = result.repo.split("/");
|
|
15
16
|
if (owner && repo) {
|
|
16
17
|
const bands = loadConfig().watch.graphqlQuotaWarnings.map((band) => ({
|
|
17
18
|
...band,
|
|
18
19
|
pollIntervalMinutes: Math.max(band.pollIntervalMinutes, minimumPollIntervalMinutes),
|
|
19
20
|
}));
|
|
20
|
-
quotaWarning = await
|
|
21
|
+
quotaWarning = await selectQuotaWarning({ owner, repo }, bands, apiUsage, persistWarning);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
const { quotaWarning: _deferredWarning, ...baseResult } = result;
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import type { GraphqlQuotaWarningBand } from "../config/load.mts";
|
|
2
|
-
import type { GraphqlApiUsage, PollSummaryResult } from "../types.mts";
|
|
3
|
-
/**
|
|
4
|
-
export declare function
|
|
2
|
+
import type { ApiResourceUsage, GraphqlApiUsage, PollSummaryResult } from "../types.mts";
|
|
3
|
+
/** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
|
|
4
|
+
export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: {
|
|
5
|
+
graphql?: Pick<GraphqlApiUsage, "remaining" | "limit">;
|
|
6
|
+
rest?: Pick<ApiResourceUsage, "resource" | "remaining" | "limit">[];
|
|
7
|
+
} | undefined, fallbackMs: number, maxMs: number): number;
|
|
8
|
+
export interface RateLimitRetry {
|
|
9
|
+
ms: number;
|
|
10
|
+
resource: string;
|
|
11
|
+
remaining?: number;
|
|
12
|
+
limit?: number;
|
|
13
|
+
resetAt?: number;
|
|
14
|
+
}
|
|
5
15
|
/**
|
|
6
|
-
* Retry delay for `--until-terminal` when GitHub
|
|
7
|
-
* limit. `null` means the error is not a retryable rate limit.
|
|
16
|
+
* Retry delay for `--until-terminal` when GitHub exhausts a primary quota or
|
|
17
|
+
* returns a secondary limit. `null` means the error is not a retryable rate limit.
|
|
8
18
|
*/
|
|
9
|
-
export declare function
|
|
19
|
+
export declare function pollRateLimitRetryAfterMs(err: unknown): RateLimitRetry | null;
|
|
20
|
+
/** Stderr line naming the exhausted budget and when the sleep ends. */
|
|
21
|
+
export declare function formatRateLimitRetryLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
|
|
10
22
|
export declare function aggregateQuotaWarning(result: PollSummaryResult, bands: GraphqlQuotaWarningBand[], intervalSeconds: number): Promise<PollSummaryResult["quotaWarning"]>;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { summarizeApiTelemetry, withGraphqlCredentialFingerprint, } from "../github/api-telemetry.mjs";
|
|
1
|
+
import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
3
2
|
import { GitHubRequestError } from "../github/errors.mjs";
|
|
4
3
|
import { isRateLimitMessage } from "../comments/rate-limit.mjs";
|
|
4
|
+
import { selectQuotaWarning } from "./quota-selection.mjs";
|
|
5
5
|
const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
|
|
6
6
|
/** Sleep at least `--interval`, and at least the active crossed quota band. */
|
|
7
|
-
|
|
7
|
+
function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
|
|
8
8
|
if (usage === undefined || usage.limit <= 0 || bands.length === 0) {
|
|
9
9
|
return Math.min(fallbackMs, maxMs);
|
|
10
10
|
}
|
|
@@ -15,34 +15,72 @@ export function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
|
|
|
15
15
|
const bandMs = active.pollIntervalMinutes * 60_000;
|
|
16
16
|
return Math.min(Math.max(fallbackMs, bandMs), maxMs);
|
|
17
17
|
}
|
|
18
|
+
/** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
|
|
19
|
+
export function quotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
|
|
20
|
+
const core = usage?.rest?.find((item) => item.resource === "core");
|
|
21
|
+
return Math.max(graphqlQuotaPollIntervalMs(bands, usage?.graphql, fallbackMs, maxMs), graphqlQuotaPollIntervalMs(bands, core, fallbackMs, maxMs));
|
|
22
|
+
}
|
|
18
23
|
/**
|
|
19
|
-
* Retry delay for `--until-terminal` when GitHub
|
|
20
|
-
* limit. `null` means the error is not a retryable rate limit.
|
|
24
|
+
* Retry delay for `--until-terminal` when GitHub exhausts a primary quota or
|
|
25
|
+
* returns a secondary limit. `null` means the error is not a retryable rate limit.
|
|
21
26
|
*/
|
|
22
|
-
export function
|
|
27
|
+
export function pollRateLimitRetryAfterMs(err) {
|
|
23
28
|
if (!(err instanceof GitHubRequestError))
|
|
24
29
|
return null;
|
|
25
|
-
const
|
|
26
|
-
err.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(err.rateLimit !== undefined && err.rateLimit.remaining <= 0);
|
|
30
|
+
const rateLimitMessage = isRateLimitMessage(err.message) ||
|
|
31
|
+
(err.graphqlErrors?.some((error) => isRateLimitMessage(error.message)) ?? false);
|
|
32
|
+
const exhausted = err.rateLimit !== undefined && err.rateLimit.remaining <= 0;
|
|
33
|
+
const retryable = err.status === 429 || err.retryAfterSeconds !== undefined || rateLimitMessage || exhausted;
|
|
30
34
|
if (!retryable)
|
|
31
35
|
return null;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const resource = retryResource(err);
|
|
37
|
+
const rateLimit = err.rateLimit;
|
|
38
|
+
const details = {
|
|
39
|
+
resource,
|
|
40
|
+
...(rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
|
|
41
|
+
...(rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
|
|
42
|
+
...(rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
|
|
43
|
+
};
|
|
44
|
+
if (err.retryAfterSeconds !== undefined) {
|
|
45
|
+
return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
|
|
36
46
|
}
|
|
37
|
-
|
|
47
|
+
if (exhausted && rateLimit !== undefined) {
|
|
48
|
+
return { ...details, ms: Math.max(rateLimit.resetAt * 1000 - Date.now(), 0) };
|
|
49
|
+
}
|
|
50
|
+
return { ...details, ms: GRAPHQL_RETRY_AFTER_DEFAULT_MS };
|
|
51
|
+
}
|
|
52
|
+
function retryResource(err) {
|
|
53
|
+
if (err.rateLimit?.resource)
|
|
54
|
+
return err.rateLimit.resource;
|
|
55
|
+
const secondary = err.status === 429 ||
|
|
56
|
+
err.retryAfterSeconds !== undefined ||
|
|
57
|
+
/secondary/i.test(err.message) ||
|
|
58
|
+
(err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
|
|
59
|
+
return secondary ? "secondary" : "graphql";
|
|
60
|
+
}
|
|
61
|
+
/** Stderr line naming the exhausted budget and when the sleep ends. */
|
|
62
|
+
export function formatRateLimitRetryLine(tickLabel, elapsedSeconds, retry) {
|
|
63
|
+
const resetAt = retry.resetAt ?? Math.ceil((Date.now() + retry.ms) / 1000);
|
|
64
|
+
const clock = `${new Date(resetAt * 1000).toISOString().slice(11, 19)}Z`;
|
|
65
|
+
const label = retry.resource === "graphql"
|
|
66
|
+
? "GitHub GraphQL rate limit"
|
|
67
|
+
: retry.resource === "secondary"
|
|
68
|
+
? "GitHub secondary rate limit"
|
|
69
|
+
: `GitHub REST ${retry.resource} rate limit`;
|
|
70
|
+
const counts = retry.remaining !== undefined && retry.limit !== undefined
|
|
71
|
+
? ` (${retry.remaining}/${retry.limit})`
|
|
72
|
+
: "";
|
|
73
|
+
return `[${tickLabel} / +${elapsedSeconds}s] ${label}${counts} — retrying at ${clock} (in ${Math.round(retry.ms / 1000)}s)\n`;
|
|
38
74
|
}
|
|
39
75
|
export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
|
|
40
|
-
const usage = summarizeApiTelemetry()
|
|
76
|
+
const usage = summarizeApiTelemetry();
|
|
41
77
|
const [owner, repo] = result.repo.split("/");
|
|
42
78
|
if (!usage || !owner || !repo)
|
|
43
79
|
return undefined;
|
|
44
|
-
|
|
80
|
+
if (!usage.graphql && !usage.rest?.some((item) => item.resource === "core"))
|
|
81
|
+
return undefined;
|
|
82
|
+
return selectQuotaWarning({ owner, repo }, bands.map((band) => ({
|
|
45
83
|
...band,
|
|
46
84
|
pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
|
|
47
|
-
})),
|
|
85
|
+
})), usage, true);
|
|
48
86
|
}
|
|
@@ -5,7 +5,7 @@ import { getRepoInfo } from "../github/client.mjs";
|
|
|
5
5
|
import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
6
6
|
import { fetchPollSummary } from "../github/poll-summary.mjs";
|
|
7
7
|
import { sleep } from "../util/sleep.mjs";
|
|
8
|
-
import { aggregateQuotaWarning,
|
|
8
|
+
import { aggregateQuotaWarning, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
|
|
9
9
|
import { planPollSummary, withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
|
|
10
10
|
import { summaryStatusSignature } from "./poll-summary-signature.mjs";
|
|
11
11
|
import { applyStackStallGuard } from "./stack-stall.mjs";
|
|
@@ -83,12 +83,12 @@ async function runAggregatePollCore(opts) {
|
|
|
83
83
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
84
84
|
}, opts.merge);
|
|
85
85
|
}
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
86
|
+
const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(error) : null;
|
|
87
|
+
if (retry === null || rateLimitRetries >= 1)
|
|
88
88
|
throw error;
|
|
89
89
|
rateLimitRetries += 1;
|
|
90
|
-
process.stderr.write(`
|
|
91
|
-
await sleep(
|
|
90
|
+
process.stderr.write(formatRateLimitRetryLine(`aggregate poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
|
|
91
|
+
await sleep(retry.ms);
|
|
92
92
|
continue;
|
|
93
93
|
}
|
|
94
94
|
const allTerminal = last.selection.kind === "stack"
|
|
@@ -142,7 +142,7 @@ async function runAggregatePollCore(opts) {
|
|
|
142
142
|
const elapsedMs = Date.now() - start;
|
|
143
143
|
const sleepMs = debounceUntil
|
|
144
144
|
? Math.min(intervalMs, Math.max(debounceUntil - Date.now(), 0))
|
|
145
|
-
:
|
|
145
|
+
: quotaPollIntervalMs(quotaBands, summarizeApiTelemetry(), intervalMs, MAX_TIMER_MS);
|
|
146
146
|
if (!opts.untilTerminal && debounceUntil === null) {
|
|
147
147
|
const remainingMs = timeoutMs - elapsedMs;
|
|
148
148
|
if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { runIterate } from "./iterate/index.mjs";
|
|
|
2
2
|
import { sleep } from "../util/sleep.mjs";
|
|
3
3
|
import { withPollApiUsage } from "./poll-run.mjs";
|
|
4
4
|
import { loadConfig } from "../config/load.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
|
|
6
6
|
import { writeDebounceProgress, writeWaitProgress } from "./poll-progress.mjs";
|
|
7
7
|
const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
|
|
8
8
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
@@ -54,12 +54,12 @@ async function runPollCore(opts) {
|
|
|
54
54
|
return result;
|
|
55
55
|
}
|
|
56
56
|
catch (err) {
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
57
|
+
const retry = untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
|
|
58
|
+
if (retry === null || rateLimitRetries >= 1)
|
|
59
59
|
throw err;
|
|
60
60
|
rateLimitRetries += 1;
|
|
61
|
-
process.stderr.write(`
|
|
62
|
-
await sleep(
|
|
61
|
+
process.stderr.write(formatRateLimitRetryLine(`poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
|
|
62
|
+
await sleep(retry.ms);
|
|
63
63
|
const result = await iterateTick(fingerprintCache);
|
|
64
64
|
rateLimitRetries = 0;
|
|
65
65
|
return result;
|
|
@@ -101,7 +101,7 @@ async function runPollCore(opts) {
|
|
|
101
101
|
if (pendingQuotaWarning === undefined)
|
|
102
102
|
debounceUntil = null;
|
|
103
103
|
const elapsedMs = Date.now() - start;
|
|
104
|
-
const sleepMs =
|
|
104
|
+
const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS);
|
|
105
105
|
if (!untilTerminal) {
|
|
106
106
|
const remainingMs = timeoutMs - elapsedMs;
|
|
107
107
|
if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
|
|
@@ -126,7 +126,7 @@ async function runPollCore(opts) {
|
|
|
126
126
|
!pastDebounce) {
|
|
127
127
|
debounceUntil = null;
|
|
128
128
|
const elapsedMs = Date.now() - start;
|
|
129
|
-
const sleepMs =
|
|
129
|
+
const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS);
|
|
130
130
|
if (!untilTerminal) {
|
|
131
131
|
const remainingMs = timeoutMs - elapsedMs;
|
|
132
132
|
if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GraphqlQuotaWarningBand } from "../config/load.mts";
|
|
2
|
+
import type { ApiUsage, GraphqlQuotaWarning } from "../types.mts";
|
|
3
|
+
/** Claim GraphQL and REST core warnings separately, then present one result. */
|
|
4
|
+
export declare function selectQuotaWarning(key: {
|
|
5
|
+
owner: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
}, bands: GraphqlQuotaWarningBand[], usage: ApiUsage, persist: boolean): Promise<GraphqlQuotaWarning | undefined>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { withGraphqlCredentialFingerprint } from "../github/api-telemetry.mjs";
|
|
2
|
+
import { composeQuotaWarning } from "../quota-budgets.mjs";
|
|
3
|
+
import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
|
|
4
|
+
/** Claim GraphQL and REST core warnings separately, then present one result. */
|
|
5
|
+
export async function selectQuotaWarning(key, bands, usage, persist) {
|
|
6
|
+
const core = usage.rest?.find((item) => item.resource === "core");
|
|
7
|
+
const graphqlWarning = usage.graphql
|
|
8
|
+
? await evaluateWorktreeGraphqlQuotaWarning(key, bands, withGraphqlCredentialFingerprint(usage.graphql), persist)
|
|
9
|
+
: undefined;
|
|
10
|
+
const coreWarning = core
|
|
11
|
+
? await evaluateWorktreeGraphqlQuotaWarning(key, bands, withGraphqlCredentialFingerprint(core), persist)
|
|
12
|
+
: undefined;
|
|
13
|
+
return composeQuotaWarning({
|
|
14
|
+
graphqlWarning,
|
|
15
|
+
coreWarning,
|
|
16
|
+
graphql: usage.graphql,
|
|
17
|
+
core,
|
|
18
|
+
bands,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GraphqlQuotaWarningBand } from "./config/load.mts";
|
|
2
|
+
import type { ApiResourceUsage, GraphqlQuotaWarning } from "./types.mts";
|
|
3
|
+
type Usage = Pick<ApiResourceUsage, "resource" | "remaining" | "limit" | "used" | "resetAt">;
|
|
4
|
+
/**
|
|
5
|
+
* One warning for the budgets that are low on this tick. A GraphQL warning
|
|
6
|
+
* recommends REST only when REST core is still above its bands. When both are
|
|
7
|
+
* low, the result uses the later reset and does not recommend a switch.
|
|
8
|
+
*/
|
|
9
|
+
export declare function composeQuotaWarning(input: {
|
|
10
|
+
graphqlWarning?: GraphqlQuotaWarning;
|
|
11
|
+
coreWarning?: GraphqlQuotaWarning;
|
|
12
|
+
graphql?: Usage;
|
|
13
|
+
core?: Usage;
|
|
14
|
+
bands: GraphqlQuotaWarningBand[];
|
|
15
|
+
}): GraphqlQuotaWarning | undefined;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** True when this sample is at or below a configured remaining-percent band. */
|
|
2
|
+
function budgetBelowBand(usage, bands) {
|
|
3
|
+
if (usage === undefined || usage.limit <= 0 || bands.length === 0)
|
|
4
|
+
return false;
|
|
5
|
+
return bands.some((band) => usage.remaining * 100 <= usage.limit * band.remainingPercent);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* One warning for the budgets that are low on this tick. A GraphQL warning
|
|
9
|
+
* recommends REST only when REST core is still above its bands. When both are
|
|
10
|
+
* low, the result uses the later reset and does not recommend a switch.
|
|
11
|
+
*/
|
|
12
|
+
export function composeQuotaWarning(input) {
|
|
13
|
+
const graphqlBelow = budgetBelowBand(input.graphql, input.bands);
|
|
14
|
+
const coreBelow = budgetBelowBand(input.core, input.bands);
|
|
15
|
+
const graphql = input.graphqlWarning;
|
|
16
|
+
const core = input.coreWarning;
|
|
17
|
+
if (graphql && core)
|
|
18
|
+
return combineWarnings(graphql, core);
|
|
19
|
+
if (graphql && coreBelow && input.core)
|
|
20
|
+
return combineWarnings(graphql, describeBudget(input.core, input.bands));
|
|
21
|
+
if (core && graphqlBelow && input.graphql) {
|
|
22
|
+
return combineWarnings(core, describeBudget(input.graphql, input.bands));
|
|
23
|
+
}
|
|
24
|
+
return graphql ?? core;
|
|
25
|
+
}
|
|
26
|
+
function describeBudget(usage, bands) {
|
|
27
|
+
const active = bands
|
|
28
|
+
.filter((band) => usage.remaining * 100 <= usage.limit * band.remainingPercent)
|
|
29
|
+
.reduce((lowest, band) => (band.remainingPercent < lowest.remainingPercent ? band : lowest));
|
|
30
|
+
return {
|
|
31
|
+
resource: usage.resource === "core" ? "core" : "graphql",
|
|
32
|
+
thresholdPercent: active.remainingPercent,
|
|
33
|
+
remaining: usage.remaining,
|
|
34
|
+
limit: usage.limit,
|
|
35
|
+
...(usage.used !== undefined && { used: usage.used }),
|
|
36
|
+
resetAt: usage.resetAt,
|
|
37
|
+
pollIntervalMinutes: active.pollIntervalMinutes,
|
|
38
|
+
pollTimeoutMinutes: active.pollIntervalMinutes * 2,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function combineWarnings(left, right) {
|
|
42
|
+
const later = left.resetAt >= right.resetAt ? left : right;
|
|
43
|
+
const interval = Math.max(left.pollIntervalMinutes, right.pollIntervalMinutes);
|
|
44
|
+
return {
|
|
45
|
+
resource: "combined",
|
|
46
|
+
thresholdPercent: Math.min(left.thresholdPercent, right.thresholdPercent),
|
|
47
|
+
remaining: later.remaining,
|
|
48
|
+
limit: later.limit,
|
|
49
|
+
...(later.used !== undefined && { used: later.used }),
|
|
50
|
+
resetAt: later.resetAt,
|
|
51
|
+
pollIntervalMinutes: interval,
|
|
52
|
+
pollTimeoutMinutes: interval * 2,
|
|
53
|
+
budgets: [toBudget(left), toBudget(right)].sort((a, b) => a.resource.localeCompare(b.resource)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function toBudget(warning) {
|
|
57
|
+
return {
|
|
58
|
+
resource: warning.resource === "core" ? "core" : "graphql",
|
|
59
|
+
thresholdPercent: warning.thresholdPercent,
|
|
60
|
+
remaining: warning.remaining,
|
|
61
|
+
limit: warning.limit,
|
|
62
|
+
...(warning.used !== undefined && { used: warning.used }),
|
|
63
|
+
resetAt: warning.resetAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/bin/quota-warning.mjs
CHANGED
|
@@ -2,5 +2,15 @@ export function buildQuotaAwareContinuation(warning, prefix) {
|
|
|
2
2
|
const interval = `${warning.pollIntervalMinutes}m`;
|
|
3
3
|
const timeout = `${warning.pollTimeoutMinutes}m`;
|
|
4
4
|
const resetTime = new Date(warning.resetAt * 1000).toISOString();
|
|
5
|
-
|
|
5
|
+
const opening = warning.resource === "combined"
|
|
6
|
+
? "GitHub's GraphQL and REST core quotas are both low. Keep using pr-shepherd at the cadence below. Do not shift incidental calls between GraphQL and REST."
|
|
7
|
+
: warning.resource === "core"
|
|
8
|
+
? `GitHub's REST core quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below. Do not add incidental REST \`gh\` calls (\`gh pr view\`, \`gh pr review\`, \`gh api\`) while REST core is below its warning threshold.`
|
|
9
|
+
: `GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR operations that do not need Shepherd's full snapshot, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr review\`, \`gh api\` REST endpoints) — they draw on the separate REST budget, not the depleted GraphQL pool.`;
|
|
10
|
+
const resetLabel = warning.resource === "combined"
|
|
11
|
+
? "both quotas have reset"
|
|
12
|
+
: warning.resource === "core"
|
|
13
|
+
? "the REST core quota resets"
|
|
14
|
+
: "the GraphQL quota resets";
|
|
15
|
+
return `${prefix} ${opening} Do not substitute \`gh pr checks\` or \`gh pr watch\` for the Shepherd loop. Resume full-cadence pr-shepherd after ${resetLabel} 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, raise any shorter interval and timeout flags to at least \`--interval ${interval} --timeout ${timeout}\`, keep any longer cadence, 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
16
|
}
|
|
@@ -32,7 +32,7 @@ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt
|
|
|
32
32
|
return { state };
|
|
33
33
|
return {
|
|
34
34
|
warning: {
|
|
35
|
-
resource: "graphql",
|
|
35
|
+
resource: sample.resource === "core" ? "core" : "graphql",
|
|
36
36
|
thresholdPercent: active.remainingPercent,
|
|
37
37
|
remaining: effective.remaining,
|
|
38
38
|
limit: effective.limit,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GraphqlQuotaWarningBand } from "../config/load.mts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ApiResourceUsage, GraphqlQuotaWarning } from "../types.mts";
|
|
3
3
|
export declare function evaluateWorktreeGraphqlQuotaWarning(key: {
|
|
4
4
|
owner: string;
|
|
5
5
|
repo: string;
|
|
6
|
-
}, bands: GraphqlQuotaWarningBand[], sample:
|
|
6
|
+
}, bands: GraphqlQuotaWarningBand[], sample: ApiResourceUsage, persist: boolean, now?: number): Promise<GraphqlQuotaWarning | undefined>;
|
|
@@ -10,9 +10,10 @@ const sessionStates = new Map();
|
|
|
10
10
|
export async function evaluateWorktreeGraphqlQuotaWarning(key, bands, sample, persist, now = Date.now() / 1000) {
|
|
11
11
|
if (bands.length === 0)
|
|
12
12
|
return undefined;
|
|
13
|
-
const
|
|
13
|
+
const quotaResource = sample.resource === "core" ? "core" : "graphql";
|
|
14
|
+
const path = await warningStatePath(key, quotaResource);
|
|
14
15
|
if (path === undefined) {
|
|
15
|
-
const sessionKey = `${key.owner}/${key.repo}`;
|
|
16
|
+
const sessionKey = `${key.owner}/${key.repo}/${quotaResource}`;
|
|
16
17
|
if (!persist) {
|
|
17
18
|
return evaluateGraphqlQuotaWarning(bands, sample, sessionStates.get(sessionKey) ?? null, now)
|
|
18
19
|
.warning;
|
|
@@ -50,7 +51,7 @@ async function serializeStateUpdate(key, update) {
|
|
|
50
51
|
});
|
|
51
52
|
return result;
|
|
52
53
|
}
|
|
53
|
-
async function warningStatePath(key) {
|
|
54
|
+
async function warningStatePath(key, resource) {
|
|
54
55
|
let worktreeKey;
|
|
55
56
|
try {
|
|
56
57
|
worktreeKey = await getWorktreeKey();
|
|
@@ -58,7 +59,7 @@ async function warningStatePath(key) {
|
|
|
58
59
|
catch {
|
|
59
60
|
return undefined;
|
|
60
61
|
}
|
|
61
|
-
return join(resolveRepoStateDir(key), "worktrees", `${worktreeKey}-
|
|
62
|
+
return join(resolveRepoStateDir(key), "worktrees", `${worktreeKey}-${resource}-quota-warnings.json`);
|
|
62
63
|
}
|
|
63
64
|
async function readState(path) {
|
|
64
65
|
try {
|
|
@@ -5,6 +5,8 @@ export interface ApiResourceUsage {
|
|
|
5
5
|
used?: number;
|
|
6
6
|
remaining: number;
|
|
7
7
|
resetAt: number;
|
|
8
|
+
/** Truncated SHA-256 of the credential. Quota state only; summarized usage omits it. */
|
|
9
|
+
credentialFingerprint?: string;
|
|
8
10
|
}
|
|
9
11
|
export interface GraphqlApiUsage extends ApiResourceUsage {
|
|
10
12
|
/** Exact sum reported by rateLimit.cost for GraphQL queries in this command. */
|
|
@@ -13,8 +15,15 @@ export interface GraphqlApiUsage extends ApiResourceUsage {
|
|
|
13
15
|
unmeasuredRequestCount: number;
|
|
14
16
|
/** Exact sum reported by rateLimit.nodeCount for measured GraphQL queries. */
|
|
15
17
|
nodeCount: number;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
}
|
|
19
|
+
/** One budget inside a combined GraphQL and REST core warning. */
|
|
20
|
+
export interface QuotaWarningBudget {
|
|
21
|
+
resource: "graphql" | "core";
|
|
22
|
+
thresholdPercent: number;
|
|
23
|
+
remaining: number;
|
|
24
|
+
limit: number;
|
|
25
|
+
used?: number;
|
|
26
|
+
resetAt: number;
|
|
18
27
|
}
|
|
19
28
|
export interface ApiUsage {
|
|
20
29
|
credentialSources: string[];
|
|
@@ -22,7 +31,7 @@ export interface ApiUsage {
|
|
|
22
31
|
rest?: ApiResourceUsage[];
|
|
23
32
|
}
|
|
24
33
|
export interface GraphqlQuotaWarning {
|
|
25
|
-
resource: "graphql";
|
|
34
|
+
resource: "graphql" | "core" | "combined";
|
|
26
35
|
thresholdPercent: number;
|
|
27
36
|
remaining: number;
|
|
28
37
|
limit: number;
|
|
@@ -30,4 +39,6 @@ export interface GraphqlQuotaWarning {
|
|
|
30
39
|
resetAt: number;
|
|
31
40
|
pollIntervalMinutes: number;
|
|
32
41
|
pollTimeoutMinutes: number;
|
|
42
|
+
/** Both budgets when GraphQL and REST core are low together. */
|
|
43
|
+
budgets?: QuotaWarningBudget[];
|
|
33
44
|
}
|
package/package.json
CHANGED