pr-shepherd 0.10.2 → 0.11.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 +1 -0
- package/bin/checks/triage.mjs +5 -46
- package/bin/cli/fix-formatter.mjs +20 -12
- package/bin/cli/formatters.mjs +18 -36
- package/bin/cli/handlers.mjs +2 -11
- package/bin/cli/iterate-lean.mjs +6 -0
- package/bin/cli/list-formatters.mjs +4 -1
- package/bin/cli-parser.iterate-fixtures.mjs +2 -0
- package/bin/commands/check.mjs +52 -53
- package/bin/commands/commit-suggestion.mjs +34 -80
- package/bin/commands/iterate/classify.mjs +15 -4
- package/bin/commands/iterate/fix-code.mjs +12 -3
- package/bin/commands/iterate/helpers.mjs +34 -0
- package/bin/commands/iterate/index.mjs +7 -3
- package/bin/commands/iterate/render.mjs +23 -46
- package/bin/commands/resolve-instructions.mjs +6 -1
- package/bin/commands/resolve.mjs +67 -22
- package/bin/config.json +1 -3
- package/bin/reporters/agent.mjs +6 -2
- package/bin/reporters/check-instructions.mjs +2 -2
- package/bin/reporters/text.mjs +2 -1
- package/bin/state/seen-comments.mjs +88 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,6 +128,7 @@ Recommendations:
|
|
|
128
128
|
- Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. As it uses `/loop`, it will continue working when your rate limit window is reset. The loop cancels automatically when the PR is merged, closed, or after the ready-delay elapses.
|
|
129
129
|
- Instruct your agents to write comments in a single review (comment, changes requested, or approved). This allows the review's comments/threads to be minimized or resolved together, keeping your pull request history clean. If you write inline comments outside of a review, each comment would still show up in the pull request history and take up space.
|
|
130
130
|
- Avoid sticky comments as they will continue to be hidden. Instead, just make a new comment, especially on reviews. If you really want sticky comments, instruct your agent to unhide/unminimize them when updating them.
|
|
131
|
+
- Avoid having automation edit comments, reviews, or threads in place because updated items get minimized. Instead, always make a new review, comment, thread, etc.
|
|
131
132
|
|
|
132
133
|
## Design Principles
|
|
133
134
|
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
|
-
import { rest
|
|
2
|
-
export function triageFailingChecks(failingChecks, repo
|
|
1
|
+
import { rest } from "../github/http.mjs";
|
|
2
|
+
export function triageFailingChecks(failingChecks, repo) {
|
|
3
3
|
const jobsCache = new Map();
|
|
4
|
-
return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache
|
|
4
|
+
return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
|
|
5
5
|
}
|
|
6
|
-
async function triageCheck(check, repo, jobsCache
|
|
7
|
-
if (check.runId === null) {
|
|
6
|
+
async function triageCheck(check, repo, jobsCache) {
|
|
7
|
+
if (check.runId === null || check.conclusion === "CANCELLED") {
|
|
8
8
|
return { ...check };
|
|
9
9
|
}
|
|
10
10
|
const jobs = await fetchJobs(check.runId, repo, jobsCache);
|
|
11
11
|
const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
|
|
12
|
-
const logTail = jobInfo?.jobId !== undefined && logTailLines > 0
|
|
13
|
-
? await fetchLogTail(jobInfo.jobId, repo, logTailLines, logTailChars, jobInfo.failedStep)
|
|
14
|
-
: undefined;
|
|
15
12
|
return {
|
|
16
13
|
...check,
|
|
17
14
|
...(jobInfo?.workflowName !== undefined && { workflowName: jobInfo.workflowName }),
|
|
18
15
|
...(jobInfo?.jobName !== undefined && { jobName: jobInfo.jobName }),
|
|
19
16
|
...(jobInfo?.failedStep !== undefined && { failedStep: jobInfo.failedStep }),
|
|
20
|
-
...(logTail !== undefined && { logTail }),
|
|
21
17
|
};
|
|
22
18
|
}
|
|
23
19
|
function fetchJobs(runId, repo, cache) {
|
|
@@ -67,42 +63,5 @@ function pickJobInfo(jobs, checkName) {
|
|
|
67
63
|
workflowName: job.workflow_name,
|
|
68
64
|
jobName: job.name,
|
|
69
65
|
failedStep,
|
|
70
|
-
jobId: job.id,
|
|
71
66
|
};
|
|
72
67
|
}
|
|
73
|
-
async function fetchLogTail(jobId, repo, logTailLines, logTailChars, failedStepName) {
|
|
74
|
-
const { owner, name } = repo;
|
|
75
|
-
try {
|
|
76
|
-
const text = await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`);
|
|
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);
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return undefined;
|
|
85
|
-
}
|
|
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,5 +1,4 @@
|
|
|
1
1
|
import { renderResolveCommand } from "../commands/iterate.mjs";
|
|
2
|
-
import { safeFence } from "./fence.mjs";
|
|
3
2
|
import { joinSections } from "../util/markdown.mjs";
|
|
4
3
|
import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
|
|
5
4
|
import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
|
|
@@ -37,16 +36,13 @@ export function formatFixCodeResult(header, result) {
|
|
|
37
36
|
: ch.detailsUrl
|
|
38
37
|
? `external \`${ch.detailsUrl}\``
|
|
39
38
|
: "(no runId)";
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
lines.push(` ${fence}`);
|
|
48
|
-
lines.push(ch.logTail.replace(/^/gm, " ").trimEnd());
|
|
49
|
-
lines.push(` ${fence}`);
|
|
39
|
+
const conclusionTag = ch.conclusion !== null ? ` [conclusion: ${ch.conclusion}]` : "";
|
|
40
|
+
const lines = [`- ${locator} — \`${workflowPrefix}${jobLabel}\`${conclusionTag}`];
|
|
41
|
+
if (ch.conclusion !== "CANCELLED") {
|
|
42
|
+
if (ch.failedStep)
|
|
43
|
+
lines.push(` > ${ch.failedStep}`);
|
|
44
|
+
if (ch.summary)
|
|
45
|
+
lines.push(` > ${ch.summary}`);
|
|
50
46
|
}
|
|
51
47
|
return lines.join("\n");
|
|
52
48
|
});
|
|
@@ -63,6 +59,13 @@ export function formatFixCodeResult(header, result) {
|
|
|
63
59
|
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
|
|
64
60
|
}
|
|
65
61
|
}
|
|
62
|
+
if (result.fix.editedSummaries.length > 0) {
|
|
63
|
+
sections.push("## Review summaries (edited since first look — already minimized; do not re-minimize)");
|
|
64
|
+
for (const r of result.fix.editedSummaries) {
|
|
65
|
+
sections.push(`### \`reviewId=${r.id}\` (@${r.author})`);
|
|
66
|
+
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
66
69
|
const firstLookSummaryIds = new Set(result.fix.firstLookSummaries.map((r) => r.id));
|
|
67
70
|
const seenSummaryIds = result.fix.reviewSummaryIds.filter((id) => !firstLookSummaryIds.has(id));
|
|
68
71
|
if (seenSummaryIds.length > 0) {
|
|
@@ -84,10 +87,15 @@ export function formatFixCodeResult(header, result) {
|
|
|
84
87
|
bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
85
88
|
}
|
|
86
89
|
for (const c of result.fix.firstLookComments) {
|
|
87
|
-
|
|
90
|
+
const editedSuffix = c.edited ? ", edited" : "";
|
|
91
|
+
bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
|
|
88
92
|
}
|
|
89
93
|
sections.push(bullets.join("\n"));
|
|
90
94
|
}
|
|
95
|
+
if (result.fix.inProgressRunIds.length > 0) {
|
|
96
|
+
sections.push("## In-progress runs");
|
|
97
|
+
sections.push(result.fix.inProgressRunIds.map((id) => `- \`${id}\``).join("\n"));
|
|
98
|
+
}
|
|
91
99
|
if (result.cancelled.length > 0) {
|
|
92
100
|
sections.push("## Cancelled runs");
|
|
93
101
|
sections.push(result.cancelled.map((id) => `- \`${id}\``).join("\n"));
|
package/bin/cli/formatters.mjs
CHANGED
|
@@ -44,7 +44,8 @@ export function formatFetchResult(result) {
|
|
|
44
44
|
bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
45
45
|
}
|
|
46
46
|
for (const c of result.firstLookComments) {
|
|
47
|
-
|
|
47
|
+
const editedSuffix = c.edited ? ", edited" : "";
|
|
48
|
+
bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
|
|
48
49
|
}
|
|
49
50
|
sections.push(bullets.join("\n"));
|
|
50
51
|
}
|
|
@@ -66,48 +67,29 @@ export function formatCommitSuggestionResult(result) {
|
|
|
66
67
|
const range = result.startLine === result.endLine
|
|
67
68
|
? `line ${result.startLine}`
|
|
68
69
|
: `lines ${result.startLine}–${result.endLine}`;
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
lines.push(`Suggestion from @${result.author} for PR #${result.pr} — thread ${result.threadId}:`);
|
|
71
|
+
lines.push(` repo: ${result.repo}`);
|
|
72
|
+
lines.push(` ${result.path} (${range})`);
|
|
73
|
+
if (result.patch) {
|
|
74
|
+
const fence = safeFence(result.patch);
|
|
71
75
|
lines.push("");
|
|
72
76
|
lines.push(`${fence}diff`);
|
|
73
|
-
lines.push(patch.trimEnd());
|
|
77
|
+
lines.push(result.patch.trimEnd());
|
|
74
78
|
lines.push(fence);
|
|
75
79
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
lines.push(`- path: ${result.path} (${range})`);
|
|
84
|
-
lines.push(`- author: @${result.author}`);
|
|
85
|
-
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
86
|
-
}
|
|
87
|
-
if (result.patch)
|
|
88
|
-
pushPatch(result.patch);
|
|
89
|
-
}
|
|
90
|
-
else if (result.applied) {
|
|
91
|
-
lines.push(`Applied suggestion from @${result.author}:`);
|
|
92
|
-
lines.push(` ${result.path} (${range})`);
|
|
93
|
-
if (result.commitSha)
|
|
94
|
-
lines.push(`Commit: ${result.commitSha}`);
|
|
95
|
-
if (result.patch)
|
|
96
|
-
pushPatch(result.patch);
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
lines.push(`Failed to apply suggestion ${result.threadId}:`);
|
|
100
|
-
lines.push(`- path: ${result.path} (${range})`);
|
|
101
|
-
lines.push(`- author: @${result.author}`);
|
|
102
|
-
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
103
|
-
if (result.patch)
|
|
104
|
-
pushPatch(result.patch);
|
|
105
|
-
}
|
|
106
|
-
if (result.postActionInstruction) {
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push("## Suggested commit message");
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(result.commitMessage);
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push(result.commitBody);
|
|
86
|
+
if (result.postActionInstructions.length > 0) {
|
|
107
87
|
lines.push("");
|
|
108
88
|
lines.push("## Instructions");
|
|
109
89
|
lines.push("");
|
|
110
|
-
|
|
90
|
+
result.postActionInstructions.forEach((inst, i) => {
|
|
91
|
+
lines.push(`${i + 1}. ${inst}`);
|
|
92
|
+
});
|
|
111
93
|
}
|
|
112
94
|
return lines.join("\n");
|
|
113
95
|
}
|
package/bin/cli/handlers.mjs
CHANGED
|
@@ -11,14 +11,12 @@ export async function handleCommitSuggestion(args) {
|
|
|
11
11
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
12
12
|
const threadId = getFlag(extra, "--thread-id");
|
|
13
13
|
if (!threadId) {
|
|
14
|
-
process.stderr.write("Usage: pr-shepherd commit-suggestion [PR] --thread-id ID
|
|
15
|
-
" (--message is required unless --dry-run is set)\n");
|
|
14
|
+
process.stderr.write("Usage: pr-shepherd commit-suggestion [PR] --thread-id ID --message MSG [--description DESC]\n");
|
|
16
15
|
process.exitCode = 1;
|
|
17
16
|
return;
|
|
18
17
|
}
|
|
19
|
-
const dryRun = hasFlag(extra, "--dry-run");
|
|
20
18
|
const message = getFlag(extra, "--message") ?? undefined;
|
|
21
|
-
if (!
|
|
19
|
+
if (!message || message.trim() === "") {
|
|
22
20
|
process.stderr.write("--message is required and must be non-empty\n");
|
|
23
21
|
process.exitCode = 1;
|
|
24
22
|
return;
|
|
@@ -30,17 +28,10 @@ export async function handleCommitSuggestion(args) {
|
|
|
30
28
|
threadId,
|
|
31
29
|
message,
|
|
32
30
|
description,
|
|
33
|
-
dryRun,
|
|
34
31
|
});
|
|
35
32
|
process.stdout.write(globalOpts.format === "json"
|
|
36
33
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
37
34
|
: `${formatCommitSuggestionResult(result)}\n`);
|
|
38
|
-
if (result.dryRun) {
|
|
39
|
-
process.exitCode = result.valid ? 0 : 1;
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
process.exitCode = result.applied ? 0 : 1;
|
|
43
|
-
}
|
|
44
35
|
}
|
|
45
36
|
export async function handleIterate(args) {
|
|
46
37
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -55,6 +55,9 @@ export function projectIterateLean(result) {
|
|
|
55
55
|
...(result.fix.firstLookSummaries.length > 0 && {
|
|
56
56
|
firstLookSummaries: result.fix.firstLookSummaries,
|
|
57
57
|
}),
|
|
58
|
+
...(result.fix.editedSummaries.length > 0 && {
|
|
59
|
+
editedSummaries: result.fix.editedSummaries,
|
|
60
|
+
}),
|
|
58
61
|
...(result.fix.surfacedApprovals.length > 0 && {
|
|
59
62
|
surfacedApprovals: result.fix.surfacedApprovals,
|
|
60
63
|
}),
|
|
@@ -64,6 +67,9 @@ export function projectIterateLean(result) {
|
|
|
64
67
|
...(result.fix.firstLookComments.length > 0 && {
|
|
65
68
|
firstLookComments: result.fix.firstLookComments,
|
|
66
69
|
}),
|
|
70
|
+
...(result.fix.inProgressRunIds.length > 0 && {
|
|
71
|
+
inProgressRunIds: result.fix.inProgressRunIds,
|
|
72
|
+
}),
|
|
67
73
|
...(result.fix.checks.length > 0 && { checks: result.fix.checks }),
|
|
68
74
|
...(result.fix.changesRequestedReviews.length > 0 && {
|
|
69
75
|
changesRequestedReviews: result.fix.changesRequestedReviews,
|
|
@@ -6,7 +6,10 @@ export function renderBodyPreview(body) {
|
|
|
6
6
|
return firstLine.slice(0, BODY_PREVIEW_MAX);
|
|
7
7
|
}
|
|
8
8
|
export function renderFirstLookStatusTag(t) {
|
|
9
|
-
|
|
9
|
+
const editedSuffix = t.edited ? ", edited" : "";
|
|
10
|
+
return t.autoResolved
|
|
11
|
+
? `[status: outdated, auto-resolved${editedSuffix}]`
|
|
12
|
+
: `[status: ${t.firstLookStatus}${editedSuffix}]`;
|
|
10
13
|
}
|
|
11
14
|
export function renderThreadBullet(t, opts = {}) {
|
|
12
15
|
const link = t.url ? ` [↗](${t.url})` : "";
|
|
@@ -31,6 +31,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
31
31
|
actionableComments: [],
|
|
32
32
|
reviewSummaryIds: [],
|
|
33
33
|
firstLookSummaries: [],
|
|
34
|
+
editedSummaries: [],
|
|
34
35
|
surfacedApprovals: [],
|
|
35
36
|
checks: [],
|
|
36
37
|
changesRequestedReviews: [],
|
|
@@ -43,6 +44,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
43
44
|
instructions: ["End this iteration."],
|
|
44
45
|
firstLookThreads: [],
|
|
45
46
|
firstLookComments: [],
|
|
47
|
+
inProgressRunIds: [],
|
|
46
48
|
},
|
|
47
49
|
cancelled: [],
|
|
48
50
|
};
|
package/bin/commands/check.mjs
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `shepherd check [PR]`
|
|
3
|
-
*
|
|
4
|
-
* Read-only snapshot of PR status. Fetches CI + comments + merge status in
|
|
5
|
-
* one GraphQL request, applies all classifiers, and returns a ShepherdReport.
|
|
6
|
-
*
|
|
7
|
-
* Exit codes:
|
|
8
|
-
* 0 READY — all checks passed, no unresolved threads, CLEAN merge status.
|
|
9
|
-
* 1 FAILING — CI has red checks, or merge has conflicts.
|
|
10
|
-
* 1 PENDING — CI passing but merge blocked (BLOCKED, UNSTABLE, or BEHIND).
|
|
11
|
-
* 1 UNKNOWN — merge state unresolvable.
|
|
12
|
-
* 2 IN_PROGRESS — CI checks still running.
|
|
13
|
-
* 3 UNRESOLVED_COMMENTS — CI ok but actionable threads remain.
|
|
14
|
-
*/
|
|
15
1
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
16
2
|
import { getRepoInfo, getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
|
|
17
3
|
import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
|
|
@@ -21,7 +7,7 @@ import { autoResolveOutdated } from "../comments/resolve.mjs";
|
|
|
21
7
|
import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
22
8
|
import { loadConfig } from "../config/load.mjs";
|
|
23
9
|
import { computeStatus } from "./check-status.mjs";
|
|
24
|
-
import {
|
|
10
|
+
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
25
11
|
export async function runCheck(opts) {
|
|
26
12
|
const repo = await getRepoInfo();
|
|
27
13
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -29,14 +15,10 @@ export async function runCheck(opts) {
|
|
|
29
15
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
30
16
|
}
|
|
31
17
|
const config = loadConfig();
|
|
32
|
-
// Only paginate APPROVED reviews when the caller will actually minimize them.
|
|
33
|
-
// Otherwise the first-page cap of 50 (already in the batch) is plenty — no extra round-trip.
|
|
34
18
|
const paginateApprovedReviews = config.iterate.minimizeApprovals;
|
|
35
19
|
const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
|
|
36
20
|
let batchData = result.data;
|
|
37
|
-
// GraphQL
|
|
38
|
-
// REST API already has the correct value. Fall back to REST in that case.
|
|
39
|
-
// Skip for non-OPEN PRs — REST also returns UNKNOWN for merged/closed PRs.
|
|
21
|
+
// Fall back to REST when GraphQL returns UNKNOWN — skip for non-OPEN PRs.
|
|
40
22
|
if ((batchData.state ?? "OPEN") === "OPEN" &&
|
|
41
23
|
(batchData.mergeable === "UNKNOWN" || batchData.mergeStateStatus === "UNKNOWN")) {
|
|
42
24
|
const restState = await getMergeableState(prNumber, repo.owner, repo.name);
|
|
@@ -53,13 +35,10 @@ export async function runCheck(opts) {
|
|
|
53
35
|
const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
|
|
54
36
|
const skipped = classifiedChecks.filter((c) => c.category === "skipped");
|
|
55
37
|
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
56
|
-
const triaged = failing.length > 0 && !opts.skipTriage
|
|
57
|
-
? await triageFailingChecks(failing, repo, config.checks.logTailLines, config.checks.logTailChars)
|
|
58
|
-
: failing;
|
|
38
|
+
const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
|
|
59
39
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
60
40
|
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
|
|
61
41
|
const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
|
|
62
|
-
// Auto-resolve outdated threads.
|
|
63
42
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
64
43
|
let autoResolved = [];
|
|
65
44
|
let autoResolveErrors = [];
|
|
@@ -69,51 +48,70 @@ export async function runCheck(opts) {
|
|
|
69
48
|
autoResolveErrors = errors;
|
|
70
49
|
}
|
|
71
50
|
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
72
|
-
// First-look: collect previously-hidden items not yet seen by the agent.
|
|
73
51
|
const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
|
|
74
52
|
const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
75
53
|
const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
76
54
|
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
|
|
77
|
-
const
|
|
55
|
+
const seenMap = await loadSeenMap(stateKey);
|
|
78
56
|
const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
|
|
79
57
|
const firstLookThreads = [
|
|
80
|
-
...outdatedCandidates
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
58
|
+
...outdatedCandidates.flatMap((t) => {
|
|
59
|
+
const cls = classifyItem(t.id, t.body, seenMap);
|
|
60
|
+
if (cls === "unchanged")
|
|
61
|
+
return [];
|
|
62
|
+
const base = {
|
|
63
|
+
...t,
|
|
64
|
+
firstLookStatus: "outdated",
|
|
65
|
+
autoResolved: autoResolvedIds.has(t.id),
|
|
66
|
+
};
|
|
67
|
+
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
68
|
+
}),
|
|
69
|
+
...resolvedCandidates.flatMap((t) => {
|
|
70
|
+
const cls = classifyItem(t.id, t.body, seenMap);
|
|
71
|
+
if (cls === "unchanged")
|
|
72
|
+
return [];
|
|
73
|
+
const base = { ...t, firstLookStatus: "resolved" };
|
|
74
|
+
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
75
|
+
}),
|
|
76
|
+
...minimizedThreadCandidates.flatMap((t) => {
|
|
77
|
+
const cls = classifyItem(t.id, t.body, seenMap);
|
|
78
|
+
if (cls === "unchanged")
|
|
79
|
+
return [];
|
|
80
|
+
const base = { ...t, firstLookStatus: "minimized" };
|
|
81
|
+
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
82
|
+
}),
|
|
93
83
|
];
|
|
94
|
-
const firstLookComments = minimizedCommentCandidates
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
84
|
+
const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
|
|
85
|
+
const cls = classifyItem(c.id, c.body, seenMap);
|
|
86
|
+
if (cls === "unchanged")
|
|
87
|
+
return [];
|
|
88
|
+
const base = { ...c, firstLookStatus: "minimized" };
|
|
89
|
+
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
90
|
+
});
|
|
91
|
+
const firstLookSummaries = [];
|
|
92
|
+
const editedSummaries = [];
|
|
93
|
+
const seenSummaries = [];
|
|
94
|
+
for (const r of batchData.reviewSummaries) {
|
|
95
|
+
const cls = classifyItem(r.id, r.body, seenMap);
|
|
96
|
+
if (cls === "new")
|
|
97
|
+
firstLookSummaries.push(r);
|
|
98
|
+
else if (cls === "edited")
|
|
99
|
+
editedSummaries.push(r);
|
|
100
|
+
else
|
|
101
|
+
seenSummaries.push(r);
|
|
102
|
+
}
|
|
101
103
|
await Promise.allSettled([
|
|
102
|
-
...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
|
|
103
|
-
...firstLookComments.map((c) => markSeen(stateKey, c.id)),
|
|
104
|
-
...firstLookSummaries.map((r) => markSeen(stateKey, r.id)),
|
|
104
|
+
...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
|
|
105
|
+
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
106
|
+
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
105
107
|
]);
|
|
106
|
-
// Actionable: all active threads and all visible comments (no classification — LLM handles triage).
|
|
107
108
|
const actionableThreads = activeThreads;
|
|
108
109
|
const actionableComments = visibleComments;
|
|
109
|
-
// Derive merge status.
|
|
110
110
|
const mergeStatus = deriveMergeStatus(batchData);
|
|
111
|
-
// Derive blockedByFilteredCheck ghost state.
|
|
112
111
|
const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
|
|
113
112
|
!verdict.anyFailing &&
|
|
114
113
|
!verdict.anyInProgress &&
|
|
115
114
|
verdict.filteredNames.length > 0;
|
|
116
|
-
// Compute overall status.
|
|
117
115
|
const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
|
|
118
116
|
return {
|
|
119
117
|
pr: prNumber,
|
|
@@ -144,6 +142,7 @@ export async function runCheck(opts) {
|
|
|
144
142
|
changesRequestedReviews: batchData.changesRequestedReviews,
|
|
145
143
|
reviewSummaries: seenSummaries,
|
|
146
144
|
firstLookSummaries,
|
|
145
|
+
editedSummaries,
|
|
147
146
|
approvedReviews: batchData.approvedReviews,
|
|
148
147
|
};
|
|
149
148
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
-
import { readFile
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
5
3
|
import { promisify } from "node:util";
|
|
6
4
|
import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
|
|
7
5
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
8
|
-
import { applyResolveOptions } from "../comments/resolve.mjs";
|
|
9
6
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
10
7
|
import { buildUnifiedDiff } from "../suggestions/patch.mjs";
|
|
11
8
|
const execFile = promisify(execFileCb);
|
|
@@ -13,13 +10,9 @@ export async function runCommitSuggestion(opts) {
|
|
|
13
10
|
if (!opts.threadId) {
|
|
14
11
|
throw new Error("--thread-id is required");
|
|
15
12
|
}
|
|
16
|
-
if (!opts.
|
|
13
|
+
if (!opts.message || opts.message.trim() === "") {
|
|
17
14
|
throw new Error("--message is required and must be non-empty");
|
|
18
15
|
}
|
|
19
|
-
const { stdout: statusOut } = await execFile("git", ["status", "--porcelain"]);
|
|
20
|
-
if (statusOut.trim() !== "") {
|
|
21
|
-
throw new Error("Working tree has uncommitted changes. Commit or stash them before running commit-suggestion.");
|
|
22
|
-
}
|
|
23
16
|
const repo = await getRepoInfo();
|
|
24
17
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
25
18
|
if (prNumber === null) {
|
|
@@ -56,6 +49,17 @@ export async function runCommitSuggestion(opts) {
|
|
|
56
49
|
if (!thread.path || thread.line === null) {
|
|
57
50
|
throw new Error(`Thread ${opts.threadId} has no file/line anchor.`);
|
|
58
51
|
}
|
|
52
|
+
// Validate the target file is clean before generating the patch, so the emitted
|
|
53
|
+
// `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
|
|
54
|
+
const { stdout: fileStatus } = await execFile("git", [
|
|
55
|
+
"status",
|
|
56
|
+
"--porcelain",
|
|
57
|
+
"--",
|
|
58
|
+
thread.path,
|
|
59
|
+
]);
|
|
60
|
+
if (fileStatus.trim() !== "") {
|
|
61
|
+
throw new Error(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`);
|
|
62
|
+
}
|
|
59
63
|
const parsed = parseSuggestion(thread.body);
|
|
60
64
|
if (!parsed) {
|
|
61
65
|
throw new Error(`Thread ${opts.threadId} has no suggestion block in the comment body.`);
|
|
@@ -75,74 +79,25 @@ export async function runCommitSuggestion(opts) {
|
|
|
75
79
|
endLine,
|
|
76
80
|
replacementLines: parsed.lines,
|
|
77
81
|
});
|
|
78
|
-
const patchFile = join(tmpdir(), `pr-shepherd-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
|
79
|
-
let patchError = null;
|
|
80
|
-
try {
|
|
81
|
-
await writeFile(patchFile, patch, { mode: 0o600 });
|
|
82
|
-
try {
|
|
83
|
-
await execFile("git", ["apply", "--check", patchFile]);
|
|
84
|
-
}
|
|
85
|
-
catch (err) {
|
|
86
|
-
patchError = (err.stderr?.trim() || String(err)).trim();
|
|
87
|
-
}
|
|
88
|
-
if (opts.dryRun) {
|
|
89
|
-
return {
|
|
90
|
-
pr: prNumber,
|
|
91
|
-
repo: `${repo.owner}/${repo.name}`,
|
|
92
|
-
threadId: opts.threadId,
|
|
93
|
-
path: filePath,
|
|
94
|
-
startLine,
|
|
95
|
-
endLine,
|
|
96
|
-
author: thread.author,
|
|
97
|
-
applied: false,
|
|
98
|
-
dryRun: true,
|
|
99
|
-
valid: patchError === null,
|
|
100
|
-
reason: patchError !== null ? `git apply rejected the patch: ${patchError}` : null,
|
|
101
|
-
patch,
|
|
102
|
-
postActionInstruction: patchError === null ? "Re-run without --dry-run to apply and commit." : "",
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
if (patchError !== null) {
|
|
106
|
-
return {
|
|
107
|
-
pr: prNumber,
|
|
108
|
-
repo: `${repo.owner}/${repo.name}`,
|
|
109
|
-
threadId: opts.threadId,
|
|
110
|
-
path: filePath,
|
|
111
|
-
startLine,
|
|
112
|
-
endLine,
|
|
113
|
-
author: thread.author,
|
|
114
|
-
applied: false,
|
|
115
|
-
reason: `git apply rejected the patch: ${patchError}`,
|
|
116
|
-
patch,
|
|
117
|
-
postActionInstruction: "",
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
await execFile("git", ["apply", patchFile]);
|
|
122
|
-
}
|
|
123
|
-
catch (applyErr) {
|
|
124
|
-
try {
|
|
125
|
-
await execFile("git", ["checkout", "--", filePath]);
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
// best-effort rollback
|
|
129
|
-
}
|
|
130
|
-
throw applyErr;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
finally {
|
|
134
|
-
await unlink(patchFile).catch(() => undefined);
|
|
135
|
-
}
|
|
136
|
-
await execFile("git", ["add", "--", filePath]);
|
|
137
82
|
const coAuthor = `Co-authored-by: ${thread.author} <${thread.author}@users.noreply.github.com>`;
|
|
138
83
|
const commitBody = opts.description ? `${opts.description}\n\n${coAuthor}` : coAuthor;
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
84
|
+
const commitMessageArg = opts.message;
|
|
85
|
+
const commitBodyArg = commitBody;
|
|
86
|
+
const quotedPath = `'${filePath.replace(/'/g, "'\\''")}'`;
|
|
87
|
+
const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}–${endLine}`;
|
|
88
|
+
const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
89
|
+
const commitCmd = [
|
|
90
|
+
"git commit",
|
|
91
|
+
`-m ${sq(commitMessageArg)}`,
|
|
92
|
+
...commitBodyArg.split("\n\n").map((p) => `-m ${sq(p)}`),
|
|
93
|
+
].join(" ");
|
|
94
|
+
const postActionInstructions = [
|
|
95
|
+
`Apply the patch to \`${filePath}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
|
|
96
|
+
`Stage the file: \`git add -- ${quotedPath}\``,
|
|
97
|
+
`Commit: \`${commitCmd}\``,
|
|
98
|
+
`Resolve the thread on GitHub: \`npx pr-shepherd resolve ${prNumber} --resolve-thread-ids ${opts.threadId}\``,
|
|
99
|
+
`Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
|
|
100
|
+
];
|
|
146
101
|
return {
|
|
147
102
|
pr: prNumber,
|
|
148
103
|
repo: `${repo.owner}/${repo.name}`,
|
|
@@ -151,11 +106,10 @@ export async function runCommitSuggestion(opts) {
|
|
|
151
106
|
startLine,
|
|
152
107
|
endLine,
|
|
153
108
|
author: thread.author,
|
|
154
|
-
applied: true,
|
|
155
|
-
commitSha,
|
|
156
109
|
patch,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
110
|
+
commitMessage: commitMessageArg,
|
|
111
|
+
commitBody: commitBodyArg,
|
|
112
|
+
filesToStage: [filePath],
|
|
113
|
+
postActionInstructions,
|
|
160
114
|
};
|
|
161
115
|
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals) {
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
|
|
3
|
+
// they are already minimized server-side (body changed after minimize was applied).
|
|
4
|
+
// First-look bodies are rendered so the agent sees them before the minimize happens.
|
|
4
5
|
const minimizeIds = [...summaries.firstLook, ...summaries.seen].map((r) => r.id);
|
|
5
6
|
if (minimizeApprovals) {
|
|
6
7
|
for (const r of approvals)
|
|
7
8
|
minimizeIds.push(r.id);
|
|
8
|
-
return {
|
|
9
|
+
return {
|
|
10
|
+
minimizeIds,
|
|
11
|
+
firstLookSummaries: summaries.firstLook,
|
|
12
|
+
editedSummaries: summaries.edited,
|
|
13
|
+
surfacedApprovals: [],
|
|
14
|
+
};
|
|
9
15
|
}
|
|
10
|
-
return {
|
|
16
|
+
return {
|
|
17
|
+
minimizeIds,
|
|
18
|
+
firstLookSummaries: summaries.firstLook,
|
|
19
|
+
editedSummaries: summaries.edited,
|
|
20
|
+
surfacedApprovals: approvals,
|
|
21
|
+
};
|
|
11
22
|
}
|
|
12
23
|
export function buildResolveCommand(threads, allCommentIds, reviews, checks, prNumber) {
|
|
13
24
|
const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
|
|
@@ -4,9 +4,9 @@ import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, bui
|
|
|
4
4
|
import { buildResolveCommand } from "./classify.mjs";
|
|
5
5
|
import { buildFixInstructions } from "./render.mjs";
|
|
6
6
|
import { applyStallGuard } from "./stall.mjs";
|
|
7
|
-
import { tryCancelRun } from "./helpers.mjs";
|
|
7
|
+
import { tryCancelRun, buildInProgressRunIds } from "./helpers.mjs";
|
|
8
8
|
export async function handleFixCode(ctx) {
|
|
9
|
-
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, surfacedApprovals, } = ctx;
|
|
9
|
+
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
|
|
10
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;
|
|
@@ -46,12 +46,19 @@ export async function handleFixCode(ctx) {
|
|
|
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
|
+
const cancelledSet = new Set(cancelled);
|
|
49
50
|
const baseLookup = validateBaseBranch(report.baseBranch);
|
|
50
51
|
const threads = report.threads.actionable.map(toAgentThread);
|
|
51
52
|
const actionableComments = report.comments.actionable.map(toAgentComment);
|
|
52
53
|
const checks = toAgentChecks(failingChecks);
|
|
53
54
|
const { changesRequestedReviews } = report;
|
|
54
55
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
56
|
+
const needsPush = threads.length > 0 ||
|
|
57
|
+
actionableComments.length > 0 ||
|
|
58
|
+
checks.length > 0 ||
|
|
59
|
+
changesRequestedReviews.length > 0 ||
|
|
60
|
+
hasConflicts;
|
|
61
|
+
const inProgressRunIds = needsPush ? buildInProgressRunIds(report, cancelledSet) : [];
|
|
55
62
|
const allCommentIds = [...actionableComments.map((c) => c.id), ...reviewSummaryIds];
|
|
56
63
|
const resolveCommand = buildResolveCommand(threads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
57
64
|
if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
|
|
@@ -73,7 +80,7 @@ export async function handleFixCode(ctx) {
|
|
|
73
80
|
}
|
|
74
81
|
const firstLookThreads = report.threads.firstLook;
|
|
75
82
|
const firstLookComments = report.comments.firstLook;
|
|
76
|
-
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries);
|
|
83
|
+
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds);
|
|
77
84
|
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
|
|
78
85
|
...base,
|
|
79
86
|
baseBranch: baseLookup.branch,
|
|
@@ -84,6 +91,7 @@ export async function handleFixCode(ctx) {
|
|
|
84
91
|
actionableComments,
|
|
85
92
|
reviewSummaryIds,
|
|
86
93
|
firstLookSummaries,
|
|
94
|
+
editedSummaries,
|
|
87
95
|
surfacedApprovals,
|
|
88
96
|
checks,
|
|
89
97
|
changesRequestedReviews,
|
|
@@ -91,6 +99,7 @@ export async function handleFixCode(ctx) {
|
|
|
91
99
|
instructions,
|
|
92
100
|
firstLookThreads,
|
|
93
101
|
firstLookComments,
|
|
102
|
+
inProgressRunIds,
|
|
94
103
|
},
|
|
95
104
|
cancelled,
|
|
96
105
|
}, report, reviewSummaryIds);
|
|
@@ -2,6 +2,13 @@ import { execFile as execFileCb } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { rest } from "../../github/http.mjs";
|
|
4
4
|
const execFile = promisify(execFileCb);
|
|
5
|
+
export function buildInProgressRunIds(report, cancelledSet) {
|
|
6
|
+
return [
|
|
7
|
+
...new Set(report.checks.inProgress
|
|
8
|
+
.map((c) => c.runId)
|
|
9
|
+
.filter((id) => id !== null && !cancelledSet.has(id))),
|
|
10
|
+
];
|
|
11
|
+
}
|
|
5
12
|
export function buildSummary(report) {
|
|
6
13
|
return {
|
|
7
14
|
passing: report.checks.passing.length,
|
|
@@ -84,6 +91,33 @@ export async function getCurrentHeadSha() {
|
|
|
84
91
|
return null;
|
|
85
92
|
}
|
|
86
93
|
}
|
|
94
|
+
export function buildWaitLog(base) {
|
|
95
|
+
const { summary, remainingSeconds } = base;
|
|
96
|
+
const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
|
|
97
|
+
switch (base.mergeStatus) {
|
|
98
|
+
case "BLOCKED":
|
|
99
|
+
if (base.reviewDecision === "REVIEW_REQUIRED")
|
|
100
|
+
parts.push("awaiting human review");
|
|
101
|
+
else if (base.reviewDecision === "APPROVED")
|
|
102
|
+
parts.push("awaiting additional approvals");
|
|
103
|
+
else
|
|
104
|
+
parts.push("awaiting human review or branch protection");
|
|
105
|
+
break;
|
|
106
|
+
case "BEHIND":
|
|
107
|
+
parts.push("branch is behind base");
|
|
108
|
+
break;
|
|
109
|
+
case "DRAFT":
|
|
110
|
+
parts.push("PR is a draft");
|
|
111
|
+
break;
|
|
112
|
+
case "UNSTABLE":
|
|
113
|
+
parts.push("some checks are unstable");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if (remainingSeconds > 0) {
|
|
117
|
+
parts.push(`${remainingSeconds}s until auto-cancel`);
|
|
118
|
+
}
|
|
119
|
+
return parts.join(" — ");
|
|
120
|
+
}
|
|
87
121
|
export function buildCooldownResult(prNumber, readyDelaySeconds) {
|
|
88
122
|
return {
|
|
89
123
|
action: "cooldown",
|
|
@@ -4,10 +4,9 @@ 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 { getLastCommitTime, getCurrentHeadSha, buildSummary, buildRelevantChecks, buildCooldownResult, } from "./helpers.mjs";
|
|
7
|
+
import { getLastCommitTime, getCurrentHeadSha, buildSummary, buildRelevantChecks, buildCooldownResult, buildWaitLog, } from "./helpers.mjs";
|
|
8
8
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
|
-
import { buildWaitLog } from "./render.mjs";
|
|
11
10
|
import { handleFixCode } from "./fix-code.mjs";
|
|
12
11
|
export async function runIterate(opts) {
|
|
13
12
|
const config = loadConfig();
|
|
@@ -91,7 +90,11 @@ export async function runIterate(opts) {
|
|
|
91
90
|
}
|
|
92
91
|
const headSha = (await getCurrentHeadSha()) ?? "unknown";
|
|
93
92
|
const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
94
|
-
const { minimizeIds: reviewSummaryIds, firstLookSummaries, surfacedApprovals, } = classifyReviewSummaries({
|
|
93
|
+
const { minimizeIds: reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = classifyReviewSummaries({
|
|
94
|
+
firstLook: report.firstLookSummaries,
|
|
95
|
+
seen: report.reviewSummaries,
|
|
96
|
+
edited: report.editedSummaries,
|
|
97
|
+
}, report.approvedReviews, config.iterate.minimizeApprovals);
|
|
95
98
|
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
96
99
|
report.comments.actionable.length > 0 ||
|
|
97
100
|
report.changesRequestedReviews.length > 0 ||
|
|
@@ -111,6 +114,7 @@ export async function runIterate(opts) {
|
|
|
111
114
|
repoName,
|
|
112
115
|
reviewSummaryIds,
|
|
113
116
|
firstLookSummaries,
|
|
117
|
+
editedSummaries,
|
|
114
118
|
surfacedApprovals,
|
|
115
119
|
});
|
|
116
120
|
}
|
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Render a resolve command as a shell snippet
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* This is not a general-purpose shell escaper. It only wraps
|
|
6
|
-
* `$DISMISS_MESSAGE` and whitespace-bearing `rc.argv` entries in double quotes
|
|
7
|
-
* so the surrounding command template can later substitute placeholder values.
|
|
8
|
-
*
|
|
9
|
-
* Callers must preserve that contract:
|
|
10
|
-
* - placeholder substitution must replace the entire quoted token (for example,
|
|
11
|
-
* replace `"$DISMISS_MESSAGE"` as a whole, not text inside the quotes);
|
|
12
|
-
* - `rc.argv` must not contain `"`, `$`, `` ` ``, or `\`, because this helper
|
|
13
|
-
* does not escape them and will throw if they are present;
|
|
14
|
-
* - `$HEAD_SHA` must not appear in `rc.argv`; when `requiresHeadSha` is set it
|
|
15
|
-
* is appended separately below as the already-quoted token `"$HEAD_SHA"`.
|
|
2
|
+
* Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE` and whitespace-bearing
|
|
3
|
+
* argv entries in double quotes for placeholder substitution. Throws if argv contains `"`, `$`,
|
|
4
|
+
* `` ` ``, or `\`. `$HEAD_SHA` is appended separately when `requiresHeadSha` is set.
|
|
16
5
|
*/
|
|
17
6
|
export function renderResolveCommand(rc) {
|
|
18
7
|
const needsQuoting = (arg) => {
|
|
@@ -29,11 +18,14 @@ export function renderResolveCommand(rc) {
|
|
|
29
18
|
}
|
|
30
19
|
return parts.join(" ");
|
|
31
20
|
}
|
|
32
|
-
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = []) {
|
|
21
|
+
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = []) {
|
|
33
22
|
const instructions = [];
|
|
23
|
+
if (inProgressRunIds.length > 0) {
|
|
24
|
+
instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\`. Do this before applying any code fixes — the push at the end of this iteration will supersede those runs anyway, so letting them continue burns CI minutes for results no one will read. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
|
|
25
|
+
}
|
|
34
26
|
const hasSuggestions = threads.some((t) => t.suggestion);
|
|
35
27
|
if (hasSuggestions) {
|
|
36
|
-
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
|
|
28
|
+
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\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the \`resolve:\` command below (the thread is not auto-resolved). If the patch fails to apply, fall through to the manual-edit step. Do not retry the same command.`);
|
|
37
29
|
}
|
|
38
30
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
39
31
|
const suggestionFallback = hasSuggestions
|
|
@@ -41,11 +33,17 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
41
33
|
: "";
|
|
42
34
|
instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
|
|
43
35
|
}
|
|
44
|
-
const
|
|
36
|
+
const cancelledRunIdChecks = checks.filter((c) => c.runId && c.conclusion === "CANCELLED");
|
|
37
|
+
const failedRunIdChecks = checks.filter((c) => c.runId && c.conclusion !== "CANCELLED");
|
|
45
38
|
const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
|
|
46
39
|
const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
|
|
47
|
-
if (
|
|
48
|
-
instructions.push(`For each failing check under \`## Failing checks\` with a run ID
|
|
40
|
+
if (failedRunIdChecks.length > 0) {
|
|
41
|
+
instructions.push(`For each failing check under \`## Failing checks\` with a run ID and no \`[conclusion: CANCELLED]\` tag: run \`gh run view <runId> --log-failed\` to fetch the failing job's log.`);
|
|
42
|
+
instructions.push(`If the log shows a transient infrastructure failure (network timeout, runner setup crash, OOM kill): run \`gh run rerun <runId> --failed\`.`);
|
|
43
|
+
instructions.push(`If the log shows a real test/build failure: apply a code fix.`);
|
|
44
|
+
}
|
|
45
|
+
if (cancelledRunIdChecks.length > 0) {
|
|
46
|
+
instructions.push(`For each \`[conclusion: CANCELLED]\` bullet under \`## Failing checks\`: the run was cancelled outside Shepherd's control (manual cancel, newer push, concurrency-group eviction). Run \`gh run rerun <runId>\` only if the cancellation looks unintended; otherwise treat it as resolved by the superseding run. Do NOT confuse these with IDs under \`## Cancelled runs\` — those were cancelled by Shepherd itself.`);
|
|
49
47
|
}
|
|
50
48
|
if (externalChecks.length > 0) {
|
|
51
49
|
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.`);
|
|
@@ -80,6 +78,12 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
80
78
|
if (firstLookSummaries.length > 0) {
|
|
81
79
|
instructions.push(`Review the bodies shown under \`## Review summaries (first look — to be minimized)\` — you are seeing these for the first time. Their IDs are already included in the \`resolve:\` command's \`--minimize-comment-ids\`; if any warrants a \`## Shepherd Journal\` entry, record it before running resolve.`);
|
|
82
80
|
}
|
|
81
|
+
const editedTotal = editedSummaries.length +
|
|
82
|
+
firstLookThreads.filter((t) => t.edited).length +
|
|
83
|
+
firstLookComments.filter((c) => c.edited).length;
|
|
84
|
+
if (editedTotal > 0) {
|
|
85
|
+
instructions.push(`Items under \`## Review summaries (edited since first look)\` and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body. Do **not** include their IDs in \`--minimize-comment-ids\`, \`--resolve-thread-ids\`, or \`--dismiss-review-ids\` — they are already closed or minimized on GitHub.`);
|
|
86
|
+
}
|
|
83
87
|
if (resolveCommand.hasMutations) {
|
|
84
88
|
const substituteParts = [];
|
|
85
89
|
if (resolveCommand.requiresHeadSha) {
|
|
@@ -108,30 +112,3 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
108
112
|
}
|
|
109
113
|
return instructions;
|
|
110
114
|
}
|
|
111
|
-
export function buildWaitLog(base) {
|
|
112
|
-
const { summary, remainingSeconds } = base;
|
|
113
|
-
const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
|
|
114
|
-
switch (base.mergeStatus) {
|
|
115
|
-
case "BLOCKED":
|
|
116
|
-
if (base.reviewDecision === "REVIEW_REQUIRED")
|
|
117
|
-
parts.push("awaiting human review");
|
|
118
|
-
else if (base.reviewDecision === "APPROVED")
|
|
119
|
-
parts.push("awaiting additional approvals");
|
|
120
|
-
else
|
|
121
|
-
parts.push("awaiting human review or branch protection");
|
|
122
|
-
break;
|
|
123
|
-
case "BEHIND":
|
|
124
|
-
parts.push("branch is behind base");
|
|
125
|
-
break;
|
|
126
|
-
case "DRAFT":
|
|
127
|
-
parts.push("PR is a draft");
|
|
128
|
-
break;
|
|
129
|
-
case "UNSTABLE":
|
|
130
|
-
parts.push("some checks are unstable");
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
if (remainingSeconds > 0) {
|
|
134
|
-
parts.push(`${remainingSeconds}s until auto-cancel`);
|
|
135
|
-
}
|
|
136
|
-
return parts.join(" — ");
|
|
137
|
-
}
|
|
@@ -23,8 +23,13 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
23
23
|
if (firstLookTotal > 0) {
|
|
24
24
|
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").`);
|
|
25
25
|
}
|
|
26
|
+
const editedTotal = firstLookThreads.filter((t) => t.edited).length +
|
|
27
|
+
firstLookComments.filter((c) => c.edited).length;
|
|
28
|
+
if (editedTotal > 0) {
|
|
29
|
+
instructions.push(`First-look bullets tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body. Do **not** include their IDs in \`--resolve-thread-ids\`, \`--minimize-comment-ids\`, or \`--dismiss-review-ids\` — they are already closed or minimized on GitHub.`);
|
|
30
|
+
}
|
|
26
31
|
if (hasSuggestions) {
|
|
27
|
-
instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json
|
|
32
|
+
instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved). If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step. Do not retry the same \`commit-suggestion\` invocation.`);
|
|
28
33
|
}
|
|
29
34
|
if (hasCodeItems) {
|
|
30
35
|
instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -5,32 +5,59 @@ import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mj
|
|
|
5
5
|
import { loadConfig } from "../config/load.mjs";
|
|
6
6
|
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
7
7
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
8
|
-
import {
|
|
9
|
-
/**
|
|
10
|
-
* Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
|
|
11
|
-
*/
|
|
8
|
+
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
9
|
+
/** Fetch mode: auto-resolve outdated threads and return all active items for LLM triage. */
|
|
12
10
|
export async function runResolveFetch(opts) {
|
|
13
11
|
const repo = await getRepoInfo();
|
|
14
12
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
15
13
|
if (prNumber === null) {
|
|
16
14
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
17
15
|
}
|
|
18
|
-
// Always bypass cache for resolve — we need fresh data before mutating.
|
|
19
16
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
20
17
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
21
18
|
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
|
|
22
19
|
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
23
|
-
// First-look: collect items that would normally be hidden and check seen markers.
|
|
24
20
|
const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
|
|
25
21
|
const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
26
22
|
const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
27
23
|
const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
|
|
28
|
-
const
|
|
29
|
-
const unseenOutdated =
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
const seenMap = await loadSeenMap(stateKey);
|
|
25
|
+
const unseenOutdated = [];
|
|
26
|
+
const editedOutdated = [];
|
|
27
|
+
for (const t of outdatedCandidates) {
|
|
28
|
+
const cls = classifyItem(t.id, t.body, seenMap);
|
|
29
|
+
if (cls === "new")
|
|
30
|
+
unseenOutdated.push(t);
|
|
31
|
+
else if (cls === "edited")
|
|
32
|
+
editedOutdated.push(t);
|
|
33
|
+
}
|
|
34
|
+
const unseenResolved = [];
|
|
35
|
+
const editedResolved = [];
|
|
36
|
+
for (const t of resolvedCandidates) {
|
|
37
|
+
const cls = classifyItem(t.id, t.body, seenMap);
|
|
38
|
+
if (cls === "new")
|
|
39
|
+
unseenResolved.push(t);
|
|
40
|
+
else if (cls === "edited")
|
|
41
|
+
editedResolved.push(t);
|
|
42
|
+
}
|
|
43
|
+
const unseenMinimizedThreads = [];
|
|
44
|
+
const editedMinimizedThreads = [];
|
|
45
|
+
for (const t of minimizedThreadCandidates) {
|
|
46
|
+
const cls = classifyItem(t.id, t.body, seenMap);
|
|
47
|
+
if (cls === "new")
|
|
48
|
+
unseenMinimizedThreads.push(t);
|
|
49
|
+
else if (cls === "edited")
|
|
50
|
+
editedMinimizedThreads.push(t);
|
|
51
|
+
}
|
|
52
|
+
const unseenMinimizedComments = [];
|
|
53
|
+
const editedMinimizedComments = [];
|
|
54
|
+
for (const c of minimizedCommentCandidates) {
|
|
55
|
+
const cls = classifyItem(c.id, c.body, seenMap);
|
|
56
|
+
if (cls === "new")
|
|
57
|
+
unseenMinimizedComments.push(c);
|
|
58
|
+
else if (cls === "edited")
|
|
59
|
+
editedMinimizedComments.push(c);
|
|
60
|
+
}
|
|
34
61
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
35
62
|
const autoResolvedIds = new Set();
|
|
36
63
|
if (outdated.length > 0) {
|
|
@@ -56,17 +83,37 @@ export async function runResolveFetch(opts) {
|
|
|
56
83
|
firstLookStatus: "outdated",
|
|
57
84
|
autoResolved: autoResolvedIds.has(t.id),
|
|
58
85
|
})),
|
|
86
|
+
...editedOutdated.map((t) => ({
|
|
87
|
+
...t,
|
|
88
|
+
firstLookStatus: "outdated",
|
|
89
|
+
autoResolved: autoResolvedIds.has(t.id),
|
|
90
|
+
edited: true,
|
|
91
|
+
})),
|
|
59
92
|
...unseenResolved.map((t) => ({ ...t, firstLookStatus: "resolved" })),
|
|
93
|
+
...editedResolved.map((t) => ({
|
|
94
|
+
...t,
|
|
95
|
+
firstLookStatus: "resolved",
|
|
96
|
+
edited: true,
|
|
97
|
+
})),
|
|
60
98
|
...unseenMinimizedThreads.map((t) => ({ ...t, firstLookStatus: "minimized" })),
|
|
99
|
+
...editedMinimizedThreads.map((t) => ({
|
|
100
|
+
...t,
|
|
101
|
+
firstLookStatus: "minimized",
|
|
102
|
+
edited: true,
|
|
103
|
+
})),
|
|
104
|
+
];
|
|
105
|
+
const firstLookComments = [
|
|
106
|
+
...unseenMinimizedComments.map((c) => ({ ...c, firstLookStatus: "minimized" })),
|
|
107
|
+
...editedMinimizedComments.map((c) => ({
|
|
108
|
+
...c,
|
|
109
|
+
firstLookStatus: "minimized",
|
|
110
|
+
edited: true,
|
|
111
|
+
})),
|
|
61
112
|
];
|
|
62
|
-
|
|
63
|
-
...c,
|
|
64
|
-
firstLookStatus: "minimized",
|
|
65
|
-
}));
|
|
66
|
-
// Mark first-look items as seen (best-effort — markSeen never throws).
|
|
113
|
+
// Mark new and edited items as seen (best-effort — markSeen never throws).
|
|
67
114
|
await Promise.allSettled([
|
|
68
|
-
...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
|
|
69
|
-
...firstLookComments.map((c) => markSeen(stateKey, c.id)),
|
|
115
|
+
...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
|
|
116
|
+
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
70
117
|
]);
|
|
71
118
|
const result = {
|
|
72
119
|
prNumber,
|
|
@@ -80,9 +127,7 @@ export async function runResolveFetch(opts) {
|
|
|
80
127
|
};
|
|
81
128
|
return { ...result, instructions: buildFetchInstructions(prNumber, result) };
|
|
82
129
|
}
|
|
83
|
-
/**
|
|
84
|
-
* Mutation mode: resolve/minimize/dismiss by ID.
|
|
85
|
-
*/
|
|
130
|
+
/** Mutation mode: resolve/minimize/dismiss by ID. */
|
|
86
131
|
export async function runResolveMutate(opts) {
|
|
87
132
|
const repo = await getRepoInfo();
|
|
88
133
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
package/bin/config.json
CHANGED
|
@@ -17,9 +17,7 @@
|
|
|
17
17
|
"fetchReviewSummaries": true
|
|
18
18
|
},
|
|
19
19
|
"checks": {
|
|
20
|
-
"ciTriggerEvents": ["pull_request", "pull_request_target"]
|
|
21
|
-
"logTailLines": 5,
|
|
22
|
-
"logTailChars": 200
|
|
20
|
+
"ciTriggerEvents": ["pull_request", "pull_request_target"]
|
|
23
21
|
},
|
|
24
22
|
"mergeStatus": {
|
|
25
23
|
"blockingReviewerLogins": ["copilot"]
|
package/bin/reporters/agent.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These strip fields that are always-false by the time items reach iterate
|
|
5
5
|
* (isResolved, isOutdated, isMinimized, createdAtUnix) and check metadata the
|
|
6
|
-
* monitor prompt never reads (event, status,
|
|
6
|
+
* monitor prompt never reads (event, status, category).
|
|
7
|
+
* conclusion is preserved on AgentCheck so the formatter can branch on CANCELLED.
|
|
7
8
|
* detailsUrl is preserved in AgentCheck as a fallback for external status checks.
|
|
8
9
|
* The original domain types are preserved for check command output.
|
|
9
10
|
*/
|
|
@@ -27,15 +28,18 @@ export function toAgentComment(c) {
|
|
|
27
28
|
return { id: c.id, author: c.author, body: c.body, url: c.url };
|
|
28
29
|
}
|
|
29
30
|
export function toAgentCheck(c) {
|
|
31
|
+
if (c.conclusion === "SKIPPED" || c.conclusion === "NEUTRAL") {
|
|
32
|
+
throw new Error(`Unexpected conclusion ${c.conclusion} in toAgentCheck`);
|
|
33
|
+
}
|
|
30
34
|
return {
|
|
31
35
|
name: c.name,
|
|
32
36
|
runId: c.runId,
|
|
33
37
|
detailsUrl: c.detailsUrl,
|
|
38
|
+
conclusion: c.conclusion,
|
|
34
39
|
...(c.workflowName !== undefined && { workflowName: c.workflowName }),
|
|
35
40
|
...(c.jobName !== undefined && { jobName: c.jobName }),
|
|
36
41
|
...(c.failedStep !== undefined && { failedStep: c.failedStep }),
|
|
37
42
|
...(c.summary !== undefined && { summary: c.summary }),
|
|
38
|
-
...(c.logTail !== undefined && { logTail: c.logTail }),
|
|
39
43
|
};
|
|
40
44
|
}
|
|
41
45
|
/**
|
|
@@ -28,8 +28,8 @@ export function buildCheckInstructions(report) {
|
|
|
28
28
|
for (const c of checks.failing) {
|
|
29
29
|
const stepHint = c.failedStep ? ` (failed step: \`${c.failedStep}\`)` : "";
|
|
30
30
|
const diagnosisHint = c.runId
|
|
31
|
-
? c.
|
|
32
|
-
? `
|
|
31
|
+
? c.conclusion === "CANCELLED"
|
|
32
|
+
? `cancelled — if unintended, rerun with \`gh run rerun ${c.runId}\``
|
|
33
33
|
: `run \`gh run view ${c.runId} --log-failed\`${stepHint} to diagnose — if transient, rerun with \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
|
|
34
34
|
: c.detailsUrl
|
|
35
35
|
? `open the check details (${c.detailsUrl}) to diagnose the failure`
|
package/bin/reporters/text.mjs
CHANGED
|
@@ -112,7 +112,8 @@ export function formatText(report) {
|
|
|
112
112
|
lines.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
113
113
|
}
|
|
114
114
|
for (const c of firstLookComments) {
|
|
115
|
-
|
|
115
|
+
const editedSuffix = c.edited ? ", edited" : "";
|
|
116
|
+
lines.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
|
|
116
117
|
}
|
|
117
118
|
firstLookSection = lines.join("\n");
|
|
118
119
|
}
|
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, access, readdir } from "node:fs/promises";
|
|
1
|
+
import { readFile, writeFile, rename, unlink, mkdir, access, readdir } from "node:fs/promises";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
4
5
|
import { resolveStateBase } from "./base.mjs";
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Public API
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Compute a 16-hex-char SHA-256 prefix of a comment body. */
|
|
10
|
+
export function hashBody(body) {
|
|
11
|
+
return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Classify a candidate item against the seen map.
|
|
15
|
+
*
|
|
16
|
+
* - "new" — no marker exists; surface the body and write the marker.
|
|
17
|
+
* - "edited" — marker exists but stored hash differs from the current body;
|
|
18
|
+
* surface the updated body and update the marker hash.
|
|
19
|
+
* - "unchanged" — marker exists and hash matches (or marker has no hash, which
|
|
20
|
+
* is treated conservatively as unchanged).
|
|
21
|
+
*/
|
|
22
|
+
export function classifyItem(id, body, map) {
|
|
23
|
+
const m = map.get(id);
|
|
24
|
+
if (!m)
|
|
25
|
+
return "new";
|
|
26
|
+
if (typeof m.bodyHash === "string" && m.bodyHash !== hashBody(body))
|
|
27
|
+
return "edited";
|
|
28
|
+
return "unchanged";
|
|
29
|
+
}
|
|
8
30
|
/**
|
|
9
31
|
* Read the seen/ directory once and return a Set of already-seen IDs.
|
|
10
32
|
* Prefer this over repeated hasSeen() calls to avoid EMFILE on large PRs.
|
|
@@ -20,6 +42,32 @@ export async function loadSeenSet(key) {
|
|
|
20
42
|
return new Set();
|
|
21
43
|
}
|
|
22
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the seen/ directory and return a Map from ID to SeenMarker.
|
|
47
|
+
* Used when the caller needs the stored bodyHash to detect in-place edits.
|
|
48
|
+
* Returns an empty Map if the directory does not yet exist.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadSeenMap(key) {
|
|
51
|
+
const map = new Map();
|
|
52
|
+
try {
|
|
53
|
+
const dir = resolveDir(key);
|
|
54
|
+
const entries = await readdir(dir);
|
|
55
|
+
const ids = entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5));
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readFile(join(dir, `${id}.json`), "utf8");
|
|
59
|
+
map.set(id, JSON.parse(raw));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// unreadable or malformed — skip
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// directory doesn't exist or unreadable — return empty map
|
|
68
|
+
}
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
23
71
|
/** Return true if a "seen" marker exists for this id. */
|
|
24
72
|
export async function hasSeen(key, id) {
|
|
25
73
|
try {
|
|
@@ -30,17 +78,50 @@ export async function hasSeen(key, id) {
|
|
|
30
78
|
return false;
|
|
31
79
|
}
|
|
32
80
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Write (or update) a "seen" marker for this id, storing the body hash so
|
|
83
|
+
* in-place edits can be detected on future fetches.
|
|
84
|
+
*
|
|
85
|
+
* - First call (no existing marker): creates `{ seenAt: now, bodyHash }`.
|
|
86
|
+
* - Subsequent call, hash unchanged: no-op (skips the write).
|
|
87
|
+
* - Subsequent call, hash changed: updates `bodyHash`, preserves original `seenAt`.
|
|
88
|
+
*
|
|
89
|
+
* All errors are silently swallowed — the marker is best-effort.
|
|
90
|
+
*/
|
|
91
|
+
export async function markSeen(key, id, body) {
|
|
92
|
+
let tmp;
|
|
35
93
|
try {
|
|
36
94
|
const path = resolvePath(key, id);
|
|
37
95
|
await mkdir(dirname(path), { recursive: true });
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
96
|
+
const newHash = hashBody(body);
|
|
97
|
+
let existing = null;
|
|
98
|
+
try {
|
|
99
|
+
const raw = await readFile(path, "utf8");
|
|
100
|
+
existing = JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// no existing marker — will create below
|
|
104
|
+
}
|
|
105
|
+
if (existing !== null && existing.bodyHash === newHash)
|
|
106
|
+
return;
|
|
107
|
+
const seenAt = existing?.seenAt ?? Date.now();
|
|
108
|
+
tmp = `${path}.${randomUUID()}.tmp`;
|
|
109
|
+
await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash }), "utf8");
|
|
110
|
+
await rename(tmp, path);
|
|
111
|
+
tmp = undefined;
|
|
41
112
|
}
|
|
42
113
|
catch {
|
|
43
|
-
//
|
|
114
|
+
// best-effort
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (tmp !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
await unlink(tmp);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// best-effort cleanup
|
|
123
|
+
}
|
|
124
|
+
}
|
|
44
125
|
}
|
|
45
126
|
}
|
|
46
127
|
/** Read the full marker for inspection (returns null on miss or error). */
|