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 CHANGED
@@ -1,6 +1,10 @@
1
1
  # co-maintainer
2
2
 
3
- [![NPM Version](https://img.shields.io/npm/v/co-maintainer)](https://www.npmjs.com/package/co-maintainer)
3
+ [![NPM Version](https://img.shields.io/npm/v/co-maintainer)](https://www.npmjs.com/package/co-maintainer) [![](https://img.shields.io/badge/blog-co_maintainer_an_imitator_of_yours-blue)](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](docs/getting-started.html) (install → first
58
- review), [full docs site](docs/index.html), sources in [`docs/md/`](docs/md/).
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 newly tool so not compared a lot of other tools. But did
66
- benchmarks with [OCR](https://open-codereview.ai/). Although I cannot offer any
67
- guarantees because I am working with very small datasets, but it shows promise.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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;
@@ -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 in P5). `GhClient` shells out to the `gh` binary,
3
- * which already retries on its own, so it never calls `githubFetch` — it
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 fails the request right away, with the reset time in the
14
- * message, rather than pausing the caller for however long that reset
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 in P5). `GhClient` shells out to the `gh` binary,
3
- * which already retries on its own, so it never calls `githubFetch` — it
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`. */
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 fails the request right away, with the reset time in the
31
- * message, rather than pausing the caller for however long that reset
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
- let response;
35
- for (let attempt = 1; attempt <= 3; attempt++) {
36
- response = await fetch(url, init);
37
- if (response.status !== 403 && response.status !== 429)
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
- const limit = rateLimit(response.headers);
47
- if (limit && limit.remaining === 0) {
48
- throw new Error(`GitHub rate limit exhausted, resets at ${limit.resetAt.toISOString()}`);
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
- return response;
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
- const discussionStatus = discussionUnchanged
264
- ? "comments/reviews cache"
265
- : "download comments/reviews";
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 (!discussionUnchanged) {
282
- const comments = await client.pages(`repos/${options.repo}/issues/${number}/comments`);
283
- const reviews = await client.pages(`repos/${options.repo}/pulls/${number}/reviews`);
284
- current.comments = comments
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
- result[index] = current;
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
- return result;
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");
@@ -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 {};
@@ -1,63 +1,152 @@
1
- import { paginate } from "./client.js";
2
- import { commandOutput, commandWithInput } from "../util/runtime.js";
3
- export class GhClient {
4
- async request(endpoint) {
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", endpoint],
27
+ args: ["api", "rate_limit"],
7
28
  stdout: "piped",
8
29
  stderr: "piped",
9
30
  });
10
- if (!result.success) {
11
- const error = new TextDecoder().decode(result.stderr).trim();
12
- throw new Error(`gh api failed: ${error || endpoint}`);
13
- }
14
- try {
15
- return JSON.parse(new TextDecoder().decode(result.stdout));
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
- catch {
18
- throw new Error(`gh returned invalid JSON for ${endpoint}`);
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
- async write(endpoint, body) {
22
- const result = await commandWithInput("gh", {
23
- args: ["api", "-X", "POST", endpoint, "--input", "-"],
24
- stdin: "piped",
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
- }, JSON.stringify(body));
28
- if (!result.success) {
29
- const error = new TextDecoder().decode(result.stderr).trim();
30
- throw new Error(`gh api failed: ${error || endpoint}`);
31
- }
32
- try {
33
- return JSON.parse(new TextDecoder().decode(result.stdout));
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
- async createCheckRun(endpoint, body) {
40
- return await this.write(endpoint, body);
105
+ updateCheckRun(endpoint, body) {
106
+ return this.send(endpoint, "PATCH", body);
41
107
  }
42
- async updateCheckRun(endpoint, body) {
43
- const result = await commandWithInput("gh", {
44
- args: ["api", "-X", "PATCH", endpoint, "--input", "-"],
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
- if (!result.success) {
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
- throw new Error(`gh api failed: ${error || endpoint}`);
52
- }
53
- try {
54
- return JSON.parse(new TextDecoder().decode(result.stdout));
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
  };
@@ -44,6 +44,7 @@ function reviewOptionsForRepo(repo, useCodegraph) {
44
44
  includePullRequestChanges: true,
45
45
  includeCommitHistory: true,
46
46
  includeHowRepoWorks: true,
47
+ onlyRequestChangedPr: false,
47
48
  };
48
49
  }
49
50
  function supersedeActiveRemoteJobs(queueKey, supersededBy) {
@@ -130,6 +130,7 @@ export function reviewOptions(repo, prNumber) {
130
130
  includePullRequestChanges: true,
131
131
  includeCommitHistory: true,
132
132
  includeHowRepoWorks: true,
133
+ onlyRequestChangedPr: false,
133
134
  useCodegraph: getRepo(repo)?.use_codegraph === 1,
134
135
  };
135
136
  }
@@ -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 ??
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {