co-maintainer 0.4.6 → 0.4.7
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/commands/review.js +1 -1
- package/dist/src/github/client.d.ts +14 -7
- package/dist/src/github/client.js +86 -22
- package/dist/src/github/gh.d.ts +18 -0
- package/dist/src/github/gh.js +134 -45
- package/dist/src/services/setup.js +1 -1
- 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
|
@@ -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;
|
|
@@ -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 = [];
|
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
|
}
|
|
@@ -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;
|