pr-shepherd 0.52.0 → 0.53.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 +17 -12
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +4 -3
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -2
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-instructions.mjs +22 -0
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/poll-summary-formatter.mjs +4 -3
- package/bin/cli/runner.d.mts +1 -0
- package/bin/cli/runner.mjs +3 -1
- package/bin/commands/check.mjs +13 -4
- package/bin/commands/clean.mjs +7 -13
- package/bin/commands/iterate/api-usage.mjs +2 -2
- package/bin/commands/iterate/check-instructions.d.mts +2 -2
- package/bin/commands/iterate/check-instructions.mjs +11 -7
- package/bin/commands/iterate/escalate.mjs +4 -0
- package/bin/commands/iterate/fix-code.mjs +6 -1
- package/bin/commands/iterate/helpers.d.mts +0 -1
- package/bin/commands/iterate/helpers.mjs +0 -15
- package/bin/commands/iterate/index.mjs +48 -27
- package/bin/commands/iterate/merge-state.mjs +6 -4
- package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
- package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
- package/bin/commands/iterate/parent-first.d.mts +6 -7
- package/bin/commands/iterate/parent-first.mjs +10 -54
- package/bin/commands/iterate/render.d.mts +1 -1
- package/bin/commands/iterate/render.mjs +7 -12
- package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
- package/bin/commands/iterate/stale-ancestry.mjs +10 -7
- package/bin/commands/iterate/stall.mjs +40 -3
- package/bin/commands/poll-progress.d.mts +5 -1
- package/bin/commands/poll-progress.mjs +7 -1
- package/bin/commands/poll-quota.mjs +2 -2
- package/bin/commands/poll-summary-instructions.d.mts +6 -1
- package/bin/commands/poll-summary-instructions.mjs +71 -122
- package/bin/commands/poll-summary.mjs +4 -2
- package/bin/commands/ready-delay.d.mts +9 -4
- package/bin/commands/ready-delay.mjs +34 -20
- package/bin/commands/shepherd-journal.mjs +4 -1
- package/bin/commands/stack-drain.d.mts +35 -0
- package/bin/commands/stack-drain.mjs +138 -0
- package/bin/commands/stack-layer-readiness.d.mts +6 -0
- package/bin/commands/stack-layer-readiness.mjs +34 -0
- package/bin/commands/stack-stall.d.mts +14 -0
- package/bin/commands/stack-stall.mjs +68 -0
- package/bin/commands/stack-work.d.mts +32 -0
- package/bin/commands/stack-work.mjs +38 -0
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +10 -0
- package/bin/config.json +1 -0
- 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/batch-parsers.mjs +1 -1
- package/bin/github/batch-raw-rules.d.mts +0 -3
- package/bin/github/batch-raw-types.d.mts +2 -0
- package/bin/github/errors.d.mts +5 -0
- package/bin/github/errors.mjs +4 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-stack-summary.gql +8 -2
- package/bin/github/gql/poll-stack-topology.gql +38 -0
- package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
- package/bin/github/gql/poll-summary-check-page.gql +23 -0
- package/bin/github/gql/poll-summary-fragment.gql +5 -62
- package/bin/github/gql/pr-merge-policy.gql +0 -3
- 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/github/merge-queue-checks.mjs +10 -1
- package/bin/github/poll-summary-check-hydration.d.mts +12 -0
- package/bin/github/poll-summary-check-hydration.mjs +55 -0
- package/bin/github/poll-summary-fingerprint.mjs +24 -4
- package/bin/github/poll-summary-projector.mjs +8 -7
- package/bin/github/poll-summary-queue-removal.mjs +10 -1
- package/bin/github/poll-summary-raw.d.mts +19 -31
- package/bin/github/poll-summary-route.mjs +8 -5
- package/bin/github/poll-summary.mjs +16 -73
- package/bin/github/queries.d.mts +7 -0
- package/bin/github/queries.mjs +9 -1
- package/bin/github/queue-removal-freshness.d.mts +16 -0
- package/bin/github/queue-removal-freshness.mjs +26 -0
- package/bin/github/stack-read.d.mts +34 -0
- package/bin/github/stack-read.mjs +92 -0
- package/bin/log/log-file.d.mts +1 -1
- package/bin/log/log-file.mjs +4 -17
- package/bin/state/base.d.mts +18 -1
- package/bin/state/base.mjs +65 -13
- package/bin/state/fix-attempts.d.mts +1 -1
- package/bin/state/fix-attempts.mjs +1 -1
- package/bin/state/graphql-quota-policy.d.mts +7 -1
- package/bin/state/graphql-quota-policy.mjs +41 -14
- package/bin/state/graphql-quota-warnings.mjs +4 -6
- package/bin/state/iterate-stall.d.mts +7 -15
- package/bin/state/iterate-stall.mjs +6 -64
- package/bin/state/rest-cache.d.mts +1 -1
- package/bin/state/rest-cache.mjs +1 -1
- package/bin/state/stack-stall.d.mts +16 -0
- package/bin/state/stack-stall.mjs +12 -0
- package/bin/state/stall-state-store.d.mts +37 -0
- package/bin/state/stall-state-store.mjs +74 -0
- package/bin/types/api-usage.d.mts +2 -0
- package/bin/types/escalate.d.mts +1 -1
- package/bin/types/github.d.mts +1 -1
- package/bin/types/iterate.d.mts +2 -1
- package/bin/types/merge-requirements.d.mts +9 -0
- package/bin/types/poll-summary.d.mts +5 -2
- package/bin/types/report.d.mts +1 -1
- 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/log/log-file.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Append-only per-worktree markdown log.
|
|
3
3
|
*
|
|
4
|
-
* Log path: $PR_SHEPHERD_STATE_DIR/<owner
|
|
4
|
+
* Log path: $PR_SHEPHERD_STATE_DIR/<owner>/<repo>/worktrees/<basename>-<sha8>.md
|
|
5
5
|
*
|
|
6
6
|
* Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
|
|
7
7
|
* Write failures flip an internal disabled flag so the CLI never crashes because
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
|
-
import {
|
|
13
|
-
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
12
|
+
import { resolveRepoStateDir } from "../state/base.mjs";
|
|
14
13
|
import { getWorktreeKey } from "../util/worktree.mjs";
|
|
15
14
|
function computeDisabled() {
|
|
16
15
|
if (process.env["PR_SHEPHERD_LOG_DISABLED"] === "1")
|
|
@@ -26,14 +25,9 @@ export function nextEntry() {
|
|
|
26
25
|
return ++_entryCounter;
|
|
27
26
|
}
|
|
28
27
|
export function getLogFilePath(key) {
|
|
29
|
-
const { owner, repo } = key;
|
|
30
|
-
if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo)) {
|
|
31
|
-
throw new Error(`Invalid repo key segments: ${owner}/${repo}`);
|
|
32
|
-
}
|
|
33
|
-
const base = resolveStateBase();
|
|
34
28
|
// Worktree key injected at init time; fall back to "unknown" if not yet set.
|
|
35
29
|
const wkey = _worktreeKey ?? "unknown";
|
|
36
|
-
return join(
|
|
30
|
+
return join(resolveRepoStateDir(key), "worktrees", `${wkey}.md`);
|
|
37
31
|
}
|
|
38
32
|
let _worktreeKey = null;
|
|
39
33
|
/**
|
|
@@ -44,9 +38,6 @@ export async function initLog(repoKey) {
|
|
|
44
38
|
if (_disabled)
|
|
45
39
|
return null;
|
|
46
40
|
try {
|
|
47
|
-
const { owner, repo } = repoKey;
|
|
48
|
-
if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo))
|
|
49
|
-
return null;
|
|
50
41
|
_worktreeKey = await getWorktreeKey();
|
|
51
42
|
const path = getLogFilePath(repoKey);
|
|
52
43
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -72,12 +63,8 @@ export function appendEntry(markdown) {
|
|
|
72
63
|
}
|
|
73
64
|
/** Resolve the log path without initializing (for the log-file subcommand). */
|
|
74
65
|
export async function resolveLogPath(repoKey) {
|
|
75
|
-
if (!SAFE_SEGMENT.test(repoKey.owner) || !SAFE_SEGMENT.test(repoKey.repo)) {
|
|
76
|
-
throw new Error(`Invalid repo key segments: ${repoKey.owner}/${repoKey.repo}`);
|
|
77
|
-
}
|
|
78
66
|
const wkey = await getWorktreeKey();
|
|
79
|
-
|
|
80
|
-
return join(base, `${repoKey.owner}-${repoKey.repo}`, "worktrees", `${wkey}.md`);
|
|
67
|
+
return join(resolveRepoStateDir(repoKey), "worktrees", `${wkey}.md`);
|
|
81
68
|
}
|
|
82
69
|
/** Exposed for tests to reset module state. */
|
|
83
70
|
export function _resetLogState() {
|
package/bin/state/base.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare function resolveStateBase(): string;
|
|
2
2
|
/**
|
|
3
|
-
* `$PR_SHEPHERD_STATE_DIR/<owner
|
|
3
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/...parts`.
|
|
4
4
|
* Owner, repo, PR number, and each extra part must be a safe path segment.
|
|
5
5
|
*/
|
|
6
6
|
export declare function resolvePrStatePath(key: {
|
|
@@ -8,3 +8,20 @@ export declare function resolvePrStatePath(key: {
|
|
|
8
8
|
repo: string;
|
|
9
9
|
pr: number;
|
|
10
10
|
}, ...parts: string[]): string;
|
|
11
|
+
/**
|
|
12
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/...parts`, beside the per-PR directories.
|
|
13
|
+
* Owner, repo, stack number, and each extra part must be a safe path segment.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveStackStatePath(key: {
|
|
16
|
+
owner: string;
|
|
17
|
+
repo: string;
|
|
18
|
+
stack: number;
|
|
19
|
+
}, ...parts: string[]): string;
|
|
20
|
+
/**
|
|
21
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>`.
|
|
22
|
+
* Owner and repo are separate segments so names that contain hyphens cannot collide.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveRepoStateDir(key: {
|
|
25
|
+
owner: string;
|
|
26
|
+
repo: string;
|
|
27
|
+
}): string;
|
package/bin/state/base.mjs
CHANGED
|
@@ -1,29 +1,81 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { SAFE_PR_NUMBER, SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
5
|
+
/** Read once per process; null when the platform or `getconf` offers no per-user temp dir. */
|
|
6
|
+
let darwinUserTempDir;
|
|
7
|
+
/**
|
|
8
|
+
* macOS's per-user temp dir, read from `confstr(_CS_DARWIN_USER_TEMP_DIR)` rather than
|
|
9
|
+
* `TMPDIR`. Sandboxed agent shells point `TMPDIR` at their own directory, so the same user's
|
|
10
|
+
* sandboxed CLI, unsandboxed CLI, and MCP server would otherwise keep separate state.
|
|
11
|
+
*/
|
|
12
|
+
function readDarwinUserTempDir() {
|
|
13
|
+
if (process.platform !== "darwin")
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const dir = execFileSync("/usr/bin/getconf", ["DARWIN_USER_TEMP_DIR"], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
}).trim();
|
|
20
|
+
return isAbsolute(dir) ? dir : null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
4
26
|
export function resolveStateBase() {
|
|
5
27
|
const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
|
|
6
|
-
|
|
28
|
+
if (envDir)
|
|
29
|
+
return envDir;
|
|
30
|
+
if (darwinUserTempDir === undefined)
|
|
31
|
+
darwinUserTempDir = readDarwinUserTempDir();
|
|
32
|
+
return join(darwinUserTempDir ?? tmpdir(), "pr-shepherd-state");
|
|
7
33
|
}
|
|
8
34
|
/**
|
|
9
|
-
* `$PR_SHEPHERD_STATE_DIR/<owner
|
|
35
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/...parts`.
|
|
10
36
|
* Owner, repo, PR number, and each extra part must be a safe path segment.
|
|
11
37
|
*/
|
|
12
38
|
export function resolvePrStatePath(key, ...parts) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
39
|
+
return resolveRepoStatePath(key, numberSegment("pr", key.pr), parts);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/...parts`, beside the per-PR directories.
|
|
43
|
+
* Owner, repo, stack number, and each extra part must be a safe path segment.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveStackStatePath(key, ...parts) {
|
|
46
|
+
return resolveRepoStatePath(key, `stack-${numberSegment("stack", key.stack)}`, parts);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>`.
|
|
50
|
+
* Owner and repo are separate segments so names that contain hyphens cannot collide.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveRepoStateDir(key) {
|
|
53
|
+
assertOwnerRepo(key);
|
|
54
|
+
return join(resolveStateBase(), key.owner, key.repo);
|
|
55
|
+
}
|
|
56
|
+
function numberSegment(name, value) {
|
|
57
|
+
const segment = String(value);
|
|
58
|
+
if (!SAFE_PR_NUMBER.test(segment)) {
|
|
59
|
+
throw new Error(`Invalid state key segment "${name}": ${value}`);
|
|
18
60
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
61
|
+
return segment;
|
|
62
|
+
}
|
|
63
|
+
function assertRepoSegment(name, value) {
|
|
64
|
+
// `.` and `..` match SAFE_SEGMENT, but they are real path segments here and would escape the base.
|
|
65
|
+
if (!SAFE_SEGMENT.test(value) || value === "." || value === "..") {
|
|
66
|
+
throw new Error(`Invalid state key segment "${name}": ${value}`);
|
|
22
67
|
}
|
|
68
|
+
}
|
|
69
|
+
function assertOwnerRepo(key) {
|
|
70
|
+
assertRepoSegment("owner", key.owner);
|
|
71
|
+
assertRepoSegment("repo", key.repo);
|
|
72
|
+
}
|
|
73
|
+
function resolveRepoStatePath(key, entry, parts) {
|
|
74
|
+
assertOwnerRepo(key);
|
|
23
75
|
for (const part of parts) {
|
|
24
|
-
if (!SAFE_SEGMENT.test(part)) {
|
|
76
|
+
if (!SAFE_SEGMENT.test(part) || part === "." || part === "..") {
|
|
25
77
|
throw new Error(`Invalid state key segment: ${part}`);
|
|
26
78
|
}
|
|
27
79
|
}
|
|
28
|
-
return join(
|
|
80
|
+
return join(resolveRepoStateDir(key), entry, ...parts);
|
|
29
81
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Tracks how many caller-visible times each review thread has been dispatched to
|
|
5
5
|
* the fix_code handler without being resolved. Body edits reset the count.
|
|
6
6
|
*
|
|
7
|
-
* State lives in `$
|
|
7
|
+
* State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
|
|
8
8
|
*/
|
|
9
9
|
export interface FixAttemptsState {
|
|
10
10
|
/** HEAD SHA at the time the counts were last written, retained for observability/compatibility. */
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Tracks how many caller-visible times each review thread has been dispatched to
|
|
5
5
|
* the fix_code handler without being resolved. Body edits reset the count.
|
|
6
6
|
*
|
|
7
|
-
* State lives in `$
|
|
7
|
+
* State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
|
|
8
8
|
*/
|
|
9
9
|
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
@@ -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 {};
|
|
@@ -4,25 +4,28 @@ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt
|
|
|
4
4
|
previous.resource !== sample.resource ||
|
|
5
5
|
previous.limit !== sample.limit ||
|
|
6
6
|
windowRolled ||
|
|
7
|
-
(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
fingerprintChanged(previous, sample);
|
|
8
|
+
const effective = !rearm && previous !== null && usageRegressed(previous, sample)
|
|
9
|
+
? newerSavedSample(previous)
|
|
10
|
+
: sample;
|
|
11
|
+
const warned = new Set(rearm || previous === null ? [] : previous.warnedThresholds);
|
|
11
12
|
const rearmEpoch = rearm ? (previous?.rearmEpoch ?? 0) + 1 : (previous?.rearmEpoch ?? 0);
|
|
12
|
-
const crossed = bands.filter((band) =>
|
|
13
|
+
const crossed = bands.filter((band) => effective.remaining * 100 <= effective.limit * band.remainingPercent);
|
|
13
14
|
const newCrossed = crossed.filter((band) => !warned.has(band.remainingPercent));
|
|
14
15
|
for (const band of crossed)
|
|
15
16
|
warned.add(band.remainingPercent);
|
|
16
17
|
const active = newCrossed.at(-1);
|
|
18
|
+
const credentialFingerprint = effective.credentialFingerprint ?? previous?.credentialFingerprint;
|
|
17
19
|
const state = {
|
|
18
|
-
resource:
|
|
19
|
-
limit:
|
|
20
|
-
...(
|
|
21
|
-
lastRemaining:
|
|
22
|
-
resetAt:
|
|
20
|
+
resource: effective.resource,
|
|
21
|
+
limit: effective.limit,
|
|
22
|
+
...(effective.used !== undefined && { lastUsed: effective.used }),
|
|
23
|
+
lastRemaining: effective.remaining,
|
|
24
|
+
resetAt: effective.resetAt,
|
|
23
25
|
warnedThresholds: bands
|
|
24
26
|
.map((band) => band.remainingPercent)
|
|
25
27
|
.filter((threshold) => warned.has(threshold)),
|
|
28
|
+
...(credentialFingerprint !== undefined && { credentialFingerprint }),
|
|
26
29
|
rearmEpoch,
|
|
27
30
|
};
|
|
28
31
|
if (active === undefined)
|
|
@@ -31,13 +34,37 @@ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt
|
|
|
31
34
|
warning: {
|
|
32
35
|
resource: "graphql",
|
|
33
36
|
thresholdPercent: active.remainingPercent,
|
|
34
|
-
remaining:
|
|
35
|
-
limit:
|
|
36
|
-
...(
|
|
37
|
-
resetAt:
|
|
37
|
+
remaining: effective.remaining,
|
|
38
|
+
limit: effective.limit,
|
|
39
|
+
...(effective.used !== undefined && { used: effective.used }),
|
|
40
|
+
resetAt: effective.resetAt,
|
|
38
41
|
pollIntervalMinutes: active.pollIntervalMinutes,
|
|
39
42
|
pollTimeoutMinutes: active.pollIntervalMinutes * 2,
|
|
40
43
|
},
|
|
41
44
|
state,
|
|
42
45
|
};
|
|
43
46
|
}
|
|
47
|
+
function fingerprintChanged(previous, sample) {
|
|
48
|
+
return (previous.credentialFingerprint !== undefined &&
|
|
49
|
+
sample.credentialFingerprint !== undefined &&
|
|
50
|
+
previous.credentialFingerprint !== sample.credentialFingerprint);
|
|
51
|
+
}
|
|
52
|
+
/** An older observation of the same window: lower used, or higher remaining when used is absent. */
|
|
53
|
+
function usageRegressed(previous, sample) {
|
|
54
|
+
if (sample.used !== undefined && previous.lastUsed !== undefined) {
|
|
55
|
+
return sample.used < previous.lastUsed;
|
|
56
|
+
}
|
|
57
|
+
return sample.remaining > previous.lastRemaining;
|
|
58
|
+
}
|
|
59
|
+
function newerSavedSample(previous) {
|
|
60
|
+
return {
|
|
61
|
+
resource: previous.resource,
|
|
62
|
+
limit: previous.limit,
|
|
63
|
+
...(previous.lastUsed !== undefined && { used: previous.lastUsed }),
|
|
64
|
+
remaining: previous.lastRemaining,
|
|
65
|
+
resetAt: previous.resetAt,
|
|
66
|
+
...(previous.credentialFingerprint !== undefined && {
|
|
67
|
+
credentialFingerprint: previous.credentialFingerprint,
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
4
|
+
import { resolveRepoStateDir } from "./base.mjs";
|
|
6
5
|
import { getWorktreeKey } from "../util/worktree.mjs";
|
|
7
6
|
import { claimWarning } from "./graphql-quota-claims.mjs";
|
|
8
7
|
import { evaluateGraphqlQuotaWarning, } from "./graphql-quota-policy.mjs";
|
|
@@ -52,9 +51,6 @@ async function serializeStateUpdate(key, update) {
|
|
|
52
51
|
return result;
|
|
53
52
|
}
|
|
54
53
|
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
54
|
let worktreeKey;
|
|
59
55
|
try {
|
|
60
56
|
worktreeKey = await getWorktreeKey();
|
|
@@ -62,7 +58,7 @@ async function warningStatePath(key) {
|
|
|
62
58
|
catch {
|
|
63
59
|
return undefined;
|
|
64
60
|
}
|
|
65
|
-
return join(
|
|
61
|
+
return join(resolveRepoStateDir(key), "worktrees", `${worktreeKey}-graphql-quota-warnings.json`);
|
|
66
62
|
}
|
|
67
63
|
async function readState(path) {
|
|
68
64
|
try {
|
|
@@ -74,6 +70,8 @@ async function readState(path) {
|
|
|
74
70
|
!Array.isArray(parsed.warnedThresholds)) {
|
|
75
71
|
return null;
|
|
76
72
|
}
|
|
73
|
+
if (typeof parsed.credentialFingerprint !== "string")
|
|
74
|
+
delete parsed.credentialFingerprint;
|
|
77
75
|
return parsed;
|
|
78
76
|
}
|
|
79
77
|
catch {
|
|
@@ -5,23 +5,15 @@
|
|
|
5
5
|
* was first seen. If the fingerprint does not change for stallTimeoutSeconds
|
|
6
6
|
* the iterate command escalates instead of repeating the same action.
|
|
7
7
|
*
|
|
8
|
-
* State lives in `$
|
|
8
|
+
* State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/iterate-stall.json`.
|
|
9
9
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
fingerprint: string;
|
|
13
|
-
/** Unix timestamp (seconds) when this fingerprint was first seen. */
|
|
14
|
-
firstSeenAt: number;
|
|
15
|
-
}
|
|
16
|
-
interface StateKey {
|
|
10
|
+
import { type StallStateStore } from "./stall-state-store.mts";
|
|
11
|
+
type Store = StallStateStore<{
|
|
17
12
|
owner: string;
|
|
18
13
|
repo: string;
|
|
19
14
|
pr: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export declare
|
|
23
|
-
|
|
24
|
-
export declare function clearStallState(key: StateKey): Promise<void>;
|
|
25
|
-
/** Write stall state (fire-and-forget — never throws). */
|
|
26
|
-
export declare function writeStallState(key: StateKey, state: StallState): Promise<void>;
|
|
15
|
+
}>;
|
|
16
|
+
export declare const readStallState: Store["read"];
|
|
17
|
+
export declare const writeStallState: Store["write"];
|
|
18
|
+
export declare const clearStallState: Store["clear"];
|
|
27
19
|
export {};
|
|
@@ -5,69 +5,11 @@
|
|
|
5
5
|
* was first seen. If the fingerprint does not change for stallTimeoutSeconds
|
|
6
6
|
* the iterate command escalates instead of repeating the same action.
|
|
7
7
|
*
|
|
8
|
-
* State lives in `$
|
|
8
|
+
* State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/iterate-stall.json`.
|
|
9
9
|
*/
|
|
10
|
-
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { dirname } from "node:path";
|
|
13
10
|
import { resolvePrStatePath } from "./base.mjs";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export
|
|
19
|
-
try {
|
|
20
|
-
const raw = await readFile(resolvePath(key), "utf8");
|
|
21
|
-
const parsed = JSON.parse(raw);
|
|
22
|
-
if (parsed === null ||
|
|
23
|
-
typeof parsed !== "object" ||
|
|
24
|
-
typeof parsed["fingerprint"] !== "string" ||
|
|
25
|
-
!Number.isFinite(parsed["firstSeenAt"])) {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
return parsed;
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
/** Clear stall state so the next invocation starts a fresh timer (fire-and-forget — never throws). */
|
|
35
|
-
export async function clearStallState(key) {
|
|
36
|
-
try {
|
|
37
|
-
await unlink(resolvePath(key));
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
// Best-effort — file may not exist.
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/** Write stall state (fire-and-forget — never throws). */
|
|
44
|
-
export async function writeStallState(key, state) {
|
|
45
|
-
let tmp;
|
|
46
|
-
try {
|
|
47
|
-
const path = resolvePath(key);
|
|
48
|
-
tmp = `${path}.${randomUUID()}.tmp`;
|
|
49
|
-
await mkdir(dirname(path), { recursive: true });
|
|
50
|
-
await writeFile(tmp, JSON.stringify(state), "utf8");
|
|
51
|
-
await rename(tmp, path);
|
|
52
|
-
tmp = undefined;
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// Best-effort.
|
|
56
|
-
}
|
|
57
|
-
finally {
|
|
58
|
-
if (tmp !== undefined) {
|
|
59
|
-
try {
|
|
60
|
-
await unlink(tmp);
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// Best-effort cleanup.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// Helpers
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
function resolvePath(key) {
|
|
72
|
-
return resolvePrStatePath(key, "iterate-stall.json");
|
|
73
|
-
}
|
|
11
|
+
import { stallStateStore } from "./stall-state-store.mjs";
|
|
12
|
+
const store = stallStateStore((key) => resolvePrStatePath(key, "iterate-stall.json"));
|
|
13
|
+
export const readStallState = store.read;
|
|
14
|
+
export const writeStallState = store.write;
|
|
15
|
+
export const clearStallState = store.clear;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* terminal, check-run annotations once the check is COMPLETED. No ETag
|
|
15
15
|
* applies; the cache is keyed on an immutable identity instead.
|
|
16
16
|
*
|
|
17
|
-
* Entries live under `$PR_SHEPHERD_STATE_DIR/<owner
|
|
17
|
+
* Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/rest-cache/`
|
|
18
18
|
* and are removed for free when `pr-shepherd clean` deletes the PR's state
|
|
19
19
|
* directory — there is no separate pruning routine.
|
|
20
20
|
*/
|
package/bin/state/rest-cache.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* terminal, check-run annotations once the check is COMPLETED. No ETag
|
|
15
15
|
* applies; the cache is keyed on an immutable identity instead.
|
|
16
16
|
*
|
|
17
|
-
* Entries live under `$PR_SHEPHERD_STATE_DIR/<owner
|
|
17
|
+
* Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/rest-cache/`
|
|
18
18
|
* and are removed for free when `pr-shepherd clean` deletes the PR's state
|
|
19
19
|
* directory — there is no separate pruning routine.
|
|
20
20
|
*/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent stall-detection state for an aggregate `--stack` selection whose open layers can
|
|
3
|
+
* only wait. Keyed by native stack number because the anchor PR can merge or change.
|
|
4
|
+
*
|
|
5
|
+
* State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/stack-stall.json`.
|
|
6
|
+
*/
|
|
7
|
+
import { type StallStateStore } from "./stall-state-store.mts";
|
|
8
|
+
type Store = StallStateStore<{
|
|
9
|
+
owner: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
stack: number;
|
|
12
|
+
}>;
|
|
13
|
+
export declare const readStackStallState: Store["read"];
|
|
14
|
+
export declare const writeStackStallState: Store["write"];
|
|
15
|
+
export declare const clearStackStallState: Store["clear"];
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent stall-detection state for an aggregate `--stack` selection whose open layers can
|
|
3
|
+
* only wait. Keyed by native stack number because the anchor PR can merge or change.
|
|
4
|
+
*
|
|
5
|
+
* State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/stack-stall.json`.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveStackStatePath } from "./base.mjs";
|
|
8
|
+
import { stallStateStore } from "./stall-state-store.mjs";
|
|
9
|
+
const store = stallStateStore((key) => resolveStackStatePath(key, "stack-stall.json"));
|
|
10
|
+
export const readStackStallState = store.read;
|
|
11
|
+
export const writeStackStallState = store.write;
|
|
12
|
+
export const clearStackStallState = store.clear;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
interface StallState {
|
|
2
|
+
/** Canonicalized JSON fingerprint of the material inputs. */
|
|
3
|
+
fingerprint: string;
|
|
4
|
+
/** Unix timestamp (seconds) when this fingerprint was first seen. */
|
|
5
|
+
firstSeenAt: number;
|
|
6
|
+
}
|
|
7
|
+
/** A missing or unreadable-as-JSON file is `state: null`. Any other I/O or key error is `ok: false`. */
|
|
8
|
+
type StallReadResult = {
|
|
9
|
+
ok: true;
|
|
10
|
+
state: StallState | null;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
type StallWriteResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
reason: string;
|
|
20
|
+
};
|
|
21
|
+
export interface StallStateStore<Key> {
|
|
22
|
+
/** Read the current stall state. Does not throw. */
|
|
23
|
+
read(key: Key): Promise<StallReadResult>;
|
|
24
|
+
/** Write stall state. Does not throw; `ok: false` means the timer was not saved. */
|
|
25
|
+
write(key: Key, state: StallState): Promise<StallWriteResult>;
|
|
26
|
+
/** Clear stall state so the next invocation starts a fresh timer (fire-and-forget — never throws). */
|
|
27
|
+
clear(key: Key): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `{fingerprint, firstSeenAt}` files behind the one-PR and `--stack` stall guards.
|
|
31
|
+
* A missing file is a miss. An unwritable directory or an unsafe key is a persistence failure
|
|
32
|
+
* so the caller can hand off instead of treating every tick as the first sighting.
|
|
33
|
+
* `ENOENT` is the only read error treated as a miss. Corrupt JSON is also a miss, so one
|
|
34
|
+
* successful rewrite can start the timer again.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stallStateStore<Key>(resolvePath: (key: Key) => string): StallStateStore<Key>;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* `{fingerprint, firstSeenAt}` files behind the one-PR and `--stack` stall guards.
|
|
6
|
+
* A missing file is a miss. An unwritable directory or an unsafe key is a persistence failure
|
|
7
|
+
* so the caller can hand off instead of treating every tick as the first sighting.
|
|
8
|
+
* `ENOENT` is the only read error treated as a miss. Corrupt JSON is also a miss, so one
|
|
9
|
+
* successful rewrite can start the timer again.
|
|
10
|
+
*/
|
|
11
|
+
export function stallStateStore(resolvePath) {
|
|
12
|
+
return {
|
|
13
|
+
async read(key) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(await readFile(resolvePath(key), "utf8"));
|
|
16
|
+
if (parsed === null ||
|
|
17
|
+
typeof parsed !== "object" ||
|
|
18
|
+
typeof parsed["fingerprint"] !== "string" ||
|
|
19
|
+
!Number.isFinite(parsed["firstSeenAt"])) {
|
|
20
|
+
return { ok: true, state: null };
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, state: parsed };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
// A missing file and corrupt JSON are both misses. One successful rewrite can start the timer.
|
|
26
|
+
if (isEnoent(error) || error instanceof SyntaxError)
|
|
27
|
+
return { ok: true, state: null };
|
|
28
|
+
return { ok: false, reason: errorReason(error) };
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
async write(key, state) {
|
|
32
|
+
let tmp;
|
|
33
|
+
try {
|
|
34
|
+
const path = resolvePath(key);
|
|
35
|
+
tmp = `${path}.${randomUUID()}.tmp`;
|
|
36
|
+
await mkdir(dirname(path), { recursive: true });
|
|
37
|
+
await writeFile(tmp, JSON.stringify(state), "utf8");
|
|
38
|
+
await rename(tmp, path);
|
|
39
|
+
tmp = undefined;
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return { ok: false, reason: errorReason(error) };
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (tmp !== undefined) {
|
|
47
|
+
try {
|
|
48
|
+
await unlink(tmp);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Best-effort cleanup.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async clear(key) {
|
|
57
|
+
try {
|
|
58
|
+
await unlink(resolvePath(key));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Best-effort — file may not exist. A leftover file can only make a later timer escalate sooner.
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function isEnoent(error) {
|
|
67
|
+
return (typeof error === "object" &&
|
|
68
|
+
error !== null &&
|
|
69
|
+
"code" in error &&
|
|
70
|
+
error.code === "ENOENT");
|
|
71
|
+
}
|
|
72
|
+
function errorReason(error) {
|
|
73
|
+
return error instanceof Error ? error.message : String(error);
|
|
74
|
+
}
|
|
@@ -13,6 +13,8 @@ export interface GraphqlApiUsage extends ApiResourceUsage {
|
|
|
13
13
|
unmeasuredRequestCount: number;
|
|
14
14
|
/** Exact sum reported by rateLimit.nodeCount for measured GraphQL queries. */
|
|
15
15
|
nodeCount: number;
|
|
16
|
+
/** Truncated SHA-256 of the credential. Quota state only; summarized usage omits it. */
|
|
17
|
+
credentialFingerprint?: string;
|
|
16
18
|
}
|
|
17
19
|
export interface ApiUsage {
|
|
18
20
|
credentialSources: string[];
|
package/bin/types/escalate.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AgentCheck, AgentComment, AgentThread } from "./report.mts";
|
|
|
2
2
|
import type { ResolveCommand } from "./iterate.mts";
|
|
3
3
|
import type { CheckStatus, Review } from "./github.mts";
|
|
4
4
|
import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
|
|
5
|
-
export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed";
|
|
5
|
+
export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "stall-state-unavailable" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed";
|
|
6
6
|
export interface AgentStalledCheck {
|
|
7
7
|
name: string;
|
|
8
8
|
status: CheckStatus;
|
package/bin/types/github.d.mts
CHANGED
|
@@ -144,7 +144,7 @@ export interface BatchPrData extends BatchPrMergeFields {
|
|
|
144
144
|
headRepoWithOwner: string | null;
|
|
145
145
|
viewerAuthorization?: ViewerAuthorization;
|
|
146
146
|
baseRefName: string;
|
|
147
|
-
/**
|
|
147
|
+
/** Base commit GitHub recorded for this PR (`PullRequest.baseRefOid`), not the branch's live tip. */
|
|
148
148
|
baseRefOid?: string;
|
|
149
149
|
reviewRequests: Array<{
|
|
150
150
|
login: string;
|