@open-cr-agent/vcs-github 0.1.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/LICENSE +202 -0
- package/README.md +3 -0
- package/dist/adapter.d.ts +61 -0
- package/dist/adapter.js +410 -0
- package/dist/client.d.ts +101 -0
- package/dist/client.js +283 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +47 -0
- package/dist/render.d.ts +15 -0
- package/dist/render.js +182 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +38 -0
- package/dist/state.d.ts +14 -0
- package/dist/state.js +110 -0
- package/package.json +39 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const pullRequestSchema: z.ZodObject<{
|
|
3
|
+
number: z.ZodNumber;
|
|
4
|
+
title: z.ZodString;
|
|
5
|
+
body: z.ZodNullable<z.ZodString>;
|
|
6
|
+
html_url: z.ZodString;
|
|
7
|
+
user: z.ZodNullable<z.ZodObject<{
|
|
8
|
+
login: z.ZodString;
|
|
9
|
+
}, z.core.$strip>>;
|
|
10
|
+
base: z.ZodObject<{
|
|
11
|
+
sha: z.ZodString;
|
|
12
|
+
ref: z.ZodString;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
head: z.ZodObject<{
|
|
15
|
+
sha: z.ZodString;
|
|
16
|
+
ref: z.ZodString;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
export type PullRequest = z.infer<typeof pullRequestSchema>;
|
|
20
|
+
declare const commentSchema: z.ZodObject<{
|
|
21
|
+
id: z.ZodNumber;
|
|
22
|
+
body: z.ZodPipe<z.ZodNullable<z.ZodString>, z.ZodTransform<string, string | null>>;
|
|
23
|
+
user: z.ZodNullable<z.ZodObject<{
|
|
24
|
+
login: z.ZodString;
|
|
25
|
+
type: z.ZodString;
|
|
26
|
+
}, z.core.$strip>>;
|
|
27
|
+
node_id: z.ZodOptional<z.ZodString>;
|
|
28
|
+
author_association: z.ZodOptional<z.ZodString>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
export type IssueComment = z.infer<typeof commentSchema>;
|
|
31
|
+
export interface ReviewComment {
|
|
32
|
+
path: string;
|
|
33
|
+
line: number;
|
|
34
|
+
start_line?: number;
|
|
35
|
+
side: "RIGHT";
|
|
36
|
+
start_side?: "RIGHT";
|
|
37
|
+
body: string;
|
|
38
|
+
}
|
|
39
|
+
export interface CreateReview {
|
|
40
|
+
commit_id: string;
|
|
41
|
+
event: "COMMENT" | "REQUEST_CHANGES";
|
|
42
|
+
body: string;
|
|
43
|
+
comments: ReviewComment[];
|
|
44
|
+
}
|
|
45
|
+
export declare class GitHubApiError extends Error {
|
|
46
|
+
readonly status: number;
|
|
47
|
+
constructor(status: number, message: string);
|
|
48
|
+
}
|
|
49
|
+
export interface GitHubApiOptions {
|
|
50
|
+
token: string;
|
|
51
|
+
baseUrl?: string;
|
|
52
|
+
fetch?: typeof fetch;
|
|
53
|
+
sleep?: (ms: number) => Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
declare const reviewSchema: z.ZodObject<{
|
|
56
|
+
id: z.ZodNumber;
|
|
57
|
+
state: z.ZodString;
|
|
58
|
+
user: z.ZodNullable<z.ZodObject<{
|
|
59
|
+
login: z.ZodString;
|
|
60
|
+
}, z.core.$strip>>;
|
|
61
|
+
}, z.core.$strip>;
|
|
62
|
+
export type PullRequestReview = z.infer<typeof reviewSchema>;
|
|
63
|
+
export interface ReviewThread {
|
|
64
|
+
id: string;
|
|
65
|
+
isResolved: boolean;
|
|
66
|
+
resolvedBy: string | undefined;
|
|
67
|
+
comments: {
|
|
68
|
+
author: string;
|
|
69
|
+
association: string;
|
|
70
|
+
body: string;
|
|
71
|
+
editor?: string;
|
|
72
|
+
}[];
|
|
73
|
+
}
|
|
74
|
+
export declare class GitHubApi {
|
|
75
|
+
private readonly repository;
|
|
76
|
+
private readonly options;
|
|
77
|
+
private readonly baseUrl;
|
|
78
|
+
private readonly fetchImpl;
|
|
79
|
+
constructor(repository: {
|
|
80
|
+
owner: string;
|
|
81
|
+
repo: string;
|
|
82
|
+
}, options: GitHubApiOptions);
|
|
83
|
+
listReviewThreads(number: number): Promise<ReviewThread[]>;
|
|
84
|
+
resolveReviewThread(threadId: string): Promise<void>;
|
|
85
|
+
commentEditor(nodeId: string): Promise<string | undefined>;
|
|
86
|
+
getPullRequest(number: number): Promise<PullRequest>;
|
|
87
|
+
listIssueComments(number: number): Promise<IssueComment[]>;
|
|
88
|
+
createIssueComment(number: number, body: string): Promise<void>;
|
|
89
|
+
updateIssueComment(id: number, body: string): Promise<void>;
|
|
90
|
+
createReview(number: number, review: CreateReview): Promise<void>;
|
|
91
|
+
filesWithDiff(number: number): Promise<Set<string>>;
|
|
92
|
+
canWrite(login: string): Promise<boolean>;
|
|
93
|
+
listReviews(number: number): Promise<PullRequestReview[]>;
|
|
94
|
+
dismissReview(number: number, reviewId: number, message: string): Promise<void>;
|
|
95
|
+
private graphql;
|
|
96
|
+
private graphqlUrl;
|
|
97
|
+
private request;
|
|
98
|
+
private send;
|
|
99
|
+
}
|
|
100
|
+
export {};
|
|
101
|
+
//# sourceMappingURL=client.d.ts.map
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { MAX_ATTEMPTS, retryDecision } from "./retry.js";
|
|
3
|
+
// Commit ids reach git as arguments; anything else is refused at the boundary.
|
|
4
|
+
const sha = z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/, "not a commit id");
|
|
5
|
+
const pullRequestSchema = z.object({
|
|
6
|
+
number: z.number(),
|
|
7
|
+
title: z.string(),
|
|
8
|
+
body: z.string().nullable(),
|
|
9
|
+
html_url: z.string(),
|
|
10
|
+
user: z.object({ login: z.string() }).nullable(),
|
|
11
|
+
base: z.object({ sha: sha, ref: z.string() }),
|
|
12
|
+
head: z.object({ sha: sha, ref: z.string() }),
|
|
13
|
+
});
|
|
14
|
+
const commentSchema = z.object({
|
|
15
|
+
id: z.number(),
|
|
16
|
+
body: z
|
|
17
|
+
.string()
|
|
18
|
+
.nullable()
|
|
19
|
+
.transform((b) => b ?? ""),
|
|
20
|
+
user: z.object({ login: z.string(), type: z.string() }).nullable(),
|
|
21
|
+
node_id: z.string().optional(),
|
|
22
|
+
author_association: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
export class GitHubApiError extends Error {
|
|
25
|
+
status;
|
|
26
|
+
constructor(status, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const reviewSchema = z.object({
|
|
32
|
+
id: z.number(),
|
|
33
|
+
state: z.string(),
|
|
34
|
+
user: z.object({ login: z.string() }).nullable(),
|
|
35
|
+
});
|
|
36
|
+
const MAX_REVIEW_PAGES = 10;
|
|
37
|
+
// GitHub lists at most 3,000 files of a pull request.
|
|
38
|
+
const MAX_FILE_PAGES = 30;
|
|
39
|
+
const pullRequestFileSchema = z.object({ filename: z.string(), patch: z.string().optional() });
|
|
40
|
+
const MAX_COMMENT_PAGES = 30;
|
|
41
|
+
const MAX_THREAD_PAGES = 10;
|
|
42
|
+
const REVIEW_THREADS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) {
|
|
43
|
+
repository(owner: $owner, name: $repo) {
|
|
44
|
+
pullRequest(number: $number) {
|
|
45
|
+
reviewThreads(first: 100, after: $after) {
|
|
46
|
+
pageInfo { hasNextPage endCursor }
|
|
47
|
+
nodes { id isResolved resolvedBy { login } comments(first: 30) { nodes { id body authorAssociation author { login } editor { login } } } latest: comments(last: 30) { nodes { id body authorAssociation author { login } editor { login } } } }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}`;
|
|
52
|
+
const graphqlResultSchema = z.object({
|
|
53
|
+
data: z.unknown().optional(),
|
|
54
|
+
errors: z.array(z.object({ message: z.string(), type: z.string().optional() })).optional(),
|
|
55
|
+
});
|
|
56
|
+
const threadCommentsSchema = z.object({
|
|
57
|
+
nodes: z.array(z.object({
|
|
58
|
+
id: z.string().optional(),
|
|
59
|
+
body: z.string(),
|
|
60
|
+
authorAssociation: z.string().default("NONE"),
|
|
61
|
+
author: z.object({ login: z.string() }).nullable(),
|
|
62
|
+
editor: z.object({ login: z.string() }).nullable().optional(),
|
|
63
|
+
})),
|
|
64
|
+
});
|
|
65
|
+
// The first page (with ocra's marker comment) followed by the latest replies
|
|
66
|
+
// it did not include; the two overlap in threads of up to 60 comments.
|
|
67
|
+
function mergeComments(first, latest) {
|
|
68
|
+
const seen = new Set(first.map((c) => c.id).filter((id) => id !== undefined));
|
|
69
|
+
return [...first, ...latest.filter((c) => c.id !== undefined && !seen.has(c.id))];
|
|
70
|
+
}
|
|
71
|
+
const reviewThreadsSchema = z.object({
|
|
72
|
+
repository: z.object({
|
|
73
|
+
pullRequest: z.object({
|
|
74
|
+
reviewThreads: z.object({
|
|
75
|
+
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }),
|
|
76
|
+
nodes: z.array(z.object({
|
|
77
|
+
id: z.string(),
|
|
78
|
+
isResolved: z.boolean(),
|
|
79
|
+
resolvedBy: z.object({ login: z.string() }).nullable().optional(),
|
|
80
|
+
comments: threadCommentsSchema,
|
|
81
|
+
// A long thread's latest replies, which the first page misses.
|
|
82
|
+
latest: threadCommentsSchema.optional(),
|
|
83
|
+
})),
|
|
84
|
+
}),
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
// A thin REST client: every response is validated before it reaches the
|
|
89
|
+
// adapter, and errors never echo the token.
|
|
90
|
+
export class GitHubApi {
|
|
91
|
+
repository;
|
|
92
|
+
options;
|
|
93
|
+
baseUrl;
|
|
94
|
+
fetchImpl;
|
|
95
|
+
constructor(repository, options) {
|
|
96
|
+
this.repository = repository;
|
|
97
|
+
this.options = options;
|
|
98
|
+
this.baseUrl = (options.baseUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
99
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
100
|
+
}
|
|
101
|
+
async listReviewThreads(number) {
|
|
102
|
+
const threads = [];
|
|
103
|
+
let after = null;
|
|
104
|
+
for (let page = 0; page < MAX_THREAD_PAGES; page += 1) {
|
|
105
|
+
const data = reviewThreadsSchema.parse(await this.graphql(REVIEW_THREADS_QUERY, {
|
|
106
|
+
owner: this.repository.owner,
|
|
107
|
+
repo: this.repository.repo,
|
|
108
|
+
number,
|
|
109
|
+
after,
|
|
110
|
+
}));
|
|
111
|
+
const connection = data.repository.pullRequest.reviewThreads;
|
|
112
|
+
for (const node of connection.nodes) {
|
|
113
|
+
threads.push({
|
|
114
|
+
id: node.id,
|
|
115
|
+
isResolved: node.isResolved,
|
|
116
|
+
resolvedBy: node.resolvedBy?.login,
|
|
117
|
+
comments: mergeComments(node.comments.nodes, node.latest?.nodes ?? []).map((c) => ({
|
|
118
|
+
author: c.author?.login ?? "",
|
|
119
|
+
association: c.authorAssociation,
|
|
120
|
+
body: c.body,
|
|
121
|
+
...(c.editor ? { editor: c.editor.login } : {}),
|
|
122
|
+
})),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (!connection.pageInfo.hasNextPage)
|
|
126
|
+
break;
|
|
127
|
+
after = connection.pageInfo.endCursor;
|
|
128
|
+
}
|
|
129
|
+
return threads;
|
|
130
|
+
}
|
|
131
|
+
async resolveReviewThread(threadId) {
|
|
132
|
+
await this.graphql("mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { id } } }", { id: threadId });
|
|
133
|
+
}
|
|
134
|
+
// Who last edited a comment, if anyone did.
|
|
135
|
+
async commentEditor(nodeId) {
|
|
136
|
+
const data = z
|
|
137
|
+
.object({
|
|
138
|
+
node: z
|
|
139
|
+
.object({ editor: z.object({ login: z.string() }).nullable().optional() })
|
|
140
|
+
.nullable(),
|
|
141
|
+
})
|
|
142
|
+
.parse(await this.graphql("query($id: ID!) { node(id: $id) { ... on IssueComment { editor { login } } } }", { id: nodeId }));
|
|
143
|
+
return data.node?.editor?.login;
|
|
144
|
+
}
|
|
145
|
+
async getPullRequest(number) {
|
|
146
|
+
return pullRequestSchema.parse(await this.request("GET", `/pulls/${number}`));
|
|
147
|
+
}
|
|
148
|
+
async listIssueComments(number) {
|
|
149
|
+
const comments = [];
|
|
150
|
+
for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
|
|
151
|
+
const batch = z
|
|
152
|
+
.array(commentSchema)
|
|
153
|
+
.parse(await this.request("GET", `/issues/${number}/comments?per_page=100&page=${page}`));
|
|
154
|
+
comments.push(...batch);
|
|
155
|
+
if (batch.length < 100)
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
return comments;
|
|
159
|
+
}
|
|
160
|
+
async createIssueComment(number, body) {
|
|
161
|
+
await this.request("POST", `/issues/${number}/comments`, { body });
|
|
162
|
+
}
|
|
163
|
+
async updateIssueComment(id, body) {
|
|
164
|
+
await this.request("PATCH", `/issues/comments/${id}`, { body });
|
|
165
|
+
}
|
|
166
|
+
async createReview(number, review) {
|
|
167
|
+
await this.request("POST", `/pulls/${number}/reviews`, review);
|
|
168
|
+
}
|
|
169
|
+
// Files GitHub shows a diff for; only their lines take inline comments.
|
|
170
|
+
async filesWithDiff(number) {
|
|
171
|
+
const files = new Set();
|
|
172
|
+
for (let page = 1; page <= MAX_FILE_PAGES; page += 1) {
|
|
173
|
+
const batch = z
|
|
174
|
+
.array(pullRequestFileSchema)
|
|
175
|
+
.parse(await this.request("GET", `/pulls/${number}/files?per_page=100&page=${page}`));
|
|
176
|
+
for (const f of batch)
|
|
177
|
+
if (f.patch !== undefined)
|
|
178
|
+
files.add(f.filename);
|
|
179
|
+
if (batch.length < 100)
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
return files;
|
|
183
|
+
}
|
|
184
|
+
// The repository permission of a user: author_association only says how
|
|
185
|
+
// someone relates to the repository (MEMBER is any organization member),
|
|
186
|
+
// not whether they may write to it.
|
|
187
|
+
async canWrite(login) {
|
|
188
|
+
const data = z
|
|
189
|
+
.object({ permission: z.string() })
|
|
190
|
+
.parse(await this.request("GET", `/collaborators/${encodeURIComponent(login)}/permission`));
|
|
191
|
+
return data.permission === "admin" || data.permission === "write";
|
|
192
|
+
}
|
|
193
|
+
async listReviews(number) {
|
|
194
|
+
const reviews = [];
|
|
195
|
+
for (let page = 1; page <= MAX_REVIEW_PAGES; page += 1) {
|
|
196
|
+
const batch = z
|
|
197
|
+
.array(reviewSchema)
|
|
198
|
+
.parse(await this.request("GET", `/pulls/${number}/reviews?per_page=100&page=${page}`));
|
|
199
|
+
reviews.push(...batch);
|
|
200
|
+
if (batch.length < 100)
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
return reviews;
|
|
204
|
+
}
|
|
205
|
+
async dismissReview(number, reviewId, message) {
|
|
206
|
+
await this.request("PUT", `/pulls/${number}/reviews/${reviewId}/dismissals`, {
|
|
207
|
+
message,
|
|
208
|
+
event: "DISMISS",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// GraphQL reports its rate limit as an error in a 200 response, which the
|
|
212
|
+
// HTTP retry does not see; it is retried here the same bounded way.
|
|
213
|
+
async graphql(query, variables) {
|
|
214
|
+
const sleep = this.options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
215
|
+
let result;
|
|
216
|
+
for (let attempt = 1;; attempt += 1) {
|
|
217
|
+
result = graphqlResultSchema.parse(await this.send("POST", this.graphqlUrl(), "/graphql", { query, variables }, true));
|
|
218
|
+
const limited = result.errors?.some((e) => e.type === "RATE_LIMITED");
|
|
219
|
+
if (!limited || attempt >= MAX_ATTEMPTS)
|
|
220
|
+
break;
|
|
221
|
+
await sleep(1_000 * 2 ** attempt);
|
|
222
|
+
}
|
|
223
|
+
if (result.errors?.length) {
|
|
224
|
+
throw new GitHubApiError(200, `GitHub GraphQL failed: ${result.errors.map((e) => e.message).join("; ")}`);
|
|
225
|
+
}
|
|
226
|
+
return result.data;
|
|
227
|
+
}
|
|
228
|
+
// github.com serves GraphQL at /graphql; GitHub Enterprise at /api/graphql
|
|
229
|
+
// next to /api/v3.
|
|
230
|
+
graphqlUrl() {
|
|
231
|
+
return this.baseUrl.endsWith("/api/v3")
|
|
232
|
+
? `${this.baseUrl.slice(0, -"/v3".length)}/graphql`
|
|
233
|
+
: `${this.baseUrl}/graphql`;
|
|
234
|
+
}
|
|
235
|
+
request(method, path, body) {
|
|
236
|
+
const { owner, repo } = this.repository;
|
|
237
|
+
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${path}`;
|
|
238
|
+
return this.send(method, url, path, body);
|
|
239
|
+
}
|
|
240
|
+
// GraphQL queries and resolving a thread are safe to repeat, although they
|
|
241
|
+
// are POSTs; REST POSTs create comments and reviews, and are not.
|
|
242
|
+
async send(method, url, path, body, idempotent = method !== "POST") {
|
|
243
|
+
const sleep = this.options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
244
|
+
for (let attempt = 1;; attempt += 1) {
|
|
245
|
+
const init = {
|
|
246
|
+
method,
|
|
247
|
+
headers: {
|
|
248
|
+
Accept: "application/vnd.github+json",
|
|
249
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
250
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
251
|
+
"User-Agent": "open-cr-agent",
|
|
252
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
253
|
+
},
|
|
254
|
+
// Creating a review with many inline comments can take GitHub a while,
|
|
255
|
+
// and a POST that timed out after GitHub acted cannot be repeated.
|
|
256
|
+
signal: AbortSignal.timeout(method === "POST" && path.endsWith("/reviews") ? 120_000 : 30_000),
|
|
257
|
+
};
|
|
258
|
+
if (body !== undefined)
|
|
259
|
+
init.body = JSON.stringify(body);
|
|
260
|
+
let response;
|
|
261
|
+
try {
|
|
262
|
+
response = await this.fetchImpl(url, init);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
const next = retryDecision(method, idempotent, "network_error", attempt);
|
|
266
|
+
if (!next.retry)
|
|
267
|
+
throw error;
|
|
268
|
+
await sleep(next.waitMs);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (response.ok)
|
|
272
|
+
return response.status === 204 ? undefined : response.json();
|
|
273
|
+
const detail = (await response.text().catch(() => "")).slice(0, 500);
|
|
274
|
+
const next = retryDecision(method, idempotent, { status: response.status, headers: response.headers, body: detail }, attempt);
|
|
275
|
+
if (next.retry) {
|
|
276
|
+
await sleep(next.waitMs);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
throw new GitHubApiError(response.status, `GitHub ${method} ${path.split("?")[0]} failed with ${response.status}: ${detail}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=client.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_BOT_LOGIN, GitHubAdapter } from "./adapter.js";
|
|
3
|
+
import { GitHubApi } from "./client.js";
|
|
4
|
+
const optionsSchema = z.object({
|
|
5
|
+
owner: z.string().min(1),
|
|
6
|
+
repo: z.string().min(1),
|
|
7
|
+
number: z.number().int().positive(),
|
|
8
|
+
token: z.string().min(1),
|
|
9
|
+
apiUrl: z.string().url().optional(),
|
|
10
|
+
botLogin: z.string().min(1).default(DEFAULT_BOT_LOGIN),
|
|
11
|
+
requestChanges: z.boolean().default(false),
|
|
12
|
+
fetch: z.custom((value) => typeof value === "function").optional(),
|
|
13
|
+
code: z.custom((value) => typeof value === "object" &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
["getDiff", "readFile", "searchCode"].every((m) => typeof value[m] === "function"), "code must provide getDiff, readFile and searchCode"),
|
|
16
|
+
snapshot: z
|
|
17
|
+
.custom((value) => typeof value === "object" && value !== null && "head" in value)
|
|
18
|
+
.optional(),
|
|
19
|
+
history: z
|
|
20
|
+
.custom((value) => typeof value === "object" &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
typeof value.filesChangedSince === "function", "history must provide filesChangedSince")
|
|
23
|
+
.optional(),
|
|
24
|
+
});
|
|
25
|
+
export const githubPlugin = {
|
|
26
|
+
name: "vcs-github",
|
|
27
|
+
configure(ctx) {
|
|
28
|
+
ctx.registerVcs("github", (raw) => {
|
|
29
|
+
const options = optionsSchema.parse(raw);
|
|
30
|
+
const api = new GitHubApi({ owner: options.owner, repo: options.repo }, {
|
|
31
|
+
token: options.token,
|
|
32
|
+
...(options.apiUrl ? { baseUrl: options.apiUrl } : {}),
|
|
33
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
34
|
+
});
|
|
35
|
+
return new GitHubAdapter({
|
|
36
|
+
pullRequest: { owner: options.owner, repo: options.repo, number: options.number },
|
|
37
|
+
api,
|
|
38
|
+
code: options.code,
|
|
39
|
+
botLogin: options.botLogin,
|
|
40
|
+
requestChanges: options.requestChanges,
|
|
41
|
+
...(options.history ? { history: options.history } : {}),
|
|
42
|
+
...(options.snapshot ? { snapshot: options.snapshot } : {}),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=plugin.js.map
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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("<!--", "<!--")
|
|
58
|
+
.replace(/<\/?[a-zA-Z][^>]*>/g, (tag) => tag.replaceAll("<", "<"))
|
|
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
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
|