pr-shepherd 0.56.2 → 0.56.3

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.56.2",
4
+ "version": "0.56.3",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -7,11 +7,10 @@
7
7
  * to PR readiness.
8
8
  * 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
9
9
  * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
- * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer run of
11
- * the same workflow exists on the same commit — this is GitHub's concurrency-group
12
- * eviction behavior, not a real failure. GitHub branch protection itself resolves
13
- * required status checks by latest-run-per-name and merges past these; mirroring
14
- * that here keeps shepherd's verdict aligned with what GitHub will actually allow.
10
+ * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
11
+ * run of the same workflow exists on the same commit and event, or when an
12
+ * exact matching check from a lower-ID run started and succeeded later.
13
+ * GitHub can start jobs out of workflow-run creation order.
15
14
  */
16
15
  import type { CheckRun, ClassifiedCheck } from "../types.mts";
17
16
  /**
@@ -36,7 +35,7 @@ export interface CiVerdict {
36
35
  filteredNames: string[];
37
36
  /** Names of checks suppressed by the user's ignoreChecks config. */
38
37
  ignoredNames: string[];
39
- /** Names of CANCELLED checks superseded by a newer run of the same workflow (concurrency-group eviction). */
38
+ /** Names of CANCELLED checks covered by another run of the same workflow and event. */
40
39
  supersededNames: string[];
41
40
  }
42
41
  /** Compute a high-level CI verdict from a list of classified checks. */
@@ -7,11 +7,10 @@
7
7
  * to PR readiness.
8
8
  * 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
9
9
  * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
- * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer run of
11
- * the same workflow exists on the same commit — this is GitHub's concurrency-group
12
- * eviction behavior, not a real failure. GitHub branch protection itself resolves
13
- * required status checks by latest-run-per-name and merges past these; mirroring
14
- * that here keeps shepherd's verdict aligned with what GitHub will actually allow.
10
+ * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
11
+ * run of the same workflow exists on the same commit and event, or when an
12
+ * exact matching check from a lower-ID run started and succeeded later.
13
+ * GitHub can start jobs out of workflow-run creation order.
15
14
  */
16
15
  import { loadConfig } from "../config/load.mjs";
17
16
  import { buildSupersededIndices } from "./superseded.mjs";
@@ -37,8 +36,7 @@ export function classifyChecks(checks, opts = {}) {
37
36
  return { ...c, category: "ignored" };
38
37
  }
39
38
  const classified = classify(c, relevantEvents);
40
- // Only ever override a "failing" verdict (i.e. conclusion === CANCELLED, guaranteed by
41
- // buildSupersededIndices below) — never touch filtered/skipped/passed classifications.
39
+ // Only override a failing CANCELLED check, never filtered/skipped/passed classifications.
42
40
  if (classified.category === "failing" && supersededIndices.has(index)) {
43
41
  return { ...classified, category: "superseded" };
44
42
  }
@@ -1,21 +1,8 @@
1
- /**
2
- * Detects check runs that are `CANCELLED` because a newer run of the *same workflow*
3
- * superseded them on the same commit (concurrency-group eviction), rather than a genuine
4
- * cancellation. Split out of classify.mts to stay under the file-length cap.
5
- */
1
+ /** Identify cancelled checks covered by another run on the same commit and event. */
6
2
  import type { CheckRun } from "../types.mts";
7
3
  /**
8
- * Grouping key is `workflowId ?? workflowName` — the numeric GitHub Actions workflow database
9
- * ID when available, falling back to the display name. Checks with neither a workflow identity
10
- * nor a numeric `runId` (status contexts, startup-failure synthetics) never participate: they
11
- * can neither be marked superseded nor count as evidence of a newer run.
12
- *
13
- * A check is superseded iff its own conclusion is `CANCELLED` and some other check sharing its
14
- * workflow key has a strictly greater `runId`. The newest run for a workflow is therefore never
15
- * superseded, even if it is itself cancelled — that case stays "failing" so the agent can decide
16
- * whether to rerun it.
17
- *
18
- * @returns Indices into `checks` (not object identities, since check-run objects are not
19
- * deduplicated by reference elsewhere) that should be reclassified as "superseded".
4
+ * A later-created run supersedes an older cancellation as before. GitHub can start
5
+ * check jobs out of run-ID order, so a lower-ID run can also cover a cancellation
6
+ * when its matching successful job actually started and completed later.
20
7
  */
