@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 CHANGED
@@ -1,18 +1,10 @@
1
- import { type ChangeRequest, type CodeMatch, type FileDiff, type PriorReview, type ReviewReport, type VcsAdapter } from "@open-cr-agent/core";
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 function declinesFinding(reply: string): boolean;
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, } from "@open-cr-agent/core";
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
- const OVERRIDE = /^\/ocra override ([0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?)\s+(\S.*)$/m;
7
- const MAX_OVERRIDE_REASON = 300;
8
- const MAX_REPLIES = 3;
9
- const MAX_REPLY_CHARS = 1_000;
10
- const DECLINE = /^(won['’]?t fix|wontfix|will not fix|by design|false positive|not a bug|working as intended|intended behaviou?r)(?=$|[\s.,;:!—–-])/i;
11
- const COMMAND = /^\/ocra dismiss\b/im;
12
- // A dismissal takes a clear decline: an explicit `/ocra dismiss`, or a reply
13
- // that opens with a decline and does not ask. Words anywhere else in a reply
14
- // ("this is not intended", "is this by design?", "acknowledged, will fix")
15
- // do not dismiss, since a dismissed finding leaves the verdict. Disagreement
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
- export class GitHubAdapter {
16
+ class GitHubPlatform {
27
17
  options;
28
- name = "github";
18
+ text = { changeRequest: "pull request", authority: "write access" };
29
19
  pullRequest;
30
- previous;
31
- reviewThreads;
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
- async getChangeRequest() {
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
- ...(override ? { override } : {}),
39
+ ...(pr.user ? { author: pr.user.login } : {}),
49
40
  };
50
41
  }
51
- // "Break glass": `/ocra override <commit> <reason>` from someone with
52
- // write access other than the author lets a blocking verdict pass for that
53
- // commit only, so a later push with new problems needs a new decision. The
54
- // author can never overrule the review of their own change. Comments are
55
- // best effort: without them there is simply no override.
56
- async override(pr) {
57
- const comments = await this.comments().catch(() => []);
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
- // Anyone with write access can edit anyone's comment, and GitHub keeps
79
- // showing the original author: a command counts only when nobody else
80
- // edited it. Unknown means no.
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
- return false;
84
- try {
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
- let known = this.writers.get(login);
96
- if (!known) {
97
- known = this.options.api.canWrite(login).catch(() => false);
98
- this.writers.set(login, known);
99
- }
100
- return known;
101
- }
102
- getDiff() {
103
- return this.options.code.getDiff();
104
- }
105
- readFile(path) {
106
- return this.options.code.readFile(path);
107
- }
108
- searchCode(literal) {
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
- // A thread of ocra's that a reviewer resolved, or answered with a clear
166
- // "won't fix", dismisses its finding. A reviewer is someone with write
167
- // access other than the pull request's author, in a comment nobody else
168
- // edited: otherwise the author could resolve or argue a finding away. Any
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 publish(report) {
210
- const { number } = this.options.pullRequest;
211
- const pr = await this.pr();
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
- return {
274
- warnings: [
275
- ...changeRequests.warnings,
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(number, report) {
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 && this.isBot(r.user.login))
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(number, commitId, report, fresh, requestChanges) {
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: commitId,
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
- comments() {
405
- this.issueComments ??= this.options.api.listIssueComments(this.options.pullRequest.number);
406
- return this.issueComments;
407
- }
408
- summaryComment() {
409
- this.previous ??= this.comments().then((comments) => comments.findLast((c) => c.user?.login === this.options.botLogin && c.body.includes(SUMMARY_MARKER)));
410
- return this.previous;
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
@@ -1,6 +1,4 @@
1
1
  export * from "./adapter.js";
2
2
  export * from "./client.js";
3
3
  export * from "./plugin.js";
4
- export * from "./render.js";
5
- export * from "./state.js";
6
4
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,6 +1,4 @@
1
1
  export * from "./adapter.js";
2
2
  export * from "./client.js";
3
3
  export * from "./plugin.js";
4
- export * from "./render.js";
5
- export * from "./state.js";
6
4
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/vcs-github",
3
- "version": "0.1.2",
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.1.2",
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("<!--", "&lt;!--")
62
- .replace(/<\/?[a-zA-Z][^>]*>/g, (tag) => tag.replaceAll("<", "&lt;"))
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