pr-shepherd 0.25.4 → 0.26.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 +23 -0
- package/bin/checks/classify.mjs +10 -2
- package/bin/classify/apply.mjs +110 -0
- package/bin/classify/loader.mjs +85 -0
- package/bin/classify/types.mjs +1 -0
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.mjs +1 -0
- package/bin/cli/fix-formatter.mjs +3 -4
- package/bin/cli/help-command-pages.mjs +20 -1
- package/bin/cli/help-top-page.mjs +3 -0
- package/bin/cli/iterate-formatter.mjs +42 -7
- package/bin/cli/iterate-instructions.mjs +3 -22
- package/bin/cli/iterate-lean.mjs +32 -9
- package/bin/cli/journal-handler.mjs +79 -0
- package/bin/cli/poll-handler.mjs +1 -0
- package/bin/cli-parser.mjs +5 -23
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +36 -8
- package/bin/commands/iterate/classify.mjs +14 -5
- package/bin/commands/iterate/fix-code.mjs +2 -2
- package/bin/commands/iterate/helpers.mjs +15 -0
- package/bin/commands/iterate/index.mjs +8 -2
- package/bin/commands/iterate/render.mjs +15 -9
- package/bin/commands/iterate/stall.mjs +4 -0
- package/bin/commands/journal/index.mjs +26 -0
- package/bin/commands/journal/transform.mjs +112 -0
- package/bin/commands/poll.mjs +45 -3
- package/bin/commands/ready-mergeability.mjs +2 -2
- package/bin/commands/shepherd-journal.mjs +2 -2
- package/bin/config/load.mjs +7 -0
- package/bin/config.json +1 -0
- package/bin/github/activity.mjs +57 -0
- package/bin/github/batch-parsers.mjs +20 -34
- package/bin/github/branch-protection.mjs +12 -0
- package/bin/github/client.mjs +17 -1
- package/bin/github/gql/batch-pr.gql +8 -0
- package/bin/github/gql/get-pr-body.gql +8 -0
- package/bin/github/gql/update-pr-body.gql +7 -0
- package/bin/github/queries.mjs +4 -0
- package/bin/types/activity.mjs +1 -0
- package/bin/types/iterate.mjs +0 -1
- package/bin/types.mjs +1 -0
- package/package.json +12 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +7 -22
- package/src/classify/types.mts +48 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { runJournal } from "../commands/journal/index.mjs";
|
|
2
|
+
import { parsePrNumber } from "./args.mjs";
|
|
3
|
+
import { USAGE } from "./help.mjs";
|
|
4
|
+
export async function handleJournal(args) {
|
|
5
|
+
for (const a of args) {
|
|
6
|
+
if (!a.startsWith("--"))
|
|
7
|
+
continue;
|
|
8
|
+
if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
|
|
9
|
+
continue;
|
|
10
|
+
process.stderr.write(`pr-shepherd: journal: unknown flag: "${a}"\n`);
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const { prNumber, extra } = parseJournalArgs(args);
|
|
15
|
+
const rawItem = extra[0];
|
|
16
|
+
if (!rawItem) {
|
|
17
|
+
process.stderr.write(`${USAGE.journal}\n`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const dryRun = args.includes("--dry-run");
|
|
22
|
+
const jsonOut = args.some((a) => a === "--format=json") ||
|
|
23
|
+
args.some((a, i) => a === "--format" && args[i + 1] === "json");
|
|
24
|
+
try {
|
|
25
|
+
const result = await runJournal({ prNumber, rawItem, dryRun });
|
|
26
|
+
if (jsonOut) {
|
|
27
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
process.stdout.write(`${formatJournalResult(result)}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
process.stderr.write(`pr-shepherd: journal: ${String(e)}\n`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function parseJournalArgs(args) {
|
|
39
|
+
const flagConsumedIndices = new Set();
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const a = args[i];
|
|
42
|
+
if (a === "--format" && i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
43
|
+
flagConsumedIndices.add(i);
|
|
44
|
+
flagConsumedIndices.add(i + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let prNumber;
|
|
48
|
+
const extra = [];
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
if (flagConsumedIndices.has(i))
|
|
51
|
+
continue;
|
|
52
|
+
const a = args[i];
|
|
53
|
+
if (a.startsWith("--"))
|
|
54
|
+
continue;
|
|
55
|
+
if (prNumber === undefined) {
|
|
56
|
+
const n = parsePrNumber(a);
|
|
57
|
+
if (n !== null) {
|
|
58
|
+
prNumber = n;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
extra.push(a);
|
|
63
|
+
}
|
|
64
|
+
return { prNumber, extra };
|
|
65
|
+
}
|
|
66
|
+
function formatJournalResult(result) {
|
|
67
|
+
if (result.dryRun) {
|
|
68
|
+
const lines = ["Dry run — no body change written."];
|
|
69
|
+
if (result.previewBody !== undefined) {
|
|
70
|
+
lines.push("", result.previewBody);
|
|
71
|
+
}
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
if (!result.mutated)
|
|
75
|
+
return "No change — entry already present.";
|
|
76
|
+
if (!result.sectionExisted)
|
|
77
|
+
return `Created ## Shepherd Journal section in PR #${result.prNumber}.`;
|
|
78
|
+
return `Appended to ## Shepherd Journal in PR #${result.prNumber}.`;
|
|
79
|
+
}
|
package/bin/cli/poll-handler.mjs
CHANGED
package/bin/cli-parser.mjs
CHANGED
|
@@ -1,26 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI argument parsing and subcommand dispatch for pr-shepherd.
|
|
3
|
-
*
|
|
4
|
-
* Usage:
|
|
5
|
-
* pr-shepherd --version
|
|
6
|
-
* pr-shepherd [PR] [--interval 45s] [--timeout 4m] [--format text|json] [--ready-delay Nm]
|
|
7
|
-
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
8
|
-
* [--no-auto-cancel-actionable]
|
|
9
|
-
* pr-shepherd resolve [PR] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
|
|
10
|
-
* [--reply-thread-ids A,B] [--dismiss-review-ids Q]
|
|
11
|
-
* [--message MSG] [--require-sha SHA]
|
|
12
|
-
* pr-shepherd commit-suggestion [PR] --thread-id ID --message MSG [--description DESC]
|
|
13
|
-
* [--format text|json]
|
|
14
|
-
* pr-shepherd mark-files-as-viewed [PR] [files...] [--tests] [--match REGEX]
|
|
15
|
-
* [--format text|json]
|
|
16
|
-
* pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
|
|
17
|
-
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
18
|
-
* [--no-auto-cancel-actionable]
|
|
19
|
-
* pr-shepherd poll [PR] [--interval 30s] [--timeout 5m] [--format text|json] [--ready-delay Nm]
|
|
20
|
-
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
21
|
-
* [--no-auto-cancel-actionable]
|
|
22
|
-
* pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]
|
|
23
|
-
*/
|
|
1
|
+
/** CLI argument parsing and subcommand dispatch for pr-shepherd. See --help for usage. */
|
|
24
2
|
import { readFileSync } from "node:fs";
|
|
25
3
|
import { runResolveMutate } from "./commands/resolve.mjs";
|
|
26
4
|
import { runLogFile } from "./commands/log-file.mjs";
|
|
@@ -29,6 +7,7 @@ import { isDefaultPollInvocation, validateDefaultPollArgs } from "./cli/default-
|
|
|
29
7
|
import { USAGE, maybePrintHelp } from "./cli/help.mjs";
|
|
30
8
|
import { formatMutateResult } from "./cli/formatters.mjs";
|
|
31
9
|
import { handleClean, handleCommitSuggestion, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
|
|
10
|
+
import { handleJournal } from "./cli/journal-handler.mjs";
|
|
32
11
|
import { handlePoll } from "./cli/poll-handler.mjs";
|
|
33
12
|
import { warnPrrcThreadIds, validateRequireSha, rejectPrrcMinimizeIds, } from "./cli/resolve-validators.mjs";
|
|
34
13
|
import { setupLog } from "./log/setup.mjs";
|
|
@@ -91,6 +70,9 @@ export async function main(argv) {
|
|
|
91
70
|
case "clean":
|
|
92
71
|
await handleClean(args.slice(1));
|
|
93
72
|
break;
|
|
73
|
+
case "journal":
|
|
74
|
+
await handleJournal(args.slice(1));
|
|
75
|
+
break;
|
|
94
76
|
default:
|
|
95
77
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
96
78
|
process.stderr.write(`${USAGE.top}\n`);
|
package/bin/commands/check.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
|
|
|
16
16
|
import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
|
|
17
17
|
import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
|
|
18
18
|
import { normalizeBotUsernames } from "../comments/authors.mjs";
|
|
19
|
+
import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
|
|
20
|
+
import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
|
|
19
21
|
export async function runCheck(opts) {
|
|
20
22
|
const repo = await getRepoInfo();
|
|
21
23
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -46,10 +48,13 @@ export async function runCheck(opts) {
|
|
|
46
48
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
47
49
|
const seenMap = await loadSeenMap(stateKey);
|
|
48
50
|
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
51
|
+
const ruleSet = await loadRules(discoverRuleFiles(process.cwd()));
|
|
52
|
+
const classifyIndex = buildClassifyIndex(ruleSet, batchData);
|
|
53
|
+
const partition = partitionBatch(classifyIndex, batchData);
|
|
49
54
|
const triaged = await attachUnseenCheckAnnotations(triagedBase, seenMap, prNumber);
|
|
50
|
-
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
|
|
51
|
-
const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments, botUsernames);
|
|
52
|
-
const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap, botUsernames);
|
|
55
|
+
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized && !partition.suppressedCommentIds.has(c.id));
|
|
56
|
+
const visibleCommentClassification = classifyVisibleComments(batchData.comments.filter((c) => !partition.suppressedCommentIds.has(c.id)), seenMap, config.iterate.minimizeComments, botUsernames);
|
|
57
|
+
const threadVisibility = classifyThreadVisibility(batchData.reviewThreads.filter((t) => !partition.suppressedThreadIds.has(t.id)), seenMap, botUsernames);
|
|
53
58
|
const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
|
|
54
59
|
const cls = classifyItem(c.id, c.body, seenMap);
|
|
55
60
|
if (cls === "unchanged")
|
|
@@ -60,7 +65,8 @@ export async function runCheck(opts) {
|
|
|
60
65
|
const firstLookSummaries = [];
|
|
61
66
|
const editedSummaries = [];
|
|
62
67
|
const seenSummaries = [];
|
|
63
|
-
|
|
68
|
+
const unseenReviewSummaries = batchData.reviewSummaries.filter((r) => !partition.suppressedReviewSummaryIds.has(r.id));
|
|
69
|
+
for (const r of unseenReviewSummaries) {
|
|
64
70
|
const cls = classifyItem(r.id, r.body, seenMap);
|
|
65
71
|
if (cls === "new")
|
|
66
72
|
firstLookSummaries.push(r);
|
|
@@ -69,7 +75,7 @@ export async function runCheck(opts) {
|
|
|
69
75
|
else
|
|
70
76
|
seenSummaries.push(r);
|
|
71
77
|
}
|
|
72
|
-
const changesRequestedReviewVisibility = classifyReviewsForDisplay(batchData.changesRequestedReviews, seenMap);
|
|
78
|
+
const changesRequestedReviewVisibility = classifyReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap);
|
|
73
79
|
const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
|
|
74
80
|
await Promise.allSettled([
|
|
75
81
|
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
@@ -78,14 +84,26 @@ export async function runCheck(opts) {
|
|
|
78
84
|
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
79
85
|
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
80
86
|
...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
87
|
+
...batchData.comments
|
|
88
|
+
.filter((c) => partition.suppressedCommentIds.has(c.id))
|
|
89
|
+
.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
90
|
+
...batchData.reviewThreads
|
|
91
|
+
.filter((t) => partition.suppressedThreadIds.has(t.id))
|
|
92
|
+
.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
93
|
+
...batchData.reviewSummaries
|
|
94
|
+
.filter((r) => partition.suppressedReviewSummaryIds.has(r.id))
|
|
95
|
+
.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
96
|
+
...batchData.changesRequestedReviews
|
|
97
|
+
.filter((r) => partition.suppressedChangesRequestedIds.has(r.id))
|
|
98
|
+
.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
81
99
|
]);
|
|
82
100
|
await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
|
|
83
101
|
const changesRequestedReviews = changesRequestedReviewVisibility.visible;
|
|
84
|
-
const changesRequestedReviewCount = batchData.changesRequestedReviews.length;
|
|
102
|
+
const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)).length;
|
|
85
103
|
const approvedReviews = approvedReviewVisibility.visible;
|
|
86
104
|
let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
|
|
87
105
|
if (status === "READY" && !didRefreshMergeability) {
|
|
88
|
-
const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length);
|
|
106
|
+
const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount);
|
|
89
107
|
batchData = refreshed.batchData;
|
|
90
108
|
mergeStatus = refreshed.mergeStatus;
|
|
91
109
|
status = refreshed.status;
|
|
@@ -113,10 +131,16 @@ export async function runCheck(opts) {
|
|
|
113
131
|
autoResolved: [],
|
|
114
132
|
autoResolveErrors: [],
|
|
115
133
|
firstLook: threadVisibility.firstLookThreads,
|
|
134
|
+
...(partition.ruleAutoResolveThreadIds.length > 0
|
|
135
|
+
? { ruleAutoResolveIds: partition.ruleAutoResolveThreadIds }
|
|
136
|
+
: undefined),
|
|
116
137
|
},
|
|
117
138
|
comments: {
|
|
118
139
|
actionable: visibleCommentClassification.actionable,
|
|
119
|
-
minimizeIds:
|
|
140
|
+
minimizeIds: [
|
|
141
|
+
...visibleCommentClassification.minimizeIds,
|
|
142
|
+
...partition.ruleAutoResolveCommentIds,
|
|
143
|
+
],
|
|
120
144
|
firstLook: firstLookComments,
|
|
121
145
|
},
|
|
122
146
|
changesRequestedReviews,
|
|
@@ -124,6 +148,10 @@ export async function runCheck(opts) {
|
|
|
124
148
|
firstLookSummaries,
|
|
125
149
|
editedSummaries,
|
|
126
150
|
approvedReviews,
|
|
151
|
+
...(partition.ruleAutoResolveReviewSummaryIds.length > 0
|
|
152
|
+
? { ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds }
|
|
153
|
+
: undefined),
|
|
127
154
|
branchProtection: batchData.branchProtection,
|
|
155
|
+
activity: batchData.activity,
|
|
128
156
|
};
|
|
129
157
|
}
|
|
@@ -12,7 +12,7 @@ function dedupeIds(ids) {
|
|
|
12
12
|
}
|
|
13
13
|
return out;
|
|
14
14
|
}
|
|
15
|
-
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all", botUsernames = new Set(), unresolvedThreads = []) {
|
|
15
|
+
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all", botUsernames = new Set(), unresolvedThreads = [], ruleAutoResolveIds = []) {
|
|
16
16
|
const blockedReviewIds = new Set(unresolvedThreads.flatMap((t) => (t.reviewId !== undefined ? [t.reviewId] : [])));
|
|
17
17
|
// First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
|
|
18
18
|
// they are already minimized server-side (body changed after minimize was applied).
|
|
@@ -21,6 +21,11 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
21
21
|
.filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
|
|
22
22
|
.filter((r) => !blockedReviewIds.has(r.id))
|
|
23
23
|
.map((r) => r.id);
|
|
24
|
+
// Rule-matched summaries are already suppressed from agent output; bypass normal policy gates.
|
|
25
|
+
for (const id of ruleAutoResolveIds) {
|
|
26
|
+
if (!minimizeIds.includes(id))
|
|
27
|
+
minimizeIds.push(id);
|
|
28
|
+
}
|
|
24
29
|
if (minimizeApprovals) {
|
|
25
30
|
const surfacedApprovals = [];
|
|
26
31
|
for (const r of approvals) {
|
|
@@ -43,14 +48,18 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
43
48
|
surfacedApprovals: approvals,
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
|
-
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber, botUsernames = new Set()) {
|
|
51
|
+
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber, botUsernames = new Set(), ruleAutoResolveThreadIds = []) {
|
|
47
52
|
const allThreads = [...threads, ...resolutionOnlyThreads];
|
|
48
53
|
const replyThreadIds = dedupeIds(allThreads
|
|
49
54
|
.filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
|
|
50
55
|
.map((t) => t.id));
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
// Rule-matched threads bypass the author check (human-author guard still applies in resolve-mutate).
|
|
57
|
+
const resolveThreadIds = dedupeIds([
|
|
58
|
+
...allThreads
|
|
59
|
+
.filter((t) => !isHumanAuthor(t) || isConfiguredBotAuthor(t, botUsernames))
|
|
60
|
+
.map((t) => t.id),
|
|
61
|
+
...ruleAutoResolveThreadIds,
|
|
62
|
+
]);
|
|
54
63
|
const hasReply = replyThreadIds.length > 0;
|
|
55
64
|
const hasResolveOrMinimize = resolveThreadIds.length > 0 || allCommentIds.length > 0;
|
|
56
65
|
if (hasReply && hasResolveOrMinimize) {
|
|
@@ -25,7 +25,7 @@ function nextFixAttempts(stored, headSha, threads) {
|
|
|
25
25
|
return { threadAttempts, threadBodyHashes };
|
|
26
26
|
}
|
|
27
27
|
export async function handleFixCode(ctx) {
|
|
28
|
-
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, } = ctx;
|
|
28
|
+
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
|
|
29
29
|
const failingChecks = report.checks.failing;
|
|
30
30
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
31
31
|
const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
|
|
@@ -76,7 +76,7 @@ export async function handleFixCode(ctx) {
|
|
|
76
76
|
const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
|
|
77
77
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
78
78
|
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
79
|
-
const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, botUsernames);
|
|
79
|
+
const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, botUsernames, ruleAutoResolveThreadIds);
|
|
80
80
|
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
81
81
|
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
82
82
|
// prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
|
|
@@ -59,6 +59,15 @@ export function buildRelevantChecks(report) {
|
|
|
59
59
|
});
|
|
60
60
|
return [...passing, ...failing];
|
|
61
61
|
}
|
|
62
|
+
export function buildActiveChecks(report) {
|
|
63
|
+
return report.checks.inProgress.map((c) => ({
|
|
64
|
+
name: c.name,
|
|
65
|
+
status: c.status,
|
|
66
|
+
runId: c.runId,
|
|
67
|
+
detailsUrl: c.detailsUrl || null,
|
|
68
|
+
...(c.summary !== undefined && { summary: c.summary }),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
62
71
|
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
63
72
|
export async function tryCancelRun(runId, owner, repo) {
|
|
64
73
|
try {
|
|
@@ -86,6 +95,12 @@ export async function getCurrentHeadSha() {
|
|
|
86
95
|
export function buildWaitLog(base) {
|
|
87
96
|
const { summary, remainingSeconds } = base;
|
|
88
97
|
const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
|
|
98
|
+
if ((base.inProgressChecks?.length ?? 0) > 0) {
|
|
99
|
+
parts.push(`active checks: ${base
|
|
100
|
+
.inProgressChecks.slice(0, 5)
|
|
101
|
+
.map((c) => c.name)
|
|
102
|
+
.join(", ")}`);
|
|
103
|
+
}
|
|
89
104
|
switch (base.mergeStatus) {
|
|
90
105
|
case "BLOCKED":
|
|
91
106
|
if (base.reviewDecision === "REVIEW_REQUIRED")
|
|
@@ -4,7 +4,7 @@ import { getCurrentPrNumber } from "../../github/client.mjs";
|
|
|
4
4
|
import { graphql } from "../../github/http.mjs";
|
|
5
5
|
import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
|
|
6
6
|
import { loadConfig } from "../../config/load.mjs";
|
|
7
|
-
import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildWaitLog } from "./helpers.mjs";
|
|
7
|
+
import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, } from "./helpers.mjs";
|
|
8
8
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
@@ -49,6 +49,8 @@ export async function runIterate(opts) {
|
|
|
49
49
|
baseBranch: report.baseBranch,
|
|
50
50
|
branchProtection: report.branchProtection,
|
|
51
51
|
checks: buildRelevantChecks(report),
|
|
52
|
+
inProgressChecks: buildActiveChecks(report),
|
|
53
|
+
activity: report.activity,
|
|
52
54
|
action: "cancel",
|
|
53
55
|
reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
|
|
54
56
|
log: `CANCEL: PR #${report.pr} is ${state} — stopping`,
|
|
@@ -58,10 +60,11 @@ export async function runIterate(opts) {
|
|
|
58
60
|
firstLook: report.firstLookSummaries,
|
|
59
61
|
seen: report.reviewSummaries,
|
|
60
62
|
edited: report.editedSummaries,
|
|
61
|
-
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly]);
|
|
63
|
+
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly], report.ruleAutoResolveReviewSummaryIds);
|
|
62
64
|
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
63
65
|
report.threads.resolutionOnly.length > 0 ||
|
|
64
66
|
report.threads.firstLook.length > 0 ||
|
|
67
|
+
(report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
|
|
65
68
|
report.comments.actionable.length > 0 ||
|
|
66
69
|
(report.comments.minimizeIds?.length ?? 0) > 0 ||
|
|
67
70
|
report.comments.firstLook.length > 0 ||
|
|
@@ -89,6 +92,8 @@ export async function runIterate(opts) {
|
|
|
89
92
|
baseBranch: report.baseBranch,
|
|
90
93
|
branchProtection: report.branchProtection,
|
|
91
94
|
checks: buildRelevantChecks(report),
|
|
95
|
+
inProgressChecks: buildActiveChecks(report),
|
|
96
|
+
activity: report.activity,
|
|
92
97
|
};
|
|
93
98
|
if (readyState.shouldCancel) {
|
|
94
99
|
await clearStallState(stallKey);
|
|
@@ -125,6 +130,7 @@ export async function runIterate(opts) {
|
|
|
125
130
|
editedSummaries,
|
|
126
131
|
surfacedApprovals,
|
|
127
132
|
botUsernames,
|
|
133
|
+
ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
|
|
128
134
|
});
|
|
129
135
|
}
|
|
130
136
|
const canMarkReady = report.status === "READY" &&
|
|
@@ -2,7 +2,9 @@ import { renderShellCommand } from "../../cli/runner.mjs";
|
|
|
2
2
|
import { buildFailingCheckInstructions } from "./check-instructions.mjs";
|
|
3
3
|
import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
|
|
4
4
|
import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
|
|
5
|
-
|
|
5
|
+
// Module-private: the final "stop" step appended to every fix_code instruction list. No longer
|
|
6
|
+
// re-exported now that the recheck/delay suffix that consumed it (in iterate-instructions) is gone.
|
|
7
|
+
const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
|
|
6
8
|
/**
|
|
7
9
|
* Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
|
|
8
10
|
* whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is never in `argv` —
|
|
@@ -15,7 +17,8 @@ export function renderResolveCommand(rc) {
|
|
|
15
17
|
}
|
|
16
18
|
return renderShellCommand(parts);
|
|
17
19
|
}
|
|
18
|
-
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews,
|
|
20
|
+
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, _baseBranch, // retained for call-site stability; rebase mechanics now defer to the caller
|
|
21
|
+
resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand) {
|
|
19
22
|
const instructions = [];
|
|
20
23
|
const hasNonConflictHints = threads.length > 0 ||
|
|
21
24
|
checks.length > 0 ||
|
|
@@ -39,17 +42,17 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
39
42
|
const resolveClause = resolveCommand.hasMutations ? ", then run the `resolve:` command" : "";
|
|
40
43
|
if (hasConflicts) {
|
|
41
44
|
// Conflicts make push mandatory regardless of whether code edits are needed.
|
|
42
|
-
instructions.push(`The branch has merge conflicts that
|
|
45
|
+
instructions.push(`The branch has merge conflicts that must be resolved before merging (see \`**branch**\` above). Apply any code edits for items ${sectionRef}, then commit and push${resolveClause}.`);
|
|
43
46
|
}
|
|
44
47
|
else {
|
|
45
48
|
const skipClause = resolveCommand.hasMutations
|
|
46
|
-
? "skip
|
|
49
|
+
? "skip the commit/push and run the `resolve:` command"
|
|
47
50
|
: "no push is needed";
|
|
48
|
-
instructions.push(`Decide for each item ${sectionRef} whether a code change is warranted. **If any code changes are needed:**
|
|
51
|
+
instructions.push(`Decide for each item ${sectionRef} whether a code change is warranted. **If any code changes are needed:** apply edits, commit, push${resolveClause}. **If no code changes are needed:** ${skipClause}.`);
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
else if (hasConflicts) {
|
|
52
|
-
instructions.push(`The branch has merge conflicts
|
|
55
|
+
instructions.push(`The branch has merge conflicts that must be resolved before merging (see \`**branch**\` above). Resolve them and push.`);
|
|
53
56
|
}
|
|
54
57
|
if (inProgressRunIds.length > 0) {
|
|
55
58
|
instructions.push(`If you decide to push new commits: cancel each in-progress run listed under \`## In-progress runs\` before applying code fixes (e.g. \`gh run cancel <id>\`). Runs may complete between the tick and your action; treat cancellation errors on already-finished runs as non-fatal. Skip this step if you are only resolving threads without pushing — the existing runs remain relevant.`);
|
|
@@ -58,10 +61,15 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
58
61
|
if (hasSuggestions)
|
|
59
62
|
instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads", false));
|
|
60
63
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
64
|
+
const fixSections = [];
|
|
65
|
+
if (threads.length > 0)
|
|
66
|
+
fixSections.push("`## Review threads`");
|
|
67
|
+
if (actionableComments.length > 0)
|
|
68
|
+
fixSections.push("`## Actionable comments`");
|
|
61
69
|
const suggestionFallback = hasSuggestions
|
|
62
70
|
? ` When applying a \`[suggestion]\` thread manually (e.g. after a failed \`commit-suggestion\` run), replace the exact line range shown in the heading (\`path:startLine-endLine\`) with the replacement shown in its \`Replaces lines …\` block verbatim — an empty replacement deletes those lines, a single blank line replaces the range with one blank line.`
|
|
63
71
|
: "";
|
|
64
|
-
instructions.push(`Apply code fixes: read and edit each file referenced under
|
|
72
|
+
instructions.push(`Apply code fixes: read and edit each file referenced under ${fixSections.join(" and ")} above.${suggestionFallback}`);
|
|
65
73
|
}
|
|
66
74
|
if (resolutionOnlyThreads.length > 0) {
|
|
67
75
|
instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`resolve:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
|
|
@@ -73,8 +81,6 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
73
81
|
if (changesRequestedReviews.length > 0) {
|
|
74
82
|
instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
|
|
75
83
|
}
|
|
76
|
-
if (hasNonConflictHints)
|
|
77
|
-
instructions.push(`If you applied code edits: commit them with a descriptive message, then rebase onto \`origin/${baseBranch}\` per your repository's conventions before pushing.`);
|
|
78
84
|
if (resolveOnlyCommand?.hasMutations)
|
|
79
85
|
instructions.push(`Run the \`resolve-only:\` command shown above — no substitutions needed.`);
|
|
80
86
|
if (resolveCommand.hasMutations) {
|
|
@@ -8,9 +8,11 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
|
|
|
8
8
|
].sort();
|
|
9
9
|
const threads = report.threads.actionable.map((t) => t.id).sort();
|
|
10
10
|
const resolutionOnlyThreads = report.threads.resolutionOnly.map((t) => t.id).sort();
|
|
11
|
+
const ruleAutoResolveThreads = (report.threads.ruleAutoResolveIds ?? []).sort((a, b) => a.localeCompare(b));
|
|
11
12
|
const comments = report.comments.actionable.map((c) => c.id).sort();
|
|
12
13
|
const reviews = report.changesRequestedReviews.map((r) => r.id).sort();
|
|
13
14
|
const summaries = [...reviewSummaryIds].sort();
|
|
15
|
+
const ruleAutoResolveSummaries = (report.ruleAutoResolveReviewSummaryIds ?? []).sort((a, b) => a.localeCompare(b));
|
|
14
16
|
return JSON.stringify({
|
|
15
17
|
action,
|
|
16
18
|
headSha,
|
|
@@ -21,9 +23,11 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
|
|
|
21
23
|
checks,
|
|
22
24
|
threads,
|
|
23
25
|
resolutionOnlyThreads,
|
|
26
|
+
ruleAutoResolveThreads,
|
|
24
27
|
comments,
|
|
25
28
|
reviews,
|
|
26
29
|
summaries,
|
|
30
|
+
ruleAutoResolveSummaries,
|
|
27
31
|
});
|
|
28
32
|
}
|
|
29
33
|
export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getRepoInfo, getPullRequestBody, updatePullRequestBody, getCurrentPrNumber, } from "../../github/client.mjs";
|
|
2
|
+
import { validateJournalItem, appendJournalItem } from "./transform.mjs";
|
|
3
|
+
export async function runJournal(opts) {
|
|
4
|
+
const validation = validateJournalItem(opts.rawItem);
|
|
5
|
+
if (!validation.ok) {
|
|
6
|
+
throw new Error(validation.error);
|
|
7
|
+
}
|
|
8
|
+
const { item } = validation;
|
|
9
|
+
const { owner, name } = await getRepoInfo();
|
|
10
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
11
|
+
if (!prNumber) {
|
|
12
|
+
throw new Error("PR number is required: no PR number provided and none found for current branch");
|
|
13
|
+
}
|
|
14
|
+
const { nodeId, body } = await getPullRequestBody(prNumber, owner, name);
|
|
15
|
+
const { body: newBody, mutated, sectionExisted } = appendJournalItem(body, item);
|
|
16
|
+
if (mutated && !opts.dryRun) {
|
|
17
|
+
await updatePullRequestBody(nodeId, newBody);
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
prNumber,
|
|
21
|
+
mutated,
|
|
22
|
+
sectionExisted,
|
|
23
|
+
dryRun: opts.dryRun,
|
|
24
|
+
...(opts.dryRun ? { previewBody: newBody } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { SHEPHERD_JOURNAL_SECTION, SHEPHERD_JOURNAL_SECTION_PATTERN, } from "../shepherd-journal.mjs";
|
|
2
|
+
/** Validates that the input is a properly formed markdown list item. */
|
|
3
|
+
export function validateJournalItem(input) {
|
|
4
|
+
const lines = input.split("\n").map((l) => l.trimEnd());
|
|
5
|
+
const nonBlank = lines.filter((l) => l.trim() !== "");
|
|
6
|
+
if (nonBlank.length === 0) {
|
|
7
|
+
return { ok: false, error: 'journal item must not be empty; expected a "- <text>" list item' };
|
|
8
|
+
}
|
|
9
|
+
if (!/^- \S/.test(nonBlank[0])) {
|
|
10
|
+
return {
|
|
11
|
+
ok: false,
|
|
12
|
+
error: `journal item must start with "- <text>"; got: ${JSON.stringify(nonBlank[0].slice(0, 40))}`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
for (const line of nonBlank.slice(1)) {
|
|
16
|
+
if (line.startsWith("#")) {
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
error: "journal item lines must not start with # (would break section structure)",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const trimmed = lines
|
|
24
|
+
.map((l) => l.trimEnd())
|
|
25
|
+
.join("\n")
|
|
26
|
+
.trim();
|
|
27
|
+
return { ok: true, item: trimmed };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Appends a validated list item to the ## Shepherd Journal section of a PR body.
|
|
31
|
+
* Creates the section at the end if absent. Skips if the exact item is already present (idempotent).
|
|
32
|
+
*/
|
|
33
|
+
export function appendJournalItem(body, item) {
|
|
34
|
+
const lines = body.split("\n");
|
|
35
|
+
const bounds = findSectionBounds(lines);
|
|
36
|
+
if (!bounds) {
|
|
37
|
+
return createSection(lines, item);
|
|
38
|
+
}
|
|
39
|
+
const { headingIdx, endIdx } = bounds;
|
|
40
|
+
const sectionLines = lines.slice(headingIdx + 1, endIdx);
|
|
41
|
+
if (itemAlreadyPresent(sectionLines, item)) {
|
|
42
|
+
return { body, mutated: false, sectionExisted: true };
|
|
43
|
+
}
|
|
44
|
+
return appendToSection(lines, headingIdx, endIdx, sectionLines, item);
|
|
45
|
+
}
|
|
46
|
+
function findSectionBounds(lines) {
|
|
47
|
+
let inFence = false;
|
|
48
|
+
let headingIdx = -1;
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
const trimmed = lines[i].trim();
|
|
51
|
+
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
|
52
|
+
inFence = !inFence;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!inFence) {
|
|
56
|
+
if (headingIdx === -1) {
|
|
57
|
+
if (SHEPHERD_JOURNAL_SECTION_PATTERN.test(lines[i].trimEnd())) {
|
|
58
|
+
headingIdx = i;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (/^#{1,2} /.test(lines[i])) {
|
|
62
|
+
return { headingIdx, endIdx: i };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (headingIdx === -1)
|
|
67
|
+
return null;
|
|
68
|
+
return { headingIdx, endIdx: lines.length };
|
|
69
|
+
}
|
|
70
|
+
function itemAlreadyPresent(sectionLines, item) {
|
|
71
|
+
const itemLines = item.split("\n").map((l) => l.trimEnd());
|
|
72
|
+
const normalizedSection = sectionLines.map((l) => l.trimEnd());
|
|
73
|
+
for (let i = 0; i <= normalizedSection.length - itemLines.length; i++) {
|
|
74
|
+
let match = true;
|
|
75
|
+
for (let j = 0; j < itemLines.length; j++) {
|
|
76
|
+
if (normalizedSection[i + j] !== itemLines[j]) {
|
|
77
|
+
match = false;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (match)
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
function appendToSection(lines, headingIdx, endIdx, sectionLines, item) {
|
|
87
|
+
const before = lines.slice(0, headingIdx + 1);
|
|
88
|
+
const after = lines.slice(endIdx);
|
|
89
|
+
// Strip trailing blank lines from section body.
|
|
90
|
+
let sectionEnd = sectionLines.length;
|
|
91
|
+
while (sectionEnd > 0 && sectionLines[sectionEnd - 1].trim() === "") {
|
|
92
|
+
sectionEnd--;
|
|
93
|
+
}
|
|
94
|
+
const trimmedSection = sectionLines.slice(0, sectionEnd);
|
|
95
|
+
// Insert blank line after heading when section was empty, then the item.
|
|
96
|
+
const newSection = trimmedSection.length === 0
|
|
97
|
+
? ["", ...item.split("\n")]
|
|
98
|
+
: [...trimmedSection, ...item.split("\n")];
|
|
99
|
+
// One blank line before the next section (or trailing newline at EOF).
|
|
100
|
+
const newBody = [...before, ...newSection, ...(after.length > 0 ? ["", ...after] : [])].join("\n");
|
|
101
|
+
return { body: newBody, mutated: true, sectionExisted: true };
|
|
102
|
+
}
|
|
103
|
+
function createSection(lines, item) {
|
|
104
|
+
// Strip trailing blank lines from the existing body.
|
|
105
|
+
let end = lines.length;
|
|
106
|
+
while (end > 0 && lines[end - 1].trim() === "") {
|
|
107
|
+
end--;
|
|
108
|
+
}
|
|
109
|
+
const trimmedLines = lines.slice(0, end);
|
|
110
|
+
const newBody = [...trimmedLines, "", SHEPHERD_JOURNAL_SECTION, "", ...item.split("\n")].join("\n");
|
|
111
|
+
return { body: newBody, mutated: true, sectionExisted: false };
|
|
112
|
+
}
|