pr-shepherd 0.10.3 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
4
- "version": "0.10.3",
4
+ "version": "0.11.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -1,23 +1,19 @@
1
- import { rest, restText } from "../github/http.mjs";
2
- export function triageFailingChecks(failingChecks, repo, logTailLines, logTailChars = 200) {
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, logTailLines, logTailChars)));
4
+ return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
5
5
  }
6
- async function triageCheck(check, repo, jobsCache, logTailLines, logTailChars) {
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 lines = [`- ${locator} \`${workflowPrefix}${jobLabel}\``];
41
- if (ch.failedStep)
42
- lines.push(` > ${ch.failedStep}`);
43
- if (ch.summary)
44
- lines.push(` > ${ch.summary}`);
45
- if (ch.logTail) {
46
- const fence = safeFence(ch.logTail);
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
  });
@@ -96,6 +92,10 @@ export function formatFixCodeResult(header, result) {
96
92
  }
97
93
  sections.push(bullets.join("\n"));
98
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
+ }
99
99
  if (result.cancelled.length > 0) {
100
100
  sections.push("## Cancelled runs");
101
101
  sections.push(result.cancelled.map((id) => `- \`${id}\``).join("\n"));
@@ -67,48 +67,29 @@ export function formatCommitSuggestionResult(result) {
67
67
  const range = result.startLine === result.endLine
68
68
  ? `line ${result.startLine}`
69
69
  : `lines ${result.startLine}–${result.endLine}`;
70
- function pushPatch(patch) {
71
- const fence = safeFence(patch);
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);
72
75
  lines.push("");
73
76
  lines.push(`${fence}diff`);
74
- lines.push(patch.trimEnd());
77
+ lines.push(result.patch.trimEnd());
75
78
  lines.push(fence);
76
79
  }
77
- if (result.dryRun) {
78
- if (result.valid) {
79
- lines.push(`Dry-run: would apply suggestion from @${result.author}:`);
80
- lines.push(` ${result.path} (${range})`);
81
- }
82
- else {
83
- lines.push(`Dry-run: suggestion cannot apply cleanly:`);
84
- lines.push(`- path: ${result.path} (${range})`);
85
- lines.push(`- author: @${result.author}`);
86
- lines.push(`- reason: ${result.reason ?? "unknown"}`);
87
- }
88
- if (result.patch)
89
- pushPatch(result.patch);
90
- }
91
- else if (result.applied) {
92
- lines.push(`Applied suggestion from @${result.author}:`);
93
- lines.push(` ${result.path} (${range})`);
94
- if (result.commitSha)
95
- lines.push(`Commit: ${result.commitSha}`);
96
- if (result.patch)
97
- pushPatch(result.patch);
98
- }
99
- else {
100
- lines.push(`Failed to apply suggestion ${result.threadId}:`);
101
- lines.push(`- path: ${result.path} (${range})`);
102
- lines.push(`- author: @${result.author}`);
103
- lines.push(`- reason: ${result.reason ?? "unknown"}`);
104
- if (result.patch)
105
- pushPatch(result.patch);
106
- }
107
- 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) {
108
87
  lines.push("");
109
88
  lines.push("## Instructions");
110
89
  lines.push("");
111
- lines.push(`1. ${result.postActionInstruction}`);
90
+ result.postActionInstructions.forEach((inst, i) => {
91
+ lines.push(`${i + 1}. ${inst}`);
92
+ });
112
93
  }
113
94
  return lines.join("\n");
114
95
  }
@@ -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 [--message MSG] [--description DESC] [--dry-run]\n" +
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 (!dryRun && (!message || message.trim() === "")) {
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);
@@ -67,6 +67,9 @@ export function projectIterateLean(result) {
67
67
  ...(result.fix.firstLookComments.length > 0 && {
68
68
  firstLookComments: result.fix.firstLookComments,
69
69
  }),
70
+ ...(result.fix.inProgressRunIds.length > 0 && {
71
+ inProgressRunIds: result.fix.inProgressRunIds,
72
+ }),
70
73
  ...(result.fix.checks.length > 0 && { checks: result.fix.checks }),
71
74
  ...(result.fix.changesRequestedReviews.length > 0 && {
72
75
  changesRequestedReviews: result.fix.changesRequestedReviews,
@@ -44,6 +44,7 @@ export function makeIterateResult(action = "wait") {
44
44
  instructions: ["End this iteration."],
45
45
  firstLookThreads: [],
46
46
  firstLookComments: [],
47
+ inProgressRunIds: [],
47
48
  },
48
49
  cancelled: [],
49
50
  };
@@ -35,9 +35,7 @@ export async function runCheck(opts) {
35
35
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
36
36
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
37
37
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
38
- const triaged = failing.length > 0 && !opts.skipTriage
39
- ? await triageFailingChecks(failing, repo, config.checks.logTailLines, config.checks.logTailChars)
40
- : failing;
38
+ const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
41
39
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
42
40
  const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
43
41
  const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
@@ -1,11 +1,8 @@
1
1
  import { execFile as execFileCb } from "node:child_process";
2
- import { readFile, writeFile, unlink } from "node:fs/promises";
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.dryRun && (!opts.message || opts.message.trim() === "")) {
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
- await execFile("git", ["commit", "-m", opts.message, "-m", commitBody]);
140
- const { stdout: shaOut } = await execFile("git", ["rev-parse", "HEAD"]);
141
- const commitSha = shaOut.trim();
142
- const resolveResult = await applyResolveOptions(prNumber, repo, {
143
- resolveThreadIds: [opts.threadId],
144
- });
145
- const resolveErrors = resolveResult.errors;
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
- postActionInstruction: resolveErrors.length > 0
158
- ? `Commit created (${commitSha}), but failed to resolve thread ${opts.threadId}: ${resolveErrors.join("; ")}. Run \`git push\` then resolve manually.`
159
- : "Run `git push` (or `git push --force-with-lease` after rebasing) to publish the commit.",
110
+ commitMessage: commitMessageArg,
111
+ commitBody: commitBodyArg,
112
+ filesToStage: [filePath],
113
+ postActionInstructions,
160
114
  };
161
115
  }
@@ -4,7 +4,7 @@ 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
9
  const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
10
10
  const failingChecks = report.checks.failing;
@@ -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, editedSummaries);
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,
@@ -92,6 +99,7 @@ export async function handleFixCode(ctx) {
92
99
  instructions,
93
100
  firstLookThreads,
94
101
  firstLookComments,
102
+ inProgressRunIds,
95
103
  },
