pr-shepherd 0.17.0 → 0.19.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 +6 -12
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/default-iterate.mjs +4 -3
- package/bin/cli/duration-flag.mjs +22 -0
- package/bin/cli/exit-codes.mjs +14 -0
- package/bin/cli/fix-formatter.mjs +1 -2
- package/bin/cli/handlers.mjs +19 -38
- package/bin/cli/help.mjs +40 -0
- package/bin/cli/iterate-emitter.mjs +19 -0
- package/bin/cli/iterate-flags.mjs +21 -0
- package/bin/cli/iterate-formatter.mjs +50 -13
- package/bin/cli/iterate-instructions.mjs +10 -16
- package/bin/cli/iterate-lean.mjs +9 -12
- package/bin/cli/poll-handler.mjs +42 -0
- package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
- package/bin/cli-parser.iterate-fixtures.mjs +4 -1
- package/bin/cli-parser.iterate.test-support.mjs +0 -4
- package/bin/cli-parser.mjs +30 -5
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/check.test-support.mjs +1 -0
- package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
- package/bin/commands/commit-suggestion.test-support.mjs +1 -0
- package/bin/commands/iterate/check-instructions.mjs +19 -17
- package/bin/commands/iterate/escalate.mjs +17 -21
- package/bin/commands/iterate/fix-code.mjs +22 -11
- package/bin/commands/iterate/index.mjs +2 -0
- package/bin/commands/iterate/render.mjs +50 -45
- package/bin/commands/iterate-test-support.mjs +1 -0
- package/bin/commands/poll.mjs +32 -0
- package/bin/commands/poll.test-support.mjs +77 -0
- package/bin/commands/resolve-instructions.mjs +2 -5
- package/bin/github/batch-parser-helpers.mjs +38 -0
- package/bin/github/batch-parsers.mjs +12 -38
- package/bin/github/gql/batch-pr.gql +9 -0
- package/bin/state/seen-comments.mjs +29 -15
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +13 -2
- package/bin/agent-runtime.mjs +0 -7
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function mapAuthorType(typeName) {
|
|
2
|
+
if (typeName === "User" || typeName === "Bot")
|
|
3
|
+
return typeName;
|
|
4
|
+
return "Unknown";
|
|
5
|
+
}
|
|
6
|
+
export function parseCreatedAt(iso) {
|
|
7
|
+
const ms = new Date(iso).getTime();
|
|
8
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
9
|
+
}
|
|
10
|
+
export function extractRunId(url) {
|
|
11
|
+
if (!url)
|
|
12
|
+
return null;
|
|
13
|
+
const m = /\/runs\/(\d+)/.exec(url);
|
|
14
|
+
return m ? (m[1] ?? null) : null;
|
|
15
|
+
}
|
|
16
|
+
export function extractCheckRunSummary(title, summary) {
|
|
17
|
+
const t = title?.trim();
|
|
18
|
+
if (t)
|
|
19
|
+
return t;
|
|
20
|
+
const firstLine = summary
|
|
21
|
+
?.split("\n")
|
|
22
|
+
?.find((l) => l.trim() !== "")
|
|
23
|
+
?.trim();
|
|
24
|
+
return firstLine || undefined;
|
|
25
|
+
}
|
|
26
|
+
export function mapStatusContextState(state) {
|
|
27
|
+
switch (state) {
|
|
28
|
+
case "SUCCESS":
|
|
29
|
+
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
30
|
+
case "FAILURE":
|
|
31
|
+
case "ERROR":
|
|
32
|
+
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
33
|
+
case "PENDING":
|
|
34
|
+
case "EXPECTED":
|
|
35
|
+
default:
|
|
36
|
+
return { status: "IN_PROGRESS", conclusion: null };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, } from "./batch-parser-helpers.mjs";
|
|
1
2
|
export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
|
|
2
3
|
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
3
4
|
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
@@ -92,6 +93,16 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
92
93
|
}
|
|
93
94
|
return [];
|
|
94
95
|
});
|
|
96
|
+
const rawProtection = raw.baseRef?.branchProtectionRule ?? null;
|
|
97
|
+
const branchProtection = rawProtection
|
|
98
|
+
? {
|
|
99
|
+
requiresApprovingReviews: rawProtection.requiresApprovingReviews,
|
|
100
|
+
requiredApprovingReviewCount: rawProtection.requiredApprovingReviewCount,
|
|
101
|
+
requiresConversationResolution: rawProtection.requiresConversationResolution,
|
|
102
|
+
requiresStatusChecks: rawProtection.requiresStatusChecks,
|
|
103
|
+
requiredStatusCheckContexts: rawProtection.requiredStatusCheckContexts ?? [],
|
|
104
|
+
}
|
|
105
|
+
: null;
|
|
95
106
|
return {
|
|
96
107
|
nodeId: raw.id,
|
|
97
108
|
number: raw.number,
|
|
@@ -112,43 +123,6 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
112
123
|
reviewSummaries,
|
|
113
124
|
approvedReviews,
|
|
114
125
|
checks,
|
|
126
|
+
branchProtection,
|
|
115
127
|
};
|
|
116
128
|
}
|
|
117
|
-
function mapAuthorType(typeName) {
|
|
118
|
-
if (typeName === "User" || typeName === "Bot")
|
|
119
|
-
return typeName;
|
|
120
|
-
return "Unknown";
|
|
121
|
-
}
|
|
122
|
-
function parseCreatedAt(iso) {
|
|
123
|
-
const ms = new Date(iso).getTime();
|
|
124
|
-
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
125
|
-
}
|
|
126
|
-
function extractRunId(url) {
|
|
127
|
-
if (!url)
|
|
128
|
-
return null;
|
|
129
|
-
const m = /\/runs\/(\d+)/.exec(url);
|
|
130
|
-
return m ? (m[1] ?? null) : null;
|
|
131
|
-
}
|
|
132
|
-
function extractCheckRunSummary(title, summary) {
|
|
133
|
-
const t = title?.trim();
|
|
134
|
-
if (t)
|
|
135
|
-
return t;
|
|
136
|
-
const firstLine = summary
|
|
137
|
-
?.split("\n")
|
|
138
|
-
?.find((l) => l.trim() !== "")
|
|
139
|
-
?.trim();
|
|
140
|
-
return firstLine || undefined;
|
|
141
|
-
}
|
|
142
|
-
function mapStatusContextState(state) {
|
|
143
|
-
switch (state) {
|
|
144
|
-
case "SUCCESS":
|
|
145
|
-
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
146
|
-
case "FAILURE":
|
|
147
|
-
case "ERROR":
|
|
148
|
-
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
149
|
-
case "PENDING":
|
|
150
|
-
case "EXPECTED":
|
|
151
|
-
default:
|
|
152
|
-
return { status: "IN_PROGRESS", conclusion: null };
|
|
153
|
-
}
|
|
154
|
-
}
|
|
@@ -24,6 +24,15 @@ query BatchPr(
|
|
|
24
24
|
nameWithOwner
|
|
25
25
|
}
|
|
26
26
|
baseRefName
|
|
27
|
+
baseRef {
|
|
28
|
+
branchProtectionRule {
|
|
29
|
+
requiresApprovingReviews
|
|
30
|
+
requiredApprovingReviewCount
|
|
31
|
+
requiresConversationResolution
|
|
32
|
+
requiresStatusChecks
|
|
33
|
+
requiredStatusCheckContexts
|
|
34
|
+
}
|
|
35
|
+
}
|
|
27
36
|
# Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
|
|
28
37
|
reviewRequests(last: 50) {
|
|
29
38
|
nodes {
|
|
@@ -33,30 +33,35 @@ export function classifyItem(id, body, map) {
|
|
|
33
33
|
* Returns an empty Set if the directory does not yet exist.
|
|
34
34
|
*/
|
|
35
35
|
export async function loadSeenSet(key) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const entries = await readdir(dir);
|
|
39
|
-
return new Set(entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5)));
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return new Set();
|
|
43
|
-
}
|
|
36
|
+
const map = await loadSeenMap(key);
|
|
37
|
+
return new Set(map.keys());
|
|
44
38
|
}
|
|
45
39
|
/**
|
|
46
40
|
* Read the seen/ directory and return a Map from ID to SeenMarker.
|
|
47
41
|
* Used when the caller needs the stored bodyHash to detect in-place edits.
|
|
48
42
|
* Returns an empty Map if the directory does not yet exist.
|
|
43
|
+
*
|
|
44
|
+
* Map keys are the stored `id` field when present (guarding against
|
|
45
|
+
* case-insensitive filesystem collisions), falling back to the filename for
|
|
46
|
+
* legacy markers that predate this field.
|
|
49
47
|
*/
|
|
50
48
|
export async function loadSeenMap(key) {
|
|
51
49
|
const map = new Map();
|
|
52
50
|
try {
|
|
53
51
|
const dir = resolveDir(key);
|
|
54
52
|
const entries = await readdir(dir);
|
|
55
|
-
const
|
|
56
|
-
for (const id of ids) {
|
|
53
|
+
for (const entry of entries.filter((e) => e.endsWith(".json"))) {
|
|
57
54
|
try {
|
|
58
|
-
const raw = await readFile(join(dir,
|
|
59
|
-
|
|
55
|
+
const raw = await readFile(join(dir, entry), "utf8");
|
|
56
|
+
const marker = JSON.parse(raw);
|
|
57
|
+
// Prefer the stored id field; fall back to filename stem for legacy markers.
|
|
58
|
+
const mapKey = typeof marker.id === "string" ? marker.id : entry.slice(0, -5);
|
|
59
|
+
// Hash-based markers (those with an id field) take priority over legacy
|
|
60
|
+
// filename-based markers so that a stale legacy file cannot overwrite a
|
|
61
|
+
// newer hash-based entry that maps to the same key.
|
|
62
|
+
if (!map.has(mapKey) || typeof marker.id === "string") {
|
|
63
|
+
map.set(mapKey, marker);
|
|
64
|
+
}
|
|
60
65
|
}
|
|
61
66
|
catch {
|
|
62
67
|
// unreadable or malformed — skip
|
|
@@ -82,7 +87,7 @@ export async function hasSeen(key, id) {
|
|
|
82
87
|
* Write (or update) a "seen" marker for this id, storing the body hash so
|
|
83
88
|
* in-place edits can be detected on future fetches.
|
|
84
89
|
*
|
|
85
|
-
* - First call (no existing marker): creates `{ seenAt: now, bodyHash }`.
|
|
90
|
+
* - First call (no existing marker): creates `{ seenAt: now, bodyHash, id }`.
|
|
86
91
|
* - Subsequent call, hash unchanged: no-op (skips the write).
|
|
87
92
|
* - Subsequent call, hash changed: updates `bodyHash`, preserves original `seenAt`.
|
|
88
93
|
*
|
|
@@ -106,7 +111,10 @@ export async function markSeen(key, id, body) {
|
|
|
106
111
|
return;
|
|
107
112
|
const seenAt = existing?.seenAt ?? Date.now();
|
|
108
113
|
tmp = `${path}.${randomUUID()}.tmp`;
|
|
109
|
-
|
|
114
|
+
// Store `id` in the payload so loadSeenMap can key by the original ID
|
|
115
|
+
// rather than the filename, guarding against case-insensitive filesystems
|
|
116
|
+
// (e.g. macOS APFS) where IDs differing only in case would collide.
|
|
117
|
+
await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash, id }), "utf8");
|
|
110
118
|
await rename(tmp, path);
|
|
111
119
|
tmp = undefined;
|
|
112
120
|
}
|
|
@@ -153,5 +161,11 @@ function resolvePath(key, id) {
|
|
|
153
161
|
if (!SAFE_SEGMENT.test(id)) {
|
|
154
162
|
throw new Error(`Invalid state key segment "id": ${id}`);
|
|
155
163
|
}
|
|
156
|
-
|
|
164
|
+
// Hash the ID to produce a case-insensitive filename. On case-insensitive
|
|
165
|
+
// filesystems (macOS APFS), IDs that differ only in case (e.g. base64 IDs
|
|
166
|
+
// from GitHub like `ChG7F` vs `ChG7f`) would otherwise share the same file,
|
|
167
|
+
// causing seen-markers to overwrite each other and items to re-surface every
|
|
168
|
+
// tick. SHA-256 is case-sensitive so distinct IDs get distinct files.
|
|
169
|
+
const hash = createHash("sha256").update(id, "utf8").digest("hex");
|
|
170
|
+
return join(resolveDir(key), `${hash}.json`);
|
|
157
171
|
}
|
package/package.json
CHANGED
|
@@ -42,7 +42,18 @@ One-tick dispatcher for iterating a PR to completion.
|
|
|
42
42
|
|
|
43
43
|
Print the full output. Follow the `## Instructions` section exactly.
|
|
44
44
|
|
|
45
|
-
5. **
|
|
45
|
+
5. **Persistence:** Do not stop polling/iterating unless directed by the human or until the CLI returns a terminal state (`[CANCEL]` or `[ESCALATE]`). Every other action — `[WAIT]`, `[MARK_READY]`, `[FIX_CODE]` — is non-terminal and must be followed by another tick via one of the strategies below.
|
|
46
|
+
|
|
47
|
+
6. **Stop conditions (terminal states):**
|
|
46
48
|
- Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
|
|
47
49
|
- Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures.
|
|
48
|
-
-
|
|
50
|
+
- **Do NOT merge the pull request** unless the human has explicitly requested or allowed it.
|
|
51
|
+
|
|
52
|
+
7. **Non-terminal actions** (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) — follow the `## Instructions` in the output. Pick one iteration strategy:
|
|
53
|
+
- **Blocking poll** — rerun as `<runner> pr-shepherd poll <N> [--interval <duration>] [--timeout <duration>]` (defaults: interval 30s, timeout 5m). Holds the agent turn until the action is non-WAIT or the timeout fires. Simplest when the agent cannot reliably schedule its own follow-up.
|
|
54
|
+
- **Scheduled wakeup + one tick** — schedule a single session-only follow-up task to rerun `<runner> pr-shepherd <N>` after a fresh 30s–4m delay, then end the turn.
|
|
55
|
+
- **Inline sleep + rerun** — sleep inline for a fresh 30s–4m delay, then rerun.
|
|
56
|
+
|
|
57
|
+
**Never write a custom polling loop** (shell `while`/`until` loops, script files that loop over pr-shepherd output, etc.). Custom loops poll only for terminal states and silently skip `[FIX_CODE]` handling — actionable review threads, failing checks, and resolve commands get missed. Use `pr-shepherd poll` for WAIT-state waiting; it exits on any non-WAIT action so the caller handles `[FIX_CODE]` and other actionable outputs normally.
|
|
58
|
+
|
|
59
|
+
Do not combine strategies (e.g., poll AND schedule a wakeup). Remember step 5 — every non-terminal output requires a follow-up tick.
|