@open-cr-agent/vcs-github 0.1.1 → 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,294 +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
- const [, commit = "", reason = ""] = match;
66
- // The full commit id: a short prefix can be matched by a new commit.
67
- if (commit.toLowerCase() !== pr.head.sha)
68
- continue;
69
- if (!(await this.unedited(c.node_id, login)) || !(await this.canWrite(login)))
70
- continue;
71
- found = { by: login, reason: reason.trim().slice(0, MAX_OVERRIDE_REASON) };
72
- }
73
- 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
+ });
74
49
  }
75
- // Anyone with write access can edit anyone's comment, and GitHub keeps
76
- // showing the original author: a command counts only when nobody else
77
- // edited it. Unknown means no.
78
- 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;
79
53
  if (!nodeId)
80
- return false;
81
- try {
82
- const editor = await this.options.api.commentEditor(nodeId);
83
- return editor === undefined || editor === login;
84
- }
85
- catch {
86
- return false;
87
- }
54
+ throw new Error("the comment has no node id to look up its editor");
55
+ return this.options.api.commentEditor(nodeId);
88
56
  }
89
- // Write access from the repository's permissions, memoized per login;
90
- // a failed lookup means no.
91
57
  canWrite(login) {
92
- let known = this.writers.get(login);
93
- if (!known) {
94
- known = this.options.api.canWrite(login).catch(() => false);
95
- this.writers.set(login, known);
96
- }
97
- return known;
98
- }
99
- getDiff() {
100
- return this.options.code.getDiff();
101
- }
102
- readFile(path) {
103
- return this.options.code.readFile(path);
104
- }
105
- searchCode(literal) {
106
- return this.options.code.searchCode(literal);
107
- }
108
- async getPriorReview() {
109
- const prior = await this.priorState();
110
- if ("untrusted" in prior)
111
- return { findings: [], fullReviewReason: prior.untrusted };
112
- const { state } = prior;
113
- if (!state)
114
- return undefined;
115
- const { dismissed, replies } = await this.repliesByPeople(state.findings);
116
- return {
117
- findings: state.findings.map((f) => dismissed.has(f.fingerprint) ? { ...f, dismissed: true } : f),
118
- ...(await this.changesSince(state)),
119
- ...(state.tier ? { tier: state.tier } : {}),
120
- ...(Object.keys(replies).length > 0 ? { replies } : {}),
121
- };
122
- }
123
- // Anyone with write access can edit the summary comment, including a pull
124
- // request's author. Every part of the state is then suspect: a head skips
125
- // their next changes, a `commented` flag hides a finding from both the
126
- // inline comments and the summary. So the state counts only when ocra was
127
- // the last to edit the comment; otherwise the run starts over.
128
- priorState() {
129
- this.prior ??= (async () => {
130
- const comment = await this.summaryComment();
131
- const state = comment ? readState(comment.body) : undefined;
132
- if (!comment || !state)
133
- return {};
134
- const editor = await this.lastEditor(comment);
135
- if (editor === undefined) {
136
- return { untrusted: "could not check who last edited the previous review's summary" };
137
- }
138
- if (!this.isBot(editor)) {
139
- return { untrusted: "the previous review's summary was edited by someone else" };
140
- }
141
- return { state };
142
- })();
143
- return this.prior;
144
- }
145
- async changesSince(state) {
146
- const { history } = this.options;
147
- if (!state.head)
148
- return { fullReviewReason: "the previous review did not record its commit" };
149
- if (!history)
150
- return { fullReviewReason: "no repository history to compare with" };
151
- const head = (await this.pr()).head.sha;
152
- const changed = await history.filesChangedSince(state.head, head);
153
- if ("reason" in changed)
154
- return { fullReviewReason: changed.reason };
155
- return {
156
- changedSince: {
157
- head: state.head,
158
- files: [...new Set([...changed.files, ...(state.pending ?? [])])],
159
- },
160
- };
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
+ }));
161
72
  }
