Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

1223 lines · 65.6 KBRaw History
1import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core";
2import { checkPublicUrl } from "../imports/options.js";
3import type { Env } from "../env.js";
4import { encryptSecret, openSecret } from "../lib/crypto.js";
5import type { QueueEntry } from "./queue.js";
6import { HttpError, checkText, newId, noreplyEmail, noreplyHost, now, randomToken } from "../lib/util.js";
7import type { RepoDO } from "./RepoDO.js";
8import { toInfo } from "./RepoDO.js";
9import { atLeast, refuseBlocked, type Caller } from "./types.js";
10import { COMMENT_SELECT } from "./social.js";
11import { requiredCheck } from "./required.js";
12import { AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, mergesByAgentPermission, type AutoMergeInfo } from "./auto-merge.js";
13
14type Sig = { name: string; email: string; timestamp: number; timezone: string };
15
16/**
17 * The author signature for a squash or rebase commit. A pull request authored by a GitHub import
18 * (a mannequin, author_kind "github") is attributed to the original GitHub user through GitHub's
19 * own noreply domain, never a Diffinery address, so an imported author is never passed off as a
20 * Diffinery account.
21 */
22export function squashSig(author: { login: string; kind?: string | null }, base: Sig, noreply: (login: string) => string): Sig {
23 if (author.kind === "github") {
24 const bare = author.login.replace(/^github:/, "");
25 return { ...base, name: bare, email: `${bare}@users.noreply.github.com` };
26 }
27 return { ...base, name: author.login, email: noreply(author.login) };
28}
29
30export type IssueRow = {
31 number: number;
32 kind: "issue" | "pull";
33 title: string;
34 body: string;
35 state: "open" | "closed";
36 state_reason: string | null;
37 author_id: string;
38 author_login: string;
39 author_kind: string;
40 assignees: string;
41 labels: string;
42 created_at: string;
43 updated_at: string;
44 closed_at: string | null;
45 locked: number;
46};
47
48export type PullRow = {
49 number: number;
50 head_ref: string;
51 head_sha: string;
52 base_ref: string;
53 draft: number;
54 merged: number;
55 merged_at: string | null;
56 merged_by: string | null;
57 merge_commit: string | null;
58 merged_base?: string | null;
59 requested_reviewers: string;
60 intent: string | null;
61 auto_merge: string | null;
62};
63
64export type Issue = {
65 number: number;
66 kind: "issue" | "pull";
67 title: string;
68 body: string;
69 state: "open" | "closed";
70 stateReason: string | null;
71 author: { id: string; login: string; kind: string };
72 assignees: string[];
73 labels: string[];
74 createdAt: string;
75 updatedAt: string;
76 closedAt: string | null;
77 comments: number;
78 locked: boolean;
79 milestone: { number: number; title: string; state: "open" | "closed"; dueOn: string | null } | null;
80 pull?: {
81 head: string;
82 headSha: string;
83 base: string;
84 draft: boolean;
85 merged: boolean;
86 mergedAt: string | null;
87 mergedBy: string | null;
88 mergeCommit: string | null;
89 requestedReviewers: string[];
90 intent: string | null;
91 /** Set while auto-merge is on: it merges (or enqueues) by itself once every rule is met. */
92 autoMerge: AutoMergeInfo | null;
93 };
94};
95
96export type Comment = {
97 id: string;
98 author: { id: string; login: string; kind: string };
99 body: string;
100 createdAt: string;
101 updatedAt: string;
102 reviewId: string | null;
103 path: string | null;
104 line: number | null;
105 side: string | null;
106 commitId: string | null;
107 inReplyTo: string | null;
108 resolved: boolean;
109 /** GitHub-style numeric ids: this comment's, its review's and its parent's. */
110 seq?: number;
111 reviewSeq?: number | null;
112 inReplyToSeq?: number | null;
113 /** Set while the comment is hidden (minimized) by someone with write access: why, by whom, when (lib/moderation.ts). */
114 minimized?: { reason: string; by: string; at: string } | null;
115};
116
117export type Review = {
118 id: string;
119 author: { id: string; login: string; kind: string };
120 state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED";
121 body: string;
122 commitId: string;
123 submittedAt: string;
124 /** GitHub-style numeric id. */
125 seq?: number;
126 /** Set when the review was brought in by a GitHub import: it is shown as history, never counted as a vote. */
127 imported?: boolean;
128};
129
130export type Check = {
131 id: string;
132 sha: string;
133 name: string;
134 status: "queued" | "in_progress" | "completed";
135 conclusion: string | null;
136 runId: string | null;
137 jobId: string | null;
138 detailsUrl: string | null;
139 output: { title?: string; summary?: string } | null;
140 app: string;
141 startedAt: string | null;
142 completedAt: string | null;
143 /** Who reported it over the REST API (none for Diffinery Actions). */
144 creatorId?: string | null;
145 creatorLogin?: string | null;
146 creatorKind?: string | null;
147 /** GitHub-style numeric id. */
148 seq?: number | null;
149};
150
151export type Mergeability = {
152 mergeable: boolean;
153 state: "clean" | "blocked" | "conflicting" | "behind" | "draft" | "merged" | "closed" | "unknown";
154 conflicts: string[];
155 approvals: number;
156 requiredApprovals: number;
157 changesRequested: string[];
158 checks: { name: string; state: string }[];
159 blockers: string[];
160 mergeBase: string | null;
161 /** Set when the base branch merges through a merge queue. */
162 mergeQueue: { method: "merge" | "squash" | "rebase"; entry: QueueEntry | null } | null;
163 /** The base branch's rules that decide the merge form: merge commits refused, admins held to the rules. */
164 linearHistory?: boolean;
165 enforceAdmins?: boolean;
166 /** How many required checks the base branch has: auto-merge waits for CI only where it has some. */
167 requiredChecks?: number;
168};
169
170const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf });
171
172/** Webhooks one repository may have, as on GitHub. */
173export const MAX_WEBHOOKS = 20;
174
175/** A webhook as listed: everything but its signing secret. */
176export interface Webhook {
177 id: string;
178 url: string;
179 events: string[];
180 active: boolean;
181 createdAt: string;
182 lastStatus: number | null;
183 lastDeliveryAt: string | null;
184}
185
186export class Collab {
187 constructor(
188 private repo: RepoDO,
189 private env: Env,
190 ) {}
191
192 private get sql() {
193 return this.repo.sql;
194 }
195
196 private nextNumber(): number {
197 this.sql.exec("INSERT INTO counters (name, value) VALUES ('issue', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1");
198 return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'issue'").one().value;
199 }
200
201 private row(number: number): IssueRow {
202 const r = this.sql.exec<IssueRow>("SELECT * FROM issues WHERE number = ?", number).toArray()[0];
203 if (!r) throw new HttpError(404, `#${number} not found`);
204 return r;
205 }
206
207 private pullRow(number: number): PullRow {
208 const r = this.sql.exec<PullRow>("SELECT * FROM pulls WHERE number = ?", number).toArray()[0];
209 if (!r) throw new HttpError(404, `pull request #${number} not found`);
210 return r;
211 }
212
213 private toIssue(r: IssueRow): Issue {
214 const comments = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM comments WHERE number = ? AND review_id IS NULL", r.number).one().n;
215 const issue: Issue = {
216 number: r.number,
217 kind: r.kind,
218 title: r.title,
219 body: r.body,
220 state: r.state,
221 stateReason: r.state_reason,
222 author: { id: r.author_id, login: r.author_login, kind: r.author_kind },
223 assignees: JSON.parse(r.assignees),
224 labels: JSON.parse(r.labels),
225 createdAt: r.created_at,
226 updatedAt: r.updated_at,
227 closedAt: r.closed_at,
228 comments,
229 locked: !!r.locked,
230 milestone: this.repo.issueMilestoneOf(r.number),
231 };
232 if (r.kind === "pull") {
233 const p = this.pullRow(r.number);
234 issue.pull = {
235 head: p.head_ref,
236 headSha: p.head_sha,
237 base: p.base_ref,
238 draft: !!p.draft,
239 merged: !!p.merged,
240 mergedAt: p.merged_at,
241 mergedBy: p.merged_by,
242 mergeCommit: p.merge_commit,
243 requestedReviewers: JSON.parse(p.requested_reviewers),
244 intent: p.intent,
245 autoMerge: p.auto_merge ? AutoMerge.info(JSON.parse(p.auto_merge)) : null,
246 };
247 }
248 return issue;
249 }
250
251 // ---------------------------------------------------------------- issues
252
253 list(
254 opts: {
255 kind?: "issue" | "pull";
256 state?: "open" | "closed" | "all";
257 label?: string;
258 author?: string;
259 limit?: number;
260 offset?: number;
261 q?: string;
262 // GitHub list filters (REST `issues` and `pulls` query parameters).
263 labels?: string[];
264 /** Any of these labels (GraphQL's `labels`); `labels` needs all of them (REST's). */
265 anyLabels?: string[];
266 assignee?: string;
267 /** Mentioned as @login in the body or a comment (GraphQL's `filterBy.mentioned`, search's `mentions:`). */
268 mentioned?: string;
269 milestone?: number | "none" | "*";
270 since?: string;
271 head?: string;
272 base?: string;
273 sort?: "created" | "updated" | "comments";
274 direction?: "asc" | "desc";
275 /** Authors whose issues and pull requests are left out (suspended people, lib/moderation.ts). */
276 hide?: string[];
277 } = {},
278 ): { items: Issue[]; total: number } {
279 const where: string[] = [];
280 const args: unknown[] = [];
281 if (opts.hide?.length) {
282 where.push("author_id NOT IN (SELECT value FROM json_each(?))");
283 args.push(JSON.stringify(opts.hide));
284 }
285 if (opts.kind) {
286 where.push("kind = ?");
287 args.push(opts.kind);
288 }
289 if (opts.state && opts.state !== "all") {
290 where.push("state = ?");
291 args.push(opts.state);
292 }
293 if (opts.author) {
294 where.push("author_login = ? COLLATE NOCASE");
295 args.push(opts.author);
296 }
297 if (opts.label) {
298 where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)");
299 args.push(opts.label);
300 }
301 if (opts.q) {
302 where.push("(title LIKE ? OR body LIKE ?)");
303 args.push(`%${opts.q}%`, `%${opts.q}%`);
304 }
305 for (const l of opts.labels ?? []) {
306 where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)");
307 args.push(l);
308 }
309 if (opts.anyLabels?.length) {
310 where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value IN (SELECT value FROM json_each(?)))");
311 args.push(JSON.stringify(opts.anyLabels));
312 }
313 if (opts.mentioned !== undefined) {
314 // @login with no letter, digit or underscore before it and no login character after it
315 // (so @ann doesn't match @anna or an email address). Logins are letters, digits and hyphens.
316 if (!/^[A-Za-z0-9-]+$/.test(opts.mentioned)) where.push("0");
317 else {
318 const pattern = `*[^a-z0-9_]@${opts.mentioned.toLowerCase()}[^a-z0-9-]*`;
319 where.push("(lower(' ' || body || ' ') GLOB ? OR EXISTS (SELECT 1 FROM comments c WHERE c.number = issues.number AND lower(' ' || c.body || ' ') GLOB ?))");
320 args.push(pattern, pattern);
321 }
322 }
323 if (opts.assignee === "none") where.push("assignees = '[]'");
324 else if (opts.assignee === "*") where.push("assignees != '[]'");
325 else if (opts.assignee) {
326 where.push("EXISTS (SELECT 1 FROM json_each(assignees) WHERE lower(value) = lower(?))");
327 args.push(opts.assignee);
328 }
329 if (opts.milestone === "none") where.push("number NOT IN (SELECT number FROM issue_milestones)");
330 else if (opts.milestone === "*") where.push("number IN (SELECT number FROM issue_milestones)");
331 else if (opts.milestone !== undefined) {
332 where.push("number IN (SELECT number FROM issue_milestones WHERE milestone = ?)");
333 args.push(opts.milestone);
334 }
335 if (opts.since) {
336 where.push("updated_at >= ?");
337 args.push(opts.since);
338 }
339 if (opts.head) {
340 where.push("number IN (SELECT number FROM pulls WHERE head_ref = ?)");
341 args.push(opts.head);
342 }
343 if (opts.base) {
344 where.push("number IN (SELECT number FROM pulls WHERE base_ref = ?)");
345 args.push(opts.base);
346 }
347 const w = where.length ? `WHERE ${where.join(" AND ")}` : "";
348 const dir = opts.direction === "asc" ? "ASC" : "DESC";
349 const order =
350 opts.sort === "updated"
351 ? `updated_at ${dir}, number ${dir}`
352 : opts.sort === "comments"
353 ? `(SELECT count(*) FROM comments c WHERE c.number = issues.number AND c.review_id IS NULL) ${dir}, number ${dir}`
354 : `number ${dir}`;
355 const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM issues ${w}`, ...args).one().n;
356 const rows = this.sql.exec<IssueRow>(`SELECT * FROM issues ${w} ORDER BY ${order} LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 30, 100), opts.offset ?? 0).toArray();
357 return { items: rows.map((r) => this.toIssue(r)), total };
358 }
359
360 /** An issue or pull request with its comments and reviews. `hide`: authors left out (suspended people, lib/moderation.ts); their own issue is a 404. */
361 get(number: number, hide?: string[]): { issue: Issue; comments: Comment[]; reviews: Review[] } {
362 const issue = this.toIssue(this.row(number));
363 if (hide?.includes(issue.author.id)) throw new HttpError(404, `#${number} not found`);
364 const comments = this.sql
365 .exec<Record<string, SqlStorageValue>>(`SELECT ${COMMENT_SELECT} FROM comments c WHERE c.number = ? ORDER BY c.created_at`, number)
366 .toArray()
367 .map(
368 (c): Comment => ({
369 id: c.id as string,
370 author: { id: c.author_id as string, login: c.author_login as string, kind: c.author_kind as string },
371 body: c.body as string,
372 createdAt: c.created_at as string,
373 updatedAt: c.updated_at as string,
374 reviewId: (c.review_id as string) ?? null,
375 path: (c.path as string) ?? null,
376 line: (c.line as number) ?? null,
377 side: (c.side as string) ?? null,
378 commitId: (c.commit_id as string) ?? null,
379 inReplyTo: (c.in_reply_to as string) ?? null,
380 resolved: !!c.resolved,
381 seq: c.seq as number,
382 reviewSeq: (c.review_seq as number) ?? null,
383 inReplyToSeq: (c.reply_seq as number) ?? null,
384 minimized: c.minimized_reason ? { reason: c.minimized_reason as string, by: (c.minimized_by as string) ?? "", at: (c.minimized_at as string) ?? "" } : null,
385 }),
386 );
387 const reviews = this.reviews(number);
388 if (hide?.length) return { issue, comments: comments.filter((c) => !hide.includes(c.author.id)), reviews: reviews.filter((r) => !hide.includes(r.author.id)) };
389 return { issue, comments, reviews };
390 }
391
392 private reviews(number: number): Review[] {
393 return this.sql
394 .exec<Record<string, string>>("SELECT rowid AS seq, * FROM reviews WHERE number = ? ORDER BY submitted_at", number)
395 .toArray()
396 .map((r) => ({
397 id: r.id!,
398 author: { id: r.author_id!, login: r.author_login!, kind: r.author_kind! },
399 state: r.state as Review["state"],
400 body: r.body!,
401 commitId: r.commit_id!,
402 submittedAt: r.submitted_at!,
403 seq: Number(r.seq),
404 imported: r.imported != null,
405 }));
406 }
407
408 createIssue(input: { title: string; body?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue {
409 refuseBlocked(caller);
410 if (!input.title?.trim()) throw new HttpError(422, "title is required");
411 checkText("Issue", input);
412 // As on GitHub, labels and assignees from someone without triage access are silently dropped.
413 if (!atLeast(caller.role, "triage")) input = { ...input, labels: [], assignees: [] };
414 const n = this.nextNumber();
415 const t = now();
416 this.sql.exec(
417 "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, labels, assignees, created_at, updated_at) VALUES (?, 'issue', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
418 n,
419 input.title.trim(),
420 input.body ?? "",
421 caller.id,
422 caller.login,
423 caller.kind,
424 JSON.stringify(input.labels ?? []),
425 JSON.stringify(input.assignees ?? []),
426 t,
427 t,
428 );
429 const m = this.repo.meta;
430 this.repo.emit({ type: "issues", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, actor: actorOf(caller), at: t });
431 this.repo.broadcast("issues", { number: n, action: "opened" });
432 return this.toIssue(this.row(n));
433 }
434
435 update(number: number, patch: { title?: string; body?: string; state?: "open" | "closed"; stateReason?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue {
436 const r = this.row(number);
437 checkText(r.kind === "pull" ? "PullRequest" : "Issue", patch);
438 const isAuthor = r.author_id === caller.id;
439 if (!isAuthor && !atLeast(caller.role, "triage")) throw new HttpError(403, "permission denied");
440 if (!atLeast(caller.role, "triage")) {
441 // As on GitHub, an author without triage access takes no part once they're blocked or the conversation is locked,
442 // and can't reopen what someone else closed.
443 refuseBlocked(caller);
444 if (r.locked) throw new HttpError(403, "conversation is locked");
445 if (patch.state === "open" && r.state === "closed") {
446 const closer = this.sql.exec<{ actor_id: string }>("SELECT actor_id FROM issue_events WHERE number = ? AND event = 'closed' ORDER BY rowid DESC LIMIT 1", number).toArray()[0];
447 if (closer && closer.actor_id !== caller.id) throw new HttpError(403, "only someone with triage access can reopen an issue someone else closed");
448 }
449 // As when creating: labels and assignees are silently dropped.
450 patch = { ...patch, labels: undefined, assignees: undefined };
451 }
452 if (r.kind === "pull" && patch.state === "open" && this.pullRow(number).merged) throw new HttpError(422, "a merged pull request cannot be reopened");
453 const t = now();
454 const state = patch.state ?? r.state;
455 this.sql.exec(
456 "UPDATE issues SET title = ?, body = ?, state = ?, state_reason = ?, labels = ?, assignees = ?, updated_at = ?, closed_at = ? WHERE number = ?",
457 patch.title ?? r.title,
458 patch.body ?? r.body,
459 state,
460 patch.state === "closed" ? (patch.stateReason ?? "completed") : patch.state === "open" ? null : r.state_reason,
461 JSON.stringify(patch.labels ?? JSON.parse(r.labels)),
462 JSON.stringify(patch.assignees ?? JSON.parse(r.assignees)),
463 t,
464 state === "closed" ? (r.closed_at ?? t) : null,
465 number,
466 );
467 this.repo.social.recordUpdate(r, patch, caller);
468 const m = this.repo.meta;
469 if ((patch.title !== undefined && patch.title !== r.title) || (patch.body !== undefined && patch.body !== r.body)) {
470 if (r.kind === "pull") {
471 const p = this.pullRow(number);
472 this.repo.emit({ type: "pull_request", action: "edited", repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
473 this.repo.background(this.repo.actions.onPullRequest("edited", number, caller).catch((e) => console.error("pr workflow trigger", e)));
474 } else this.repo.emit({ type: "issues", action: "edited", repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t });
475 }
476 if (patch.state && patch.state !== r.state) {
477 const action = patch.state === "closed" ? "closed" : "reopened";
478 if (r.kind === "pull") {
479 const p = this.pullRow(number);
480 this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
481 if (!p.merged) this.repo.background(this.repo.actions.onPullRequest(action, number, caller).catch((e) => console.error("pr workflow trigger", e)));
482 if (action === "closed" && this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref);
483 if (action === "closed") this.repo.autoMerge.clear(number);
484 } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t });
485 }
486 const before = new Set((JSON.parse(r.assignees) as string[]).map((x) => x.toLowerCase()));
487 const added = (patch.assignees ?? []).filter((x) => !before.has(x.toLowerCase()));
488 if (added.length) {
489 if (r.kind === "pull") {
490 const p = this.pullRow(number);
491 this.repo.emit({ type: "pull_request", action: "assigned", repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t, assignees: added });
492 } else this.repo.emit({ type: "issues", action: "assigned", repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t, assignees: added });
493 }
494 this.repo.broadcast(`pr:${number}`, { action: "updated" });
495 return this.toIssue(this.row(number));
496 }
497
498 /** Mark a pull request ready for review, or convert it back to a draft. */
499 setDraft(number: number, draft: boolean, caller: Caller): Issue {
500 const r = this.row(number);
501 if (r.kind !== "pull") throw new HttpError(404, "not a pull request");
502 if (r.author_id !== caller.id && !atLeast(caller.role, "write")) throw new HttpError(403, "only the author or someone with write access can change draft status");
503 if (!atLeast(caller.role, "write")) refuseBlocked(caller);
504 if (r.state !== "open") throw new HttpError(422, "the pull request is not open");
505 const p = this.pullRow(number);
506 if (!!p.draft === draft) return this.toIssue(r);
507 const t = now();
508 this.sql.exec("UPDATE pulls SET draft = ? WHERE number = ?", draft ? 1 : 0, number);
509 this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number);
510 const action = draft ? "converted_to_draft" : "ready_for_review";
511 // GitHub names the timeline event convert_to_draft; the webhook action is converted_to_draft.
512 this.repo.social.record(number, draft ? "convert_to_draft" : "ready_for_review", caller, {});
513 const m = this.repo.meta;
514 this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
515 this.repo.background(this.repo.actions.onPullRequest(action, number, caller).catch((e) => console.error("pr workflow trigger", e)));
516 // As on GitHub, a draft can't auto-merge; one marked ready may now be mergeable.
517 if (draft) this.repo.autoMerge.cancel(number, caller, "the pull request was converted to a draft");
518 else {
519 this.repo.autoMerge.schedule({ number });
520 // A branch rule naming its agent, or (when they mark their own draft ready) the author's own setting.
521 this.repo.background(this.repo.autoMerge.applyRule(number, true).then(() => this.repo.autoMerge.applyOwn(number, caller, { announce: true })));
522 }
523 this.repo.broadcast(`pr:${number}`, { action: "updated" });
524 return this.toIssue(this.row(number));
525 }
526
527 addComment(number: number, body: string, caller: Caller): Comment {
528 refuseBlocked(caller);
529 const r = this.row(number);
530 if (r.locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked");
531 if (!body?.trim()) throw new HttpError(422, "body is required");
532 checkText("IssueComment", { body });
533 const id = newId();
534 const t = now();
535 this.sql.exec("INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id, number, caller.id, caller.login, caller.kind, body, t, t);
536 this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number);
537 const m = this.repo.meta;
538 this.repo.emit({ type: "issue_comment", action: "created", repoId: m.repoId, owner: m.owner, repo: m.name, number, commentId: id, actor: actorOf(caller), at: t });
539 this.repo.broadcast(`pr:${number}`, { action: "comment", id });
540 return this.get(number).comments.find((c) => c.id === id)!;
541 }
542
543 /**
544 * Hide (minimize) a comment with one of GitHub's reasons, or (null) show it again. Needs write
545 * access, as on GitHub; agents can't. Works for issue and review comments, by either id form.
546 */
547 minimizeComment(id: string, reason: string | null, caller: Caller): { number: number; reason: string | null } {
548 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to hide comments");
549 if (caller.kind !== "user") throw new HttpError(403, "only people can hide comments");
550 id = this.repo.social.commentKey(id);
551 const c = this.sql.exec<{ number: number }>("SELECT number FROM comments WHERE id = ?", id).toArray()[0];
552 if (!c) throw new HttpError(404, "comment not found");
553 if (reason) this.sql.exec("UPDATE comments SET minimized_reason = ?, minimized_by = ?, minimized_at = ? WHERE id = ?", reason, caller.login, now(), id);
554 else this.sql.exec("UPDATE comments SET minimized_reason = NULL, minimized_by = NULL, minimized_at = NULL WHERE id = ?", id);
555 this.repo.broadcast(`pr:${c.number}`, { action: "comment", id });
556 return { number: c.number, reason };
557 }
558
559 editComment(id: string, body: string, caller: Caller): void {
560 checkText("IssueComment", { body });
561 id = this.repo.social.commentKey(id);
562 const c = this.sql.exec<{ author_id: string; number: number | null }>("SELECT author_id, number FROM comments WHERE id = ?", id).toArray()[0];
563 if (!c) throw new HttpError(404, "comment not found");
564 if (c.author_id !== caller.id && !atLeast(caller.role, "maintain")) throw new HttpError(403, "permission denied");
565 if (!atLeast(caller.role, "maintain")) {
566 // Its author edits it only while they may still comment: not blocked, and the conversation not locked.
567 refuseBlocked(caller);
568 if (c.number != null && this.row(c.number).locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked");
569 }
570 this.sql.exec("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?", body, now(), id);
571 }
572
573 labels(): Array<{ name: string; color: string; description: string }> {
574 return this.sql.exec<{ name: string; color: string; description: string }>("SELECT * FROM labels ORDER BY name").toArray();
575 }
576
577 setLabel(name: string, color: string | null, description = ""): void {
578 if (color === null) this.sql.exec("DELETE FROM labels WHERE name = ?", name);
579 else this.sql.exec("INSERT OR REPLACE INTO labels (name, color, description) VALUES (?, ?, ?)", name, color.replace(/^#/, ""), description);
580 }
581
582 // ---------------------------------------------------------------- pull requests
583
584 /**
585 * `autoMerge`: what the opener asked for (the API's `auto_merge`): false keeps auto-merge off, true turns it on.
586 * Left out, a branch rule or the opener's own setting may turn it on (auto-merge.ts opened). When they asked
587 * and it stayed off, `autoMergeSkipped` says why.
588 */
589 async createPull(input: { title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[]; autoMerge?: boolean }, caller: Caller): Promise<Issue & { autoMergeSkipped?: string }> {
590 refuseBlocked(caller);
591 this.repo.policy.assertMayCreateOrApprove(caller, "create");
592 checkText("PullRequest", input);
593 const base = input.base ?? this.repo.meta.defaultBranch;
594 if (input.head === base) throw new HttpError(422, "head and base must be different branches");
595 const headSha = this.repo.getRef(`refs/heads/${input.head}`);
596 const baseSha = this.repo.getRef(`refs/heads/${base}`);
597 if (!headSha) throw new HttpError(422, `head branch '${input.head}' does not exist`);
598 if (!baseSha) throw new HttpError(422, `base branch '${base}' does not exist`);
599 if (await isAncestor(this.repo.store, headSha, baseSha)) throw new HttpError(422, `no commits between ${base} and ${input.head}`);
600 const dup = this.sql.exec<{ number: number }>("SELECT p.number FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND p.base_ref = ? AND i.state = 'open'", input.head, base).toArray()[0];
601 if (dup) throw new HttpError(422, `a pull request already exists for ${input.head}: #${dup.number}`, { number: dup.number });
602 if (!input.title?.trim()) throw new HttpError(422, "title is required");
603 const n = this.nextNumber();
604 const t = now();
605 this.sql.exec(
606 "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, created_at, updated_at) VALUES (?, 'pull', ?, ?, ?, ?, ?, ?, ?)",
607 n,
608 input.title.trim(),
609 input.body ?? "",
610 caller.id,
611 caller.login,
612 caller.kind,
613 t,
614 t,
615 );
616 this.sql.exec(
617 "INSERT INTO pulls (number, head_ref, head_sha, base_ref, draft, requested_reviewers, intent) VALUES (?, ?, ?, ?, ?, ?, ?)",
618 n,
619 input.head,
620 headSha,
621 base,
622 input.draft ? 1 : 0,
623 JSON.stringify(input.reviewers ?? []),
624 input.intent ?? null,
625 );
626 const m = this.repo.meta;
627 this.repo.emit({ type: "pull_request", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, headRef: input.head, headSha, baseRef: base, actor: actorOf(caller), at: t });
628 await this.repo.actions.onPullRequest("opened", n, caller).catch((e) => console.error("pr workflow trigger", e));
629 // A branch rule, or the opener's own setting, may turn auto-merge on (auto-merge.ts opened). A draft waits until it's ready.
630 let skipped: string | null = input.draft && input.autoMerge === true ? "the pull request is a draft; auto-merge can turn on once it is marked ready" : null;
631 if (!input.draft) {
632 try {
633 skipped = await this.repo.autoMerge.opened(n, caller, typeof input.autoMerge === "boolean" ? input.autoMerge : undefined);
634 } catch (e) {
635 console.error("auto-merge on open", e);
636 }
637 }
638 const issue = this.toIssue(this.row(n));
639 return skipped && input.autoMerge === true ? { ...issue, autoMergeSkipped: skipped } : issue;
640 }
641
642 /**
643 * Keep `refs/pull/<n>/head` on the pull request's head and `refs/pull/<n>/merge` on a
644 * test merge commit of the head into the current base, like GitHub. The merge ref is
645 * removed while the two conflict. Returns the base and merge commits.
646 */
647 async refreshPullRefs(number: number): Promise<{ baseSha: string | null; mergeSha: string | null }> {
648 const p = this.pullRow(number);
649 const headRef = `refs/pull/${number}/head`;
650 const mergeRef = `refs/pull/${number}/merge`;
651 if (this.repo.getRef(headRef) !== p.head_sha) this.repo.casRef(headRef, null, p.head_sha);
652 const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
653 const current = this.repo.getRef(mergeRef);
654 if (!baseSha) return { baseSha: null, mergeSha: current };
655 const store = this.repo.store;
656 if (current) {
657 const c = await readCommit(store, current).catch(() => null);
658 if (c && c.parents.length === 2 && c.parents[0] === baseSha && c.parents[1] === p.head_sha) return { baseSha, mergeSha: current };
659 }
660 const mb = await mergeBase(store, baseSha, p.head_sha);
661 const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), mb ? readCommit(store, mb) : null]);
662 const merged = await mergeTrees(store, mt?.tree ?? null, bt.tree, ht.tree);
663 if (merged.conflicts.length || !merged.tree) {
664 if (current) this.repo.casRef(mergeRef, null, null);
665 return { baseSha, mergeSha: null };
666 }
667 const sig = { name: "Diffinery", email: `noreply@${noreplyHost(this.env.PUBLIC_URL)}`, timestamp: Math.floor(Date.now() / 1000), timezone: "+0000" };
668 const sha = await store.write("commit", serializeCommit({ tree: merged.tree, parents: [baseSha, p.head_sha], author: sig, committer: sig, message: `Merge ${p.head_sha} into ${baseSha}\n` }));
669 this.repo.casRef(mergeRef, null, sha);
670 return { baseSha, mergeSha: sha };
671 }
672
673 requestReviewers(number: number, reviewers: string[], caller: Caller): void {
674 if (!atLeast(caller.role, "triage") && this.row(number).author_id !== caller.id) throw new HttpError(403, "permission denied");
675 const p = this.pullRow(number);
676 const set = new Set<string>([...JSON.parse(p.requested_reviewers), ...reviewers]);
677 this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify([...set]), number);
678 const before = new Set((JSON.parse(p.requested_reviewers) as string[]).map((x) => x.toLowerCase()));
679 const added = [...new Set(reviewers)].filter((x) => !before.has(x.toLowerCase()));
680 if (added.length) {
681 const m = this.repo.meta;
682 this.repo.emit({ type: "pull_request", action: "review_requested", repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: now(), reviewers: added });
683 }
684 for (const l of added) this.repo.social.record(number, "review_requested", caller, { requested_reviewer: l });
685 }
686
687 /** Called after a branch moves: refresh open pull requests whose head is that branch. */
688 async syncPullsForBranch(branch: string, sha: string, caller: Caller): Promise<number[]> {
689 const rows = this.sql.exec<PullRow>("SELECT p.* FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND i.state = 'open'", branch).toArray();
690 const m = this.repo.meta;
691 const out: number[] = [];
692 for (const p of rows) {
693 if (p.head_sha === sha) continue;
694 this.sql.exec("UPDATE pulls SET head_sha = ? WHERE number = ?", sha, p.number);
695 this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", now(), p.number);
696 const prefs = this.repo.protectionFor(p.base_ref);
697 if (prefs?.dismissStaleReviews) this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE number = ? AND state = 'APPROVED' AND commit_id != ?", p.number, sha);
698 this.repo.autoMerge.headPushed(p.number, caller);
699 this.repo.emit({ type: "pull_request", action: "synchronize", repoId: m.repoId, owner: m.owner, repo: m.name, number: p.number, headRef: branch, headSha: sha, baseRef: p.base_ref, actor: actorOf(caller), at: now() });
700 await this.repo.actions.onPullRequest("synchronize", p.number, caller).catch((e) => console.error("pr workflow trigger", e));
701 if (this.repo.queue.isQueued(p.number)) this.repo.queue.schedule(p.base_ref);
702 this.repo.autoMerge.schedule({ number: p.number });
703 // The agent that opened it pushed again: a branch rule may auto-merge its pull requests (opened before the rule, say).
704 if (this.row(p.number).author_id === caller.id) this.repo.background(this.repo.autoMerge.applyRule(p.number, false));
705 this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha });
706 out.push(p.number);
707 }
708 return out;
709 }
710
711 /**
712 * The commit a pull request's commits and files are counted from: its base branch, or for a merged
713 * one, where the base branch was when it merged (afterwards the base contains the head, and nothing
714 * would be left). Pull requests merged before that was kept use their merge commit's first parent.
715 */
716 private async diffBase(p: PullRow): Promise<string | null> {
717 if (p.merged) {
718 const store = this.repo.store;
719 if (p.merged_base && (await readCommit(store, p.merged_base).catch(() => null))) return p.merged_base;
720 if (p.merge_commit && p.merge_commit !== p.head_sha) {
721 const parent = (await readCommit(store, p.merge_commit).catch(() => null))?.parents[0];
722 if (parent) return parent;
723 }
724 }
725 return this.repo.getRef(`refs/heads/${p.base_ref}`);
726 }
727
728 async pullFiles(number: number) {
729 const p = this.pullRow(number);
730 const baseSha = (await this.diffBase(p)) ?? p.head_sha;
731 const mb = await mergeBase(this.repo.store, baseSha, p.head_sha);
732 return { mergeBase: mb, headSha: p.head_sha, ...(await this.repo.diff(mb, p.head_sha)) };
733 }
734
735 /**
736 * Line counts for the pull request's changes (GraphQL's additions, deletions and changedFiles), kept
737 * for the base and head they were counted at: clients such as Claude Code ask for them on every poll.
738 */
739 async pullStats(number: number): Promise<{ additions: number; deletions: number; changedFiles: number }> {
740 const p = this.pullRow(number);
741 const baseSha = (await this.diffBase(p)) ?? p.head_sha;
742 const key = `${number}:${baseSha}:${p.head_sha}`;
743 const hit = this.statsCache.get(key);
744 if (hit) return hit;
745 const mb = await mergeBase(this.repo.store, baseSha, p.head_sha);
746 const d = await this.repo.diff(mb, p.head_sha, { patch: false });
747 const stats = { additions: d.additions, deletions: d.deletions, changedFiles: d.totalFiles };
748 if (this.statsCache.size >= 256) this.statsCache.delete(this.statsCache.keys().next().value!);
749 this.statsCache.set(key, stats);
750 return stats;
751 }
752 private statsCache = new Map<string, { additions: number; deletions: number; changedFiles: number }>();
753
754 /**
755 * GitHub's reviewDecision and the base branch's required checks, counted as mergeability() counts
756 * approvals but without its merge: APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or null when no
757 * review is needed and nobody has reviewed.
758 */
759 reviewState(number: number): { decision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; requiredChecks: string[]; protection: { requiredApprovals: number; linearHistory: boolean } | null } {
760 const r = this.row(number);
761 const p = this.pullRow(number);
762 const rules = this.repo.protectionFor(p.base_ref) ?? {};
763 let required = rules.requiredApprovals ?? 0;
764 if (r.author_kind === "agent" && required < 1) required = 1;
765 const latest = new Map<string, Review>();
766 for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED" && rv.author.kind !== "github" && !rv.imported) latest.set(rv.author.id, rv);
767 let approvals = 0;
768 let changes = false;
769 for (const rv of latest.values()) {
770 if (rv.state === "APPROVED" && rv.author.kind === "user" && (!rules.dismissStaleReviews || rv.commitId === p.head_sha)) approvals++;
771 if (rv.state === "CHANGES_REQUESTED") changes = true;
772 }
773 const decision = changes ? "CHANGES_REQUESTED" : approvals > 0 && approvals >= required ? "APPROVED" : required > 0 ? "REVIEW_REQUIRED" : null;
774 const protectedBase = this.repo.protectionFor(p.base_ref) !== null;
775 return { decision, requiredChecks: rules.requiredChecks ?? [], protection: protectedBase ? { requiredApprovals: rules.requiredApprovals ?? 0, linearHistory: !!rules.requireLinearHistory } : null };
776 }
777
778 /** The first workflow file the pull request adds, changes or deletes, compared with where it branched off its base. */
779 async changedWorkflow(number: number): Promise<string | null> {
780 const p = this.pullRow(number);
781 const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
782 const mb = baseSha ? await mergeBase(this.repo.store, baseSha, p.head_sha) : null;
783 return this.repo.policy.changedWorkflow(mb ?? ZERO_OID, p.head_sha);
784 }
785
786 async pullCommits(number: number) {
787 const p = this.pullRow(number);
788 const baseSha = await this.diffBase(p);
789 const mb = baseSha ? await mergeBase(this.repo.store, baseSha, p.head_sha) : null;
790 const stop = new Set<string>();
791 if (mb) for (const c of await walkCommits(this.repo.store, [mb], { limit: 100000 })) stop.add(c.oid);
792 return (await walkCommits(this.repo.store, [p.head_sha], { stop, limit: 250 })).map((c) => toInfo(c.oid, c.commit)).reverse();
793 }
794
795 submitReview(
796 number: number,
797 input: { event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string; inReplyTo?: string }> },
798 caller: Caller,
799 ): Review {
800 refuseBlocked(caller);
801 checkText("PullRequestReview", input);
802 for (const c of input.comments ?? []) checkText("PullRequestReviewComment", c);
803 const r = this.row(number);
804 const p = this.pullRow(number);
805 if (r.state !== "open") throw new HttpError(422, "pull request is closed");
806 if (r.locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked");
807 if (input.event !== "COMMENT" && r.author_id === caller.id) throw new HttpError(422, "you cannot approve or request changes on your own pull request");
808 if (input.event !== "COMMENT" && !atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to approve or request changes");
809 // A workflow's GITHUB_TOKEN approves (or blocks) only where the repository allows it, as on GitHub.
810 if (input.event !== "COMMENT") this.repo.policy.assertMayCreateOrApprove(caller, "approve");
811 if (input.event === "COMMENT" && !input.body?.trim() && !input.comments?.length) throw new HttpError(422, "a comment review needs a body or line comments");
812 const id = newId();
813 const t = now();
814 const state = input.event === "APPROVE" ? "APPROVED" : input.event === "REQUEST_CHANGES" ? "CHANGES_REQUESTED" : "COMMENTED";
815 this.sql.exec(
816 "INSERT INTO reviews (id, number, author_id, author_login, author_kind, state, body, commit_id, submitted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
817 id,
818 number,
819 caller.id,
820 caller.login,
821 caller.kind,
822 state,
823 input.body ?? "",
824 p.head_sha,
825 t,
826 );
827 for (const c of input.comments ?? []) {
828 this.sql.exec(
829 "INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at, review_id, path, line, side, commit_id, in_reply_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
830 newId(),
831 number,
832 caller.id,
833 caller.login,
834 caller.kind,
835 c.body,
836 t,
837 t,
838 id,
839 c.path,
840 c.line,
841 c.side ?? "RIGHT",
842 p.head_sha,
843 c.inReplyTo ?? null,
844 );
845 }
846 const reviewers = (JSON.parse(p.requested_reviewers) as string[]).filter((x) => x.toLowerCase() !== caller.login.toLowerCase());
847 this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify(reviewers), number);
848 this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number);
849 const m = this.repo.meta;
850 this.repo.emit({ type: "pull_request_review", action: "submitted", repoId: m.repoId, owner: m.owner, repo: m.name, number, state, actor: actorOf(caller), at: t });
851 this.repo.broadcast(`pr:${number}`, { action: "review", state });
852 this.repo.autoMerge.schedule({ number });
853 return this.reviews(number).find((x) => x.id === id)!;
854 }
855
856 resolveThread(commentId: string, resolved: boolean, caller: Caller): void {
857 if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied");
858 this.sql.exec("UPDATE comments SET resolved = ? WHERE id = ? OR in_reply_to = ?", resolved ? 1 : 0, commentId, commentId);
859 }
860
861 // ---------------------------------------------------------------- checks
862
863 checks(sha: string): Check[] {
864 return this.sql
865 .exec<Record<string, string | null>>("SELECT * FROM checks WHERE sha = ? ORDER BY name", sha)
866 .toArray()
867 .map((c) => ({
868 id: c.id!,
869 sha: c.sha!,
870 name: c.name!,
871 status: c.status as Check["status"],
872 conclusion: c.conclusion ?? null,
873 runId: c.run_id ?? null,
874 jobId: c.job_id ?? null,
875 detailsUrl: c.details_url ?? null,
876 output: c.output ? JSON.parse(c.output) : null,
877 app: c.app!,
878 startedAt: c.started_at ?? null,
879 completedAt: c.completed_at ?? null,
880 creatorId: c.creator_id ?? null,
881 creatorLogin: c.creator_login ?? null,
882 creatorKind: c.creator_kind ?? null,
883 seq: c.seq === null || c.seq === undefined ? null : Number(c.seq),
884 }));
885 }
886
887 /** Create or update a check run, keyed by (sha, name, app). */
888 upsertCheck(input: { sha: string; name: string; status: Check["status"]; conclusion?: string | null; runId?: string; jobId?: string; detailsUrl?: string; output?: Check["output"]; app?: string; creator?: { id: string; login: string; kind: string } }): Check {
889 const app = input.app ?? "diffinery-actions";
890 const existing = this.sql.exec<{ id: string; started_at: string | null; seq: number | null }>("SELECT id, started_at, seq FROM checks WHERE sha = ? AND name = ? AND app = ?", input.sha, input.name, app).toArray()[0];
891 const t = now();
892 const id = existing?.id ?? newId();
893 const seq = existing?.seq ?? this.sql.exec<{ n: number }>("SELECT coalesce(max(seq), 0) + 1 AS n FROM checks").one().n;
894 this.sql.exec(
895 "INSERT OR REPLACE INTO checks (id, sha, name, status, conclusion, run_id, job_id, details_url, output, app, started_at, completed_at, creator_id, creator_login, creator_kind, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
896 id,
897 input.sha,
898 input.name,
899 input.status,
900 input.conclusion ?? null,
901 input.runId ?? null,
902 input.jobId ?? null,
903 input.detailsUrl ?? null,
904 input.output ? JSON.stringify(input.output) : null,
905 app,
906 existing?.started_at ?? (input.status !== "queued" ? t : null),
907 input.status === "completed" ? t : null,
908 input.creator?.id ?? null,
909 input.creator?.login ?? null,
910 input.creator?.kind ?? null,
911 seq,
912 );
913 for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", input.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
914 if (input.status === "completed") this.checkCompleted(input.sha);
915 return this.checks(input.sha).find((c) => c.id === id)!;
916 }
917
918 /** A check run or commit status finished on `sha`: a merge group may pass, a pull request may be ready to auto-merge. */
919 checkCompleted(sha: string): void {
920 const queueBase = this.repo.queue.baseForGroup(sha);
921 if (queueBase) this.repo.queue.schedule(queueBase);
922 this.repo.autoMerge.schedule({ sha });
923 }
924
925 // ---------------------------------------------------------------- merging
926
927 /** Remember what a mergeability check found for this base and head, for Today (agentic/today-repo.ts). Never throws. */
928 private recordMergeState(number: number, baseSha: string, headSha: string, state: "clean" | "conflicting" | "in_base"): void {
929 try {
930 this.sql.exec(
931 `INSERT INTO pull_merge_state (number, base_sha, head_sha, state) VALUES (?, ?, ?, ?)
932 ON CONFLICT(number) DO UPDATE SET base_sha = excluded.base_sha, head_sha = excluded.head_sha, state = excluded.state
933 WHERE base_sha IS NOT excluded.base_sha OR head_sha IS NOT excluded.head_sha OR state IS NOT excluded.state`,
934 number,
935 baseSha,
936 headSha,
937 state,
938 );
939 } catch (e) {
940 console.error("recording merge state failed", number, e);
941 }
942 }
943
944 /**
945 * Whether the pull request can merge now, and if not, why. `automatic`: Diffinery itself would
946 * land it for `caller` (auto-merge or the merge queue), which an agent may do only where the
947 * branch allows it (./auto-merge.ts); every other rule applies all the same.
948 */
949 async mergeability(number: number, caller?: Caller, opts: { automatic?: boolean } = {}): Promise<Mergeability> {
950 const r = this.row(number);
951 const p = this.pullRow(number);
952 const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null, mergeQueue: null };
953 if (p.merged) return { ...result, state: "merged", blockers: ["already merged"] };
954 if (r.state === "closed") return { ...result, state: "closed", blockers: ["pull request is closed"] };
955 const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
956 if (!baseSha) return { ...result, blockers: [`base branch ${p.base_ref} no longer exists`] };
957 const mb = await mergeBase(this.repo.store, baseSha, p.head_sha);
958 result.mergeBase = mb;
959 const [bt, ht, mt] = await Promise.all([readCommit(this.repo.store, baseSha), readCommit(this.repo.store, p.head_sha), mb ? readCommit(this.repo.store, mb) : null]);
960 const merged = await mergeTrees(this.repo.store, mt?.tree ?? null, bt.tree, ht.tree);
961 result.conflicts = merged.conflicts;
962 this.recordMergeState(number, baseSha, p.head_sha, mb === p.head_sha ? "in_base" : merged.conflicts.length ? "conflicting" : "clean");
963 const rules = this.repo.protectionFor(p.base_ref) ?? {};
964 if (rules.mergeQueue) result.mergeQueue = { method: rules.mergeQueue.method ?? "merge", entry: this.repo.queue.entry(number) };
965 result.linearHistory = !!rules.requireLinearHistory;
966 result.enforceAdmins = !!rules.enforceAdmins;
967 result.requiredChecks = rules.requiredChecks?.length ?? 0;
968 result.requiredApprovals = rules.requiredApprovals ?? 0;
969 // When Diffinery lands it for an agent (./auto-merge.ts), an approval counts only for the commit it
970 // approved: nobody merges by hand to notice that the agent pushed again after a person approved.
971 const isDefaultBase = p.base_ref === this.repo.meta.defaultBranch;
972 const agentLanding = !!opts.automatic && !!caller && landsByAgentRule(caller, this.repo.protectionFor(p.base_ref), isDefaultBase);
973 // An agent merging on its own policy's permission (Agent setup): a person approved its latest commit.
974 const byPermission = !!caller && mergesByAgentPermission(caller, this.repo.protectionFor(p.base_ref), isDefaultBase, !!opts.automatic);
975 const currentOnly = !!rules.dismissStaleReviews || agentLanding || byPermission;
976 const latest = new Map<string, Review>();
977 // Imported reviews are history, not votes, whoever authored them; and so are plain comments.
978 for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED" && rv.author.kind !== "github" && !rv.imported) latest.set(rv.author.id, rv);
979 for (const rv of latest.values()) {
980 // Only people's approvals count toward requirements: agents' reviews are advisory, and a
981 // workflow's token (kind "system", or "agent" for code an agent wrote) never approves its own way in.
982 if (rv.state === "APPROVED" && rv.author.kind === "user" && (!currentOnly || rv.commitId === p.head_sha)) result.approvals++;
983 if (rv.state === "CHANGES_REQUESTED") result.changesRequested.push(rv.author.login);
984 }
985 // Commit statuses count as checks too: a required check matches a status context.
986 const checks = [...this.checks(p.head_sha), ...this.repo.statuses.asChecks(p.head_sha)];
987 result.checks = checks.map((c) => ({ name: c.name, state: c.status === "completed" ? (c.conclusion ?? "neutral") : c.status }));
988 if (agentLanding && !byPermission && rules.agentAutoMergeWithoutApproval) {
989 // An admin waived approvals for what Diffinery lands for agents here: green required checks
990 // are enough (allowAgentAutoMerge always has some). Requested changes still block it below.
991 result.requiredApprovals = 0;
992 } else if ((r.author_kind === "agent" || byPermission) && result.requiredApprovals < 1) {
993 // Agent-authored changes always need one human approval unless a rule says otherwise, and so does
994 // whatever an agent merges on its own permission.
995 result.requiredApprovals = 1;
996 }
997 if (p.draft) result.blockers.push("pull request is a draft");
998 if (result.conflicts.length) result.blockers.push(`merge conflicts in ${result.conflicts.length} file(s)`);
999 if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)${agentLanding || byPermission ? " of its latest commit" : ""}`);
1000 if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`);
1001 for (const name of rules.requiredChecks ?? []) {
1002 const c = requiredCheck(name, checks, rules.requiredCheckSources?.[name]);
1003 if (c.state === "missing") result.blockers.push(`required check '${name}' has not run`);
1004 else if (c.state === "pending") result.blockers.push(`required check '${name}' is ${c.status}`);
1005 else if (c.state === "failure") result.blockers.push(`required check '${name}' ${c.conclusion}`);
1006 }
1007 if (rules.requireLinearHistory && mb !== baseSha) result.blockers.push("branch is behind its base; update it (linear history required)");
1008 if (caller) {
1009 if (!atLeast(caller.role, "write")) result.blockers.push("write access is required to merge");
1010 const workflowEdit = await this.repo.policy.refuseWorkflowEdit(caller, mb ?? ZERO_OID, p.head_sha);
1011 if (workflowEdit) result.blockers.push(workflowEdit);
1012 const isDefault = p.base_ref === this.repo.meta.defaultBranch;
1013 const protection = this.repo.protectionFor(p.base_ref);
1014 if (opts.automatic && !isPerson(caller)) {
1015 // Agents and workflow tokens never merge here themselves; a rule may let Diffinery land it for them.
1016 const refusal = automaticLandingRefusal(caller, protection, isDefault, p.base_ref);
1017 if (refusal) result.blockers.push(refusal.message);
1018 else if (landsByAgentRule(caller, protection, isDefault)) {
1019 // CI gates what lands, so an agent's change to the workflows that run it needs a person.
1020 const path = await this.repo.policy.changedWorkflow(mb ?? ZERO_OID, p.head_sha);
1021 if (path) result.blockers.push(`the pull request changes workflow file \`${path}\`; a person must merge it`);
1022 }
1023 } else if (byPermission) {
1024 const path = await this.repo.policy.changedWorkflow(mb ?? ZERO_OID, p.head_sha);
1025 if (path) result.blockers.push(`the pull request changes workflow file \`${path}\`; a person must merge it`);
1026 } else if (caller.kind === "agent" && (isDefault || Object.keys(rules).length) && !rules.agentsMayMerge) result.blockers.push("agents may not merge into this branch; a human must merge");
1027 }
1028 result.mergeable = result.blockers.length === 0;
1029 result.state = p.draft ? "draft" : result.conflicts.length ? "conflicting" : result.mergeable ? "clean" : "blocked";
1030 return result;
1031 }
1032
1033 /** Merge now. `automatic`: auto-merge doing it for `caller` (./auto-merge.ts), which never bypasses anything. */
1034 async merge(
1035 number: number,
1036 input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean; bypassMergeQueue?: boolean; bypassRules?: boolean },
1037 caller: Caller,
1038 opts: { automatic?: boolean } = {},
1039 ) {
1040 const p = this.pullRow(number);
1041 const r = this.row(number);
1042 // The Worker refuses writes to an archived repository; auto-merge, which runs here by itself, is refused here.
1043 if (await this.repo.isArchived()) throw new HttpError(403, "repository is archived (read-only)");
1044 if (input.expectedHeadSha && input.expectedHeadSha !== p.head_sha) throw new HttpError(409, "head branch was modified; review and try again");
1045 const m = await this.mergeability(number, caller, opts);
1046 const rules = this.repo.protectionFor(p.base_ref) ?? {};
1047 // Admins (people, not agents) may bypass unmet rules, but only when they ask to, and never through auto-merge.
1048 const mayBypass = !opts.automatic && caller.role === "admin" && !rules.enforceAdmins && caller.kind === "user" && m.conflicts.length === 0 && !p.draft;
1049 // Each bypass covers only what it names: bypass_merge_queue skips the queue, bypass_rules skips unmet rules.
1050 const bypassQueue = mayBypass && !!input.bypassMergeQueue;
1051 const adminBypass = mayBypass && !!input.bypassRules;
1052 if (rules.mergeQueue && !bypassQueue) {
1053 throw new HttpError(405, `${p.base_ref} requires a merge queue: add the pull request to the queue${mayBypass ? " (or, as an admin, bypass the queue with bypass_merge_queue)" : ""}`, { blockers: [`${p.base_ref} merges through a merge queue`] });
1054 }
1055 if (!m.mergeable && !adminBypass) throw new HttpError(405, `pull request is not mergeable: ${m.blockers.join("; ")}${mayBypass ? " (as an admin you can bypass with bypass_rules)" : ""}`, { blockers: m.blockers });
1056 const baseRef = `refs/heads/${p.base_ref}`;
1057 const baseSha = this.repo.getRef(baseRef)!;
1058 const store = this.repo.store;
1059 const method = input.method ?? (rules.requireLinearHistory ? "squash" : "merge");
1060 if (method === "merge" && rules.requireLinearHistory && !adminBypass) throw new HttpError(405, `${p.base_ref} requires linear history; use squash or rebase`);
1061 const linearBypassed = method === "merge" && !!rules.requireLinearHistory;
1062 const queueBypassed = bypassQueue && !!rules.mergeQueue;
1063 if ((adminBypass && (!m.mergeable || linearBypassed)) || queueBypassed) {
1064 const meta = this.repo.meta;
1065 const blockers = [...(adminBypass ? m.blockers : []), ...(linearBypassed ? ["linear history required"] : []), ...(queueBypassed ? ["merge queue"] : [])];
1066 this.repo.emit({ type: "audit", action: "pull_request.merge_bypassed", actor: caller, target: `${meta.owner}/${meta.name}#${number}`, detail: { blockers, base: p.base_ref }, at: now() });
1067 }
1068 let newSha: string;
1069 const ts = Math.floor(Date.now() / 1000);
1070 const sig = { name: caller.login, email: noreplyEmail(this.env.PUBLIC_URL, caller.login), timestamp: ts, timezone: "+0000" };
1071 if (method === "rebase") {
1072 if (!(await isAncestor(store, baseSha, p.head_sha))) throw new HttpError(405, "rebase merge requires the branch to be up to date with its base (fast-forward only for now)");
1073 newSha = p.head_sha;
1074 } else {
1075 const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), m.mergeBase ? readCommit(store, m.mergeBase) : null]);
1076 const merged = await mergeTrees(store, mt?.tree ?? null, bt.tree, ht.tree);
1077 if (merged.conflicts.length || !merged.tree) throw new HttpError(405, "merge conflicts");
1078 const title = input.title ?? (method === "squash" ? `${r.title} (#${number})` : `Merge pull request #${number} from ${p.head_ref}`);
1079 const body = input.message ?? (method === "squash" ? (await this.pullCommits(number)).map((c) => `* ${c.message.split("\n")[0]}`).join("\n") : r.title);
1080 const author = method === "squash" ? squashSig({ login: r.author_login, kind: r.author_kind }, sig, (login) => noreplyEmail(this.env.PUBLIC_URL, login)) : sig;
1081 // Like every commit an agent makes, its merge commit says who it acts for.
1082 const extraHeaders: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
1083 newSha = await store.write(
1084 "commit",
1085 serializeCommit({ tree: merged.tree, parents: method === "squash" ? [baseSha] : [baseSha, p.head_sha], author, committer: sig, message: `${title}\n\n${body}\n`, extraHeaders }),
1086 );
1087 }
1088 if (!this.repo.casRef(baseRef, baseSha, newSha)) throw new HttpError(409, "base branch moved during merge; try again");
1089 await this.completeMerge(number, newSha, baseSha, caller, !!input.deleteBranch);
1090 return { merged: true, sha: newSha, method };
1091 }
1092
1093 /**
1094 * Whether landing the pull request may delete its head branch (a merge's `delete_branch`, also kept
1095 * by auto-merge and the merge queue): only a branch of this repository, never the default branch,
1096 * and never a protected one, whoever merges and whatever the rule's allowDeletion says.
1097 */
1098 headBranchDeletable(number: number): boolean {
1099 const p = this.pullRow(number);
1100 return !this.repo.policy.isForkPull(number) && p.head_ref !== this.repo.meta.defaultBranch && !this.repo.protectionFor(p.head_ref);
1101 }
1102
1103 /** Record a merge whose commit is already on the base branch (directly or through the merge queue). */
1104 async completeMerge(number: number, newSha: string, baseSha: string, caller: Caller, deleteBranch: boolean): Promise<void> {
1105 const p = this.pullRow(number);
1106 const r = this.row(number);
1107 const baseRef = `refs/heads/${p.base_ref}`;
1108 const t = now();
1109 this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ?, merged_base = ?, auto_merge = NULL WHERE number = ?", t, caller.login, newSha, baseSha, number);
1110 if (this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref);
1111 this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number);
1112 this.repo.social.record(number, "merged", caller, { commit_id: newSha });
1113 this.repo.social.record(number, "closed", caller, { commit_id: newSha, state_reason: "merged" });
1114 const meta = this.repo.meta;
1115 this.repo.emit({ type: "pull_request", action: "merged", repoId: meta.repoId, owner: meta.owner, repo: meta.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
1116 // A merge is a push to the base branch from the server's point of view.
1117 const serverCaller: Caller = { ...caller, role: "admin" };
1118 await this.repo.afterRefUpdate(baseRef, baseSha, newSha, serverCaller);
1119 // Close keywords: "fixes #12", "closes #3", "resolves #7".
1120 for (const match of `${r.title}\n${r.body}`.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi)) {
1121 const n = Number(match[1]);
1122 try {
1123 const target = this.row(n);
1124 if (target.kind === "issue" && target.state === "open") this.update(n, { state: "closed", stateReason: "completed" }, serverCaller);
1125 } catch {
1126 /* unknown issue number */
1127 }
1128 }
1129 if (deleteBranch && this.headBranchDeletable(number)) {
1130 const headRef = `refs/heads/${p.head_ref}`;
1131 if (this.repo.casRef(headRef, p.head_sha, null)) await this.repo.afterRefUpdate(headRef, p.head_sha, ZERO_OID, serverCaller);
1132 }
1133 this.repo.broadcast(`pr:${number}`, { action: "merged", sha: newSha });
1134 // pull_request workflows with `types: [closed]` (merged ones see github.event.pull_request.merged).
1135 this.repo.background(this.repo.actions.onPullRequest("closed", number, caller).catch((e) => console.error("pr workflow trigger", e)));
1136 }
1137
1138 // ---------------------------------------------------------------- webhooks
1139
1140 /** The repository's webhooks, without their signing secrets. */
1141 webhooks(): Webhook[] {
1142 return this.sql
1143 .exec<Record<string, string | number | null>>("SELECT id, url, events, active, created_at, last_status, last_delivery_at FROM webhooks ORDER BY created_at")
1144 .toArray()
1145 .map((w) => ({
1146 id: w.id as string,
1147 url: w.url as string,
1148 events: JSON.parse(w.events as string),
1149 active: !!w.active,
1150 createdAt: w.created_at as string,
1151 lastStatus: (w.last_status as number) ?? null,
1152 lastDeliveryAt: (w.last_delivery_at as string) ?? null,
1153 }));
1154 }
1155
1156 /**
1157 * The active webhooks with their signing secrets, for delivery. Secrets are stored encrypted; a hook
1158 * saved before that (secret_encrypted = 0) is still signed with its plaintext secret, which is
1159 * encrypted in place here so it doesn't stay in the clear. One sealed with MASTER_KEY_PREV, or in the
1160 * older v1 envelope, is sealed again with the current MASTER_KEY the same way.
1161 */
1162 async deliverableWebhooks(): Promise<Array<Webhook & { secret: string }>> {
1163 const rows = new Map(
1164 this.sql
1165 .exec<{ id: string; secret: string; secret_encrypted: number }>("SELECT id, secret, secret_encrypted FROM webhooks WHERE active = 1")
1166 .toArray()
1167 .map((r) => [r.id, r]),
1168 );
1169 const out: Array<Webhook & { secret: string }> = [];
1170 for (const h of this.webhooks()) {
1171 const r = rows.get(h.id);
1172 if (!r) continue;
1173 if (r.secret_encrypted) {
1174 // A secret that doesn't decrypt (a changed MASTER_KEY, a damaged row) leaves that one hook undelivered, not every hook.
1175 const opened = await openSecret(this.env, this.hookScope(h.id), r.secret).catch((e: Error) => void console.error("webhook secret unreadable", h.id, e.message));
1176 if (!opened) continue;
1177 if (!opened.current) {
1178 // Only if the row still holds the envelope read above; a failure here leaves it for the next delivery.
1179 await encryptSecret(this.env, this.hookScope(h.id), opened.plaintext)
1180 .then((sealed) => void this.sql.exec("UPDATE webhooks SET secret = ? WHERE id = ? AND secret_encrypted = 1 AND secret = ?", sealed, h.id, r.secret))
1181 .catch((e: Error) => console.error("webhook secret not sealed again", h.id, e.message));
1182 }
1183 out.push({ ...h, secret: opened.plaintext });
1184 continue;
1185 }
1186 const sealed = await encryptSecret(this.env, this.hookScope(h.id), r.secret);
1187 // Only if the row is still the plaintext read above (no concurrent re-encryption or delete).
1188 this.sql.exec("UPDATE webhooks SET secret = ?, secret_encrypted = 1 WHERE id = ? AND secret_encrypted = 0 AND secret = ?", sealed, h.id, r.secret);
1189 out.push({ ...h, secret: r.secret });
1190 }
1191 return out;
1192 }
1193
1194 /** Each hook's secret gets its own key, so a ciphertext can't be moved to another hook or into the repository's Actions secrets. */
1195 private hookScope(id: string): string {
1196 return `repo:${this.repo.meta.repoId}:webhook:${id}`;
1197 }
1198
1199 async createWebhook(url: string, events: string[], secret: string | undefined, ownHost: string): Promise<{ id: string; secret: string }> {
1200 // HTTPS to a public host other than Diffinery's own, at most 20 per repository (as on GitHub), so a
1201 // repository can't aim deliveries at internal addresses or multiply every event into a flood.
1202 checkPublicUrl(url, false, "webhook URL", ownHost);
1203 const count = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM webhooks").one().n;
1204 if (count >= MAX_WEBHOOKS) throw new HttpError(422, `a repository can have at most ${MAX_WEBHOOKS} webhooks`);
1205 const id = newId();
1206 const s = secret ?? randomToken(24);
1207 const sealed = await encryptSecret(this.env, this.hookScope(id), s).catch((e: Error) => {
1208 throw new HttpError(422, `could not store the webhook secret: ${e.message || "encryption unavailable"} (is MASTER_KEY configured?)`);
1209 });
1210 // Counted again after the await, so concurrent creates can't pass the limit together.
1211 if (this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM webhooks").one().n >= MAX_WEBHOOKS) throw new HttpError(422, `a repository can have at most ${MAX_WEBHOOKS} webhooks`);
1212 this.sql.exec("INSERT INTO webhooks (id, url, secret, secret_encrypted, events, created_at) VALUES (?, ?, ?, 1, ?, ?)", id, url, sealed, JSON.stringify(events.length ? events : ["*"]), now());
1213 return { id, secret: s };
1214 }
1215
1216 deleteWebhook(id: string): void {
1217 this.sql.exec("DELETE FROM webhooks WHERE id = ?", id);
1218 }
1219
1220 recordDelivery(id: string, status: number): void {
1221 this.sql.exec("UPDATE webhooks SET last_status = ?, last_delivery_at = ? WHERE id = ?", status, now(), id);
1222 }
1223}