@open-cr-agent/vcs-github 0.1.2 → 0.2.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/dist/adapter.d.ts +2 -42
- package/dist/adapter.js +80 -308
- package/dist/client.js +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/package.json +3 -2
- package/dist/render.d.ts +0 -15
- package/dist/render.js +0 -189
- package/dist/retry.d.ts +0 -11
- package/dist/retry.js +0 -38
- package/dist/state.d.ts +0 -14
- package/dist/state.js +0 -110
package/dist/adapter.d.ts
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type CodeSource, type History, PlatformReview } from "@open-cr-agent/vcs-platform";
|
|
2
2
|
import { type GitHubApi, type PullRequest } from "./client.js";
|
|
3
3
|
export interface GitHubPullRequest {
|
|
4
4
|
owner: string;
|
|
5
5
|
repo: string;
|
|
6
6
|
number: number;
|
|
7
7
|
}
|
|
8
|
-
export type CodeSource = Pick<VcsAdapter, "getDiff" | "readFile" | "searchCode">;
|
|
9
|
-
export interface History {
|
|
10
|
-
filesChangedSince(from: string, to: string): Promise<{
|
|
11
|
-
files: string[];
|
|
12
|
-
} | {
|
|
13
|
-
reason: string;
|
|
14
|
-
}>;
|
|
15
|
-
}
|
|
16
8
|
export interface GitHubAdapterOptions {
|
|
17
9
|
pullRequest: GitHubPullRequest;
|
|
18
10
|
api: GitHubApi;
|
|
@@ -23,39 +15,7 @@ export interface GitHubAdapterOptions {
|
|
|
23
15
|
snapshot?: PullRequest;
|
|
24
16
|
}
|
|
25
17
|
export declare const DEFAULT_BOT_LOGIN = "github-actions[bot]";
|
|
26
|
-
export declare
|
|
27
|
-
export declare class GitHubAdapter implements VcsAdapter {
|
|
28
|
-
private readonly options;
|
|
29
|
-
readonly name = "github";
|
|
30
|
-
private pullRequest;
|
|
31
|
-
private previous;
|
|
32
|
-
private reviewThreads;
|
|
33
|
-
private issueComments;
|
|
34
|
-
private readonly writers;
|
|
35
|
-
private prior;
|
|
18
|
+
export declare class GitHubAdapter extends PlatformReview {
|
|
36
19
|
constructor(options: GitHubAdapterOptions);
|
|
37
|
-
getChangeRequest(): Promise<ChangeRequest>;
|
|
38
|
-
private override;
|
|
39
|
-
private unedited;
|
|
40
|
-
private canWrite;
|
|
41
|
-
getDiff(): Promise<FileDiff[]>;
|
|
42
|
-
readFile(path: string): Promise<string | undefined>;
|
|
43
|
-
searchCode(literal: string): Promise<CodeMatch[]>;
|
|
44
|
-
getPriorReview(): Promise<PriorReview | undefined>;
|
|
45
|
-
private priorState;
|
|
46
|
-
private changesSince;
|
|
47
|
-
private repliesByPeople;
|
|
48
|
-
publish(report: ReviewReport): Promise<{
|
|
49
|
-
warnings: string[];
|
|
50
|
-
}>;
|
|
51
|
-
private syncChangeRequest;
|
|
52
|
-
private resolveFixedThreads;
|
|
53
|
-
private lastEditor;
|
|
54
|
-
private isBot;
|
|
55
|
-
private postReview;
|
|
56
|
-
private threads;
|
|
57
|
-
private pr;
|
|
58
|
-
private comments;
|
|
59
|
-
private summaryComment;
|
|
60
20
|
}
|
|
61
21
|
//# sourceMappingURL=adapter.d.ts.map
|
package/dist/adapter.js
CHANGED
|
@@ -1,297 +1,105 @@
|
|
|
1
|
-
import { errorMessage
|
|
1
|
+
import { errorMessage } from "@open-cr-agent/core";
|
|
2
|
+
import { PlatformReview, } from "@open-cr-agent/vcs-platform";
|
|
2
3
|
import { GitHubApiError, } from "./client.js";
|
|
3
|
-
import { FINDING_MARKER, inlineComment, renderSummary } from "./render.js";
|
|
4
|
-
import { readState, SUMMARY_MARKER } from "./state.js";
|
|
5
4
|
export const DEFAULT_BOT_LOGIN = "github-actions[bot]";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// ("I disagree") keeps the finding reported.
|
|
17
|
-
export function declinesFinding(reply) {
|
|
18
|
-
if (COMMAND.test(reply))
|
|
19
|
-
return true;
|
|
20
|
-
const opening = reply
|
|
21
|
-
.trim()
|
|
22
|
-
.split(/(?<=[.!?])\s|\n/)[0]
|
|
23
|
-
?.trim() ?? "";
|
|
24
|
-
return DECLINE.test(opening) && !opening.endsWith("?");
|
|
5
|
+
// A GitHub pull request as ocra's review conversation (vcs-platform).
|
|
6
|
+
export class GitHubAdapter extends PlatformReview {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
super({
|
|
9
|
+
name: "github",
|
|
10
|
+
platform: new GitHubPlatform(options),
|
|
11
|
+
code: options.code,
|
|
12
|
+
...(options.history ? { history: options.history } : {}),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
25
15
|
}
|
|
26
|
-
|
|
16
|
+
class GitHubPlatform {
|
|
27
17
|
options;
|
|
28
|
-
|
|
18
|
+
text = { changeRequest: "pull request", authority: "write access" };
|
|
29
19
|
pullRequest;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
issueComments;
|
|
33
|
-
writers = new Map();
|
|
34
|
-
prior;
|
|
20
|
+
// The REST comments behind the platform's, for their GraphQL node ids.
|
|
21
|
+
listed = new Map();
|
|
35
22
|
constructor(options) {
|
|
36
23
|
this.options = options;
|
|
37
24
|
}
|
|
38
|
-
|
|
25
|
+
// REST names the Actions bot "github-actions[bot]", GraphQL "github-actions".
|
|
26
|
+
async bot() {
|
|
27
|
+
const login = this.options.botLogin;
|
|
28
|
+
return { login, is: (other) => other === login || `${other}[bot]` === login };
|
|
29
|
+
}
|
|
30
|
+
async changeRequest() {
|
|
39
31
|
const pr = await this.pr();
|
|
40
32
|
const { owner, repo } = this.options.pullRequest;
|
|
41
|
-
const override = await this.override(pr);
|
|
42
33
|
return {
|
|
43
34
|
id: `${owner}/${repo}#${pr.number}`,
|
|
44
35
|
title: pr.title,
|
|
45
36
|
description: pr.body ?? "",
|
|
46
37
|
baseSha: pr.base.sha,
|
|
47
38
|
headSha: pr.head.sha,
|
|
48
|
-
...(
|
|
39
|
+
...(pr.user ? { author: pr.user.login } : {}),
|
|
49
40
|
};
|
|
50
41
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const author = pr.user?.login;
|
|
59
|
-
let found;
|
|
60
|
-
for (const c of comments) {
|
|
61
|
-
const login = c.user?.login;
|
|
62
|
-
const match = OVERRIDE.exec(c.body);
|
|
63
|
-
if (!match || !login || login === author || this.isBot(login))
|
|
64
|
-
continue;
|
|
65
|
-
// ocra's own summary is never a command, whichever account posted it.
|
|
66
|
-
if (c.body.includes(SUMMARY_MARKER))
|
|
67
|
-
continue;
|
|
68
|
-
const [, commit = "", reason = ""] = match;
|
|
69
|
-
// The full commit id: a short prefix can be matched by a new commit.
|
|
70
|
-
if (commit.toLowerCase() !== pr.head.sha)
|
|
71
|
-
continue;
|
|
72
|
-
if (!(await this.unedited(c.node_id, login)) || !(await this.canWrite(login)))
|
|
73
|
-
continue;
|
|
74
|
-
found = { by: login, reason: reason.trim().slice(0, MAX_OVERRIDE_REASON) };
|
|
75
|
-
}
|
|
76
|
-
return found;
|
|
42
|
+
async comments() {
|
|
43
|
+
const comments = await this.options.api.listIssueComments(this.options.pullRequest.number);
|
|
44
|
+
return comments.map((c) => {
|
|
45
|
+
const id = String(c.id);
|
|
46
|
+
this.listed.set(id, c);
|
|
47
|
+
return { id, body: c.body, ...(c.user ? { author: c.user.login } : {}) };
|
|
48
|
+
});
|
|
77
49
|
}
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
async unedited(nodeId, login) {
|
|
50
|
+
// GitHub keeps who edited a comment in GraphQL only, by node id.
|
|
51
|
+
async editor(comment) {
|
|
52
|
+
const nodeId = this.listed.get(comment.id)?.node_id;
|
|
82
53
|
if (!nodeId)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const editor = await this.options.api.commentEditor(nodeId);
|
|
86
|
-
return editor === undefined || editor === login;
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
54
|
+
throw new Error("the comment has no node id to look up its editor");
|
|
55
|
+
return this.options.api.commentEditor(nodeId);
|
|
91
56
|
}
|
|
92
|
-
// Write access from the repository's permissions, memoized per login;
|
|
93
|
-
// a failed lookup means no.
|
|
94
57
|
canWrite(login) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return this.options.code.searchCode(literal);
|
|
110
|
-
}
|
|
111
|
-
async getPriorReview() {
|
|
112
|
-
const prior = await this.priorState();
|
|
113
|
-
if ("untrusted" in prior)
|
|
114
|
-
return { findings: [], fullReviewReason: prior.untrusted };
|
|
115
|
-
const { state } = prior;
|
|
116
|
-
if (!state)
|
|
117
|
-
return undefined;
|
|
118
|
-
const { dismissed, replies } = await this.repliesByPeople(state.findings);
|
|
119
|
-
return {
|
|
120
|
-
findings: state.findings.map((f) => dismissed.has(f.fingerprint) ? { ...f, dismissed: true } : f),
|
|
121
|
-
...(await this.changesSince(state)),
|
|
122
|
-
...(state.tier ? { tier: state.tier } : {}),
|
|
123
|
-
...(Object.keys(replies).length > 0 ? { replies } : {}),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
// Anyone with write access can edit the summary comment, including a pull
|
|
127
|
-
// request's author. Every part of the state is then suspect: a head skips
|
|
128
|
-
// their next changes, a `commented` flag hides a finding from both the
|
|
129
|
-
// inline comments and the summary. So the state counts only when ocra was
|
|
130
|
-
// the last to edit the comment; otherwise the run starts over.
|
|
131
|
-
priorState() {
|
|
132
|
-
this.prior ??= (async () => {
|
|
133
|
-
const comment = await this.summaryComment();
|
|
134
|
-
const state = comment ? readState(comment.body) : undefined;
|
|
135
|
-
if (!comment || !state)
|
|
136
|
-
return {};
|
|
137
|
-
const editor = await this.lastEditor(comment);
|
|
138
|
-
if (editor === undefined) {
|
|
139
|
-
return { untrusted: "could not check who last edited the previous review's summary" };
|
|
140
|
-
}
|
|
141
|
-
if (!this.isBot(editor)) {
|
|
142
|
-
return { untrusted: "the previous review's summary was edited by someone else" };
|
|
143
|
-
}
|
|
144
|
-
return { state };
|
|
145
|
-
})();
|
|
146
|
-
return this.prior;
|
|
147
|
-
}
|
|
148
|
-
async changesSince(state) {
|
|
149
|
-
const { history } = this.options;
|
|
150
|
-
if (!state.head)
|
|
151
|
-
return { fullReviewReason: "the previous review did not record its commit" };
|
|
152
|
-
if (!history)
|
|
153
|
-
return { fullReviewReason: "no repository history to compare with" };
|
|
154
|
-
const head = (await this.pr()).head.sha;
|
|
155
|
-
const changed = await history.filesChangedSince(state.head, head);
|
|
156
|
-
if ("reason" in changed)
|
|
157
|
-
return { fullReviewReason: changed.reason };
|
|
158
|
-
return {
|
|
159
|
-
changedSince: {
|
|
160
|
-
head: state.head,
|
|
161
|
-
files: [...new Set([...changed.files, ...(state.pending ?? [])])],
|
|
162
|
-
},
|
|
163
|
-
};
|
|
58
|
+
return this.options.api.canWrite(login);
|
|
59
|
+
}
|
|
60
|
+
async threads() {
|
|
61
|
+
const threads = await this.options.api.listReviewThreads(this.options.pullRequest.number);
|
|
62
|
+
return threads.map((t) => ({
|
|
63
|
+
id: t.id,
|
|
64
|
+
resolved: t.isResolved,
|
|
65
|
+
...(t.resolvedBy ? { resolvedBy: t.resolvedBy } : {}),
|
|
66
|
+
comments: t.comments.map((c) => ({
|
|
67
|
+
author: c.author,
|
|
68
|
+
body: c.body,
|
|
69
|
+
...(c.editor ? { editor: c.editor } : {}),
|
|
70
|
+
})),
|
|
71
|
+
}));
|
|
164
72
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
// other reply from a reviewer dismisses nothing, but the judge weighs it
|
|
170
|
-
// when the finding comes back (and cannot drop a confirmed critical for
|
|
171
|
-
// it). Replies are collected for every thread of ocra's, not only tracked
|
|
172
|
-
// findings, so a finding a reply argued away does not return without it.
|
|
173
|
-
// Thread data is best effort: without it nothing is dismissed or replied.
|
|
174
|
-
async repliesByPeople(findings) {
|
|
175
|
-
const commented = new Set(findings.filter((f) => f.commented).map((f) => f.fingerprint));
|
|
176
|
-
const author = (await this.pr()).user?.login;
|
|
177
|
-
const reviewer = async (login) => login !== undefined && login !== author && !this.isBot(login) && (await this.canWrite(login));
|
|
178
|
-
const threads = await this.threads().catch(() => []);
|
|
179
|
-
const dismissed = new Set();
|
|
180
|
-
const replies = {};
|
|
181
|
-
for (const thread of threads) {
|
|
182
|
-
const [first, ...rest] = thread.comments;
|
|
183
|
-
const fingerprint = first && FINDING_MARKER.exec(first.body)?.[1];
|
|
184
|
-
if (!first || !fingerprint || !this.isBot(first.author))
|
|
185
|
-
continue;
|
|
186
|
-
const said = [];
|
|
187
|
-
let declined = false;
|
|
188
|
-
for (const r of rest) {
|
|
189
|
-
const own = r.editor === undefined || r.editor === r.author;
|
|
190
|
-
if (!own || !(await reviewer(r.author)))
|
|
191
|
-
continue;
|
|
192
|
-
if (declinesFinding(r.body))
|
|
193
|
-
declined = true;
|
|
194
|
-
else if (r.body.trim() !== "")
|
|
195
|
-
said.push(r.body.trim().slice(0, MAX_REPLY_CHARS));
|
|
196
|
-
}
|
|
197
|
-
if (commented.has(fingerprint)) {
|
|
198
|
-
const resolved = thread.isResolved && (await reviewer(thread.resolvedBy));
|
|
199
|
-
if (resolved || declined) {
|
|
200
|
-
dismissed.add(fingerprint);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
if (said.length > 0)
|
|
205
|
-
replies[fingerprint] = said.slice(-MAX_REPLIES);
|
|
206
|
-
}
|
|
207
|
-
return { dismissed, replies };
|
|
73
|
+
async publishFindings(report, fresh) {
|
|
74
|
+
const changeRequests = await this.syncChangeRequest(report);
|
|
75
|
+
const posted = await this.postReview(report, fresh, changeRequests.request);
|
|
76
|
+
return { posted, warnings: changeRequests.warnings, finish: changeRequests.withdraw };
|
|
208
77
|
}
|
|
209
|
-
async
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const previous = await this.summaryComment();
|
|
213
|
-
const prior = await this.priorState();
|
|
214
|
-
const trusted = "state" in prior ? prior.state : undefined;
|
|
215
|
-
const before = trusted?.findings ?? [];
|
|
216
|
-
const alreadyCommented = new Set([
|
|
217
|
-
...before.filter((f) => f.commented).map((f) => f.fingerprint),
|
|
218
|
-
...(trusted?.posted ?? []),
|
|
219
|
-
]);
|
|
220
|
-
const fresh = report.findings.flatMap((f) => {
|
|
221
|
-
if (alreadyCommented.has(f.fingerprint))
|
|
222
|
-
return [];
|
|
223
|
-
const comment = inlineComment(f);
|
|
224
|
-
return comment ? [{ fingerprint: f.fingerprint, comment }] : [];
|
|
225
|
-
});
|
|
226
|
-
const changeRequests = await this.syncChangeRequest(number, report);
|
|
227
|
-
const posted = await this.postReview(number, pr.head.sha, report, fresh, changeRequests.request);
|
|
228
|
-
const commented = new Set([...alreadyCommented, ...posted]);
|
|
229
|
-
const current = new Set(report.findings.map((f) => f.fingerprint));
|
|
230
|
-
const rereview = report.rereview;
|
|
231
|
-
const quiet = [
|
|
232
|
-
...(rereview?.notReproduced ?? []),
|
|
233
|
-
...(rereview?.notRechecked ?? []),
|
|
234
|
-
...(rereview?.unchanged ?? []),
|
|
235
|
-
...(rereview?.dismissed ?? []),
|
|
236
|
-
];
|
|
237
|
-
// Low-confidence findings (--ultra) are shown, not counted; stored as
|
|
238
|
-
// ordinary findings they would count on the next push.
|
|
239
|
-
const state = [
|
|
240
|
-
...report.findings
|
|
241
|
-
.filter((f) => !f.lowConfidence)
|
|
242
|
-
.map((f) => ({
|
|
243
|
-
fingerprint: f.fingerprint,
|
|
244
|
-
title: f.title,
|
|
245
|
-
file: f.file,
|
|
246
|
-
severity: f.severity,
|
|
247
|
-
commented: commented.has(f.fingerprint),
|
|
248
|
-
...(f.quote ? { quote: f.quote } : {}),
|
|
249
|
-
...(f.verification ? { verification: f.verification } : {}),
|
|
250
|
-
})),
|
|
251
|
-
...quiet.filter((f) => !current.has(f.fingerprint)),
|
|
252
|
-
];
|
|
253
|
-
const pending = report.coverage
|
|
254
|
-
.filter((c) => c.status === "failed" || c.status === "unreviewed")
|
|
255
|
-
.map((c) => c.path);
|
|
256
|
-
const tracked = new Set(state.map((f) => f.fingerprint));
|
|
257
|
-
const untracked = [...commented].filter((fp) => !tracked.has(fp));
|
|
258
|
-
const body = renderSummary({
|
|
259
|
-
report,
|
|
260
|
-
commented,
|
|
261
|
-
state: {
|
|
262
|
-
findings: state,
|
|
263
|
-
head: report.changeRequest.headSha,
|
|
264
|
-
pending,
|
|
265
|
-
tier: report.tier,
|
|
266
|
-
...(untracked.length > 0 ? { posted: untracked } : {}),
|
|
267
|
-
},
|
|
268
|
-
});
|
|
269
|
-
if (previous)
|
|
270
|
-
await this.options.api.updateIssueComment(previous.id, body);
|
|
78
|
+
async writeSummary(existing, body) {
|
|
79
|
+
if (existing)
|
|
80
|
+
await this.options.api.updateIssueComment(Number(existing.id), body);
|
|
271
81
|
else
|
|
272
|
-
await this.options.api.createIssueComment(number, body);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
...(await changeRequests.withdraw()),
|
|
277
|
-
...(await this.resolveFixedThreads(number, report)),
|
|
278
|
-
],
|
|
279
|
-
};
|
|
82
|
+
await this.options.api.createIssueComment(this.options.pullRequest.number, body);
|
|
83
|
+
}
|
|
84
|
+
resolveThread(id) {
|
|
85
|
+
return this.options.api.resolveReviewThread(id);
|
|
280
86
|
}
|
|
281
87
|
// With requestChanges on, ocra's "changes requested" follows the verdict:
|
|
282
88
|
// requested once while it blocks, withdrawn (dismissed) once it no longer
|
|
283
89
|
// does. GitHub keeps a reviewer's last blocking review until it is
|
|
284
90
|
// dismissed, so otherwise a fixed pull request stayed blocked.
|
|
285
|
-
async syncChangeRequest(
|
|
91
|
+
async syncChangeRequest(report) {
|
|
286
92
|
const none = { request: false, warnings: [], withdraw: async () => [] };
|
|
287
93
|
if (this.options.requestChanges !== true)
|
|
288
94
|
return none;
|
|
95
|
+
const { number } = this.options.pullRequest;
|
|
96
|
+
const bot = await this.bot();
|
|
289
97
|
// An override lets the commit pass, so it lifts the request too.
|
|
290
98
|
const blocking = report.verdict === "significant_concerns" && report.changeRequest.override === undefined;
|
|
291
99
|
let active;
|
|
292
100
|
try {
|
|
293
101
|
active = (await this.options.api.listReviews(number))
|
|
294
|
-
.filter((r) => r.state === "CHANGES_REQUESTED" && r.user &&
|
|
102
|
+
.filter((r) => r.state === "CHANGES_REQUESTED" && r.user && bot.is(r.user.login))
|
|
295
103
|
.map((r) => r.id);
|
|
296
104
|
}
|
|
297
105
|
catch (error) {
|
|
@@ -317,48 +125,15 @@ export class GitHubAdapter {
|
|
|
317
125
|
},
|
|
318
126
|
};
|
|
319
127
|
}
|
|
320
|
-
// Resolving threads is a courtesy: the summary already lists what was
|
|
321
|
-
// fixed, so a failure here is a warning, not a failed publish.
|
|
322
|
-
async resolveFixedThreads(number, report) {
|
|
323
|
-
const fixed = new Set((report.rereview?.fixed ?? []).filter((f) => f.commented).map((f) => f.fingerprint));
|
|
324
|
-
if (fixed.size === 0)
|
|
325
|
-
return [];
|
|
326
|
-
try {
|
|
327
|
-
for (const thread of await this.threads()) {
|
|
328
|
-
const first = thread.comments[0];
|
|
329
|
-
const fingerprint = first && FINDING_MARKER.exec(first.body)?.[1];
|
|
330
|
-
if (thread.isResolved || !fingerprint || !fixed.has(fingerprint))
|
|
331
|
-
continue;
|
|
332
|
-
if (!this.isBot(first.author))
|
|
333
|
-
continue;
|
|
334
|
-
await this.options.api.resolveReviewThread(thread.id);
|
|
335
|
-
}
|
|
336
|
-
return [];
|
|
337
|
-
}
|
|
338
|
-
catch (error) {
|
|
339
|
-
return [`could not resolve the threads of fixed findings: ${errorMessage(error)}`];
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
// The account that last edited the comment (ocra itself when nobody did),
|
|
343
|
-
// or undefined when that cannot be told.
|
|
344
|
-
async lastEditor(comment) {
|
|
345
|
-
if (!comment.node_id)
|
|
346
|
-
return undefined;
|
|
347
|
-
try {
|
|
348
|
-
return (await this.options.api.commentEditor(comment.node_id)) ?? this.options.botLogin;
|
|
349
|
-
}
|
|
350
|
-
catch {
|
|
351
|
-
return undefined;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
// REST names the Actions bot "github-actions[bot]", GraphQL "github-actions".
|
|
355
|
-
isBot(login) {
|
|
356
|
-
return login === this.options.botLogin || `${login}[bot]` === this.options.botLogin;
|
|
357
|
-
}
|
|
358
128
|
// Returns the fingerprints that now have inline comments. GitHub rejects
|
|
359
129
|
// positions outside its own diff with 422; those findings then stay in the
|
|
360
130
|
// summary instead of failing the publish.
|
|
361
|
-
async postReview(
|
|
131
|
+
async postReview(report, inline, requestChanges) {
|
|
132
|
+
const { number } = this.options.pullRequest;
|
|
133
|
+
let fresh = inline.map((f) => ({
|
|
134
|
+
fingerprint: f.finding.fingerprint,
|
|
135
|
+
comment: reviewComment(f),
|
|
136
|
+
}));
|
|
362
137
|
// GitHub rejects the whole review when one comment sits on a file it
|
|
363
138
|
// shows no diff for (too large, too many files); those go to the summary.
|
|
364
139
|
if (fresh.length > 0) {
|
|
@@ -369,7 +144,7 @@ export class GitHubAdapter {
|
|
|
369
144
|
if (fresh.length === 0 && !requestChanges)
|
|
370
145
|
return [];
|
|
371
146
|
const review = {
|
|
372
|
-
commit_id:
|
|
147
|
+
commit_id: (await this.pr()).head.sha,
|
|
373
148
|
event: requestChanges ? "REQUEST_CHANGES" : "COMMENT",
|
|
374
149
|
body: `ocra: ${report.findings.length} finding(s); details in the summary comment.`,
|
|
375
150
|
};
|
|
@@ -391,23 +166,20 @@ export class GitHubAdapter {
|
|
|
391
166
|
return [];
|
|
392
167
|
}
|
|
393
168
|
}
|
|
394
|
-
threads() {
|
|
395
|
-
this.reviewThreads ??= this.options.api.listReviewThreads(this.options.pullRequest.number);
|
|
396
|
-
return this.reviewThreads;
|
|
397
|
-
}
|
|
398
169
|
pr() {
|
|
399
170
|
this.pullRequest ??= this.options.snapshot
|
|
400
171
|
? Promise.resolve(this.options.snapshot)
|
|
401
172
|
: this.options.api.getPullRequest(this.options.pullRequest.number);
|
|
402
173
|
return this.pullRequest;
|
|
403
174
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
175
|
+
}
|
|
176
|
+
function reviewComment({ finding, body }) {
|
|
177
|
+
const { start, end } = finding.lineRange;
|
|
178
|
+
const comment = { path: finding.file, line: end, side: "RIGHT", body };
|
|
179
|
+
if (start !== end) {
|
|
180
|
+
comment.start_line = start;
|
|
181
|
+
comment.start_side = "RIGHT";
|
|
182
|
+
}
|
|
183
|
+
return comment;
|
|
412
184
|
}
|
|
413
185
|
//# sourceMappingURL=adapter.js.map
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { MAX_ATTEMPTS, retryDecision } from "@open-cr-agent/vcs-platform";
|
|
1
2
|
import { z } from "zod";
|
|
2
|
-
import { MAX_ATTEMPTS, retryDecision } from "./retry.js";
|
|
3
3
|
// Commit ids reach git as arguments; anything else is refused at the boundary.
|
|
4
4
|
const sha = z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/, "not a commit id");
|
|
5
5
|
const pullRequestSchema = z.object({
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-cr-agent/vcs-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "GitHub pull request adapter for Open-CR-Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"code-review",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"!dist/**/*.map"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@open-cr-agent/core": "0.
|
|
33
|
+
"@open-cr-agent/core": "0.2.0",
|
|
34
|
+
"@open-cr-agent/vcs-platform": "0.2.0",
|
|
34
35
|
"zod": "^4.6.5"
|
|
35
36
|
},
|
|
36
37
|
"publishConfig": {
|
package/dist/render.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { type Finding, type ReviewReport } from "@open-cr-agent/core";
|
|
2
|
-
import type { ReviewComment } from "./client.js";
|
|
3
|
-
import { type ReviewState } from "./state.js";
|
|
4
|
-
export declare function safeMarkdown(text: string): string;
|
|
5
|
-
export declare function codeSpan(text: string): string;
|
|
6
|
-
export declare const FINDING_MARKER: RegExp;
|
|
7
|
-
export declare function inlineBody(f: Finding): string;
|
|
8
|
-
export declare function inlineComment(f: Finding): ReviewComment | undefined;
|
|
9
|
-
export interface SummaryInput {
|
|
10
|
-
report: ReviewReport;
|
|
11
|
-
commented: ReadonlySet<string>;
|
|
12
|
-
state: ReviewState;
|
|
13
|
-
}
|
|
14
|
-
export declare function renderSummary({ report, commented, state }: SummaryInput): string;
|
|
15
|
-
//# sourceMappingURL=render.d.ts.map
|
package/dist/render.js
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { coverageGaps, } from "@open-cr-agent/core";
|
|
2
|
-
import { SUMMARY_MARKER, writeState } from "./state.js";
|
|
3
|
-
// GitHub rejects comments over 65,536 characters.
|
|
4
|
-
const MAX_SUMMARY_CHARS = 65_000;
|
|
5
|
-
const ICON = { critical: "🔴", warning: "🟠", suggestion: "🔵" };
|
|
6
|
-
const VERIFICATION = {
|
|
7
|
-
confirmed: "verified",
|
|
8
|
-
uncertain: "unverified (verifier unsure)",
|
|
9
|
-
unchecked: "not verified",
|
|
10
|
-
};
|
|
11
|
-
// A run that reviewed nothing has no verdict to announce, and one that missed
|
|
12
|
-
// files or could not verify a critical finding says so next to its verdict.
|
|
13
|
-
function headline(report) {
|
|
14
|
-
const { notReviewed, nothingReviewed } = coverageGaps(report.coverage);
|
|
15
|
-
if (nothingReviewed)
|
|
16
|
-
return "⏸️ Not reviewed";
|
|
17
|
-
const incomplete = notReviewed > 0 || report.unverifiedCriticals > 0;
|
|
18
|
-
const overridden = report.verdict === "significant_concerns" && report.changeRequest.override
|
|
19
|
-
? " · overridden"
|
|
20
|
-
: "";
|
|
21
|
-
return `${VERDICT[report.verdict]}${overridden}${incomplete ? " · incomplete" : ""}`;
|
|
22
|
-
}
|
|
23
|
-
// Who overrode a blocking verdict, or how someone entitled to can.
|
|
24
|
-
function overrideNote(report) {
|
|
25
|
-
if (report.verdict !== "significant_concerns")
|
|
26
|
-
return [];
|
|
27
|
-
const override = report.changeRequest.override;
|
|
28
|
-
if (override) {
|
|
29
|
-
return [
|
|
30
|
-
"",
|
|
31
|
-
`**Overridden** by @\u200b${safeMarkdown(override.by)} for ${codeSpan(report.changeRequest.headSha.slice(0, 7))}: ${safeMarkdown(override.reason)}`,
|
|
32
|
-
];
|
|
33
|
-
}
|
|
34
|
-
return [
|
|
35
|
-
"",
|
|
36
|
-
`Someone with write access other than the author can let this commit pass by commenting \`/ocra override ${report.changeRequest.headSha} <reason>\`.`,
|
|
37
|
-
];
|
|
38
|
-
}
|
|
39
|
-
function verification(f) {
|
|
40
|
-
return VERIFICATION[f.verification ?? "unchecked"];
|
|
41
|
-
}
|
|
42
|
-
const VERDICT = {
|
|
43
|
-
approved: "✅ Approved",
|
|
44
|
-
approved_with_comments: "💬 Approved with comments",
|
|
45
|
-
minor_issues: "⚠️ Minor issues",
|
|
46
|
-
significant_concerns: "🛑 Significant concerns",
|
|
47
|
-
};
|
|
48
|
-
// Model text is untrusted: it may not close our markup, mention people,
|
|
49
|
-
// pull in images, spell one of ocra's commands (`/ocra …`, which would count
|
|
50
|
-
// if ocra posted as a person with write access), or become a link at all. A
|
|
51
|
-
// pull request can plant an address for a reviewer to repeat (the adversarial
|
|
52
|
-
// probe saw one), and a bot comment lends it credibility. Inline links lose
|
|
53
|
-
// their `](`, and every `]:` is escaped, so no link reference definition
|
|
54
|
-
// (`[1]: https://…`) can form, at line start or inside a blockquote or list
|
|
55
|
-
// item, and no reference-style link (`[x][1]`, `[x][]`, `[1]`) has anything
|
|
56
|
-
// to resolve to. Web addresses get a zero-width space in their scheme or
|
|
57
|
-
// after `www`, which GitHub's autolinking needs intact, so they read the
|
|
58
|
-
// same and stay text; email addresses already lose theirs at the `@`.
|
|
59
|
-
export function safeMarkdown(text) {
|
|
60
|
-
return text
|
|
61
|
-
.replaceAll("<!--", "<!--")
|
|
62
|
-
.replace(/<\/?[a-zA-Z][^>]*>/g, (tag) => tag.replaceAll("<", "<"))
|
|
63
|
-
.replace(/@(?=[A-Za-z0-9-])/g, "@\u200b")
|
|
64
|
-
.replace(/!\[/g, "!\u200b[")
|
|
65
|
-
.replace(/\/(?=ocra)/gi, "/\u200b")
|
|
66
|
-
.replace(/\b(https?|ftp)(?=:\/\/)/gi, "$1\u200b")
|
|
67
|
-
.replace(/\bwww(?=\.)/gi, "www\u200b")
|
|
68
|
-
.replaceAll("](", "]\\(")
|
|
69
|
-
.replaceAll("]:", "]\\:");
|
|
70
|
-
}
|
|
71
|
-
// File paths come from the diff, so the author controls them: a backtick or
|
|
72
|
-
// newline must not end the code span and let markup through, and angle
|
|
73
|
-
// brackets must not form ocra's HTML-comment markers in the raw body.
|
|
74
|
-
export function codeSpan(text) {
|
|
75
|
-
const safe = text
|
|
76
|
-
.replaceAll("`", "\u02cb")
|
|
77
|
-
.replace(/[\r\n]+/g, " ")
|
|
78
|
-
.replaceAll("<", "\u2039")
|
|
79
|
-
.replaceAll(">", "\u203a");
|
|
80
|
-
return `\`${safe}\``;
|
|
81
|
-
}
|
|
82
|
-
function location(f) {
|
|
83
|
-
if (!f.lineRange)
|
|
84
|
-
return codeSpan(f.file);
|
|
85
|
-
const { start, end } = f.lineRange;
|
|
86
|
-
return codeSpan(`${f.file}:${start === end ? start : `${start}-${end}`}`);
|
|
87
|
-
}
|
|
88
|
-
export const FINDING_MARKER = /<!-- ocra:finding ([0-9a-f]{16}) -->/;
|
|
89
|
-
export function inlineBody(f) {
|
|
90
|
-
const parts = [
|
|
91
|
-
`<!-- ocra:finding ${f.fingerprint} -->`,
|
|
92
|
-
`${ICON[f.severity]} **${safeMarkdown(f.title)}** · ${f.severity} · ${verification(f)} · ${f.reviewer}${f.lowConfidence ? " · low confidence" : ""}`,
|
|
93
|
-
"",
|
|
94
|
-
safeMarkdown(f.body),
|
|
95
|
-
];
|
|
96
|
-
if (f.suggestion)
|
|
97
|
-
parts.push("", `**Suggestion:** ${safeMarkdown(f.suggestion)}`);
|
|
98
|
-
return parts.join("\n");
|
|
99
|
-
}
|
|
100
|
-
export function inlineComment(f) {
|
|
101
|
-
if (!f.lineRange || !f.anchor.inDiff)
|
|
102
|
-
return undefined;
|
|
103
|
-
const comment = {
|
|
104
|
-
path: f.file,
|
|
105
|
-
line: f.lineRange.end,
|
|
106
|
-
side: "RIGHT",
|
|
107
|
-
body: inlineBody(f),
|
|
108
|
-
};
|
|
109
|
-
if (f.lineRange.start !== f.lineRange.end) {
|
|
110
|
-
comment.start_line = f.lineRange.start;
|
|
111
|
-
comment.start_side = "RIGHT";
|
|
112
|
-
}
|
|
113
|
-
return comment;
|
|
114
|
-
}
|
|
115
|
-
export function renderSummary({ report, commented, state }) {
|
|
116
|
-
const counts = ["critical", "warning", "suggestion"]
|
|
117
|
-
.map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`)
|
|
118
|
-
.join(", ");
|
|
119
|
-
const lines = [
|
|
120
|
-
SUMMARY_MARKER,
|
|
121
|
-
`## ocra review · ${headline(report)}`,
|
|
122
|
-
"",
|
|
123
|
-
safeMarkdown(report.summary),
|
|
124
|
-
...overrideNote(report),
|
|
125
|
-
"",
|
|
126
|
-
`**${report.findings.length} finding(s)** (${counts}) · risk tier \`${report.tier}\``,
|
|
127
|
-
];
|
|
128
|
-
if (report.scope?.mode === "incremental") {
|
|
129
|
-
lines.push("", `Reviewed only what changed since ${codeSpan(report.scope.since.slice(0, 7))}.`);
|
|
130
|
-
}
|
|
131
|
-
else if (report.scope) {
|
|
132
|
-
lines.push("", `Reviewed every file again: ${safeMarkdown(report.scope.reason)}.`);
|
|
133
|
-
}
|
|
134
|
-
// Only true while no confirmed critical blocks, and low-confidence
|
|
135
|
-
// findings do not count at all.
|
|
136
|
-
const unverified = report.verdict === "significant_concerns"
|
|
137
|
-
? 0
|
|
138
|
-
: report.findings.filter((f) => f.severity === "critical" && f.verification !== "confirmed" && !f.lowConfidence).length;
|
|
139
|
-
if (unverified > 0) {
|
|
140
|
-
lines.push("", `${unverified} critical finding(s) are not verified, so they cap the verdict at minor issues.`);
|
|
141
|
-
}
|
|
142
|
-
if (report.unverifiedCriticals > 0) {
|
|
143
|
-
lines.push("", `**Incomplete:** verification failed or ran out of budget for ${report.unverifiedCriticals} critical finding(s); check them yourself.`);
|
|
144
|
-
}
|
|
145
|
-
const inSummary = report.findings.filter((f) => !commented.has(f.fingerprint));
|
|
146
|
-
if (inSummary.length > 0) {
|
|
147
|
-
lines.push("", "### Findings outside the diff");
|
|
148
|
-
for (const f of inSummary) {
|
|
149
|
-
lines.push(`- ${ICON[f.severity]} ${location(f)} **${safeMarkdown(f.title)}** _(${verification(f)}${f.lowConfidence ? ", low confidence" : ""})_: ${safeMarkdown(f.body).replaceAll("\n", " ")}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (report.remembered.length > 0) {
|
|
153
|
-
lines.push("", `${report.remembered.length} finding(s) matched \`.ocra/memory.json\` and are not repeated.`);
|
|
154
|
-
}
|
|
155
|
-
const rereview = report.rereview;
|
|
156
|
-
if (rereview && rereview.fixed.length > 0) {
|
|
157
|
-
lines.push("", "### Fixed since the last review");
|
|
158
|
-
for (const f of rereview.fixed)
|
|
159
|
-
lines.push(`- ~~${safeMarkdown(f.title)}~~ ${codeSpan(f.file)}`);
|
|
160
|
-
}
|
|
161
|
-
if (rereview && rereview.notReproduced.length > 0) {
|
|
162
|
-
lines.push("", "### Not reported this time, code unchanged", "", "Still open and counted in the verdict until the code changes or a reviewer dismisses them.", "");
|
|
163
|
-
for (const f of rereview.notReproduced)
|
|
164
|
-
lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
|
|
165
|
-
}
|
|
166
|
-
if (rereview && rereview.unchanged.length > 0) {
|
|
167
|
-
lines.push("", "### Still open in unchanged files", "", "Reported earlier in files not changed since; they count in the verdict.", "");
|
|
168
|
-
for (const f of rereview.unchanged)
|
|
169
|
-
lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
|
|
170
|
-
}
|
|
171
|
-
if (rereview && rereview.dismissed.length > 0) {
|
|
172
|
-
lines.push("", "### Dismissed by reviewers");
|
|
173
|
-
for (const f of rereview.dismissed)
|
|
174
|
-
lines.push(`- ${safeMarkdown(f.title)} ${codeSpan(f.file)}`);
|
|
175
|
-
}
|
|
176
|
-
if (rereview && rereview.notRechecked.length > 0) {
|
|
177
|
-
lines.push("", "### Not re-checked this time", "", "Their files were not reviewed in this run; they stay open and count in the verdict.", "");
|
|
178
|
-
for (const f of rereview.notRechecked)
|
|
179
|
-
lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
|
|
180
|
-
}
|
|
181
|
-
const failed = report.coverage.filter((c) => c.status === "failed" || c.status === "unreviewed");
|
|
182
|
-
const { costUsd, inputTokens, outputTokens } = report.usage;
|
|
183
|
-
lines.push("", "_The verdict is advice from language models that read the change itself, and can be swayed by text in it. Do not use it as a security gate._", "", "<details><summary>Coverage and cost</summary>", "", `${report.coverage.filter((c) => c.status === "reviewed").length} reviewed · ${report.coverage.filter((c) => c.status === "unchanged").length} unchanged since the last review · ${failed.length} not reviewed · ${report.coverage.filter((c) => c.status === "excluded").length} excluded · ${inputTokens} in / ${outputTokens} out tokens · $${costUsd.toFixed(4)}`, ...failed.map((c) => `- not reviewed: ${codeSpan(c.path)}`), "", "</details>");
|
|
184
|
-
const text = lines.join("\n");
|
|
185
|
-
const footer = `\n\n${writeState(state)}`;
|
|
186
|
-
const room = MAX_SUMMARY_CHARS - footer.length;
|
|
187
|
-
return (text.length > room ? `${text.slice(0, room - 40)}\n\n…(truncated)` : text) + footer;
|
|
188
|
-
}
|
|
189
|
-
//# sourceMappingURL=render.js.map
|
package/dist/retry.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export declare const MAX_ATTEMPTS = 3;
|
|
2
|
-
export interface RetryDecision {
|
|
3
|
-
retry: boolean;
|
|
4
|
-
waitMs: number;
|
|
5
|
-
}
|
|
6
|
-
export declare function retryDecision(method: string, idempotent: boolean, outcome: {
|
|
7
|
-
status: number;
|
|
8
|
-
headers: Headers;
|
|
9
|
-
body: string;
|
|
10
|
-
} | "network_error", attempt: number, now?: number): RetryDecision;
|
|
11
|
-
//# sourceMappingURL=retry.d.ts.map
|
package/dist/retry.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
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
|