pr-shepherd 0.42.0 → 0.44.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 (145) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +24 -18
  3. package/bin/api.d.mts +3 -2
  4. package/bin/api.mjs +13 -18
  5. package/bin/checks/classify.d.mts +3 -1
  6. package/bin/checks/classify.mjs +5 -2
  7. package/bin/cli/args.mjs +14 -9
  8. package/bin/cli/default-poll.mjs +1 -0
  9. package/bin/cli/fix-formatter.mjs +20 -8
  10. package/bin/cli/handlers.mjs +1 -0
  11. package/bin/cli/help-command-pages.d.mts +9 -6
  12. package/bin/cli/help-command-pages.mjs +7 -4
  13. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  14. package/bin/cli/help-iterate-poll-pages.mjs +9 -4
  15. package/bin/cli/help-top-page.d.mts +1 -1
  16. package/bin/cli/help-top-page.mjs +2 -2
  17. package/bin/cli/help.d.mts +10 -7
  18. package/bin/cli/iterate-flags.d.mts +1 -0
  19. package/bin/cli/iterate-flags.mjs +2 -0
  20. package/bin/cli/iterate-formatter.d.mts +1 -1
  21. package/bin/cli/iterate-formatter.mjs +9 -6
  22. package/bin/cli/iterate-instructions.mjs +15 -0
  23. package/bin/cli/iterate-lean.d.mts +1 -2
  24. package/bin/cli/iterate-lean.mjs +16 -17
  25. package/bin/cli/iterate-merge-formatter.d.mts +3 -0
  26. package/bin/cli/iterate-merge-formatter.mjs +52 -0
  27. package/bin/cli/iterate-verbose.d.mts +6 -0
  28. package/bin/cli/iterate-verbose.mjs +14 -0
  29. package/bin/cli/journal-formatter.mjs +3 -0
  30. package/bin/cli/journal-handler.mjs +11 -7
  31. package/bin/cli/list-formatters.d.mts +3 -1
  32. package/bin/cli/list-formatters.mjs +9 -7
  33. package/bin/cli/mark-files-as-viewed-formatter.mjs +7 -1
  34. package/bin/cli/mutate-formatter.mjs +4 -0
  35. package/bin/cli/poll-handler.mjs +1 -0
  36. package/bin/cli/runner.mjs +4 -3
  37. package/bin/commands/check-terminal-report.mjs +1 -0
  38. package/bin/commands/check.mjs +64 -7
  39. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  40. package/bin/commands/commit-suggestion-instruction.mjs +2 -2
  41. package/bin/commands/commit-suggestion.mjs +3 -1
  42. package/bin/commands/iterate/base.d.mts +5 -0
  43. package/bin/commands/iterate/base.mjs +25 -0
  44. package/bin/commands/iterate/check-instructions.d.mts +5 -19
  45. package/bin/commands/iterate/check-instructions.mjs +24 -25
  46. package/bin/commands/iterate/classify.d.mts +2 -2
  47. package/bin/commands/iterate/classify.mjs +43 -25
  48. package/bin/commands/iterate/escalate.d.mts +3 -1
  49. package/bin/commands/iterate/escalate.mjs +29 -2
  50. package/bin/commands/iterate/fix-code.mjs +81 -26
  51. package/bin/commands/iterate/helpers.d.mts +0 -2
  52. package/bin/commands/iterate/helpers.mjs +6 -17
  53. package/bin/commands/iterate/index.mjs +31 -43
  54. package/bin/commands/iterate/mark-ready.d.mts +2 -0
  55. package/bin/commands/iterate/mark-ready.mjs +38 -0
  56. package/bin/commands/iterate/merge-state.d.mts +15 -0
  57. package/bin/commands/iterate/merge-state.mjs +82 -0
  58. package/bin/commands/iterate/merge.d.mts +13 -0
  59. package/bin/commands/iterate/merge.mjs +45 -0
  60. package/bin/commands/iterate/render.d.mts +2 -2
  61. package/bin/commands/iterate/render.mjs +19 -20
  62. package/bin/commands/iterate/stall.mjs +4 -2
  63. package/bin/commands/iterate/thread-mutation-routing.d.mts +9 -0
  64. package/bin/commands/iterate/thread-mutation-routing.mjs +29 -0
  65. package/bin/commands/journal/index.d.mts +5 -0
  66. package/bin/commands/journal/index.mjs +11 -2
  67. package/bin/commands/mark-files-as-viewed.d.mts +2 -1
  68. package/bin/commands/mark-files-as-viewed.mjs +5 -92
  69. package/bin/commands/poll.mjs +3 -1
  70. package/bin/commands/resolve-mutate.mjs +51 -9
  71. package/bin/commands/shepherd-journal.d.mts +1 -1
  72. package/bin/commands/shepherd-journal.mjs +2 -2
  73. package/bin/commands/suggestion-patch-item.d.mts +1 -1
  74. package/bin/commands/suggestion-patch-item.mjs +3 -11
  75. package/bin/commands/suggestion-patches.mjs +3 -3
  76. package/bin/comments/authors.d.mts +6 -0
  77. package/bin/comments/authors.mjs +5 -0
  78. package/bin/comments/marker.d.mts +6 -1
  79. package/bin/comments/marker.mjs +8 -1
  80. package/bin/comments/resolve.d.mts +4 -0
  81. package/bin/comments/thread-visibility.mjs +10 -10
  82. package/bin/comments/visible-comments.mjs +2 -1
  83. package/bin/config/load.d.mts +5 -1
  84. package/bin/config/load.mjs +34 -0
  85. package/bin/config/merge-command-args.d.mts +2 -0
  86. package/bin/config/merge-command-args.mjs +44 -0
  87. package/bin/config.json +3 -0
  88. package/bin/exit-codes.d.mts +2 -0
  89. package/bin/exit-codes.mjs +4 -0
  90. package/bin/github/batch-parse-checks.d.mts +3 -0
  91. package/bin/github/batch-parse-checks.mjs +29 -0
  92. package/bin/github/batch-parsers-rules.d.mts +3 -1
  93. package/bin/github/batch-parsers-rules.mjs +33 -0
  94. package/bin/github/batch-parsers.d.mts +2 -2
  95. package/bin/github/batch-parsers.mjs +40 -27
  96. package/bin/github/batch-raw-rules.d.mts +48 -0
  97. package/bin/github/batch-raw-types.d.mts +12 -0
  98. package/bin/github/batch-raw-types.mjs +0 -1
  99. package/bin/github/batch.mjs +3 -1
  100. package/bin/github/client.d.mts +1 -0
  101. package/bin/github/client.mjs +8 -1
  102. package/bin/github/gql/batch-pr-page.gql +6 -0
  103. package/bin/github/gql/batch-pr.gql +105 -0
  104. package/bin/github/gql/commit-check-contexts.gql +58 -0
  105. package/bin/github/gql/get-pr-body.gql +1 -0
  106. package/bin/github/gql/review-thread-comments.gql +1 -0
  107. package/bin/github/gql/suggestion-threads.gql +1 -0
  108. package/bin/github/merge-queue-checks.d.mts +4 -0
  109. package/bin/github/merge-queue-checks.mjs +48 -0
  110. package/bin/github/queries.d.mts +2 -0
  111. package/bin/github/queries.mjs +2 -0
  112. package/bin/github/suggestion-thread.mjs +1 -0
  113. package/bin/mcp/server.mjs +13 -6
  114. package/bin/pr-reference.d.mts +13 -0
  115. package/bin/pr-reference.mjs +23 -1
  116. package/bin/reporters/agent.mjs +11 -3
  117. package/bin/threads/transcript.d.mts +2 -0
  118. package/bin/threads/transcript.mjs +2 -0
  119. package/bin/types/activity.d.mts +2 -0
  120. package/bin/types/agent-thread.d.mts +1 -0
  121. package/bin/types/escalate.d.mts +35 -0
  122. package/bin/types/escalate.mjs +1 -0
  123. package/bin/types/github.d.mts +27 -0
  124. package/bin/types/github.mjs +0 -3
  125. package/bin/types/iterate.d.mts +23 -39
  126. package/bin/types/merge-action.d.mts +12 -0
  127. package/bin/types/merge-action.mjs +1 -0
  128. package/bin/types/merge-queue.d.mts +13 -0
  129. package/bin/types/merge-queue.mjs +1 -0
  130. package/bin/types/merge-requirements.d.mts +17 -0
  131. package/bin/types/report.d.mts +22 -1
  132. package/bin/types/review-thread.d.mts +1 -0
  133. package/bin/types/suggestion-patch.d.mts +1 -1
  134. package/bin/types.d.mts +3 -0
  135. package/bin/types.mjs +3 -0
  136. package/bin/util/markdown.d.mts +2 -0
  137. package/bin/util/markdown.mjs +7 -0
  138. package/package.json +1 -1
  139. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  140. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  141. package/plugins/pr-shepherd/.mcp.json +1 -1
  142. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +3 -3
  143. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +17 -15
  144. package/bin/commands/iterate/reruns.d.mts +0 -20
  145. package/bin/commands/iterate/reruns.mjs +0 -105
