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.

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