pr-shepherd 0.43.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 (96) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +18 -17
  3. package/bin/api.d.mts +3 -2
  4. package/bin/api.mjs +13 -18
  5. package/bin/cli/args.mjs +13 -9
  6. package/bin/cli/fix-formatter.mjs +8 -6
  7. package/bin/cli/help-command-pages.d.mts +9 -6
  8. package/bin/cli/help-command-pages.mjs +7 -4
  9. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  10. package/bin/cli/help-iterate-poll-pages.mjs +2 -2
  11. package/bin/cli/help-top-page.d.mts +1 -1
  12. package/bin/cli/help-top-page.mjs +2 -2
  13. package/bin/cli/help.d.mts +10 -7
  14. package/bin/cli/iterate-formatter.d.mts +1 -1
  15. package/bin/cli/iterate-formatter.mjs +5 -5
  16. package/bin/cli/iterate-lean.mjs +4 -0
  17. package/bin/cli/journal-formatter.mjs +3 -0
  18. package/bin/cli/journal-handler.mjs +11 -7
  19. package/bin/cli/list-formatters.d.mts +3 -1
  20. package/bin/cli/list-formatters.mjs +9 -7
  21. package/bin/cli/mark-files-as-viewed-formatter.mjs +7 -1
  22. package/bin/cli/mutate-formatter.mjs +4 -0
  23. package/bin/commands/check.mjs +22 -6
  24. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  25. package/bin/commands/commit-suggestion-instruction.mjs +2 -2
  26. package/bin/commands/commit-suggestion.mjs +3 -1
  27. package/bin/commands/iterate/check-instructions.d.mts +5 -19
  28. package/bin/commands/iterate/check-instructions.mjs +24 -25
  29. package/bin/commands/iterate/classify.d.mts +2 -2
  30. package/bin/commands/iterate/classify.mjs +43 -25
  31. package/bin/commands/iterate/escalate.d.mts +1 -1
  32. package/bin/commands/iterate/escalate.mjs +11 -0
  33. package/bin/commands/iterate/fix-code.mjs +81 -42
  34. package/bin/commands/iterate/helpers.d.mts +0 -2
  35. package/bin/commands/iterate/helpers.mjs +0 -15
  36. package/bin/commands/iterate/index.mjs +6 -12
  37. package/bin/commands/iterate/mark-ready.d.mts +2 -0
  38. package/bin/commands/iterate/mark-ready.mjs +38 -0
  39. package/bin/commands/iterate/merge-state.mjs +38 -9
  40. package/bin/commands/iterate/merge.mjs +0 -1
  41. package/bin/commands/iterate/render.d.mts +2 -2
  42. package/bin/commands/iterate/render.mjs +19 -20
  43. package/bin/commands/iterate/stall.mjs +4 -2
  44. package/bin/commands/iterate/thread-mutation-routing.d.mts +9 -0
  45. package/bin/commands/iterate/thread-mutation-routing.mjs +29 -0
  46. package/bin/commands/journal/index.d.mts +5 -0
  47. package/bin/commands/journal/index.mjs +11 -2
  48. package/bin/commands/mark-files-as-viewed.d.mts +2 -1
  49. package/bin/commands/mark-files-as-viewed.mjs +5 -92
  50. package/bin/commands/resolve-mutate.mjs +51 -9
  51. package/bin/commands/shepherd-journal.d.mts +1 -1
  52. package/bin/commands/shepherd-journal.mjs +2 -2
  53. package/bin/commands/suggestion-patch-item.d.mts +1 -1
  54. package/bin/commands/suggestion-patch-item.mjs +3 -11
  55. package/bin/commands/suggestion-patches.mjs +3 -3
  56. package/bin/comments/authors.d.mts +6 -0
  57. package/bin/comments/authors.mjs +5 -0
  58. package/bin/comments/marker.d.mts +6 -1
  59. package/bin/comments/marker.mjs +8 -1
  60. package/bin/comments/resolve.d.mts +4 -0
  61. package/bin/comments/thread-visibility.mjs +10 -10
  62. package/bin/comments/visible-comments.mjs +2 -1
  63. package/bin/config/load.d.mts +1 -1
  64. package/bin/github/batch-parsers.d.mts +2 -2
  65. package/bin/github/batch-parsers.mjs +17 -1
  66. package/bin/github/batch-raw-types.d.mts +12 -0
  67. package/bin/github/batch.mjs +1 -1
  68. package/bin/github/client.d.mts +1 -0
  69. package/bin/github/client.mjs +8 -1
  70. package/bin/github/gql/batch-pr-page.gql +6 -0
  71. package/bin/github/gql/batch-pr.gql +13 -0
  72. package/bin/github/gql/get-pr-body.gql +1 -0
  73. package/bin/github/gql/review-thread-comments.gql +1 -0
  74. package/bin/github/gql/suggestion-threads.gql +1 -0
  75. package/bin/github/suggestion-thread.mjs +1 -0
  76. package/bin/mcp/server.mjs +12 -6
  77. package/bin/pr-reference.d.mts +13 -0
  78. package/bin/pr-reference.mjs +23 -1
  79. package/bin/reporters/agent.mjs +5 -0
  80. package/bin/threads/transcript.d.mts +2 -0
  81. package/bin/threads/transcript.mjs +2 -0
  82. package/bin/types/agent-thread.d.mts +1 -0
  83. package/bin/types/escalate.d.mts +6 -1
  84. package/bin/types/github.d.mts +19 -0
  85. package/bin/types/iterate.d.mts +9 -8
  86. package/bin/types/report.d.mts +14 -1
  87. package/bin/types/review-thread.d.mts +1 -0
  88. package/bin/types/suggestion-patch.d.mts +1 -1
  89. package/package.json +1 -1
  90. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  91. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  92. package/plugins/pr-shepherd/.mcp.json +1 -1
  93. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +3 -3
  94. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +15 -13
  95. package/bin/commands/iterate/reruns.d.mts +0 -20
  96. package/bin/commands/iterate/reruns.mjs +0 -105