162
- // A thread of ocra's that a reviewer resolved, or answered with a clear
163
- // "won't fix", dismisses its finding. A reviewer is someone with write
164
- // access other than the pull request's author, in a comment nobody else
165
- // edited: otherwise the author could resolve or argue a finding away. Any
166
- // other reply from a reviewer dismisses nothing, but the judge weighs it
167
- // when the finding comes back (and cannot drop a confirmed critical for
168
- // it). Replies are collected for every thread of ocra's, not only tracked
169
- // findings, so a finding a reply argued away does not return without it.
170
- // Thread data is best effort: without it nothing is dismissed or replied.
171
- async repliesByPeople(findings) {
172
- const commented = new Set(findings.filter((f) => f.commented).map((f) => f.fingerprint));
173
- const author = (await this.pr()).user?.login;
174
- const reviewer = async (login) => login !== undefined && login !== author && !this.isBot(login) && (await this.canWrite(login));
175
- const threads = await this.threads().catch(() => []);
176
- const dismissed = new Set();
177
- const replies = {};
178
- for (const thread of threads) {
179
- const [first, ...rest] = thread.comments;
180
- const fingerprint = first && FINDING_MARKER.exec(first.body)?.[1];
181
- if (!first || !fingerprint || !this.isBot(first.author))
182
- continue;
183
- const said = [];
184
- let declined = false;
185
- for (const r of rest) {
186
- const own = r.editor === undefined || r.editor === r.author;
187
- if (!own || !(await reviewer(r.author)))
188
- continue;
189
- if (declinesFinding(r.body))
190
- declined = true;
191
- else if (r.body.trim() !== "")
192
- said.push(r.body.trim().slice(0, MAX_REPLY_CHARS));
193
- }
194
- if (commented.has(fingerprint)) {
195
- const resolved = thread.isResolved && (await reviewer(thread.resolvedBy));
196
- if (resolved || declined) {
197
- dismissed.add(fingerprint);
198
- continue;
199
- }
200
- }
201
- if (said.length > 0)
202
- replies[fingerprint] = said.slice(-MAX_REPLIES);
203
- }
204
- 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 };
205
77
  }
206
- async publish(report) {
207
- const { number } = this.options.pullRequest;
208
- const pr = await this.pr();
209
- const previous = await this.summaryComment();
210
- const prior = await this.priorState();
211
- const trusted = "state" in prior ? prior.state : undefined;
212
- const before = trusted?.findings ?? [];
213
- const alreadyCommented = new Set([
214
- ...before.filter((f) => f.commented).map((f) => f.fingerprint),
215
- ...(trusted?.posted ?? []),
216
- ]);
217
- const fresh = report.findings.flatMap((f) => {
218
- if (alreadyCommented.has(f.fingerprint))
219
- return [];
220
- const comment = inlineComment(f);
221
- return comment ? [{ fingerprint: f.fingerprint, comment }] : [];
222
- });
223
- const changeRequests = await this.syncChangeRequest(number, report);
224
- const posted = await this.postReview(number, pr.head.sha, report, fresh, changeRequests.request);
225
- const commented = new Set([...alreadyCommented, ...posted]);
226
- const current = new Set(report.findings.map((f) => f.fingerprint));
227
- const rereview = report.rereview;
228
- const quiet = [
229
- ...(rereview?.notReproduced ?? []),
230
- ...(rereview?.notRechecked ?? []),
231
- ...(rereview?.unchanged ?? []),
232
- ...(rereview?.dismissed ?? []),
233
- ];
234
- // Low-confidence findings (--ultra) are shown, not counted; stored as
235
- // ordinary findings they would count on the next push.
236
- const state = [
237
- ...report.findings
238
- .filter((f) => !f.lowConfidence)
239
- .map((f) => ({
240
- fingerprint: f.fingerprint,
241
- title: f.title,
242
- file: f.file,
243
- severity: f.severity,
244
- commented: commented.has(f.fingerprint),
245
- ...(f.quote ? { quote: f.quote } : {}),
246
- ...(f.verification ? { verification: f.verification } : {}),
247
- })),
248
- ...quiet.filter((f) => !current.has(f.fingerprint)),
249
- ];
250
- const pending = report.coverage
251
- .filter((c) => c.status === "failed" || c.status === "unreviewed")
252
- .map((c) => c.path);
253
- const tracked = new Set(state.map((f) => f.fingerprint));
254
- const untracked = [...commented].filter((fp) => !tracked.has(fp));
255
- const body = renderSummary({
256
- report,
257
- commented,
258
- state: {
259
- findings: state,
260
- head: report.changeRequest.headSha,
261
- pending,
262
- tier: report.tier,
263
- ...(untracked.length > 0 ? { posted: untracked } : {}),
264
- },
265
- });
266
- if (previous)
267
- 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);
268
81
  else
