pr-shepherd 0.52.1 → 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/README.md +15 -13
- package/bin/cli/api-usage-formatter.mjs +17 -3
- package/bin/cli/iterate-instructions.mjs +2 -21
- package/bin/cli/poll-summary-formatter.mjs +1 -2
- package/bin/commands/iterate/api-usage.mjs +4 -3
- package/bin/commands/iterate/index.mjs +3 -12
- package/bin/commands/iterate/parent-first.d.mts +6 -23
- package/bin/commands/iterate/parent-first.mjs +7 -64
- package/bin/commands/poll-quota.d.mts +18 -6
- package/bin/commands/poll-quota.mjs +55 -17
- package/bin/commands/poll-summary-instructions.mjs +26 -41
- package/bin/commands/poll-summary.mjs +6 -6
- package/bin/commands/poll.mjs +8 -10
- package/bin/commands/quota-selection.d.mts +7 -0
- package/bin/commands/quota-selection.mjs +20 -0
- package/bin/commands/stack-drain.d.mts +5 -5
- package/bin/commands/stack-drain.mjs +39 -30
- package/bin/commands/stack-layer-readiness.d.mts +2 -3
- package/bin/commands/stack-layer-readiness.mjs +4 -5
- package/bin/commands/stack-stall.mjs +0 -1
- package/bin/commands/stack-work.d.mts +4 -4
- package/bin/commands/stack-work.mjs +5 -6
- package/bin/github/api-telemetry-aggregate.d.mts +1 -0
- package/bin/github/api-telemetry-aggregate.mjs +8 -2
- package/bin/github/api-telemetry.d.mts +4 -0
- package/bin/github/api-telemetry.mjs +12 -0
- package/bin/github/graphql-http.mjs +6 -0
- package/bin/github/http-auth.d.mts +3 -0
- package/bin/github/http-auth.mjs +6 -0
- package/bin/github/http-intermediate.d.mts +1 -0
- package/bin/github/http-intermediate.mjs +3 -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.d.mts +7 -1
- package/bin/state/graphql-quota-policy.mjs +42 -15
- package/bin/state/graphql-quota-warnings.d.mts +2 -2
- package/bin/state/graphql-quota-warnings.mjs +7 -4
- package/bin/types/api-usage.d.mts +14 -1
- package/bin/types/merge-requirements.d.mts +3 -13
- package/bin/types/poll-summary.d.mts +0 -2
- 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/bin/commands/poll.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { runIterate } from "./iterate/index.mjs";
|
|
2
|
-
import { heldByLowerLayer } from "./iterate/parent-first.mjs";
|
|
3
2
|
import { sleep } from "../util/sleep.mjs";
|
|
4
3
|
import { withPollApiUsage } from "./poll-run.mjs";
|
|
5
4
|
import { loadConfig } from "../config/load.mjs";
|
|
6
|
-
import {
|
|
5
|
+
import { formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
|
|
7
6
|
import { writeDebounceProgress, writeWaitProgress } from "./poll-progress.mjs";
|
|
8
7
|
const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
|
|
9
8
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
@@ -55,12 +54,12 @@ async function runPollCore(opts) {
|
|
|
55
54
|
return result;
|
|
56
55
|
}
|
|
57
56
|
catch (err) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
57
|
+
const retry = untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
|
|
58
|
+
if (retry === null || rateLimitRetries >= 1)
|
|
60
59
|
throw err;
|
|
61
60
|
rateLimitRetries += 1;
|
|
62
|
-
process.stderr.write(`
|
|
63
|
-
await sleep(
|
|
61
|
+
process.stderr.write(formatRateLimitRetryLine(`poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
|
|
62
|
+
await sleep(retry.ms);
|
|
64
63
|
const result = await iterateTick(fingerprintCache);
|
|
65
64
|
rateLimitRetries = 0;
|
|
66
65
|
return result;
|
|
@@ -98,12 +97,11 @@ async function runPollCore(opts) {
|
|
|
98
97
|
}
|
|
99
98
|
break;
|
|
100
99
|
}
|
|
101
|
-
|
|
102
|
-
if (lastResult.action === "wait" && !pastDebounce && !heldByLowerLayer(lastResult)) {
|
|
100
|
+
if (lastResult.action === "wait" && !pastDebounce) {
|
|
103
101
|
if (pendingQuotaWarning === undefined)
|
|
104
102
|
debounceUntil = null;
|
|
105
103
|
const elapsedMs = Date.now() - start;
|
|
106
|
-
const sleepMs =
|
|
104
|
+
const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS);
|
|
107
105
|
if (!untilTerminal) {
|
|
108
106
|
const remainingMs = timeoutMs - elapsedMs;
|
|
109
107
|
if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
|
|
@@ -128,7 +126,7 @@ async function runPollCore(opts) {
|
|
|
128
126
|
!pastDebounce) {
|
|
129
127
|
debounceUntil = null;
|
|
130
128
|
const elapsedMs = Date.now() - start;
|
|
131
|
-
const sleepMs =
|
|
129
|
+
const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS);
|
|
132
130
|
if (!untilTerminal) {
|
|
133
131
|
const remainingMs = timeoutMs - elapsedMs;
|
|
134
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
|
+
}
|
|
@@ -8,17 +8,17 @@ export interface StackPlan {
|
|
|
8
8
|
instructions: string[];
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Merge the ready
|
|
12
|
-
* number first, but stack numbers come from the repository's issue and
|
|
13
|
-
* request sequence, so a PR number never names a stack.
|
|
11
|
+
* Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
|
|
12
|
+
* stack number first, but stack numbers come from the repository's issue and
|
|
13
|
+
* pull request sequence, so a PR number never names a stack.
|
|
14
14
|
*/
|
|
15
|
-
export declare function
|
|
15
|
+
export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean): StackPlan | undefined;
|
|
16
16
|
/**
|
|
17
17
|
* A merge-requested, fully ready stack whose bottom open layer still targets
|
|
18
18
|
* the merged layer below it until GitHub retargets it onto the stack base.
|
|
19
19
|
*/
|
|
20
20
|
export declare function retargetWaitPlan(first: PollSummaryItem): StackPlan;
|
|
21
|
-
/** Every remaining layer only waits
|
|
21
|
+
/** Every remaining layer only waits on CI or merge state. */
|
|
22
22
|
export declare function idleWaitPlan(idle: PollSummaryItem[]): StackPlan;
|
|
23
23
|
export declare function describeIdleLayers(idle: PollSummaryItem[]): string;
|
|
24
24
|
export declare function appendAutonomousInstructions(instructions: string[], candidates: PollSummaryItem[]): void;
|
|
@@ -1,51 +1,63 @@
|
|
|
1
1
|
import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
|
|
2
2
|
import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Highest open layer such that it and every open layer below it are ready to
|
|
5
|
+
* merge together. `gh stack merge <PR>` lands that PR and every unmerged layer
|
|
6
|
+
* below it. A merge queue accepts the same prefix and evaluates each layer
|
|
7
|
+
* from the bottom; a failure ejects that layer and those above it.
|
|
7
8
|
*/
|
|
8
|
-
function
|
|
9
|
+
function readyPrefixTop(result) {
|
|
9
10
|
const layers = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
|
|
10
|
-
|
|
11
|
-
if (!bottom || !isStackLayer(bottom) || bottom.state !== "OPEN")
|
|
11
|
+
if (layers.some((item) => item.state === "OPEN" && item.isInMergeQueue))
|
|
12
12
|
return undefined;
|
|
13
|
-
|
|
13
|
+
const open = layers.filter((item) => item.state === "OPEN");
|
|
14
|
+
const bottom = open[0];
|
|
15
|
+
if (!bottom || !isStackLayer(bottom) || bottom.state !== "OPEN")
|
|
14
16
|
return undefined;
|
|
15
17
|
if (bottom.baseRefName !== bottom.stack.baseRefName)
|
|
16
18
|
return undefined;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const stale = new Set((result.stackAncestry ?? []).map((gap) => gap.childPr));
|
|
20
|
+
let top;
|
|
21
|
+
for (const item of open) {
|
|
22
|
+
if (!isStackLayer(item))
|
|
23
|
+
break;
|
|
24
|
+
if (item.action === "escalate" || !isStackLayerReady(item) || stale.has(item.pr))
|
|
25
|
+
break;
|
|
26
|
+
if (layers.some((layer) => layer.state === "CLOSED" && stackPosition(layer) < stackPosition(item)))
|
|
27
|
+
break;
|
|
28
|
+
top = item;
|
|
29
|
+
}
|
|
30
|
+
return top;
|
|
22
31
|
}
|
|
23
32
|
/**
|
|
24
|
-
* Merge the ready
|
|
25
|
-
* number first, but stack numbers come from the repository's issue and
|
|
26
|
-
* request sequence, so a PR number never names a stack.
|
|
33
|
+
* Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
|
|
34
|
+
* stack number first, but stack numbers come from the repository's issue and
|
|
35
|
+
* pull request sequence, so a PR number never names a stack.
|
|
27
36
|
*/
|
|
28
|
-
export function
|
|
37
|
+
export function planPrefixDrain(result, mergeRequested) {
|
|
29
38
|
if (!mergeRequested)
|
|
30
39
|
return undefined;
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
40
|
+
const top = readyPrefixTop(result);
|
|
41
|
+
if (!top)
|
|
33
42
|
return undefined;
|
|
34
43
|
const gaps = result.stackAncestry ?? [];
|
|
35
44
|
const staleChildren = new Set(gaps.map((gap) => gap.childPr));
|
|
36
|
-
const open = result.prs
|
|
37
|
-
|
|
45
|
+
const open = result.prs
|
|
46
|
+
.filter((item) => item.state === "OPEN")
|
|
47
|
+
.sort((left, right) => stackPosition(left) - stackPosition(right));
|
|
48
|
+
const above = splitStackWork(open.filter((item) => stackPosition(item) > stackPosition(top) &&
|
|
38
49
|
item.action !== "escalate" &&
|
|
39
50
|
(!isStackLayerReady(item) || staleChildren.has(item.pr))), staleChildren);
|
|
51
|
+
const span = open[0]?.pr === top.pr ? "that layer alone" : `PR #${top.pr} and every unmerged layer below it`;
|
|
40
52
|
const instructions = [
|
|
41
|
-
`1. PR #${
|
|
53
|
+
`1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes --squash\` to merge ${span}. When the base uses a merge queue, the same command queues that prefix together and GitHub evaluates each layer from the bottom; a failure ejects that layer and the layers above it. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\` first.`,
|
|
42
54
|
];
|
|
43
|
-
appendAutonomousInstructions(instructions,
|
|
44
|
-
appendMarkReadyInstructions(instructions,
|
|
55
|
+
appendAutonomousInstructions(instructions, above.sessions);
|
|
56
|
+
appendMarkReadyInstructions(instructions, above.markReady);
|
|
45
57
|
const handoffs = findHumanHandoffs(result);
|
|
46
58
|
if (handoffs)
|
|
47
59
|
appendHumanHandoffInstructions(instructions, handoffs, false);
|
|
48
|
-
instructions.push(`${instructions.length + 1}. After the merge attempt, rerun this same \`--stack --merge\` selector; GitHub retargets the next layer onto \`${
|
|
60
|
+
instructions.push(`${instructions.length + 1}. After the merge attempt, rerun this same \`--stack --merge\` selector; GitHub retargets the next layer onto \`${top.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
|
|
49
61
|
return {
|
|
50
62
|
action: "merge",
|
|
51
63
|
stackMergeable: gaps.length === 0 && open.every(isStackLayerReady),
|
|
@@ -66,7 +78,7 @@ export function retargetWaitPlan(first) {
|
|
|
66
78
|
],
|
|
67
79
|
};
|
|
68
80
|
}
|
|
69
|
-
/** Every remaining layer only waits
|
|
81
|
+
/** Every remaining layer only waits on CI or merge state. */
|
|
70
82
|
export function idleWaitPlan(idle) {
|
|
71
83
|
return {
|
|
72
84
|
action: "wait",
|
|
@@ -79,9 +91,7 @@ export function idleWaitPlan(idle) {
|
|
|
79
91
|
};
|
|
80
92
|
}
|
|
81
93
|
export function describeIdleLayers(idle) {
|
|
82
|
-
return idle
|
|
83
|
-
.map((item) => `PR #${item.pr} (${item.blockedByPr ? `stack-blocked by PR #${item.blockedByPr}` : item.reasons.join(", ")})`)
|
|
84
|
-
.join("; ");
|
|
94
|
+
return idle.map((item) => `PR #${item.pr} (${item.reasons.join(", ")})`).join("; ");
|
|
85
95
|
}
|
|
86
96
|
export function appendAutonomousInstructions(instructions, candidates) {
|
|
87
97
|
if (candidates.length === 0)
|
|
@@ -89,10 +99,9 @@ export function appendAutonomousInstructions(instructions, candidates) {
|
|
|
89
99
|
instructions.push(`${instructions.length + 1}. Start or delegate the relevant one-PR sessions below; review and CI work on separate layers can proceed concurrently.`);
|
|
90
100
|
for (const item of candidates) {
|
|
91
101
|
instructions.push(item.pollCommand
|
|
92
|
-
? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.
|
|
102
|
+
? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
|
|
93
103
|
: `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
|
|
94
104
|
}
|
|
95
|
-
instructions.push(`${instructions.length + 1}. Keep upper draft PRs in draft until every lower layer has completed Shepherd READY.`);
|
|
96
105
|
}
|
|
97
106
|
export function findHumanHandoffs(result) {
|
|
98
107
|
const lastOpen = result.prs.filter((item) => item.state === "OPEN").at(-1);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { PollSummaryItem, StackLayerBlockReason } from "../types.mts";
|
|
2
2
|
/**
|
|
3
|
-
* Why a native stack layer
|
|
4
|
-
*
|
|
5
|
-
* share this predicate so both always name the same blocking layer.
|
|
3
|
+
* Why a native stack layer is not ready to merge, or undefined when it is.
|
|
4
|
+
* A draft is marked ready by its own session; this predicate does not gate that.
|
|
6
5
|
*/
|
|
7
6
|
export declare function stackLayerBlockReason(item: PollSummaryItem): StackLayerBlockReason | undefined;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Why a native stack layer
|
|
3
|
-
*
|
|
4
|
-
* share this predicate so both always name the same blocking layer.
|
|
2
|
+
* Why a native stack layer is not ready to merge, or undefined when it is.
|
|
3
|
+
* A draft is marked ready by its own session; this predicate does not gate that.
|
|
5
4
|
*/
|
|
6
5
|
export function stackLayerBlockReason(item) {
|
|
7
6
|
if (item.state !== "OPEN")
|
|
@@ -13,7 +12,7 @@ export function stackLayerBlockReason(item) {
|
|
|
13
12
|
// A receipt only establishes readiness after a merge-queue removal once
|
|
14
13
|
// the one-PR session has observed and acknowledged that exact removal.
|
|
15
14
|
// The aggregate projection preserves an unacknowledged removal here, so
|
|
16
|
-
//
|
|
15
|
+
// the layer is not ready to merge.
|
|
17
16
|
if (item.queueRemoval)
|
|
18
17
|
return "queue-removal";
|
|
19
18
|
if ((item.checks?.failing ?? 0) > 0)
|
|
@@ -30,6 +29,6 @@ export function stackLayerBlockReason(item) {
|
|
|
30
29
|
return "merge-state";
|
|
31
30
|
}
|
|
32
31
|
// A layer that looks ready but has not completed its own one-PR receipt
|
|
33
|
-
// is not
|
|
32
|
+
// is not ready to merge.
|
|
34
33
|
return item.readyReceipt === true ? undefined : "no-ready-receipt";
|
|
35
34
|
}
|
|
@@ -19,14 +19,14 @@ export interface StackWork {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
|
|
22
|
-
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A
|
|
23
|
-
*
|
|
24
|
-
* child keeps its session, which returns the ancestry repair.
|
|
22
|
+
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
|
|
23
|
+
* kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
|
|
24
|
+
* A stale-ancestry child keeps its session, which returns the ancestry repair.
|
|
25
25
|
*/
|
|
26
26
|
export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
|
|
27
27
|
/**
|
|
28
28
|
* The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
|
|
29
|
-
* first because only the one-PR session reads the full review context
|
|
29
|
+
* first because only the one-PR session reads the full review context.
|
|
30
30
|
*/
|
|
31
31
|
export declare function appendMarkReadyInstructions(instructions: string[], layers: ProbedLayer[]): void;
|
|
32
32
|
export {};
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
export const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
|
|
6
6
|
/**
|
|
7
7
|
* Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
|
|
8
|
-
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A
|
|
9
|
-
*
|
|
10
|
-
* child keeps its session, which returns the ancestry repair.
|
|
8
|
+
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
|
|
9
|
+
* kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
|
|
10
|
+
* A stale-ancestry child keeps its session, which returns the ancestry repair.
|
|
11
11
|
*/
|
|
12
12
|
export function splitStackWork(candidates, staleChildren) {
|
|
13
13
|
const work = { sessions: [], markReady: [], idle: [] };
|
|
@@ -15,8 +15,7 @@ export function splitStackWork(candidates, staleChildren) {
|
|
|
15
15
|
if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
|
|
16
16
|
work.sessions.push(item);
|
|
17
17
|
}
|
|
18
|
-
else if (item.
|
|
19
|
-
item.reasons.includes("draft-auto-mark-ready-disabled")) {
|
|
18
|
+
else if (item.reasons.includes("draft-auto-mark-ready-disabled")) {
|
|
20
19
|
work.markReady.push(item);
|
|
21
20
|
}
|
|
22
21
|
else {
|
|
@@ -27,7 +26,7 @@ export function splitStackWork(candidates, staleChildren) {
|
|
|
27
26
|
}
|
|
28
27
|
/**
|
|
29
28
|
* The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
|
|
30
|
-
* first because only the one-PR session reads the full review context
|
|
29
|
+
* first because only the one-PR session reads the full review context.
|
|
31
30
|
*/
|
|
32
31
|
export function appendMarkReadyInstructions(instructions, layers) {
|
|
33
32
|
for (const item of layers) {
|
|
@@ -21,7 +21,7 @@ export function aggregateEvents(events) {
|
|
|
21
21
|
aggregate.graphql.measuredQueryCost += event.rateLimit.cost;
|
|
22
22
|
aggregate.graphql.nodeCount += event.rateLimit?.nodeCount ?? 0;
|
|
23
23
|
if (event.rateLimit !== undefined) {
|
|
24
|
-
aggregate.graphql
|
|
24
|
+
adoptGraphqlRateLimit(aggregate.graphql, event.rateLimit, event.credentialFingerprint);
|
|
25
25
|
}
|
|
26
26
|
continue;
|
|
27
27
|
}
|
|
@@ -48,7 +48,7 @@ export function mergeAggregate(target, source) {
|
|
|
48
48
|
target.graphql.unmeasuredRequestCount += source.graphql.unmeasuredRequestCount;
|
|
49
49
|
target.graphql.nodeCount += source.graphql.nodeCount;
|
|
50
50
|
if (source.graphql.rateLimit !== undefined) {
|
|
51
|
-
target.graphql
|
|
51
|
+
adoptGraphqlRateLimit(target.graphql, source.graphql.rateLimit, source.graphql.credentialFingerprint);
|
|
52
52
|
}
|
|
53
53
|
for (const [resource, sourceGroup] of source.rest) {
|
|
54
54
|
const targetGroup = target.rest.get(resource) ?? { requestCount: 0 };
|
|
@@ -65,6 +65,12 @@ export function aggregateStore(active) {
|
|
|
65
65
|
mergeAggregate(all, aggregateEvents(active.events));
|
|
66
66
|
return all;
|
|
67
67
|
}
|
|
68
|
+
function adoptGraphqlRateLimit(graphql, candidate, fingerprint) {
|
|
69
|
+
const next = selectAuthoritativeRateLimit(graphql.rateLimit, candidate);
|
|
70
|
+
if (next !== graphql.rateLimit)
|
|
71
|
+
graphql.credentialFingerprint = fingerprint;
|
|
72
|
+
graphql.rateLimit = next;
|
|
73
|
+
}
|
|
68
74
|
function selectAuthoritativeRateLimit(current, candidate) {
|
|
69
75
|
if (current === undefined)
|
|
70
76
|
return { ...candidate };
|
|
@@ -4,10 +4,14 @@ export interface ApiTelemetryEvent {
|
|
|
4
4
|
kind: "GraphQL" | "REST";
|
|
5
5
|
method: string;
|
|
6
6
|
authSource: string;
|
|
7
|
+
/** Truncated SHA-256 of the credential that produced this request. */
|
|
8
|
+
credentialFingerprint?: string;
|
|
7
9
|
rateLimit?: RateLimitInfo;
|
|
8
10
|
}
|
|
9
11
|
/** Isolates a top-level CLI/MCP command while allowing nested iterate ticks to aggregate. */
|
|
10
12
|
export declare function withApiTelemetryScope<T>(fn: () => Promise<T>): Promise<T>;
|
|
11
13
|
export declare function recordApiTelemetry(event: ApiTelemetryEvent): void;
|
|
12
14
|
export declare function mergeGraphqlRateLimit(headerRateLimit: RateLimitInfo | null, data: unknown): RateLimitInfo | null;
|
|
15
|
+
/** Attach the credential fingerprint without adding it when this command has none. */
|
|
16
|
+
export declare function withGraphqlCredentialFingerprint<T extends object>(sample: T): T;
|
|
13
17
|
export declare function summarizeApiTelemetry(): ApiUsage | undefined;
|
|
@@ -65,6 +65,18 @@ export function mergeGraphqlRateLimit(headerRateLimit, data) {
|
|
|
65
65
|
...(payload !== null && Number.isFinite(payload.nodeCount) && { nodeCount: payload.nodeCount }),
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
/** Fingerprint of the credential that owns the authoritative GraphQL rate-limit sample. */
|
|
69
|
+
function graphqlQuotaCredentialFingerprint() {
|
|
70
|
+
const active = store();
|
|
71
|
+
if (active === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
return aggregateStore(active).graphql.credentialFingerprint;
|
|
74
|
+
}
|
|
75
|
+
/** Attach the credential fingerprint without adding it when this command has none. */
|
|
76
|
+
export function withGraphqlCredentialFingerprint(sample) {
|
|
77
|
+
const credentialFingerprint = graphqlQuotaCredentialFingerprint();
|
|
78
|
+
return credentialFingerprint === undefined ? sample : { ...sample, credentialFingerprint };
|
|
79
|
+
}
|
|
68
80
|
export function summarizeApiTelemetry() {
|
|
69
81
|
const active = store();
|
|
70
82
|
if (active === undefined)
|
|
@@ -22,9 +22,11 @@ async function graphqlInner(query, vars, opts) {
|
|
|
22
22
|
}));
|
|
23
23
|
const t0 = performance.now();
|
|
24
24
|
let authSource = "unknown";
|
|
25
|
+
let credentialFingerprint;
|
|
25
26
|
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
|
|
26
27
|
const auth = await makeAuthHeaders();
|
|
27
28
|
authSource = auth.source;
|
|
29
|
+
credentialFingerprint = auth.fingerprint;
|
|
28
30
|
return fetch(url, {
|
|
29
31
|
method: "POST",
|
|
30
32
|
headers: auth.headers,
|
|
@@ -38,6 +40,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
38
40
|
response,
|
|
39
41
|
durationMs,
|
|
40
42
|
authSource,
|
|
43
|
+
credentialFingerprint,
|
|
41
44
|
}));
|
|
42
45
|
const durationMs = Math.round(performance.now() - retryT0);
|
|
43
46
|
const headerRateLimit = parseRateLimit(res.headers);
|
|
@@ -61,6 +64,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
61
64
|
kind: "GraphQL",
|
|
62
65
|
method: "POST",
|
|
63
66
|
authSource,
|
|
67
|
+
credentialFingerprint,
|
|
64
68
|
rateLimit: headerRateLimit ?? undefined,
|
|
65
69
|
});
|
|
66
70
|
throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, {
|
|
@@ -93,6 +97,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
93
97
|
kind: "GraphQL",
|
|
94
98
|
method: "POST",
|
|
95
99
|
authSource,
|
|
100
|
+
credentialFingerprint,
|
|
96
101
|
rateLimit: headerRateLimit ?? undefined,
|
|
97
102
|
});
|
|
98
103
|
throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
|
|
@@ -123,6 +128,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
123
128
|
kind: "GraphQL",
|
|
124
129
|
method: "POST",
|
|
125
130
|
authSource,
|
|
131
|
+
credentialFingerprint,
|
|
126
132
|
rateLimit: rateLimit ?? undefined,
|
|
127
133
|
});
|
|
128
134
|
const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
|
|
@@ -2,6 +2,8 @@ export type AuthSource = "GH_TOKEN" | "GITHUB_TOKEN" | "gh auth token" | "GITHUB
|
|
|
2
2
|
export declare function _resetTokenCache(): void;
|
|
3
3
|
export declare function hasCachedToken(): boolean;
|
|
4
4
|
export declare function clearTokenCache(): void;
|
|
5
|
+
/** Truncated SHA-256. Callers persist this instead of the token or its full hash. */
|
|
6
|
+
export declare function credentialFingerprint(token: string): string;
|
|
5
7
|
/**
|
|
6
8
|
* `extra` lets callers layer additional headers (e.g. `If-None-Match` for
|
|
7
9
|
* conditional REST requests) on top of the standard auth/version headers.
|
|
@@ -9,4 +11,5 @@ export declare function clearTokenCache(): void;
|
|
|
9
11
|
export declare function makeAuthHeaders(extra?: Record<string, string>): Promise<{
|
|
10
12
|
headers: Record<string, string>;
|
|
11
13
|
source: AuthSource;
|
|
14
|
+
fingerprint: string;
|
|
12
15
|
}>;
|
package/bin/github/http-auth.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { execFile as execFileCb } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
@@ -15,6 +16,10 @@ export function clearTokenCache() {
|
|
|
15
16
|
_token = undefined;
|
|
16
17
|
_tokenSource = undefined;
|
|
17
18
|
}
|
|
19
|
+
/** Truncated SHA-256. Callers persist this instead of the token or its full hash. */
|
|
20
|
+
export function credentialFingerprint(token) {
|
|
21
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
22
|
+
}
|
|
18
23
|
async function resolveToken() {
|
|
19
24
|
if (_token && _tokenSource)
|
|
20
25
|
return { token: _token, source: _tokenSource };
|
|
@@ -58,6 +63,7 @@ export async function makeAuthHeaders(extra) {
|
|
|
58
63
|
const { token, source } = await resolveToken();
|
|
59
64
|
return {
|
|
60
65
|
source,
|
|
66
|
+
fingerprint: credentialFingerprint(token),
|
|
61
67
|
headers: {
|
|
62
68
|
Authorization: `Bearer ${token}`,
|
|
63
69
|
Accept: "application/vnd.github+json",
|
|
@@ -15,6 +15,9 @@ export function recordIntermediateResponse(opts) {
|
|
|
15
15
|
kind: opts.kind === "GraphQL" ? "GraphQL" : "REST",
|
|
16
16
|
method: opts.method,
|
|
17
17
|
authSource: opts.authSource,
|
|
18
|
+
...(opts.credentialFingerprint !== undefined && {
|
|
19
|
+
credentialFingerprint: opts.credentialFingerprint,
|
|
20
|
+
}),
|
|
18
21
|
rateLimit,
|
|
19
22
|
});
|
|
20
23
|
}
|
|
@@ -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
|
}
|
|
@@ -7,11 +7,17 @@ export interface GraphqlQuotaWarningState {
|
|
|
7
7
|
lastRemaining: number;
|
|
8
8
|
resetAt: number;
|
|
9
9
|
warnedThresholds: number[];
|
|
10
|
+
/** Truncated SHA-256 of the credential. Absent in state written before fingerprints. */
|
|
11
|
+
credentialFingerprint?: string;
|
|
10
12
|
rearmEpoch?: number;
|
|
11
13
|
}
|
|
12
|
-
|
|
14
|
+
type GraphqlQuotaSample = Pick<GraphqlApiUsage, "resource" | "limit" | "used" | "remaining" | "resetAt"> & {
|
|
15
|
+
credentialFingerprint?: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function evaluateGraphqlQuotaWarning(bands: GraphqlQuotaWarningBand[], sample: GraphqlQuotaSample, previous: GraphqlQuotaWarningState | null, observedAt?: number): {
|
|
13
18
|
warning?: GraphqlQuotaWarning;
|
|
14
19
|
state: GraphqlQuotaWarningState & {
|
|
15
20
|
rearmEpoch: number;
|
|
16
21
|
};
|
|
17
22
|
};
|
|
23
|
+
export {};
|