pr-shepherd 0.25.3 → 0.26.0

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