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.
Files changed (64) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli/poll-summary-formatter.mjs +3 -0
  3. package/bin/cli/rule-auto-resolve-lines.d.mts +3 -0
  4. package/bin/cli/rule-auto-resolve-lines.mjs +12 -0
  5. package/bin/cli/stack-layer-view.d.mts +2 -1
  6. package/bin/cli/stack-overview.mjs +3 -0
  7. package/bin/commands/check-activity.d.mts +8 -0
  8. package/bin/commands/check-activity.mjs +26 -0
  9. package/bin/commands/check-annotations.mjs +2 -1
  10. package/bin/commands/check-wait-detectors.mjs +11 -0
  11. package/bin/commands/check.mjs +6 -1
  12. package/bin/commands/poll-summary-signature.d.mts +6 -0
  13. package/bin/commands/poll-summary-signature.mjs +19 -2
  14. package/bin/commands/poll-summary.mjs +7 -0
  15. package/bin/commands/ready-delay.d.mts +1 -0
  16. package/bin/commands/ready-delay.mjs +8 -3
  17. package/bin/commands/rule-auto-resolve-format.d.mts +7 -0
  18. package/bin/commands/rule-auto-resolve-format.mjs +11 -4
  19. package/bin/commands/rule-auto-resolve.d.mts +14 -2
  20. package/bin/comments/thread-visibility.d.mts +6 -0
  21. package/bin/comments/thread-visibility.mjs +30 -15
  22. package/bin/github/batch-parse-suites.d.mts +14 -0
  23. package/bin/github/batch-parse-suites.mjs +4 -1
  24. package/bin/github/batch-parser-helpers.mjs +3 -0
  25. package/bin/github/check-annotation-cache.d.mts +5 -0
  26. package/bin/github/check-annotation-cache.mjs +6 -3
  27. package/bin/github/fingerprint.mjs +4 -0
  28. package/bin/github/poll-summary-annotation-probe.d.mts +5 -0
  29. package/bin/github/poll-summary-annotation-probe.mjs +17 -3
  30. package/bin/github/poll-summary-annotations.d.mts +14 -0
  31. package/bin/github/poll-summary-annotations.mjs +75 -0
  32. package/bin/github/poll-summary-blocking-reviewer.d.mts +2 -0
  33. package/bin/github/poll-summary-blocking-reviewer.mjs +9 -0
  34. package/bin/github/poll-summary-checks.d.mts +1 -0
  35. package/bin/github/poll-summary-checks.mjs +20 -2
  36. package/bin/github/poll-summary-fingerprint.d.mts +7 -0
  37. package/bin/github/poll-summary-fingerprint.mjs +7 -0
  38. package/bin/github/poll-summary-projector.d.mts +3 -1
  39. package/bin/github/poll-summary-projector.mjs +13 -24
  40. package/bin/github/poll-summary-queue-removal.d.mts +2 -0
  41. package/bin/github/poll-summary-queue-removal.mjs +11 -0
  42. package/bin/github/poll-summary-readiness.mjs +1 -0
  43. package/bin/github/poll-summary-review-parity.d.mts +24 -0
  44. package/bin/github/poll-summary-review-parity.mjs +82 -0
  45. package/bin/github/poll-summary-review.d.mts +11 -2
  46. package/bin/github/poll-summary-review.mjs +87 -59
  47. package/bin/github/poll-summary-row-error.d.mts +16 -0
  48. package/bin/github/poll-summary-row-error.mjs +74 -0
  49. package/bin/github/poll-summary-rule-auto-resolve.d.mts +32 -0
  50. package/bin/github/poll-summary-rule-auto-resolve.mjs +119 -0
  51. package/bin/github/poll-summary-rule-handoff.d.mts +7 -0
  52. package/bin/github/poll-summary-rule-handoff.mjs +20 -0
  53. package/bin/github/poll-summary-rule-items.d.mts +20 -0
  54. package/bin/github/poll-summary-rule-items.mjs +28 -0
  55. package/bin/github/poll-summary.mjs +15 -7
  56. package/bin/github/rest-reader-core.mjs +13 -2
  57. package/bin/github/rest-summary-read.mjs +12 -1
  58. package/bin/types/github.d.mts +2 -0
  59. package/bin/types/poll-summary.d.mts +23 -0
  60. package/package.json +2 -2
  61. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  62. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  63. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  64. 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
- prs: await Promise.all(raw.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
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
- for (const raw of rawPrs) {
64
- await hydratePollSummaryChecks(raw, repo);
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: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, required, viewerLogin))),
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
- if (url.origin !== "https://api.github.com" || url.pathname !== original.split("?")[0])
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
- return `${url.pathname}${url.search}`;
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) ?? null,
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
+ }
@@ -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.0.0",
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.72.0",
94
+ "oxfmt": "^0.73.0",
95
95
  "oxlint": "^1.60.0",
96
96
  "typescript": "^7.0.2",
97
97
  "vitest": "^5.0.0"
@@ -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": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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@1.0.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@1.1.0", "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@1.0.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@1.1.0", "pr-shepherd-mcp"]
5
5
  }
6
6
  }