pr-shepherd 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.30.1",
4
+ "version": "0.31.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -0,0 +1,25 @@
1
+ import { blockquote } from "./list-formatters.mjs";
2
+ export function renderCheckAnnotation(a) {
3
+ const loc = `${a.path}:${renderAnnotationRange(a)}`;
4
+ const link = a.blobUrl ? ` [↗](${a.blobUrl})` : "";
5
+ const title = a.title ? ` — ${a.title}` : "";
6
+ const lines = [`- \`${a.id}\`${link} \`${loc}\` [${a.level}]${title}`];
7
+ if (a.message.trim() !== "")
8
+ lines.push(blockquote(a.message));
9
+ if (a.rawDetails !== undefined && a.rawDetails.trim() !== "")
10
+ lines.push(blockquote(a.rawDetails));
11
+ return lines.join("\n");
12
+ }
13
+ export function renderProtectedRun(run) {
14
+ const label = run.workflowName
15
+ ? `${run.workflowName} (${run.checkNames.join(", ")})`
16
+ : run.checkNames.join(", ");
17
+ return `- \`${run.runId}\` — \`${label}\` [matched: \`${run.matchedPattern}\`]`;
18
+ }
19
+ function renderAnnotationRange(a) {
20
+ if (a.startLine === null && a.endLine === null)
21
+ return "?";
22
+ const start = a.startLine ?? a.endLine;
23
+ const end = a.endLine ?? a.startLine;
24
+ return start === end ? String(start) : `${start}-${end}`;
25
+ }
@@ -3,6 +3,7 @@ import { joinSections } from "../util/markdown.mjs";
3
3
  import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
4
4
  import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, renderThreadConversation, blockquote, } from "./list-formatters.mjs";
5
5
  import { numberInstructions } from "./iterate-instructions.mjs";
