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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/checks/classify.d.mts +5 -6
- package/bin/checks/classify.mjs +5 -7
- package/bin/checks/superseded.d.mts +4 -17
- package/bin/checks/superseded.mjs +67 -43
- package/bin/github/gql/poll-summary-check-contexts.gql +2 -0
- package/bin/github/poll-summary-checks.mjs +22 -18
- package/bin/github/poll-summary-raw.d.mts +2 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
|
@@ -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
|
|
11
|
-
* the same workflow exists on the same commit
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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. */
|
package/bin/checks/classify.mjs
CHANGED
|
@@ -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
|
|
11
|
-
* the same workflow exists on the same commit
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
26
|
-
const
|
|
36
|
+
const maxRunIdByGroup = new Map();
|
|
37
|
+
const runIds = checks.map(numericRunId);
|
|
27
38
|
checks.forEach((check, index) => {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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((
|
|
42
|
-
if (
|
|
48
|
+
checks.forEach((cancelled, index) => {
|
|
49
|
+
if (cancelled.conclusion !== "CANCELLED")
|
|
43
50
|
return;
|
|
44
|
-
const
|
|
45
|
-
|
|
51
|
+
const group = groupKeyOf(cancelled);
|
|
52
|
+
const runId = runIds[index];
|
|
53
|
+
if (group === undefined || runId === undefined)
|
|
46
54
|
return;
|
|
47
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
75
|
+
...(context.startedAt && { startedAtUnix: parseCreatedAt(context.startedAt) }),
|
|
76
|
+
...(context.completedAt && { completedAtUnix: parseCreatedAt(context.completedAt) }),
|
|
77
|
+
...(scope && { scope }),
|
|
74
78
|
};
|
|
75
|
-
})
|
|
79
|
+
});
|
|
76
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.56.
|
|
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.
|
|
92
|
+
"oxfmt": "^0.70.0",
|
|
93
93
|
"oxlint": "^1.60.0",
|
|
94
94
|
"typescript": "^7.0.2",
|
|
95
95
|
"vitest": "^5.0.0"
|