pr-shepherd 0.8.1 → 0.10.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 +123 -21
- package/bin/checks/classify.mjs +2 -1
- package/bin/checks/triage.mjs +63 -61
- package/bin/cli/args.mjs +9 -3
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +45 -19
- package/bin/cli/formatters.mjs +61 -44
- package/bin/cli/handlers.mjs +5 -4
- package/bin/cli/iterate-formatter.mjs +51 -45
- package/bin/cli/iterate-lean.mjs +95 -0
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +6 -5
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check-status.mjs +7 -4
- package/bin/commands/check.mjs +39 -3
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +10 -54
- package/bin/commands/iterate/escalate.mjs +5 -14
- package/bin/commands/iterate/fix-code.mjs +15 -16
- package/bin/commands/iterate/helpers.mjs +8 -5
- package/bin/commands/iterate/index.mjs +18 -12
- package/bin/commands/iterate/render.mjs +28 -12
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/log-file.mjs +7 -0
- package/bin/commands/monitor.mjs +7 -4
- package/bin/commands/ready-delay.mjs +2 -2
- package/bin/commands/resolve-instructions.mjs +14 -5
- package/bin/commands/resolve.mjs +38 -37
- package/bin/commands/status.mjs +41 -29
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +4 -7
- package/bin/github/batch-parsers.mjs +4 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +6 -0
- package/bin/github/gql/get-pr-head-sha.gql +7 -0
- package/bin/github/http.mjs +166 -11
- package/bin/github/queries.mjs +7 -11
- package/bin/log/log-file.mjs +88 -0
- package/bin/log/session.mjs +100 -0
- package/bin/log/setup.mjs +54 -0
- package/bin/reporters/agent.mjs +21 -13
- package/bin/reporters/check-instructions.mjs +9 -14
- package/bin/reporters/text.mjs +93 -105
- package/bin/state/base.mjs +5 -0
- package/bin/state/fix-attempts.mjs +2 -2
- package/bin/state/iterate-stall.mjs +2 -2
- package/bin/state/seen-comments.mjs +76 -0
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/types/report.mjs +0 -1
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +2 -2
- package/bin/commands/iterate/steps.mjs +0 -31
- package/bin/github/gql/dismiss-review.gql +0 -7
- package/bin/github/gql/minimize-comment.gql +0 -7
- package/bin/github/gql/multi-pr-status.gql +0 -32
- package/bin/github/gql/resolve-thread.gql +0 -7
package/bin/commands/check.mjs
CHANGED
|
@@ -21,15 +21,17 @@ import { autoResolveOutdated } from "../comments/resolve.mjs";
|
|
|
21
21
|
import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
22
22
|
import { loadConfig } from "../config/load.mjs";
|
|
23
23
|
import { computeStatus } from "./check-status.mjs";
|
|
24
|
+
import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
|
|
24
25
|
export async function runCheck(opts) {
|
|
25
26
|
const repo = await getRepoInfo();
|
|
26
27
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
27
28
|
if (prNumber === null) {
|
|
28
29
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
29
30
|
}
|
|
31
|
+
const config = loadConfig();
|
|
30
32
|
// Only paginate APPROVED reviews when the caller will actually minimize them.
|
|
31
33
|
// Otherwise the first-page cap of 50 (already in the batch) is plenty — no extra round-trip.
|
|
32
|
-
const paginateApprovedReviews =
|
|
34
|
+
const paginateApprovedReviews = config.iterate.minimizeApprovals;
|
|
33
35
|
const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
|
|
34
36
|
let batchData = result.data;
|
|
35
37
|
// GraphQL sometimes returns UNKNOWN for mergeable/mergeStateStatus while the
|
|
@@ -52,8 +54,10 @@ export async function runCheck(opts) {
|
|
|
52
54
|
const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
|
|
53
55
|
const skipped = classifiedChecks.filter((c) => c.category === "skipped");
|
|
54
56
|
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
const triaged = failing.length > 0 && !opts.skipTriage
|
|
58
|
+
? await triageFailingChecks(failing, repo, config.checks.logTailLines, config.checks.logTailChars)
|
|
59
|
+
: failing;
|
|
60
|
+
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
57
61
|
// Resolve threads and comments.
|
|
58
62
|
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
|
|
59
63
|
const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
|
|
@@ -67,6 +71,36 @@ export async function runCheck(opts) {
|
|
|
67
71
|
autoResolveErrors = errors;
|
|
68
72
|
}
|
|
69
73
|
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
74
|
+
// First-look: collect previously-hidden items not yet seen by the agent.
|
|
75
|
+
const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
|
|
76
|
+
const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
77
|
+
const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
78
|
+
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
|
|
79
|
+
const seenSet = await loadSeenSet(stateKey);
|
|
80
|
+
const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
|
|
81
|
+
const firstLookThreads = [
|
|
82
|
+
...outdatedCandidates
|
|
83
|
+
.filter((t) => !seenSet.has(t.id))
|
|
84
|
+
.map((t) => ({
|
|
85
|
+
...t,
|
|
86
|
+
firstLookStatus: "outdated",
|
|
87
|
+
autoResolved: autoResolvedIds.has(t.id),
|
|
88
|
+
})),
|
|
89
|
+
...resolvedCandidates
|
|
90
|
+
.filter((t) => !seenSet.has(t.id))
|
|
91
|
+
.map((t) => ({ ...t, firstLookStatus: "resolved" })),
|
|
92
|
+
...minimizedThreadCandidates
|
|
93
|
+
.filter((t) => !seenSet.has(t.id))
|
|
94
|
+
.map((t) => ({ ...t, firstLookStatus: "minimized" })),
|
|
95
|
+
];
|
|
96
|
+
const firstLookComments = minimizedCommentCandidates
|
|
97
|
+
.filter((c) => !seenSet.has(c.id))
|
|
98
|
+
.map((c) => ({ ...c, firstLookStatus: "minimized" }));
|
|
99
|
+
// Mark first-look items as seen (best-effort — markSeen never throws).
|
|
100
|
+
await Promise.allSettled([
|
|
101
|
+
...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
|
|
102
|
+
...firstLookComments.map((c) => markSeen(stateKey, c.id)),
|
|
103
|
+
]);
|
|
70
104
|
// Actionable: all active threads and all visible comments (no classification — LLM handles triage).
|
|
71
105
|
const actionableThreads = activeThreads;
|
|
72
106
|
const actionableComments = visibleComments;
|
|
@@ -99,9 +133,11 @@ export async function runCheck(opts) {
|
|
|
99
133
|
actionable: actionableThreads,
|
|
100
134
|
autoResolved,
|
|
101
135
|
autoResolveErrors,
|
|
136
|
+
firstLook: firstLookThreads,
|
|
102
137
|
},
|
|
103
138
|
comments: {
|
|
104
139
|
actionable: actionableComments,
|
|
140
|
+
firstLook: firstLookComments,
|
|
105
141
|
},
|
|
106
142
|
changesRequestedReviews: batchData.changesRequestedReviews,
|
|
107
143
|
reviewSummaries: batchData.reviewSummaries,
|
|
@@ -3,7 +3,7 @@ import { readFile, writeFile, unlink } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { getRepoInfo, getCurrentPrNumber,
|
|
6
|
+
import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
|
|
7
7
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
8
8
|
import { applyResolveOptions } from "../comments/resolve.mjs";
|
|
9
9
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
@@ -25,19 +25,21 @@ export async function runCommitSuggestion(opts) {
|
|
|
25
25
|
if (prNumber === null) {
|
|
26
26
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
27
27
|
}
|
|
28
|
-
const head = await getPrHead(prNumber, repo.owner, repo.name);
|
|
29
28
|
const currentBranch = await getCurrentBranch();
|
|
30
|
-
if (currentBranch !== head.ref) {
|
|
31
|
-
throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${head.ref}". ` +
|
|
32
|
-
`Check out "${head.ref}" before applying suggestions.`);
|
|
33
|
-
}
|
|
34
29
|
const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
35
30
|
const localHeadSha = localHeadOut.trim();
|
|
36
|
-
if (localHeadSha !== head.sha) {
|
|
37
|
-
throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${head.sha}. ` +
|
|
38
|
-
`Pull/rebase "${head.ref}" to the latest PR head and try again.`);
|
|
39
|
-
}
|
|
40
31
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
32
|
+
if (!data.headRepoWithOwner) {
|
|
33
|
+
throw new Error(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`);
|
|
34
|
+
}
|
|
35
|
+
if (currentBranch !== data.headRefName) {
|
|
36
|
+
throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
|
|
37
|
+
`Check out "${data.headRefName}" before applying suggestions.`);
|
|
38
|
+
}
|
|
39
|
+
if (localHeadSha !== data.headRefOid) {
|
|
40
|
+
throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
|
|
41
|
+
`Pull/rebase "${data.headRefName}" to the latest PR head and try again.`);
|
|
42
|
+
}
|
|
41
43
|
const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
|
|
42
44
|
if (!thread) {
|
|
43
45
|
throw new Error(`Thread ${opts.threadId} not found on PR #${prNumber}.`);
|
|
@@ -1,58 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
"copilot-pull-request-reviewer",
|
|
5
|
-
"gemini-code-assist",
|
|
6
|
-
"coderabbitai",
|
|
7
|
-
]);
|
|
8
|
-
function isBotAuthor(login) {
|
|
9
|
-
const bare = login.replace(/\[bot\]$/, "");
|
|
10
|
-
if (bare !== login)
|
|
11
|
-
return true;
|
|
12
|
-
return KNOWN_BOT_LOGINS.has(bare);
|
|
13
|
-
}
|
|
14
|
-
export function classifyReviewSummaries(summaries, approvals, cfg) {
|
|
15
|
-
const minimizeIds = [];
|
|
16
|
-
const surfacedSummaries = [];
|
|
17
|
-
for (const r of summaries) {
|
|
18
|
-
const enabled = isBotAuthor(r.author) ? cfg.bots : cfg.humans;
|
|
19
|
-
if (enabled)
|
|
20
|
-
minimizeIds.push(r.id);
|
|
21
|
-
else
|
|
22
|
-
surfacedSummaries.push(r);
|
|
23
|
-
}
|
|
24
|
-
if (cfg.approvals) {
|
|
1
|
+
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals) {
|
|
2
|
+
const minimizeIds = summaries.map((r) => r.id);
|
|
3
|
+
if (minimizeApprovals) {
|
|
25
4
|
for (const r of approvals)
|
|
26
5
|
minimizeIds.push(r.id);
|
|
6
|
+
return { minimizeIds, surfacedApprovals: [] };
|
|
27
7
|
}
|
|
28
|
-
return { minimizeIds,
|
|
29
|
-
}
|
|
30
|
-
// Patterns that indicate a comment is bot-generated noise rather than actionable feedback.
|
|
31
|
-
// Conservative: only match explicit known patterns to avoid accidentally suppressing real reviews.
|
|
32
|
-
const NOISE_PATTERNS = [
|
|
33
|
-
/you have reached your daily quota/i,
|
|
34
|
-
/please wait up to \d+ hours?/i,
|
|
35
|
-
/rate[\s-]?limit(?:ed)?\s*[-—:]\s*try again/i,
|
|
36
|
-
/resuming (monitoring|watch|checking)/i,
|
|
37
|
-
/restarting (monitoring|watch)/i,
|
|
38
|
-
];
|
|
39
|
-
function isNoiseComment(comment) {
|
|
40
|
-
return NOISE_PATTERNS.some((p) => p.test(comment.body));
|
|
41
|
-
}
|
|
42
|
-
export function classifyComments(comments) {
|
|
43
|
-
const actionable = [];
|
|
44
|
-
const noiseIds = [];
|
|
45
|
-
for (const c of comments) {
|
|
46
|
-
if (isNoiseComment(c)) {
|
|
47
|
-
noiseIds.push(c.id);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
actionable.push(c);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { actionable, noiseIds };
|
|
8
|
+
return { minimizeIds, surfacedApprovals: approvals };
|
|
54
9
|
}
|
|
55
|
-
export function buildResolveCommand(threads,
|
|
10
|
+
export function buildResolveCommand(threads, allCommentIds, reviews, checks, prNumber) {
|
|
56
11
|
const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
|
|
57
12
|
if (threads.length > 0) {
|
|
58
13
|
argv.push("--resolve-thread-ids", threads.map((t) => t.id).join(","));
|
|
@@ -65,9 +20,10 @@ export function buildResolveCommand(threads, actionableComments, allCommentIds,
|
|
|
65
20
|
argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
|
|
66
21
|
argv.push("--message", "$DISMISS_MESSAGE");
|
|
67
22
|
}
|
|
68
|
-
// A push
|
|
69
|
-
//
|
|
70
|
-
|
|
23
|
+
// A push is required when threads, CI failures, or changes-requested reviews are present — the
|
|
24
|
+
// CLI knows those imply code edits. Comments are surfaced for the agent to evaluate; the CLI
|
|
25
|
+
// cannot know whether a given comment will require a push, so comments are excluded here.
|
|
26
|
+
const requiresHeadSha = threads.length > 0 || checks.length > 0 || reviews.length > 0;
|
|
71
27
|
// hasMutations = we appended at least one of --resolve-thread-ids,
|
|
72
28
|
// --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
|
|
73
29
|
// (rather than derived from argv.length) so callers don't couple to the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
-
export function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews,
|
|
2
|
+
export function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, failingChecks, threadAttempts, hasConflicts) {
|
|
3
3
|
const triggers = [];
|
|
4
4
|
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
5
5
|
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
@@ -12,7 +12,7 @@ export function checkEscalateTriggers(actionableThreads, actionableComments, cha
|
|
|
12
12
|
if (changesRequestedReviews.length > 0 &&
|
|
13
13
|
actionableThreads.length === 0 &&
|
|
14
14
|
actionableComments.length === 0 &&
|
|
15
|
-
|
|
15
|
+
failingChecks.length === 0 &&
|
|
16
16
|
!hasConflicts) {
|
|
17
17
|
triggers.push("pr-level-changes-requested");
|
|
18
18
|
}
|
|
@@ -31,8 +31,8 @@ export function checkEscalateTriggers(actionableThreads, actionableComments, cha
|
|
|
31
31
|
/**
|
|
32
32
|
* Validate the base branch name from the GraphQL batch (`report.baseBranch`)
|
|
33
33
|
* and fall back safely if it's missing/unsafe. The branch is interpolated into
|
|
34
|
-
* shell commands by `
|
|
35
|
-
*
|
|
34
|
+
* shell commands by `buildFixInstructions`, so we reject anything outside
|
|
35
|
+
* `[A-Za-z0-9._/-]` to prevent shell injection.
|
|
36
36
|
*/
|
|
37
37
|
export function validateBaseBranch(raw) {
|
|
38
38
|
const trimmed = raw.trim();
|
|
@@ -52,18 +52,9 @@ export function validateBaseBranch(raw) {
|
|
|
52
52
|
}
|
|
53
53
|
return { branch: trimmed, isFallback: false };
|
|
54
54
|
}
|
|
55
|
-
export function buildRebaseShellScript(baseBranch) {
|
|
56
|
-
return [
|
|
57
|
-
`if ! git diff --quiet || ! git diff --cached --quiet; then`,
|
|
58
|
-
` echo "SKIP rebase: dirty worktree (uncommitted changes present)"`,
|
|
59
|
-
` exit 1`,
|
|
60
|
-
`fi`,
|
|
61
|
-
`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease`,
|
|
62
|
-
].join("\n");
|
|
63
|
-
}
|
|
64
55
|
export function buildEscalateHumanMessage(escalate, pr) {
|
|
65
56
|
const lines = [];
|
|
66
|
-
lines.push("⚠️
|
|
57
|
+
lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
|
|
67
58
|
lines.push("");
|
|
68
59
|
lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
|
|
69
60
|
lines.push("");
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
|
|
2
2
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
|
|
3
3
|
import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { buildResolveCommand } from "./classify.mjs";
|
|
5
5
|
import { buildFixInstructions } from "./render.mjs";
|
|
6
6
|
import { applyStallGuard } from "./stall.mjs";
|
|
7
7
|
import { tryCancelRun } from "./helpers.mjs";
|
|
8
8
|
export async function handleFixCode(ctx) {
|
|
9
|
-
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds,
|
|
10
|
-
const
|
|
9
|
+
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, surfacedApprovals, } = ctx;
|
|
10
|
+
const failingChecks = report.checks.failing;
|
|
11
11
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
12
12
|
const isNewSha = stored?.headSha !== headSha;
|
|
13
13
|
// Accumulate across shas — only increment when a push is detected (sha changed)
|
|
@@ -17,7 +17,7 @@ export async function handleFixCode(ctx) {
|
|
|
17
17
|
currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews,
|
|
20
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, failingChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
|
|
21
21
|
if (escalateTriggers.triggers.length > 0) {
|
|
22
22
|
const escalateBase = {
|
|
23
23
|
triggers: escalateTriggers.triggers,
|
|
@@ -41,23 +41,19 @@ export async function handleFixCode(ctx) {
|
|
|
41
41
|
let cancelled = [];
|
|
42
42
|
if (!opts.noAutoCancelActionable) {
|
|
43
43
|
const uniqueRunIds = [
|
|
44
|
-
...new Set(
|
|
44
|
+
...new Set(failingChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
45
45
|
];
|
|
46
46
|
const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
|
|
47
47
|
cancelled = results.filter((id) => id !== null);
|
|
48
48
|
}
|
|
49
49
|
const baseLookup = validateBaseBranch(report.baseBranch);
|
|
50
50
|
const threads = report.threads.actionable.map(toAgentThread);
|
|
51
|
-
const
|
|
52
|
-
const checks = toAgentChecks(
|
|
51
|
+
const actionableComments = report.comments.actionable.map(toAgentComment);
|
|
52
|
+
const checks = toAgentChecks(failingChecks);
|
|
53
53
|
const { changesRequestedReviews } = report;
|
|
54
54
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
55
|
-
const allCommentIds = [
|
|
56
|
-
|
|
57
|
-
...noiseCommentIds,
|
|
58
|
-
...reviewSummaryIds,
|
|
59
|
-
];
|
|
60
|
-
const resolveCommand = buildResolveCommand(threads, actionableComments, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
55
|
+
const allCommentIds = [...actionableComments.map((c) => c.id), ...reviewSummaryIds];
|
|
56
|
+
const resolveCommand = buildResolveCommand(threads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
61
57
|
if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
|
|
62
58
|
const fallbackEscalateBase = {
|
|
63
59
|
triggers: ["base-branch-unknown"],
|
|
@@ -75,7 +71,9 @@ export async function handleFixCode(ctx) {
|
|
|
75
71
|
},
|
|
76
72
|
};
|
|
77
73
|
}
|
|
78
|
-
const
|
|
74
|
+
const firstLookThreads = report.threads.firstLook;
|
|
75
|
+
const firstLookComments = report.comments.firstLook;
|
|
76
|
+
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments);
|
|
79
77
|
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
|
|
80
78
|
...base,
|
|
81
79
|
baseBranch: baseLookup.branch,
|
|
@@ -84,13 +82,14 @@ export async function handleFixCode(ctx) {
|
|
|
84
82
|
mode: "rebase-and-push",
|
|
85
83
|
threads,
|
|
86
84
|
actionableComments,
|
|
87
|
-
noiseCommentIds,
|
|
88
85
|
reviewSummaryIds,
|
|
89
|
-
|
|
86
|
+
surfacedApprovals,
|
|
90
87
|
checks,
|
|
91
88
|
changesRequestedReviews,
|
|
92
89
|
resolveCommand,
|
|
93
90
|
instructions,
|
|
91
|
+
firstLookThreads,
|
|
92
|
+
firstLookComments,
|
|
94
93
|
},
|
|
95
94
|
cancelled,
|
|
96
95
|
}, report, reviewSummaryIds);
|
|
@@ -13,7 +13,8 @@ export function buildSummary(report) {
|
|
|
13
13
|
/**
|
|
14
14
|
* Build the full list of CI checks relevant to PR readiness: triggered by a PR
|
|
15
15
|
* event (or StatusContext with null event), completed, and not skipped/neutral.
|
|
16
|
-
* Includes both passing and failing. Failing entries carry
|
|
16
|
+
* Includes both passing and failing. Failing entries carry workflowName, jobName,
|
|
17
|
+
* failedStep, and summary.
|
|
17
18
|
*/
|
|
18
19
|
export function buildRelevantChecks(report) {
|
|
19
20
|
const excluded = new Set([null, "SKIPPED", "NEUTRAL"]);
|
|
@@ -41,10 +42,10 @@ export function buildRelevantChecks(report) {
|
|
|
41
42
|
conclusion,
|
|
42
43
|
runId: c.runId,
|
|
43
44
|
detailsUrl: c.detailsUrl || null,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
failedStep: c.failedStep,
|
|
47
|
-
summary: c.summary,
|
|
45
|
+
...(c.workflowName !== undefined && { workflowName: c.workflowName }),
|
|
46
|
+
...(c.jobName !== undefined && { jobName: c.jobName }),
|
|
47
|
+
...(c.failedStep !== undefined && { failedStep: c.failedStep }),
|
|
48
|
+
...(c.summary !== undefined && { summary: c.summary }),
|
|
48
49
|
},
|
|
49
50
|
];
|
|
50
51
|
});
|
|
@@ -91,6 +92,8 @@ export function buildCooldownResult(prNumber, readyDelaySeconds) {
|
|
|
91
92
|
status: "UNKNOWN",
|
|
92
93
|
state: "UNKNOWN",
|
|
93
94
|
mergeStateStatus: "UNKNOWN",
|
|
95
|
+
mergeStatus: "UNKNOWN",
|
|
96
|
+
reviewDecision: null,
|
|
94
97
|
copilotReviewInProgress: false,
|
|
95
98
|
isDraft: false,
|
|
96
99
|
shouldCancel: false,
|
|
@@ -9,7 +9,6 @@ import { classifyReviewSummaries } from "./classify.mjs";
|
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { buildWaitLog } from "./render.mjs";
|
|
11
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
12
|
-
import { buildRerunCiResult } from "./steps.mjs";
|
|
13
12
|
export async function runIterate(opts) {
|
|
14
13
|
const config = loadConfig();
|
|
15
14
|
const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
|
|
@@ -36,6 +35,8 @@ export async function runIterate(opts) {
|
|
|
36
35
|
repo: report.repo,
|
|
37
36
|
status: report.status,
|
|
38
37
|
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
38
|
+
mergeStatus: report.mergeStatus.status,
|
|
39
|
+
reviewDecision: report.mergeStatus.reviewDecision,
|
|
39
40
|
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
40
41
|
isDraft: report.mergeStatus.isDraft,
|
|
41
42
|
shouldCancel: true,
|
|
@@ -61,6 +62,8 @@ export async function runIterate(opts) {
|
|
|
61
62
|
status: report.status,
|
|
62
63
|
state: report.mergeStatus.state,
|
|
63
64
|
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
65
|
+
mergeStatus: report.mergeStatus.status,
|
|
66
|
+
reviewDecision: report.mergeStatus.reviewDecision,
|
|
64
67
|
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
65
68
|
isDraft: report.mergeStatus.isDraft,
|
|
66
69
|
shouldCancel: readyState.shouldCancel,
|
|
@@ -70,24 +73,31 @@ export async function runIterate(opts) {
|
|
|
70
73
|
checks: buildRelevantChecks(report),
|
|
71
74
|
};
|
|
72
75
|
if (readyState.shouldCancel) {
|
|
76
|
+
let cancelNote;
|
|
77
|
+
if (base.mergeStatus !== "BLOCKED")
|
|
78
|
+
cancelNote = "has been ready for review";
|
|
79
|
+
else if (base.reviewDecision === "REVIEW_REQUIRED")
|
|
80
|
+
cancelNote = "is awaiting human review";
|
|
81
|
+
else if (base.reviewDecision === "APPROVED")
|
|
82
|
+
cancelNote = "is awaiting additional approvals";
|
|
83
|
+
else
|
|
84
|
+
cancelNote = "is awaiting human review or branch protection resolution";
|
|
73
85
|
return {
|
|
74
86
|
...base,
|
|
75
87
|
action: "cancel",
|
|
76
88
|
reason: "ready-delay-elapsed",
|
|
77
|
-
log: `CANCEL: PR #${base.pr}
|
|
89
|
+
log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping monitor`,
|
|
78
90
|
};
|
|
79
91
|
}
|
|
80
92
|
const headSha = (await getCurrentHeadSha()) ?? "unknown";
|
|
81
93
|
const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
82
|
-
const { minimizeIds: reviewSummaryIds,
|
|
83
|
-
const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
|
|
94
|
+
const { minimizeIds: reviewSummaryIds, surfacedApprovals } = classifyReviewSummaries(report.reviewSummaries, report.approvedReviews, config.iterate.minimizeApprovals);
|
|
84
95
|
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
85
96
|
report.comments.actionable.length > 0 ||
|
|
86
97
|
report.changesRequestedReviews.length > 0 ||
|
|
87
|
-
|
|
98
|
+
report.checks.failing.length > 0 ||
|
|
88
99
|
report.mergeStatus.status === "CONFLICTS" ||
|
|
89
|
-
reviewSummaryIds.length > 0
|
|
90
|
-
surfacedSummaries.length > 0;
|
|
100
|
+
reviewSummaryIds.length > 0;
|
|
91
101
|
if (hasActionableWork) {
|
|
92
102
|
return handleFixCode({
|
|
93
103
|
base,
|
|
@@ -100,13 +110,9 @@ export async function runIterate(opts) {
|
|
|
100
110
|
repoOwner,
|
|
101
111
|
repoName,
|
|
102
112
|
reviewSummaryIds,
|
|
103
|
-
|
|
113
|
+
surfacedApprovals,
|
|
104
114
|
});
|
|
105
115
|
}
|
|
106
|
-
const transientChecks = report.checks.failing.filter((f) => (f.failureKind === "timeout" || f.failureKind === "cancelled") && f.runId !== null);
|
|
107
|
-
if (transientChecks.length > 0) {
|
|
108
|
-
return buildRerunCiResult(transientChecks, base, prNumber, stallKey, stallTimeoutSeconds, headSha, report, reviewSummaryIds);
|
|
109
|
-
}
|
|
110
116
|
const canMarkReady = report.status === "READY" &&
|
|
111
117
|
report.mergeStatus.isDraft &&
|
|
112
118
|
!report.mergeStatus.copilotReviewInProgress &&
|
|
@@ -28,22 +28,26 @@ export function renderResolveCommand(rc) {
|
|
|
28
28
|
}
|
|
29
29
|
return parts.join(" ");
|
|
30
30
|
}
|
|
31
|
-
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount) {
|
|
31
|
+
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = []) {
|
|
32
32
|
const instructions = [];
|
|
33
|
+
const hasSuggestions = threads.some((t) => t.suggestion);
|
|
34
|
+
if (hasSuggestions) {
|
|
35
|
+
instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` the CLI already resolved the thread — remove its ID from \`--resolve-thread-ids\` in the \`resolve:\` command below. On \`applied: false\` read \`reason\` and \`patch\`, fall through to the manual-edit step, and do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) to preview the patch without mutating the working tree.`);
|
|
36
|
+
}
|
|
33
37
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
34
|
-
|
|
38
|
+
const suggestionFallback = hasSuggestions
|
|
39
|
+
? ` 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.`
|
|
40
|
+
: "";
|
|
41
|
+
instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
|
|
35
42
|
}
|
|
36
|
-
// Mirror the truthiness checks in `formatIterateResult` (cli/iterate-formatter.mts) so each
|
|
37
|
-
// AgentCheck maps to the same bullet shape here as there: runId → runId
|
|
38
|
-
// bullet, else detailsUrl → external bullet, else `(no runId)` bullet.
|
|
39
43
|
const checksWithRunId = checks.filter((c) => c.runId);
|
|
40
44
|
const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
|
|
41
45
|
const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
|
|
42
46
|
if (checksWithRunId.length > 0) {
|
|
43
|
-
instructions.push(`For each
|
|
47
|
+
instructions.push(`For each failing check under \`## Failing checks\` with a run ID, examine the log tail in the fenced block to decide what to do:\n - If the log tail shows a transient runner or infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\` and stop this iteration — CI will re-run automatically.\n - If the log tail shows a real test or build failure, apply a code fix.\n - If the fenced log block is absent, run \`gh run view <runId> --log-failed\` first to fetch it, then choose between rerun and fix above.`);
|
|
44
48
|
}
|
|
45
49
|
if (externalChecks.length > 0) {
|
|
46
|
-
instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure —
|
|
50
|
+
instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — log tails are not available for external checks.`);
|
|
47
51
|
}
|
|
48
52
|
if (bareChecks.length > 0) {
|
|
49
53
|
instructions.push(`For each bullet in \`## Failing checks\` starting with \`(no runId)\`: there is no run or details URL to inspect. Escalate these to a human — they require manual investigation outside the pr-shepherd flow.`);
|
|
@@ -84,6 +88,13 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
84
88
|
if (needsPush && cancelledCount > 0) {
|
|
85
89
|
instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — the CLI cancelled those runs before your push, and your push has already triggered new runs with different IDs.`);
|
|
86
90
|
}
|
|
91
|
+
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
92
|
+
if (firstLookTotal > 0) {
|
|
93
|
+
instructions.push(`Items in \`## First-look items\` are for acknowledgement only — do not pass their IDs to \`--resolve-thread-ids\`, \`--minimize-comment-ids\`, or \`--dismiss-review-ids\`. Acknowledge each one with a one-line classification (e.g. "outdated — addressed by commit abc1234", "resolved — already fixed", "minimized — noise").`);
|
|
94
|
+
}
|
|
95
|
+
if (resolveCommand.hasMutations) {
|
|
96
|
+
instructions.push(`For any large decisions or rejections you made this iteration, add or update a \`## Shepherd Journal\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision. For threads and comments, use the markdown link shown in its heading above; for reviews, reference the review ID.`);
|
|
97
|
+
}
|
|
87
98
|
if (needsPush) {
|
|
88
99
|
instructions.push(`Stop this iteration — CI needs time to run on the new push before the next tick.`);
|
|
89
100
|
}
|
|
@@ -96,15 +107,20 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
96
107
|
return instructions;
|
|
97
108
|
}
|
|
98
109
|
export function buildWaitLog(base) {
|
|
99
|
-
const { summary,
|
|
110
|
+
const { summary, remainingSeconds } = base;
|
|
100
111
|
const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
|
|
101
|
-
switch (
|
|
112
|
+
switch (base.mergeStatus) {
|
|
113
|
+
case "BLOCKED":
|
|
114
|
+
if (base.reviewDecision === "REVIEW_REQUIRED")
|
|
115
|
+
parts.push("awaiting human review");
|
|
116
|
+
else if (base.reviewDecision === "APPROVED")
|
|
117
|
+
parts.push("awaiting additional approvals");
|
|
118
|
+
else
|
|
119
|
+
parts.push("awaiting human review or branch protection");
|
|
120
|
+
break;
|
|
102
121
|
case "BEHIND":
|
|
103
122
|
parts.push("branch is behind base");
|
|
104
123
|
break;
|
|
105
|
-
case "BLOCKED":
|
|
106
|
-
parts.push("blocked by pending reviews or required status checks");
|
|
107
|
-
break;
|
|
108
124
|
case "DRAFT":
|
|
109
125
|
parts.push("PR is a draft");
|
|
110
126
|
break;
|
|
@@ -3,7 +3,7 @@ import { toAgentThread, toAgentComment } from "../../reporters/agent.mjs";
|
|
|
3
3
|
import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
|
|
4
4
|
export function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
|
|
5
5
|
const checks = [
|
|
6
|
-
...report.checks.failing.map((f) => `failing:${f.name}:${f.
|
|
6
|
+
...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
|
|
7
7
|
...report.checks.inProgress.map((p) => `inProgress:${p.name}`),
|
|
8
8
|
].sort();
|
|
9
9
|
const threads = report.threads.actionable.map((t) => t.id).sort();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getRepoInfo } from "../github/client.mjs";
|
|
2
|
+
import { resolveLogPath } from "../log/log-file.mjs";
|
|
3
|
+
export async function runLogFile() {
|
|
4
|
+
const { owner, name } = await getRepoInfo();
|
|
5
|
+
const path = await resolveLogPath({ owner, repo: name });
|
|
6
|
+
return { path };
|
|
7
|
+
}
|
package/bin/commands/monitor.mjs
CHANGED
|
@@ -16,7 +16,12 @@ export async function runMonitor(opts) {
|
|
|
16
16
|
if (!Number.isFinite(maxTurns) || maxTurns <= 0 || !Number.isInteger(maxTurns)) {
|
|
17
17
|
throw new Error(`Invalid config: watch.maxTurns must be a positive integer, got ${JSON.stringify(maxTurns)}`);
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// No space after `#` — `# text` is a CommonMark ATX heading; `#text` is not.
|
|
20
|
+
// Trailing `:` prevents substring false positives: without it, the dedup grep
|
|
21
|
+
// for pr=1 would match a cron prompt for pr=135. Both the CronList check in
|
|
22
|
+
// step 1 of formatMonitorResult's ## Instructions and the in-prompt Self-dedup
|
|
23
|
+
// block depend on this exact string — don't change the format.
|
|
24
|
+
const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
|
|
20
25
|
const loopPrompt = buildLoopPrompt(prNumber, loopTag, opts.readyDelaySuffix);
|
|
21
26
|
const loopArgs = `${interval} --max-turns ${maxTurns} --expires ${expiresHours}h`;
|
|
22
27
|
return { prNumber, loopTag, loopArgs, loopPrompt };
|
|
@@ -56,9 +61,7 @@ function validateReadyDelaySuffix(readyDelaySuffix) {
|
|
|
56
61
|
}
|
|
57
62
|
function buildLoopPrompt(prNumber, loopTag, readyDelaySuffix) {
|
|
58
63
|
const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
|
|
59
|
-
const iterateCmd = validatedDelay
|
|
60
|
-
? `npx pr-shepherd iterate ${prNumber} --no-cache --ready-delay ${validatedDelay}`
|
|
61
|
-
: `npx pr-shepherd iterate ${prNumber} --no-cache`;
|
|
64
|
+
const iterateCmd = `npx pr-shepherd iterate ${prNumber}${validatedDelay ? ` --ready-delay ${validatedDelay}` : ""}`;
|
|
62
65
|
return [
|
|
63
66
|
loopTag,
|
|
64
67
|
"",
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
9
9
|
import { join, dirname } from "node:path";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
10
|
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
11
|
+
import { resolveStateBase } from "../state/base.mjs";
|
|
12
12
|
/**
|
|
13
13
|
* Update the ready-delay state machine and return the current decision.
|
|
14
14
|
*
|
|
@@ -64,7 +64,7 @@ function readySincePath(pr, owner, repo) {
|
|
|
64
64
|
throw new Error(`Invalid path segment "${field}": ${value}`);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
const base =
|
|
67
|
+
const base = resolveStateBase();
|
|
68
68
|
return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
|
|
69
69
|
}
|
|
70
70
|
async function safeUnlink(path) {
|