@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 +22 -0
- package/dist/http.js +53 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/neutralize.js +5 -4
- package/dist/options.d.ts +15 -0
- package/dist/options.js +22 -0
- package/dist/platform.d.ts +2 -0
- package/dist/render.d.ts +2 -2
- package/dist/render.js +117 -49
- package/dist/review.d.ts +1 -1
- package/dist/review.js +9 -3
- package/dist/state.js +4 -1
- package/dist/suggestion.d.ts +6 -0
- package/dist/suggestion.js +46 -0
- package/dist/target.d.ts +26 -0
- package/dist/target.js +2 -0
- package/package.json +2 -2
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
|
package/dist/internal.d.ts
CHANGED
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";
|
package/dist/neutralize.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/options.js
ADDED
|
@@ -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
|
package/dist/platform.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
lines.push("",
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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 [
|
|
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
|
package/dist/target.d.ts
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-cr-agent/vcs-platform",
|
|
3
|
-
"version": "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.
|
|
41
|
+
"@open-cr-agent/core": "0.6.0",
|
|
42
42
|
"zod": "^4.6.5"
|
|
43
43
|
},
|
|
44
44
|
"publishConfig": {
|