269
- await this.options.api.createIssueComment(number, body);
270
- return {
271
- warnings: [
272
- ...changeRequests.warnings,
273
- ...(await changeRequests.withdraw()),
274
- ...(await this.resolveFixedThreads(number, report)),
275
- ],
276
- };
82
+ await this.options.api.createIssueComment(this.options.pullRequest.number, body);
83
+ }
84
+ resolveThread(id) {
85
+ return this.options.api.resolveReviewThread(id);
277
86
  }
278
87
  // With requestChanges on, ocra's "changes requested" follows the verdict:
279
88
  // requested once while it blocks, withdrawn (dismissed) once it no longer
280
89
  // does. GitHub keeps a reviewer's last blocking review until it is
281
90
  // dismissed, so otherwise a fixed pull request stayed blocked.
282
- async syncChangeRequest(number, report) {
91
+ async syncChangeRequest(report) {
283
92
  const none = { request: false, warnings: [], withdraw: async () => [] };
284
93
  if (this.options.requestChanges !== true)
285
94
  return none;
95
+ const { number } = this.options.pullRequest;
96
+ const bot = await this.bot();
286
97
  // An override lets the commit pass, so it lifts the request too.
287
98
  const blocking = report.verdict === "significant_concerns" && report.changeRequest.override === undefined;
288
99
  let active;
289
100
  try {
290
101
  active = (await this.options.api.listReviews(number))
291
- .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))
292
103
  .map((r) => r.id);
293
104
  }
294
105
  catch (error) {
@@ -314,48 +125,15 @@ export class GitHubAdapter {
314
125
  },
315
126
  };
316
127
  }
317
- // Resolving threads is a courtesy: the summary already lists what was
318
- // fixed, so a failure here is a warning, not a failed publish.
319
- async resolveFixedThreads(number, report) {
320
- const fixed = new Set((report.rereview?.fixed ?? []).filter((f) => f.commented).map((f) => f.fingerprint));
321
- if (fixed.size === 0)
322
- return [];
323
- try {
324
- for (const thread of await this.threads()) {
325
- const first = thread.comments[0];
326
- const fingerprint = first && FINDING_MARKER.exec(first.body)?.[1];
327
- if (thread.isResolved || !fingerprint || !fixed.has(fingerprint))
328
- continue;
329
- if (!this.isBot(first.author))
330
- continue;
331
- await this.options.api.resolveReviewThread(thread.id);
332
- }
333
- return [];
334
- }
335
- catch (error) {
336
- return [`could not resolve the threads of fixed findings: ${errorMessage(error)}`];
337
- }
338
- }
339
- // The account that last edited the comment (ocra itself when nobody did),
340
- // or undefined when that cannot be told.
341
- async lastEditor(comment) {
342
- if (!comment.node_id)
343
- return undefined;
344
- try {
345
- return (await this.options.api.commentEditor(comment.node_id)) ?? this.options.botLogin;
346
- }
347
- catch {
348
- return undefined;
349
- }
350
- }
351
- // REST names the Actions bot "github-actions[bot]", GraphQL "github-actions".
352
- isBot(login) {
353
- return login === this.options.botLogin || `${login}[bot]` === this.options.botLogin;
354
- }
355
128
  // Returns the fingerprints that now have inline comments. GitHub rejects
356
129
  // positions outside its own diff with 422; those findings then stay in the
357
130
  // summary instead of failing the publish.
358
- 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
+ }));
359
137
  // GitHub rejects the whole review when one comment sits on a file it
360
138
  // shows no diff for (too large, too many files); those go to the summary.
