pr-shepherd 0.30.0 → 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.0",
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, 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,10 +79,8 @@ 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 uniqueRunIds = [
82
- ...new Set(failingChecks.map((c) => c.runId).filter((id) => id !== null)),
83
- ];
84
- const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
82
+ const runIds = buildAutoCancelRunIdsWithOptions(report, { protectedRunIds });
83
+ const results = await Promise.all(runIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
85
84
  cancelled = results.filter((id) => id !== null);
86
85
  }
87
86
  const cancelledSet = new Set(cancelled);
@@ -100,7 +99,12 @@ export async function handleFixCode(ctx) {
100
99
  hasConflicts ||
101
100
  changesRequestedReviews.length > 0 ||
102
101
  actionableComments.length > 0;
103
- const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
102
+ const inProgressRunIds = pushLikely
103
+ ? buildInProgressRunIds(report, cancelledSet, {
104
+ suppressProtectedFreshReruns: false,
105
+ protectedRunIds,
106
+ })
107
+ : [];
104
108
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
105
109
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
106
110
  const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, botUsernames, ruleAutoResolveThreadIds);
@@ -154,6 +158,7 @@ export async function handleFixCode(ctx) {
154
158
  firstLookThreads,
155
159
  firstLookComments,
156
160
  inProgressRunIds,
161
+ protectedRuns,
157
162
  },
158
163
  cancelled,
159
164
  };
