pr-shepherd 0.51.0 → 0.52.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 +25 -17
- package/bin/checks/log-excerpt.d.mts +1 -0
- package/bin/checks/log-excerpt.mjs +192 -0
- package/bin/checks/triage.mjs +2 -121
- package/bin/cli/iterate-lean.mjs +0 -3
- package/bin/cli/poll-summary-emitter.mjs +2 -3
- package/bin/cli/poll-summary-formatter.mjs +9 -2
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/iterate/escalate.mjs +0 -13
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +14 -42
- package/bin/commands/iterate/index.mjs +140 -3
- package/bin/commands/iterate/merge-state.mjs +33 -18
- package/bin/commands/iterate/parent-first.d.mts +9 -0
- package/bin/commands/iterate/parent-first.mjs +57 -0
- package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
- package/bin/commands/iterate/stale-ancestry.mjs +37 -0
- package/bin/commands/poll-summary-instructions.mjs +179 -99
- package/bin/commands/poll-summary.mjs +28 -4
- package/bin/exit-codes.d.mts +2 -0
- package/bin/exit-codes.mjs +2 -0
- package/bin/github/batch-parsers.mjs +1 -0
- package/bin/github/batch-raw-rules.d.mts +3 -0
- package/bin/github/gql/poll-summary-fragment.gql +54 -0
- package/bin/github/gql/pr-merge-policy.gql +3 -0
- package/bin/github/poll-summary-fingerprint.d.mts +8 -0
- package/bin/github/poll-summary-fingerprint.mjs +28 -0
- package/bin/github/poll-summary-projector.mjs +67 -14
- package/bin/github/poll-summary-queue-removal.d.mts +5 -0
- package/bin/github/poll-summary-queue-removal.mjs +16 -0
- package/bin/github/poll-summary-raw.d.mts +33 -0
- package/bin/github/poll-summary-readiness.d.mts +6 -0
- package/bin/github/poll-summary-readiness.mjs +25 -0
- package/bin/github/poll-summary.d.mts +3 -0
- package/bin/github/poll-summary.mjs +5 -0
- package/bin/state/ready-receipts.d.mts +45 -0
- package/bin/state/ready-receipts.mjs +86 -0
- package/bin/types/escalate.d.mts +2 -3
- package/bin/types/github.d.mts +2 -0
- package/bin/types/poll-summary.d.mts +12 -2
- package/bin/types/report.d.mts +2 -0
- package/package.json +4 -4
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
- package/bin/state/bot-cr-seen.d.mts +0 -51
- package/bin/state/bot-cr-seen.mjs +0 -100
|
@@ -3,8 +3,12 @@ import { updateReadyDelay } from "../commands/ready-delay.mjs";
|
|
|
3
3
|
import { formatPrUrl } from "../pr-reference.mjs";
|
|
4
4
|
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
5
5
|
import { loadSeenMap } from "../state/seen-comments.mjs";
|
|
6
|
+
import { isReadyReceiptCurrent, readReadyReceipt } from "../state/ready-receipts.mjs";
|
|
6
7
|
import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
|
|
7
8
|
import { summarizePollSummaryReview } from "./poll-summary-review.mjs";
|
|
9
|
+
import { fingerprintRawSummaryPr } from "./poll-summary-fingerprint.mjs";
|
|
10
|
+
import { currentQueueRemovalEvent } from "./poll-summary-queue-removal.mjs";
|
|
11
|
+
import { isCurrentSummaryReady } from "./poll-summary-readiness.mjs";
|
|
8
12
|
import { normalizePollSummaryState, routePollSummary } from "./poll-summary-route.mjs";
|
|
9
13
|
export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdminister = false) {
|
|
10
14
|
const repoName = `${repo.owner}/${repo.name}`;
|
|
@@ -12,6 +16,7 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
12
16
|
const checks = summarizePollSummaryChecks(raw);
|
|
13
17
|
const review = await summarizePollSummaryReview(raw, seen, viewerCanAdminister);
|
|
14
18
|
const blockingReviewerInProgress = detectBlockingReviewer(raw);
|
|
19
|
+
const removalEvent = currentQueueRemovalEvent(raw);
|
|
15
20
|
let { action, reasons } = routePollSummary(raw, checks, review, opts);
|
|
16
21
|
let remainingSeconds;
|
|
17
22
|
if (raw.isDraft && blockingReviewerInProgress && action === "mark_ready") {
|
|
@@ -19,12 +24,14 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
19
24
|
reasons = ["blocking-reviewer-in-progress"];
|
|
20
25
|
}
|
|
21
26
|
const appearsReady = reasons.includes("appears-ready");
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
if (opts.stackPrNumber === undefined) {
|
|
28
|
+
const readyDelaySeconds = opts.readyDelaySeconds ?? (loadConfig().watch?.readyDelayMinutes ?? 10) * 60;
|
|
29
|
+
const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, { retainElapsed: true });
|
|
30
|
+
if (appearsReady && !readyState.shouldCancel) {
|
|
31
|
+
action = "wait";
|
|
32
|
+
reasons = ["ready-delay"];
|
|
33
|
+
remainingSeconds = readyState.remainingSeconds;
|
|
34
|
+
}
|
|
28
35
|
}
|
|
29
36
|
const stack = raw.stack
|
|
30
37
|
? {
|
|
@@ -34,6 +41,32 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
34
41
|
baseRefName: raw.stack.baseRefName,
|
|
35
42
|
}
|
|
36
43
|
: undefined;
|
|
44
|
+
const fingerprint = opts.stackPrNumber !== undefined ? fingerprintRawSummaryPr(raw) : null;
|
|
45
|
+
const receipt = fingerprint
|
|
46
|
+
? await readReadyReceipt({ owner: repo.owner, repo: repo.name, pr: raw.number })
|
|
47
|
+
: null;
|
|
48
|
+
// A queued PR's target branch can advance as earlier queue entries merge.
|
|
49
|
+
// Keep the pre-enqueue base binding for the receipt comparison while the
|
|
50
|
+
// merge group itself supplies the current mergeability evidence.
|
|
51
|
+
const receiptFingerprint = raw.isInMergeQueue && receipt
|
|
52
|
+
? fingerprintRawSummaryPr({ ...raw, baseRefOid: receipt.baseRefOid })
|
|
53
|
+
: fingerprint;
|
|
54
|
+
const currentReady = isCurrentSummaryReady(raw, checks, review, {
|
|
55
|
+
allowQueuedProgress: opts.stackPrNumber !== undefined && raw.isInMergeQueue,
|
|
56
|
+
});
|
|
57
|
+
const readyReceipt = receiptFingerprint !== null &&
|
|
58
|
+
isReadyReceiptCurrent(receipt, {
|
|
59
|
+
headRefOid: raw.headRefOid,
|
|
60
|
+
baseRefOid: raw.isInMergeQueue && receipt ? receipt.baseRefOid : raw.baseRefOid,
|
|
61
|
+
readinessFingerprint: receiptFingerprint,
|
|
62
|
+
status: currentReady ? "READY" : "PENDING",
|
|
63
|
+
isDraft: raw.isDraft,
|
|
64
|
+
});
|
|
65
|
+
const queueRemoval = removalEvent?.id && readyReceipt && receipt?.acknowledgedQueueRemovalId === removalEvent.id
|
|
66
|
+
? undefined
|
|
67
|
+
: removalEvent
|
|
68
|
+
? projectQueueRemoval(removalEvent)
|
|
69
|
+
: undefined;
|
|
37
70
|
return {
|
|
38
71
|
pr: raw.number,
|
|
39
72
|
repo: repoName,
|
|
@@ -50,15 +83,31 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
50
83
|
baseRefName: raw.baseRefName,
|
|
51
84
|
...(raw.isDraft && { isDraft: true }),
|
|
52
85
|
...(raw.isInMergeQueue && { isInMergeQueue: true }),
|
|
86
|
+
...(queueRemoval && { queueRemoval }),
|
|
53
87
|
...(blockingReviewerInProgress && { blockingReviewerInProgress: true }),
|
|
54
88
|
...(remainingSeconds !== undefined && { remainingSeconds }),
|
|
55
89
|
...(Object.keys(checks).length > 0 && { checks }),
|
|
56
90
|
...(Object.keys(review).length > 0 && { review }),
|
|
57
91
|
...(stack && { stack }),
|
|
58
|
-
...(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
92
|
+
...(readyReceipt && { readyReceipt: true }),
|
|
93
|
+
...((opts.stackPrNumber !== undefined && raw.state === "OPEN") ||
|
|
94
|
+
(!["wait", "cancel"].includes(action) &&
|
|
95
|
+
!(opts.stackPrNumber !== undefined && raw.stack && action === "merge"))
|
|
96
|
+
? {
|
|
97
|
+
pollCommand: buildPollCommand(repoName, raw.number, raw.isDraft, opts),
|
|
98
|
+
}
|
|
99
|
+
: {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function projectQueueRemoval(removal) {
|
|
103
|
+
const removalTime = Date.parse(removal.createdAt);
|
|
104
|
+
const parents = removal.beforeCommit?.parents?.nodes.map((parent) => parent.oid) ?? [];
|
|
105
|
+
return {
|
|
106
|
+
reason: removal.reason,
|
|
107
|
+
createdAtUnix: Math.floor(removalTime / 1000),
|
|
108
|
+
...(removal.actor?.login && { actor: removal.actor.login }),
|
|
109
|
+
...(removal.beforeCommit?.oid && { beforeCommitOid: removal.beforeCommit.oid }),
|
|
110
|
+
beforeCommitParentOids: parents,
|
|
62
111
|
};
|
|
63
112
|
}
|
|
64
113
|
function detectBlockingReviewer(raw) {
|
|
@@ -67,16 +116,20 @@ function detectBlockingReviewer(raw) {
|
|
|
67
116
|
return ((raw.reviewRequests?.nodes ?? []).some((request) => matches(request.requestedReviewer)) ||
|
|
68
117
|
(raw.latestReviews?.nodes ?? []).some((review) => review.state === "PENDING" && matches(review.author)));
|
|
69
118
|
}
|
|
70
|
-
function buildPollCommand(repo, pr, opts) {
|
|
71
|
-
const
|
|
72
|
-
|
|
119
|
+
function buildPollCommand(repo, pr, isDraft, opts) {
|
|
120
|
+
const autoMarkReadyDisabled = opts.noAutoMarkReady || loadConfig().actions.autoMarkReady === false;
|
|
121
|
+
const boundedDraft = isDraft && autoMarkReadyDisabled;
|
|
122
|
+
const args = boundedDraft
|
|
123
|
+
? [formatPrUrl(repo, pr), "--timeout", "1s", "--debounce", "0s", "--no-auto-mark-ready"]
|
|
124
|
+
: [formatPrUrl(repo, pr), "--until-terminal"];
|
|
125
|
+
if (opts.merge && opts.stackPrNumber === undefined)
|
|
73
126
|
args.push("--merge");
|
|
74
127
|
if (opts.readyDelaySeconds !== undefined)
|
|
75
128
|
args.push("--ready-delay", `${opts.readyDelaySeconds}s`);
|
|
76
129
|
if (opts.stallTimeoutSeconds !== undefined) {
|
|
77
130
|
args.push("--stall-timeout", `${opts.stallTimeoutSeconds}s`);
|
|
78
131
|
}
|
|
79
|
-
if (opts.noAutoMarkReady)
|
|
132
|
+
if (opts.noAutoMarkReady && !boundedDraft)
|
|
80
133
|
args.push("--no-auto-mark-ready");
|
|
81
134
|
return buildPrShepherdCommand(args).text;
|
|
82
135
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
2
|
+
type QueueRemovalEvent = NonNullable<RawSummaryPr["mergeQueueRemovals"]>["nodes"][number];
|
|
3
|
+
/** Latest queue removal that still applies to this PR head, not an older attempt. */
|
|
4
|
+
export declare function currentQueueRemovalEvent(raw: RawSummaryPr): QueueRemovalEvent | null;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Latest queue removal that still applies to this PR head, not an older attempt. */
|
|
2
|
+
export function currentQueueRemovalEvent(raw) {
|
|
3
|
+
const removal = raw.mergeQueueRemovals?.nodes[0];
|
|
4
|
+
if (!removal || raw.isInMergeQueue)
|
|
5
|
+
return null;
|
|
6
|
+
const removalTime = Date.parse(removal.createdAt);
|
|
7
|
+
if (!Number.isFinite(removalTime))
|
|
8
|
+
return null;
|
|
9
|
+
const addition = raw.mergeQueueAdditions?.nodes[0];
|
|
10
|
+
if (addition && Date.parse(addition.createdAt) > removalTime)
|
|
11
|
+
return null;
|
|
12
|
+
const parents = removal.beforeCommit?.parents?.nodes.map((parent) => parent.oid);
|
|
13
|
+
if (!parents?.includes(raw.headRefOid))
|
|
14
|
+
return null;
|
|
15
|
+
return removal;
|
|
16
|
+
}
|
|
@@ -25,6 +25,9 @@ type RawCheckContext = {
|
|
|
25
25
|
status: string;
|
|
26
26
|
conclusion: string | null;
|
|
27
27
|
detailsUrl?: string;
|
|
28
|
+
annotations?: {
|
|
29
|
+
totalCount: number;
|
|
30
|
+
};
|
|
28
31
|
checkSuite: {
|
|
29
32
|
workflowRun: {
|
|
30
33
|
databaseId?: string | number;
|
|
@@ -48,6 +51,13 @@ export interface RawSummaryPr {
|
|
|
48
51
|
title: string;
|
|
49
52
|
url: string;
|
|
50
53
|
state: string;
|
|
54
|
+
updatedAt?: string;
|
|
55
|
+
lifecycleEvents?: {
|
|
56
|
+
nodes: Array<{
|
|
57
|
+
__typename: string;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
}>;
|
|
60
|
+
} | null;
|
|
51
61
|
isDraft: boolean;
|
|
52
62
|
viewerCanUpdate: boolean;
|
|
53
63
|
headRefName: string;
|
|
@@ -69,6 +79,29 @@ export interface RawSummaryPr {
|
|
|
69
79
|
}>;
|
|
70
80
|
};
|
|
71
81
|
isInMergeQueue: boolean;
|
|
82
|
+
mergeQueueAdditions?: {
|
|
83
|
+
nodes: Array<{
|
|
84
|
+
createdAt: string;
|
|
85
|
+
}>;
|
|
86
|
+
} | null;
|
|
87
|
+
mergeQueueRemovals?: {
|
|
88
|
+
nodes: Array<{
|
|
89
|
+
id?: string;
|
|
90
|
+
reason: string | null;
|
|
91
|
+
createdAt: string;
|
|
92
|
+
actor: {
|
|
93
|
+
login: string;
|
|
94
|
+
} | null;
|
|
95
|
+
beforeCommit: {
|
|
96
|
+
oid: string;
|
|
97
|
+
parents: {
|
|
98
|
+
nodes: Array<{
|
|
99
|
+
oid: string;
|
|
100
|
+
}>;
|
|
101
|
+
} | null;
|
|
102
|
+
} | null;
|
|
103
|
+
}>;
|
|
104
|
+
} | null;
|
|
72
105
|
mergeQueueEntry: {
|
|
73
106
|
headCommit: {
|
|
74
107
|
statusCheckRollup: RawCheckRollup | null;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PollSummaryChecks, PollSummaryReview } from "../types.mts";
|
|
2
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
+
/** Fresh compact evidence required before a READY receipt can be used. */
|
|
4
|
+
export declare function isCurrentSummaryReady(raw: RawSummaryPr, checks: PollSummaryChecks, review: PollSummaryReview, options?: {
|
|
5
|
+
allowQueuedProgress?: boolean;
|
|
6
|
+
}): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
|
|
2
|
+
/** Fresh compact evidence required before a READY receipt can be used. */
|
|
3
|
+
export function isCurrentSummaryReady(raw, checks, review, options = {}) {
|
|
4
|
+
const queued = options.allowQueuedProgress === true && raw.isInMergeQueue;
|
|
5
|
+
// Merge-group checks may still be running after the PR earned its receipt;
|
|
6
|
+
// source-commit checks must remain complete. A fresh pending source check is
|
|
7
|
+
// not made ready merely by entering the queue.
|
|
8
|
+
const sourceChecks = queued
|
|
9
|
+
? summarizePollSummaryChecks({ ...raw, mergeQueueEntry: null })
|
|
10
|
+
: checks;
|
|
11
|
+
return (checks.incomplete !== true &&
|
|
12
|
+
sourceChecks.incomplete !== true &&
|
|
13
|
+
review.incomplete !== true &&
|
|
14
|
+
raw.state === "OPEN" &&
|
|
15
|
+
!raw.isDraft &&
|
|
16
|
+
raw.mergeable !== "CONFLICTING" &&
|
|
17
|
+
raw.mergeStateStatus !== "DIRTY" &&
|
|
18
|
+
(queued || raw.mergeable === "MERGEABLE") &&
|
|
19
|
+
(queued ||
|
|
20
|
+
!["DIRTY", "BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"].includes(raw.mergeStateStatus)) &&
|
|
21
|
+
(checks.failing ?? 0) === 0 &&
|
|
22
|
+
(sourceChecks.failing ?? 0) === 0 &&
|
|
23
|
+
(sourceChecks.inProgress ?? 0) === 0 &&
|
|
24
|
+
(review.actionable ?? 0) === 0);
|
|
25
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { PollSummaryCommandOptions, PollSummaryItem, PollSummarySelection, PollSummaryStackAncestry } from "../types.mts";
|
|
2
2
|
import { type RepoInfo } from "./client.mts";
|
|
3
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
4
|
export interface FetchedPollSummary {
|
|
4
5
|
selection: PollSummarySelection;
|
|
5
6
|
prs: PollSummaryItem[];
|
|
6
7
|
stackAncestry?: PollSummaryStackAncestry[];
|
|
7
8
|
}
|
|
8
9
|
export declare function fetchPollSummary(opts: PollSummaryCommandOptions, repo: RepoInfo): Promise<FetchedPollSummary>;
|
|
10
|
+
/** Fresh, read-only snapshot used to bind a one-PR READY receipt to stack routing. */
|
|
11
|
+
export declare function fetchRawSummaryPr(pr: number, repo: RepoInfo): Promise<RawSummaryPr>;
|
|
@@ -24,6 +24,11 @@ export async function fetchPollSummary(opts, repo) {
|
|
|
24
24
|
prs: await Promise.all(raw.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
+
/** Fresh, read-only snapshot used to bind a one-PR READY receipt to stack routing. */
|
|
28
|
+
export async function fetchRawSummaryPr(pr, repo) {
|
|
29
|
+
const fetched = await fetchExplicitChunk([pr], repo);
|
|
30
|
+
return fetched.prs[0];
|
|
31
|
+
}
|
|
27
32
|
async function fetchExplicitChunk(prs, repo) {
|
|
28
33
|
const declarations = prs.map((_, index) => `$pr${index}: Int!`).join(", ");
|
|
29
34
|
const aliases = prs
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable evidence that a single-PR Shepherd poll observed a ready PR after
|
|
3
|
+
* its ready-delay elapsed. The ref OIDs and readiness fingerprint are part of
|
|
4
|
+
* the evidence: a receipt is never a general-purpose "this PR is ready"
|
|
5
|
+
* cache.
|
|
6
|
+
*/
|
|
7
|
+
export interface ReadyReceipt {
|
|
8
|
+
version: 1;
|
|
9
|
+
owner: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
pr: number;
|
|
12
|
+
headRefOid: string;
|
|
13
|
+
baseRefOid: string;
|
|
14
|
+
status: "READY";
|
|
15
|
+
isDraft: false;
|
|
16
|
+
/** Canonical representation of the readiness inputs observed by Shepherd. */
|
|
17
|
+
readinessFingerprint: string;
|
|
18
|
+
/** Exact merge-queue removal observed by the one-PR session, if any. */
|
|
19
|
+
acknowledgedQueueRemovalId?: string;
|
|
20
|
+
recordedAtUnix: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ReadyReceiptKey {
|
|
23
|
+
owner: string;
|
|
24
|
+
repo: string;
|
|
25
|
+
pr: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ReadyReceiptCurrentState {
|
|
28
|
+
headRefOid: string;
|
|
29
|
+
baseRefOid: string;
|
|
30
|
+
readinessFingerprint: string;
|
|
31
|
+
status: string;
|
|
32
|
+
isDraft: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Read a persisted one-PR readiness receipt. Invalid/stale-shaped files are ignored. */
|
|
35
|
+
export declare function readReadyReceipt(key: ReadyReceiptKey): Promise<ReadyReceipt | null>;
|
|
36
|
+
/** Persist evidence for a completed one-PR ready-delay observation. */
|
|
37
|
+
export declare function writeReadyReceipt(receipt: ReadyReceipt): Promise<void>;
|
|
38
|
+
/** Remove a receipt after the observed PR state no longer matches it. */
|
|
39
|
+
export declare function clearReadyReceipt(key: ReadyReceiptKey): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Check the complete binding before using a receipt for aggregate routing.
|
|
42
|
+
* Callers must provide a fresh fingerprint from the same readiness inputs used
|
|
43
|
+
* to create the receipt; ref OIDs alone are not sufficient.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isReadyReceiptCurrent(receipt: ReadyReceipt | null, current: ReadyReceiptCurrentState): receipt is ReadyReceipt;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { resolvePrStatePath } from "./base.mjs";
|
|
4
|
+
/** Read a persisted one-PR readiness receipt. Invalid/stale-shaped files are ignored. */
|
|
5
|
+
export async function readReadyReceipt(key) {
|
|
6
|
+
const path = receiptPath(key);
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
9
|
+
return isReadyReceipt(parsed) && sameKey(parsed, key) ? parsed : null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Persist evidence for a completed one-PR ready-delay observation. */
|
|
16
|
+
export async function writeReadyReceipt(receipt) {
|
|
17
|
+
if (!isReadyReceipt(receipt))
|
|
18
|
+
throw new Error("Invalid ready receipt");
|
|
19
|
+
const path = receiptPath(receipt);
|
|
20
|
+
await mkdir(dirname(path), { recursive: true });
|
|
21
|
+
await writeFile(path, `${JSON.stringify(receipt)}\n`, "utf8");
|
|
22
|
+
}
|
|
23
|
+
/** Remove a receipt after the observed PR state no longer matches it. */
|
|
24
|
+
export async function clearReadyReceipt(key) {
|
|
25
|
+
try {
|
|
26
|
+
await unlink(receiptPath(key));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
// Missing receipts are already clear. Any other failure must reach the
|
|
30
|
+
// caller so stale evidence cannot be silently retained as if it cleared.
|
|
31
|
+
if (isNodeErrorCode(error, "ENOENT"))
|
|
32
|
+
return;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isNodeErrorCode(error, code) {
|
|
37
|
+
return (error !== null &&
|
|
38
|
+
typeof error === "object" &&
|
|
39
|
+
"code" in error &&
|
|
40
|
+
error.code === code);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Check the complete binding before using a receipt for aggregate routing.
|
|
44
|
+
* Callers must provide a fresh fingerprint from the same readiness inputs used
|
|
45
|
+
* to create the receipt; ref OIDs alone are not sufficient.
|
|
46
|
+
*/
|
|
47
|
+
export function isReadyReceiptCurrent(receipt, current) {
|
|
48
|
+
return (receipt !== null &&
|
|
49
|
+
receipt.status === "READY" &&
|
|
50
|
+
receipt.isDraft === false &&
|
|
51
|
+
current.status === "READY" &&
|
|
52
|
+
current.isDraft === false &&
|
|
53
|
+
receipt.headRefOid === current.headRefOid &&
|
|
54
|
+
receipt.baseRefOid === current.baseRefOid &&
|
|
55
|
+
receipt.readinessFingerprint === current.readinessFingerprint);
|
|
56
|
+
}
|
|
57
|
+
function receiptPath(key) {
|
|
58
|
+
return resolvePrStatePath(key, "ready-receipt.json");
|
|
59
|
+
}
|
|
60
|
+
function sameKey(receipt, key) {
|
|
61
|
+
return receipt.owner === key.owner && receipt.repo === key.repo && receipt.pr === key.pr;
|
|
62
|
+
}
|
|
63
|
+
function isReadyReceipt(value) {
|
|
64
|
+
if (value === null || typeof value !== "object")
|
|
65
|
+
return false;
|
|
66
|
+
const candidate = value;
|
|
67
|
+
return (candidate.version === 1 &&
|
|
68
|
+
typeof candidate.owner === "string" &&
|
|
69
|
+
typeof candidate.repo === "string" &&
|
|
70
|
+
typeof candidate.pr === "number" &&
|
|
71
|
+
Number.isInteger(candidate.pr) &&
|
|
72
|
+
candidate.pr > 0 &&
|
|
73
|
+
typeof candidate.headRefOid === "string" &&
|
|
74
|
+
candidate.headRefOid.length > 0 &&
|
|
75
|
+
typeof candidate.baseRefOid === "string" &&
|
|
76
|
+
candidate.baseRefOid.length > 0 &&
|
|
77
|
+
candidate.status === "READY" &&
|
|
78
|
+
candidate.isDraft === false &&
|
|
79
|
+
typeof candidate.readinessFingerprint === "string" &&
|
|
80
|
+
candidate.readinessFingerprint.length > 0 &&
|
|
81
|
+
(candidate.acknowledgedQueueRemovalId === undefined ||
|
|
82
|
+
(typeof candidate.acknowledgedQueueRemovalId === "string" &&
|
|
83
|
+
candidate.acknowledgedQueueRemovalId.length > 0)) &&
|
|
84
|
+
typeof candidate.recordedAtUnix === "number" &&
|
|
85
|
+
Number.isFinite(candidate.recordedAtUnix));
|
|
86
|
+
}
|
package/bin/types/escalate.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
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
|
-
import type { MergeQueueRemovalStatus
|
|
5
|
-
export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "check-follow-up-unavailable" | "authorization-required" | "
|
|
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";
|
|
6
6
|
export interface AgentStalledCheck {
|
|
7
7
|
name: string;
|
|
8
8
|
status: CheckStatus;
|
|
@@ -39,7 +39,6 @@ export interface EscalateDetails {
|
|
|
39
39
|
suggestion: string;
|
|
40
40
|
humanMessage: string;
|
|
41
41
|
mergeQueueRemoval?: MergeQueueRemovalStatus;
|
|
42
|
-
stack?: StackStatus;
|
|
43
42
|
authorization?: Array<{
|
|
44
43
|
action: "mark-ready" | "merge-or-enqueue";
|
|
45
44
|
targetIds: string[];
|
package/bin/types/github.d.mts
CHANGED
|
@@ -144,6 +144,8 @@ export interface BatchPrData extends BatchPrMergeFields {
|
|
|
144
144
|
headRepoWithOwner: string | null;
|
|
145
145
|
viewerAuthorization?: ViewerAuthorization;
|
|
146
146
|
baseRefName: string;
|
|
147
|
+
/** Git OID of the base branch tip observed with this PR, when available. */
|
|
148
|
+
baseRefOid?: string;
|
|
147
149
|
reviewRequests: Array<{
|
|
148
150
|
login: string;
|
|
149
151
|
}>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ApiUsage, GraphqlQuotaWarning } from "./api-usage.mts";
|
|
2
2
|
import type { MergeableState, MergeStateStatus, ReviewDecision } from "./github.mts";
|
|
3
|
+
import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
|
|
3
4
|
import type { ShepherdAction } from "./iterate.mts";
|
|
4
5
|
export interface PollSummaryChecks {
|
|
5
6
|
passing?: number;
|
|
@@ -50,12 +51,17 @@ export interface PollSummaryItem {
|
|
|
50
51
|
baseRefName: string;
|
|
51
52
|
isDraft?: true;
|
|
52
53
|
isInMergeQueue?: true;
|
|
54
|
+
queueRemoval?: MergeQueueRemovalStatus;
|
|
53
55
|
blockingReviewerInProgress?: true;
|
|
54
56
|
remainingSeconds?: number;
|
|
55
57
|
checks?: PollSummaryChecks;
|
|
56
58
|
review?: PollSummaryReview;
|
|
57
59
|
stack?: PollSummaryStack;
|
|
58
60
|
pollCommand?: string;
|
|
61
|
+
/** A current one-PR READY-after-delay completion was verified. */
|
|
62
|
+
readyReceipt?: true;
|
|
63
|
+
/** Lowest unready ancestor that prevents this layer from being stack-mergeable. */
|
|
64
|
+
blockedByPr?: number;
|
|
59
65
|
}
|
|
60
66
|
export type PollSummarySelection = {
|
|
61
67
|
kind: "prs";
|
|
@@ -66,6 +72,8 @@ export type PollSummarySelection = {
|
|
|
66
72
|
stackNumber: number;
|
|
67
73
|
stackSize: number;
|
|
68
74
|
};
|
|
75
|
+
/** Aggregate-only transition; one-PR actions remain unchanged. */
|
|
76
|
+
export type StackNextAction = "shepherd" | "wait" | "merge" | "cancel" | "escalate";
|
|
69
77
|
export interface PollSummaryResult {
|
|
70
78
|
mode: "summary";
|
|
71
79
|
repo: string;
|
|
@@ -74,8 +82,10 @@ export interface PollSummaryResult {
|
|
|
74
82
|
prs: PollSummaryItem[];
|
|
75
83
|
/** Present only for native-stack boundaries whose recorded refs differ. */
|
|
76
84
|
stackAncestry?: PollSummaryStackAncestry[];
|
|
77
|
-
/**
|
|
78
|
-
nextAction?:
|
|
85
|
+
/** Immediate stack transition; human blockers surface in rows while shepherdable work remains. */
|
|
86
|
+
nextAction?: StackNextAction;
|
|
87
|
+
/** Whether every open layer is independently ready and stack ancestry is linear. */
|
|
88
|
+
stackMergeable?: boolean;
|
|
79
89
|
instructions?: string[];
|
|
80
90
|
apiUsage?: ApiUsage;
|
|
81
91
|
quotaWarning?: GraphqlQuotaWarning;
|
package/bin/types/report.d.mts
CHANGED
|
@@ -31,6 +31,8 @@ export interface ShepherdReport {
|
|
|
31
31
|
status: ShepherdStatus;
|
|
32
32
|
/** PR base branch from the GraphQL batch. */
|
|
33
33
|
baseBranch: string;
|
|
34
|
+
/** Git OID of the base branch tip observed with this report, when available. */
|
|
35
|
+
baseRefOid?: string;
|
|
34
36
|
mergeStatus: MergeStatusResult;
|
|
35
37
|
checks: {
|
|
36
38
|
passing: ClassifiedCheck[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -85,14 +85,14 @@
|
|
|
85
85
|
"devDependencies": {
|
|
86
86
|
"@types/node": "^26.0.0",
|
|
87
87
|
"@types/picomatch": "^4.0.3",
|
|
88
|
-
"@vitest/coverage-v8": "^
|
|
88
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
89
89
|
"husky": "^9.1.7",
|
|
90
90
|
"knip": "^6.14.1",
|
|
91
91
|
"marked": "^18.0.11",
|
|
92
|
-
"oxfmt": "^0.
|
|
92
|
+
"oxfmt": "^0.67.0",
|
|
93
93
|
"oxlint": "^1.60.0",
|
|
94
94
|
"typescript": "^7.0.2",
|
|
95
|
-
"vitest": "^
|
|
95
|
+
"vitest": "^5.0.0"
|
|
96
96
|
},
|
|
97
97
|
"engines": {
|
|
98
98
|
"node": ">=22.18.0"
|
|
@@ -20,11 +20,11 @@ If the requested PR does not exist yet, review and commit the in-scope changes,
|
|
|
20
20
|
|
|
21
21
|
1. Parse optional PR numbers, repository-qualified `owner/repo#N` references, or GitHub PR URLs and an optional `--merge` flag from `$ARGUMENTS`; alternatively parse one `--stack PR` selector. A clear request to merge, land, or enqueue the selected PR or stack also opts into `--merge` without a literal flag; a request only to create or open a PR does not. When the user asks to shepherd or merge a native stack and supplies an anchor PR without a literal `--stack`, use that PR as the `--stack` selector. Otherwise let pr-shepherd infer the current branch PR. Reject any remaining argument. Follow the target repository's local `AGENTS.md` and `CLAUDE.md` standards while making changes.
|
|
22
22
|
|
|
23
|
-
2. For the CLI, convert supplied `owner/repo#N` references to `https://github.com/owner/repo/pull/N`; otherwise pass supplied URLs or bare numbers unchanged, then run `pr-shepherd [PR ...] --until-terminal`, or `pr-shepherd --stack PR --until-terminal` for a stack, omitting `[PR ...]` when none was supplied and appending `--merge` when requested. This command keeps ordinary `[WAIT]` and `[MARK_READY]` ticks inside the same invocation; aggregate selectors return
|
|
23
|
+
2. For the CLI, convert supplied `owner/repo#N` references to `https://github.com/owner/repo/pull/N`; otherwise pass supplied URLs or bare numbers unchanged, then run `pr-shepherd [PR ...] --until-terminal`, or `pr-shepherd --stack PR --until-terminal` for a stack, omitting `[PR ...]` when none was supplied and appending `--merge` when requested. This command keeps ordinary `[WAIT]` and `[MARK_READY]` ticks inside the same invocation; aggregate selectors return their next stack action or terminal result. A qualified reference may name a fork or upstream repository: it is the GitHub target, while the current checkout continues to supply local git/config/rules context. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, first repository-qualify every supplied reference with its GitHub URL or `owner/repo#N`; resolve bare numbers through `gh pr view <number> --json url --jq .url`, and resolve an omitted target with `gh pr view --json url --jq .url`. If that does not produce the required qualified selector, stop and report that MCP cannot safely determine it. Otherwise call `iterate` with `pr`, `prs`, or `stack` as selected, plus `merge: true` when merge intent was requested, and print its full result.
|
|
24
24
|
|
|
25
25
|
3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patches` with the same qualified PR reference; do not run a shell `pr-shepherd apply` command.
|
|
26
26
|
|
|
27
|
-
4. After completing the returned instructions, immediately repeat step 2 with the same target and canonical options unless the action is `[CANCEL]` or `[ESCALATE]`, or the human directs you to stop. Preserve `--until-terminal` and any requested `--merge`; apply any polling-cadence adjustment printed by the CLI. Every other action is non-terminal: complete its instructions and rerun without asking whether to continue. `[FIX_CODE]` is always non-terminal,
|
|
27
|
+
4. After completing the returned instructions, immediately repeat step 2 with the same target and canonical options unless the action is `[CANCEL]` or `[ESCALATE]`, or the human directs you to stop. Preserve `--until-terminal` and any requested `--merge`; apply any polling-cadence adjustment printed by the CLI. Every other action is non-terminal: complete its instructions and rerun without asking whether to continue. `[FIX_CODE]` is always non-terminal, as is stack-level `[SHEPHERD]`; only `[ESCALATE]` hands work to a human. After a push or `rerun:`, do not wait for CI to finish first — you may pull check logs, but do not poll with `gh pr checks`, `gh pr watch`, `gh run watch`, or equivalent GitHub MCP check waiters.
|
|
28
28
|
|
|
29
29
|
## Playbooks
|
|
30
30
|
|
|
@@ -59,7 +59,7 @@ Match each failure's `[conclusion: …]` tag under `## Failing checks` to a rule
|
|
|
59
59
|
|
|
60
60
|
More specific rows win over the general "GitHub Actions failure" row — check conclusion first.
|
|
61
61
|
|
|
62
|
-
A `[rerun authorized]` tag with a `rerun:` command means the viewer's repository role grants GitHub's Actions rerun capability (WRITE+) and GitHub reports the original workflow attempt — Shepherd verified these from `repositoryPermission` and `run_attempt`. Run the printed command at most once. Later attempts carry an `[attempt: N]` tag
|
|
62
|
+
A `[rerun authorized]` tag with a `rerun:` command means the viewer's repository role grants GitHub's Actions rerun capability (WRITE+) and GitHub reports the original workflow attempt — Shepherd verified these from `repositoryPermission` and `run_attempt`. Run the printed command at most once. Later attempts carry an `[attempt: N]` tag and never get another rerun command; an included log excerpt remains autonomous investigation work, while a later attempt without usable evidence can return `[ESCALATE]` when no other work remains. A run still in progress, an `ACTION_REQUIRED` run (paused pending manual workflow approval — a rerun cannot grant that approval), a check whose runId does not resolve to a GitHub Actions workflow, or a run whose attempt metadata is unavailable never gets `[rerun authorized]`. When a check has no autonomous follow-up and no other agent work remains, Shepherd returns `[ESCALATE]`; do not invent a handoff from a `[FIX_CODE]` result.
|
|
63
63
|
|
|
64
64
|
When several bullets share one runId (matrix jobs from the same run), the `rerun:` command is printed once, on the first bullet; every bullet for that runId still carries `[rerun authorized]` and is covered by that single command — do not run it more than once.
|
|
65
65
|
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persistent first-seen state for bot CHANGES_REQUESTED reviews.
|
|
3
|
-
*
|
|
4
|
-
* Bot CRs are auto-dismissed via `--dismiss-review-ids` in the post-push
|
|
5
|
-
* `apply review:` command. If the agent drops that flag, the bot CR keeps the PR in
|
|
6
|
-
* `CHANGES_REQUESTED` state. This file tracks when each bot CR was first
|
|
7
|
-
* observed so the iterate loop can escalate after `iterate.stallTimeoutMinutes`
|
|
8
|
-
* — independent of the broader fingerprint-based `stall-timeout` mechanism in
|
|
9
|
-
* `iterate-stall.mts` (which only fires when no field of the iterate result
|
|
10
|
-
* changed).
|
|
11
|
-
*
|
|
12
|
-
* State lives in
|
|
13
|
-
* `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/bot-cr-seen.json`.
|
|
14
|
-
*
|
|
15
|
-
* `bodyHash` lets us reset `firstSeenAt` when the bot re-issues the review
|
|
16
|
-
* with a different body, so a fresh review gets the full timeout window.
|
|
17
|
-
*/
|
|
18
|
-
interface BotCrSeenEntry {
|
|
19
|
-
/** Unix timestamp (seconds) when this review was first observed undismissed. */
|
|
20
|
-
firstSeenAt: number;
|
|
21
|
-
/** SHA-256 body-hash prefix; reset firstSeenAt when this changes. */
|
|
22
|
-
bodyHash: string;
|
|
23
|
-
}
|
|
24
|
-
export interface BotCrSeenState {
|
|
25
|
-
reviews: Record<string, BotCrSeenEntry>;
|
|
26
|
-
}
|
|
27
|
-
interface StateKey {
|
|
28
|
-
owner: string;
|
|
29
|
-
repo: string;
|
|
30
|
-
pr: number;
|
|
31
|
-
}
|
|
32
|
-
export declare function readBotCrSeenState(key: StateKey): Promise<BotCrSeenState | null>;
|
|
33
|
-
export declare function writeBotCrSeenState(key: StateKey, state: BotCrSeenState): Promise<void>;
|
|
34
|
-
/**
|
|
35
|
-
* Update tracked entries against the current set of bot CR reviews:
|
|
36
|
-
* - Insert any new IDs with `firstSeenAt = now` and the current body hash.
|
|
37
|
-
* - Reset `firstSeenAt` for entries whose body hash changed (review re-issued).
|
|
38
|
-
* - Drop entries whose IDs are no longer in the input (review was dismissed
|
|
39
|
-
* or superseded by an approval).
|
|
40
|
-
*
|
|
41
|
-
* Pure function: returns the next state and the IDs whose age has reached the
|
|
42
|
-
* `stallTimeoutSeconds` threshold (escalate candidates).
|
|
43
|
-
*/
|
|
44
|
-
export declare function updateBotCrSeenState(previous: BotCrSeenState | null, currentBotCrReviews: ReadonlyArray<{
|
|
45
|
-
id: string;
|
|
46
|
-
body: string;
|
|
47
|
-
}>, nowSeconds: number, stallTimeoutSeconds: number): {
|
|
48
|
-
next: BotCrSeenState;
|
|
49
|
-
staleIds: string[];
|
|
50
|
-
};
|
|
51
|
-
export {};
|