96
104
  cancelled,
97
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();
@@ -18,11 +18,14 @@ export function renderResolveCommand(rc) {
18
18
  }
19
19
  return parts.join(" ");
20
20
  }
21
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = []) {
21
+ export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = []) {
22
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
+ }
23
26
  const hasSuggestions = threads.some((t) => t.suggestion);
24
27
  if (hasSuggestions) {
25
- instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` the CLI already resolved the thread remove its ID from \`--resolve-thread-ids\` in the \`resolve:\` command below. On \`applied: false\` read \`reason\` and \`patch\`, fall through to the manual-edit step, and do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) to preview the patch without mutating the working tree.`);
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.`);
26
29
  }
27
30
  if (threads.length > 0 || actionableComments.length > 0) {
28
31
  const suggestionFallback = hasSuggestions
@@ -30,11 +33,17 @@ export function buildFixInstructions(threads, actionableComments, checks, review
30
33
  : "";
31
34
  instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
32
35
  }
33
- const checksWithRunId = checks.filter((c) => c.runId);
36
+ const cancelledRunIdChecks = checks.filter((c) => c.runId && c.conclusion === "CANCELLED");
37
+ const failedRunIdChecks = checks.filter((c) => c.runId && c.conclusion !== "CANCELLED");
34
38
  const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
35
39
  const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
36
- if (checksWithRunId.length > 0) {
37
- instructions.push(`For each failing check under \`## Failing checks\` with a run ID, examine the log tail in the fenced block to decide what to do:\n - If the log tail shows a transient runner or infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\` and stop this iteration — CI will re-run automatically.\n - If the log tail shows a real test or build failure, apply a code fix.\n - If the fenced log block is absent, run \`gh run view <runId> --log-failed\` first to fetch it, then choose between rerun and fix above.`);
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.`);
38
47
  }
39
48
  if (externalChecks.length > 0) {
40
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.`);
@@ -103,30 +112,3 @@ export function buildFixInstructions(threads, actionableComments, checks, review
103
112
  }
104
113
  return instructions;
105
114
  }
106
- export function buildWaitLog(base) {
107
- const { summary, remainingSeconds } = base;
108
- const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
109
- switch (base.mergeStatus) {
110
- case "BLOCKED":
111
- if (base.reviewDecision === "REVIEW_REQUIRED")
112
- parts.push("awaiting human review");
113
- else if (base.reviewDecision === "APPROVED")
114
- parts.push("awaiting additional approvals");
115
- else
116
- parts.push("awaiting human review or branch protection");
117
- break;
118
- case "BEHIND":
119
- parts.push("branch is behind base");
120
- break;
121
- case "DRAFT":
122
- parts.push("PR is a draft");
123
- break;
124
- case "UNSTABLE":
125
- parts.push("some checks are unstable");
126
- break;
127
- }
128
- if (remainingSeconds > 0) {
129
- parts.push(`${remainingSeconds}s until auto-cancel`);
130
- }
131
- return parts.join(" — ");
132
- }
@@ -29,7 +29,7 @@ export function buildFetchInstructions(prNumber, result) {
29
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
30
  }
31
31
  if (hasSuggestions) {
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\`, one thread at a time. On \`applied: true\` mark it Fixed — the CLI already resolved the thread, so exclude the ID from \`--resolve-thread-ids\`. On \`applied: false\` read \`reason\` and \`patch\`, then fall through to the manual fix step do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) if you want to inspect the unified diff before it mutates the working tree the CLI validates with \`git apply --check\`, returns the patch and \`valid: true/false\`, and exits \`1\` on drift without committing or resolving the thread.`);
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.`);
33
33
  }
34
34
  if (hasCodeItems) {
35
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/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"]
@@ -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, conclusion, category).
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.logTail !== undefined
32
- ? `examine the log tail${stepHint} — if transient, run \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",