361
139
  if (fresh.length > 0) {
@@ -366,7 +144,7 @@ export class GitHubAdapter {
366
144
  if (fresh.length === 0 && !requestChanges)
367
145
  return [];
368
146
  const review = {
369
- commit_id: commitId,
147
+ commit_id: (await this.pr()).head.sha,
370
148
  event: requestChanges ? "REQUEST_CHANGES" : "COMMENT",
371
149
  body: `ocra: ${report.findings.length} finding(s); details in the summary comment.`,
372
150
  };
@@ -388,23 +166,20 @@ export class GitHubAdapter {
388
166
  return [];
389
167
  }
390
168
  }
391
- threads() {
392
- this.reviewThreads ??= this.options.api.listReviewThreads(this.options.pullRequest.number);
393
- return this.reviewThreads;
394
- }
395
169
  pr() {
396
170
  this.pullRequest ??= this.options.snapshot
397
171
  ? Promise.resolve(this.options.snapshot)
398
172
  : this.options.api.getPullRequest(this.options.pullRequest.number);
399
173
  return this.pullRequest;
400
174
  }
401
- comments() {
402
- this.issueComments ??= this.options.api.listIssueComments(this.options.pullRequest.number);
403
- return this.issueComments;
404
- }
405
- summaryComment() {
406
- this.previous ??= this.comments().then((comments) => comments.findLast((c) => c.user?.login === this.options.botLogin && c.body.includes(SUMMARY_MARKER)));
407
- return this.previous;
408
- }
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;
409
184
  }