@@ -5,13 +5,14 @@ import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/ag
5
5
  import { hashBody, markSeen } from "../../state/seen-comments.mjs";
6
6
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
7
7
  import { buildResolveCommand } from "./classify.mjs";
8
+ import { buildThreadMutationRouting } from "./thread-mutation-routing.mjs";
8
9
  import { buildFixInstructions } from "./render.mjs";
9
10
  import { applyStallGuard } from "./stall.mjs";
10
- import { tryCancelRun, buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./helpers.mjs";
11
11
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
13
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
14
14
  import { loadConfig } from "../../config/load.mjs";
15
+ import { formatPrUrl } from "../../pr-reference.mjs";
15
16
  function nextFixAttempts(stored, headSha, threads) {
16
17
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
17
18
  const threadBodyHashes = stored?.threadBodyHashes
@@ -29,9 +30,73 @@ function nextFixAttempts(stored, headSha, threads) {
29
30
  }
30
31
  export async function handleFixCode(ctx) {
31
32
  const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
33
+ const prReference = formatPrUrl(report.repo, prNumber);
32
34
  const failingChecks = report.checks.failing;
33
35
  const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
34
- const { protectedRunIds, protectedRuns } = buildRunProtection([...failingChecks, ...report.checks.inProgress], opts.neverCancelRuns);
36
+ const allThreads = [...report.threads.actionable, ...report.threads.resolutionOnly];
37
+ const ruleAutoResolveIds = new Set(ruleAutoResolveThreadIds ?? []);
38
+ const routedThreadMutations = buildThreadMutationRouting(allThreads, botUsernames, [
39
+ ...ruleAutoResolveIds,
40
+ ]);
41
+ const replyIdSet = new Set(routedThreadMutations.replyThreadIds);
42
+ const resolveIdSet = new Set(routedThreadMutations.resolveThreadIds);
43
+ const unauthorizedReplies = allThreads.filter((thread) => report.viewerAuthorization !== undefined &&
44
+ replyIdSet.has(thread.id) &&
45
+ thread.viewerCanReply !== true);
46
+ const unauthorizedResolves = allThreads.filter((thread) => report.viewerAuthorization !== undefined &&
47
+ resolveIdSet.has(thread.id) &&
48
+ thread.viewerCanResolve !== true);
49
+ const unauthorizedDismissals = report.changesRequestedReviews.filter((review) => report.viewerAuthorization !== undefined &&
50
+ (!isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames)) &&
51
+ report.viewerAuthorization?.viewerCanAdminister !== true);
52
+ const authorization = [
53
+ ...(unauthorizedReplies.length > 0
54
+ ? [
55
+ {
56
+ action: "reply-thread",
57
+ targetIds: unauthorizedReplies.map((thread) => thread.id),
58
+ reason: "denied-or-unverifiable",
59
+ },
60
+ ]
61
+ : []),
62
+ ...(unauthorizedResolves.length > 0
63
+ ? [
64
+ {
65
+ action: "resolve-thread",
66
+ targetIds: unauthorizedResolves.map((thread) => thread.id),
67
+ reason: "denied-or-unverifiable",
68
+ },
69
+ ]
70
+ : []),
71
+ ...(unauthorizedDismissals.length > 0
72
+ ? [
73
+ {
74
+ action: "dismiss-review",
75
+ targetIds: unauthorizedDismissals.map((review) => review.id),
76
+ reason: "denied-or-unverifiable",
77
+ },
78
+ ]
79
+ : []),
80
+ ];
81
+ if (authorization.length > 0) {
82
+ const authorizationEscalateBase = {
83
+ triggers: ["authorization-required"],
84
+ unresolvedThreads: allThreads.map(toAgentThread),
85
+ ambiguousComments: report.comments.actionable.map(toAgentComment),
86
+ changesRequestedReviews: report.changesRequestedReviews,
87
+ authorization,
88
+ suggestion: buildEscalateSuggestion(["authorization-required"]),
89
+ };
90
+ return {
91
+ ...base,
92
+ action: "escalate",
93
+ escalate: {
94
+ ...authorizationEscalateBase,
95
+ humanMessage: buildEscalateHumanMessage(authorizationEscalateBase, prReference),
96
+ },
97
+ };
98
+ }
99
+ const protectedRuns = [];
35
100
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
36
101
  const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
37
102
  const botCrReviews = report.changesRequestedReviews.filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames));
