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.

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