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.

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