21
8
  export declare function buildSupersededIndices(checks: CheckRun[]): Set<number>;
@@ -1,59 +1,83 @@
1
- /**
2
- * Detects check runs that are `CANCELLED` because a newer run of the *same workflow*
3
- * superseded them on the same commit (concurrency-group eviction), rather than a genuine
4
- * cancellation. Split out of classify.mts to stay under the file-length cap.
5
- */
6
- /** Grouping key for a check's workflow: numeric `workflowId`, falling back to `workflowName`. */
1
+ /** Identify cancelled checks covered by another run on the same commit and event. */
2
+ /** Prefer the stable workflow ID; retain the name fallback for the older-run rule. */
7
3
  function workflowKeyOf(check) {
8
- return check.workflowId ?? check.workflowName;
4
+ if (check.workflowId)
5
+ return `id:${check.workflowId}`;
6
+ return check.workflowName ? `name:${check.workflowName}` : undefined;
7
+ }
8
+ function groupKeyOf(check) {
9
+ const workflow = workflowKeyOf(check);
10
+ if (workflow === undefined)
11
+ return undefined;
12
+ return JSON.stringify([workflow, check.event, check.scope ?? null, check.commitOid ?? null]);
13
+ }
14
+ function numericRunId(check) {
15
+ if (check.runId === null || !/^[1-9]\d*$/.test(check.runId))
16
+ return undefined;
17
+ const id = Number(check.runId);
18
+ return Number.isSafeInteger(id) ? id : undefined;
19
+ }
20
+ function validTimes(check) {
21
+ const { startedAtUnix: start, completedAtUnix: completion } = check;
22
+ return (typeof start === "number" &&
23
+ Number.isFinite(start) &&
24
+ start > 0 &&
25
+ typeof completion === "number" &&
26
+ Number.isFinite(completion) &&
27
+ completion > 0 &&
28
+ completion >= start);
9
29
  }
10
30
  /**
11
- * Grouping key is `workflowId ?? workflowName` — the numeric GitHub Actions workflow database
12
- * ID when available, falling back to the display name. Checks with neither a workflow identity
13
- * nor a numeric `runId` (status contexts, startup-failure synthetics) never participate: they
14
- * can neither be marked superseded nor count as evidence of a newer run.
15
- *
16
- * A check is superseded iff its own conclusion is `CANCELLED` and some other check sharing its
17
- * workflow key has a strictly greater `runId`. The newest run for a workflow is therefore never
18
- * superseded, even if it is itself cancelled — that case stays "failing" so the agent can decide
19
- * whether to rerun it.
20
- *
21
- * @returns Indices into `checks` (not object identities, since check-run objects are not
22
- * deduplicated by reference elsewhere) that should be reclassified as "superseded".
31
+ * A later-created run supersedes an older cancellation as before. GitHub can start
32
+ * check jobs out of run-ID order, so a lower-ID run can also cover a cancellation
33
+ * when its matching successful job actually started and completed later.
23
34
  */
