@open-cr-agent/vcs-platform 0.4.0 → 0.6.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/http.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ export interface PlatformApi {
3
+ platform: string;
4
+ headers: Record<string, string>;
5
+ fetch: typeof fetch;
6
+ sleep?: ((ms: number) => Promise<void>) | undefined;
7
+ timeoutMs: (method: string, path: string) => number;
8
+ toError: (status: number, message: string) => Error;
9
+ }
10
+ export interface PlatformCall {
11
+ method: string;
12
+ url: string;
13
+ path: string;
14
+ body?: unknown;
15
+ idempotent?: boolean;
16
+ }
17
+ export declare function sendWithRetry(api: PlatformApi, call: PlatformCall): Promise<unknown>;
18
+ export declare const pageInfoSchema: z.ZodObject<{
19
+ hasNextPage: z.ZodBoolean;
20
+ endCursor: z.ZodNullable<z.ZodString>;
21
+ }, z.core.$strip>;
22
+ //# sourceMappingURL=http.d.ts.map
package/dist/http.js ADDED
@@ -0,0 +1,53 @@
1
+ import { setTimeout as wait } from "node:timers/promises";
2
+ import { z } from "zod";
3
+ import { retryDecision } from "./retry.js";
4
+ const USER_AGENT = "open-cr-agent";
5
+ // How much of a refused request's answer an error quotes.
6
+ const MAX_DETAIL_CHARS = 500;
7
+ // Sends a JSON request to a platform's API, retrying rate limits, outages
8
+ // and lost connections a bounded number of times; resolves to the parsed
9
+ // answer, or undefined for a 204.
10
+ export async function sendWithRetry(api, call) {
11
+ const { method, url, path, body } = call;
12
+ const idempotent = call.idempotent ?? method !== "POST";
13
+ const sleep = api.sleep ?? ((ms) => wait(ms));
14
+ for (let attempt = 1;; attempt += 1) {
15
+ const init = {
16
+ method,
17
+ headers: {
18
+ ...api.headers,
19
+ "User-Agent": USER_AGENT,
20
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
21
+ },
22
+ signal: AbortSignal.timeout(api.timeoutMs(method, path)),
23
+ };
24
+ if (body !== undefined)
25
+ init.body = JSON.stringify(body);
26
+ let response;
27
+ try {
28
+ response = await api.fetch(url, init);
29
+ }
30
+ catch (error) {
31
+ const next = retryDecision(method, idempotent, "network_error", attempt);
32
+ if (!next.retry)
33
+ throw error;
34
+ await sleep(next.waitMs);
35
+ continue;
36
+ }
37
+ if (response.ok)
38
+ return response.status === 204 ? undefined : response.json();
39
+ const detail = (await response.text().catch(() => "")).slice(0, MAX_DETAIL_CHARS);
40
+ const next = retryDecision(method, idempotent, { status: response.status, headers: response.headers, body: detail }, attempt);
41
+ if (!next.retry) {
42
+ const where = `${api.platform} ${method} ${path.split("?")[0]}`;
43
+ throw api.toError(response.status, `${where} failed with ${response.status}: ${detail}`);
44
+ }
45
+ await sleep(next.waitMs);
46
+ }
47
+ }
48
+ // Both platforms' GraphQL APIs page connections the same way.
49
+ export const pageInfoSchema = z.object({
50
+ hasNextPage: z.boolean(),
51
+ endCursor: z.string().nullable(),
52
+ });
53
+ //# sourceMappingURL=http.js.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
+ export { changeRequestSchema, codeSourceSchema, commitIdSchema, fetchSchema, historySchema, } from "./options.js";
1
2
  export type { Bot, InlineFinding, PlatformChangeRequest, PlatformComment, PlatformThread, PublishedFindings, ReviewPlatform, } from "./platform.js";
2
3
  export { MAX_ATTEMPTS, type RetryDecision, retryDecision } from "./retry.js";
3
4
  export { type CodeSource, type History, PlatformReview, type PlatformReviewOptions, } from "./review.js";