410
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.1",
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.1",
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,182 +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, or render as a link that says one thing and goes elsewhere
50
- // (a bot comment lends it credibility). Inline links lose their `](`, and
51
- // every `]:` is escaped, so no link reference definition (`[1]: https://…`)
52
- // can form, at line start or inside a blockquote or list item, and no
53
- // reference-style link (`[x][1]`, `[x][]`, `[1]`) has anything to resolve to.
54
- // Bare URLs still show as text.
55
- export function safeMarkdown(text) {
56
- return text
57
- .replaceAll("<!--", "&lt;!--")
58
- .replace(/<\/?[a-zA-Z][^>]*>/g, (tag) => tag.replaceAll("<", "&lt;"))
59
- .replace(/@(?=[A-Za-z0-9-])/g, "@\u200b")
60
- .replace(/!\[/g, "!\u200b[")
61
- .replaceAll("](", "]\\(")
62
- .replaceAll("]:", "]\\:");
63
- }
64
- // File paths come from the diff, so the author controls them: a backtick or
65
- // newline must not end the code span and let markup through, and angle
66
- // brackets must not form ocra's HTML-comment markers in the raw body.
67
- export function codeSpan(text) {
68
- const safe = text
69
- .replaceAll("`", "\u02cb")
70
- .replace(/[\r\n]+/g, " ")
71
- .replaceAll("<", "\u2039")
72
- .replaceAll(">", "\u203a");
73
- return `\`${safe}\``;
74
- }
75
- function location(f) {
76
- if (!f.lineRange)
77
- return codeSpan(f.file);
78
- const { start, end } = f.lineRange;
79
- return codeSpan(`${f.file}:${start === end ? start : `${start}-${end}`}`);
80
- }
81
- export const FINDING_MARKER = /<!-- ocra:finding ([0-9a-f]{16}) -->/;
82
- export function inlineBody(f) {
83
- const parts = [
84
- `<!-- ocra:finding ${f.fingerprint} -->`,
85
- `${ICON[f.severity]} **${safeMarkdown(f.title)}** · ${f.severity} · ${verification(f)} · ${f.reviewer}${f.lowConfidence ? " · low confidence" : ""}`,
86
- "",
87
- safeMarkdown(f.body),
88
- ];
89
- if (f.suggestion)
90
- parts.push("", `**Suggestion:** ${safeMarkdown(f.suggestion)}`);
91
- return parts.join("\n");
92
- }
93
- export function inlineComment(f) {
94
- if (!f.lineRange || !f.anchor.inDiff)
95
- return undefined;
96
- const comment = {
97
- path: f.file,
98
- line: f.lineRange.end,
99
- side: "RIGHT",
100
- body: inlineBody(f),
101
- };
102
- if (f.lineRange.start !== f.lineRange.end) {
103
- comment.start_line = f.lineRange.start;
104
- comment.start_side = "RIGHT";
105
- }
106
- return comment;
107
- }
108
- export function renderSummary({ report, commented, state }) {
109
- const counts = ["critical", "warning", "suggestion"]
110
- .map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`)
111
- .join(", ");
112
- const lines = [
113
- SUMMARY_MARKER,
114
- `## ocra review · ${headline(report)}`,
115
- "",
116
- safeMarkdown(report.summary),
117
- ...overrideNote(report),
118
- "",
119
- `**${report.findings.length} finding(s)** (${counts}) · risk tier \`${report.tier}\``,
120
- ];
121
- if (report.scope?.mode === "incremental") {
122
- lines.push("", `Reviewed only what changed since ${codeSpan(report.scope.since.slice(0, 7))}.`);
123
- }
124
- else if (report.scope) {
125
- lines.push("", `Reviewed every file again: ${safeMarkdown(report.scope.reason)}.`);
126
- }
127
- // Only true while no confirmed critical blocks, and low-confidence
128
- // findings do not count at all.
129
- const unverified = report.verdict === "significant_concerns"
130
- ? 0
131
- : report.findings.filter((f) => f.severity === "critical" && f.verification !== "confirmed" && !f.lowConfidence).length;
132
- if (unverified > 0) {
133
- lines.push("", `${unverified} critical finding(s) are not verified, so they cap the verdict at minor issues.`);
134
- }
135
- if (report.unverifiedCriticals > 0) {
136
- lines.push("", `**Incomplete:** verification failed or ran out of budget for ${report.unverifiedCriticals} critical finding(s); check them yourself.`);
137
- }
138
- const inSummary = report.findings.filter((f) => !commented.has(f.fingerprint));
139
- if (inSummary.length > 0) {
140
- lines.push("", "### Findings outside the diff");
141
- for (const f of inSummary) {
142
- lines.push(`- ${ICON[f.severity]} ${location(f)} **${safeMarkdown(f.title)}** _(${verification(f)}${f.lowConfidence ? ", low confidence" : ""})_: ${safeMarkdown(f.body).replaceAll("\n", " ")}`);
143
- }
144
- }
145
- if (report.remembered.length > 0) {
146
- lines.push("", `${report.remembered.length} finding(s) matched \`.ocra/memory.json\` and are not repeated.`);
147
- }
148
- const rereview = report.rereview;
149
- if (rereview && rereview.fixed.length > 0) {
150
- lines.push("", "### Fixed since the last review");
151
- for (const f of rereview.fixed)
152
- lines.push(`- ~~${safeMarkdown(f.title)}~~ ${codeSpan(f.file)}`);
153
- }
154
- if (rereview && rereview.notReproduced.length > 0) {
155
- lines.push("", "### Not reported this time, code unchanged", "", "Still open and counted in the verdict until the code changes or a reviewer dismisses them.", "");
156
- for (const f of rereview.notReproduced)
157
- lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
158
- }
159
- if (rereview && rereview.unchanged.length > 0) {
160
- lines.push("", "### Still open in unchanged files", "", "Reported earlier in files not changed since; they count in the verdict.", "");
161
- for (const f of rereview.unchanged)
162
- lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
163
- }
164
- if (rereview && rereview.dismissed.length > 0) {
165
- lines.push("", "### Dismissed by reviewers");
166
- for (const f of rereview.dismissed)
167
- lines.push(`- ${safeMarkdown(f.title)} ${codeSpan(f.file)}`);
168
- }
169
- if (rereview && rereview.notRechecked.length > 0) {
170
- lines.push("", "### Not re-checked this time", "", "Their files were not reviewed in this run; they stay open and count in the verdict.", "");
171
- for (const f of rereview.notRechecked)
172
- lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
173
- }
174
- const failed = report.coverage.filter((c) => c.status === "failed" || c.status === "unreviewed");
175
- const { costUsd, inputTokens, outputTokens } = report.usage;
176
- 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>");
177
- const text = lines.join("\n");
178
- const footer = `\n\n${writeState(state)}`;
179
- const room = MAX_SUMMARY_CHARS - footer.length;
180
- return (text.length > room ? `${text.slice(0, room - 40)}\n\n…(truncated)` : text) + footer;
181
- }
182
- //# 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