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 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.7",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 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 = [];
@@ -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
  }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {