pr-shepherd 0.35.0 → 0.36.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/README.md +32 -12
- package/bin/checks/triage.mjs +11 -3
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.mjs +1 -0
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +10 -5
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +2 -1
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-formatter.mjs +11 -2
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/poll-handler.mjs +7 -0
- package/bin/commands/check.d.mts +1 -0
- package/bin/commands/check.mjs +26 -22
- package/bin/commands/commit-suggestion.mjs +11 -5
- package/bin/commands/iterate/fix-code.mjs +1 -1
- package/bin/commands/iterate/helpers.d.mts +1 -0
- package/bin/commands/iterate/helpers.mjs +15 -6
- package/bin/commands/iterate/index.mjs +3 -10
- package/bin/commands/poll.d.mts +2 -0
- package/bin/commands/poll.mjs +51 -17
- package/bin/commands/ready-delay.mjs +3 -13
- package/bin/config/load.mjs +67 -25
- package/bin/github/batch-page-helpers.d.mts +45 -0
- package/bin/github/batch-page-helpers.mjs +63 -0
- package/bin/github/batch-page.d.mts +14 -0
- package/bin/github/batch-page.mjs +62 -0
- package/bin/github/batch-parse-suites.d.mts +4 -0
- package/bin/github/batch-parse-suites.mjs +25 -0
- package/bin/github/batch-parsers-rules.d.mts +6 -0
- package/bin/github/batch-parsers-rules.mjs +122 -0
- package/bin/github/batch-parsers.mjs +6 -0
- package/bin/github/batch-raw-rules.d.mts +59 -0
- package/bin/github/batch-raw-rules.mjs +1 -0
- package/bin/github/batch-raw-types.d.mts +20 -11
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +14 -120
- package/bin/github/gql/batch-pr-page.gql +189 -0
- package/bin/github/gql/batch-pr.gql +74 -21
- package/bin/github/gql/commit-suggestion-thread.gql +40 -0
- package/bin/github/http.d.mts +2 -1
- package/bin/github/http.mjs +2 -1
- package/bin/github/pagination.d.mts +3 -2
- package/bin/github/pagination.mjs +3 -2
- package/bin/github/queries.d.mts +4 -0
- package/bin/github/queries.mjs +4 -0
- package/bin/github/rest-http.d.mts +6 -1
- package/bin/github/rest-http.mjs +25 -86
- package/bin/github/rest-text.d.mts +1 -0
- package/bin/github/rest-text.mjs +88 -0
- package/bin/github/suggestion-thread.d.mts +9 -0
- package/bin/github/suggestion-thread.mjs +45 -0
- package/bin/github/thread-comments.mjs +12 -8
- package/bin/merge-status/derive.mjs +2 -0
- package/bin/merge-status/requirements-format.d.mts +3 -0
- package/bin/merge-status/requirements-format.mjs +88 -0
- package/bin/merge-status/requirements.d.mts +2 -0
- package/bin/merge-status/requirements.mjs +51 -0
- package/bin/state/base.d.mts +9 -0
- package/bin/state/base.mjs +23 -0
- package/bin/state/bot-cr-seen.mjs +3 -13
- package/bin/state/fix-attempts.mjs +3 -13
- package/bin/state/iterate-stall.mjs +3 -13
- package/bin/state/seen-comments.mjs +6 -13
- package/bin/types/github.d.mts +4 -7
- package/bin/types/iterate.d.mts +6 -7
- package/bin/types/merge-requirements.d.mts +82 -0
- package/bin/types/merge-requirements.mjs +2 -0
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- package/bin/util/path-segment.d.mts +2 -0
- package/bin/util/path-segment.mjs +2 -0
- package/bin/util/pool.d.mts +2 -0
- package/bin/util/pool.mjs +18 -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
package/bin/github/http.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { GitHubRequestError } from "./errors.mts";
|
|
2
2
|
export { _resetTokenCache } from "./http-auth.mts";
|
|
3
3
|
export { graphql, graphqlWithRateLimit } from "./graphql-http.mts";
|
|
4
|
-
export { rest,
|
|
4
|
+
export { rest, restWithRateLimit } from "./rest-http.mts";
|
|
5
|
+
export { restText } from "./rest-text.mts";
|
|
5
6
|
export type { RateLimitInfo } from "./http-utils.mts";
|
package/bin/github/http.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { GitHubRequestError } from "./errors.mjs";
|
|
2
2
|
export { _resetTokenCache } from "./http-auth.mjs";
|
|
3
3
|
export { graphql, graphqlWithRateLimit } from "./graphql-http.mjs";
|
|
4
|
-
export { rest,
|
|
4
|
+
export { rest, restWithRateLimit } from "./rest-http.mjs";
|
|
5
|
+
export { restText } from "./rest-text.mjs";
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* that returns pages of fake data.
|
|
7
7
|
*
|
|
8
8
|
* GitHub's GraphQL connections support two cursor directions:
|
|
9
|
-
* - Forward (`after` + `first`) — used by check contexts.
|
|
10
|
-
* - Backward (`before` + `last`) — used by reviewThreads
|
|
9
|
+
* - Forward (`after` + `first`) — used by check contexts and nested thread comments.
|
|
10
|
+
* - Backward (`before` + `last`) — used by reviewThreads/comments/reviews extra pages
|
|
11
|
+
* (combined in `batch-page.mts`; `paginateBackward` remains the generic primitive).
|
|
11
12
|
*/
|
|
12
13
|
interface PageInfo {
|
|
13
14
|
hasNextPage?: boolean;
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* that returns pages of fake data.
|
|
7
7
|
*
|
|
8
8
|
* GitHub's GraphQL connections support two cursor directions:
|
|
9
|
-
* - Forward (`after` + `first`) — used by check contexts.
|
|
10
|
-
* - Backward (`before` + `last`) — used by reviewThreads
|
|
9
|
+
* - Forward (`after` + `first`) — used by check contexts and nested thread comments.
|
|
10
|
+
* - Backward (`before` + `last`) — used by reviewThreads/comments/reviews extra pages
|
|
11
|
+
* (combined in `batch-page.mts`; `paginateBackward` remains the generic primitive).
|
|
11
12
|
*/
|
|
12
13
|
/**
|
|
13
14
|
* Paginate forward through a GraphQL connection (`first` / `after` cursors).
|
package/bin/github/queries.d.mts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
/** The primary batch query that fetches CI + comments + merge status in one round-trip. */
|
|
10
10
|
export declare const BATCH_PR_QUERY: string;
|
|
11
|
+
/** Slim @include follow-up for outstanding batch-query connections. */
|
|
12
|
+
export declare const BATCH_PR_PAGE_QUERY: string;
|
|
13
|
+
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
14
|
+
export declare const COMMIT_SUGGESTION_THREAD_QUERY: string;
|
|
11
15
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
|
12
16
|
export declare const REVIEW_THREAD_COMMENTS_QUERY: string;
|
|
13
17
|
/** Fetch inline annotations for a single CheckRun by node ID. */
|
package/bin/github/queries.mjs
CHANGED
|
@@ -11,6 +11,10 @@ import { join } from "node:path";
|
|
|
11
11
|
const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
|
|
12
12
|
/** The primary batch query that fetches CI + comments + merge status in one round-trip. */
|
|
13
13
|
export const BATCH_PR_QUERY = gql("batch-pr.gql");
|
|
14
|
+
/** Slim @include follow-up for outstanding batch-query connections. */
|
|
15
|
+
export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
|
|
16
|
+
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
17
|
+
export const COMMIT_SUGGESTION_THREAD_QUERY = gql("commit-suggestion-thread.gql");
|
|
14
18
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
|
15
19
|
export const REVIEW_THREAD_COMMENTS_QUERY = gql("review-thread-comments.gql");
|
|
16
20
|
/** Fetch inline annotations for a single CheckRun by node ID. */
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
+
import { type RateLimitInfo } from "./http-utils.mts";
|
|
2
|
+
export interface RestResult<T = unknown> {
|
|
3
|
+
data: T;
|
|
4
|
+
rateLimit?: RateLimitInfo;
|
|
5
|
+
}
|
|
1
6
|
export declare function rest<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
|
|
2
|
-
export declare function
|
|
7
|
+
export declare function restWithRateLimit<T = unknown>(method: string, path: string, body?: unknown): Promise<RestResult<T>>;
|
package/bin/github/rest-http.mjs
CHANGED
|
@@ -3,10 +3,29 @@ import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
|
3
3
|
import { GitHubRequestError } from "./errors.mjs";
|
|
4
4
|
import { makeHeaders } from "./http-auth.mjs";
|
|
5
5
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
6
|
-
import { parseRateLimit, parseRetryAfter, redactToken,
|
|
6
|
+
import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
|
|
7
7
|
const BASE_URL = "https://api.github.com";
|
|
8
|
+
const SAFE_GITHUB_REST_PATH = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/?-]*$/;
|
|
9
|
+
function githubApiUrl(path) {
|
|
10
|
+
if (!SAFE_GITHUB_REST_PATH.test(path) || path.includes("://")) {
|
|
11
|
+
throw new Error(`Invalid GitHub REST path: ${path}`);
|
|
12
|
+
}
|
|
13
|
+
const url = new URL(path, `${BASE_URL}/`);
|
|
14
|
+
if (url.origin !== BASE_URL) {
|
|
15
|
+
throw new Error(`Invalid GitHub REST URL origin: ${url.origin}`);
|
|
16
|
+
}
|
|
17
|
+
const pathname = path.split("?")[0] ?? path;
|
|
18
|
+
if (pathname.split("/").some((seg) => seg === ".." || seg === ".")) {
|
|
19
|
+
throw new Error(`Invalid GitHub REST path: ${path}`);
|
|
20
|
+
}
|
|
21
|
+
return url.href;
|
|
22
|
+
}
|
|
8
23
|
export async function rest(method, path, body) {
|
|
9
|
-
const
|
|
24
|
+
const { data } = await restWithRateLimit(method, path, body);
|
|
25
|
+
return data;
|
|
26
|
+
}
|
|
27
|
+
export async function restWithRateLimit(method, path, body) {
|
|
28
|
+
const url = githubApiUrl(path);
|
|
10
29
|
const n = nextEntry();
|
|
11
30
|
appendEntry(formatRequestEntry({ n, kind: "REST", method, url, body }));
|
|
12
31
|
const t0 = performance.now();
|
|
@@ -17,6 +36,7 @@ export async function rest(method, path, body) {
|
|
|
17
36
|
}), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "REST", method, url, status, durationMs })));
|
|
18
37
|
const durationMs = Math.round(performance.now() - retryT0);
|
|
19
38
|
const ct = res.headers.get("content-type") ?? "";
|
|
39
|
+
const rateLimit = parseRateLimit(res.headers) ?? undefined;
|
|
20
40
|
if (!res.ok) {
|
|
21
41
|
const text = await res.text();
|
|
22
42
|
appendEntry(formatResponseEntry({
|
|
@@ -31,7 +51,7 @@ export async function rest(method, path, body) {
|
|
|
31
51
|
}));
|
|
32
52
|
throw new GitHubRequestError(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
|
|
33
53
|
status: res.status,
|
|
34
|
-
rateLimit
|
|
54
|
+
rateLimit,
|
|
35
55
|
retryAfterSeconds: parseRetryAfter(res.headers),
|
|
36
56
|
});
|
|
37
57
|
}
|
|
@@ -48,7 +68,7 @@ export async function rest(method, path, body) {
|
|
|
48
68
|
body: json,
|
|
49
69
|
attempt: attempt > 1 ? attempt : undefined,
|
|
50
70
|
}));
|
|
51
|
-
return json;
|
|
71
|
+
return { data: json, rateLimit };
|
|
52
72
|
}
|
|
53
73
|
appendEntry(formatResponseEntry({
|
|
54
74
|
n,
|
|
@@ -60,86 +80,5 @@ export async function rest(method, path, body) {
|
|
|
60
80
|
contentType: ct || undefined,
|
|
61
81
|
attempt: attempt > 1 ? attempt : undefined,
|
|
62
82
|
}));
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
65
|
-
export async function restText(path) {
|
|
66
|
-
const url = `${BASE_URL}${path}`;
|
|
67
|
-
const n = nextEntry();
|
|
68
|
-
appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
|
|
69
|
-
const t0 = performance.now();
|
|
70
|
-
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, { method: "GET", headers: await makeHeaders(), redirect: "manual" }), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "restText", method: "GET", url, status, durationMs })));
|
|
71
|
-
const durationMs = Math.round(performance.now() - retryT0);
|
|
72
|
-
if ([301, 302, 307, 308].includes(res.status)) {
|
|
73
|
-
const redirected = await followRestTextRedirect(res, { n, url, durationMs, attempt });
|
|
74
|
-
if (redirected !== null)
|
|
75
|
-
return redirected;
|
|
76
|
-
}
|
|
77
|
-
if (!res.ok) {
|
|
78
|
-
const text = await res.text();
|
|
79
|
-
appendEntry(formatResponseEntry({
|
|
80
|
-
n,
|
|
81
|
-
kind: "restText",
|
|
82
|
-
method: "GET",
|
|
83
|
-
url,
|
|
84
|
-
status: res.status,
|
|
85
|
-
durationMs,
|
|
86
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
87
|
-
}));
|
|
88
|
-
throw new GitHubRequestError(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
|
|
89
|
-
status: res.status,
|
|
90
|
-
rateLimit: parseRateLimit(res.headers) ?? undefined,
|
|
91
|
-
retryAfterSeconds: parseRetryAfter(res.headers),
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
appendEntry(formatResponseEntry({
|
|
95
|
-
n,
|
|
96
|
-
kind: "restText",
|
|
97
|
-
method: "GET",
|
|
98
|
-
url,
|
|
99
|
-
status: res.status,
|
|
100
|
-
durationMs,
|
|
101
|
-
contentLength: parseContentLength(res.headers),
|
|
102
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
103
|
-
}));
|
|
104
|
-
return res.text();
|
|
105
|
-
}
|
|
106
|
-
async function followRestTextRedirect(res, entry) {
|
|
107
|
-
appendEntry(formatResponseEntry({
|
|
108
|
-
n: entry.n,
|
|
109
|
-
kind: "restText",
|
|
110
|
-
method: "GET",
|
|
111
|
-
url: entry.url,
|
|
112
|
-
status: res.status,
|
|
113
|
-
durationMs: entry.durationMs,
|
|
114
|
-
attempt: entry.attempt > 1 ? entry.attempt : undefined,
|
|
115
|
-
}));
|
|
116
|
-
const location = res.headers.get("location");
|
|
117
|
-
if (!location)
|
|
118
|
-
return null;
|
|
119
|
-
const n2 = nextEntry();
|
|
120
|
-
const logUrl = redactUrl(location);
|
|
121
|
-
appendEntry(formatRequestEntry({ n: n2, kind: "restText", method: "GET", url: logUrl }));
|
|
122
|
-
const t1 = performance.now();
|
|
123
|
-
const redirectRes = await fetch(location);
|
|
124
|
-
appendEntry(formatResponseEntry({
|
|
125
|
-
n: n2,
|
|
126
|
-
kind: "restText",
|
|
127
|
-
method: "GET",
|
|
128
|
-
url: logUrl,
|
|
129
|
-
status: redirectRes.status,
|
|
130
|
-
durationMs: Math.round(performance.now() - t1),
|
|
131
|
-
contentLength: parseContentLength(redirectRes.headers),
|
|
132
|
-
}));
|
|
133
|
-
if (!redirectRes.ok) {
|
|
134
|
-
throw new GitHubRequestError(`redirect target ${location} failed: ${redirectRes.status}`, {
|
|
135
|
-
status: redirectRes.status,
|
|
136
|
-
rateLimit: parseRateLimit(redirectRes.headers) ?? undefined,
|
|
137
|
-
retryAfterSeconds: parseRetryAfter(redirectRes.headers),
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
return redirectRes.text();
|
|
141
|
-
}
|
|
142
|
-
function parseContentLength(headers) {
|
|
143
|
-
const raw = headers.get("content-length");
|
|
144
|
-
return raw !== null && Number.isFinite(Number(raw)) ? Number(raw) : undefined;
|
|
83
|
+
return { data: undefined, rateLimit };
|
|
145
84
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function restText(path: string): Promise<string>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { appendEntry, nextEntry } from "../log/log-file.mjs";
|
|
2
|
+
import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
3
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
4
|
+
import { makeHeaders } from "./http-auth.mjs";
|
|
5
|
+
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
6
|
+
import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody } from "./http-utils.mjs";
|
|
7
|
+
const BASE_URL = "https://api.github.com";
|
|
8
|
+
export async function restText(path) {
|
|
9
|
+
const url = `${BASE_URL}${path}`;
|
|
10
|
+
const n = nextEntry();
|
|
11
|
+
appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
|
|
12
|
+
const t0 = performance.now();
|
|
13
|
+
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, { method: "GET", headers: await makeHeaders(), redirect: "manual" }), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "restText", method: "GET", url, status, durationMs })));
|
|
14
|
+
const durationMs = Math.round(performance.now() - retryT0);
|
|
15
|
+
if ([301, 302, 307, 308].includes(res.status)) {
|
|
16
|
+
const redirected = await followRestTextRedirect(res, { n, url, durationMs, attempt });
|
|
17
|
+
if (redirected !== null)
|
|
18
|
+
return redirected;
|
|
19
|
+
}
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
appendEntry(formatResponseEntry({
|
|
23
|
+
n,
|
|
24
|
+
kind: "restText",
|
|
25
|
+
method: "GET",
|
|
26
|
+
url,
|
|
27
|
+
status: res.status,
|
|
28
|
+
durationMs,
|
|
29
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
30
|
+
}));
|
|
31
|
+
throw new GitHubRequestError(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
|
|
32
|
+
status: res.status,
|
|
33
|
+
rateLimit: parseRateLimit(res.headers) ?? undefined,
|
|
34
|
+
retryAfterSeconds: parseRetryAfter(res.headers),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
appendEntry(formatResponseEntry({
|
|
38
|
+
n,
|
|
39
|
+
kind: "restText",
|
|
40
|
+
method: "GET",
|
|
41
|
+
url,
|
|
42
|
+
status: res.status,
|
|
43
|
+
durationMs,
|
|
44
|
+
contentLength: parseContentLength(res.headers),
|
|
45
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
46
|
+
}));
|
|
47
|
+
return res.text();
|
|
48
|
+
}
|
|
49
|
+
async function followRestTextRedirect(res, entry) {
|
|
50
|
+
appendEntry(formatResponseEntry({
|
|
51
|
+
n: entry.n,
|
|
52
|
+
kind: "restText",
|
|
53
|
+
method: "GET",
|
|
54
|
+
url: entry.url,
|
|
55
|
+
status: res.status,
|
|
56
|
+
durationMs: entry.durationMs,
|
|
57
|
+
attempt: entry.attempt > 1 ? entry.attempt : undefined,
|
|
58
|
+
}));
|
|
59
|
+
const location = res.headers.get("location");
|
|
60
|
+
if (!location)
|
|
61
|
+
return null;
|
|
62
|
+
const n2 = nextEntry();
|
|
63
|
+
const logUrl = redactUrl(location);
|
|
64
|
+
appendEntry(formatRequestEntry({ n: n2, kind: "restText", method: "GET", url: logUrl }));
|
|
65
|
+
const t1 = performance.now();
|
|
66
|
+
const redirectRes = await fetch(location);
|
|
67
|
+
appendEntry(formatResponseEntry({
|
|
68
|
+
n: n2,
|
|
69
|
+
kind: "restText",
|
|
70
|
+
method: "GET",
|
|
71
|
+
url: logUrl,
|
|
72
|
+
status: redirectRes.status,
|
|
73
|
+
durationMs: Math.round(performance.now() - t1),
|
|
74
|
+
contentLength: parseContentLength(redirectRes.headers),
|
|
75
|
+
}));
|
|
76
|
+
if (!redirectRes.ok) {
|
|
77
|
+
throw new GitHubRequestError(`redirect target ${logUrl} failed: ${redirectRes.status}`, {
|
|
78
|
+
status: redirectRes.status,
|
|
79
|
+
rateLimit: parseRateLimit(redirectRes.headers) ?? undefined,
|
|
80
|
+
retryAfterSeconds: parseRetryAfter(redirectRes.headers),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return redirectRes.text();
|
|
84
|
+
}
|
|
85
|
+
function parseContentLength(headers) {
|
|
86
|
+
const raw = headers.get("content-length");
|
|
87
|
+
return raw !== null && Number.isFinite(Number(raw)) ? Number(raw) : undefined;
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReviewThread } from "../types.mts";
|
|
2
|
+
import type { RepoInfo } from "./client.mts";
|
|
3
|
+
export interface SuggestionThreadResult {
|
|
4
|
+
headRefOid: string;
|
|
5
|
+
headRefName: string;
|
|
6
|
+
headRepoWithOwner: string | null;
|
|
7
|
+
thread: ReviewThread | null;
|
|
8
|
+
}
|
|
9
|
+
export declare function fetchSuggestionThread(pr: number, repo: RepoInfo, threadId: string): Promise<SuggestionThreadResult>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { graphql } from "./client.mjs";
|
|
2
|
+
import { COMMIT_SUGGESTION_THREAD_QUERY } from "./queries.mjs";
|
|
3
|
+
import { mapAuthorType, parseCreatedAt } from "./batch-parser-helpers.mjs";
|
|
4
|
+
export async function fetchSuggestionThread(pr, repo, threadId) {
|
|
5
|
+
const result = await graphql(COMMIT_SUGGESTION_THREAD_QUERY, {
|
|
6
|
+
owner: repo.owner,
|
|
7
|
+
repo: repo.name,
|
|
8
|
+
pr,
|
|
9
|
+
threadId,
|
|
10
|
+
});
|
|
11
|
+
const pull = result.data.repository.pullRequest;
|
|
12
|
+
if (!pull) {
|
|
13
|
+
throw new Error(`PR #${pr} not found`);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
headRefOid: pull.headRefOid,
|
|
17
|
+
headRefName: pull.headRefName,
|
|
18
|
+
headRepoWithOwner: pull.headRepository?.nameWithOwner ?? null,
|
|
19
|
+
thread: parseThread(result.data.node, pr, threadId),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function parseThread(raw, pr, threadId) {
|
|
23
|
+
if (!raw?.id || !raw.comments || raw.id !== threadId)
|
|
24
|
+
return null;
|
|
25
|
+
if (raw.pullRequest?.number !== pr)
|
|
26
|
+
return null;
|
|
27
|
+
const comment = raw.comments.nodes[0];
|
|
28
|
+
return {
|
|
29
|
+
id: raw.id,
|
|
30
|
+
isResolved: raw.isResolved,
|
|
31
|
+
isOutdated: raw.isOutdated,
|
|
32
|
+
isMinimized: comment?.isMinimized ?? false,
|
|
33
|
+
path: raw.path ?? comment?.path ?? null,
|
|
34
|
+
line: raw.line ?? comment?.line ?? null,
|
|
35
|
+
startLine: raw.startLine ?? comment?.startLine ?? null,
|
|
36
|
+
author: comment?.author?.login ?? "unknown",
|
|
37
|
+
authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
|
|
38
|
+
...(comment?.authorAssociation !== undefined && {
|
|
39
|
+
authorAssociation: comment.authorAssociation,
|
|
40
|
+
}),
|
|
41
|
+
body: comment?.body ?? "",
|
|
42
|
+
url: comment?.url ?? "",
|
|
43
|
+
createdAtUnix: comment?.createdAt ? parseCreatedAt(comment.createdAt) : 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -1,22 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { graphqlWithRateLimit } from "./client.mjs";
|
|
2
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
2
3
|
import { paginateForward } from "./pagination.mjs";
|
|
3
4
|
import { REVIEW_THREAD_COMMENTS_QUERY } from "./queries.mjs";
|
|
5
|
+
import { mapPool } from "../util/pool.mjs";
|
|
6
|
+
const THREAD_COMMENT_PAGE_CONCURRENCY = 4;
|
|
4
7
|
export async function hydrateThreadCommentPages(threads) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
hydrated.push(await hydrateThreadCommentPage(thread));
|
|
8
|
-
}
|
|
9
|
-
return hydrated;
|
|
8
|
+
const gate = {};
|
|
9
|
+
return mapPool(threads, THREAD_COMMENT_PAGE_CONCURRENCY, (thread) => hydrateThreadCommentPage(thread, gate));
|
|
10
10
|
}
|
|
11
|
-
async function hydrateThreadCommentPage(thread) {
|
|
11
|
+
async function hydrateThreadCommentPage(thread, gate) {
|
|
12
12
|
const pageInfo = thread.comments.pageInfo;
|
|
13
13
|
if (!pageInfo?.hasNextPage || !pageInfo.endCursor)
|
|
14
14
|
return thread;
|
|
15
15
|
const extra = await paginateForward(async (cursor) => {
|
|
16
|
-
|
|
16
|
+
if (gate.remaining === 0) {
|
|
17
|
+
throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; thread comment pagination incomplete", { status: 403 });
|
|
18
|
+
}
|
|
19
|
+
const res = await graphqlWithRateLimit(REVIEW_THREAD_COMMENTS_QUERY, {
|
|
17
20
|
threadId: thread.id,
|
|
18
21
|
...(cursor ? { commentsCursor: cursor } : {}),
|
|
19
22
|
});
|
|
23
|
+
gate.remaining = res.rateLimit?.remaining;
|
|
20
24
|
const node = res.data.node;
|
|
21
25
|
if (!node?.comments) {
|
|
22
26
|
const nodeType = node?.__typename ?? "null";
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* 9. mergeStateStatus CLEAN → CLEAN
|
|
17
17
|
*/
|
|
18
18
|
import { loadConfig } from "../config/load.mjs";
|
|
19
|
+
import { deriveMergeRequirements } from "./requirements.mjs";
|
|
19
20
|
export function deriveMergeStatus(pr) {
|
|
20
21
|
const blockingBotReviewInProgress = detectBlockingBotReview(pr);
|
|
21
22
|
let status;
|
|
@@ -55,6 +56,7 @@ export function deriveMergeStatus(pr) {
|
|
|
55
56
|
reviewDecision: pr.reviewDecision,
|
|
56
57
|
blockingBotReviewInProgress,
|
|
57
58
|
mergeStateStatus: pr.mergeStateStatus,
|
|
59
|
+
mergeRequirements: deriveMergeRequirements(pr),
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const REQUIRED = "[Required]";
|
|
2
|
+
const NOT_REQUIRED = "[Not Required]";
|
|
3
|
+
export function formatMergeRequirementLines(req) {
|
|
4
|
+
const lines = [
|
|
5
|
+
formatApprovals(req.approvals.current, req.approvals.requiredCount),
|
|
6
|
+
formatConversations(req.conversationsResolved),
|
|
7
|
+
];
|
|
8
|
+
if (req.codeOwnerReview)
|
|
9
|
+
lines.push(`Code owner review: ${REQUIRED}`);
|
|
10
|
+
if (req.lastPushApproval)
|
|
11
|
+
lines.push(`Last-push approval: ${REQUIRED}`);
|
|
12
|
+
if (req.signedCommits)
|
|
13
|
+
lines.push(`Signed commits: ${REQUIRED}`);
|
|
14
|
+
if (req.linearHistory)
|
|
15
|
+
lines.push(`Linear history: ${REQUIRED}`);
|
|
16
|
+
if (req.branchUpToDate) {
|
|
17
|
+
const value = req.branchUpToDate.current ? "Yes" : "No";
|
|
18
|
+
lines.push(`Branch up to date: ${value} ${REQUIRED}`);
|
|
19
|
+
}
|
|
20
|
+
if (req.requiredStatusChecks) {
|
|
21
|
+
const n = req.requiredStatusChecks.contexts.length;
|
|
22
|
+
lines.push(`Required status checks: ${n} ${REQUIRED}`);
|
|
23
|
+
}
|
|
24
|
+
if (req.requiredDeployments) {
|
|
25
|
+
const envs = req.requiredDeployments.environments.join(", ");
|
|
26
|
+
lines.push(`Required deployments: ${envs} ${REQUIRED}`);
|
|
27
|
+
}
|
|
28
|
+
if (req.requiredWorkflows)
|
|
29
|
+
lines.push(`Required workflows: ${REQUIRED}`);
|
|
30
|
+
if (req.codeScanning)
|
|
31
|
+
lines.push(`Code scanning: ${REQUIRED}`);
|
|
32
|
+
if (req.mergeQueue)
|
|
33
|
+
lines.push(formatMergeQueue(req.mergeQueue));
|
|
34
|
+
if (req.stack) {
|
|
35
|
+
const { number, position, size, baseRefName } = req.stack;
|
|
36
|
+
lines.push(`Stack: #${number} ${position}/${size} (base ${baseRefName})`);
|
|
37
|
+
}
|
|
38
|
+
return lines;
|
|
39
|
+
}
|
|
40
|
+
export function blockedReasonFromRequirements(req) {
|
|
41
|
+
if (!req)
|
|
42
|
+
return null;
|
|
43
|
+
const unmet = [];
|
|
44
|
+
if (req.approvals.requiredCount > 0 && req.approvals.current < req.approvals.requiredCount) {
|
|
45
|
+
const need = req.approvals.requiredCount - req.approvals.current;
|
|
46
|
+
unmet.push(`awaiting ${need} approval${need === 1 ? "" : "s"}`);
|
|
47
|
+
}
|
|
48
|
+
if (req.conversationsResolved.required && !req.conversationsResolved.resolved) {
|
|
49
|
+
unmet.push("unresolved conversations are required");
|
|
50
|
+
}
|
|
51
|
+
if (req.branchUpToDate && !req.branchUpToDate.current) {
|
|
52
|
+
unmet.push("branch is behind base");
|
|
53
|
+
}
|
|
54
|
+
if (req.mergeQueue?.inQueue) {
|
|
55
|
+
const pos = req.mergeQueue.position != null ? ` position ${req.mergeQueue.position}` : "";
|
|
56
|
+
unmet.push(`in merge queue${pos}`);
|
|
57
|
+
}
|
|
58
|
+
else if (req.mergeQueue?.required) {
|
|
59
|
+
unmet.push("merge queue is required");
|
|
60
|
+
}
|
|
61
|
+
return unmet.length > 0 ? unmet.join("; ") : null;
|
|
62
|
+
}
|
|
63
|
+
function formatApprovals(current, requiredCount) {
|
|
64
|
+
const tag = requiredCount > 0 ? REQUIRED : NOT_REQUIRED;
|
|
65
|
+
if (current === 0 && requiredCount === 0)
|
|
66
|
+
return `Approvals: None ${NOT_REQUIRED}`;
|
|
67
|
+
if (current === 0)
|
|
68
|
+
return `Approvals: None ${REQUIRED}`;
|
|
69
|
+
if (requiredCount > 0)
|
|
70
|
+
return `Approvals: ${current}/${requiredCount} ${tag}`;
|
|
71
|
+
return `Approvals: ${current} ${NOT_REQUIRED}`;
|
|
72
|
+
}
|
|
73
|
+
function formatConversations(c) {
|
|
74
|
+
const value = c.resolved ? "Yes" : "No";
|
|
75
|
+
const tag = c.required ? REQUIRED : NOT_REQUIRED;
|
|
76
|
+
return `Conversations Resolved: ${value} ${tag}`;
|
|
77
|
+
}
|
|
78
|
+
function formatMergeQueue(q) {
|
|
79
|
+
const tag = q.required ? REQUIRED : NOT_REQUIRED;
|
|
80
|
+
if (q.inQueue) {
|
|
81
|
+
const pos = q.position != null ? `position ${q.position}` : "Yes";
|
|
82
|
+
const state = q.state ? ` ${q.state}` : "";
|
|
83
|
+
return `Merge queue: ${pos}${state} ${tag}`;
|
|
84
|
+
}
|
|
85
|
+
if (q.required || q.enabled)
|
|
86
|
+
return `Merge queue: No ${tag}`;
|
|
87
|
+
return `Merge queue: No ${NOT_REQUIRED}`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { EMPTY_BRANCH_RULES } from "../github/batch-parsers-rules.mjs";
|
|
2
|
+
export function deriveMergeRequirements(pr) {
|
|
3
|
+
const rules = pr.branchRules ?? EMPTY_BRANCH_RULES;
|
|
4
|
+
const currentApprovals = pr.latestReviews.filter((r) => r.state === "APPROVED").length;
|
|
5
|
+
const unresolvedCount = pr.reviewThreads.filter((t) => !t.isResolved).length;
|
|
6
|
+
const req = {
|
|
7
|
+
approvals: {
|
|
8
|
+
current: currentApprovals,
|
|
9
|
+
requiredCount: rules.requiredApprovingReviewCount,
|
|
10
|
+
},
|
|
11
|
+
conversationsResolved: {
|
|
12
|
+
resolved: unresolvedCount === 0,
|
|
13
|
+
unresolvedCount,
|
|
14
|
+
required: rules.requiresConversationResolution,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
if (rules.requiresCodeOwnerReviews)
|
|
18
|
+
req.codeOwnerReview = { required: true };
|
|
19
|
+
if (rules.requiresLastPushApproval)
|
|
20
|
+
req.lastPushApproval = { required: true };
|
|
21
|
+
if (rules.requiresCommitSignatures)
|
|
22
|
+
req.signedCommits = { required: true };
|
|
23
|
+
if (rules.requiresLinearHistory)
|
|
24
|
+
req.linearHistory = { required: true };
|
|
25
|
+
if (rules.requiresStrictStatusChecks) {
|
|
26
|
+
req.branchUpToDate = { current: pr.mergeStateStatus !== "BEHIND", required: true };
|
|
27
|
+
}
|
|
28
|
+
if (rules.requiredStatusCheckContexts.length > 0) {
|
|
29
|
+
req.requiredStatusChecks = { contexts: rules.requiredStatusCheckContexts };
|
|
30
|
+
}
|
|
31
|
+
if (rules.requiredDeploymentEnvironments.length > 0) {
|
|
32
|
+
req.requiredDeployments = { environments: rules.requiredDeploymentEnvironments };
|
|
33
|
+
}
|
|
34
|
+
if (rules.requiresWorkflows)
|
|
35
|
+
req.requiredWorkflows = { required: true };
|
|
36
|
+
if (rules.requiresCodeScanning)
|
|
37
|
+
req.codeScanning = { required: true };
|
|
38
|
+
const queueRequired = rules.requiresMergeQueue || Boolean(pr.isMergeQueueEnabled);
|
|
39
|
+
if (queueRequired || pr.isInMergeQueue) {
|
|
40
|
+
req.mergeQueue = {
|
|
41
|
+
required: queueRequired,
|
|
42
|
+
enabled: Boolean(pr.isMergeQueueEnabled),
|
|
43
|
+
inQueue: Boolean(pr.isInMergeQueue),
|
|
44
|
+
...(pr.mergeQueueEntry?.position !== undefined && { position: pr.mergeQueueEntry.position }),
|
|
45
|
+
...(pr.mergeQueueEntry?.state !== undefined && { state: pr.mergeQueueEntry.state }),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (pr.stack)
|
|
49
|
+
req.stack = pr.stack;
|
|
50
|
+
return req;
|
|
51
|
+
}
|
package/bin/state/base.d.mts
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
1
|
export declare function resolveStateBase(): string;
|
|
2
|
+
/**
|
|
3
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
|
|
4
|
+
* Owner, repo, PR number, and each extra part must be a safe path segment.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolvePrStatePath(key: {
|
|
7
|
+
owner: string;
|
|
8
|
+
repo: string;
|
|
9
|
+
pr: number;
|
|
10
|
+
}, ...parts: string[]): string;
|
package/bin/state/base.mjs
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
+
import { SAFE_PR_NUMBER, SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
3
4
|
export function resolveStateBase() {
|
|
4
5
|
const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
|
|
5
6
|
return envDir ? envDir : join(tmpdir(), "pr-shepherd-state");
|
|
6
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
|
|
10
|
+
* Owner, repo, PR number, and each extra part must be a safe path segment.
|
|
11
|
+
*/
|
|
12
|
+
export function resolvePrStatePath(key, ...parts) {
|
|
13
|
+
if (!SAFE_SEGMENT.test(key.owner)) {
|
|
14
|
+
throw new Error(`Invalid state key segment "owner": ${key.owner}`);
|
|
15
|
+
}
|
|
16
|
+
if (!SAFE_SEGMENT.test(key.repo)) {
|
|
17
|
+
throw new Error(`Invalid state key segment "repo": ${key.repo}`);
|
|
18
|
+
}
|
|
19
|
+
const pr = String(key.pr);
|
|
20
|
+
if (!SAFE_PR_NUMBER.test(pr)) {
|
|
21
|
+
throw new Error(`Invalid state key segment "pr": ${key.pr}`);
|
|
22
|
+
}
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
if (!SAFE_SEGMENT.test(part)) {
|
|
25
|
+
throw new Error(`Invalid state key segment: ${part}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return join(resolveStateBase(), `${key.owner}-${key.repo}`, pr, ...parts);
|
|
29
|
+
}
|
|
@@ -17,9 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { resolveStateBase } from "./base.mjs";
|
|
20
|
+
import { dirname } from "node:path";
|
|
21
|
+
import { resolvePrStatePath } from "./base.mjs";
|
|
23
22
|
import { hashBody } from "./seen-comments.mjs";
|
|
24
23
|
export async function readBotCrSeenState(key) {
|
|
25
24
|
try {
|
|
@@ -97,14 +96,5 @@ export function updateBotCrSeenState(previous, currentBotCrReviews, nowSeconds,
|
|
|
97
96
|
return { next: { reviews: next }, staleIds };
|
|
98
97
|
}
|
|
99
98
|
function resolvePath(key) {
|
|
100
|
-
|
|
101
|
-
["owner", key.owner],
|
|
102
|
-
["repo", key.repo],
|
|
103
|
-
]) {
|
|
104
|
-
if (!SAFE_SEGMENT.test(value)) {
|
|
105
|
-
throw new Error(`Invalid state key segment "${field}": ${value}`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
const base = resolveStateBase();
|
|
109
|
-
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "bot-cr-seen.json");
|
|
99
|
+
return resolvePrStatePath(key, "bot-cr-seen.json");
|
|
110
100
|
}
|