@open-cr-agent/vcs-github 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +3 -0
- package/dist/adapter.d.ts +61 -0
- package/dist/adapter.js +410 -0
- package/dist/client.d.ts +101 -0
- package/dist/client.js +283 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +47 -0
- package/dist/render.d.ts +15 -0
- package/dist/render.js +182 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +38 -0
- package/dist/state.d.ts +14 -0
- package/dist/state.js +110 -0
- package/package.json +39 -0
package/dist/retry.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// GitHub answers bursts with 429 or a 403 "secondary rate limit", and has
|
|
2
|
+
// short 5xx outages. One such answer during publish used to throw away a
|
|
3
|
+
// paid review, so requests are retried a bounded number of times.
|
|
4
|
+
export const MAX_ATTEMPTS = 3;
|
|
5
|
+
const MAX_WAIT_MS = 60_000;
|
|
6
|
+
// A request GitHub refused without acting on it (rate limits) can always be
|
|
7
|
+
// repeated. A 5xx or a lost connection may have happened after GitHub acted,
|
|
8
|
+
// so only requests that are safe to repeat retry then: POST would post the
|
|
9
|
+
// same comment or review twice.
|
|
10
|
+
export function retryDecision(method, idempotent, outcome, attempt, now = Date.now()) {
|
|
11
|
+
const stop = { retry: false, waitMs: 0 };
|
|
12
|
+
if (attempt >= MAX_ATTEMPTS)
|
|
13
|
+
return stop;
|
|
14
|
+
const backoff = 1_000 * 2 ** (attempt - 1);
|
|
15
|
+
const safe = idempotent || method === "GET";
|
|
16
|
+
if (outcome === "network_error")
|
|
17
|
+
return safe ? { retry: true, waitMs: backoff } : stop;
|
|
18
|
+
const { status, headers, body } = outcome;
|
|
19
|
+
const rateLimited = status === 429 ||
|
|
20
|
+
(status === 403 &&
|
|
21
|
+
(headers.get("x-ratelimit-remaining") === "0" || /secondary rate limit/i.test(body)));
|
|
22
|
+
if (rateLimited)
|
|
23
|
+
return { retry: true, waitMs: rateLimitWait(headers, now) ?? backoff };
|
|
24
|
+
if (safe && status >= 500)
|
|
25
|
+
return { retry: true, waitMs: backoff };
|
|
26
|
+
return stop;
|
|
27
|
+
}
|
|
28
|
+
function rateLimitWait(headers, now) {
|
|
29
|
+
const after = Number(headers.get("retry-after"));
|
|
30
|
+
if (Number.isFinite(after) && after > 0)
|
|
31
|
+
return Math.min(after * 1_000, MAX_WAIT_MS);
|
|
32
|
+
const reset = Number(headers.get("x-ratelimit-reset"));
|
|
33
|
+
if (Number.isFinite(reset) && reset > 0) {
|
|
34
|
+
return Math.min(Math.max(reset * 1_000 - now, 0), MAX_WAIT_MS);
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=retry.js.map
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PriorFinding } from "@open-cr-agent/core";
|
|
2
|
+
import { type RiskTier } from "@open-cr-agent/core";
|
|
3
|
+
export declare const SUMMARY_MARKER = "<!-- ocra:review -->";
|
|
4
|
+
export declare const MAX_WRITTEN_STATE_CHARS = 30000;
|
|
5
|
+
export interface ReviewState {
|
|
6
|
+
findings: PriorFinding[];
|
|
7
|
+
head?: string;
|
|
8
|
+
pending?: string[];
|
|
9
|
+
posted?: string[];
|
|
10
|
+
tier?: RiskTier;
|
|
11
|
+
}
|
|
12
|
+
export declare function readState(body: string): ReviewState | undefined;
|
|
13
|
+
export declare function writeState(state: ReviewState): string;
|
|
14
|
+
//# sourceMappingURL=state.d.ts.map
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { RISK_TIERS, severitySchema, verificationSchema } from "@open-cr-agent/core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const SUMMARY_MARKER = "<!-- ocra:review -->";
|
|
4
|
+
// Only a state block that ends the comment counts: author-controlled text
|
|
5
|
+
// (file paths, titles) is printed above it and must not be able to plant one.
|
|
6
|
+
const STATE_PATTERN = /<!-- ocra:state v1 ([A-Za-z0-9+/=]+) -->\s*$/;
|
|
7
|
+
const MAX_STATE_FINDINGS = 500;
|
|
8
|
+
const MAX_STATE_CHARS = 60_000;
|
|
9
|
+
// Written states stay well under what is read, so the summary text keeps room
|
|
10
|
+
// in GitHub's 65,536-character comment limit.
|
|
11
|
+
export const MAX_WRITTEN_STATE_CHARS = 30_000;
|
|
12
|
+
const MAX_PENDING_FILES = 1_000;
|
|
13
|
+
const MAX_POSTED = 1_000;
|
|
14
|
+
const SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
15
|
+
const stateSchema = z.object({
|
|
16
|
+
findings: z
|
|
17
|
+
.array(z.object({
|
|
18
|
+
fingerprint: z.string().regex(/^[0-9a-f]{16}$/),
|
|
19
|
+
title: z.string().max(300),
|
|
20
|
+
file: z.string().max(1_000),
|
|
21
|
+
severity: severitySchema,
|
|
22
|
+
commented: z.boolean(),
|
|
23
|
+
quote: z
|
|
24
|
+
.object({
|
|
25
|
+
lines: z.number().int().positive().max(1_000),
|
|
26
|
+
hash: z.string().regex(/^[0-9a-f]{16}$/),
|
|
27
|
+
})
|
|
28
|
+
.optional(),
|
|
29
|
+
verification: verificationSchema.optional(),
|
|
30
|
+
}))
|
|
31
|
+
.max(MAX_STATE_FINDINGS),
|
|
32
|
+
head: z.string().regex(SHA).optional(),
|
|
33
|
+
pending: z.array(z.string().max(1_000)).max(MAX_PENDING_FILES).optional(),
|
|
34
|
+
posted: z
|
|
35
|
+
.array(z.string().regex(/^[0-9a-f]{16}$/))
|
|
36
|
+
.max(MAX_POSTED)
|
|
37
|
+
.optional(),
|
|
38
|
+
tier: z.enum(RISK_TIERS).optional(),
|
|
39
|
+
});
|
|
40
|
+
// The state lives in a comment anyone with write access can edit, so it is
|
|
41
|
+
// validated and bounded, and the adapter ignores it when someone other than
|
|
42
|
+
// ocra edited the comment. Dismissals are never stored: they are recomputed
|
|
43
|
+
// from review threads on every run.
|
|
44
|
+
export function readState(body) {
|
|
45
|
+
const encoded = STATE_PATTERN.exec(body)?.[1];
|
|
46
|
+
if (!encoded || encoded.length > MAX_STATE_CHARS)
|
|
47
|
+
return undefined;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = stateSchema.safeParse(JSON.parse(Buffer.from(encoded, "base64").toString("utf8")));
|
|
50
|
+
if (!parsed.success)
|
|
51
|
+
return undefined;
|
|
52
|
+
const { head, pending, posted, tier } = parsed.data;
|
|
53
|
+
return {
|
|
54
|
+
findings: parsed.data.findings.map(({ quote, verification, ...finding }) => ({
|
|
55
|
+
...finding,
|
|
56
|
+
...(quote ? { quote } : {}),
|
|
57
|
+
...(verification ? { verification } : {}),
|
|
58
|
+
})),
|
|
59
|
+
...(head ? { head } : {}),
|
|
60
|
+
...(pending ? { pending } : {}),
|
|
61
|
+
...(posted?.length ? { posted } : {}),
|
|
62
|
+
...(tier ? { tier } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function writeState(state) {
|
|
70
|
+
const kept = state.findings.slice(0, MAX_STATE_FINDINGS).map((f) => ({
|
|
71
|
+
fingerprint: f.fingerprint,
|
|
72
|
+
title: f.title.slice(0, 300),
|
|
73
|
+
file: f.file.slice(0, 1_000),
|
|
74
|
+
severity: f.severity,
|
|
75
|
+
commented: f.commented,
|
|
76
|
+
...(f.quote ? { quote: f.quote } : {}),
|
|
77
|
+
...(f.verification ? { verification: f.verification } : {}),
|
|
78
|
+
}));
|
|
79
|
+
// Without the full list of unfinished files the next run could skip one of
|
|
80
|
+
// them, so a list that does not fit drops the head and forces a full review.
|
|
81
|
+
const pending = state.pending ?? [];
|
|
82
|
+
const scope = state.head && pending.length <= MAX_PENDING_FILES
|
|
83
|
+
? {
|
|
84
|
+
head: state.head,
|
|
85
|
+
...(pending.length > 0 ? { pending: pending.map((p) => p.slice(0, 1_000)) } : {}),
|
|
86
|
+
}
|
|
87
|
+
: {};
|
|
88
|
+
// Too large a state first loses its scope (a full review next time), then
|
|
89
|
+
// findings from the end of the list (this run's come first, most severe
|
|
90
|
+
// first); a dropped finding's inline comment may be posted again.
|
|
91
|
+
const extras = {
|
|
92
|
+
...(state.posted?.length ? { posted: state.posted.slice(-MAX_POSTED) } : {}),
|
|
93
|
+
...(state.tier ? { tier: state.tier } : {}),
|
|
94
|
+
};
|
|
95
|
+
let encoded = encode(kept, { ...scope, ...extras });
|
|
96
|
+
if (encoded.length > MAX_WRITTEN_STATE_CHARS)
|
|
97
|
+
encoded = encode(kept, extras);
|
|
98
|
+
if (encoded.length > MAX_WRITTEN_STATE_CHARS)
|
|
99
|
+
encoded = encode(kept, {});
|
|
100
|
+
let count = kept.length;
|
|
101
|
+
while (encoded.length > MAX_WRITTEN_STATE_CHARS && count > 0) {
|
|
102
|
+
count = Math.floor(count * 0.8);
|
|
103
|
+
encoded = encode(kept.slice(0, count), {});
|
|
104
|
+
}
|
|
105
|
+
return `<!-- ocra:state v1 ${encoded} -->`;
|
|
106
|
+
}
|
|
107
|
+
function encode(findings, scope) {
|
|
108
|
+
return Buffer.from(JSON.stringify({ findings, ...scope }), "utf8").toString("base64");
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=state.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-cr-agent/vcs-github",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GitHub pull request adapter for Open-CR-Agent",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"code-review",
|
|
7
|
+
"github",
|
|
8
|
+
"pull-request"
|
|
9
|
+
],
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"homepage": "https://ocra.majincheng.com",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/jma49/Open-CR-Agent.git",
|
|
15
|
+
"directory": "packages/vcs-github"
|
|
16
|
+
},
|
|
17
|
+
"bugs": "https://github.com/jma49/Open-CR-Agent/issues",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.19"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"!dist/**/*.map"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@open-cr-agent/core": "0.1.0",
|
|
34
|
+
"zod": "^4.6.5"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|