pr-shepherd 0.15.2 → 0.16.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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +23 -89
- package/bin/cli/args.mjs +0 -1
- package/bin/cli/default-iterate.mjs +2 -7
- package/bin/cli/exit-codes.mjs +0 -4
- package/bin/cli/fix-formatter.mjs +8 -8
- package/bin/cli/handlers.mjs +3 -72
- package/bin/cli/iterate-formatter.mjs +8 -17
- package/bin/cli/iterate-instructions.mjs +13 -47
- package/bin/cli/iterate-lean.mjs +4 -11
- package/bin/cli/list-formatters.mjs +6 -3
- package/bin/cli-parser.iterate-fixtures.mjs +4 -7
- package/bin/cli-parser.mjs +4 -33
- package/bin/commands/check-status.mjs +2 -2
- package/bin/commands/check.mjs +7 -6
- package/bin/commands/commit-suggestion-instruction.mjs +23 -0
- package/bin/commands/iterate/check-instructions.mjs +1 -1
- package/bin/commands/iterate/classify.mjs +9 -4
- package/bin/commands/iterate/escalate.mjs +5 -6
- package/bin/commands/iterate/fix-code.mjs +3 -3
- package/bin/commands/iterate/helpers.mjs +0 -29
- package/bin/commands/iterate/index.mjs +18 -19
- package/bin/commands/iterate/render.mjs +5 -17
- package/bin/commands/resolve-instructions.mjs +2 -10
- package/bin/commands/resolve-mutate.mjs +16 -0
- package/bin/commands/resolve.mjs +6 -17
- package/bin/commands/shepherd-journal.mjs +1 -1
- package/bin/comments/minimize-policy.mjs +15 -0
- package/bin/comments/visible-comments.mjs +20 -0
- package/bin/config/load.mjs +10 -3
- package/bin/config.json +2 -3
- package/bin/github/batch-parsers.mjs +10 -0
- package/bin/github/gql/batch-pr.gql +6 -0
- package/bin/github/queries.mjs +0 -2
- package/bin/index.mjs +0 -2
- package/bin/merge-status/derive.mjs +6 -6
- package/bin/reporters/agent.mjs +10 -3
- package/bin/state/iterate-stall.mjs +9 -0
- package/package.json +5 -6
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +31 -26
- package/bin/commands/iterate.mjs +0 -2
- package/bin/commands/monitor.mjs +0 -139
- package/bin/commands/status.mjs +0 -126
- package/bin/github/gql/multi-pr-status-paged.gql +0 -31
- package/bin/reporters/check-instructions.mjs +0 -69
- package/bin/reporters/json.mjs +0 -10
- package/bin/reporters/text.mjs +0 -156
- package/plugin/skills/check/SKILL.md +0 -37
- package/plugin/skills/monitor/SKILL.md +0 -40
- package/plugin/skills/resolve/SKILL.md +0 -38
package/bin/commands/resolve.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
2
2
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
3
3
|
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
4
|
-
import { autoResolveOutdated
|
|
4
|
+
import { autoResolveOutdated } from "../comments/resolve.mjs";
|
|
5
5
|
import { loadConfig } from "../config/load.mjs";
|
|
6
|
+
import { classifyVisibleComments } from "../comments/visible-comments.mjs";
|
|
6
7
|
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
7
8
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
8
9
|
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
10
|
+
export { runResolveMutate } from "./resolve-mutate.mjs";
|
|
9
11
|
export async function runResolveFetch(opts) {
|
|
10
12
|
const repo = await getRepoInfo();
|
|
11
13
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -15,7 +17,6 @@ export async function runResolveFetch(opts) {
|
|
|
15
17
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
16
18
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
17
19
|
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved);
|
|
18
|
-
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
19
20
|
const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
|
|
20
21
|
const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
21
22
|
const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
@@ -70,6 +71,7 @@ export async function runResolveFetch(opts) {
|
|
|
70
71
|
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
|
|
71
72
|
const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
|
|
72
73
|
const cfg = loadConfig();
|
|
74
|
+
const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
|
|
73
75
|
const actionableThreads = activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
|
|
74
76
|
const thread = rest;
|
|
75
77
|
const suggestion = extractSuggestion(rest);
|
|
@@ -114,13 +116,14 @@ export async function runResolveFetch(opts) {
|
|
|
114
116
|
await Promise.allSettled([
|
|
115
117
|
...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
|
|
116
118
|
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
119
|
+
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
117
120
|
]);
|
|
118
121
|
const result = {
|
|
119
122
|
prNumber,
|
|
120
123
|
actionableThreads,
|
|
121
124
|
resolutionOnlyThreads,
|
|
122
125
|
firstLookThreads,
|
|
123
|
-
actionableComments:
|
|
126
|
+
actionableComments: visibleCommentClassification.actionable,
|
|
124
127
|
firstLookComments,
|
|
125
128
|
changesRequestedReviews: data.changesRequestedReviews,
|
|
126
129
|
reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
|
|
@@ -128,17 +131,3 @@ export async function runResolveFetch(opts) {
|
|
|
128
131
|
};
|
|
129
132
|
return { ...result, instructions: buildFetchInstructions(prNumber, result, cfg.cli?.runner) };
|
|
130
133
|
}
|
|
131
|
-
export async function runResolveMutate(opts) {
|
|
132
|
-
const repo = await getRepoInfo();
|
|
133
|
-
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
134
|
-
if (prNumber === null) {
|
|
135
|
-
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
136
|
-
}
|
|
137
|
-
return applyResolveOptions(prNumber, repo, {
|
|
138
|
-
resolveThreadIds: opts.resolveThreadIds,
|
|
139
|
-
minimizeCommentIds: opts.minimizeCommentIds,
|
|
140
|
-
dismissReviewIds: opts.dismissReviewIds,
|
|
141
|
-
dismissMessage: opts.dismissMessage,
|
|
142
|
-
requireSha: opts.requireSha,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
|
|
2
2
|
export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
|
|
3
3
|
export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
|
|
4
|
-
export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look
|
|
4
|
+
export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Any IDs eligible for minimization are already included in the resolve command's `--minimize-comment-ids`; if any warrants a Shepherd Journal note, append it before running resolve.";
|
|
5
5
|
export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
|
|
6
6
|
return [
|
|
7
7
|
`For any large decisions or rejections you made this iteration, add or update a \`${SHEPHERD_JOURNAL_SECTION}\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision.`,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function shouldMinimizeAuthor(authorType, policy) {
|
|
2
|
+
switch (policy) {
|
|
3
|
+
case undefined:
|
|
4
|
+
case "all":
|
|
5
|
+
return true;
|
|
6
|
+
case "bots":
|
|
7
|
+
return authorType === "Bot";
|
|
8
|
+
case "users":
|
|
9
|
+
return authorType === "User";
|
|
10
|
+
case "none":
|
|
11
|
+
return false;
|
|
12
|
+
default:
|
|
13
|
+
throw new Error(`Invalid minimizeComments policy: ${String(policy)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { shouldMinimizeAuthor } from "./minimize-policy.mjs";
|
|
2
|
+
import { classifyItem } from "../state/seen-comments.mjs";
|
|
3
|
+
export function classifyVisibleComments(comments, seenMap, minimizeComments) {
|
|
4
|
+
const actionable = [];
|
|
5
|
+
const minimizeIds = [];
|
|
6
|
+
const toMarkSeen = [];
|
|
7
|
+
for (const c of comments.filter((comment) => !comment.isMinimized)) {
|
|
8
|
+
if (shouldMinimizeAuthor(c.authorType, minimizeComments)) {
|
|
9
|
+
actionable.push(c);
|
|
10
|
+
minimizeIds.push(c.id);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const cls = classifyItem(c.id, c.body, seenMap);
|
|
14
|
+
if (cls === "unchanged")
|
|
15
|
+
continue;
|
|
16
|
+
actionable.push(c);
|
|
17
|
+
toMarkSeen.push(c);
|
|
18
|
+
}
|
|
19
|
+
return { actionable, minimizeIds, toMarkSeen };
|
|
20
|
+
}
|
package/bin/config/load.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
import builtins from "../config.json" with { type: "json" };
|
|
6
6
|
import { parseCliRunner } from "../cli/runner.mjs";
|
|
7
|
+
const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
|
|
7
8
|
const RC_FILENAME = ".pr-shepherdrc.yml";
|
|
8
9
|
function findRcFile(startDir) {
|
|
9
10
|
const home = homedir();
|
|
@@ -37,6 +38,14 @@ function deepMerge(base, override) {
|
|
|
37
38
|
}
|
|
38
39
|
return result;
|
|
39
40
|
}
|
|
41
|
+
function isMinimizeCommentsPolicy(value) {
|
|
42
|
+
return MINIMIZE_COMMENTS_POLICIES.some((policy) => policy === value);
|
|
43
|
+
}
|
|
44
|
+
function parseMinimizeCommentsPolicy(value) {
|
|
45
|
+
if (isMinimizeCommentsPolicy(value))
|
|
46
|
+
return value;
|
|
47
|
+
throw new Error(`Invalid config: iterate.minimizeComments must be one of "all", "bots", "users", or "none", got ${JSON.stringify(value)}`);
|
|
48
|
+
}
|
|
40
49
|
const defaults = builtins;
|
|
41
50
|
const configCache = new Map();
|
|
42
51
|
export function loadConfig() {
|
|
@@ -58,9 +67,7 @@ export function loadConfig() {
|
|
|
58
67
|
throw new Error(`Invalid config: cli must be a plain object, got ${JSON.stringify(config.cli)}`);
|
|
59
68
|
}
|
|
60
69
|
config.cli.runner = parseCliRunner(config.cli.runner);
|
|
61
|
-
|
|
62
|
-
throw new Error(`Invalid config: watch.interval must be a duration string like "4m" or "1h", got ${JSON.stringify(config.watch?.interval)}`);
|
|
63
|
-
}
|
|
70
|
+
config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
|
|
64
71
|
configCache.set(cwd, config);
|
|
65
72
|
return config;
|
|
66
73
|
}
|
package/bin/config.json
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
"runner": "auto"
|
|
4
4
|
},
|
|
5
5
|
"iterate": {
|
|
6
|
-
"cooldownSeconds": 30,
|
|
7
6
|
"fixAttemptsPerThread": 3,
|
|
8
7
|
"stallTimeoutMinutes": 30,
|
|
9
|
-
"minimizeApprovals": false
|
|
8
|
+
"minimizeApprovals": false,
|
|
9
|
+
"minimizeComments": "all"
|
|
10
10
|
},
|
|
11
11
|
"watch": {
|
|
12
|
-
"interval": "4m",
|
|
13
12
|
"readyDelayMinutes": 10
|
|
14
13
|
},
|
|
15
14
|
"resolve": {
|
|
@@ -18,6 +18,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
18
18
|
line: comment?.line ?? null,
|
|
19
19
|
startLine: comment?.startLine ?? null,
|
|
20
20
|
author: comment?.author?.login ?? "unknown",
|
|
21
|
+
authorType: mapAuthorType(comment?.author?.__typename),
|
|
21
22
|
body: comment?.body ?? "",
|
|
22
23
|
url: comment?.url ?? "",
|
|
23
24
|
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
27
28
|
id: c.id,
|
|
28
29
|
isMinimized: c.isMinimized,
|
|
29
30
|
author: c.author?.login ?? "unknown",
|
|
31
|
+
authorType: mapAuthorType(c.author?.__typename),
|
|
30
32
|
body: c.body,
|
|
31
33
|
url: c.url,
|
|
32
34
|
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
@@ -34,6 +36,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
34
36
|
const changesRequestedReviews = rawReviewNodes.map((r) => ({
|
|
35
37
|
id: r.id,
|
|
36
38
|
author: r.author?.login ?? "unknown",
|
|
39
|
+
authorType: mapAuthorType(r.author?.__typename),
|
|
37
40
|
body: r.body,
|
|
38
41
|
}));
|
|
39
42
|
const reviewSummaries = rawReviewSummaryNodes
|
|
@@ -41,6 +44,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
41
44
|
.map((r) => ({
|
|
42
45
|
id: r.id,
|
|
43
46
|
author: r.author?.login ?? "unknown",
|
|
47
|
+
authorType: mapAuthorType(r.author?.__typename),
|
|
44
48
|
body: r.body,
|
|
45
49
|
}));
|
|
46
50
|
// APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
|
|
@@ -51,6 +55,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
51
55
|
.map((r) => ({
|
|
52
56
|
id: r.id,
|
|
53
57
|
author: r.author?.login ?? "unknown",
|
|
58
|
+
authorType: mapAuthorType(r.author?.__typename),
|
|
54
59
|
body: r.body,
|
|
55
60
|
}));
|
|
56
61
|
const checks = rawCheckNodes.flatMap((node) => {
|
|
@@ -109,6 +114,11 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
109
114
|
checks,
|
|
110
115
|
};
|
|
111
116
|
}
|
|
117
|
+
function mapAuthorType(typeName) {
|
|
118
|
+
if (typeName === "User" || typeName === "Bot")
|
|
119
|
+
return typeName;
|
|
120
|
+
return "Unknown";
|
|
121
|
+
}
|
|
112
122
|
function parseCreatedAt(iso) {
|
|
113
123
|
const ms = new Date(iso).getTime();
|
|
114
124
|
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
@@ -43,6 +43,7 @@ query BatchPr(
|
|
|
43
43
|
latestReviews(last: 100) {
|
|
44
44
|
nodes {
|
|
45
45
|
author {
|
|
46
|
+
__typename
|
|
46
47
|
login
|
|
47
48
|
}
|
|
48
49
|
state
|
|
@@ -64,6 +65,7 @@ query BatchPr(
|
|
|
64
65
|
isMinimized
|
|
65
66
|
url
|
|
66
67
|
author {
|
|
68
|
+
__typename
|
|
67
69
|
login
|
|
68
70
|
}
|
|
69
71
|
body
|
|
@@ -85,6 +87,7 @@ query BatchPr(
|
|
|
85
87
|
isMinimized
|
|
86
88
|
url
|
|
87
89
|
author {
|
|
90
|
+
__typename
|
|
88
91
|
login
|
|
89
92
|
}
|
|
90
93
|
body
|
|
@@ -103,6 +106,7 @@ query BatchPr(
|
|
|
103
106
|
nodes {
|
|
104
107
|
id
|
|
105
108
|
author {
|
|
109
|
+
__typename
|
|
106
110
|
login
|
|
107
111
|
}
|
|
108
112
|
body
|
|
@@ -117,6 +121,7 @@ query BatchPr(
|
|
|
117
121
|
id
|
|
118
122
|
isMinimized
|
|
119
123
|
author {
|
|
124
|
+
__typename
|
|
120
125
|
login
|
|
121
126
|
}
|
|
122
127
|
body
|
|
@@ -131,6 +136,7 @@ query BatchPr(
|
|
|
131
136
|
id
|
|
132
137
|
isMinimized
|
|
133
138
|
author {
|
|
139
|
+
__typename
|
|
134
140
|
login
|
|
135
141
|
}
|
|
136
142
|
body
|
package/bin/github/queries.mjs
CHANGED
|
@@ -13,8 +13,6 @@ const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8
|
|
|
13
13
|
export const BATCH_PR_QUERY = gql("batch-pr.gql");
|
|
14
14
|
/** Returns the current head commit SHA for a PR. Used by waitForSha polling. */
|
|
15
15
|
export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
|
|
16
|
-
/** Paginated follow-up for `shepherd status` — used when reviewThreads is truncated (totalCount > 100). */
|
|
17
|
-
export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
|
|
18
16
|
/** Look up PR number by branch name (for getCurrentPrNumber). */
|
|
19
17
|
export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
|
|
20
18
|
/** Convert a draft PR to ready for review. */
|
package/bin/index.mjs
CHANGED
|
@@ -4,10 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* pr-shepherd [PR]
|
|
7
|
-
* pr-shepherd check [PR]
|
|
8
7
|
* pr-shepherd resolve [PR]
|
|
9
8
|
* pr-shepherd iterate [PR]
|
|
10
|
-
* pr-shepherd status PR1 [PR2 …]
|
|
11
9
|
*/
|
|
12
10
|
import { main } from "./cli-parser.mjs";
|
|
13
11
|
function formatCause(cause, seen = new Set(), depth = 0) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Interpretation order for `status` — first match wins:
|
|
8
8
|
* 1. mergeable == CONFLICTING → CONFLICTS (hard conflict even for drafts)
|
|
9
|
-
* 2.
|
|
9
|
+
* 2. blockingBotReviewInProgress → BLOCKED
|
|
10
10
|
* 3. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts, even for drafts)
|
|
11
11
|
* 4. isDraft → DRAFT
|
|
12
12
|
* 5. mergeStateStatus BEHIND → BEHIND
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { loadConfig } from "../config/load.mjs";
|
|
19
19
|
export function deriveMergeStatus(pr) {
|
|
20
|
-
const
|
|
20
|
+
const blockingBotReviewInProgress = detectBlockingBotReview(pr);
|
|
21
21
|
let status;
|
|
22
22
|
if (pr.mergeable === "CONFLICTING") {
|
|
23
23
|
status = "CONFLICTS";
|
|
24
24
|
}
|
|
25
|
-
else if (
|
|
25
|
+
else if (blockingBotReviewInProgress) {
|
|
26
26
|
status = "BLOCKED";
|
|
27
27
|
}
|
|
28
28
|
else if (pr.mergeStateStatus === "DIRTY") {
|
|
@@ -53,14 +53,14 @@ export function deriveMergeStatus(pr) {
|
|
|
53
53
|
isDraft: pr.isDraft,
|
|
54
54
|
mergeable: pr.mergeable,
|
|
55
55
|
reviewDecision: pr.reviewDecision,
|
|
56
|
-
|
|
56
|
+
blockingBotReviewInProgress,
|
|
57
57
|
mergeStateStatus: pr.mergeStateStatus,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
|
-
//
|
|
61
|
+
// Blocking bot review detection
|
|
62
62
|
// ---------------------------------------------------------------------------
|
|
63
|
-
function
|
|
63
|
+
function detectBlockingBotReview(pr) {
|
|
64
64
|
// A blocking bot review is "in progress" when:
|
|
65
65
|
// 1. Any reviewRequest has a login starting with one of the configured prefixes, OR
|
|
66
66
|
// 2. Any latestReview has such a login AND state == "PENDING"
|
package/bin/reporters/agent.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These strip fields that are always-false by the time items reach iterate
|
|
5
5
|
* (isResolved, isOutdated, isMinimized, createdAtUnix) and check metadata the
|
|
6
|
-
*
|
|
6
|
+
* agent/iterate prompt never reads (event, status, category).
|
|
7
7
|
* conclusion is preserved on AgentCheck so the formatter can branch on run-level conclusions.
|
|
8
8
|
* detailsUrl is preserved in AgentCheck as a fallback for external status checks.
|
|
9
|
-
* The original domain types are preserved
|
|
9
|
+
* The original domain types are preserved as internal snapshot types.
|
|
10
10
|
*/
|
|
11
11
|
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
12
12
|
export function toAgentThread(t) {
|
|
@@ -19,13 +19,20 @@ export function toAgentThread(t) {
|
|
|
19
19
|
t.startLine !== null &&
|
|
20
20
|
t.startLine !== t.line && { startLine: t.startLine }),
|
|
21
21
|
author: t.author,
|
|
22
|
+
...(t.authorType !== undefined && { authorType: t.authorType }),
|
|
22
23
|
body: t.body,
|
|
23
24
|
url: t.url,
|
|
24
25
|
...(suggestion !== undefined && { suggestion }),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
export function toAgentComment(c) {
|
|
28
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
id: c.id,
|
|
31
|
+
author: c.author,
|
|
32
|
+
...(c.authorType !== undefined && { authorType: c.authorType }),
|
|
33
|
+
body: c.body,
|
|
34
|
+
url: c.url,
|
|
35
|
+
};
|
|
29
36
|
}
|
|
30
37
|
export function toAgentCheck(c) {
|
|
31
38
|
if (c.conclusion === "SKIPPED" || c.conclusion === "NEUTRAL") {
|
|
@@ -32,6 +32,15 @@ export async function readStallState(key) {
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/** Clear stall state so the next invocation starts a fresh timer (fire-and-forget — never throws). */
|
|
36
|
+
export async function clearStallState(key) {
|
|
37
|
+
try {
|
|
38
|
+
await unlink(resolvePath(key));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Best-effort — file may not exist.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
35
44
|
/** Write stall state (fire-and-forget — never throws). */
|
|
36
45
|
export async function writeStallState(key, state) {
|
|
37
46
|
let tmp;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/**",
|
|
13
|
-
"plugin/**",
|
|
14
13
|
"plugins/**",
|
|
15
14
|
"plugins/**/.codex-plugin/**",
|
|
16
15
|
".claude-plugin/**",
|
|
@@ -36,12 +35,12 @@
|
|
|
36
35
|
},
|
|
37
36
|
"scripts": {
|
|
38
37
|
"build": "node scripts/build.mjs",
|
|
39
|
-
"prepare": "node scripts/install-husky.mjs &&
|
|
38
|
+
"prepare": "node scripts/install-husky.mjs && npm run build",
|
|
40
39
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
41
40
|
"typecheck": "tsc --noEmit",
|
|
42
|
-
"lint": "oxlint src/
|
|
43
|
-
"format": "oxfmt src/
|
|
44
|
-
"format:check": "oxfmt --check src/
|
|
41
|
+
"lint": "oxlint src/ plugins/ .agents/plugins/",
|
|
42
|
+
"format": "oxfmt src/ plugins/ .agents/plugins/ docs/ README.md",
|
|
43
|
+
"format:check": "oxfmt --check src/ plugins/ .agents/plugins/ docs/ README.md",
|
|
45
44
|
"test": "vitest run",
|
|
46
45
|
"test:coverage": "vitest run --coverage",
|
|
47
46
|
"test:watch": "vitest"
|
|
@@ -1,43 +1,48 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pr-shepherd
|
|
3
|
-
description: '
|
|
3
|
+
description: 'Iterate a GitHub pull request to completion with pr-shepherd. Use for requests like "use pr-shepherd", "iterate PR #123", or "run pr-shepherd until this PR is ready".'
|
|
4
|
+
user-invocable: true
|
|
5
|
+
argument-hint: "[PR number or URL]"
|
|
6
|
+
allowed-tools: ["Bash", "Read", "Grep", "Edit", "Write", "Glob", "Skill"]
|
|
4
7
|
---
|
|
5
8
|
|
|
6
9
|
# pr-shepherd
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
One-tick dispatcher for iterating a PR to completion.
|
|
9
12
|
|
|
10
|
-
##
|
|
13
|
+
## Arguments: $ARGUMENTS
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
- If the user provides a PR number, use it.
|
|
14
|
-
- If the user provides a GitHub PR URL, extract the PR number.
|
|
15
|
-
- If no PR is provided, infer it from the current branch with:
|
|
16
|
-
`gh pr view --json number --jq .number`
|
|
17
|
-
- If no PR is found, report that and stop.
|
|
15
|
+
## Steps
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
- `
|
|
17
|
+
1. **Resolve PR number:**
|
|
18
|
+
- If `$ARGUMENTS` contains a PR number, use it.
|
|
19
|
+
- If `$ARGUMENTS` contains a GitHub PR URL, extract the number.
|
|
20
|
+
- Otherwise, infer: `gh pr view --json number --jq .number`
|
|
21
|
+
- If no PR found, report an error and stop.
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
- Prefer `package.json` `packageManager`: `pnpm@...` -> `pnpm exec`, `yarn@...` -> `yarn run`, `npm@...` -> `npx --no-install`.
|
|
24
|
-
- If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` -> `pnpm exec`, `yarn.lock` -> `yarn run`, `package-lock.json` or no signal -> `npx --no-install`.
|
|
25
|
-
- Example: in `~/filaments`, use `pnpm exec pr-shepherd ...` because the root package declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml`.
|
|
23
|
+
2. **Short-circuit if merged or closed:**
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
- In other repositories, run through the selected package runner so Codex does not install packages implicitly. If the package is missing, tell the user to install `pr-shepherd` with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, or `npm install --save-dev pr-shepherd`.
|
|
25
|
+
```bash
|
|
26
|
+
gh pr view <N> --json state --jq '.state'
|
|
27
|
+
```
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
- `<runner> pr-shepherd PR_NUMBER`
|
|
29
|
+
If `MERGED` or `CLOSED`, output: `PR #N is already merged/closed. Nothing to do.` and stop.
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
3. **Select the package runner** from the target repository root:
|
|
32
|
+
- Prefer `package.json` `packageManager`: `pnpm@...` → `pnpm exec`, `yarn@...` → `yarn run`, `npm@...` → `npx`.
|
|
33
|
+
- If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` → `pnpm exec`, `yarn.lock` → `yarn run`, `package-lock.json` or no signal → `npx`.
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
4. **Run one iterate tick:**
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
If the package is missing in the target repository, tell the user to install pr-shepherd with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, or `npm install --save-dev pr-shepherd`.
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
```bash
|
|
40
|
+
<runner> pr-shepherd <N>
|
|
41
|
+
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Print the full output. Follow the `## Instructions` section exactly.
|
|
44
|
+
|
|
45
|
+
5. **Stop conditions:**
|
|
46
|
+
- Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
|
|
47
|
+
- 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` to sleep/wait and rerun.
|
package/bin/commands/iterate.mjs
DELETED
package/bin/commands/monitor.mjs
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { getCurrentPrNumber } from "../github/client.mjs";
|
|
2
|
-
import { loadConfig } from "../config/load.mjs";
|
|
3
|
-
import { joinSections } from "../util/markdown.mjs";
|
|
4
|
-
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
5
|
-
export async function runMonitor(opts) {
|
|
6
|
-
const config = loadConfig();
|
|
7
|
-
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
8
|
-
if (prNumber === null) {
|
|
9
|
-
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
10
|
-
}
|
|
11
|
-
const { interval } = config.watch;
|
|
12
|
-
if (typeof interval !== "string" || !/^\d+[smhd]$/.test(interval)) {
|
|
13
|
-
throw new Error(`Invalid config: watch.interval must be a duration string like "4m" or "1h", got ${JSON.stringify(interval)}`);
|
|
14
|
-
}
|
|
15
|
-
// No space after `#` — `# text` is a CommonMark ATX heading; `#text` is not.
|
|
16
|
-
// Trailing `:` prevents substring false positives: without it, the dedup grep
|
|
17
|
-
// for pr=1 would match a cron prompt for pr=135. Both the CronList check in
|
|
18
|
-
// step 1 of formatMonitorResult's ## Instructions and the in-prompt Self-dedup
|
|
19
|
-
// block depend on this exact string — don't change the format.
|
|
20
|
-
const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
|
|
21
|
-
const loopArgs = interval;
|
|
22
|
-
const reusableCommand = buildIterateCommand(prNumber, opts.readyDelaySuffix, config.cli?.runner);
|
|
23
|
-
const loopPrompt = buildLoopPrompt(prNumber, loopTag, reusableCommand, opts.runtime ?? "claude", config.cli?.runner);
|
|
24
|
-
return {
|
|
25
|
-
prNumber,
|
|
26
|
-
loopTag,
|
|
27
|
-
loopArgs,
|
|
28
|
-
loopPrompt,
|
|
29
|
-
reusableCommand,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Formatters
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
export function formatMonitorResult(result, opts) {
|
|
36
|
-
const { prNumber, loopTag, loopArgs, loopPrompt } = result;
|
|
37
|
-
const runtime = opts?.runtime ?? "claude";
|
|
38
|
-
const sections = [
|
|
39
|
-
[
|
|
40
|
-
`# PR #${prNumber} [MONITOR]`,
|
|
41
|
-
"",
|
|
42
|
-
`Loop tag: \`${loopTag}\``,
|
|
43
|
-
runtime === "codex" ? null : `Loop args: \`${loopArgs}\``,
|
|
44
|
-
]
|
|
45
|
-
.filter((line) => line !== null)
|
|
46
|
-
.join("\n"),
|
|
47
|
-
runtime === "codex" ? `Reusable command: \`${result.reusableCommand}\`` : null,
|
|
48
|
-
"## Loop prompt",
|
|
49
|
-
loopPrompt,
|
|
50
|
-
"## Instructions",
|
|
51
|
-
buildMonitorInstructions(result, runtime)
|
|
52
|
-
.map((inst, i) => `${i + 1}. ${inst}`)
|
|
53
|
-
.join("\n"),
|
|
54
|
-
];
|
|
55
|
-
return joinSections(sections);
|
|
56
|
-
}
|
|
57
|
-
export function formatMonitorJson(result, opts) {
|
|
58
|
-
const runtime = opts?.runtime ?? "claude";
|
|
59
|
-
if (runtime === "codex") {
|
|
60
|
-
return {
|
|
61
|
-
prNumber: result.prNumber,
|
|
62
|
-
loopTag: result.loopTag,
|
|
63
|
-
loopPrompt: result.loopPrompt,
|
|
64
|
-
reusableCommand: result.reusableCommand,
|
|
65
|
-
instructions: buildMonitorInstructions(result, runtime),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
prNumber: result.prNumber,
|
|
70
|
-
loopTag: result.loopTag,
|
|
71
|
-
loopArgs: result.loopArgs,
|
|
72
|
-
loopPrompt: result.loopPrompt,
|
|
73
|
-
instructions: buildMonitorInstructions(result, runtime),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// Internal
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
function validateReadyDelaySuffix(readyDelaySuffix) {
|
|
80
|
-
if (readyDelaySuffix === undefined)
|
|
81
|
-
return undefined;
|
|
82
|
-
const trimmed = readyDelaySuffix.trim();
|
|
83
|
-
if (!/^\d+(?:m|min|minutes?|h|hours?)$/.test(trimmed)) {
|
|
84
|
-
throw new Error(`Invalid --ready-delay: ${readyDelaySuffix}. Expected a duration like 5m, 2h, 10m, or 1h.`);
|
|
85
|
-
}
|
|
86
|
-
return trimmed;
|
|
87
|
-
}
|
|
88
|
-
function buildIterateCommand(prNumber, readyDelaySuffix, runner) {
|
|
89
|
-
const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
|
|
90
|
-
return buildPrShepherdCommand([String(prNumber), ...(validatedDelay ? ["--ready-delay", validatedDelay] : [])], { runner }).text;
|
|
91
|
-
}
|
|
92
|
-
function buildLoopPrompt(prNumber, loopTag, iterateCmd, runtime = "claude", runner) {
|
|
93
|
-
if (runtime === "codex") {
|
|
94
|
-
return [
|
|
95
|
-
loopTag,
|
|
96
|
-
"",
|
|
97
|
-
"**IMPORTANT — Codex recurrence rules:**",
|
|
98
|
-
"- Run the command below once and follow its `## Instructions` exactly.",
|
|
99
|
-
"- If the output tells you to continue the active Codex goal, pick a fresh sleep/timeout between 1 and 4 minutes, wait that long, and rerun the reusable command from the monitor output.",
|
|
100
|
-
"- Stop only when Shepherd emits `[CANCEL]` because the ready-delay completed or the PR was merged/closed, or when Shepherd emits `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).",
|
|
101
|
-
`- Do not call \`/loop\`, \`ScheduleWakeup\`, \`CronCreate\`, or \`${buildPrShepherdCommand(["monitor", String(prNumber)], { runner }).text}\`; Codex recurrence is explicit \`iterate\` command cycles.`,
|
|
102
|
-
"",
|
|
103
|
-
"Run in a single Bash call:",
|
|
104
|
-
` ${iterateCmd}`,
|
|
105
|
-
"",
|
|
106
|
-
`Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with \`# PR #${prNumber} [\`), report the first line of stderr and stop so the user can retry.`,
|
|
107
|
-
"",
|
|
108
|
-
"The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Every output ends with a `## Instructions` section — follow those numbered steps exactly.",
|
|
109
|
-
].join("\n");
|
|
110
|
-
}
|
|
111
|
-
return [
|
|
112
|
-
loopTag,
|
|
113
|
-
"",
|
|
114
|
-
"**IMPORTANT — recurrence rules:**",
|
|
115
|
-
"- **Do NOT call `ScheduleWakeup` or `/loop`.** This session is fired by a recurring cron job. Either call creates a duplicate runner, causing concurrent git operations and `.git/index.lock` collisions.",
|
|
116
|
-
"- End the turn cleanly after completing the actions below. The cron job handles the next fire.",
|
|
117
|
-
"",
|
|
118
|
-
`**Self-dedup:** Run \`CronList\`. If more than one job contains \`${loopTag}\`, keep the lowest job ID and \`CronDelete\` the rest (ignore errors — a concurrent runner may have already deleted them).`,
|
|
119
|
-
"",
|
|
120
|
-
"Run in a single Bash call:",
|
|
121
|
-
` ${iterateCmd}`,
|
|
122
|
-
"",
|
|
123
|
-
`Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with \`# PR #${prNumber} [\`), log the first line of stderr and continue — do not cancel the loop. The next cron fire will retry.`,
|
|
124
|
-
"",
|
|
125
|
-
"The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Every output ends with a `## Instructions` section — follow those numbered steps exactly.",
|
|
126
|
-
].join("\n");
|
|
127
|
-
}
|
|
128
|
-
function buildMonitorInstructions(result, runtime) {
|
|
129
|
-
if (runtime === "codex") {
|
|
130
|
-
return [
|
|
131
|
-
"Run the `## Loop prompt` body once inline now.",
|
|
132
|
-
`For an active Codex goal, keep cycling with \`${result.reusableCommand}\` by picking a fresh sleep/timeout between 1 and 4 minutes before each rerun until a terminal condition is reached. Codex does not create a \`/loop\` monitor.`,
|
|
133
|
-
];
|
|
134
|
-
}
|
|
135
|
-
return [
|
|
136
|
-
`Run \`CronList\`. If any job's prompt contains \`${result.loopTag}\`, run the \`## Loop prompt\` body once inline (as if it were a cron tick) then stop — do not create a duplicate loop.`,
|
|
137
|
-
"Otherwise, invoke the `/loop` skill via the Skill tool. Build the `args` parameter as: only the value inside the backticks on the `Loop args` line above (the interval — not the `Loop args:` label), then a blank line, then the full `## Loop prompt` body.",
|
|
138
|
-
];
|
|
139
|
-
}
|