pr-shepherd 1.0.0 → 1.1.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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli/poll-summary-formatter.mjs +3 -0
- package/bin/cli/rule-auto-resolve-lines.d.mts +3 -0
- package/bin/cli/rule-auto-resolve-lines.mjs +12 -0
- package/bin/cli/stack-layer-view.d.mts +2 -1
- package/bin/cli/stack-overview.mjs +3 -0
- package/bin/commands/check-activity.d.mts +8 -0
- package/bin/commands/check-activity.mjs +26 -0
- package/bin/commands/check-annotations.mjs +2 -1
- package/bin/commands/check-wait-detectors.mjs +11 -0
- package/bin/commands/check.mjs +6 -1
- package/bin/commands/poll-summary-signature.d.mts +6 -0
- package/bin/commands/poll-summary-signature.mjs +19 -2
- package/bin/commands/poll-summary.mjs +7 -0
- package/bin/commands/ready-delay.d.mts +1 -0
- package/bin/commands/ready-delay.mjs +8 -3
- package/bin/commands/rule-auto-resolve-format.d.mts +7 -0
- package/bin/commands/rule-auto-resolve-format.mjs +11 -4
- package/bin/commands/rule-auto-resolve.d.mts +14 -2
- package/bin/comments/thread-visibility.d.mts +6 -0
- package/bin/comments/thread-visibility.mjs +30 -15
- package/bin/github/batch-parse-suites.d.mts +14 -0
- package/bin/github/batch-parse-suites.mjs +4 -1
- package/bin/github/batch-parser-helpers.mjs +3 -0
- package/bin/github/check-annotation-cache.d.mts +5 -0
- package/bin/github/check-annotation-cache.mjs +6 -3
- package/bin/github/fingerprint.mjs +4 -0
- package/bin/github/poll-summary-annotation-probe.d.mts +5 -0
- package/bin/github/poll-summary-annotation-probe.mjs +17 -3
- package/bin/github/poll-summary-annotations.d.mts +14 -0
- package/bin/github/poll-summary-annotations.mjs +75 -0
- package/bin/github/poll-summary-blocking-reviewer.d.mts +2 -0
- package/bin/github/poll-summary-blocking-reviewer.mjs +9 -0
- package/bin/github/poll-summary-checks.d.mts +1 -0
- package/bin/github/poll-summary-checks.mjs +20 -2
- package/bin/github/poll-summary-fingerprint.d.mts +7 -0
- package/bin/github/poll-summary-fingerprint.mjs +7 -0
- package/bin/github/poll-summary-projector.d.mts +3 -1
- package/bin/github/poll-summary-projector.mjs +13 -24
- package/bin/github/poll-summary-queue-removal.d.mts +2 -0
- package/bin/github/poll-summary-queue-removal.mjs +11 -0
- package/bin/github/poll-summary-readiness.mjs +1 -0
- package/bin/github/poll-summary-review-parity.d.mts +24 -0
- package/bin/github/poll-summary-review-parity.mjs +82 -0
- package/bin/github/poll-summary-review.d.mts +11 -2
- package/bin/github/poll-summary-review.mjs +87 -59
- package/bin/github/poll-summary-row-error.d.mts +16 -0
- package/bin/github/poll-summary-row-error.mjs +74 -0
- package/bin/github/poll-summary-rule-auto-resolve.d.mts +32 -0
- package/bin/github/poll-summary-rule-auto-resolve.mjs +119 -0
- package/bin/github/poll-summary-rule-handoff.d.mts +7 -0
- package/bin/github/poll-summary-rule-handoff.mjs +20 -0
- package/bin/github/poll-summary-rule-items.d.mts +20 -0
- package/bin/github/poll-summary-rule-items.mjs +28 -0
- package/bin/github/poll-summary.mjs +15 -7
- package/bin/github/rest-reader-core.mjs +13 -2
- package/bin/github/rest-summary-read.mjs +12 -1
- package/bin/types/github.d.mts +2 -0
- package/bin/types/poll-summary.d.mts +23 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
- 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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
2
|
+
import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
|
|
3
|
+
import { refreshUnknownSummaryMergeability } from "./poll-summary-mergeability.mjs";
|
|
4
|
+
import { pollCommandFields } from "./poll-summary-command.mjs";
|
|
5
|
+
import { normalizePollSummaryState } from "./poll-summary-route.mjs";
|
|
6
|
+
import { rateLimitKind } from "./rate-limit-kind.mjs";
|
|
7
|
+
import { isGithubReadFallbackError } from "./transport.mjs";
|
|
8
|
+
import { formatPrUrl } from "../pr-reference.mjs";
|
|
9
|
+
/**
|
|
10
|
+
* Whether one PR's failed read becomes a row. Throttles, credential failures, and
|
|
11
|
+
* errors that switch transport describe the whole tick, so they still reject it.
|
|
12
|
+
*/
|
|
13
|
+
function isIsolatableRowError(error) {
|
|
14
|
+
if (isGithubReadFallbackError(error))
|
|
15
|
+
return false;
|
|
16
|
+
if (!(error instanceof GitHubRequestError))
|
|
17
|
+
return true;
|
|
18
|
+
return error.status !== 401 && rateLimitKind(error) === null;
|
|
19
|
+
}
|
|
20
|
+
/** Hydrate each PR's checks and mergeability; one PR's unreadable checks must not fail its siblings. */
|
|
21
|
+
export async function hydrateRowsIsolated(raws, repo) {
|
|
22
|
+
const failures = new Map();
|
|
23
|
+
for (const raw of raws) {
|
|
24
|
+
try {
|
|
25
|
+
await hydratePollSummaryChecks(raw, repo);
|
|
26
|
+
await refreshUnknownSummaryMergeability(raw, repo);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (!isIsolatableRowError(error))
|
|
30
|
+
throw error;
|
|
31
|
+
failures.set(raw.number, error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return failures;
|
|
35
|
+
}
|
|
36
|
+
/** A non-terminal row whose `pollCommand` runs the authoritative one-PR session. */
|
|
37
|
+
function readFailureRow(raw, repo, opts, error) {
|
|
38
|
+
const repoName = `${repo.owner}/${repo.name}`;
|
|
39
|
+
return {
|
|
40
|
+
pr: raw.number,
|
|
41
|
+
repo: repoName,
|
|
42
|
+
...(raw.transport && { transport: raw.transport }),
|
|
43
|
+
title: raw.title,
|
|
44
|
+
url: raw.url || formatPrUrl(repoName, raw.number),
|
|
45
|
+
action: "triage",
|
|
46
|
+
reasons: ["summary-read-failed"],
|
|
47
|
+
error: error instanceof Error ? error.message : String(error),
|
|
48
|
+
state: normalizePollSummaryState(raw.state),
|
|
49
|
+
mergeable: raw.mergeable,
|
|
50
|
+
mergeStateStatus: raw.mergeStateStatus,
|
|
51
|
+
headRefName: raw.headRefName,
|
|
52
|
+
headRefOid: raw.headRefOid,
|
|
53
|
+
baseRefName: raw.baseRefName,
|
|
54
|
+
...(raw.isDraft && { isDraft: true }),
|
|
55
|
+
...pollCommandFields(repoName, raw.number, raw.isDraft, opts),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Summarize each PR, turning an isolatable failure into a row. `failures` holds
|
|
60
|
+
* PRs whose chunk-time hydration already failed. A fatal error, or no readable
|
|
61
|
+
* row at all, rejects the tick with the first such error.
|
|
62
|
+
*/
|
|
63
|
+
export async function summarizeRowsIsolated(raws, failures, repo, opts, summarize) {
|
|
64
|
+
const settled = await Promise.allSettled(raws.map((raw) => failures.has(raw.number) ? Promise.reject(failures.get(raw.number)) : summarize(raw)));
|
|
65
|
+
const rejected = settled.filter((entry) => entry.status === "rejected");
|
|
66
|
+
const fatal = rejected.find((entry) => !isIsolatableRowError(entry.reason));
|
|
67
|
+
if (fatal)
|
|
68
|
+
throw fatal.reason;
|
|
69
|
+
if (rejected.length === settled.length)
|
|
70
|
+
throw rejected[0].reason;
|
|
71
|
+
return settled.map((entry, index) => entry.status === "fulfilled"
|
|
72
|
+
? entry.value
|
|
73
|
+
: readFailureRow(raws[index], repo, opts, entry.reason));
|
|
74
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ClassifyAction } from "../classify/types.mts";
|
|
2
|
+
import { type SeenMarker } from "../state/seen-comments.mts";
|
|
3
|
+
import type { PollSummaryRuleAutoResolve } from "../types.mts";
|
|
4
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
5
|
+
export interface SummaryStateKey {
|
|
6
|
+
owner: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
pr: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SummaryRuleActions {
|
|
11
|
+
comments: ReadonlyMap<string, ClassifyAction>;
|
|
12
|
+
summaries: ReadonlyMap<string, ClassifyAction>;
|
|
13
|
+
threads: ReadonlyMap<string, ClassifyAction>;
|
|
14
|
+
}
|
|
15
|
+
export interface SummaryAutoResolve {
|
|
16
|
+
/** Items whose mutation GitHub confirmed this tick. */
|
|
17
|
+
confirmed: ReadonlySet<string>;
|
|
18
|
+
/** Items Shepherd may mutate whose mutation was not confirmed; the full check still has work. */
|
|
19
|
+
unconfirmed: ReadonlySet<string>;
|
|
20
|
+
report?: PollSummaryRuleAutoResolve;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Apply the rule-driven auto-resolve the one-PR check performs: minimize matching PR comments and
|
|
24
|
+
* COMMENTED review summaries, resolve matching unresolved threads. Capability, denial, and marker
|
|
25
|
+
* handling mirror `check.mts`; a denied item is left to the normal first-look gate.
|
|
26
|
+
*/
|
|
27
|
+
export declare function applySummaryRuleAutoResolve(input: {
|
|
28
|
+
raw: RawSummaryPr;
|
|
29
|
+
actions: SummaryRuleActions;
|
|
30
|
+
seen: Map<string, SeenMarker>;
|
|
31
|
+
key: SummaryStateKey;
|
|
32
|
+
}): Promise<SummaryAutoResolve>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { normalizeAuthorType } from "../comments/authors.mjs";
|
|
2
|
+
import { buildRuleAutoResolveReport } from "../commands/rule-auto-resolve-format.mjs";
|
|
3
|
+
import { applySuppressedRuleAutoResolve } from "../commands/rule-auto-resolve.mjs";
|
|
4
|
+
import { markSeen, mutationWasDenied } from "../state/seen-comments.mjs";
|
|
5
|
+
import { threadTranscriptBodies } from "../threads/transcript.mjs";
|
|
6
|
+
import { canGenerateGithubMutation } from "./mutation-policy.mjs";
|
|
7
|
+
const NONE = { confirmed: new Set(), unconfirmed: new Set() };
|
|
8
|
+
function matchesAutoResolve(action) {
|
|
9
|
+
return action?.suppress === true && action.autoResolve === true;
|
|
10
|
+
}
|
|
11
|
+
function toReviewThread(thread) {
|
|
12
|
+
const root = thread.comments.nodes[0];
|
|
13
|
+
return {
|
|
14
|
+
id: thread.id,
|
|
15
|
+
...(thread.isResolved !== undefined && { isResolved: thread.isResolved }),
|
|
16
|
+
...(thread.isOutdated !== undefined && { isOutdated: thread.isOutdated }),
|
|
17
|
+
...(thread.viewerCanResolve !== undefined && { viewerCanResolve: thread.viewerCanResolve }),
|
|
18
|
+
path: thread.path ?? root?.path ?? null,
|
|
19
|
+
line: thread.line ?? null,
|
|
20
|
+
startLine: thread.startLine ?? null,
|
|
21
|
+
author: root?.author?.login ?? "unknown",
|
|
22
|
+
authorType: normalizeAuthorType(root?.author?.__typename, root?.author?.login),
|
|
23
|
+
body: root?.body ?? "",
|
|
24
|
+
url: root?.url ?? "",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The transcript the full check hashes is complete only when the window holds every comment. */
|
|
28
|
+
function threadDenied(thread, seen) {
|
|
29
|
+
const transcript = threadTranscriptBodies(thread.comments.nodes.map((comment) => comment.body));
|
|
30
|
+
return thread.comments.pageInfo.hasPreviousPage
|
|
31
|
+
? seen.get(thread.id)?.deniedMutationBodyHash !== undefined
|
|
32
|
+
: mutationWasDenied(thread.id, transcript, seen);
|
|
33
|
+
}
|
|
34
|
+
/** Patch the read to match GitHub so a stack handoff does not replay the mutation. */
|
|
35
|
+
function patchRaw(raw, confirmed) {
|
|
36
|
+
for (const comment of [...raw.comments.nodes, ...raw.reviews.nodes])
|
|
37
|
+
if (confirmed.has(comment.id))
|
|
38
|
+
comment.isMinimized = true;
|
|
39
|
+
for (const thread of raw.reviewThreads.nodes)
|
|
40
|
+
if (confirmed.has(thread.id))
|
|
41
|
+
thread.isResolved = true;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Apply the rule-driven auto-resolve the one-PR check performs: minimize matching PR comments and
|
|
45
|
+
* COMMENTED review summaries, resolve matching unresolved threads. Capability, denial, and marker
|
|
46
|
+
* handling mirror `check.mts`; a denied item is left to the normal first-look gate.
|
|
47
|
+
*/
|
|
48
|
+
export async function applySummaryRuleAutoResolve(input) {
|
|
49
|
+
const { raw, actions, seen, key } = input;
|
|
50
|
+
const comments = raw.comments.nodes.filter((comment) => !comment.isMinimized &&
|
|
51
|
+
matchesAutoResolve(actions.comments.get(comment.id)) &&
|
|
52
|
+
!mutationWasDenied(comment.id, comment.body, seen) &&
|
|
53
|
+
canGenerateGithubMutation(comment.viewerCanMinimize, "minimize"));
|
|
54
|
+
const summaries = raw.reviews.nodes.filter((review) => !review.isMinimized &&
|
|
55
|
+
matchesAutoResolve(actions.summaries.get(review.id)) &&
|
|
56
|
+
!mutationWasDenied(review.id, review.body, seen) &&
|
|
57
|
+
canGenerateGithubMutation(review.viewerCanMinimize, "minimize"));
|
|
58
|
+
const threads = raw.reviewThreads.nodes.filter((thread) => thread.isResolved === false &&
|
|
59
|
+
thread.isOutdated !== true &&
|
|
60
|
+
matchesAutoResolve(actions.threads.get(thread.id)) &&
|
|
61
|
+
!threadDenied(thread, seen) &&
|
|
62
|
+
canGenerateGithubMutation(thread.viewerCanResolve, "resolve"));
|
|
63
|
+
if (comments.length + summaries.length + threads.length === 0)
|
|
64
|
+
return NONE;
|
|
65
|
+
// The full check marks every non-denied suppressed item before it mutates, whatever the outcome.
|
|
66
|
+
await Promise.allSettled([
|
|
67
|
+
...[...comments, ...summaries].map((item) => markSeen(key, item.id, item.body)),
|
|
68
|
+
...threads
|
|
69
|
+
.filter((thread) => !thread.comments.pageInfo.hasPreviousPage)
|
|
70
|
+
.map((thread) => markSeen(key, thread.id, threadTranscriptBodies(thread.comments.nodes.map((comment) => comment.body)))),
|
|
71
|
+
]);
|
|
72
|
+
const reviewThreads = threads.map(toReviewThread);
|
|
73
|
+
const reasonOf = (id, map) => {
|
|
74
|
+
const reason = map.get(id)?.reason;
|
|
75
|
+
return [id, reason ? [reason] : []];
|
|
76
|
+
};
|
|
77
|
+
const ruleReasons = new Map([
|
|
78
|
+
...comments.map((item) => reasonOf(item.id, actions.comments)),
|
|
79
|
+
...summaries.map((item) => reasonOf(item.id, actions.summaries)),
|
|
80
|
+
...threads.map((item) => reasonOf(item.id, actions.threads)),
|
|
81
|
+
]);
|
|
82
|
+
const partition = {
|
|
83
|
+
suppressedCommentIds: new Set(comments.map((item) => item.id)),
|
|
84
|
+
suppressedThreadIds: new Set(threads.map((item) => item.id)),
|
|
85
|
+
suppressedReviewSummaryIds: new Set(summaries.map((item) => item.id)),
|
|
86
|
+
suppressedChangesRequestedIds: new Set(),
|
|
87
|
+
ruleAutoResolveCommentIds: comments.map((item) => item.id),
|
|
88
|
+
ruleAutoResolveThreadIds: threads.map((item) => item.id),
|
|
89
|
+
ruleAutoResolveReviewSummaryIds: summaries.map((item) => item.id),
|
|
90
|
+
ruleReasons,
|
|
91
|
+
};
|
|
92
|
+
const applied = await applySuppressedRuleAutoResolve({
|
|
93
|
+
enabled: true,
|
|
94
|
+
partition,
|
|
95
|
+
batch: { comments, reviewSummaries: summaries, reviewThreads },
|
|
96
|
+
});
|
|
97
|
+
const confirmed = new Set([
|
|
98
|
+
...applied.autoMinimized.map((item) => item.id),
|
|
99
|
+
...applied.autoResolved.map((thread) => thread.id),
|
|
100
|
+
]);
|
|
101
|
+
patchRaw(raw, confirmed);
|
|
102
|
+
const full = buildRuleAutoResolveReport({
|
|
103
|
+
threads: applied.autoResolved,
|
|
104
|
+
minimized: applied.autoMinimized,
|
|
105
|
+
errors: applied.autoResolveErrors,
|
|
106
|
+
errorReasons: applied.errorReasons,
|
|
107
|
+
});
|
|
108
|
+
const report = full && {
|
|
109
|
+
...full,
|
|
110
|
+
...(full.threads && {
|
|
111
|
+
threads: full.threads.map((thread) => ({
|
|
112
|
+
id: thread.id,
|
|
113
|
+
...(thread.url && { url: thread.url }),
|
|
114
|
+
})),
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
const unconfirmed = [...ruleReasons.keys()].filter((id) => !confirmed.has(id));
|
|
118
|
+
return { confirmed, unconfirmed: new Set(unconfirmed), ...(report && { report }) };
|
|
119
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PollSummaryItem } from "../types.mts";
|
|
2
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
+
/**
|
|
4
|
+
* A stack handoff clones the read before rows are evaluated. Reflect the rule-driven mutations the
|
|
5
|
+
* rows confirmed so the one-PR session that consumes the handoff does not repeat them.
|
|
6
|
+
*/
|
|
7
|
+
export declare function patchHandoffLayers(layers: RawSummaryPr[], items: PollSummaryItem[]): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stack handoff clones the read before rows are evaluated. Reflect the rule-driven mutations the
|
|
3
|
+
* rows confirmed so the one-PR session that consumes the handoff does not repeat them.
|
|
4
|
+
*/
|
|
5
|
+
export function patchHandoffLayers(layers, items) {
|
|
6
|
+
for (const item of items) {
|
|
7
|
+
const report = item.ruleAutoResolve;
|
|
8
|
+
const layer = layers.find((candidate) => candidate.number === item.pr);
|
|
9
|
+
if (!report || !layer)
|
|
10
|
+
continue;
|
|
11
|
+
const minimized = new Set(report.minimized?.map((entry) => entry.id));
|
|
12
|
+
const resolved = new Set(report.threads?.map((thread) => thread.id));
|
|
13
|
+
for (const node of [...layer.comments.nodes, ...layer.reviews.nodes])
|
|
14
|
+
if (minimized.has(node.id))
|
|
15
|
+
node.isMinimized = true;
|
|
16
|
+
for (const thread of layer.reviewThreads.nodes)
|
|
17
|
+
if (resolved.has(thread.id))
|
|
18
|
+
thread.isResolved = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { applyRules } from "../classify/apply.mts";
|
|
2
|
+
import type { ClassifyAction, ClassifyItem } from "../classify/types.mts";
|
|
3
|
+
import type { RawAuthor } from "./poll-summary-raw.mts";
|
|
4
|
+
type Rules = Parameters<typeof applyRules>[0];
|
|
5
|
+
export interface RuleSource {
|
|
6
|
+
id: string;
|
|
7
|
+
body: string;
|
|
8
|
+
author: RawAuthor | null;
|
|
9
|
+
authorAssociation?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
path?: string | null;
|
|
12
|
+
}
|
|
13
|
+
/** The rule action for one compact item, built like the full check builds its rule items. */
|
|
14
|
+
export declare function summaryRuleAction(rules: Rules, kind: ClassifyItem["kind"], raw: RuleSource | undefined, id?: string, path?: string | null, body?: string): ClassifyAction;
|
|
15
|
+
/**
|
|
16
|
+
* An auto-resolve match is hidden only once its mutation is confirmed or the item is already
|
|
17
|
+
* resolved; until then it stays visible so the full check can run or surface it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function ruleHides(action: ClassifyAction, kind: ClassifyItem["kind"], autoResolveSatisfied: boolean): boolean;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { applyRules } from "../classify/apply.mjs";
|
|
2
|
+
import { normalizeAuthorType } from "../comments/authors.mjs";
|
|
3
|
+
/** The rule action for one compact item, built like the full check builds its rule items. */
|
|
4
|
+
export function summaryRuleAction(rules, kind, raw, id = raw?.id ?? "unknown", path, body = raw?.body ?? "") {
|
|
5
|
+
const item = {
|
|
6
|
+
kind,
|
|
7
|
+
id,
|
|
8
|
+
author: raw?.author?.login ?? "unknown",
|
|
9
|
+
authorType: normalizeAuthorType(raw?.author?.__typename, raw?.author?.login),
|
|
10
|
+
...(raw?.authorAssociation !== undefined && { authorAssociation: raw.authorAssociation }),
|
|
11
|
+
body,
|
|
12
|
+
// A changes-requested rule item carries no URL, as in the full check.
|
|
13
|
+
...(kind === "review-thread" ? { url: raw?.url ?? "" } : {}),
|
|
14
|
+
...(kind !== "review-thread" && kind !== "changes-requested" && raw?.url
|
|
15
|
+
? { url: raw.url }
|
|
16
|
+
: {}),
|
|
17
|
+
...(kind === "review-thread" && { path }),
|
|
18
|
+
};
|
|
19
|
+
return applyRules(rules, item);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* An auto-resolve match is hidden only once its mutation is confirmed or the item is already
|
|
23
|
+
* resolved; until then it stays visible so the full check can run or surface it.
|
|
24
|
+
*/
|
|
25
|
+
export function ruleHides(action, kind, autoResolveSatisfied) {
|
|
26
|
+
return (action.suppress === true &&
|
|
27
|
+
(action.autoResolve !== true || kind === "changes-requested" || autoResolveSatisfied));
|
|
28
|
+
}
|
|
@@ -8,11 +8,13 @@ import { missingRepositoryError } from "./errors.mjs";
|
|
|
8
8
|
import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
|
|
9
9
|
import { refreshUnknownSummaryMergeability } from "./poll-summary-mergeability.mjs";
|
|
10
10
|
import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
|
|
11
|
+
import { hydrateRowsIsolated, summarizeRowsIsolated, } from "./poll-summary-row-error.mjs";
|
|
11
12
|
import { trunkRequiredContexts } from "./poll-summary-unreported.mjs";
|
|
12
13
|
import { loadStackSizeHint, storeStackSizeHint } from "./stack-size-hint.mjs";
|
|
13
14
|
import { POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
|
|
14
15
|
import { stackAncestryGaps } from "./stack-read.mjs";
|
|
15
16
|
import { readGraphqlStackSummary } from "./poll-summary-stack-page.mjs";
|
|
17
|
+
import { patchHandoffLayers } from "./poll-summary-rule-handoff.mjs";
|
|
16
18
|
import { stackHandoffSource } from "./stack-handoff.mjs";
|
|
17
19
|
const MAX_EXPLICIT_PRS_PER_QUERY = 50;
|
|
18
20
|
async function fetchSelectedPollSummary(opts, repo) {
|
|
@@ -23,21 +25,27 @@ async function fetchSelectedPollSummary(opts, repo) {
|
|
|
23
25
|
throw new ShepherdError("Aggregate poll requires at least two PRs or a stack PR", EXIT.USAGE);
|
|
24
26
|
}
|
|
25
27
|
const raw = [];
|
|
28
|
+
const failures = new Map();
|
|
26
29
|
let viewerCanAdminister;
|
|
27
30
|
for (let offset = 0; offset < requested.length; offset += MAX_EXPLICIT_PRS_PER_QUERY) {
|
|
28
31
|
const chunk = requested.slice(offset, offset + MAX_EXPLICIT_PRS_PER_QUERY);
|
|
29
32
|
const fetched = await fetchExplicitChunk(chunk, repo);
|
|
30
33
|
viewerCanAdminister = fetched.viewerCanAdminister;
|
|
31
34
|
raw.push(...fetched.prs);
|
|
35
|
+
for (const [pr, error] of fetched.failures ?? [])
|
|
36
|
+
failures.set(pr, error);
|
|
32
37
|
}
|
|
33
38
|
return {
|
|
34
39
|
selection: { kind: "prs", requested },
|
|
35
|
-
|
|
40
|
+
// One explicit PR's failed read becomes a row; stack selections stay fail-closed.
|
|
41
|
+
prs: await summarizeRowsIsolated(raw, failures, repo, opts, (pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, undefined, null, true)),
|
|
36
42
|
};
|
|
37
43
|
}
|
|
38
44
|
/** Fresh, read-only snapshot used to bind a one-PR READY receipt to stack routing. */
|
|
39
45
|
export async function fetchRawSummaryPr(pr, repo) {
|
|
40
46
|
const fetched = await fetchExplicitChunk([pr], repo);
|
|
47
|
+
if (fetched.failures?.has(pr))
|
|
48
|
+
throw fetched.failures.get(pr);
|
|
41
49
|
return fetched.prs[0];
|
|
42
50
|
}
|
|
43
51
|
async function fetchGraphqlExplicitChunk(prs, repo) {
|
|
@@ -60,11 +68,8 @@ async function fetchGraphqlExplicitChunk(prs, repo) {
|
|
|
60
68
|
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
61
69
|
return raw;
|
|
62
70
|
});
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
await refreshUnknownSummaryMergeability(raw, repo);
|
|
66
|
-
}
|
|
67
|
-
return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
|
|
71
|
+
const failures = await hydrateRowsIsolated(rawPrs, repo);
|
|
72
|
+
return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister, failures };
|
|
68
73
|
}
|
|
69
74
|
async function fetchExplicitChunk(prs, repo) {
|
|
70
75
|
return githubOperation("PollSummary", () => runWithGithubTransport("graphql", () => fetchGraphqlExplicitChunk(prs, repo)), async () => ({
|
|
@@ -101,9 +106,12 @@ async function fetchStackSummary(opts, repo) {
|
|
|
101
106
|
const handoffSource = getGithubTransport() === "rest" ? undefined : stackHandoffSource(read);
|
|
102
107
|
const stackAncestry = stackAncestryGaps(ordered);
|
|
103
108
|
const required = await trunkRequiredContexts(ordered, repo);
|
|
109
|
+
const prs = await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, required, viewerLogin, true)));
|
|
110
|
+
if (handoffSource)
|
|
111
|
+
patchHandoffLayers(handoffSource.layers, prs);
|
|
104
112
|
return {
|
|
105
113
|
selection: { kind: "stack", anchor, stackNumber, stackSize },
|
|
106
|
-
prs
|
|
114
|
+
prs,
|
|
107
115
|
...(stackAncestry.length > 0 && { stackAncestry }),
|
|
108
116
|
...(allowedMergeMethods && { allowedMergeMethods }),
|
|
109
117
|
...(handoffSource && { handoffSource }),
|
|
@@ -101,7 +101,9 @@ function nextRestPage(link, original) {
|
|
|
101
101
|
if (!match)
|
|
102
102
|
malformedRest(`invalid pagination link for ${original}`);
|
|
103
103
|
const url = new URL(match[1], "https://api.github.com");
|
|
104
|
-
|
|
104
|
+
const originalPath = original.split("?")[0];
|
|
105
|
+
if (url.origin !== "https://api.github.com" ||
|
|
106
|
+
!samePaginationResource(url.pathname, originalPath))
|
|
105
107
|
malformedRest(`unsafe pagination link for ${original}`);
|
|
106
108
|
if (url.searchParams.get("per_page") !== "100")
|
|
107
109
|
malformedRest(`changed page size for ${original}`);
|
|
@@ -113,7 +115,16 @@ function nextRestPage(link, original) {
|
|
|
113
115
|
JSON.stringify(source.searchParams.getAll(key)))
|
|
114
116
|
malformedRest(`changed pagination filter for ${original}`);
|
|
115
117
|
}
|
|
116
|
-
|
|
118
|
+
// Rebuild on the requested path so the repository id in a /repositories/{id} link is never trusted.
|
|
119
|
+
return `${originalPath}${url.search}`;
|
|
120
|
+
}
|
|
121
|
+
/** GitHub may link `/repos/{owner}/{repo}/…` pages through `/repositories/{id}/…`. */
|
|
122
|
+
function samePaginationResource(linkPath, originalPath) {
|
|
123
|
+
if (linkPath === originalPath)
|
|
124
|
+
return true;
|
|
125
|
+
const linkSuffix = /^\/repositories\/\d+(\/.*)$/.exec(linkPath)?.[1];
|
|
126
|
+
const originalSuffix = /^\/repos\/[^/]+\/[^/]+(\/.*)$/.exec(originalPath)?.[1];
|
|
127
|
+
return linkSuffix !== undefined && linkSuffix === originalSuffix;
|
|
117
128
|
}
|
|
118
129
|
/** A pull request whose mergeability GitHub is still computing must be re-read, never replayed. */
|
|
119
130
|
function isSettledBody(body) {
|
|
@@ -99,7 +99,7 @@ export function restSummary(data, pull, feedback, checks, baseRef) {
|
|
|
99
99
|
nodes: checks.suites.map((suite) => ({
|
|
100
100
|
status: suite.status.toUpperCase(),
|
|
101
101
|
conclusion: suite.conclusion?.toUpperCase() ?? null,
|
|
102
|
-
workflowRun: checks.workflowRuns.find((run) => run.check_suite_id === suite.id)
|
|
102
|
+
workflowRun: restSuiteWorkflowRun(checks.workflowRuns.find((run) => run.check_suite_id === suite.id)),
|
|
103
103
|
})),
|
|
104
104
|
},
|
|
105
105
|
},
|
|
@@ -110,3 +110,14 @@ export function restSummary(data, pull, feedback, checks, baseRef) {
|
|
|
110
110
|
transportUnavailable: data.transportUnavailable,
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
+
/** The GraphQL `workflowRun` shape, so summary readers see one suite format on either transport. */
|
|
114
|
+
function restSuiteWorkflowRun(run) {
|
|
115
|
+
if (!run)
|
|
116
|
+
return null;
|
|
117
|
+
return {
|
|
118
|
+
databaseId: run.id,
|
|
119
|
+
event: run.event,
|
|
120
|
+
url: run.html_url,
|
|
121
|
+
workflow: { name: run.name },
|
|
122
|
+
};
|
|
123
|
+
}
|
package/bin/types/github.d.mts
CHANGED
|
@@ -60,6 +60,8 @@ export interface CheckRun {
|
|
|
60
60
|
workflowId?: string;
|
|
61
61
|
/** True when GraphQL reported at least one CheckRun annotation. Omitted when false. */
|
|
62
62
|
hasAnnotations?: boolean;
|
|
63
|
+
/** Annotation total GraphQL reported for this CheckRun. Internal: validates cached lists. */
|
|
64
|
+
annotationCount?: number;
|
|
63
65
|
/**
|
|
64
66
|
* Every annotation on this run, when BatchPr's `annotations(first: 1)` page already held all
|
|
65
67
|
* of them. Internal: lets the check path skip `CheckRunAnnotationsBatch`; never rendered.
|
|
@@ -3,6 +3,7 @@ import type { MergeableState, MergeStateStatus, ReviewDecision } from "./github.
|
|
|
3
3
|
import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
|
|
4
4
|
import type { ShepherdAction } from "./iterate.mts";
|
|
5
5
|
import type { NextCheck } from "./next-check.mts";
|
|
6
|
+
import type { AutoMinimizedItem } from "./report.mts";
|
|
6
7
|
export interface PollSummaryChecks {
|
|
7
8
|
passing?: number;
|
|
8
9
|
failing?: number;
|
|
@@ -12,11 +13,26 @@ export interface PollSummaryChecks {
|
|
|
12
13
|
ignored?: number;
|
|
13
14
|
superseded?: number;
|
|
14
15
|
incomplete?: true;
|
|
16
|
+
/**
|
|
17
|
+
* Non-SUCCESS checks with annotations the one-PR check has not shown yet (or that could not be
|
|
18
|
+
* read). Only set on a row that would otherwise claim ready; it routes to `triage`.
|
|
19
|
+
*/
|
|
20
|
+
annotated?: number;
|
|
15
21
|
/** Required contexts with no check run and no status context. Omitted when empty. */
|
|
16
22
|
unreportedRequired?: string[];
|
|
17
23
|
/** A relevant Actions workflow suite has not completed. */
|
|
18
24
|
actionsWorkflowInProgress?: true;
|
|
19
25
|
}
|
|
26
|
+
/** The one-PR `ruleAutoResolve` content, with threads reduced to what a row needs: ID and link. */
|
|
27
|
+
export interface PollSummaryRuleAutoResolve {
|
|
28
|
+
summary: string;
|
|
29
|
+
threads?: Array<{
|
|
30
|
+
id: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
}>;
|
|
33
|
+
minimized?: AutoMinimizedItem[];
|
|
34
|
+
errors?: string[];
|
|
35
|
+
}
|
|
20
36
|
export interface PollSummaryReview {
|
|
21
37
|
comments?: number;
|
|
22
38
|
reviews?: number;
|
|
@@ -56,6 +72,8 @@ export interface PollSummaryItem {
|
|
|
56
72
|
/** Conservative routing hint; the selected one-PR poll makes the authoritative decision. */
|
|
57
73
|
action: ShepherdAction;
|
|
58
74
|
reasons: string[];
|
|
75
|
+
/** Raw message of the error that stopped this PR's summary read (`summary-read-failed`). */
|
|
76
|
+
error?: string;
|
|
59
77
|
state: "OPEN" | "CLOSED" | "MERGED" | "UNKNOWN";
|
|
60
78
|
mergeable: MergeableState;
|
|
61
79
|
mergeStateStatus: MergeStateStatus;
|
|
@@ -72,6 +90,11 @@ export interface PollSummaryItem {
|
|
|
72
90
|
remainingSeconds?: number;
|
|
73
91
|
checks?: PollSummaryChecks;
|
|
74
92
|
review?: PollSummaryReview;
|
|
93
|
+
/**
|
|
94
|
+
* Rule-driven auto-resolve this tick performed or failed, in the one-PR `ruleAutoResolve`
|
|
95
|
+
* shape. Kept out of `review` so a one-tick event does not churn the status signature.
|
|
96
|
+
*/
|
|
97
|
+
ruleAutoResolve?: PollSummaryRuleAutoResolve;
|
|
75
98
|
stack?: PollSummaryStack;
|
|
76
99
|
pollCommand?: string;
|
|
77
100
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"husky": "^9.1.7",
|
|
92
92
|
"knip": "^6.14.1",
|
|
93
93
|
"marked": "^18.0.11",
|
|
94
|
-
"oxfmt": "^0.
|
|
94
|
+
"oxfmt": "^0.73.0",
|
|
95
95
|
"oxlint": "^1.60.0",
|
|
96
96
|
"typescript": "^7.0.2",
|
|
97
97
|
"vitest": "^5.0.0"
|