pr-shepherd 0.53.1 → 0.54.1
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/README.md +1 -1
- package/bin/cli/args.mjs +3 -0
- package/bin/cli/check-blocker-handler.d.mts +2 -0
- package/bin/cli/check-blocker-handler.mjs +82 -0
- package/bin/cli/help-command-pages.d.mts +22 -3
- package/bin/cli/help-command-pages.mjs +21 -2
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +1 -1
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -1
- package/bin/cli/help.d.mts +23 -4
- package/bin/cli/help.mjs +2 -0
- package/bin/cli/iterate-branch-segment.d.mts +3 -0
- package/bin/cli/iterate-branch-segment.mjs +13 -0
- package/bin/cli/iterate-formatter.mjs +5 -13
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/poll-handler.mjs +5 -1
- package/bin/cli-parser.mjs +4 -0
- package/bin/commands/apply-check-blocker.d.mts +22 -0
- package/bin/commands/apply-check-blocker.mjs +36 -0
- package/bin/commands/check-annotations.d.mts +1 -1
- package/bin/commands/check-annotations.mjs +27 -17
- package/bin/commands/check-blocker-ref.d.mts +3 -0
- package/bin/commands/check-blocker-ref.mjs +50 -0
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
- package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
- package/bin/commands/iterate/check-instructions.d.mts +2 -0
- package/bin/commands/iterate/check-instructions.mjs +4 -0
- package/bin/commands/iterate/conflicting-head-ci.d.mts +18 -0
- package/bin/commands/iterate/conflicting-head-ci.mjs +32 -0
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +61 -16
- package/bin/commands/iterate/index.mjs +22 -8
- package/bin/commands/iterate/native-stack-rebase.d.mts +8 -2
- package/bin/commands/iterate/native-stack-rebase.mjs +14 -4
- package/bin/commands/iterate/stack-trunk-conflict.d.mts +13 -0
- package/bin/commands/iterate/stack-trunk-conflict.mjs +51 -0
- package/bin/commands/poll-quota.d.mts +2 -0
- package/bin/commands/poll-quota.mjs +25 -12
- package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
- package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
- package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
- package/bin/commands/poll-rate-limit-delay.mjs +7 -0
- package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
- package/bin/commands/poll-rate-limit-wait.mjs +125 -0
- package/bin/commands/poll-summary.mjs +21 -9
- package/bin/commands/poll.mjs +22 -17
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +24 -2
- package/bin/config.json +1 -0
- package/bin/github/batch-parse-suites.d.mts +2 -0
- package/bin/github/batch-parse-suites.mjs +5 -0
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +2 -1
- package/bin/github/check-annotation-cache.d.mts +8 -0
- package/bin/github/check-annotation-cache.mjs +23 -0
- package/bin/github/check-annotation-pages.d.mts +11 -0
- package/bin/github/check-annotation-pages.mjs +36 -0
- package/bin/github/check-annotation-shape.d.mts +21 -0
- package/bin/github/check-annotation-shape.mjs +50 -0
- package/bin/github/check-annotations-batch.d.mts +17 -0
- package/bin/github/check-annotations-batch.mjs +92 -0
- package/bin/github/check-annotations.d.mts +8 -11
- package/bin/github/check-annotations.mjs +14 -103
- package/bin/github/gql/batch-pr.gql +1 -1
- package/bin/github/gql/check-run-annotations-batch.gql +41 -0
- package/bin/github/gql/upper-layer-conflict-target.gql +36 -0
- package/bin/github/pagination.d.mts +1 -1
- package/bin/github/pagination.mjs +1 -1
- package/bin/github/poll-summary-check-blockers.d.mts +16 -0
- package/bin/github/poll-summary-check-blockers.mjs +25 -0
- package/bin/github/poll-summary-checks.d.mts +2 -0
- package/bin/github/poll-summary-checks.mjs +35 -20
- package/bin/github/poll-summary-projector.mjs +5 -0
- package/bin/github/queries.d.mts +11 -0
- package/bin/github/queries.mjs +11 -0
- package/bin/state/check-blockers.d.mts +30 -0
- package/bin/state/check-blockers.mjs +88 -0
- package/bin/state/conflicting-head-seen.d.mts +13 -0
- package/bin/state/conflicting-head-seen.mjs +44 -0
- package/bin/types/iterate.d.mts +5 -0
- package/bin/types/report.d.mts +2 -0
- package/package.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,8 @@
|
|
|
1
|
+
import { type StateKey } from "../state/rest-cache.mts";
|
|
2
|
+
import type { CheckAnnotation } from "../types.mts";
|
|
3
|
+
export interface AnnotationCacheOptions {
|
|
4
|
+
stateKey: StateKey;
|
|
5
|
+
headSha?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function readFreshCheckAnnotations(checkRunId: string, cacheOpts: AnnotationCacheOptions | undefined): Promise<CheckAnnotation[] | undefined>;
|
|
8
|
+
export declare function storeCheckAnnotations(checkRunId: string, annotations: CheckAnnotation[], cacheOpts: AnnotationCacheOptions | undefined): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Some Checks-API publishers PATCH additional annotations onto an already
|
|
4
|
+
* COMPLETED check run without minting a new node id, so COMPLETED is not a
|
|
5
|
+
* reliable immutability signal. Bound the cache instead of trusting it forever.
|
|
6
|
+
*/
|
|
7
|
+
const ANNOTATION_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
8
|
+
function cacheName(checkRunId) {
|
|
9
|
+
return `annotations-${checkRunId}`;
|
|
10
|
+
}
|
|
11
|
+
export async function readFreshCheckAnnotations(checkRunId, cacheOpts) {
|
|
12
|
+
if (!cacheOpts)
|
|
13
|
+
return undefined;
|
|
14
|
+
const cached = await loadDerived(cacheOpts.stateKey, cacheName(checkRunId));
|
|
15
|
+
if (cached && Date.now() - cached.storedAt < ANNOTATION_CACHE_MAX_AGE_MS)
|
|
16
|
+
return cached.value;
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
export async function storeCheckAnnotations(checkRunId, annotations, cacheOpts) {
|
|
20
|
+
if (!cacheOpts)
|
|
21
|
+
return;
|
|
22
|
+
await storeDerived(cacheOpts.stateKey, cacheName(checkRunId), annotations, cacheOpts.headSha);
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type RawCheckAnnotation } from "./check-annotation-shape.mts";
|
|
2
|
+
import type { CheckAnnotation } from "../types.mts";
|
|
3
|
+
export interface AnnotationPage {
|
|
4
|
+
pageInfo: {
|
|
5
|
+
hasNextPage: boolean;
|
|
6
|
+
endCursor: string | null;
|
|
7
|
+
};
|
|
8
|
+
nodes: RawCheckAnnotation[];
|
|
9
|
+
}
|
|
10
|
+
/** Pages a single check run. `firstPage` skips the initial `node(id:)` request. */
|
|
11
|
+
export declare function collectAnnotations(checkRunId: string, firstPage?: AnnotationPage): Promise<CheckAnnotation[]>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { graphql } from "./client.mjs";
|
|
2
|
+
import { toCheckAnnotation } from "./check-annotation-shape.mjs";
|
|
3
|
+
import { CHECK_RUN_ANNOTATIONS_QUERY } from "./queries.mjs";
|
|
4
|
+
const ANNOTATIONS_PER_PAGE = 100;
|
|
5
|
+
const MAX_ANNOTATION_PAGES = 10;
|
|
6
|
+
/** Pages a single check run. `firstPage` skips the initial `node(id:)` request. */
|
|
7
|
+
export async function collectAnnotations(checkRunId, firstPage) {
|
|
8
|
+
const nodes = [];
|
|
9
|
+
let cursor = null;
|
|
10
|
+
let seeded = firstPage;
|
|
11
|
+
for (let pageNumber = 1; pageNumber <= MAX_ANNOTATION_PAGES; pageNumber++) {
|
|
12
|
+
// eslint-disable-next-line no-await-in-loop
|
|
13
|
+
const current = seeded ?? (await fetchAnnotationPage(checkRunId, cursor));
|
|
14
|
+
seeded = undefined;
|
|
15
|
+
nodes.push(...current.nodes);
|
|
16
|
+
if (!current.pageInfo.hasNextPage || !current.pageInfo.endCursor)
|
|
17
|
+
break;
|
|
18
|
+
if (pageNumber === MAX_ANNOTATION_PAGES) {
|
|
19
|
+
process.stderr.write(`pr-shepherd: annotation pagination cap (${MAX_ANNOTATION_PAGES * ANNOTATIONS_PER_PAGE} annotations) reached for check run ${checkRunId} — annotation output may be incomplete\n`);
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
cursor = current.pageInfo.endCursor;
|
|
23
|
+
}
|
|
24
|
+
return nodes.map((node) => toCheckAnnotation(checkRunId, node));
|
|
25
|
+
}
|
|
26
|
+
async function fetchAnnotationPage(checkRunId, cursor) {
|
|
27
|
+
const res = await graphql(CHECK_RUN_ANNOTATIONS_QUERY, {
|
|
28
|
+
id: checkRunId,
|
|
29
|
+
...(cursor ? { cursor } : {}),
|
|
30
|
+
});
|
|
31
|
+
const node = res.data.node;
|
|
32
|
+
if (node?.__typename !== "CheckRun" || node.annotations === undefined) {
|
|
33
|
+
return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
34
|
+
}
|
|
35
|
+
return node.annotations;
|
|
36
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CheckAnnotation } from "../types.mts";
|
|
2
|
+
export interface RawCheckAnnotation {
|
|
3
|
+
fullDatabaseId: string | null;
|
|
4
|
+
path: string;
|
|
5
|
+
annotationLevel: string;
|
|
6
|
+
title: string | null;
|
|
7
|
+
message: string;
|
|
8
|
+
rawDetails: string | null;
|
|
9
|
+
blobUrl: string | null;
|
|
10
|
+
location: {
|
|
11
|
+
start: {
|
|
12
|
+
line: number | null;
|
|
13
|
+
column: number | null;
|
|
14
|
+
};
|
|
15
|
+
end: {
|
|
16
|
+
line: number | null;
|
|
17
|
+
column: number | null;
|
|
18
|
+
};
|
|
19
|
+
} | null;
|
|
20
|
+
}
|
|
21
|
+
export declare function toCheckAnnotation(checkRunId: string, raw: RawCheckAnnotation): CheckAnnotation;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const ANNOTATION_TEXT_MAX_CHARS = 4_000;
|
|
3
|
+
const TRUNCATED_SUFFIX = "\n[truncated]";
|
|
4
|
+
export function toCheckAnnotation(checkRunId, raw) {
|
|
5
|
+
const id = `check_annotation_${raw.fullDatabaseId ?? fallbackId(checkRunId, raw)}`;
|
|
6
|
+
const title = raw.title?.trim() || undefined;
|
|
7
|
+
const rawDetails = raw.rawDetails?.trim() || undefined;
|
|
8
|
+
const blobUrl = raw.blobUrl?.trim() || undefined;
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
path: raw.path,
|
|
12
|
+
startLine: raw.location?.start.line ?? null,
|
|
13
|
+
endLine: raw.location?.end.line ?? raw.location?.start.line ?? null,
|
|
14
|
+
...(raw.location?.start.column !== undefined && {
|
|
15
|
+
startColumn: raw.location.start.column,
|
|
16
|
+
}),
|
|
17
|
+
...(raw.location?.end.column !== undefined && {
|
|
18
|
+
endColumn: raw.location.end.column,
|
|
19
|
+
}),
|
|
20
|
+
level: raw.annotationLevel,
|
|
21
|
+
...(title !== undefined && { title }),
|
|
22
|
+
message: truncateAnnotationText(raw.message),
|
|
23
|
+
...(rawDetails !== undefined && { rawDetails: truncateAnnotationText(rawDetails) }),
|
|
24
|
+
...(blobUrl !== undefined && { blobUrl }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function truncateAnnotationText(text) {
|
|
28
|
+
if (text.length <= ANNOTATION_TEXT_MAX_CHARS)
|
|
29
|
+
return text;
|
|
30
|
+
return `${text.slice(0, ANNOTATION_TEXT_MAX_CHARS - TRUNCATED_SUFFIX.length).trimEnd()}${TRUNCATED_SUFFIX}`;
|
|
31
|
+
}
|
|
32
|
+
function fallbackId(checkRunId, raw) {
|
|
33
|
+
const start = raw.location?.start;
|
|
34
|
+
const end = raw.location?.end;
|
|
35
|
+
const parts = [
|
|
36
|
+
checkRunId,
|
|
37
|
+
raw.path,
|
|
38
|
+
raw.annotationLevel,
|
|
39
|
+
raw.title ?? "",
|
|
40
|
+
raw.message,
|
|
41
|
+
raw.rawDetails ?? "",
|
|
42
|
+
raw.blobUrl ?? "",
|
|
43
|
+
String(start?.line ?? ""),
|
|
44
|
+
String(start?.column ?? ""),
|
|
45
|
+
String(end?.line ?? ""),
|
|
46
|
+
String(end?.column ?? ""),
|
|
47
|
+
];
|
|
48
|
+
const input = parts.map((part) => `${part.length}:${part}`).join("|");
|
|
49
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 24);
|
|
50
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CheckAnnotation } from "../types.mts";
|
|
2
|
+
import { type AnnotationCacheOptions } from "./check-annotation-cache.mts";
|
|
3
|
+
interface AnnotationBatchFailure {
|
|
4
|
+
checkRunId: string;
|
|
5
|
+
error: unknown;
|
|
6
|
+
}
|
|
7
|
+
interface CheckAnnotationBatchResult {
|
|
8
|
+
annotations: Map<string, CheckAnnotation[]>;
|
|
9
|
+
failures: AnnotationBatchFailure[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* First annotation page for every uncached id, in chunks of 20. Further pages
|
|
13
|
+
* use the single-node query and only run when that first page has `hasNextPage`.
|
|
14
|
+
* A retryable rate limit aborts the remaining chunks and pages.
|
|
15
|
+
*/
|
|
16
|
+
export declare function fetchCheckRunAnnotationsBatch(checkRunIds: string[], cacheOpts?: AnnotationCacheOptions): Promise<CheckAnnotationBatchResult>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { pollRateLimitRetryAfterMs } from "../commands/poll-quota.mjs";
|
|
2
|
+
import { readFreshCheckAnnotations, } from "./check-annotation-cache.mjs";
|
|
3
|
+
import {} from "./check-annotation-shape.mjs";
|
|
4
|
+
import { fetchCheckRunAnnotations } from "./check-annotations.mjs";
|
|
5
|
+
import { graphql } from "./client.mjs";
|
|
6
|
+
import { CHECK_RUN_ANNOTATIONS_BATCH_QUERY } from "./queries.mjs";
|
|
7
|
+
/**
|
|
8
|
+
* One `nodes` connection plus one nested `annotations(first: 100)` per id.
|
|
9
|
+
* 20 ids are 21 connection-requests, which GitHub prices as 1 point.
|
|
10
|
+
*/
|
|
11
|
+
const ANNOTATION_BATCH_CHUNK_SIZE = 20;
|
|
12
|
+
function chunksOf(items, size) {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for (let index = 0; index < items.length; index += size) {
|
|
15
|
+
chunks.push(items.slice(index, index + size));
|
|
16
|
+
}
|
|
17
|
+
return chunks;
|
|
18
|
+
}
|
|
19
|
+
function retryableRateLimit(err) {
|
|
20
|
+
return pollRateLimitRetryAfterMs(err) !== null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* First annotation page for every uncached id, in chunks of 20. Further pages
|
|
24
|
+
* use the single-node query and only run when that first page has `hasNextPage`.
|
|
25
|
+
* A retryable rate limit aborts the remaining chunks and pages.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchCheckRunAnnotationsBatch(checkRunIds, cacheOpts) {
|
|
28
|
+
const annotations = new Map();
|
|
29
|
+
const failures = [];
|
|
30
|
+
const cached = await Promise.all(checkRunIds.map((id) => readFreshCheckAnnotations(id, cacheOpts)));
|
|
31
|
+
const uncached = [];
|
|
32
|
+
checkRunIds.forEach((id, index) => {
|
|
33
|
+
const hit = cached[index];
|
|
34
|
+
if (hit)
|
|
35
|
+
annotations.set(id, hit);
|
|
36
|
+
else
|
|
37
|
+
uncached.push(id);
|
|
38
|
+
});
|
|
39
|
+
for (const chunk of chunksOf(uncached, ANNOTATION_BATCH_CHUNK_SIZE)) {
|
|
40
|
+
// eslint-disable-next-line no-await-in-loop
|
|
41
|
+
await fetchChunk(chunk, annotations, failures, cacheOpts);
|
|
42
|
+
}
|
|
43
|
+
return { annotations, failures };
|
|
44
|
+
}
|
|
45
|
+
async function fetchChunk(chunk, annotations, failures, cacheOpts) {
|
|
46
|
+
try {
|
|
47
|
+
const nodes = await requestFirstPages(chunk);
|
|
48
|
+
await applyBatchNodes(chunk, nodes, annotations, failures, cacheOpts);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (retryableRateLimit(err))
|
|
52
|
+
throw err;
|
|
53
|
+
// A non-rate-limit batch failure (one NOT_FOUND or FORBIDDEN node rejects
|
|
54
|
+
// the whole `nodes(ids:)` request) must not drop the other ids.
|
|
55
|
+
await fetchChunkOneByOne(chunk, annotations, failures, cacheOpts);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function applyBatchNodes(chunk, nodes, annotations, failures, cacheOpts) {
|
|
59
|
+
for (const id of chunk) {
|
|
60
|
+
const node = nodes.find((candidate) => candidate?.id === id) ?? null;
|
|
61
|
+
if (node?.__typename !== "CheckRun" || node.annotations === undefined) {
|
|
62
|
+
failures.push({ checkRunId: id, error: new Error("check run annotations unavailable") });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
// eslint-disable-next-line no-await-in-loop
|
|
67
|
+
annotations.set(id, await fetchCheckRunAnnotations(id, cacheOpts, node.annotations));
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (retryableRateLimit(err))
|
|
71
|
+
throw err;
|
|
72
|
+
failures.push({ checkRunId: id, error: err });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function fetchChunkOneByOne(chunk, annotations, failures, cacheOpts) {
|
|
77
|
+
for (const id of chunk) {
|
|
78
|
+
try {
|
|
79
|
+
// eslint-disable-next-line no-await-in-loop
|
|
80
|
+
annotations.set(id, await fetchCheckRunAnnotations(id, cacheOpts));
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
if (retryableRateLimit(err))
|
|
84
|
+
throw err;
|
|
85
|
+
failures.push({ checkRunId: id, error: err });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function requestFirstPages(ids) {
|
|
90
|
+
const res = await graphql(CHECK_RUN_ANNOTATIONS_BATCH_QUERY, { ids }, { allowPartialData: true });
|
|
91
|
+
return res.data.nodes ?? [];
|
|
92
|
+
}
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type AnnotationCacheOptions } from "./check-annotation-cache.mts";
|
|
2
|
+
import { type AnnotationPage } from "./check-annotation-pages.mts";
|
|
2
3
|
import type { CheckAnnotation } from "../types.mts";
|
|
3
|
-
export interface AnnotationCacheOptions {
|
|
4
|
-
stateKey: StateKey;
|
|
5
|
-
headSha?: string;
|
|
6
|
-
}
|
|
7
4
|
/**
|
|
8
|
-
* Fetches all inline annotations for
|
|
5
|
+
* Fetches all inline annotations for one check run.
|
|
9
6
|
*
|
|
10
|
-
* When `cacheOpts` is provided, the result is cached by `checkRunId
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* When `cacheOpts` is provided, the result is cached by `checkRunId`. Callers
|
|
8
|
+
* only pass `cacheOpts` for COMPLETED runs (a re-run mints a new node id).
|
|
9
|
+
* Prefer `fetchCheckRunAnnotationsBatch` when attaching many checks at once;
|
|
10
|
+
* this single-node query remains for direct callers and follow-up pages.
|
|
14
11
|
*/
|
|
15
|
-
export declare function fetchCheckRunAnnotations(checkRunId: string, cacheOpts?: AnnotationCacheOptions): Promise<CheckAnnotation[]>;
|
|
12
|
+
export declare function fetchCheckRunAnnotations(checkRunId: string, cacheOpts?: AnnotationCacheOptions, initialPage?: AnnotationPage): Promise<CheckAnnotation[]>;
|
|
@@ -1,109 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { CHECK_RUN_ANNOTATIONS_QUERY } from "./queries.mjs";
|
|
4
|
-
import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
|
|
5
|
-
const ANNOTATIONS_PER_PAGE = 100;
|
|
6
|
-
const MAX_ANNOTATION_PAGES = 10;
|
|
7
|
-
const ANNOTATION_TEXT_MAX_CHARS = 4_000;
|
|
8
|
-
const TRUNCATED_SUFFIX = "\n[truncated]";
|
|
1
|
+
import { readFreshCheckAnnotations, storeCheckAnnotations, } from "./check-annotation-cache.mjs";
|
|
2
|
+
import { collectAnnotations } from "./check-annotation-pages.mjs";
|
|
9
3
|
/**
|
|
10
|
-
*
|
|
11
|
-
* onto an already-COMPLETED check run without minting a new check-run node
|
|
12
|
-
* ID, so "COMPLETED" is not a reliable immutability signal on its own. Bound
|
|
13
|
-
* the cache instead of trusting it forever, so a long-running poll session
|
|
14
|
-
* eventually revalidates.
|
|
15
|
-
*/
|
|
16
|
-
const ANNOTATION_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
17
|
-
/**
|
|
18
|
-
* Fetches all inline annotations for a check run.
|
|
4
|
+
* Fetches all inline annotations for one check run.
|
|
19
5
|
*
|
|
20
|
-
* When `cacheOpts` is provided, the result is cached by `checkRunId
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
6
|
+
* When `cacheOpts` is provided, the result is cached by `checkRunId`. Callers
|
|
7
|
+
* only pass `cacheOpts` for COMPLETED runs (a re-run mints a new node id).
|
|
8
|
+
* Prefer `fetchCheckRunAnnotationsBatch` when attaching many checks at once;
|
|
9
|
+
* this single-node query remains for direct callers and follow-up pages.
|
|
24
10
|
*/
|
|
25
|
-
export async function fetchCheckRunAnnotations(checkRunId, cacheOpts) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return cached.value;
|
|
31
|
-
}
|
|
32
|
-
let cursor = null;
|
|
33
|
-
const nodes = [];
|
|
34
|
-
for (let page = 1; page <= MAX_ANNOTATION_PAGES; page++) {
|
|
35
|
-
// eslint-disable-next-line no-await-in-loop
|
|
36
|
-
const result = await fetchAnnotationPage(checkRunId, cursor);
|
|
37
|
-
nodes.push(...result.nodes);
|
|
38
|
-
if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor)
|
|
39
|
-
break;
|
|
40
|
-
if (page === MAX_ANNOTATION_PAGES) {
|
|
41
|
-
process.stderr.write(`pr-shepherd: annotation pagination cap (${MAX_ANNOTATION_PAGES * ANNOTATIONS_PER_PAGE} annotations) reached for check run ${checkRunId} — annotation output may be incomplete\n`);
|
|
42
|
-
break;
|
|
43
|
-
}
|
|
44
|
-
cursor = result.pageInfo.endCursor;
|
|
45
|
-
}
|
|
46
|
-
const annotations = nodes.map((node) => toCheckAnnotation(checkRunId, node));
|
|
47
|
-
if (cacheOpts) {
|
|
48
|
-
await storeDerived(cacheOpts.stateKey, cacheName, annotations, cacheOpts.headSha);
|
|
11
|
+
export async function fetchCheckRunAnnotations(checkRunId, cacheOpts, initialPage) {
|
|
12
|
+
if (initialPage === undefined) {
|
|
13
|
+
const cached = await readFreshCheckAnnotations(checkRunId, cacheOpts);
|
|
14
|
+
if (cached)
|
|
15
|
+
return cached;
|
|
49
16
|
}
|
|
17
|
+
const annotations = await collectAnnotations(checkRunId, initialPage);
|
|
18
|
+
await storeCheckAnnotations(checkRunId, annotations, cacheOpts);
|
|
50
19
|
return annotations;
|
|
51
20
|
}
|
|
52
|
-
async function fetchAnnotationPage(checkRunId, cursor) {
|
|
53
|
-
const res = await graphql(CHECK_RUN_ANNOTATIONS_QUERY, {
|
|
54
|
-
id: checkRunId,
|
|
55
|
-
...(cursor ? { cursor } : {}),
|
|
56
|
-
});
|
|
57
|
-
const node = res.data.node;
|
|
58
|
-
if (node?.__typename !== "CheckRun" || node.annotations === undefined) {
|
|
59
|
-
return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
60
|
-
}
|
|
61
|
-
return node.annotations;
|
|
62
|
-
}
|
|
63
|
-
function toCheckAnnotation(checkRunId, raw) {
|
|
64
|
-
const id = `check_annotation_${raw.fullDatabaseId ?? fallbackId(checkRunId, raw)}`;
|
|
65
|
-
const title = raw.title?.trim() || undefined;
|
|
66
|
-
const rawDetails = raw.rawDetails?.trim() || undefined;
|
|
67
|
-
const blobUrl = raw.blobUrl?.trim() || undefined;
|
|
68
|
-
return {
|
|
69
|
-
id,
|
|
70
|
-
path: raw.path,
|
|
71
|
-
startLine: raw.location?.start.line ?? null,
|
|
72
|
-
endLine: raw.location?.end.line ?? raw.location?.start.line ?? null,
|
|
73
|
-
...(raw.location?.start.column !== undefined && {
|
|
74
|
-
startColumn: raw.location.start.column,
|
|
75
|
-
}),
|
|
76
|
-
...(raw.location?.end.column !== undefined && {
|
|
77
|
-
endColumn: raw.location.end.column,
|
|
78
|
-
}),
|
|
79
|
-
level: raw.annotationLevel,
|
|
80
|
-
...(title !== undefined && { title }),
|
|
81
|
-
message: truncateAnnotationText(raw.message),
|
|
82
|
-
...(rawDetails !== undefined && { rawDetails: truncateAnnotationText(rawDetails) }),
|
|
83
|
-
...(blobUrl !== undefined && { blobUrl }),
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
function truncateAnnotationText(text) {
|
|
87
|
-
if (text.length <= ANNOTATION_TEXT_MAX_CHARS)
|
|
88
|
-
return text;
|
|
89
|
-
return `${text.slice(0, ANNOTATION_TEXT_MAX_CHARS - TRUNCATED_SUFFIX.length).trimEnd()}${TRUNCATED_SUFFIX}`;
|
|
90
|
-
}
|
|
91
|
-
function fallbackId(checkRunId, raw) {
|
|
92
|
-
const start = raw.location?.start;
|
|
93
|
-
const end = raw.location?.end;
|
|
94
|
-
const parts = [
|
|
95
|
-
checkRunId,
|
|
96
|
-
raw.path,
|
|
97
|
-
raw.annotationLevel,
|
|
98
|
-
raw.title ?? "",
|
|
99
|
-
raw.message,
|
|
100
|
-
raw.rawDetails ?? "",
|
|
101
|
-
raw.blobUrl ?? "",
|
|
102
|
-
String(start?.line ?? ""),
|
|
103
|
-
String(start?.column ?? ""),
|
|
104
|
-
String(end?.line ?? ""),
|
|
105
|
-
String(end?.column ?? ""),
|
|
106
|
-
];
|
|
107
|
-
const input = parts.map((part) => `${part.length}:${part}`).join("|");
|
|
108
|
-
return createHash("sha256").update(input).digest("hex").slice(0, 24);
|
|
109
|
-
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
query CheckRunAnnotationsBatch($ids: [ID!]!) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
10
|
+
nodes(ids: $ids) {
|
|
11
|
+
id
|
|
12
|
+
__typename
|
|
13
|
+
... on CheckRun {
|
|
14
|
+
annotations(first: 100) {
|
|
15
|
+
pageInfo {
|
|
16
|
+
hasNextPage
|
|
17
|
+
endCursor
|
|
18
|
+
}
|
|
19
|
+
nodes {
|
|
20
|
+
fullDatabaseId
|
|
21
|
+
path
|
|
22
|
+
annotationLevel
|
|
23
|
+
title
|
|
24
|
+
message
|
|
25
|
+
rawDetails
|
|
26
|
+
blobUrl
|
|
27
|
+
location {
|
|
28
|
+
start {
|
|
29
|
+
line
|
|
30
|
+
column
|
|
31
|
+
}
|
|
32
|
+
end {
|
|
33
|
+
line
|
|
34
|
+
column
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
query UpperLayerConflictTarget($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
10
|
+
repository(owner: $owner, name: $name) {
|
|
11
|
+
pullRequest(number: $number) {
|
|
12
|
+
# compare() is invoked on the PR base. behindBy is commits on that base
|
|
13
|
+
# that the head does not contain — the same direction as REST base...head.
|
|
14
|
+
baseRef {
|
|
15
|
+
compare(headRef: $headRef) {
|
|
16
|
+
behindBy
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
stack {
|
|
20
|
+
entries(first: 100) {
|
|
21
|
+
pageInfo {
|
|
22
|
+
hasNextPage
|
|
23
|
+
}
|
|
24
|
+
nodes {
|
|
25
|
+
position
|
|
26
|
+
pullRequest {
|
|
27
|
+
number
|
|
28
|
+
state
|
|
29
|
+
baseRefName
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -33,7 +33,7 @@ export declare function paginateForward<T>(fetchFn: (cursor: string | null) => P
|
|
|
33
33
|
/**
|
|
34
34
|
* Paginate backward through a GraphQL connection (`last` / `before` cursors).
|
|
35
35
|
*
|
|
36
|
-
* Used for `reviewThreads(last: 100, before: $before)
|
|
36
|
+
* Used for `reviewThreads(last: 100, before: $before)` follow-up pages.
|
|
37
37
|
*
|
|
38
38
|
* @param fetchFn Called once per page. Receives the cursor (or null for
|
|
39
39
|
* the very first page). Returns a Connection<T> with
|
|
@@ -35,7 +35,7 @@ export async function paginateForward(fetchFn, initialCursor) {
|
|
|
35
35
|
/**
|
|
36
36
|
* Paginate backward through a GraphQL connection (`last` / `before` cursors).
|
|
37
37
|
*
|
|
38
|
-
* Used for `reviewThreads(last: 100, before: $before)
|
|
38
|
+
* Used for `reviewThreads(last: 100, before: $before)` follow-up pages.
|
|
39
39
|
*
|
|
40
40
|
* @param fetchFn Called once per page. Receives the cursor (or null for
|
|
41
41
|
* the very first page). Returns a Connection<T> with
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PollSummaryChecks, PollSummaryItem, PollSummaryReview } from "../types.mts";
|
|
2
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
+
type Routed = Pick<PollSummaryItem, "action" | "reasons">;
|
|
4
|
+
/**
|
|
5
|
+
* A layer whose only failing checks are deferred on open blockers waits.
|
|
6
|
+
* Review work, conflicts, an incomplete rollup, or any check that is not
|
|
7
|
+
* deferred stay on the routed action. Stack rows are probed so the planner
|
|
8
|
+
* does not start a one-PR session for a wait it would otherwise shepherd.
|
|
9
|
+
*/
|
|
10
|
+
export declare function applyOpenCheckBlockers(raw: RawSummaryPr, repo: {
|
|
11
|
+
owner: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}, routed: Routed, checks: PollSummaryChecks, review: PollSummaryReview, stack: boolean): Promise<Routed & {
|
|
14
|
+
pollProbe?: true;
|
|
15
|
+
}>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolveCheckBlockerGate } from "../commands/iterate/check-blocker-gate.mjs";
|
|
2
|
+
import { failingSummaryCheckNames } from "./poll-summary-checks.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* A layer whose only failing checks are deferred on open blockers waits.
|
|
5
|
+
* Review work, conflicts, an incomplete rollup, or any check that is not
|
|
6
|
+
* deferred stay on the routed action. Stack rows are probed so the planner
|
|
7
|
+
* does not start a one-PR session for a wait it would otherwise shepherd.
|
|
8
|
+
*/
|
|
9
|
+
export async function applyOpenCheckBlockers(raw, repo, routed, checks, review, stack) {
|
|
10
|
+
if (routed.action !== "fix_code" || !routed.reasons.includes("failing-checks"))
|
|
11
|
+
return routed;
|
|
12
|
+
if ((review.actionable ?? 0) > 0 || checks.incomplete)
|
|
13
|
+
return routed;
|
|
14
|
+
const names = failingSummaryCheckNames(raw);
|
|
15
|
+
if (names.length === 0)
|
|
16
|
+
return routed;
|
|
17
|
+
const gate = await resolveCheckBlockerGate({ owner: repo.owner, repo: repo.name, pr: raw.number }, names.map((name) => ({ name })));
|
|
18
|
+
if (!gate || !names.every((name) => gate.deferredNames.has(name)))
|
|
19
|
+
return routed;
|
|
20
|
+
return {
|
|
21
|
+
action: "wait",
|
|
22
|
+
reasons: gate.openBlockers.map((ref) => `blocked-by:${ref}`),
|
|
23
|
+
...(stack ? { pollProbe: true } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { PollSummaryChecks } from "../types.mts";
|
|
2
2
|
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
3
|
export declare function summarizePollSummaryChecks(raw: RawSummaryPr): PollSummaryChecks;
|
|
4
|
+
/** Names of failing checks in the loaded summary rollup. */
|
|
5
|
+
export declare function failingSummaryCheckNames(raw: RawSummaryPr): string[];
|
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import { classifyChecks } from "../checks/classify.mjs";
|
|
2
2
|
export function summarizePollSummaryChecks(raw) {
|
|
3
|
-
const rollups =
|
|
3
|
+
const rollups = summaryRollups(raw);
|
|
4
|
+
const counts = {};
|
|
5
|
+
for (const check of classifyChecks(checkRuns(rollups), {
|
|
6
|
+
additionalRelevantEvents: ["merge_group"],
|
|
7
|
+
})) {
|
|
8
|
+
const key = {
|
|
9
|
+
passed: "passing",
|
|
10
|
+
failing: "failing",
|
|
11
|
+
in_progress: "inProgress",
|
|
12
|
+
skipped: "skipped",
|
|
13
|
+
filtered: "filtered",
|
|
14
|
+
ignored: "ignored",
|
|
15
|
+
superseded: "superseded",
|
|
16
|
+
}[check.category];
|
|
17
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
18
|
+
}
|
|
19
|
+
const summary = counts;
|
|
20
|
+
if (rollups.some((rollup) => rollup.contexts.pageInfo.hasPreviousPage)) {
|
|
21
|
+
summary.incomplete = true;
|
|
22
|
+
}
|
|
23
|
+
return summary;
|
|
24
|
+
}
|
|
25
|
+
/** Names of failing checks in the loaded summary rollup. */
|
|
26
|
+
export function failingSummaryCheckNames(raw) {
|
|
27
|
+
return classifyChecks(checkRuns(summaryRollups(raw)), {
|
|
28
|
+
additionalRelevantEvents: ["merge_group"],
|
|
29
|
+
})
|
|
30
|
+
.filter((check) => check.category === "failing")
|
|
31
|
+
.map((check) => check.name);
|
|
32
|
+
}
|
|
33
|
+
function summaryRollups(raw) {
|
|
34
|
+
return [
|
|
4
35
|
raw.commits.nodes[0]?.commit.statusCheckRollup,
|
|
5
36
|
raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
|
|
6
37
|
].filter((rollup) => rollup !== null && rollup !== undefined);
|
|
7
|
-
|
|
38
|
+
}
|
|
39
|
+
function checkRuns(rollups) {
|
|
40
|
+
return rollups.flatMap((rollup, rollupIndex) => rollup.contexts.nodes.map((context) => {
|
|
8
41
|
if (context.__typename === "StatusContext") {
|
|
9
42
|
return {
|
|
10
43
|
name: context.context,
|
|
@@ -40,22 +73,4 @@ export function summarizePollSummaryChecks(raw) {
|
|
|
40
73
|
...(rollupIndex === 1 && { scope: "merge_group" }),
|
|
41
74
|
};
|
|
42
75
|
}));
|
|
43
|
-
const counts = {};
|
|
44
|
-
for (const check of classifyChecks(checks, { additionalRelevantEvents: ["merge_group"] })) {
|
|
45
|
-
const key = {
|
|
46
|
-
passed: "passing",
|
|
47
|
-
failing: "failing",
|
|
48
|
-
in_progress: "inProgress",
|
|
49
|
-
skipped: "skipped",
|
|
50
|
-
filtered: "filtered",
|
|
51
|
-
ignored: "ignored",
|
|
52
|
-
superseded: "superseded",
|
|
53
|
-
}[check.category];
|
|
54
|
-
counts[key] = (counts[key] ?? 0) + 1;
|
|
55
|
-
}
|
|
56
|
-
const summary = counts;
|
|
57
|
-
if (rollups.some((rollup) => rollup.contexts.pageInfo.hasPreviousPage)) {
|
|
58
|
-
summary.incomplete = true;
|
|
59
|
-
}
|
|
60
|
-
return summary;
|
|
61
76
|
}
|