@@ -1,6 +1,7 @@
1
1
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
2
2
  import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
3
3
  import { isConfiguredBotAuthor, isHumanAuthor, } from "../../comments/authors.mjs";
4
+ import { buildThreadMutationRouting } from "./thread-mutation-routing.mjs";
4
5
  function dedupeIds(ids) {
5
6
  const seen = new Set();
6
7
  const out = [];
@@ -14,7 +15,8 @@ function dedupeIds(ids) {
14
15
  }
15
16
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all", botUsernames = new Set(), unresolvedThreads = [], ruleAutoResolveIds = []) {
16
17
  const blockedReviewIds = new Set(unresolvedThreads.flatMap((t) => (t.reviewId !== undefined ? [t.reviewId] : [])));
17
- const eligible = (r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames) &&
18
+ const eligible = (r) => r.viewerCanMinimize === true &&
19
+ shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames) &&
18
20
  !blockedReviewIds.has(r.id);
19
21
  // First-look summaries still need one tick to surface their body to the agent,
20
22
  // so their minimize IDs ride in the agent-facing apply command. Seen summaries
@@ -34,7 +36,8 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
34
36
  if (minimizeApprovals) {
35
37
  const surfacedApprovals = [];
36
38
  for (const r of approvals) {
37
- if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
39
+ if (r.viewerCanMinimize === true &&
40
+ shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
38
41
  minimizeIds.push(r.id);
39
42
  else
40
43
  surfacedApprovals.push(r);
@@ -55,35 +58,45 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
55
58
  surfacedApprovals: approvals,
56
59
  };
57
60
  }
58
- export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber, botUsernames = new Set(), ruleAutoResolveThreadIds = []) {
61
+ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prReference, botUsernames = new Set(), ruleAutoResolveThreadIds = [], viewerAuthorization, authorizationThreads = []) {
59
62
  const allThreads = [...threads, ...resolutionOnlyThreads];
60
- const replyThreadIds = dedupeIds(allThreads
61
- .filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
62
- .map((t) => t.id));
63
- // Rule-matched threads bypass the author check (human-author guard still applies in resolve-mutate).
64
- const resolveThreadIds = dedupeIds([
65
- ...allThreads
66
- .filter((t) => !isHumanAuthor(t) || isConfiguredBotAuthor(t, botUsernames))
67
- .map((t) => t.id),
68
- ...ruleAutoResolveThreadIds,
69
- ]);
63
+ const routed = buildThreadMutationRouting(allThreads, botUsernames, ruleAutoResolveThreadIds);
64
+ const canReply = new Set(authorizationThreads
65
+ .filter((thread) => thread.viewerCanReply === true)
66
+ .map((thread) => thread.id));
67
+ const canResolve = new Set(authorizationThreads
68
+ .filter((thread) => thread.viewerCanResolve === true)
69
+ .map((thread) => thread.id));
70
+ const replyThreadIds = routed.replyThreadIds.filter((id) => canReply.has(id));
71
+ // Viewer-authored human resolves stay paired with an authorized reply. Marker-ended
72
+ // viewer-authored retries and bot/non-human resolves need only resolve authorization.
73
+ const pairedResolveThreadIds = routed.pairedResolveThreadIds.filter((id) => canReply.has(id) && canResolve.has(id));
74
+ const standaloneResolveThreadIds = routed.standaloneResolveThreadIds.filter((id) => canResolve.has(id));
75
+ const resolveThreadIds = dedupeIds([...pairedResolveThreadIds, ...standaloneResolveThreadIds]);
70
76
  // Bot/non-human CHANGES_REQUESTED reviews are auto-dismissed after the agent pushes a fix.
71
77
  // Human reviews are left for the reviewer to re-review or dismiss themselves.
72
- const dismissReviewIds = dedupeIds(reviews
73
- .filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames))
74
- .map((r) => r.id));
78
+ const dismissReviewIds = dedupeIds(viewerAuthorization?.viewerCanAdminister === true
79
+ ? reviews
80
+ .filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames))
81
+ .map((r) => r.id)
82
+ : []);
75
83
  const hasReply = replyThreadIds.length > 0;