24
35
  export function buildSupersededIndices(checks) {
25
- const runIdByIndex = new Map();
26
- const maxRunIdByWorkflow = new Map();
36
+ const maxRunIdByGroup = new Map();
37
+ const runIds = checks.map(numericRunId);
27
38
  checks.forEach((check, index) => {
28
- const workflowKey = workflowKeyOf(check);
29
- if (workflowKey === undefined || check.runId === null)
30
- return;
31
- const runIdNum = Number(check.runId);
32
- if (!Number.isFinite(runIdNum))
39
+ const group = groupKeyOf(check);
40
+ const runId = runIds[index];
41
+ if (group === undefined || runId === undefined)
33
42
  return;
34
- runIdByIndex.set(index, runIdNum);
35
- const currentMax = maxRunIdByWorkflow.get(workflowKey);
36
- if (currentMax === undefined || runIdNum > currentMax) {
37
- maxRunIdByWorkflow.set(workflowKey, runIdNum);
38
- }
43
+ const previous = maxRunIdByGroup.get(group);
44
+ if (previous === undefined || runId > previous)
45
+ maxRunIdByGroup.set(group, runId);
39
46
  });
40
47
  const superseded = new Set();
41
- checks.forEach((check, index) => {
42
- if (check.conclusion !== "CANCELLED")
48
+ checks.forEach((cancelled, index) => {
49
+ if (cancelled.conclusion !== "CANCELLED")
43
50
  return;
44
- const runIdNum = runIdByIndex.get(index);
45
- if (runIdNum === undefined)
51
+ const group = groupKeyOf(cancelled);
52
+ const runId = runIds[index];
53
+ if (group === undefined || runId === undefined)
46
54
  return;
47
- // workflowKeyOf(check) is guaranteed defined here, with a corresponding entry in
48
- // maxRunIdByWorkflow: runIdByIndex is only ever populated in the loop above alongside a
49
- // maxRunIdByWorkflow entry for that same workflow key (at minimum, this check's own
50
- // runIdNum) — the two maps are always updated together for a given index. A defensive
51
- // undefined-check here would therefore guard a branch no input can ever exercise, which
52
- // would silently fail this repo's 100%-coverage requirement instead of catching a real bug.
53
- const maxRunId = maxRunIdByWorkflow.get(workflowKeyOf(check));
54
- if (maxRunId > runIdNum) {
55
+ if (maxRunIdByGroup.get(group) > runId) {
55
56
  superseded.add(index);
57
+ return;
56
58
  }
59
+ if (cancelled.status !== "COMPLETED" ||
60
+ !cancelled.workflowId ||
61
+ cancelled.event === null ||
62
+ !validTimes(cancelled))
63
+ return;
64
+ const covered = checks.some((success, candidateIndex) => {
65
+ const candidateRunId = runIds[candidateIndex];
66
+ return (candidateRunId !== undefined &&
67
+ candidateRunId < runId &&
68
+ success.status === "COMPLETED" &&
69
+ success.conclusion === "SUCCESS" &&
70
+ success.workflowId === cancelled.workflowId &&
71
+ success.event === cancelled.event &&
72
+ success.scope === cancelled.scope &&
73
+ success.commitOid === cancelled.commitOid &&
74
+ success.name === cancelled.name &&
75
+ validTimes(success) &&
76
+ success.startedAtUnix > cancelled.startedAtUnix &&
77
+ success.completedAtUnix > cancelled.completedAtUnix);
78
+ });
79
+ if (covered)
80
+ superseded.add(index);
57
81
  });
58
82
  return superseded;
59
83
  }
@@ -12,6 +12,8 @@ fragment PollSummaryCheckContexts on StatusCheckRollupContextConnection {
12
12
  status
13
13
  conclusion
14
14
  detailsUrl
15
+ startedAt
16
+ completedAt
15
17
  checkSuite {
16
18
  createdAt
17
19
  workflowRun {
@@ -1,10 +1,9 @@
1
1
  import { classifyChecks } from "../checks/classify.mjs";
2
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
2
3
  export function summarizePollSummaryChecks(raw) {
3
4
  const rollups = summaryRollups(raw);
4
5
  const counts = {};
5
- for (const check of classifyChecks(checkRuns(rollups), {
6
- additionalRelevantEvents: ["merge_group"],
7
- })) {
6
+ for (const check of classifiedSummaryChecks(raw)) {
8
7
  const key = {
9
8
  passed: "passing",
10
9
  failing: "failing",
@@ -17,33 +16,36 @@ export function summarizePollSummaryChecks(raw) {
17
16
  counts[key] = (counts[key] ?? 0) + 1;
18
17
  }
19
18
  const summary = counts;
20
- if (rollups.some((rollup) => rollup.contexts.pageInfo.hasPreviousPage)) {
19
+ if (rollups.some(({ rollup }) => rollup.contexts.pageInfo.hasPreviousPage)) {
21
20
  summary.incomplete = true;
22
21
  }
23
22
  return summary;
24
23
  }
25
24
  /** Names of failing checks in the loaded summary rollup. */
26
25
  export function failingSummaryCheckNames(raw) {
27
- return classifyChecks(checkRuns(summaryRollups(raw)), {
28
- additionalRelevantEvents: ["merge_group"],
29
- })
26
+ return classifiedSummaryChecks(raw)
30
27
  .filter((check) => check.category === "failing")
31
28
  .map((check) => check.name);
32
29
  }
33
30
  function summaryRollups(raw) {
34
31
  return [
35
- raw.commits.nodes[0]?.commit.statusCheckRollup,
36
- raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
37
- ].filter((rollup) => rollup !== null && rollup !== undefined);
32
+ { rollup: raw.commits.nodes[0]?.commit.statusCheckRollup, scope: undefined },
33
+ {
34
+ rollup: raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
35
+ scope: "merge_group",
36
+ },
37
+ ].flatMap(({ rollup, scope }) => (rollup ? [{ rollup, scope }] : []));
38
38
  }
39
- function checkRuns(rollups) {
40
- return rollups.flatMap((rollup, rollupIndex) => rollup.contexts.nodes.map((context) => {
39
+ function classifiedSummaryChecks(raw) {
40
+ // A successful check on the queue commit cannot cover a PR-head cancellation, or vice versa.
41
+ return summaryRollups(raw).flatMap(({ rollup, scope }) => classifyChecks(checkRuns(rollup, scope), scope === "merge_group" ? { additionalRelevantEvents: ["merge_group"] } : {}));
42
+ }
43
+ function checkRuns(rollup, scope) {
44
+ return rollup.contexts.nodes.map((context) => {
41
45
  if (context.__typename === "StatusContext") {
42
46
  return {
43
47
  name: context.context,
44
- status: context.state === "PENDING" || context.state === "EXPECTED"
45
- ? "IN_PROGRESS"
46
- : "COMPLETED",
48
+ status: context.state === "PENDING" || context.state === "EXPECTED" ? "IN_PROGRESS" : "COMPLETED",
47
49
  conclusion: context.state === "SUCCESS"
48
50
  ? "SUCCESS"
49
51
  : context.state === "FAILURE" || context.state === "ERROR"
@@ -53,7 +55,7 @@ function checkRuns(rollups) {
53
55
  detailsUrl: "",
54
56
  event: null,
55
57
  runId: null,
56
- ...(rollupIndex === 1 && { scope: "merge_group" }),
58
+ ...(scope && { scope }),
57
59
  };
58
60
  }
59
61
  const run = context.checkSuite?.workflowRun;
@@ -70,7 +72,9 @@ function checkRuns(rollups) {
70
72
  ...(run?.workflow?.databaseId != null && {
71
73
  workflowId: String(run.workflow.databaseId),
72
74
  }),
73
- ...(rollupIndex === 1 && { scope: "merge_group" }),
75
+ ...(context.startedAt && { startedAtUnix: parseCreatedAt(context.startedAt) }),
76
+ ...(context.completedAt && { completedAtUnix: parseCreatedAt(context.completedAt) }),
77
+ ...(scope && { scope }),
74
78
  };
75
- }));
79
+ });
76
80
  }
@@ -26,6 +26,8 @@ type RawCheckContext = {
26
26
  status: string;
27
27
  conclusion: string | null;
28
28
  detailsUrl?: string;
29
+ startedAt?: string | null;
30
+ completedAt?: string | null;
29
31
  annotations?: {
30
32
  totalCount: number;
31
33
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.2",
3
+ "version": "0.56.3",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -89,7 +89,7 @@
89
89
  "husky": "^9.1.7",
90
90
  "knip": "^6.14.1",
91
91
  "marked": "^18.0.11",
92
- "oxfmt": "^0.68.0",
92
+ "oxfmt": "^0.70.0",
93
93
  "oxlint": "^1.60.0",
94
94
  "typescript": "^7.0.2",
95
95
  "vitest": "^5.0.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.2",
3
+ "version": "0.56.3",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.56.2", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.56.3", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.56.2", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.56.3", "pr-shepherd-mcp"]
5
5
  }
6
6
  }