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.
Files changed (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -12
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/default-iterate.mjs +4 -3
  5. package/bin/cli/duration-flag.mjs +22 -0
  6. package/bin/cli/exit-codes.mjs +14 -0
  7. package/bin/cli/fix-formatter.mjs +1 -2
  8. package/bin/cli/handlers.mjs +19 -38
  9. package/bin/cli/help.mjs +40 -0
  10. package/bin/cli/iterate-emitter.mjs +19 -0
  11. package/bin/cli/iterate-flags.mjs +21 -0
  12. package/bin/cli/iterate-formatter.mjs +50 -13
  13. package/bin/cli/iterate-instructions.mjs +10 -16
  14. package/bin/cli/iterate-lean.mjs +9 -12
  15. package/bin/cli/poll-handler.mjs +42 -0
  16. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
  17. package/bin/cli-parser.iterate-fixtures.mjs +4 -1
  18. package/bin/cli-parser.iterate.test-support.mjs +0 -4
  19. package/bin/cli-parser.mjs +30 -5
  20. package/bin/commands/check-terminal-report.mjs +1 -0
  21. package/bin/commands/check.mjs +1 -0
  22. package/bin/commands/check.test-support.mjs +1 -0
  23. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
  24. package/bin/commands/commit-suggestion.test-support.mjs +1 -0
  25. package/bin/commands/iterate/check-instructions.mjs +19 -17
  26. package/bin/commands/iterate/escalate.mjs +17 -21
  27. package/bin/commands/iterate/fix-code.mjs +22 -11
  28. package/bin/commands/iterate/index.mjs +2 -0
  29. package/bin/commands/iterate/render.mjs +50 -45
  30. package/bin/commands/iterate-test-support.mjs +1 -0
  31. package/bin/commands/poll.mjs +32 -0
  32. package/bin/commands/poll.test-support.mjs +77 -0
  33. package/bin/commands/resolve-instructions.mjs +2 -5
  34. package/bin/github/batch-parser-helpers.mjs +38 -0
  35. package/bin/github/batch-parsers.mjs +12 -38
  36. package/bin/github/gql/batch-pr.gql +9 -0
  37. package/bin/state/seen-comments.mjs +29 -15
  38. package/package.json +1 -1
  39. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  40. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +13 -2
  41. 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
- try {
37
- const dir = resolveDir(key);
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 ids = entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5));
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, `${id}.json`), "utf8");
59
- map.set(id, JSON.parse(raw));
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
- await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash }), "utf8");
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
- return join(resolveDir(key), `${id}.json`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -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. **Stop conditions:**
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
- - All other actions (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) are non-terminal: follow the `## Instructions`. For Claude, schedule exactly one next session-only iteration and end the turn; do not sleep inline and do not create a recurring cron or polling loop (`while true`, repeated polling, etc.).
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.
@@ -1,7 +0,0 @@
1
- export function detectAgentRuntime(env = process.env) {
2
- if (env.AGENT?.trim().toLowerCase() === "codex")
3
- return "codex";
4
- if (env.CODEX_CI === "1")
5
- return "codex";
6
- return "claude";
7
- }