co-maintainer 0.4.6 → 0.4.8
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/README.md +12 -23
- package/dist/package.json +1 -1
- package/dist/src/cli/args.js +5 -1
- package/dist/src/cli/commands/review.js +1 -1
- package/dist/src/config.d.ts +1 -0
- package/dist/src/github/client.d.ts +14 -7
- package/dist/src/github/client.js +86 -22
- package/dist/src/github/collect.js +52 -18
- package/dist/src/github/gh.d.ts +18 -0
- package/dist/src/github/gh.js +134 -45
- package/dist/src/knowledge/types.d.ts +3 -0
- package/dist/src/services/remote_review.js +1 -0
- package/dist/src/services/review.js +1 -0
- package/dist/src/services/setup.js +3 -1
- package/dist/src/types.d.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# co-maintainer
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/co-maintainer)
|
|
3
|
+
[](https://www.npmjs.com/package/co-maintainer) [](https://muratkirazkaya.com/blogs/co-maintainer-an-imitator-of-yours)
|
|
4
|
+
|
|
5
|
+
[Website](https://groophylifefor.github.io/co-maintainer/) ·
|
|
6
|
+
[Docs](https://groophylifefor.github.io/co-maintainer/getting-started.html) ·
|
|
7
|
+
[Cloud](https://dashboard.cm.ordu.dev/) (hosted, free, early access, invite only for now)
|
|
4
8
|
|
|
5
9
|
`co-maintainer` analyzes a GitHub repository and writes repository-specific
|
|
6
10
|
`SKILL.md` guidance that helps developers contribute changes more reliably.
|
|
@@ -18,24 +22,8 @@ npm install -g co-maintainer
|
|
|
18
22
|
|
|
19
23
|
Requires Node.js 24 or newer.
|
|
20
24
|
|
|
21
|
-
> **Migrating from JSR:** co-maintainer used to be published as
|
|
22
|
-
> `jsr:@murat/co-maintainer` for Deno. JSR is deprecated and no longer updated;
|
|
23
|
-
> install the npm package instead.
|
|
24
|
-
|
|
25
|
-
On a Linux VPS, persist npm's global bin directory if the installer prints a
|
|
26
|
-
PATH notice:
|
|
27
|
-
|
|
28
|
-
```sh
|
|
29
|
-
grep -qxF 'export PATH="$(npm prefix -g)/bin:$PATH"' ~/.bashrc || printf '\nexport PATH="$(npm prefix -g)/bin:$PATH"\n' >> ~/.bashrc
|
|
30
|
-
source ~/.bashrc
|
|
31
|
-
```
|
|
32
|
-
|
|
33
25
|
## Usage
|
|
34
26
|
|
|
35
|
-
```sh
|
|
36
|
-
co-maintainer probe owner/repo --auth=gh
|
|
37
|
-
```
|
|
38
|
-
|
|
39
27
|
```sh
|
|
40
28
|
co-maintainer probe owner/repo --auth=gh
|
|
41
29
|
|
|
@@ -54,24 +42,25 @@ co-maintainer review --json # machine-readable stdout; logs on stderr
|
|
|
54
42
|
npm run review-local-e2e # fake-AI local loop (developers, from repo root)
|
|
55
43
|
```
|
|
56
44
|
|
|
57
|
-
Documentation: [Getting started](
|
|
58
|
-
review), [full docs site](
|
|
45
|
+
Documentation: [Getting started](https://groophylifefor.github.io/co-maintainer/getting-started.html)
|
|
46
|
+
(install → first review), [full docs site](https://groophylifefor.github.io/co-maintainer/),
|
|
47
|
+
sources in [`docs/md/`](docs/md/).
|
|
59
48
|
|
|
60
49
|
Use `co-maintainer help`, `co-maintainer -h`, or `co-maintainer --help` for the
|
|
61
50
|
full CLI help.
|
|
62
51
|
|
|
63
52
|
## Compared to other tools
|
|
64
53
|
|
|
65
|
-
co-maintainer is a
|
|
66
|
-
|
|
67
|
-
guarantees because
|
|
54
|
+
co-maintainer is a new tool, so it has not been compared with many others yet.
|
|
55
|
+
It was benchmarked against [OCR](https://open-codereview.ai/). I cannot offer any
|
|
56
|
+
guarantees because the datasets are very small, but it shows promise.
|
|
68
57
|
|
|
69
58
|
In core_v2 benchmarks:
|
|
70
59
|
|
|
71
60
|
- **2-3x better results** than OCR
|
|
72
61
|
- **2-8x faster** than OCR
|
|
73
62
|
- **70-200x fewer tokens** than OCR
|
|
74
|
-
- **60-270x cheaper** than
|
|
63
|
+
- **60-270x cheaper** than OCR
|
|
75
64
|
|
|
76
65
|
```mermaid
|
|
77
66
|
xychart-beta
|
package/dist/package.json
CHANGED
package/dist/src/cli/args.js
CHANGED
|
@@ -52,6 +52,7 @@ export async function parseArgs(args) {
|
|
|
52
52
|
console.log("Options: --include-codebase --include-pull-requests --include-pull-request-changes");
|
|
53
53
|
console.log(" --include-commit-history --include-how-repo-works");
|
|
54
54
|
console.log(" --max-commits=N --max-pr-months=N --max-pull-request-change-lines=N --max-comment=N");
|
|
55
|
+
console.log(" --only-request-changed-pr");
|
|
55
56
|
console.log(" --auth=gh|pat --github-pat=... --ai=none|openrouter|hetzner --token=... --low-model=... --high-model=...");
|
|
56
57
|
process.exit(0);
|
|
57
58
|
}
|
|
@@ -145,7 +146,8 @@ export async function parseArgs(args) {
|
|
|
145
146
|
if (arg === "--debug" ||
|
|
146
147
|
arg === "--log-time" ||
|
|
147
148
|
arg === "--review-upstream" ||
|
|
148
|
-
arg === "--disable-codegraph"
|
|
149
|
+
arg === "--disable-codegraph" ||
|
|
150
|
+
arg === "--only-request-changed-pr")
|
|
149
151
|
continue;
|
|
150
152
|
if (arg.startsWith("--") && !known)
|
|
151
153
|
die(`Unknown option: ${arg}`);
|
|
@@ -241,6 +243,8 @@ export async function parseArgs(args) {
|
|
|
241
243
|
includePullRequestChanges: enabled("include-pull-request-changes"),
|
|
242
244
|
includeCommitHistory: enabled("include-commit-history"),
|
|
243
245
|
includeHowRepoWorks: enabled("include-how-repo-works"),
|
|
246
|
+
onlyRequestChangedPr: rest.includes("--only-request-changed-pr") ||
|
|
247
|
+
repoConfig.onlyRequestChangedPr === true,
|
|
244
248
|
maxCommits: value("max-commits") ?? repoConfig.maxCommits ?? configDefault.maxCommits,
|
|
245
249
|
maxPrMonths: value("max-pr-months") ??
|
|
246
250
|
repoConfig.maxPrMonths ??
|
|
@@ -39,7 +39,7 @@ async function runReviewPr(options, cli) {
|
|
|
39
39
|
throw new Error("review supports gh authentication only");
|
|
40
40
|
}
|
|
41
41
|
log("review", `reading PR #${options.prNumber} in ${options.repo} via gh`);
|
|
42
|
-
const result = await timed("review GitHub collection and AI", options.logTime, () => reviewPullRequest(new GhClient(), options, async (response) => {
|
|
42
|
+
const result = await timed("review GitHub collection and AI", options.logTime, () => reviewPullRequest(new GhClient(options.debug), options, async (response) => {
|
|
43
43
|
aiMetrics.calls++;
|
|
44
44
|
aiMetrics.tokensIn += response.tokensIn;
|
|
45
45
|
aiMetrics.tokensOut += response.tokensOut;
|
package/dist/src/config.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type RepoConfig = {
|
|
|
17
17
|
includePullRequestChanges?: boolean;
|
|
18
18
|
includeCommitHistory?: boolean;
|
|
19
19
|
includeHowRepoWorks?: boolean;
|
|
20
|
+
onlyRequestChangedPr?: boolean;
|
|
20
21
|
/** Five field cron in UTC, checked by `serve` to queue a remake. */
|
|
21
22
|
remakeCron?: string;
|
|
22
23
|
};
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/** Shared by every `GitHubClient` implementation that talks over `fetch`
|
|
2
|
-
* (pat.ts, and app.ts
|
|
3
|
-
*
|
|
4
|
-
* still shares `paginate`, the one 20-line loop every client used to carry
|
|
5
|
-
* its own copy of. */
|
|
2
|
+
* (pat.ts, and app.ts). `GhClient` shells out to the `gh` binary and does
|
|
3
|
+
* not use `githubFetch`; both paths share `paginate` and `waitForRateLimit`. */
|
|
6
4
|
export declare class GitHubHttpError extends Error {
|
|
7
5
|
readonly status: number;
|
|
8
6
|
constructor(status: number, body: string);
|
|
9
7
|
}
|
|
10
8
|
export declare function isAccessDenied(error: unknown): boolean;
|
|
9
|
+
/** Primary probes wake at the reset, but at least every half hour. */
|
|
10
|
+
export declare const RATE_LIMIT_PROBE_MS: number;
|
|
11
|
+
/** How long to sleep before the next try. A past reset is `0` (try now).
|
|
12
|
+
* An unreadable reset is one probe interval. */
|
|
13
|
+
export declare function probeDelay(resetAt: Date, now: number): number;
|
|
14
|
+
export declare function rateLimitError(resetAt: Date): Error;
|
|
15
|
+
/** Sleep until `resetAt`, or at most `RATE_LIMIT_PROBE_MS`, whichever is
|
|
16
|
+
* sooner. Throws once this attempt has already waited two hours. A `0`
|
|
17
|
+
* delay returns immediately so the caller can try once more. */
|
|
18
|
+
export declare function waitForRateLimit(resetAt: Date, startedAt: number): Promise<void>;
|
|
11
19
|
/** A secondary (abuse-detection) rate limit carries a short `retry-after`
|
|
12
20
|
* and is retried in place, up to three attempts total. A primary limit at
|
|
13
|
-
* zero remaining
|
|
14
|
-
*
|
|
15
|
-
* takes — the caller decides whether to wait, not this function. */
|
|
21
|
+
* zero remaining sleeps until its reset, probing at least every 30 minutes,
|
|
22
|
+
* then retries the same request. */
|
|
16
23
|
export declare function githubFetch(url: string, init: RequestInit): Promise<Response>;
|
|
17
24
|
export declare function paginate<T>(request: (endpoint: string) => Promise<T[]>, endpoint: string, limit?: number, progress?: (page: number, fetched: number) => void): Promise<T[]>;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/** Shared by every `GitHubClient` implementation that talks over `fetch`
|
|
2
|
-
* (pat.ts, and app.ts
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
* its own copy of. */
|
|
2
|
+
* (pat.ts, and app.ts). `GhClient` shells out to the `gh` binary and does
|
|
3
|
+
* not use `githubFetch`; both paths share `paginate` and `waitForRateLimit`. */
|
|
4
|
+
import { log } from "../util/log.js";
|
|
6
5
|
export class GitHubHttpError extends Error {
|
|
7
6
|
status;
|
|
8
7
|
constructor(status, body) {
|
|
@@ -15,6 +14,10 @@ export function isAccessDenied(error) {
|
|
|
15
14
|
return (error instanceof GitHubHttpError &&
|
|
16
15
|
(error.status === 403 || error.status === 404));
|
|
17
16
|
}
|
|
17
|
+
/** Primary probes wake at the reset, but at least every half hour. */
|
|
18
|
+
export const RATE_LIMIT_PROBE_MS = 30 * 60 * 1000;
|
|
19
|
+
/** A sliding reset header must not pin a serve job. */
|
|
20
|
+
const RATE_LIMIT_MAX_WAIT_MS = 2 * 60 * 60 * 1000;
|
|
18
21
|
function rateLimit(headers) {
|
|
19
22
|
const remaining = headers.get("x-ratelimit-remaining");
|
|
20
23
|
const reset = headers.get("x-ratelimit-reset");
|
|
@@ -25,31 +28,92 @@ function rateLimit(headers) {
|
|
|
25
28
|
resetAt: new Date(Number(reset) * 1000),
|
|
26
29
|
};
|
|
27
30
|
}
|
|
31
|
+
/** How long to sleep before the next try. A past reset is `0` (try now).
|
|
32
|
+
* An unreadable reset is one probe interval. */
|
|
33
|
+
export function probeDelay(resetAt, now) {
|
|
34
|
+
const until = resetAt.getTime() - now;
|
|
35
|
+
if (!Number.isFinite(until))
|
|
36
|
+
return RATE_LIMIT_PROBE_MS;
|
|
37
|
+
if (until <= 0)
|
|
38
|
+
return 0;
|
|
39
|
+
return Math.min(until, RATE_LIMIT_PROBE_MS);
|
|
40
|
+
}
|
|
41
|
+
export function rateLimitError(resetAt) {
|
|
42
|
+
const when = Number.isFinite(resetAt.getTime())
|
|
43
|
+
? resetAt.toISOString()
|
|
44
|
+
: "unknown";
|
|
45
|
+
return new Error(`GitHub rate limit exhausted, resets at ${when}`);
|
|
46
|
+
}
|
|
47
|
+
function resetLabel(resetAt) {
|
|
48
|
+
return Number.isFinite(resetAt.getTime()) ? resetAt.toISOString() : "unknown";
|
|
49
|
+
}
|
|
50
|
+
// ponytail: one process-wide sleep. A search limit (minutes) and a core
|
|
51
|
+
// limit (an hour) exhausted together wait out the longer one.
|
|
52
|
+
let rateLimitGate;
|
|
53
|
+
function pauseForReset(resetAt, delay) {
|
|
54
|
+
if (rateLimitGate)
|
|
55
|
+
return rateLimitGate;
|
|
56
|
+
log("github", `rate limit, next probe in ${Math.ceil(delay / 60_000)}m (reset ${resetLabel(resetAt)})`);
|
|
57
|
+
const pending = new Promise((resolve) => {
|
|
58
|
+
setTimeout(resolve, delay);
|
|
59
|
+
});
|
|
60
|
+
rateLimitGate = pending.finally(() => {
|
|
61
|
+
rateLimitGate = undefined;
|
|
62
|
+
});
|
|
63
|
+
return rateLimitGate;
|
|
64
|
+
}
|
|
65
|
+
/** Sleep until `resetAt`, or at most `RATE_LIMIT_PROBE_MS`, whichever is
|
|
66
|
+
* sooner. Throws once this attempt has already waited two hours. A `0`
|
|
67
|
+
* delay returns immediately so the caller can try once more. */
|
|
68
|
+
export async function waitForRateLimit(resetAt, startedAt) {
|
|
69
|
+
if (Date.now() - startedAt >= RATE_LIMIT_MAX_WAIT_MS) {
|
|
70
|
+
throw rateLimitError(resetAt);
|
|
71
|
+
}
|
|
72
|
+
const delay = probeDelay(resetAt, Date.now());
|
|
73
|
+
if (delay === 0)
|
|
74
|
+
return;
|
|
75
|
+
await pauseForReset(resetAt, delay);
|
|
76
|
+
}
|
|
28
77
|
/** A secondary (abuse-detection) rate limit carries a short `retry-after`
|
|
29
78
|
* and is retried in place, up to three attempts total. A primary limit at
|
|
30
|
-
* zero remaining
|
|
31
|
-
*
|
|
32
|
-
* takes — the caller decides whether to wait, not this function. */
|
|
79
|
+
* zero remaining sleeps until its reset, probing at least every 30 minutes,
|
|
80
|
+
* then retries the same request. */
|
|
33
81
|
export async function githubFetch(url, init) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
82
|
+
const started = Date.now();
|
|
83
|
+
let spun = false;
|
|
84
|
+
for (;;) {
|
|
85
|
+
let response;
|
|
86
|
+
let exhausted;
|
|
87
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
88
|
+
response = await fetch(url, init);
|
|
89
|
+
if (response.status !== 403 && response.status !== 429)
|
|
90
|
+
return response;
|
|
91
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
92
|
+
const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
|
|
93
|
+
if (Number.isFinite(retryAfter) && retryAfter >= 0 && attempt < 3) {
|
|
94
|
+
console.log(`[github] secondary rate limit, waiting ${retryAfter}s (attempt ${attempt} of 3)`);
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const limit = rateLimit(response.headers);
|
|
99
|
+
if (limit && limit.remaining === 0) {
|
|
100
|
+
exhausted = limit;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
38
103
|
return response;
|
|
39
|
-
const retryAfterHeader = response.headers.get("retry-after");
|
|
40
|
-
const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
|
|
41
|
-
if (Number.isFinite(retryAfter) && retryAfter >= 0 && attempt < 3) {
|
|
42
|
-
console.log(`[github] secondary rate limit, waiting ${retryAfter}s (attempt ${attempt} of 3)`);
|
|
43
|
-
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
44
|
-
continue;
|
|
45
104
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
105
|
+
if (!exhausted)
|
|
106
|
+
return response;
|
|
107
|
+
const delay = probeDelay(exhausted.resetAt, Date.now());
|
|
108
|
+
if (delay === 0) {
|
|
109
|
+
if (spun)
|
|
110
|
+
throw rateLimitError(exhausted.resetAt);
|
|
111
|
+
spun = true;
|
|
112
|
+
continue;
|
|
49
113
|
}
|
|
50
|
-
|
|
114
|
+
spun = false;
|
|
115
|
+
await waitForRateLimit(exhausted.resetAt, started);
|
|
51
116
|
}
|
|
52
|
-
return response;
|
|
53
117
|
}
|
|
54
118
|
export async function paginate(request, endpoint, limit, progress) {
|
|
55
119
|
const items = [];
|
|
@@ -10,6 +10,12 @@ function percent(done, total) {
|
|
|
10
10
|
return "0%";
|
|
11
11
|
return `${Math.round((done / total) * 100)}%`;
|
|
12
12
|
}
|
|
13
|
+
function changesRequested(reviews) {
|
|
14
|
+
return reviews.some((review) => review.state === "CHANGES_REQUESTED");
|
|
15
|
+
}
|
|
16
|
+
function reviewBodies(reviews) {
|
|
17
|
+
return reviews.map((review) => String(review.body ?? "")).filter(Boolean);
|
|
18
|
+
}
|
|
13
19
|
function withinPrWindow(updatedAt, months) {
|
|
14
20
|
if (months === undefined || months === 0)
|
|
15
21
|
return true;
|
|
@@ -258,13 +264,44 @@ async function pullRequests(client, options, previous, phase, progress) {
|
|
|
258
264
|
changedFiles: cached?.changedFiles ?? [],
|
|
259
265
|
diff: cached?.diff ?? "",
|
|
260
266
|
};
|
|
267
|
+
if (typeof cached?.changesRequested === "boolean") {
|
|
268
|
+
current.changesRequested = cached.changesRequested;
|
|
269
|
+
}
|
|
270
|
+
const only = options.onlyRequestChangedPr;
|
|
261
271
|
const discussionUnchanged = cached?.updatedAt === current.updatedAt;
|
|
272
|
+
const decisionKnown = typeof cached?.changesRequested === "boolean";
|
|
262
273
|
const diffUnchanged = cached?.headSha === current.headSha && Boolean(cached?.diff);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
274
|
+
let dropped = only && discussionUnchanged && cached?.changesRequested === false;
|
|
275
|
+
const loadComments = async () => {
|
|
276
|
+
const comments = await client.pages(`repos/${options.repo}/issues/${number}/comments`);
|
|
277
|
+
current.comments = comments
|
|
278
|
+
.map((comment) => String(comment.body ?? ""))
|
|
279
|
+
.filter(Boolean)
|
|
280
|
+
.slice(0, options.maxComments);
|
|
281
|
+
};
|
|
282
|
+
const loadReviews = async () => {
|
|
283
|
+
const reviews = await client.pages(`repos/${options.repo}/pulls/${number}/reviews`);
|
|
284
|
+
current.reviews = reviewBodies(reviews);
|
|
285
|
+
current.changesRequested = changesRequested(reviews);
|
|
286
|
+
};
|
|
287
|
+
if (!dropped && only && (!discussionUnchanged || !decisionKnown)) {
|
|
288
|
+
await loadReviews();
|
|
289
|
+
if (!current.changesRequested)
|
|
290
|
+
dropped = true;
|
|
291
|
+
else if (!discussionUnchanged)
|
|
292
|
+
await loadComments();
|
|
293
|
+
}
|
|
294
|
+
else if (!dropped && !discussionUnchanged) {
|
|
295
|
+
await loadComments();
|
|
296
|
+
await loadReviews();
|
|
297
|
+
}
|
|
298
|
+
const discussionStatus = dropped
|
|
299
|
+
? "dropped"
|
|
300
|
+
: discussionUnchanged
|
|
301
|
+
? "comments/reviews cache"
|
|
302
|
+
: "download comments/reviews";
|
|
266
303
|
let diffStatus = "diff disabled";
|
|
267
|
-
if (options.includePullRequestChanges) {
|
|
304
|
+
if (!dropped && options.includePullRequestChanges) {
|
|
268
305
|
const lines = current.additions + current.deletions;
|
|
269
306
|
diffStatus = diffUnchanged
|
|
270
307
|
? "diff cache"
|
|
@@ -278,18 +315,10 @@ async function pullRequests(client, options, previous, phase, progress) {
|
|
|
278
315
|
if (phase) {
|
|
279
316
|
phase.text = `PR #${number} · ${discussionStatus} · ${diffStatus} · ${completed}/${selected.length} · ${percent(completed, selected.length)}`;
|
|
280
317
|
}
|
|
281
|
-
if (!
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
.map((comment) => String(comment.body ?? ""))
|
|
286
|
-
.filter(Boolean)
|
|
287
|
-
.slice(0, options.maxComments);
|
|
288
|
-
current.reviews = reviews
|
|
289
|
-
.map((review) => String(review.body ?? ""))
|
|
290
|
-
.filter(Boolean);
|
|
291
|
-
}
|
|
292
|
-
if (options.includePullRequestChanges && !diffUnchanged && detail) {
|
|
318
|
+
if (!dropped &&
|
|
319
|
+
options.includePullRequestChanges &&
|
|
320
|
+
!diffUnchanged &&
|
|
321
|
+
detail) {
|
|
293
322
|
const lines = current.additions + current.deletions;
|
|
294
323
|
if (options.maxPullRequestChangeLines === undefined ||
|
|
295
324
|
lines <= options.maxPullRequestChangeLines) {
|
|
@@ -311,7 +340,8 @@ async function pullRequests(client, options, previous, phase, progress) {
|
|
|
311
340
|
current.diff = "";
|
|
312
341
|
}
|
|
313
342
|
}
|
|
314
|
-
|
|
343
|
+
if (!dropped)
|
|
344
|
+
result[index] = current;
|
|
315
345
|
completed++;
|
|
316
346
|
if (phase) {
|
|
317
347
|
phase.text = `pull requests ${completed}/${selected.length} · ${percent(completed, selected.length)}`;
|
|
@@ -322,7 +352,11 @@ async function pullRequests(client, options, previous, phase, progress) {
|
|
|
322
352
|
await save;
|
|
323
353
|
}
|
|
324
354
|
});
|
|
325
|
-
|
|
355
|
+
const kept = result.filter((item) => !!item);
|
|
356
|
+
if (options.onlyRequestChangedPr) {
|
|
357
|
+
log("fetch", `only request-changed pr · kept ${kept.length} · dropped ${selected.length - kept.length}`);
|
|
358
|
+
}
|
|
359
|
+
return kept;
|
|
326
360
|
}
|
|
327
361
|
async function commits(client, options, meta, phase) {
|
|
328
362
|
const branch = String(meta.default_branch ?? "main");
|
package/dist/src/github/gh.d.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import type { GitHubClient } from "../types.ts";
|
|
2
|
+
type QuotaRow = {
|
|
3
|
+
limit?: number;
|
|
4
|
+
remaining?: number;
|
|
5
|
+
reset?: number;
|
|
6
|
+
};
|
|
7
|
+
type QuotaBody = {
|
|
8
|
+
resources?: {
|
|
9
|
+
core?: QuotaRow;
|
|
10
|
+
search?: QuotaRow;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** `core 4120/5000 · search 28/30 · reset 21:58` from a `rate_limit` body. */
|
|
14
|
+
export declare function quotaLine(body: QuotaBody): string;
|
|
2
15
|
export declare class GhClient implements GitHubClient {
|
|
16
|
+
private readonly debug;
|
|
17
|
+
constructor(debug?: boolean);
|
|
3
18
|
request<T>(endpoint: string): Promise<T>;
|
|
4
19
|
write<T>(endpoint: string, body: unknown): Promise<T>;
|
|
5
20
|
createCheckRun<T>(endpoint: string, body: unknown): Promise<T>;
|
|
6
21
|
updateCheckRun<T>(endpoint: string, body: unknown): Promise<T>;
|
|
7
22
|
pages<T>(endpoint: string, limit?: number, progress?: (page: number, fetched: number) => void): Promise<T[]>;
|
|
23
|
+
private send;
|
|
24
|
+
private call;
|
|
8
25
|
}
|
|
26
|
+
export {};
|
package/dist/src/github/gh.js
CHANGED
|
@@ -1,63 +1,152 @@
|
|
|
1
|
-
import { paginate } from "./client.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { paginate, probeDelay, RATE_LIMIT_PROBE_MS, rateLimitError, waitForRateLimit, } from "./client.js";
|
|
2
|
+
import { log } from "../util/log.js";
|
|
3
|
+
import { commandOutput, commandWithInput, } from "../util/runtime.js";
|
|
4
|
+
const PULSE_MS = 120_000;
|
|
5
|
+
let lastCallAt = 0;
|
|
6
|
+
let pulse;
|
|
7
|
+
function count(value) {
|
|
8
|
+
return typeof value === "number" ? String(value) : "?";
|
|
9
|
+
}
|
|
10
|
+
function hhmm(reset) {
|
|
11
|
+
if (typeof reset !== "number" || !Number.isFinite(reset))
|
|
12
|
+
return "??:??";
|
|
13
|
+
const date = new Date(reset * 1000);
|
|
14
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
15
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
16
|
+
return `${hours}:${minutes}`;
|
|
17
|
+
}
|
|
18
|
+
/** `core 4120/5000 · search 28/30 · reset 21:58` from a `rate_limit` body. */
|
|
19
|
+
export function quotaLine(body) {
|
|
20
|
+
const core = body.resources?.core;
|
|
21
|
+
const search = body.resources?.search;
|
|
22
|
+
return `github quota · core ${count(core?.remaining)}/${count(core?.limit)} · search ${count(search?.remaining)}/${count(search?.limit)} · reset ${hhmm(core?.reset)}`;
|
|
23
|
+
}
|
|
24
|
+
async function printQuota() {
|
|
25
|
+
try {
|
|
5
26
|
const result = await commandOutput("gh", {
|
|
6
|
-
args: ["api",
|
|
27
|
+
args: ["api", "rate_limit"],
|
|
7
28
|
stdout: "piped",
|
|
8
29
|
stderr: "piped",
|
|
9
30
|
});
|
|
10
|
-
if (!result.success)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
31
|
+
if (!result.success)
|
|
32
|
+
return;
|
|
33
|
+
log("debug", quotaLine(JSON.parse(new TextDecoder().decode(result.stdout))));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// The line is diagnostic. The request it follows still stands.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function unref(timer) {
|
|
40
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) {
|
|
41
|
+
timer.unref();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** While `gh` calls keep landing, print the quota every two minutes.
|
|
45
|
+
* The probe itself does not count as a call, so a quiet process stops. */
|
|
46
|
+
function noteGhCall(debug) {
|
|
47
|
+
if (!debug)
|
|
48
|
+
return;
|
|
49
|
+
lastCallAt = Date.now();
|
|
50
|
+
if (pulse)
|
|
51
|
+
return;
|
|
52
|
+
const timer = setInterval(() => {
|
|
53
|
+
if (Date.now() - lastCallAt > PULSE_MS) {
|
|
54
|
+
clearInterval(timer);
|
|
55
|
+
pulse = undefined;
|
|
56
|
+
return;
|
|
16
57
|
}
|
|
17
|
-
|
|
18
|
-
|
|
58
|
+
void printQuota();
|
|
59
|
+
}, PULSE_MS);
|
|
60
|
+
unref(timer);
|
|
61
|
+
pulse = timer;
|
|
62
|
+
}
|
|
63
|
+
async function readLimit(endpoint) {
|
|
64
|
+
try {
|
|
65
|
+
const result = await commandOutput("gh", {
|
|
66
|
+
args: ["api", "rate_limit"],
|
|
67
|
+
stdout: "piped",
|
|
68
|
+
stderr: "piped",
|
|
69
|
+
});
|
|
70
|
+
if (!result.success)
|
|
71
|
+
return undefined;
|
|
72
|
+
const body = JSON.parse(new TextDecoder().decode(result.stdout));
|
|
73
|
+
const row = endpoint.startsWith("search/")
|
|
74
|
+
? body.resources?.search
|
|
75
|
+
: body.resources?.core;
|
|
76
|
+
if (!row ||
|
|
77
|
+
typeof row.remaining !== "number" ||
|
|
78
|
+
typeof row.reset !== "number") {
|
|
79
|
+
return undefined;
|
|
19
80
|
}
|
|
81
|
+
return { remaining: row.remaining, resetAt: new Date(row.reset * 1000) };
|
|
20
82
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class GhClient {
|
|
88
|
+
debug;
|
|
89
|
+
constructor(debug = false) {
|
|
90
|
+
this.debug = debug;
|
|
91
|
+
}
|
|
92
|
+
request(endpoint) {
|
|
93
|
+
return this.call(endpoint, () => commandOutput("gh", {
|
|
94
|
+
args: ["api", endpoint],
|
|
25
95
|
stdout: "piped",
|
|
26
96
|
stderr: "piped",
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
throw new Error(`gh returned invalid JSON for ${endpoint}`);
|
|
37
|
-
}
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
write(endpoint, body) {
|
|
100
|
+
return this.send(endpoint, "POST", body);
|
|
101
|
+
}
|
|
102
|
+
createCheckRun(endpoint, body) {
|
|
103
|
+
return this.write(endpoint, body);
|
|
38
104
|
}
|
|
39
|
-
|
|
40
|
-
return
|
|
105
|
+
updateCheckRun(endpoint, body) {
|
|
106
|
+
return this.send(endpoint, "PATCH", body);
|
|
41
107
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
108
|
+
pages(endpoint, limit, progress) {
|
|
109
|
+
return paginate((e) => this.request(e), endpoint, limit, progress);
|
|
110
|
+
}
|
|
111
|
+
send(endpoint, method, body) {
|
|
112
|
+
return this.call(endpoint, () => commandWithInput("gh", {
|
|
113
|
+
args: ["api", "-X", method, endpoint, "--input", "-"],
|
|
45
114
|
stdin: "piped",
|
|
46
115
|
stdout: "piped",
|
|
47
116
|
stderr: "piped",
|
|
48
|
-
}, JSON.stringify(body));
|
|
49
|
-
|
|
117
|
+
}, JSON.stringify(body)));
|
|
118
|
+
}
|
|
119
|
+
async call(endpoint, run) {
|
|
120
|
+
noteGhCall(this.debug);
|
|
121
|
+
const started = Date.now();
|
|
122
|
+
let spun = false;
|
|
123
|
+
for (;;) {
|
|
124
|
+
const result = await run();
|
|
125
|
+
if (result.success) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(new TextDecoder().decode(result.stdout));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new Error(`gh returned invalid JSON for ${endpoint}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
50
133
|
const error = new TextDecoder().decode(result.stderr).trim();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
134
|
+
const bucket = await readLimit(endpoint);
|
|
135
|
+
const limited = bucket
|
|
136
|
+
? bucket.remaining === 0
|
|
137
|
+
: /rate limit/i.test(error);
|
|
138
|
+
if (!limited)
|
|
139
|
+
throw new Error(`gh api failed: ${error || endpoint}`);
|
|
140
|
+
const resetAt = bucket?.resetAt ?? new Date(Date.now() + RATE_LIMIT_PROBE_MS);
|
|
141
|
+
const delay = probeDelay(resetAt, Date.now());
|
|
142
|
+
if (delay === 0) {
|
|
143
|
+
if (spun)
|
|
144
|
+
throw rateLimitError(resetAt);
|
|
145
|
+
spun = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
spun = false;
|
|
149
|
+
await waitForRateLimit(resetAt, started);
|
|
55
150
|
}
|
|
56
|
-
catch {
|
|
57
|
-
throw new Error(`gh returned invalid JSON for ${endpoint}`);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
pages(endpoint, limit, progress) {
|
|
61
|
-
return paginate((e) => this.request(e), endpoint, limit, progress);
|
|
62
151
|
}
|
|
63
152
|
}
|
|
@@ -12,6 +12,9 @@ export type PullRequest = {
|
|
|
12
12
|
deletions: number;
|
|
13
13
|
comments: string[];
|
|
14
14
|
reviews: string[];
|
|
15
|
+
/** Set once reviews have been read. Missing means an older checkpoint
|
|
16
|
+
* never stored the review state. */
|
|
17
|
+
changesRequested?: boolean;
|
|
15
18
|
changedFiles: string[];
|
|
16
19
|
diff: string;
|
|
17
20
|
};
|
|
@@ -36,7 +36,7 @@ export function clientFor(options) {
|
|
|
36
36
|
if (options.auth === "pat") {
|
|
37
37
|
return new PatClient(options.githubPat ?? "");
|
|
38
38
|
}
|
|
39
|
-
return new GhClient();
|
|
39
|
+
return new GhClient(options.debug);
|
|
40
40
|
}
|
|
41
41
|
function optionsForState(options) {
|
|
42
42
|
const { command: _, aiToken: __, githubPat: ___, ...rest } = options;
|
|
@@ -259,6 +259,7 @@ export async function runInitOrRemake(options) {
|
|
|
259
259
|
includePullRequestChanges: options.includePullRequestChanges,
|
|
260
260
|
includeCommitHistory: options.includeCommitHistory,
|
|
261
261
|
includeHowRepoWorks: options.includeHowRepoWorks,
|
|
262
|
+
onlyRequestChangedPr: options.onlyRequestChangedPr,
|
|
262
263
|
});
|
|
263
264
|
});
|
|
264
265
|
log("write", `${path} · ${result.changed.length
|
|
@@ -306,6 +307,7 @@ export function optionsFromConfig(repo, command) {
|
|
|
306
307
|
includePullRequestChanges: repoConfig.includePullRequestChanges ?? true,
|
|
307
308
|
includeCommitHistory: repoConfig.includeCommitHistory ?? true,
|
|
308
309
|
includeHowRepoWorks: repoConfig.includeHowRepoWorks ?? true,
|
|
310
|
+
onlyRequestChangedPr: repoConfig.onlyRequestChangedPr === true,
|
|
309
311
|
maxCommits: repoConfig.maxCommits ?? config.defaults?.maxCommits,
|
|
310
312
|
maxPrMonths: repoConfig.maxPrMonths ?? config.defaults?.maxPrMonths,
|
|
311
313
|
maxPullRequestChangeLines: repoConfig.maxPullRequestChangeLines ??
|
package/dist/src/types.d.ts
CHANGED
|
@@ -34,6 +34,9 @@ export type Options = {
|
|
|
34
34
|
includePullRequestChanges: boolean;
|
|
35
35
|
includeCommitHistory: boolean;
|
|
36
36
|
includeHowRepoWorks: boolean;
|
|
37
|
+
/** Keep only pull requests in the window that have at least one
|
|
38
|
+
* `CHANGES_REQUESTED` review. Off by default, so the window is unchanged. */
|
|
39
|
+
onlyRequestChangedPr: boolean;
|
|
37
40
|
maxCommits?: number;
|
|
38
41
|
maxPrMonths?: number;
|
|
39
42
|
maxPullRequestChangeLines?: number;
|