@@ -55,7 +120,7 @@ export async function handleFixCode(ctx) {
55
120
  action: "escalate",
56
121
  escalate: {
57
122
  ...escalateBase,
58
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
123
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
59
124
  },
60
125
  };
61
126
  }
@@ -74,18 +139,14 @@ export async function handleFixCode(ctx) {
74
139
  action: "escalate",
75
140
  escalate: {
76
141
  ...escalateBase,
77
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
142
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
78
143
  },
79
144
  };
80
145
  }
81
146
  await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
82
- let cancelled = [];
83
- if (!opts.noAutoCancelActionable) {
84
- const runIds = buildAutoCancelRunIdsWithOptions(report, { protectedRunIds });
85
- const results = await Promise.all(runIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
86
- cancelled = results.filter((id) => id !== null);
87
- }
88
- const cancelledSet = new Set(cancelled);
147
+ // GitHub does not expose a per-run viewer capability for cancellation. Fail closed:
148
+ // do not issue or recommend an Actions mutation based only on repository role.
149
+ const cancelled = [];
89
150
  const baseLookup = validateBaseBranch(report.baseBranch);
90
151
  const threads = report.threads.actionable.map(toAgentThread);
91
152
  const resolutionOnlyThreads = report.threads.resolutionOnly;
@@ -102,21 +163,15 @@ export async function handleFixCode(ctx) {
102
163
  // Only surface in-progress runs when a push is plausible — resolution-only and
103
164
  // summary-only iterations have no path to a push, so listing runs would prompt
104
165
  // unnecessary cancellation.
105
- const pushLikely = threads.length > 0 ||
106
- failingAgentChecks.length > 0 ||
107
- annotatedExtra.length > 0 ||
108
- hasConflicts ||
109
- changesRequestedReviews.length > 0 ||
110
- actionableComments.length > 0;
111
- const inProgressRunIds = pushLikely
112
- ? buildInProgressRunIds(report, cancelledSet, {
113
- suppressProtectedFreshReruns: false,
114
- protectedRunIds,
115
- })
116
- : [];
166
+ const inProgressRunIds = [];
117
167
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
118
168
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
119
- const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, failingAgentChecks, prNumber, botUsernames, ruleAutoResolveThreadIds);
169
+ // Conflict resolution necessarily changes the branch head, but Shepherd cannot verify
170
+ // authorization for the local Git credential that would publish it. Defer review mutations
171
+ // until an authorized push updates the PR and a fresh iteration can rebuild SHA-safe commands.
172
+ const { resolveCommand, resolveOnlyCommand } = hasConflicts
173
+ ? buildResolveCommand([], [], [], [], [], prReference, botUsernames, [], undefined, [])
174
+ : buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads);
120
175
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
121
176
  // would need the correct base to rebase safely. This is a conservative guard, not a