76
84
  const hasDismiss = dismissReviewIds.length > 0;
77
85
  // Mutations that require --message: replies (to human threads) and dismissals (of bot CR reviews).
78
86
  const hasMessageMutations = hasReply || hasDismiss;
79
87
  const hasResolveOrMinimize = resolveThreadIds.length > 0 || allCommentIds.length > 0;
80
- if (hasMessageMutations && hasResolveOrMinimize) {
81
- // Split: message-bearing mutations (replies + dismissals) ride in resolveArgv;
82
- // resolve/minimize mutations go in resolveOnlyArgv so they can run without SHA or message.
83
- const resolveArgv = buildPrShepherdCommand(["apply", "review", String(prNumber)]).argv;
88
+ const hasStandaloneResolveOrMinimize = standaloneResolveThreadIds.length > 0 || allCommentIds.length > 0;
89
+ if (hasMessageMutations && hasStandaloneResolveOrMinimize) {
90
+ // Split: message-bearing mutations plus their paired viewer-authored resolves ride in
91
+ // resolveArgv; standalone resolve/minimize mutations go in resolveOnlyArgv so they can
92
+ // run without SHA or message.
93
+ const resolveArgv = buildPrShepherdCommand(["apply", "review", String(prReference)]).argv;
84
94
  if (replyThreadIds.length > 0) {
85
95
  resolveArgv.push("--reply-thread-ids", replyThreadIds.join(","));
86
96
  }
97
+ if (pairedResolveThreadIds.length > 0) {
98
+ resolveArgv.push("--resolve-thread-ids", pairedResolveThreadIds.join(","));
99
+ }
87
100
  resolveArgv.push("--message", "$DISMISS_MESSAGE");
88
101
  if (hasDismiss) {
89
102
  resolveArgv.push("--dismiss-review-ids", dismissReviewIds.join(","));
@@ -96,12 +109,15 @@ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentId
96
109
  requiresHeadSha,
97
110
  requiresDismissMessage: true,
98
111
  ...(replyThreadIds.length > 0 ? { replyThreadIds } : undefined),
112
+ ...(pairedResolveThreadIds.length > 0
113
+ ? { resolveThreadIds: pairedResolveThreadIds }
114
+ : undefined),
99
115
  ...(hasDismiss ? { dismissReviewIds } : undefined),
100
116
  hasMutations: true,
101
117
  };
102
- const resolveOnlyArgv = buildPrShepherdCommand(["apply", "review", String(prNumber)]).argv;
103
- if (resolveThreadIds.length > 0) {
104
- resolveOnlyArgv.push("--resolve-thread-ids", resolveThreadIds.join(","));
118
+ const resolveOnlyArgv = buildPrShepherdCommand(["apply", "review", String(prReference)]).argv;
119
+ if (standaloneResolveThreadIds.length > 0) {
120
+ resolveOnlyArgv.push("--resolve-thread-ids", standaloneResolveThreadIds.join(","));
105
121
  }
106
122
  if (allCommentIds.length > 0) {
107
123
  resolveOnlyArgv.push("--minimize-comment-ids", allCommentIds.join(","));
@@ -110,13 +126,15 @@ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentId
110
126
  argv: resolveOnlyArgv,
111
127
  requiresHeadSha: false,
112
128
  requiresDismissMessage: false,
113
- ...(resolveThreadIds.length > 0 ? { resolveThreadIds } : undefined),
129
+ ...(standaloneResolveThreadIds.length > 0
130
+ ? { resolveThreadIds: standaloneResolveThreadIds }
131
+ : undefined),
114
132
  hasMutations: true,
115
133
  };
116
134
  return { resolveCommand, resolveOnlyCommand };
117
135
  }
118
136
  // Single command: all mutations combined (or only one category present).
119
- const argv = buildPrShepherdCommand(["apply", "review", String(prNumber)]).argv;
137
+ const argv = buildPrShepherdCommand(["apply", "review", String(prReference)]).argv;
120
138
  if (replyThreadIds.length > 0) {
121
139
  argv.push("--reply-thread-ids", replyThreadIds.join(","));
122
140
  argv.push("--message", "$DISMISS_MESSAGE");
@@ -26,7 +26,7 @@ interface BaseBranchLookup {
26
26
  */
27
27
  export declare function formatDurationApprox(seconds: number): string;
28
28
  export declare function validateBaseBranch(raw: string): BaseBranchLookup;
29
- export declare function buildEscalateHumanMessage(escalate: Omit<EscalateDetails, "humanMessage">, pr: number, opts?: {
29
+ export declare function buildEscalateHumanMessage(escalate: Omit<EscalateDetails, "humanMessage">, pr: string | number, opts?: {
30
30
  merge?: boolean;
31
31
  }): string;
32
32
  export declare function buildEscalateSuggestion(triggers: EscalateTrigger[], detail?: string): string;
@@ -121,6 +121,14 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
121
121
  if (removal.beforeCommitOid)
122
122
  lines.push(`- queue commit: \`${removal.beforeCommitOid}\``);
123
123
  }
124
+ if (escalate.authorization && escalate.authorization.length > 0) {
125
+ lines.push("");
126
+ lines.push("## Authorization");
127
+ lines.push("");
128
+ for (const item of escalate.authorization) {
129
+ lines.push(`- ${item.action}: ${item.targetIds.map((id) => `\`${id}\``).join(", ")} — GitHub denied the action or did not expose a confirming capability`);
130
+ }
131
+ }
124
132
  if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
125
133
  lines.push("");
126
134
  lines.push("## Fix attempts");
@@ -136,6 +144,9 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
136
144
  return lines.join("\n");
137
145
  }
138
146
  export function buildEscalateSuggestion(triggers, detail) {
147
+ if (triggers.includes("authorization-required")) {
148
+ return "GitHub did not confirm that the current viewer may perform one or more required actions. Ask a repository maintainer to handle the listed items; Shepherd will not recommend commands that would be denied.";
149
+ }
139
150
  if (triggers.includes("merge-queue-removed")) {
140
151
  const reason = detail ? ` GitHub reason: ${detail}.` : "";
141
152
  return `The PR left the merge queue without an actionable check failure.${reason} Confirm the removal was not intentional before adding it again.`;
@@ -5,14 +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 { buildMergeCommandPlan } from "./merge.mjs";
15
+ import { formatPrUrl } from "../../pr-reference.mjs";
16
16
  function nextFixAttempts(stored, headSha, threads) {
17
17
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
18
18
  const threadBodyHashes = stored?.threadBodyHashes
@@ -30,9 +30,73 @@ function nextFixAttempts(stored, headSha, threads) {
30
30
  }
31
31
  export async function handleFixCode(ctx) {
32
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);
33
34
  const failingChecks = report.checks.failing;
34
35
  const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
35
- 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 = [];
36
100
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
37
101
  const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
38
102
  const botCrReviews = report.changesRequestedReviews.filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames));
@@ -56,7 +120,7 @@ export async function handleFixCode(ctx) {
56
120
  action: "escalate",
57
121
  escalate: {
58
122
  ...escalateBase,
59
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
123
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
60
124
  },
61
125
  };
62
126
  }
@@ -75,18 +139,14 @@ export async function handleFixCode(ctx) {
75
139
  action: "escalate",
76
140
  escalate: {
77
141
  ...escalateBase,
78
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
142
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
79
143
  },
80
144
  };
81
145
  }
82
146
  await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
83
- let cancelled = [];
84
- if (!opts.noAutoCancelActionable) {
85
- const runIds = buildAutoCancelRunIdsWithOptions(report, { protectedRunIds });
86
- const results = await Promise.all(runIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
87
- cancelled = results.filter((id) => id !== null);
88
- }
89
- 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 = [];
90
150
  const baseLookup = validateBaseBranch(report.baseBranch);
91
151
  const threads = report.threads.actionable.map(toAgentThread);
92
152
  const resolutionOnlyThreads = report.threads.resolutionOnly;
@@ -103,21 +163,15 @@ export async function handleFixCode(ctx) {
103
163
  // Only surface in-progress runs when a push is plausible — resolution-only and
104
164
  // summary-only iterations have no path to a push, so listing runs would prompt
105
165
  // unnecessary cancellation.
106
- const pushLikely = threads.length > 0 ||
107
- failingAgentChecks.length > 0 ||
108
- annotatedExtra.length > 0 ||
109
- hasConflicts ||
110
- changesRequestedReviews.length > 0 ||
111
- actionableComments.length > 0;
112
- const inProgressRunIds = pushLikely
113
- ? buildInProgressRunIds(report, cancelledSet, {
114
- suppressProtectedFreshReruns: false,
115
- protectedRunIds,
116
- })
117
- : [];
166
+ const inProgressRunIds = [];
118
167
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
119
168
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
120
- 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);
121
175
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
122
176
  // would need the correct base to rebase safely. This is a conservative guard, not a
123
177
  // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
@@ -142,27 +196,13 @@ export async function handleFixCode(ctx) {
142
196
  action: "escalate",
143
197
  escalate: {
144
198
  ...fallbackEscalateBase,
145
- humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prNumber),
199
+ humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prReference),
146
200
  },
147
201
  };
148
202
  }
149
203
  const firstLookThreads = report.threads.firstLook;
150
204
  const firstLookComments = report.comments.firstLook;
151
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind);
152
- const hasMergeGroupFailure = failingChecks.some((check) => check.scope === "merge_group");
153
- const needsRequeue = Boolean(hasMergeGroupFailure || report.mergeQueue?.inQueue || report.mergeQueue?.latestRemoval);
154
- const requeue = opts.merge && needsRequeue
155
- ? buildMergeCommandPlan({
156
- pr: report.pr,
157
- repo: report.repo,
158
- nodeId: report.nodeId,
159
- headSha: "$HEAD_SHA",
160
- queue: true,
161
- })
162
- : undefined;
163
- if (requeue) {
164
- instructions.splice(Math.max(0, instructions.length - 1), 0, "After all fixes and review mutations, replace `$HEAD_SHA` in the requeue commands with the full pushed PR-head SHA (or `$(git rev-parse HEAD)`). If GitHub no longer reports the PR in the merge queue, run the `requeue:` command shown above; if the gh CLI reports that auto-merge is disabled, run `requeue API fallback:` instead.");
165
- }
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);
166
206
  const prospectiveResult = {
167
207
  ...base,
168
208
  baseBranch: baseLookup.branch,
@@ -184,7 +224,6 @@ export async function handleFixCode(ctx) {
184
224
  firstLookComments,
185
225
  inProgressRunIds,
186
226
  protectedRuns,
187
- ...(requeue && { requeue }),
188
227
  },
189
228
  cancelled,
190
229
  };
@@ -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,
@@ -106,19 +104,6 @@ export function buildActiveChecks(report) {
106
104
  ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
107
105
  }));
108
106
  }
109
- export async function tryCancelRun(runId, owner, repo) {
110
- try {
111
- await rest("POST", `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`);
112
- return runId;
113
- }
114
- catch (err) {
115
- const msg = err instanceof Error ? err.message : String(err);
116
- if (/409|already completed|cannot cancel a workflow run that is completed/i.test(msg))
117
- return null;
118
- process.stderr.write(`pr-shepherd: cancel run ${runId} failed (ignored): ${msg}\n`);
119
- return null;
120
- }
121
- }
122
107
  export async function getCurrentHeadSha() {
123
108
  try {
124
109
  const { stdout } = await execFile("git", ["rev-parse", "HEAD"], {
@@ -1,8 +1,6 @@
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
6
  import { getCurrentHeadSha, buildWaitLog, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
@@ -15,6 +13,7 @@ import { autoMinimizeComments } from "../../comments/resolve.mjs";
15
13
  import { checksWithActionableAnnotations } from "../check-annotations.mjs";
16
14
  import { buildReadyMergeResult, handleActiveMergeState } from "./merge-state.mjs";
17
15
  import { buildIterateBase } from "./base.mjs";
16
+ import { markReadyIfAuthorized } from "./mark-ready.mjs";
18
17
  export async function runIterate(opts) {
19
18
  const config = loadConfig();
20
19
  const botUsernames = normalizeBotUsernames(config.botUsernames);
@@ -76,7 +75,8 @@ export async function runIterate(opts) {
76
75
  report.mergeStatus.status === "CONFLICTS" ||
77
76
  reviewSummaryIds.length > 0 ||
78
77
  firstLookSummaries.length > 0 ||
79
- editedSummaries.length > 0;
78
+ editedSummaries.length > 0 ||
79
+ (config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
80
80
  const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
81
81
  const isCleanReadyHandoff = report.status === "READY" && !hasActionableWork && !activeMerge;
82
82
  const readyState = await updateReadyDelay(report.pr, isCleanReadyHandoff, readyDelaySeconds, repoOwner, repoName);
@@ -113,15 +113,9 @@ export async function runIterate(opts) {
113
113
  const canMarkReady = report.status === "READY" &&
114
114
  report.mergeStatus.isDraft &&
115
115
  !report.mergeStatus.blockingBotReviewInProgress;
116
- if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
117
- await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
118
- return {
119
- ...base,
120
- action: "mark_ready",
121
- markedReady: true,
122
- log: `MARKED READY: PR #${report.pr} converted from draft to ready for review`,
123
- };
124
- }
116
+ const markReadyResult = await markReadyIfAuthorized(canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady, base, report);
117
+ if (markReadyResult)
118
+ return markReadyResult;
125
119
  if (readyState.shouldCancel) {
126
120
  await clearStallState(stallKey);
127
121
  const mergeResult = buildReadyMergeResult(opts.merge, true, base, report);
@@ -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
+ }
@@ -1,21 +1,48 @@
1
1
  import { clearStallState } from "../../state/iterate-stall.mjs";
2
2
  import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
3
3
  import { buildMergeCommandPlan } from "./merge.mjs";
4
+ import { formatPrUrl } from "../../pr-reference.mjs";
4
5
  export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
5
6
  if (!enabled || !readyElapsed || report.mergeStatus.isDraft)
6
7
  return null;
7
8
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
8
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
+ };
9
37
  return {
10
38
  ...base,
11
- action: "merge",
12
- merge: buildMergeCommandPlan({
13
- pr: report.pr,
14
- repo: report.repo,
15
- nodeId: report.nodeId,
16
- headSha: report.headSha ?? "unknown",
17
- queue,
18
- }),
39
+ action: "escalate",
40
+ escalate: {
41
+ ...authorizationEscalateBase,
42
+ humanMessage: buildEscalateHumanMessage(authorizationEscalateBase, report.pr, {
43
+ merge: true,
44
+ }),
45
+ },
19
46
  };
20
47
  }
21
48
  export async function handleActiveMergeState(input) {
@@ -47,7 +74,9 @@ export async function handleActiveMergeState(input) {
47
74
  action: "escalate",
48
75
  escalate: {
49
76
  ...escalateBase,
50
- humanMessage: buildEscalateHumanMessage(escalateBase, report.pr, { merge: true }),
77
+ humanMessage: buildEscalateHumanMessage(escalateBase, formatPrUrl(report.repo, report.pr), {
78
+ merge: true,
79
+ }),
51
80
  },
52
81
  };
53
82
  }
@@ -38,7 +38,6 @@ export function buildMergeCommandPlan(input) {
38
38
  return {
39
39
  mode: "auto",
40
40
  command: { argv: [...base, "--auto", ...commandArgs] },
41
- fallbackCommand: { argv: [...base, ...commandArgs] },
42
41
  };
43
42
  }
44
43
  export function renderMergeCommand(command) {
@@ -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[];