5
+ export { githubSuggestion, gitlabSuggestion, type SuggestionFence, } from "./suggestion.js";
6
+ export type { ChangeRequestRef, LocalCode, PlatformTarget, ResolveTargetOptions, } from "./target.js";
4
7
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ // The review conversation every platform adapter shares (ADR-0016), for
2
+ // ocra's own adapters. Not a contract yet: a minor release may change it.
3
+ export { changeRequestSchema, codeSourceSchema, commitIdSchema, fetchSchema, historySchema, } from "./options.js";
1
4
  export { MAX_ATTEMPTS, retryDecision } from "./retry.js";
2
5
  export { PlatformReview, } from "./review.js";
6
+ export { githubSuggestion, gitlabSuggestion, } from "./suggestion.js";
3
7
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,4 @@
1
+ export { type PlatformApi, type PlatformCall, pageInfoSchema, sendWithRetry, } from "./http.js";
1
2
  export { safeMarkdown } from "./neutralize.js";
2
3
  export { renderSummary } from "./render.js";
3
4
  export { declinesFinding } from "./review.js";
package/dist/internal.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // "@open-cr-agent/vcs-platform/internal": what the platform adapters' tests
2
2
  // check against. Not a contract: any release may change it.
3
+ export { pageInfoSchema, sendWithRetry, } from "./http.js";
3
4
  export { safeMarkdown } from "./neutralize.js";
4
5
  export { renderSummary } from "./render.js";
5
6
  export { declinesFinding } from "./review.js";
@@ -1,3 +1,4 @@
1
+ import { at } from "@open-cr-agent/core/internal";
1
2
  // Model text is untrusted: it may not close our markup, mention people,
2
3
  // pull in images, spell one of ocra's commands (`/ocra …`, which would count
3
4
  // if ocra posted as a person with write access), start a line with a slash