122
177
  // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
@@ -141,13 +196,13 @@ export async function handleFixCode(ctx) {
141
196
  action: "escalate",
142
197
  escalate: {
143
198
  ...fallbackEscalateBase,
144
- humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prNumber),
199
+ humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prReference),
145
200
  },
146
201
  };
147
202
  }
148
203
  const firstLookThreads = report.threads.firstLook;
149
204
  const firstLookComments = report.comments.firstLook;
150
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind);
205
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true);
151
206
  const prospectiveResult = {
152
207
  ...base,
153
208
  baseBranch: baseLookup.branch,
@@ -1,5 +1,4 @@
1
1
  import type { ActiveCheck, IterateResult, IterateResultBase, IterateResultSummary, RelevantCheck, ShepherdReport } from "../../types.mts";
2
- export { buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./reruns.mts";
3
2
  export declare function buildSummary(report: ShepherdReport): IterateResultSummary;
4
3
  /** Non-blocking check-name lists (ignored/superseded), omitted from the result when empty. */
5
4
  export declare function buildSuppressedCheckFields(report: ShepherdReport): Pick<IterateResultBase, "ignoredNames" | "supersededNames">;
@@ -8,7 +7,6 @@ export declare function buildTerminalCancelResult(report: ShepherdReport): Itera
8
7
  /** Build completed, non-skipped checks relevant to PR readiness. */
9
8
  export declare function buildRelevantChecks(report: ShepherdReport): RelevantCheck[];
10
9
  export declare function buildActiveChecks(report: ShepherdReport): ActiveCheck[];
11
- export declare function tryCancelRun(runId: string, owner: string, repo: string): Promise<string | null>;
12
10
  export declare function getCurrentHeadSha(): Promise<string | null>;
13
11
  export declare function buildWaitLog(base: IterateResultBase): string;
14
12
  export declare function blockedCancelNote(base: IterateResultBase): string;
@@ -1,10 +1,8 @@
1
1
  import { execFile as execFileCb } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { rest } from "../../github/http.mjs";
4
3
  import { getExecutionCwd } from "../../execution-context.mjs";
5
4
  import { blockedReasonFromRequirements } from "../../merge-status/requirements-format.mjs";
6
5
  const execFile = promisify(execFileCb);
7
- export { buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./reruns.mjs";
8
6
  export function buildSummary(report) {
9
7
  return {
10
8
  passing: report.checks.passing.length,
@@ -65,6 +63,8 @@ export function buildRelevantChecks(report) {
65
63
  runId: c.runId,
66
64
  detailsUrl: c.detailsUrl || null,
67
65
  ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
66
+ ...(c.scope !== undefined && { scope: c.scope }),
67
+ ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
68
68
  summary: c.summary,
69
69
  },
70
70
  ];
@@ -85,6 +85,8 @@ export function buildRelevantChecks(report) {
85
85
  ...(c.summary !== undefined && { summary: c.summary }),
86
86
  ...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
87
87
  ...(c.annotations !== undefined && { annotations: c.annotations }),
88
+ ...(c.scope !== undefined && { scope: c.scope }),
89
+ ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
88
90
  },
89
91
  ];
90
92
  });
@@ -98,23 +100,10 @@ export function buildActiveChecks(report) {
98
100
  detailsUrl: c.detailsUrl || null,
99
101
  ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
100
102
  ...(c.summary !== undefined && { summary: c.summary }),
103
+ ...(c.scope !== undefined && { scope: c.scope }),
104
+ ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
101
105
  }));