@@ -2,13 +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 function buildInProgressRunIds(report, cancelledSet) {
6
- return [
7
- ...new Set(report.checks.inProgress
8
- .map((c) => c.runId)
9
- .filter((id) => id !== null && !cancelledSet.has(id))),
10
- ];
11
- }
5
+ export { buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./reruns.mjs";
12
6
  export function buildSummary(report) {
13
7
  return {
14
8
  passing: report.checks.passing.length,
@@ -17,12 +11,7 @@ export function buildSummary(report) {
17
11
  inProgress: report.checks.inProgress.length,
18
12
  };
19
13
  }
20
- /**
21
- * Build the full list of CI checks relevant to PR readiness: triggered by a PR
22
- * event (or StatusContext with null event), completed, and not skipped/neutral.
23
- * Includes both passing and failing. Failing entries carry workflowName, jobName,
24
- * failedStep, and summary.
25
- */
14
+ /** Build completed, non-skipped checks relevant to PR readiness. */
26
15
  export function buildRelevantChecks(report) {
27
16
  const excluded = new Set([null, "SKIPPED", "NEUTRAL"]);
28
17
  const passing = report.checks.passing.flatMap((c) => {
@@ -35,6 +24,7 @@ export function buildRelevantChecks(report) {
35
24
  conclusion,
36
25
  runId: c.runId,
37
26
  detailsUrl: c.detailsUrl || null,
27
+ ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
38
28
  summary: c.summary,
39
29
  },
40
30
  ];
@@ -66,6 +56,7 @@ export function buildActiveChecks(report) {
66
56
  status: c.status,
67
57
  runId: c.runId,
68
58
  detailsUrl: c.detailsUrl || null,
59
+ ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
69
60
  ...(c.summary !== undefined && { summary: c.summary }),
70
61
  }));
71
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,
@@ -0,0 +1,105 @@
1
+ import picomatch from "picomatch";
2
+ function matchesRerunCheck(failure, check) {
3
+ if (failure.runId !== null && check.runId !== null) {
4
+ return failure.runId === check.runId && failure.name === check.name;
5
+ }
6
+ return failure.runId === null && check.runId === null && failure.name === check.name;
7
+ }
8
+ function isProtectedByFreshRerun(failure, check) {
9
+ const attemptStartedAt = check.startedAtUnix ?? check.updatedAtUnix ?? check.createdAtUnix;
10
+ return (attemptStartedAt !== undefined &&
11
+ matchesRerunCheck(failure, check) &&
12
+ failure.completedAtUnix !== undefined &&
13
+ attemptStartedAt >= failure.completedAtUnix &&
14
+ (failure.startedAtUnix === undefined || failure.startedAtUnix < attemptStartedAt));
15
+ }
16
+ function hasProtectedFreshRerun(failure, checks) {
17
+ return checks.some((check) => isProtectedByFreshRerun(failure, check));
18
+ }
19
+ function isProtectedFreshRerun(check, failures) {
20
+ const matchingFailures = failures.filter((failure) => matchesRerunCheck(failure, check));
21
+ return (matchingFailures.length > 0 &&
22
+ matchingFailures.every((failure) => isProtectedByFreshRerun(failure, check)));
23
+ }
24
+ function protectedFreshRerunIds(report) {
25
+ return new Set(report.checks.inProgress
26
+ .filter((check) => isProtectedFreshRerun(check, report.checks.failing))
27
+ .map((check) => check.runId)
28
+ .filter((id) => id !== null));
29
+ }
30
+ export function buildAutoCancelRunIdsWithOptions(report, opts = {}) {
31
+ return [
32
+ ...new Set(report.checks.failing
33
+ .filter((check) => !hasProtectedFreshRerun(check, report.checks.inProgress))
34
+ .map((check) => check.runId)
35
+ .filter((id) => id !== null && !(opts.protectedRunIds?.has(id) ?? false))),
36
+ ];
37
+ }
38
+ export function buildInProgressRunIds(report, cancelledSet, opts = {}) {
39
+ const protectedRunIds = opts.suppressProtectedFreshReruns === false ? new Set() : protectedFreshRerunIds(report);
40
+ return [
41
+ ...new Set(report.checks.inProgress
42
+ .map((check) => check.runId)
43
+ .filter((id) => id !== null &&
44
+ !cancelledSet.has(id) &&
45
+ !protectedRunIds.has(id) &&
46
+ !(opts.protectedRunIds?.has(id) ?? false))),
47
+ ];
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,16 +82,14 @@ 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
- const rawCreatedAt = node.checkSuite
88
- ? (node.checkSuite.workflowRun?.createdAt ?? node.checkSuite.createdAt)
89
- : undefined;
90
- const rawUpdatedAt = node.checkSuite
91
- ? (node.checkSuite.workflowRun?.updatedAt ?? node.checkSuite.updatedAt)
92
- : undefined;
88
+ const rawCreatedAt = node.checkSuite?.workflowRun?.createdAt ?? node.checkSuite?.createdAt;
89
+ const rawUpdatedAt = node.checkSuite?.workflowRun?.updatedAt ?? node.checkSuite?.updatedAt;
93
90
  const createdAtUnix = rawCreatedAt ? parseCreatedAt(rawCreatedAt) : undefined;
94
91
  const startedAtUnix = node.startedAt ? parseCreatedAt(node.startedAt) : undefined;
92
+ const completedAtUnix = node.completedAt ? parseCreatedAt(node.completedAt) : undefined;
95
93
  const updatedAtUnix = rawUpdatedAt ? parseCreatedAt(rawUpdatedAt) : undefined;
96
94
  return [
97
95
  {
@@ -103,8 +101,10 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
103
101
  detailsUrl: node.detailsUrl ?? "",
104
102
  event,
105
103
  runId,
104
+ ...(workflowName !== undefined && { workflowName }),
106
105
  ...(createdAtUnix !== undefined && { createdAtUnix }),
107
106
  ...(startedAtUnix !== undefined && { startedAtUnix }),
107
+ ...(completedAtUnix !== undefined && { completedAtUnix }),
108
108
  ...(updatedAtUnix !== undefined && { updatedAtUnix }),
109
109
  ...(summary !== undefined && { summary }),
110
110
  },
@@ -192,6 +192,7 @@ query BatchPr(
192
192
  status
193
193
  conclusion
194
194
  detailsUrl
195
+ completedAt
195
196
  startedAt
196
197
  title
197
198
  summary
@@ -202,6 +203,9 @@ query BatchPr(
202
203
  event
203
204
  createdAt
204
205
  updatedAt
206
+ workflow {
207
+ name
208
+ }
205
209
  }
206
210
  }
207
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.0",
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.0",
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",