pr-shepherd 0.9.0 → 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 +2 -2
- package/bin/checks/triage.mjs +32 -40
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +21 -24
- package/bin/cli/formatters.mjs +33 -51
- package/bin/cli/handlers.mjs +1 -1
- package/bin/cli/iterate-formatter.mjs +20 -35
- package/bin/cli/iterate-lean.mjs +0 -3
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +1 -2
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check.mjs +1 -2
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +5 -29
- package/bin/commands/iterate/escalate.mjs +3 -12
- package/bin/commands/iterate/fix-code.mjs +4 -9
- package/bin/commands/iterate/render.mjs +9 -2
- 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 +5 -2
- package/bin/commands/resolve.mjs +1 -20
- package/bin/commands/status.mjs +40 -28
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +2 -2
- package/bin/github/batch-parsers.mjs +2 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +4 -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 +14 -1
- package/bin/reporters/text.mjs +81 -102
- 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 +2 -2
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +2 -2
- 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/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Loop args: `4m --max-turns 50 --expires 8h`
|
|
|
40
40
|
after completing the actions below. The cron job handles the next fire.
|
|
41
41
|
|
|
42
42
|
Run in a single Bash call:
|
|
43
|
-
npx pr-shepherd iterate 123
|
|
43
|
+
npx pr-shepherd iterate 123
|
|
44
44
|
|
|
45
45
|
…(self-dedup guidance, error-handling instructions)…
|
|
46
46
|
|
|
@@ -231,7 +231,7 @@ actions:
|
|
|
231
231
|
autoMarkReady: false # disable to stay draft until you manually promote
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
-
Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state base dir).
|
|
234
|
+
Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log).
|
|
235
235
|
|
|
236
236
|
See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
|
|
237
237
|
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -1,44 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Triage failing check runs: call the jobs API once per runId to fetch
|
|
3
|
-
* workflow name, job name, and the first failed step name. Then fetch the
|
|
4
|
-
* last N lines of the failing job's log so the agent can diagnose failures
|
|
5
|
-
* without a separate tool call.
|
|
6
|
-
*
|
|
7
|
-
* No heuristic classification is applied — the raw GitHub `conclusion` field
|
|
8
|
-
* is preserved as-is. The agent decides whether a failure is transient or
|
|
9
|
-
* real based on the log tail and other context.
|
|
10
|
-
*
|
|
11
|
-
* For checks with runId === null (external StatusContexts) no API calls are
|
|
12
|
-
* made; workflowName, jobName, failedStep, and logTail are all absent.
|
|
13
|
-
*
|
|
14
|
-
* Jobs responses are cached by runId so checks that share a run (e.g. matrix
|
|
15
|
-
* builds or multiple required steps in one workflow) make only one API call.
|
|
16
|
-
* Log tails are fetched per job ID, not per runId.
|
|
17
|
-
*/
|
|
18
1
|
import { rest, restText } from "../github/http.mjs";
|
|
19
|
-
|
|
20
|
-
// Public API
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
/**
|
|
23
|
-
* Triage each failing check: call the jobs API for each check with a non-null
|
|
24
|
-
* runId to fetch workflow name, job name, and the first failed step name, then
|
|
25
|
-
* fetch the last `logTailLines` lines of the failing job's log.
|
|
26
|
-
*/
|
|
27
|
-
export function triageFailingChecks(failingChecks, repo, logTailLines) {
|
|
2
|
+
export function triageFailingChecks(failingChecks, repo, logTailLines, logTailChars = 200) {
|
|
28
3
|
const jobsCache = new Map();
|
|
29
|
-
return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, logTailLines)));
|
|
4
|
+
return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, logTailLines, logTailChars)));
|
|
30
5
|
}
|
|
31
|
-
|
|
32
|
-
// Internal
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
async function triageCheck(check, repo, jobsCache, logTailLines) {
|
|
6
|
+
async function triageCheck(check, repo, jobsCache, logTailLines, logTailChars) {
|
|
35
7
|
if (check.runId === null) {
|
|
36
8
|
return { ...check };
|
|
37
9
|
}
|
|
38
10
|
const jobs = await fetchJobs(check.runId, repo, jobsCache);
|
|
39
11
|
const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
|
|
40
12
|
const logTail = jobInfo?.jobId !== undefined && logTailLines > 0
|
|
41
|
-
? await fetchLogTail(jobInfo.jobId, repo, logTailLines)
|
|
13
|
+
? await fetchLogTail(jobInfo.jobId, repo, logTailLines, logTailChars, jobInfo.failedStep)
|
|
42
14
|
: undefined;
|
|
43
15
|
return {
|
|
44
16
|
...check,
|
|
@@ -80,9 +52,6 @@ async function fetchJobsUncached(runId, repo) {
|
|
|
80
52
|
return allJobs;
|
|
81
53
|
}
|
|
82
54
|
function pickJobInfo(jobs, checkName) {
|
|
83
|
-
// Match by name. For matrix jobs sharing a check name, prefer a failing one.
|
|
84
|
-
// Fall back to prefix matching for matrix jobs whose workflow-API name includes
|
|
85
|
-
// a suffix like "(ubuntu)" while checkName is just the base name.
|
|
86
55
|
const exactMatches = jobs.filter((j) => j.name === checkName);
|
|
87
56
|
const matchedJobs = exactMatches.length > 0 ? exactMatches : jobs.filter((j) => j.name.startsWith(checkName));
|
|
88
57
|
const job = matchedJobs.find((j) => j.conclusion === "failure") ??
|
|
@@ -101,16 +70,39 @@ function pickJobInfo(jobs, checkName) {
|
|
|
101
70
|
jobId: job.id,
|
|
102
71
|
};
|
|
103
72
|
}
|
|
104
|
-
async function fetchLogTail(jobId, repo, logTailLines) {
|
|
73
|
+
async function fetchLogTail(jobId, repo, logTailLines, logTailChars, failedStepName) {
|
|
105
74
|
const { owner, name } = repo;
|
|
106
75
|
try {
|
|
107
76
|
const text = await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`);
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
77
|
+
const allLines = text.split("\n");
|
|
78
|
+
const stepLines = failedStepName ? extractStepLines(allLines, failedStepName) : null;
|
|
79
|
+
const lines = stepLines ?? allLines;
|
|
80
|
+
const tail = lines.length <= logTailLines ? lines.join("\n") : lines.slice(-logTailLines).join("\n");
|
|
81
|
+
return tail.length <= logTailChars ? tail : tail.slice(-logTailChars);
|
|
112
82
|
}
|
|
113
83
|
catch {
|
|
114
84
|
return undefined;
|
|
115
85
|
}
|
|
116
86
|
}
|
|
87
|
+
// Extract the lines inside the ##[group]..##[endgroup] section for the named step.
|
|
88
|
+
// Returns null when no matching group is found so the caller falls back to the full log.
|
|
89
|
+
function extractStepLines(lines, stepName) {
|
|
90
|
+
const lowerStep = stepName.toLowerCase();
|
|
91
|
+
let inStep = false;
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const content = line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
|
|
95
|
+
if (!inStep) {
|
|
96
|
+
if (content.startsWith("##[group]") && content.slice(9).toLowerCase().includes(lowerStep)) {
|
|
97
|
+
inStep = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (content.startsWith("##[endgroup]")) {
|
|
101
|
+
inStep = false;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
result.push(line);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result.length > 0 ? result : null;
|
|
108
|
+
}
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { renderResolveCommand } from "../commands/iterate.mjs";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
2
|
+
import { safeFence } from "./fence.mjs";
|
|
3
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
4
|
+
import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
|
|
5
|
+
import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
|
|
6
6
|
export function formatFixCodeResult(header, result) {
|
|
7
7
|
const sections = [header];
|
|
8
8
|
if (result.fix.threads.length > 0) {
|
|
9
9
|
sections.push("## Review threads");
|
|
10
10
|
for (const t of result.fix.threads) {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
11
|
+
const lineLabel = renderLineRange(t.startLine, t.line);
|
|
12
|
+
const loc = t.path ? `\`${t.path}:${lineLabel}\`` : "(no location)";
|
|
13
|
+
const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
|
|
14
|
+
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
15
|
+
sections.push(`### ${heading} — ${loc} (@${t.author})${suggestionMarker}`);
|
|
14
16
|
sections.push(blockquote(t.body));
|
|
17
|
+
if (t.suggestion) {
|
|
18
|
+
sections.push(renderSuggestionBlock(t.suggestion, ""));
|
|
19
|
+
}
|
|
15
20
|
}
|
|
16
21
|
}
|
|
17
22
|
if (result.fix.actionableComments.length > 0) {
|
|
18
23
|
sections.push("## Actionable comments");
|
|
19
24
|
for (const c of result.fix.actionableComments) {
|
|
20
|
-
const heading = c.url ? `[
|
|
25
|
+
const heading = c.url ? `[commentId=${c.id}](${c.url})` : `\`commentId=${c.id}\``;
|
|
21
26
|
sections.push(`### ${heading} (@${c.author})`);
|
|
22
27
|
sections.push(blockquote(c.body));
|
|
23
28
|
}
|
|
@@ -49,20 +54,16 @@ export function formatFixCodeResult(header, result) {
|
|
|
49
54
|
}
|
|
50
55
|
if (result.fix.changesRequestedReviews.length > 0) {
|
|
51
56
|
sections.push("## Changes-requested reviews");
|
|
52
|
-
sections.push(result.fix.changesRequestedReviews.map((r) =>
|
|
53
|
-
}
|
|
54
|
-
if (result.fix.noiseCommentIds.length > 0) {
|
|
55
|
-
sections.push("## Noise (minimize only)");
|
|
56
|
-
sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
|
|
57
|
+
sections.push(result.fix.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
|
|
57
58
|
}
|
|
58
59
|
if (result.fix.reviewSummaryIds.length > 0) {
|
|
59
60
|
sections.push("## Review summaries (minimize only)");
|
|
60
|
-
sections.push(result.fix.reviewSummaryIds.map((id) =>
|
|
61
|
+
sections.push(result.fix.reviewSummaryIds.map((id) => `- \`${id}\``).join("\n"));
|
|
61
62
|
}
|
|
62
63
|
if (result.fix.surfacedApprovals.length > 0) {
|
|
63
64
|
sections.push("## Approvals (surfaced — not minimized)");
|
|
64
65
|
for (const r of result.fix.surfacedApprovals) {
|
|
65
|
-
sections.push(`###
|
|
66
|
+
sections.push(`### \`reviewId=${r.id}\` (@${r.author})`);
|
|
66
67
|
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -71,20 +72,16 @@ export function formatFixCodeResult(header, result) {
|
|
|
71
72
|
sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
|
|
72
73
|
const bullets = [];
|
|
73
74
|
for (const t of result.fix.firstLookThreads) {
|
|
74
|
-
|
|
75
|
-
? `[status: outdated, auto-resolved]`
|
|
76
|
-
: `[status: ${t.firstLookStatus}]`;
|
|
77
|
-
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
78
|
-
bullets.push(`- \`${t.id}\` ${loc} (@${t.author}) ${statusTag}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
|
|
75
|
+
bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
79
76
|
}
|
|
80
77
|
for (const c of result.fix.firstLookComments) {
|
|
81
|
-
bullets.push(
|
|
78
|
+
bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
|
|
82
79
|
}
|
|
83
80
|
sections.push(bullets.join("\n"));
|
|
84
81
|
}
|
|
85
82
|
if (result.cancelled.length > 0) {
|
|
86
83
|
sections.push("## Cancelled runs");
|
|
87
|
-
sections.push(result.cancelled.map((id) =>
|
|
84
|
+
sections.push(result.cancelled.map((id) => `- \`${id}\``).join("\n"));
|
|
88
85
|
}
|
|
89
86
|
sections.push("## Post-fix push");
|
|
90
87
|
const postFixLines = [`- base: \`${result.baseBranch}\``];
|
|
@@ -94,9 +91,9 @@ export function formatFixCodeResult(header, result) {
|
|
|
94
91
|
sections.push(postFixLines.join("\n"));
|
|
95
92
|
sections.push("## Instructions");
|
|
96
93
|
sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
|
|
97
|
-
return sections
|
|
94
|
+
return joinSections(sections);
|
|
98
95
|
}
|
|
99
|
-
|
|
96
|
+
function blockquote(body) {
|
|
100
97
|
return body
|
|
101
98
|
.replace(/\r\n/g, "\n")
|
|
102
99
|
.split("\n")
|
package/bin/cli/formatters.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { formatIterateResult } from "./iterate-formatter.mjs";
|
|
2
2
|
export { projectIterateLean } from "./iterate-lean.mjs";
|
|
3
|
+
import { safeFence } from "./fence.mjs";
|
|
4
|
+
import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
|
|
5
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
3
6
|
export function formatFetchResult(result) {
|
|
4
7
|
const activeTotal = result.actionableThreads.length +
|
|
5
8
|
result.actionableComments.length +
|
|
@@ -19,44 +22,29 @@ export function formatFetchResult(result) {
|
|
|
19
22
|
sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
|
|
20
23
|
(result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
|
|
21
24
|
sections.push(result.actionableThreads
|
|
22
|
-
.map((t) => {
|
|
23
|
-
|
|
24
|
-
const link = t.url ? ` [↗](${t.url})` : "";
|
|
25
|
-
return `- \`threadId=${t.id}\`${link} \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
|
|
26
|
-
})
|
|
27
|
-
.join("\n"));
|
|
25
|
+
.map((t) => renderThreadBullet(t, { renderSuggestion: true }))
|
|
26
|
+
.join("\n\n"));
|
|
28
27
|
}
|
|
29
28
|
if (result.actionableComments.length > 0) {
|
|
30
29
|
sections.push(`## Actionable PR Comments (${result.actionableComments.length})`);
|
|
31
|
-
sections.push(result.actionableComments
|
|
32
|
-
.map((c) => {
|
|
33
|
-
const link = c.url ? ` [↗](${c.url})` : "";
|
|
34
|
-
return `- \`commentId=${c.id}\`${link} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
|
|
35
|
-
})
|
|
36
|
-
.join("\n"));
|
|
30
|
+
sections.push(result.actionableComments.map((c) => renderCommentBullet(c)).join("\n"));
|
|
37
31
|
}
|
|
38
32
|
if (result.changesRequestedReviews.length > 0) {
|
|
39
33
|
sections.push(`## Pending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length})`);
|
|
40
|
-
sections.push(result.changesRequestedReviews.map((r) =>
|
|
34
|
+
sections.push(result.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
|
|
41
35
|
}
|
|
42
36
|
if (result.reviewSummaries.length > 0) {
|
|
43
37
|
sections.push(`## Review summaries (${result.reviewSummaries.length})`);
|
|
44
|
-
sections.push(result.reviewSummaries
|
|
45
|
-
.map((r) => `- \`reviewId=${r.id}\` (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`)
|
|
46
|
-
.join("\n"));
|
|
38
|
+
sections.push(result.reviewSummaries.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n"));
|
|
47
39
|
}
|
|
48
40
|
if (firstLookTotal > 0) {
|
|
49
41
|
sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
|
|
50
42
|
const bullets = [];
|
|
51
43
|
for (const t of result.firstLookThreads) {
|
|
52
|
-
|
|
53
|
-
? `[status: outdated, auto-resolved]`
|
|
54
|
-
: `[status: ${t.firstLookStatus}]`;
|
|
55
|
-
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
56
|
-
bullets.push(`- \`threadId=${t.id}\` ${loc} (@${t.author}) ${statusTag}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
|
|
44
|
+
bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
57
45
|
}
|
|
58
46
|
for (const c of result.firstLookComments) {
|
|
59
|
-
bullets.push(
|
|
47
|
+
bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
|
|
60
48
|
}
|
|
61
49
|
sections.push(bullets.join("\n"));
|
|
62
50
|
}
|
|
@@ -71,14 +59,21 @@ export function formatFetchResult(result) {
|
|
|
71
59
|
.join(", "));
|
|
72
60
|
sections.push("## Instructions");
|
|
73
61
|
sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
|
|
74
|
-
return
|
|
62
|
+
return joinSections(sections);
|
|
75
63
|
}
|
|
76
64
|
export function formatCommitSuggestionResult(result) {
|
|
77
65
|
const lines = [];
|
|
66
|
+
const range = result.startLine === result.endLine
|
|
67
|
+
? `line ${result.startLine}`
|
|
68
|
+
: `lines ${result.startLine}–${result.endLine}`;
|
|
69
|
+
function pushPatch(patch) {
|
|
70
|
+
const fence = safeFence(patch);
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push(`${fence}diff`);
|
|
73
|
+
lines.push(patch.trimEnd());
|
|
74
|
+
lines.push(fence);
|
|
75
|
+
}
|
|
78
76
|
if (result.dryRun) {
|
|
79
|
-
const range = result.startLine === result.endLine
|
|
80
|
-
? `line ${result.startLine}`
|
|
81
|
-
: `lines ${result.startLine}-${result.endLine}`;
|
|
82
77
|
if (result.valid) {
|
|
83
78
|
lines.push(`Dry-run: would apply suggestion from @${result.author}:`);
|
|
84
79
|
lines.push(` ${result.path} (${range})`);
|
|
@@ -89,45 +84,32 @@ export function formatCommitSuggestionResult(result) {
|
|
|
89
84
|
lines.push(`- author: @${result.author}`);
|
|
90
85
|
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
91
86
|
}
|
|
92
|
-
if (result.patch)
|
|
93
|
-
|
|
94
|
-
lines.push("```diff");
|
|
95
|
-
lines.push(result.patch.trimEnd());
|
|
96
|
-
lines.push("```");
|
|
97
|
-
}
|
|
87
|
+
if (result.patch)
|
|
88
|
+
pushPatch(result.patch);
|
|
98
89
|
}
|
|
99
90
|
else if (result.applied) {
|
|
100
91
|
lines.push(`Applied suggestion from @${result.author}:`);
|
|
101
|
-
const range = result.startLine === result.endLine
|
|
102
|
-
? `line ${result.startLine}`
|
|
103
|
-
: `lines ${result.startLine}-${result.endLine}`;
|
|
104
92
|
lines.push(` ${result.path} (${range})`);
|
|
105
93
|
if (result.commitSha)
|
|
106
94
|
lines.push(`Commit: ${result.commitSha}`);
|
|
107
|
-
if (result.patch)
|
|
108
|
-
|
|
109
|
-
lines.push("```diff");
|
|
110
|
-
lines.push(result.patch.trimEnd());
|
|
111
|
-
lines.push("```");
|
|
112
|
-
}
|
|
95
|
+
if (result.patch)
|
|
96
|
+
pushPatch(result.patch);
|
|
113
97
|
}
|
|
114
98
|
else {
|
|
115
99
|
lines.push(`Failed to apply suggestion ${result.threadId}:`);
|
|
116
|
-
lines.push(`- path: ${result.path} (
|
|
100
|
+
lines.push(`- path: ${result.path} (${range})`);
|
|
117
101
|
lines.push(`- author: @${result.author}`);
|
|
118
102
|
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
119
|
-
if (result.patch)
|
|
120
|
-
|
|
121
|
-
lines.push("```diff");
|
|
122
|
-
lines.push(result.patch.trimEnd());
|
|
123
|
-
lines.push("```");
|
|
124
|
-
}
|
|
103
|
+
if (result.patch)
|
|
104
|
+
pushPatch(result.patch);
|
|
125
105
|
}
|
|
126
106
|
if (result.postActionInstruction) {
|
|
127
107
|
lines.push("");
|
|
128
|
-
lines.push(
|
|
108
|
+
lines.push("## Instructions");
|
|
109
|
+
lines.push("");
|
|
110
|
+
lines.push(`1. ${result.postActionInstruction}`);
|
|
129
111
|
}
|
|
130
|
-
return
|
|
112
|
+
return lines.join("\n");
|
|
131
113
|
}
|
|
132
114
|
export function formatMutateResult(result) {
|
|
133
115
|
const lines = [];
|
|
@@ -139,5 +121,5 @@ export function formatMutateResult(result) {
|
|
|
139
121
|
lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
|
|
140
122
|
if (result.errors.length)
|
|
141
123
|
lines.push(`Errors:\n ${result.errors.join("\n ")}`);
|
|
142
|
-
return
|
|
124
|
+
return lines.join("\n");
|
|
143
125
|
}
|
package/bin/cli/handlers.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export async function handleCommitSuggestion(args) {
|
|
|
34
34
|
});
|
|
35
35
|
process.stdout.write(globalOpts.format === "json"
|
|
36
36
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
37
|
-
: formatCommitSuggestionResult(result));
|
|
37
|
+
: `${formatCommitSuggestionResult(result)}\n`);
|
|
38
38
|
if (result.dryRun) {
|
|
39
39
|
process.exitCode = result.valid ? 0 : 1;
|
|
40
40
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatFixCodeResult } from "./fix-formatter.mjs";
|
|
2
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
2
3
|
/**
|
|
3
4
|
* Format an IterateResult as human-readable Markdown.
|
|
4
5
|
*
|
|
@@ -48,51 +49,35 @@ export function formatIterateResult(result, opts) {
|
|
|
48
49
|
case "cooldown":
|
|
49
50
|
// In default mode: suppress base/summary lines — cooldown carries UNKNOWN/empty
|
|
50
51
|
// placeholders that add no value. Emit only heading + log + Instructions.
|
|
51
|
-
return [
|
|
52
|
+
return joinSections([
|
|
52
53
|
verbose ? header : heading,
|
|
53
|
-
"",
|
|
54
54
|
result.log,
|
|
55
|
-
"",
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
].join("\n");
|
|
60
|
-
case "wait": {
|
|
61
|
-
const parts = [
|
|
55
|
+
"## Instructions\n\n1. End this iteration — the next cron fire will recheck once CI starts reporting.",
|
|
56
|
+
]);
|
|
57
|
+
case "wait":
|
|
58
|
+
return joinSections([
|
|
62
59
|
header,
|
|
63
60
|
result.log,
|
|
64
|
-
"## Instructions",
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
case "mark_ready": {
|
|
70
|
-
const parts = [
|
|
61
|
+
"## Instructions\n\n1. End this iteration — the next cron fire will recheck.",
|
|
62
|
+
]);
|
|
63
|
+
case "mark_ready":
|
|
64
|
+
return joinSections([
|
|
71
65
|
header,
|
|
72
66
|
result.log,
|
|
73
|
-
"## Instructions",
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return
|
|
77
|
-
}
|
|
78
|
-
case "cancel": {
|
|
79
|
-
const parts = [
|
|
67
|
+
"## Instructions\n\n1. The CLI already marked the PR ready for review — end this iteration.",
|
|
68
|
+
]);
|
|
69
|
+
case "cancel":
|
|
70
|
+
return joinSections([
|
|
80
71
|
[`${heading} — ${result.reason}`, "", baseLine, summaryLine].join("\n"),
|
|
81
72
|
result.log,
|
|
82
|
-
"## Instructions",
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
case "escalate": {
|
|
88
|
-
const parts = [
|
|
73
|
+
"## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
|
|
74
|
+
]);
|
|
75
|
+
case "escalate":
|
|
76
|
+
return joinSections([
|
|
89
77
|
header,
|
|
90
78
|
result.escalate.humanMessage,
|
|
91
|
-
"## Instructions",
|
|
92
|
-
|
|
93
|
-
];
|
|
94
|
-
return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
|
|
95
|
-
}
|
|
79
|
+
"## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
|
|
80
|
+
]);
|
|
96
81
|
case "fix_code":
|
|
97
82
|
return formatFixCodeResult(header, result);
|
|
98
83
|
}
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -49,9 +49,6 @@ export function projectIterateLean(result) {
|
|
|
49
49
|
...(result.fix.actionableComments.length > 0 && {
|
|
50
50
|
actionableComments: result.fix.actionableComments,
|
|
51
51
|
}),
|
|
52
|
-
...(result.fix.noiseCommentIds.length > 0 && {
|
|
53
|
-
noiseCommentIds: result.fix.noiseCommentIds,
|
|
54
|
-
}),
|
|
55
52
|
...(result.fix.reviewSummaryIds.length > 0 && {
|
|
56
53
|
reviewSummaryIds: result.fix.reviewSummaryIds,
|
|
57
54
|
}),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
|
|
2
|
+
const BODY_PREVIEW_MAX = 100;
|
|
3
|
+
export function renderBodyPreview(body) {
|
|
4
|
+
const normalizedBody = body.replace(/\r\n?/g, "\n");
|
|
5
|
+
const firstLine = normalizedBody.split("\n")[0]?.trim() ?? "";
|
|
6
|
+
return firstLine.slice(0, BODY_PREVIEW_MAX);
|
|
7
|
+
}
|
|
8
|
+
export function renderFirstLookStatusTag(t) {
|
|
9
|
+
return t.autoResolved ? "[status: outdated, auto-resolved]" : `[status: ${t.firstLookStatus}]`;
|
|
10
|
+
}
|
|
11
|
+
export function renderThreadBullet(t, opts = {}) {
|
|
12
|
+
const link = t.url ? ` [↗](${t.url})` : "";
|
|
13
|
+
const loc = t.path
|
|
14
|
+
? `\`${t.path}:${renderLineRange(t.startLine ?? undefined, t.line ?? null)}\``
|
|
15
|
+
: "`(no location)`";
|
|
16
|
+
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
17
|
+
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
18
|
+
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (@${t.author})${suggestionMarker}${statusSuffix}: ${renderBodyPreview(t.body)}`;
|
|
19
|
+
if (t.suggestion && opts.renderSuggestion) {
|
|
20
|
+
return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
|
|
21
|
+
}
|
|
22
|
+
return bulletLine;
|
|
23
|
+
}
|
|
24
|
+
export function renderCommentBullet(c, opts = {}) {
|
|
25
|
+
const link = c.url ? ` [↗](${c.url})` : "";
|
|
26
|
+
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
27
|
+
return `- \`commentId=${c.id}\`${link} (@${c.author})${statusSuffix}: ${renderBodyPreview(c.body)}`;
|
|
28
|
+
}
|
|
29
|
+
export function renderReviewBullet(r, opts = {}) {
|
|
30
|
+
const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
|
|
31
|
+
return `- \`reviewId=${r.id}\` (@${r.author})${bodySuffix}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { safeFence } from "./fence.mjs";
|
|
2
|
+
export function renderLineRange(startLine, endLine) {
|
|
3
|
+
if (endLine === null)
|
|
4
|
+
return "?";
|
|
5
|
+
if (startLine !== undefined && startLine !== endLine)
|
|
6
|
+
return `${startLine}-${endLine}`;
|
|
7
|
+
return String(endLine);
|
|
8
|
+
}
|
|
9
|
+
export function renderSuggestionBlock(s, indent = " ") {
|
|
10
|
+
const rangeLabel = s.startLine !== s.endLine ? `lines ${s.startLine}–${s.endLine}` : `line ${s.startLine}`;
|
|
11
|
+
const content = s.lines.join("\n");
|
|
12
|
+
const fence = safeFence(content);
|
|
13
|
+
const label = s.lines.length === 0
|
|
14
|
+
? `Replaces ${rangeLabel} with nothing:`
|
|
15
|
+
: s.lines.length === 1 && s.lines[0] === ""
|
|
16
|
+
? `Replaces ${rangeLabel} with a blank line:`
|
|
17
|
+
: `Replaces ${rangeLabel}:`;
|
|
18
|
+
return [
|
|
19
|
+
`${indent}${label}`,
|
|
20
|
+
`${indent}${fence}`,
|
|
21
|
+
...(content === ""
|
|
22
|
+
? []
|
|
23
|
+
: [
|
|
24
|
+
content
|
|
25
|
+
.split("\n")
|
|
26
|
+
.map((l) => `${indent}${l}`)
|
|
27
|
+
.join("\n"),
|
|
28
|
+
]),
|
|
29
|
+
`${indent}${fence}`,
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
@@ -29,7 +29,6 @@ export function makeIterateResult(action = "wait") {
|
|
|
29
29
|
mode: "rebase-and-push",
|
|
30
30
|
threads: [],
|
|
31
31
|
actionableComments: [],
|
|
32
|
-
noiseCommentIds: [],
|
|
33
32
|
reviewSummaryIds: [],
|
|
34
33
|
surfacedApprovals: [],
|
|
35
34
|
checks: [],
|
|
@@ -64,7 +63,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
64
63
|
ambiguousComments: [],
|
|
65
64
|
changesRequestedReviews: [],
|
|
66
65
|
suggestion: "check manually",
|
|
67
|
-
humanMessage: "⚠️
|
|
66
|
+
humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
|
|
68
67
|
},
|
|
69
68
|
};
|
|
70
69
|
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { runCheck } from "./commands/check.mjs";
|
|
20
20
|
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
21
|
+
import { runLogFile } from "./commands/log-file.mjs";
|
|
21
22
|
import { formatJson } from "./reporters/json.mjs";
|
|
22
23
|
import { formatText } from "./reporters/text.mjs";
|
|
23
24
|
import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
24
25
|
import { statusToExitCode } from "./cli/exit-codes.mjs";
|
|
25
26
|
import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
|
|
26
27
|
import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
|
|
28
|
+
import { setupLog } from "./log/setup.mjs";
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
28
30
|
// Entry
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
@@ -34,6 +36,13 @@ export async function main(argv) {
|
|
|
34
36
|
process.stdout.write(`${readVersion()}\n`);
|
|
35
37
|
return;
|
|
36
38
|
}
|
|
39
|
+
// log-file must run before the stdout tee and log init to avoid recursion.
|
|
40
|
+
if (subcommand === "log-file") {
|
|
41
|
+
await handleLogFile(args.slice(1));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// Initialize the per-worktree log and install a stdout tee.
|
|
45
|
+
await setupLog(argv);
|
|
37
46
|
switch (subcommand) {
|
|
38
47
|
case "check":
|
|
39
48
|
await handleCheck(args.slice(1));
|
|
@@ -55,7 +64,7 @@ export async function main(argv) {
|
|
|
55
64
|
break;
|
|
56
65
|
default:
|
|
57
66
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
58
|
-
process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status> [options]\n" +
|
|
67
|
+
process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status|log-file> [options]\n" +
|
|
59
68
|
" pr-shepherd --version | -v\n");
|
|
60
69
|
process.exitCode = 1;
|
|
61
70
|
return;
|
|
@@ -76,6 +85,21 @@ async function handleCheck(args) {
|
|
|
76
85
|
process.stdout.write(`${output}\n`);
|
|
77
86
|
process.exitCode = statusToExitCode(report.status);
|
|
78
87
|
}
|
|
88
|
+
async function handleLogFile(args) {
|
|
89
|
+
const jsonOut = args.some((a) => a === "--format=json") ||
|
|
90
|
+
(() => {
|
|
91
|
+
const idx = args.indexOf("--format");
|
|
92
|
+
return idx !== -1 && args[idx + 1] === "json";
|
|
93
|
+
})();
|
|
94
|
+
try {
|
|
95
|
+
const result = await runLogFile();
|
|
96
|
+
process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${result.path}\n`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
process.stderr.write(`pr-shepherd: log-file: ${String(e)}\n`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
79
103
|
async function handleResolve(args) {
|
|
80
104
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
81
105
|
const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
|
|
@@ -91,7 +115,7 @@ async function handleResolve(args) {
|
|
|
91
115
|
const result = await runResolveFetch({ ...globalOpts, prNumber });
|
|
92
116
|
process.stdout.write(globalOpts.format === "json"
|
|
93
117
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
94
|
-
: formatFetchResult(result));
|
|
118
|
+
: `${formatFetchResult(result)}\n`);
|
|
95
119
|
}
|
|
96
120
|
else {
|
|
97
121
|
const result = await runResolveMutate({
|
|
@@ -105,6 +129,6 @@ async function handleResolve(args) {
|
|
|
105
129
|
});
|
|
106
130
|
process.stdout.write(globalOpts.format === "json"
|
|
107
131
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
108
|
-
: formatMutateResult(result));
|
|
132
|
+
: `${formatMutateResult(result)}\n`);
|
|
109
133
|
}
|
|
110
134
|
}
|
package/bin/commands/check.mjs
CHANGED
|
@@ -54,9 +54,8 @@ export async function runCheck(opts) {
|
|
|
54
54
|
const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
|
|
55
55
|
const skipped = classifiedChecks.filter((c) => c.category === "skipped");
|
|
56
56
|
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
57
|
-
// Triage failures (fetch job info + log tails) — skipped when caller short-circuits early.
|
|
58
57
|
const triaged = failing.length > 0 && !opts.skipTriage
|
|
59
|
-
? await triageFailingChecks(failing, repo, config.checks.logTailLines)
|
|
58
|
+
? await triageFailingChecks(failing, repo, config.checks.logTailLines, config.checks.logTailChars)
|
|
60
59
|
: failing;
|
|
61
60
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
62
61
|
// Resolve threads and comments.
|