6
+ import { renderCheckAnnotation, renderProtectedRun } from "./fix-formatter-extra.mjs";
6
7
  export function formatFixCodeResult(header, result) {
7
8
  const sections = [header];
8
9
  if (result.fix.threads.length > 0) {
@@ -116,6 +117,9 @@ export function formatFixCodeResult(header, result) {
116
117
  sections.push("## In-progress runs");
117
118
  sections.push(result.fix.inProgressRunIds.map((id) => `- \`${id}\``).join("\n"));
118
119
  }
120
+ if (result.fix.protectedRuns.length > 0) {
121
+ sections.push("## Protected runs", result.fix.protectedRuns.map(renderProtectedRun).join("\n"));
122
+ }
119
123
  if (result.cancelled.length > 0) {
120
124
  sections.push("## Cancelled runs");
121
125
  sections.push(result.cancelled.map((id) => `- \`${id}\``).join("\n"));
@@ -132,29 +136,9 @@ export function formatFixCodeResult(header, result) {
132
136
  sections.push(numberInstructions(result.fix.instructions));
133
137
  return joinSections(sections);
134
138
  }
135
- function renderCheckAnnotation(a) {
136
- const loc = `${a.path}:${renderAnnotationRange(a)}`;
137
- const link = a.blobUrl ? ` [↗](${a.blobUrl})` : "";
138
- const title = a.title ? ` — ${a.title}` : "";
139
- const lines = [`- \`${a.id}\`${link} \`${loc}\` [${a.level}]${title}`];
140
- if (a.message.trim() !== "")
141
- lines.push(blockquote(a.message));
142
- if (a.rawDetails !== undefined && a.rawDetails.trim() !== "")
143
- lines.push(blockquote(a.rawDetails));
144
- return lines.join("\n");
145
- }
146
139
  function indentBlockquote(body, indent) {
147
140
  return blockquote(body)
148
141
  .split("\n")
149
142
  .map((line) => `${indent}${line}`)
150
143
  .join("\n");
151
144
  }
152
- function renderAnnotationRange(a) {
153
- if (a.startLine === null && a.endLine === null)
154
- return "?";
155
- const start = a.startLine ?? a.endLine;
156
- const end = a.endLine ?? a.startLine;
157
- if (start === end)
158
- return String(start);
159
- return `${start}-${end}`;
160
- }
@@ -114,6 +114,9 @@ export function projectIterateLean(result, opts) {
114
114
  ...(result.fix.inProgressRunIds.length > 0 && {
115
115
  inProgressRunIds: result.fix.inProgressRunIds,
116
116
  }),
117
+ ...(result.fix.protectedRuns.length > 0 && {
118
+ protectedRuns: result.fix.protectedRuns,
119
+ }),
117
120
  ...(result.fix.checks.length > 0 && { checks: result.fix.checks }),
118
121
  ...(result.fix.changesRequestedReviews.length > 0 && {
119
122
  changesRequestedReviews: result.fix.changesRequestedReviews,
@@ -7,7 +7,7 @@ import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, bui
7
7
  import { buildResolveCommand } from "./classify.mjs";
8
8
  import { buildFixInstructions } from "./render.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
- import { tryCancelRun, buildAutoCancelRunIds, buildInProgressRunIds } from "./helpers.mjs";
10
+ import { tryCancelRun, buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./helpers.mjs";
11
11
  import { annotationMarkerBody } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
13
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
@@ -29,6 +29,7 @@ function nextFixAttempts(stored, headSha, threads) {
29
29
  export async function handleFixCode(ctx) {
30
30
  const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
31
31
  const failingChecks = report.checks.failing;
32
+ const { protectedRunIds, protectedRuns } = buildRunProtection([...failingChecks, ...report.checks.inProgress], opts.neverCancelRuns);
32
33
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
33
34
  const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
34
35
  const botCrReviews = report.changesRequestedReviews.filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames));
@@ -78,7 +79,7 @@ export async function handleFixCode(ctx) {
78
79
  await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
79
80
  let cancelled = [];
80
81
  if (!opts.noAutoCancelActionable) {
81
- const runIds = buildAutoCancelRunIds(report);
82
+ const runIds = buildAutoCancelRunIdsWithOptions(report, { protectedRunIds });
82
83
  const results = await Promise.all(runIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
83
84
  cancelled = results.filter((id) => id !== null);
84
85
  }
@@ -101,6 +102,7 @@ export async function handleFixCode(ctx) {
101
102
  const inProgressRunIds = pushLikely
102
103
  ? buildInProgressRunIds(report, cancelledSet, {
103
104
  suppressProtectedFreshReruns: false,
105
+ protectedRunIds,
104
106
  })
105
107
  : [];
106
108
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
@@ -156,6 +158,7 @@ export async function handleFixCode(ctx) {
156
158
  firstLookThreads,
157
159
  firstLookComments,
158
160
  inProgressRunIds,
161
+ protectedRuns,
159
162
  },
160
163
  cancelled,
161
164
  };
@@ -2,7 +2,7 @@ import { execFile as execFileCb } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { rest } from "../../github/http.mjs";
4
4
  const execFile = promisify(execFileCb);
5
- export { buildAutoCancelRunIds, buildInProgressRunIds } from "./reruns.mjs";
5
+ export { buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./reruns.mjs";
6
6
  export function buildSummary(report) {
7
7
  return {
8
8
  passing: report.checks.passing.length,
@@ -24,6 +24,7 @@ export function buildRelevantChecks(report) {
24
24
  conclusion,
25
25
  runId: c.runId,
26
26
  detailsUrl: c.detailsUrl || null,
27
+ ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
27
28
  summary: c.summary,
28
29
  },
29
30
  ];
@@ -55,6 +56,7 @@ export function buildActiveChecks(report) {
55
56
  status: c.status,
56
57
  runId: c.runId,
57
58
  detailsUrl: c.detailsUrl || null,
59
+ ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
58
60
  ...(c.summary !== undefined && { summary: c.summary }),
59
61
  }));
60
62
  }
@@ -16,12 +16,12 @@ export async function runIterate(opts) {
16
16
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
17
17
  const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
18
18
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
19
- if (prNumber === null) {
19
+ if (prNumber === null)
20
20
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
21
- }
22
- const optsWithPr = { ...opts, prNumber };
21
+ const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
23
22
  const report = await runCheck({
24
- ...optsWithPr,
23
+ ...opts,
24
+ prNumber,
25
25
  autoResolve: config.actions.autoResolveOutdated,
26
26
  autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
27
27
  });
@@ -121,7 +121,7 @@ export async function runIterate(opts) {
121
121
  return handleFixCode({
122
122
  base,
123
123
  report,
124
- opts,
124
+ opts: { ...opts, prNumber, neverCancelRuns },
125
125
  headSha,
126
126
  stallKey,
127
127
  prNumber,
@@ -1,3 +1,4 @@
1
+ import picomatch from "picomatch";
1
2
  function matchesRerunCheck(failure, check) {
2
3
  if (failure.runId !== null && check.runId !== null) {
3
4
  return failure.runId === check.runId && failure.name === check.name;
@@ -26,12 +27,12 @@ function protectedFreshRerunIds(report) {
26
27
  .map((check) => check.runId)
27
28
  .filter((id) => id !== null));
28
29
  }
29
- export function buildAutoCancelRunIds(report) {
30
+ export function buildAutoCancelRunIdsWithOptions(report, opts = {}) {
30
31
  return [
31
32
  ...new Set(report.checks.failing
32
33
  .filter((check) => !hasProtectedFreshRerun(check, report.checks.inProgress))
33
34
  .map((check) => check.runId)
34
- .filter((id) => id !== null)),
35
+ .filter((id) => id !== null && !(opts.protectedRunIds?.has(id) ?? false))),
35
36
  ];
36
37
  }
37
38
  export function buildInProgressRunIds(report, cancelledSet, opts = {}) {
@@ -39,6 +40,66 @@ export function buildInProgressRunIds(report, cancelledSet, opts = {}) {
39
40
  return [
40
41
  ...new Set(report.checks.inProgress
41
42
  .map((check) => check.runId)
42
- .filter((id) => id !== null && !cancelledSet.has(id) && !protectedRunIds.has(id))),
43
+ .filter((id) => id !== null &&
44
+ !cancelledSet.has(id) &&
45
+ !protectedRunIds.has(id) &&
46
+ !(opts.protectedRunIds?.has(id) ?? false))),
43
47
  ];
44
48
  }
49
+ export function buildRunProtection(checks, patterns = []) {
50
+ if (patterns.length === 0)
51
+ return { protectedRunIds: new Set(), protectedRuns: [] };
52
+ const matchers = patterns.map((pattern) => ({
53
+ pattern,
54
+ isMatch: picomatch(pattern, { nocase: true }),
55
+ }));
56
+ const byRunId = new Map();
57
+ for (const check of checks) {
58
+ if (check.runId === null)
59
+ continue;
60
+ const match = findProtectionMatch(check, matchers);
61
+ if (match === null)
62
+ continue;
63
+ addProtectedRun(byRunId, check.runId, check, match);
64
+ }
65
+ const protectedRuns = [...byRunId.values()];
66
+ return {
67
+ protectedRunIds: new Set(protectedRuns.map((run) => run.runId)),
68
+ protectedRuns,
69
+ };
70
+ }
71
+ function addProtectedRun(byRunId, runId, check, matchedPattern) {
72
+ const existing = byRunId.get(runId);
73
+ if (existing) {
74
+ updateProtectedRun(existing, check);
75
+ return;
76
+ }
77
+ byRunId.set(runId, createProtectedRun(runId, check, matchedPattern));
78
+ }
79
+ function updateProtectedRun(run, check) {
80
+ if (!run.checkNames.includes(check.name))
81
+ run.checkNames.push(check.name);
82
+ if (run.workflowName === undefined && check.workflowName !== undefined) {
83
+ run.workflowName = check.workflowName;
84
+ }
85
+ }
86
+ function createProtectedRun(runId, check, matchedPattern) {
87
+ return {
88
+ runId,
89
+ matchedPattern,
90
+ checkNames: [check.name],
91
+ ...(check.workflowName !== undefined && { workflowName: check.workflowName }),
92
+ };
93
+ }
94
+ function findProtectionMatch(check, matchers) {
95
+ const candidates = [
96
+ check.workflowName,
97
+ "jobName" in check ? check.jobName : undefined,
98
+ check.name,
99
+ ].filter((value) => value !== undefined && value.trim() !== "");
100
+ for (const matcher of matchers) {
101
+ if (candidates.some((candidate) => matcher.isMatch(candidate)))
102
+ return matcher.pattern;
103
+ }
104
+ return null;
105
+ }
@@ -57,6 +57,12 @@ function parseIgnoreChecks(value) {
57
57
  }
58
58
  return value;
59
59
  }
60
+ function parseNeverCancelRuns(value) {
61
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
62
+ throw new Error(`Invalid config: actions.neverCancelRuns must be an array of strings`);
63
+ }
64
+ return value;
65
+ }
60
66
  const defaults = builtins;
61
67
  const configCache = new Map();
62
68
  export function loadConfig() {
@@ -74,6 +80,7 @@ export function loadConfig() {
74
80
  const config = deepMerge(defaults, parsed);
75
81
  config.botUsernames = parseBotUsernames(config.botUsernames);
76
82
  config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
83
+ config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
77
84
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
78
85
  configCache.set(cwd, config);
79
86
  return config;
package/bin/config.json CHANGED
@@ -39,6 +39,7 @@
39
39
  "autoResolveOutdated": true,
40
40
  "autoMinimizeSuppressed": true,
41
41
  "autoMarkReady": true,
42
- "commitSuggestions": true
42
+ "commitSuggestions": true,
43
+ "neverCancelRuns": []
43
44
  }
44
45
  }
@@ -82,6 +82,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
82
82
  const checks = rawCheckNodes.flatMap((node) => {
83
83
  if (node.__typename === "CheckRun") {
84
84
  const event = node.checkSuite?.workflowRun?.event ?? null;
85
+ const workflowName = node.checkSuite?.workflowRun?.workflow?.name?.trim() || undefined;
85
86
  const runId = extractRunId(node.detailsUrl);
86
87
  const summary = extractCheckRunSummary(node.title, node.summary);
87
88
  const rawCreatedAt = node.checkSuite?.workflowRun?.createdAt ?? node.checkSuite?.createdAt;
@@ -100,6 +101,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
100
101
  detailsUrl: node.detailsUrl ?? "",
101
102
  event,
102
103
  runId,
104
+ ...(workflowName !== undefined && { workflowName }),
103
105
  ...(createdAtUnix !== undefined && { createdAtUnix }),
104
106
  ...(startedAtUnix !== undefined && { startedAtUnix }),
105
107
  ...(completedAtUnix !== undefined && { completedAtUnix }),
@@ -203,6 +203,9 @@ query BatchPr(
203
203
  event
204
204
  createdAt
205
205
  updatedAt
206
+ workflow {
207
+ name
208
+ }
206
209
  }
207
210
  }
208
211
  }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -38,7 +38,7 @@
38
38
  "@vitest/coverage-v8": "^4.1.4",
39
39
  "husky": "^9.1.7",
40
40
  "knip": "^6.14.1",
41
- "oxfmt": "^0.52.0",
41
+ "oxfmt": "^0.53.0",
42
42
  "oxlint": "^1.60.0",
43
43
  "typescript": "^6.0.3",
44
44
  "vitest": "^4.1.4"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",