@@ -38,15 +39,15 @@ export function safeMarkdown(text, { startsLine = false } = {}) {
38
39
  const lines = text.split("\n");
39
40
  const out = [];
40
41
  for (let i = 0; i < lines.length; i++) {
41
- const line = lines[i];
42
+ const line = at(lines, i);
42
43
  const opener = i > 0 || startsLine ? FENCE_OPENER.exec(line) : null;
43
- const end = opener ? closingFence(lines, i + 1, opener[1].length) : -1;
44
+ const end = opener ? closingFence(lines, i + 1, at(opener, 1).length) : -1;
44
45
  if (end === -1) {
45
46
  out.push(neutralizeInline(line));
46
47
  continue;
47
48
  }
48
49
  for (let j = i; j <= end; j++)
49
- out.push(neutralizeCode(lines[j]));
50
+ out.push(neutralizeCode(at(lines, j)));
50
51
  i = end;
51
52
  }
52
53
  return out.join("\n");
@@ -54,7 +55,7 @@ export function safeMarkdown(text, { startsLine = false } = {}) {
54
55
  function closingFence(lines, from, length) {
55
56
  const closer = new RegExp(`^ {0,3}\`{${length},}[ \\t]*$`);
56
57
  for (let j = from; j < lines.length; j++)
57
- if (closer.test(lines[j]))
58
+ if (closer.test(at(lines, j)))
58
59
  return j;
59
60
  return -1;
60
61
  }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import type { CodeSource, History } from "./review.js";
3
+ export declare const codeSourceSchema: z.ZodCustom<CodeSource, CodeSource>;
4
+ export declare const historySchema: z.ZodCustom<History, History>;
5
+ export declare const fetchSchema: z.ZodCustom<typeof fetch, typeof fetch>;
6
+ export declare const commitIdSchema: z.ZodString;
7
+ export declare const changeRequestSchema: z.ZodObject<{
8
+ id: z.ZodString;
9
+ title: z.ZodString;
10
+ description: z.ZodString;
11
+ baseSha: z.ZodString;
12
+ headSha: z.ZodString;
13
+ author: z.ZodExactOptional<z.ZodString>;
14
+ }, z.core.$strip>;
15
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ // What every platform adapter's registry options share. The code source and
3
+ // the history are ports the caller implements, so only their shape can be
4
+ // checked; the change request is data and is validated in full.
5
+ const hasMethods = (value, methods) => typeof value === "object" &&
6
+ value !== null &&
7
+ methods.every((m) => typeof value[m] === "function");
8
+ export const codeSourceSchema = z.custom((value) => hasMethods(value, ["getDiff", "readFile", "searchCode"]), "code must provide getDiff, readFile and searchCode");
9
+ export const historySchema = z.custom((value) => hasMethods(value, ["filesChangedSince"]), "history must provide filesChangedSince");
10
+ export const fetchSchema = z.custom((value) => typeof value === "function");
11
+ // Commit ids reach git as arguments; anything else is refused at the boundary.
12
+ export const commitIdSchema = z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/, "not a commit id");
13
+ // The change request as the diff under review was built from it.
14
+ export const changeRequestSchema = z.object({
15
+ id: z.string().min(1),
16
+ title: z.string(),
17
+ description: z.string(),
18
+ baseSha: commitIdSchema,
19
+ headSha: commitIdSchema,
20
+ author: z.string().exactOptional(),
21
+ });
22
+ //# sourceMappingURL=options.js.map
@@ -1,7 +1,9 @@
1
1
  import type { Finding, ReviewReport } from "@open-cr-agent/core";
2
2
  import type { PlatformText } from "./render.js";
3
+ import type { SuggestionFence } from "./suggestion.js";
3
4
  export interface ReviewPlatform {
4
5
  readonly text: PlatformText;
6
+ readonly suggestionFence: SuggestionFence;
5
7
  bot(): Promise<Bot>;
6
8
  changeRequest(): Promise<PlatformChangeRequest>;
7
9
  comments(): Promise<PlatformComment[]>;
package/dist/render.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { type Finding, type ReviewReport } from "@open-cr-agent/core";
2
2
  import { type ReviewState } from "./state.js";
3
+ import { type SuggestionFence } from "./suggestion.js";
3
4
  export interface PlatformText {
4
5
  changeRequest: string;
5
6
  authority: string;
6
7
  }
7
- export declare function codeSpan(text: string): string;
8
8
  export declare const FINDING_MARKER: RegExp;
9
- export declare function inlineBody(f: Finding): string;
9
+ export declare function inlineBody(f: Finding, fence: SuggestionFence): string;
10
10
  export interface SummaryInput {
11
11
  report: ReviewReport;
12
12
  commented: ReadonlySet<string>;
package/dist/render.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { coverageGaps, } from "@open-cr-agent/core";
2
2
  import { safeMarkdown } from "./neutralize.js";
3
3
  import { SUMMARY_MARKER, writeState } from "./state.js";
4
+ import { suggestionBlock } from "./suggestion.js";
4
5
  // GitHub rejects comments over 65,536 characters; GitLab takes 1,000,000,
5
6
  // but a summary that long is no use to anyone.
6
7
  const MAX_SUMMARY_CHARS = 65_000;
@@ -56,7 +57,7 @@ const VERDICT = {
56
57
  // File paths come from the diff, so the author controls them: a backtick or
57
58
  // newline must not end the code span and let markup through, and angle
58
59
  // brackets must not form ocra's HTML-comment markers in the raw body.
59
- export function codeSpan(text) {
60
+ function codeSpan(text) {
60
61
  const safe = text
61
62
  .replaceAll("`", "\u02cb")
62
63
  .replace(/[\r\n]+/g, " ")
@@ -70,8 +71,9 @@ function location(f) {
70
71
  const { start, end } = f.lineRange;
71
72
  return codeSpan(`${f.file}:${start === end ? start : `${start}-${end}`}`);
72
73
  }
74
+ const TILDE_FENCE = /^ {0,3}~{3,}/m;
73
75
  export const FINDING_MARKER = /<!-- ocra:finding ([0-9a-f]{16}) -->/;
74
- export function inlineBody(f) {
76
+ export function inlineBody(f, fence) {
75
77
  const parts = [
76
78
  `<!-- ocra:finding ${f.fingerprint} -->`,
77
79
  `${ICON[f.severity]} **${safeMarkdown(f.title)}** · ${f.severity} · ${verification(f)} · ${f.reviewer}${f.lowConfidence ? " · low confidence" : ""}`,
@@ -80,13 +82,34 @@ export function inlineBody(f) {
80
82
  ];
81
83
  if (f.suggestion)
82
84
  parts.push("", `**Suggestion:** ${safeMarkdown(f.suggestion)}`);
85
+ // A `~~~` fence in model text stays text to safeMarkdown, and one left open
86
+ // would run to the end of the comment and swallow the suggestion.
87
+ const block = TILDE_FENCE.test(parts.join("\n")) ? undefined : suggestionBlock(f, fence);
88
+ if (block)
89
+ parts.push("", block);
83
90
  return parts.join("\n");
84
91
  }
85
92
  export function renderSummary({ report, commented, state, text, unattributed = 0, }) {
93
+ const lines = [
94
+ ...verdictSection(report, text),
95
+ ...scopeNote(report),
96
+ ...incompleteNotes(report, text),
97
+ ...unattributedNote(unattributed),
98
+ ...outsideDiff(report.findings, commented),
99
+ ...memoryNotes(report.remembered),
100
+ ...rereviewSections(report.rereview),
101
+ ...coverageAndCost(report),
102
+ ];
103
+ const markdown = lines.join("\n");
104
+ const footer = `\n\n${writeState(state)}`;
105
+ const room = MAX_SUMMARY_CHARS - footer.length;
106
+ return ((markdown.length > room ? `${markdown.slice(0, room - 40)}\n\n…(truncated)` : markdown) + footer);
107
+ }
108
+ function verdictSection(report, text) {
86
109
  const counts = ["critical", "warning", "suggestion"]
87
110
  .map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`)
88
111
  .join(", ");
89
- const lines = [
112
+ return [
90
113
  SUMMARY_MARKER,
91
114
  `## ocra review · ${headline(report)}`,
92
115
  "",
@@ -95,12 +118,18 @@ export function renderSummary({ report, commented, state, text, unattributed = 0
95
118
  "",
96
119
  `**${report.findings.length} finding(s)** (${counts}) · risk tier \`${report.tier}\``,
97
120
  ];
121
+ }
122
+ function scopeNote(report) {
98
123
  if (report.scope?.mode === "incremental") {
99
- lines.push("", `Reviewed only what changed since ${codeSpan(report.scope.since.slice(0, 7))}.`);
100
- }
101
- else if (report.scope) {
102
- lines.push("", `Reviewed every file again: ${safeMarkdown(report.scope.reason)}.`);
124
+ return ["", `Reviewed only what changed since ${codeSpan(report.scope.since.slice(0, 7))}.`];
103
125
  }
126
+ if (report.scope)
127
+ return ["", `Reviewed every file again: ${safeMarkdown(report.scope.reason)}.`];
128
+ return [];
129
+ }
130
+ // Unverified criticals that cap the verdict, and what the run did not check.
131
+ function incompleteNotes(report, text) {
132
+ const lines = [];
104
133
  // Only true while no confirmed critical blocks, and low-confidence
105
134
  // findings do not count at all.
106
135
  const unverified = report.verdict === "significant_concerns"
@@ -119,51 +148,90 @@ export function renderSummary({ report, commented, state, text, unattributed = 0
119
148
  : "";
120
149
  lines.push("", `**Incomplete:** ${notReviewed} selected file(s) were not reviewed${limit}. They are listed under Coverage and cost, and the next review of this ${text.changeRequest} includes them.`);
121
150
  }
122
- if (unattributed > 0) {
123
- lines.push("", `${unattributed} resolved thread(s) of ocra's did not dismiss their finding: nothing says who resolved them, as when a push resolves outdated threads. To dismiss a finding, answer its thread with \`/ocra dismiss\` or "won't fix".`);
124
- }
125
- const inSummary = report.findings.filter((f) => !commented.has(f.fingerprint));
126
- if (inSummary.length > 0) {
127
- lines.push("", "### Findings outside the diff");
128
- for (const f of inSummary) {
129
- lines.push(`- ${ICON[f.severity]} ${location(f)} **${safeMarkdown(f.title)}** _(${verification(f)}${f.lowConfidence ? ", low confidence" : ""})_: ${safeMarkdown(f.body.replaceAll("\n", " "))}`);
130
- }
131
- }
132
- if (report.remembered.length > 0) {
133
- lines.push("", `${report.remembered.length} finding(s) matched \`.ocra/memory.json\` and are not repeated.`);
134
- }
135
- const rereview = report.rereview;
136
- if (rereview && rereview.fixed.length > 0) {
137
- lines.push("", "### Fixed since the last review");
138
- for (const f of rereview.fixed)
139
- lines.push(`- ~~${safeMarkdown(f.title)}~~ ${codeSpan(f.file)}`);
140
- }
141
- if (rereview && rereview.notReproduced.length > 0) {
142
- lines.push("", "### Not reported this time, code unchanged", "", "Still open and counted in the verdict until the code changes or a reviewer dismisses them.", "");
143
- for (const f of rereview.notReproduced)
144
- lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
145
- }
146
- if (rereview && rereview.unchanged.length > 0) {
147
- lines.push("", "### Still open in unchanged files", "", "Reported earlier in files not changed since; they count in the verdict.", "");
148
- for (const f of rereview.unchanged)
149
- lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
150
- }
151
- if (rereview && rereview.dismissed.length > 0) {
152
- lines.push("", "### Dismissed by reviewers");
153
- for (const f of rereview.dismissed)
154
- lines.push(`- ${safeMarkdown(f.title)} ${codeSpan(f.file)}`);
151
+ return lines;
152
+ }
153
+ function unattributedNote(unattributed) {
154
+ if (unattributed <= 0)
155
+ return [];
156
+ return [
157
+ "",
158
+ `${unattributed} resolved thread(s) of ocra's did not dismiss their finding: nothing says who resolved them, as when a push resolves outdated threads. To dismiss a finding, answer its thread with \`/ocra dismiss\` or "won't fix".`,
159
+ ];
160
+ }
161
+ function outsideDiff(findings, commented) {
162
+ const inSummary = findings.filter((f) => !commented.has(f.fingerprint));
163
+ if (inSummary.length === 0)
164
+ return [];
165
+ return [
166
+ "",
167
+ "### Findings outside the diff",
168
+ ...inSummary.map((f) => `- ${ICON[f.severity]} ${location(f)} **${safeMarkdown(f.title)}** _(${verification(f)}${f.lowConfidence ? ", low confidence" : ""})_: ${safeMarkdown(f.body.replaceAll("\n", " "))}`),
169
+ ];
170
+ }
171
+ function memoryNotes(remembered) {
172
+ const lines = [];
173
+ const fromRepository = remembered.filter((e) => e.source === "repository").length;
174
+ const fromAccount = remembered.length - fromRepository;
175
+ if (fromRepository > 0) {
176
+ lines.push("", `${fromRepository} finding(s) matched \`.ocra/memory.json\` and are not repeated.`);
155
177
  }
156
- if (rereview && rereview.notRechecked.length > 0) {
157
- lines.push("", "### Not re-checked this time", "", "Their files were not reviewed in this run; they stay open and count in the verdict.", "");
158
- for (const f of rereview.notRechecked)
159
- lines.push(`- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`);
178
+ // The reviewer's own account memory can hide findings too; say so, so a
179
+ // suppression nobody on the team chose is visible (ADR-0028).
180
+ if (fromAccount > 0) {
181
+ lines.push("", `${fromAccount} finding(s) matched the reviewing account's ocra Cloud memory and are not repeated.`);
160
182
  }
183
+ return lines;
184
+ }
185
+ function rereviewSections(rereview) {
186
+ if (!rereview)
187
+ return [];
188
+ return [
189
+ ...(rereview.fixed.length > 0
190
+ ? [
191
+ "",
192
+ "### Fixed since the last review",
193
+ ...rereview.fixed.map((f) => `- ~~${safeMarkdown(f.title)}~~ ${codeSpan(f.file)}`),
194
+ ]
195
+ : []),
196
+ ...openList("Not reported this time, code unchanged", "Still open and counted in the verdict until the code changes or a reviewer dismisses them.", rereview.notReproduced),
197
+ ...openList("Still open in unchanged files", "Reported earlier in files not changed since; they count in the verdict.", rereview.unchanged),
198
+ ...(rereview.dismissed.length > 0
199
+ ? [
200
+ "",
201
+ "### Dismissed by reviewers",
202
+ ...rereview.dismissed.map((f) => `- ${safeMarkdown(f.title)} ${codeSpan(f.file)}`),
203
+ ]
204
+ : []),
205
+ ...openList("Not re-checked this time", "Their files were not reviewed in this run; they stay open and count in the verdict.", rereview.notRechecked),
206
+ ];
207
+ }
208
+ // Earlier findings still open, under a heading that says why.
209
+ function openList(title, intro, findings) {
210
+ if (findings.length === 0)
211
+ return [];
212
+ return [
213
+ "",
214
+ `### ${title}`,
215
+ "",
216
+ intro,
217
+ "",
218
+ ...findings.map((f) => `- ${ICON[f.severity]} ${safeMarkdown(f.title)} ${codeSpan(f.file)} _(${verification(f)})_`),
219
+ ];
220
+ }
221
+ function coverageAndCost(report) {
161
222
  const failed = report.coverage.filter((c) => c.status === "failed" || c.status === "unreviewed");
223
+ const count = (status) => report.coverage.filter((c) => c.status === status).length;
162
224
  const { costUsd, inputTokens, outputTokens } = report.usage;
163
- 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)}${spendLimitNote(report)} · run ${codeSpan(report.runId)}`, ...failed.map((c) => `- not reviewed: ${codeSpan(c.path)}`), "", "</details>");
164
- const markdown = lines.join("\n");
165
- const footer = `\n\n${writeState(state)}`;
166
- const room = MAX_SUMMARY_CHARS - footer.length;
167
- return ((markdown.length > room ? `${markdown.slice(0, room - 40)}\n\n…(truncated)` : markdown) + footer);
225
+ return [
226
+ "",
227
+ "_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._",
228
+ "",
229
+ "<details><summary>Coverage and cost</summary>",
230
+ "",
231
+ `${count("reviewed")} reviewed · ${count("unchanged")} unchanged since the last review · ${failed.length} not reviewed · ${count("excluded")} excluded · ${inputTokens} in / ${outputTokens} out tokens · $${costUsd.toFixed(4)}${spendLimitNote(report)} · run ${codeSpan(report.runId)}`,
232
+ ...failed.map((c) => `- not reviewed: ${codeSpan(c.path)}`),
233
+ "",
234
+ "</details>",
235
+ ];
168
236
  }
169
237
  //# sourceMappingURL=render.js.map
package/dist/review.d.ts CHANGED
@@ -16,11 +16,11 @@ export interface PlatformReviewOptions {
16
16
  }
17
17
  export declare function declinesFinding(reply: string): boolean;
18
18
  export declare class PlatformReview implements VcsAdapter {
19
- private readonly options;
20
19
  readonly name: string;
21
20
  private readonly platform;
22
21
  private readonly memo;
23
22
  private readonly writers;
23
+ private readonly options;
24
24
  constructor(options: PlatformReviewOptions);
25
25
  getChangeRequest(): Promise<ChangeRequest>;
26
26
  private override;
package/dist/review.js CHANGED
@@ -1,4 +1,4 @@
1
- import { errorMessage } from "@open-cr-agent/core/internal";
1
+ import { errorMessage } from "@open-cr-agent/core";
2
2
  import { FINDING_MARKER, inlineBody, renderSummary } from "./render.js";
3
3
  import { lostProgress, readState, SUMMARY_MARKER } from "./state.js";
4
4
  const OVERRIDE = /^\/ocra override ([0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?)\s+(\S.*)$/m;
@@ -25,11 +25,11 @@ export function declinesFinding(reply) {
25
25
  // which earlier state and which comments count, who may override or dismiss,
26
26
  // and what gets posted. The platform only maps its API (ReviewPlatform).
27
27
  export class PlatformReview {
28
- options;
29
28
  name;
30
29
  platform;
31
30
  memo = new Map();
32
31
  writers = new Map();
32
+ options;
33
33
  constructor(options) {
34
34
  this.options = options;
35
35
  this.name = options.name;
@@ -215,7 +215,12 @@ export class PlatformReview {
215
215
  const fresh = report.findings.flatMap((f) => {
216
216
  if (alreadyCommented.has(f.fingerprint) || !f.lineRange || !f.anchor.inDiff)
217
217
  return [];
218
- return [{ finding: { ...f, lineRange: f.lineRange }, body: inlineBody(f) }];
218
+ return [
219
+ {
220
+ finding: { ...f, lineRange: f.lineRange },
221
+ body: inlineBody(f, this.platform.suggestionFence),
222
+ },
223
+ ];
219
224
  });
220
225
  const published = await this.platform.publishFindings(report, fresh);
221
226
  const commented = new Set([...alreadyCommented, ...published.posted]);
@@ -240,6 +245,7 @@ export class PlatformReview {
240
245
  commented: commented.has(f.fingerprint),
241
246
  ...(f.quote ? { quote: f.quote } : {}),
242
247
  ...(f.verification ? { verification: f.verification } : {}),
248
+ reviewer: f.reviewer,
243
249
  })),
244
250
  ...quiet.filter((f) => !current.has(f.fingerprint)),
245
251
  ];
package/dist/state.js CHANGED
@@ -27,6 +27,7 @@ const stateSchema = z.object({
27
27
  })
28
28
  .optional(),
29
29
  verification: verificationSchema.optional(),
30
+ reviewer: z.string().min(1).max(100).optional(),
30
31
  }))
31
32
  .max(MAX_STATE_FINDINGS),
32
33
  head: z.string().regex(SHA).optional(),
@@ -51,10 +52,11 @@ export function readState(body) {
51
52
  return undefined;
52
53
  const { head, pending, posted, tier } = parsed.data;
53
54
  return {
54
- findings: parsed.data.findings.map(({ quote, verification, ...finding }) => ({
55
+ findings: parsed.data.findings.map(({ quote, verification, reviewer, ...finding }) => ({
55
56
  ...finding,
56
57
  ...(quote ? { quote } : {}),
57
58
  ...(verification ? { verification } : {}),
59
+ ...(reviewer ? { reviewer } : {}),
58
60
  })),
59
61
  ...(head ? { head } : {}),
60
62
  ...(pending ? { pending } : {}),
@@ -75,6 +77,7 @@ export function writeState(state) {
75
77
  commented: f.commented,
76
78
  ...(f.quote ? { quote: f.quote } : {}),
77
79
  ...(f.verification ? { verification: f.verification } : {}),
80
+ ...(f.reviewer && f.reviewer.length <= 100 ? { reviewer: f.reviewer } : {}),
78
81
  }));
79
82
  // Without the full list of unfinished files the next run could skip one of
80
83
  // them, so a list that does not fit drops the head and forces a full review.
@@ -0,0 +1,6 @@
1
+ import type { Finding, LineRange } from "@open-cr-agent/core";
2
+ export type SuggestionFence = (range: LineRange) => string | undefined;
3
+ export declare const githubSuggestion: SuggestionFence;
4
+ export declare const gitlabSuggestion: SuggestionFence;
5
+ export declare function suggestionBlock(f: Finding, fence: SuggestionFence): string | undefined;
6
+ //# sourceMappingURL=suggestion.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { isUnsafeCodePoint } from "@open-cr-agent/core/internal";
2
+ // GitHub's comment spans its lines, so its suggestion replaces them.
3
+ export const githubSuggestion = () => "suggestion";
4
+ // GitLab's thread sits on the range's last line; the suggestion reaches the
5
+ // lines above it, at most 100.
6
+ const GITLAB_MAX_LINES_ABOVE = 100;
7
+ export const gitlabSuggestion = ({ start, end }) => end - start > GITLAB_MAX_LINES_ABOVE ? undefined : `suggestion:-${end - start}+0`;
8
+ const MAX_REPLACEMENT_CHARS = 20_000;
9
+ // What safeMarkdown rewrites even in code, because ocra reads it from the raw
10
+ // comment: its markers and its commands.
11
+ const REWRITTEN_IN_CODE = /<!--|\/ocra/i;
12
+ // A finding's fix as a committable suggestion (ADR-0029): only for a fix of
13
+ // exactly the lines its inline comment is on, and only when the replacement
14
+ // can be posted as written. Neutralizing it like other model text would
15
+ // change the code a click commits, so anything that would need it gets no
16
+ // block. The fence is longer than any backtick run inside, so the
17
+ // replacement cannot close it; inside a code block nothing renders as a
18
+ // mention, link or HTML, and GitLab runs no quick action.
19
+ export function suggestionBlock(f, fence) {
20
+ const { fix, lineRange } = f;
21
+ if (!fix || !lineRange || !f.anchor.inDiff)
22
+ return undefined;
23
+ if (fix.startLine !== lineRange.start || fix.endLine !== lineRange.end)
24
+ return undefined;
25
+ if (!postable(fix.replacement))
26
+ return undefined;
27
+ const info = fence(lineRange);
28
+ if (info === undefined)
29
+ return undefined;
30
+ const longestRun = Math.max(0, ...(fix.replacement.match(/`+/g) ?? []).map((r) => r.length));
31
+ const ticks = "`".repeat(Math.max(3, longestRun + 1));
32
+ return [`${ticks}${info}`, ...(fix.replacement === "" ? [] : [fix.replacement]), ticks].join("\n");
33
+ }
34
+ // Carriage returns and controls are refused rather than stripped, like
35
+ // everything else that would change the code.
36
+ function postable(replacement) {
37
+ if (replacement.length > MAX_REPLACEMENT_CHARS || REWRITTEN_IN_CODE.test(replacement)) {
38
+ return false;
39
+ }
40
+ for (const char of replacement) {
41
+ if (isUnsafeCodePoint(char.codePointAt(0)))
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+ //# sourceMappingURL=suggestion.js.map
@@ -0,0 +1,26 @@
1
+ import type { PluginRegistry, VcsAdapter } from "@open-cr-agent/core";
2
+ import type { CodeSource, History } from "./review.js";
3
+ export interface ChangeRequestRef {
4
+ number: number;
5
+ repository?: string;
6
+ }
7
+ export interface ResolveTargetOptions {
8
+ ref: ChangeRequestRef;
9
+ env: Readonly<Record<string, string | undefined>>;
10
+ origin(): Promise<string | undefined>;
11
+ warn(message: string): void;
12
+ fetch?: typeof fetch;
13
+ }
14
+ export interface LocalCode {
15
+ code: CodeSource;
16
+ history: History;
17
+ }
18
+ export interface PlatformTarget<Settings> {
19
+ platform: string;
20
+ baseSha: string;
21
+ headSha: string;
22
+ headRefs: string[];
23
+ webUrl: string;
24
+ createVcs(registry: Pick<PluginRegistry, "createVcs">, local: LocalCode, settings: Settings): VcsAdapter;
25
+ }
26
+ //# sourceMappingURL=target.d.ts.map
package/dist/target.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=target.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/vcs-platform",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "What Open-CR-Agent's code review platform adapters share: the review summary, its state, and who may command ocra",
5
5
  "keywords": [
6
6
  "code-review",
@@ -38,7 +38,7 @@
38
38
  "!dist/**/*.map"
39
39
  ],
40
40
  "dependencies": {
41
- "@open-cr-agent/core": "0.4.0",
41
+ "@open-cr-agent/core": "0.6.0",
42
42
  "zod": "^4.6.5"
43
43
  },
44
44
  "publishConfig": {