102
106
  }
103
- // Best-effort: cancelling a completed run is a no-op, not an error.
104
- export async function tryCancelRun(runId, owner, repo) {
105
- try {
106
- await rest("POST", `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`);
107
- return runId;
108
- }
109
- catch (err) {
110
- const msg = err instanceof Error ? err.message : String(err);
111
- // GitHub returns 409 when the run reached a terminal state — expected, not worth logging.
112
- if (/409|already completed|cannot cancel a workflow run that is completed/i.test(msg))
113
- return null;
114
- process.stderr.write(`pr-shepherd: cancel run ${runId} failed (ignored): ${msg}\n`);
115
- return null;
116
- }
117
- }
118
107
  export async function getCurrentHeadSha() {
119
108
  try {
120
109
  const { stdout } = await execFile("git", ["rev-parse", "HEAD"], {
@@ -1,11 +1,9 @@
1
1
  import { runCheck } from "../check.mjs";
2
2
  import { updateReadyDelay } from "../ready-delay.mjs";
3
3
  import { getCurrentPrNumber } from "../../github/client.mjs";
4
- import { graphql } from "../../github/http.mjs";
5
- import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
6
4
  import { loadConfig } from "../../config/load.mjs";
7
5
  import { EXIT, ShepherdError } from "../../exit-codes.mjs";
8
- import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
6
+ import { getCurrentHeadSha, buildWaitLog, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
9
7
  import { classifyReviewSummaries } from "./classify.mjs";
10
8
  import { applyStallGuard } from "./stall.mjs";
11
9
  import { clearStallState } from "../../state/iterate-stall.mjs";
@@ -13,6 +11,9 @@ import { handleFixCode } from "./fix-code.mjs";
13
11
  import { normalizeBotUsernames } from "../../comments/authors.mjs";
14
12
  import { autoMinimizeComments } from "../../comments/resolve.mjs";
15
13
  import { checksWithActionableAnnotations } from "../check-annotations.mjs";
14
+ import { buildReadyMergeResult, handleActiveMergeState } from "./merge-state.mjs";
15
+ import { buildIterateBase } from "./base.mjs";
16
+ import { markReadyIfAuthorized } from "./mark-ready.mjs";
16
17
  export async function runIterate(opts) {
17
18
  const config = loadConfig();
18
19
  const botUsernames = normalizeBotUsernames(config.botUsernames);
@@ -74,40 +75,12 @@ export async function runIterate(opts) {
74
75
  report.mergeStatus.status === "CONFLICTS" ||
75
76
  reviewSummaryIds.length > 0 ||
76
77
  firstLookSummaries.length > 0 ||
77
- editedSummaries.length > 0;
78
- const isCleanReadyHandoff = report.status === "READY" && !hasActionableWork;
78
+ editedSummaries.length > 0 ||
79
+ (config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
80
+ const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
81
+ const isCleanReadyHandoff = report.status === "READY" && !hasActionableWork && !activeMerge;
79
82
  const readyState = await updateReadyDelay(report.pr, isCleanReadyHandoff, readyDelaySeconds, repoOwner, repoName);
80
- const base = {
81
- pr: report.pr,
82
- repo: report.repo,
83
- status: report.status,
84
- state: report.mergeStatus.state,
85
- mergeStateStatus: report.mergeStatus.mergeStateStatus,
86
- mergeStatus: report.mergeStatus.status,
87
- reviewDecision: report.mergeStatus.reviewDecision,
88
- blockingBotReviewInProgress: report.mergeStatus.blockingBotReviewInProgress,
89
- isDraft: report.mergeStatus.isDraft,
90
- shouldCancel: readyState.shouldCancel,
91
- remainingSeconds: readyState.remainingSeconds,
92
- summary: buildSummary(report),
93
- baseBranch: report.baseBranch,
94
- branchProtection: report.branchProtection,
95
- mergeRequirements: report.mergeStatus.mergeRequirements,
96
- checks: buildRelevantChecks(report),
97
- inProgressChecks: buildActiveChecks(report),
98
- ...buildSuppressedCheckFields(report),
99
- activity: report.activity,
100
- };
101
- if (readyState.shouldCancel) {
102
- await clearStallState(stallKey);
103
- const cancelNote = blockedCancelNote(base);
104
- return {
105
- ...base,
106
- action: "cancel",
107
- reason: "ready-delay-elapsed",
108
- log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
109
- };
110
- }
83
+ const base = buildIterateBase(report, readyState);
111
84
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
112
85
  if (hasActionableWork) {
113
86
  return handleFixCode({
@@ -128,17 +101,32 @@ export async function runIterate(opts) {
128
101
  ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
129
102
  });
130
103
  }
104
+ const mergeStateResult = await handleActiveMergeState({
105
+ enabled: opts.merge,
106
+ active: activeMerge,
107
+ base,
108
+ report,
109
+ stallKey,
110
+ });
111
+ if (mergeStateResult)
112
+ return mergeStateResult;
131
113
  const canMarkReady = report.status === "READY" &&
132
114
  report.mergeStatus.isDraft &&
133
- !report.mergeStatus.blockingBotReviewInProgress &&
134
- !readyState.shouldCancel;
135
- if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
136
- await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
115
+ !report.mergeStatus.blockingBotReviewInProgress;
116
+ const markReadyResult = await markReadyIfAuthorized(canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady, base, report);
117
+ if (markReadyResult)
118
+ return markReadyResult;
119
+ if (readyState.shouldCancel) {
120
+ await clearStallState(stallKey);
121
+ const mergeResult = buildReadyMergeResult(opts.merge, true, base, report);
122
+ if (mergeResult)
123
+ return mergeResult;
124
+ const cancelNote = blockedCancelNote(base);
137
125
  return {
138
126
  ...base,
139
- action: "mark_ready",
140
- markedReady: true,
141
- log: `MARKED READY: PR #${report.pr} converted from draft to ready for review`,
127
+ action: "cancel",
128
+ reason: "ready-delay-elapsed",
129
+ log: `CANCEL: PR #${base.pr} ${cancelNote} ready-delay elapsed, stopping`,
142
130
  };
143
131
  }
144
132
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, { ...base, action: "wait", log: buildWaitLog(base) }, report, reviewSummaryIds);
@@ -0,0 +1,2 @@
1
+ import type { IterateResult, IterateResultBase, ShepherdReport } from "../../types.mts";
2
+ export declare function markReadyIfAuthorized(enabled: boolean, base: IterateResultBase, report: ShepherdReport): Promise<IterateResult | null>;
@@ -0,0 +1,38 @@
1
+ import { graphql } from "../../github/http.mjs";
2
+ import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
3
+ import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
4
+ export async function markReadyIfAuthorized(enabled, base, report) {
5
+ if (!enabled)
6
+ return null;
7
+ if (report.viewerAuthorization?.viewerCanUpdate === true) {
8
+ await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
9
+ return {
10
+ ...base,
11
+ action: "mark_ready",
12
+ markedReady: true,
13
+ log: `MARKED READY: PR #${report.pr} converted from draft to ready for review`,
14
+ };
15
+ }
16
+ const escalation = {
17
+ triggers: ["authorization-required"],
18
+ unresolvedThreads: [],
19
+ ambiguousComments: [],
20
+ changesRequestedReviews: [],
21
+ authorization: [
22
+ {
23
+ action: "mark-ready",
24
+ targetIds: [report.nodeId],
25
+ reason: "denied-or-unverifiable",
26
+ },
27
+ ],
28
+ suggestion: buildEscalateSuggestion(["authorization-required"]),
29
+ };
30
+ return {
31
+ ...base,
32
+ action: "escalate",
33
+ escalate: {
34
+ ...escalation,
35
+ humanMessage: buildEscalateHumanMessage(escalation, report.pr),
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { IterateResult, IterateResultBase, ShepherdReport } from "../../types.mts";
2
+ type StallKey = {
3
+ owner: string;
4
+ repo: string;
5
+ pr: number;
6
+ };
7
+ export declare function buildReadyMergeResult(enabled: boolean | undefined, readyElapsed: boolean, base: IterateResultBase, report: ShepherdReport): IterateResult | null;
8
+ export declare function handleActiveMergeState(input: {
9
+ enabled: boolean | undefined;
10
+ active: boolean;
11
+ base: IterateResultBase;
12
+ report: ShepherdReport;
13
+ stallKey: StallKey;
14
+ }): Promise<IterateResult | null>;
15
+ export {};
@@ -0,0 +1,82 @@
1
+ import { clearStallState } from "../../state/iterate-stall.mjs";
2
+ import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
3
+ import { buildMergeCommandPlan } from "./merge.mjs";
4
+ import { formatPrUrl } from "../../pr-reference.mjs";
5
+ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
6
+ if (!enabled || !readyElapsed || report.mergeStatus.isDraft)
7
+ return null;
8
+ const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
9
+ report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
10
+ if (!queue && report.viewerAuthorization?.viewerCanEnableAutoMerge === true) {
11
+ return {
12
+ ...base,
13
+ action: "merge",
14
+ merge: buildMergeCommandPlan({
15
+ pr: report.pr,
16
+ repo: report.repo,
17
+ nodeId: report.nodeId,
18
+ headSha: report.headSha ?? "unknown",
19
+ queue: false,
20
+ }),
21
+ };
22
+ }
23
+ const authorizationEscalateBase = {
24
+ triggers: ["authorization-required"],
25
+ unresolvedThreads: [],
26
+ ambiguousComments: [],
27
+ changesRequestedReviews: [],
28
+ authorization: [
29
+ {
30
+ action: "merge-or-enqueue",
31
+ targetIds: [report.nodeId],
32
+ reason: "denied-or-unverifiable",
33
+ },
34
+ ],
35
+ suggestion: buildEscalateSuggestion(["authorization-required"]),
36
+ };
37
+ return {
38
+ ...base,
39
+ action: "escalate",
40
+ escalate: {
41
+ ...authorizationEscalateBase,
42
+ humanMessage: buildEscalateHumanMessage(authorizationEscalateBase, report.pr, {
43
+ merge: true,
44
+ }),
45
+ },
46
+ };
47
+ }
48
+ export async function handleActiveMergeState(input) {
49
+ const { enabled, active, base, report, stallKey } = input;
50
+ if (enabled && active) {
51
+ await clearStallState(stallKey);
52
+ return {
53
+ ...base,
54
+ action: "wait",
55
+ log: report.mergeQueue?.inQueue
56
+ ? `WAIT: PR #${report.pr} is in the merge queue`
57
+ : `WAIT: PR #${report.pr} has auto-merge enabled`,
58
+ };
59
+ }
60
+ const removal = report.mergeQueue?.latestRemoval;
61
+ if (!enabled || !removal || report.mergeQueue?.headUpdatedAfterRemoval)
62
+ return null;
63
+ await clearStallState(stallKey);
64
+ const escalateBase = {
65
+ triggers: ["merge-queue-removed"],
66
+ unresolvedThreads: [],
67
+ ambiguousComments: [],
68
+ changesRequestedReviews: [],
69
+ mergeQueueRemoval: removal,
70
+ suggestion: buildEscalateSuggestion(["merge-queue-removed"], removal.reason ?? "GitHub did not provide a reason"),
71
+ };
72
+ return {
73
+ ...base,
74
+ action: "escalate",
75
+ escalate: {
76
+ ...escalateBase,
77
+ humanMessage: buildEscalateHumanMessage(escalateBase, formatPrUrl(report.repo, report.pr), {
78
+ merge: true,
79
+ }),
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,13 @@
1
+ import type { MergeCommandPlan } from "../../types.mts";
2
+ interface MergePlanInput {
3
+ pr: number;
4
+ repo: string;
5
+ nodeId: string;
6
+ headSha: string;
7
+ queue: boolean;
8
+ }
9
+ export declare function buildMergeCommandPlan(input: MergePlanInput): MergeCommandPlan;
10
+ export declare function renderMergeCommand(command: {
11
+ argv: string[];
12
+ }): string;
13
+ export {};
@@ -0,0 +1,45 @@
1
+ import { loadConfig } from "../../config/load.mjs";
2
+ import { findMergeStrategies } from "../../config/merge-command-args.mjs";
3
+ import { renderShellCommand } from "../../cli/runner.mjs";
4
+ const ENQUEUE_MUTATION = "mutation EnqueuePullRequest($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }";
5
+ export function buildMergeCommandPlan(input) {
6
+ const base = [
7
+ "gh",
8
+ "pr",
9
+ "merge",
10
+ String(input.pr),
11
+ "--repo",
12
+ input.repo,
13
+ "--match-head-commit",
14
+ input.headSha,
15
+ ];
16
+ if (input.queue) {
17
+ return {
18
+ mode: "queue",
19
+ command: { argv: base },
20
+ queueApiFallbackCommand: {
21
+ argv: [
22
+ "gh",
23
+ "api",
24
+ "graphql",
25
+ "-f",
26
+ `query=${ENQUEUE_MUTATION}`,
27
+ "-f",
28
+ `pullRequestId=${input.nodeId}`,
29
+ "-f",
30
+ `expectedHeadOid=${input.headSha}`,
31
+ ],
32
+ },
33
+ };
34
+ }
35
+ const configuredArgs = loadConfig().merge?.commandArgs ?? [];
36
+ const hasStrategy = findMergeStrategies(configuredArgs).length > 0;
37
+ const commandArgs = hasStrategy ? configuredArgs : [...configuredArgs, "--merge"];
38
+ return {
39
+ mode: "auto",
40
+ command: { argv: [...base, "--auto", ...commandArgs] },
41
+ };
42
+ }
43
+ export function renderMergeCommand(command) {
44
+ return renderShellCommand(command.argv);
45
+ }
@@ -1,5 +1,5 @@
1
1
  import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, FirstLookThread, FirstLookComment, ReviewThread } from "../../types.mts";
2
2
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
3
3
  export declare function renderResolveCommand(rc: ResolveCommand): string;
4
- export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prNumber: number, cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
- isBehind?: boolean): string[];
4
+ export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
+ isBehind?: boolean, viewerCanUpdate?: boolean): string[];
@@ -10,8 +10,8 @@ export function renderResolveCommand(rc) {
10
10
  parts.push("--require-sha", "$HEAD_SHA");
11
11
  return renderShellCommand(parts);
12
12
  }
13
- export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
- isBehind = false) {
13
+ export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
+ isBehind = false, viewerCanUpdate = false) {
15
15
  const instructions = [];
16
16
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
17
17
  const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
@@ -38,13 +38,13 @@ isBehind = false) {
38
38
  instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
39
39
  }
40
40
  if (hasConflicts) {
41
- instructions.push("The branch has merge conflicts (see `**branch**` above). Resolve them before committing and pushing.");
41
+ instructions.push("The branch has merge conflicts (see `**branch**` above). Resolve them before committing.");
42
42
  }
43
43
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
44
44
  if (firstLookTotal > 0) {
45
45
  instructions.push("Review every item under `## First-look items` before acting.");
46
46
  }
47
- if (firstLookSummaries.length > 0)
47
+ if (firstLookSummaries.length > 0 && viewerCanUpdate)
48
48
  instructions.push(SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE);
49
49
  const editedTotal = editedSummaries.length +
50
50
  actionableComments.filter((c) => c.edited).length +
@@ -53,15 +53,13 @@ isBehind = false) {
53
53
  if (editedTotal > 0) {
54
54
  instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
55
55
  }
56
- if (inProgressRunIds.length > 0) {
57
- instructions.push("If you will push, first cancel every ID under `## In-progress runs` with `gh run cancel <id>` (ignore errors for runs that already finished). If you will not push, leave them alone.");
58
- }
59
- if (cancelledCount > 0) {
60
- instructions.push("Do not cancel the IDs under `## Cancelled runs` again. The CLI already cancelled them.");
61
- }
56
+ // GitHub exposes no exact viewer capability for workflow-run cancellation, so the
57
+ // informational run lists never produce a cancellation recommendation.
58
+ void inProgressRunIds;
59
+ void cancelledCount;
62
60
  const hasSuggestions = threads.some((t) => t.suggestion);
63
61
  if (hasSuggestions)
64
- instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads"));
62
+ instructions.push(buildCommitSuggestionInstruction(prReference, "## Review threads"));
65
63
  if (threads.length > 0 || actionableComments.length > 0) {
66
64
  // Actionable comments carry no file/line location (unlike threads), so "referenced above"
67
65
  // is only accurate when threads are present.
@@ -83,21 +81,22 @@ isBehind = false) {
83
81
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
84
82
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
85
83
  if (hasConflicts) {
86
- instructions.push(`Commit any remaining changes and push the conflict resolution${mutationSuffix}.`);
84
+ instructions.push(`Commit any remaining conflict-resolution changes${mutationSuffix}.`);
87
85
  }
88
86
  else if (hasNonConflictHints) {
89
- instructions.push(`If you changed code, commit any remaining changes and push${mutationSuffix}. Otherwise, do not commit or push.`);
87
+ instructions.push("If you changed code, commit any remaining changes, then stop and hand off for a push whose authorization is established outside Shepherd; do not run the remaining review mutations or iterate until the remote PR head changes. Shepherd cannot verify the Git credential's push authorization. If you did not change code, do not commit and continue with the remaining steps.");
90
88
  }
91
- if (hasReviewMutations ||
92
- hasNonConflictHints ||
93
- firstLookTotal > 0 ||
94
- firstLookSummaries.length > 0 ||
95
- editedTotal > 0) {
96
- instructions.push(buildShepherdJournalInstruction(prNumber));
89
+ if (viewerCanUpdate &&
90
+ (hasReviewMutations ||
91
+ hasNonConflictHints ||
92
+ firstLookTotal > 0 ||
93
+ firstLookSummaries.length > 0 ||
94
+ editedTotal > 0)) {
95
+ instructions.push(buildShepherdJournalInstruction(prReference));
97
96
  }
98
97
  if (resolveOnlyCommand?.hasMutations)
99
98
  instructions.push("Run the `resolve-only:` command shown above.");
100
99
  instructions.push(...buildResolveCommandInstruction(resolveCommand));
101
- instructions.push(buildFixCompletionInstruction(failingChecks));
100
+ instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
102
101
  return instructions;
103
102
  }