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.

485 lines · 28.4 KBRaw History
1import { isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit } from "@diffinery/git-core";
2import { HttpError, checkText, now } from "../lib/util.js";
3import type { Comment, IssueRow, Review } from "./collab.js";
4import type { RepoDO } from "./RepoDO.js";
5import { atLeast, refuseBlocked, type Caller } from "./types.js";
6
7export const REACTION_CONTENT = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"];
8
9export type Person = { id: string; login: string; kind: string };
10export type Reaction = { id: number; content: string; user: Person; createdAt: string };
11/** `detail` is JSON text: what the event is about (label, assignee, rename, lock reason, ...). */
12export type IssueEvent = { id: number; number: number; event: string; actor: Person; detail: string; createdAt: string };
13export type LabelRow = { id: number; name: string; color: string; description: string };
14export type TimelineItem =
15 | ({ kind: "event" } & IssueEvent)
16 | { kind: "comment"; comment: Comment; createdAt: string }
17 | { kind: "review"; review: Review; createdAt: string };
18
19type CommentRow = {
20 id: string;
21 number: number;
22 author_id: string;
23 author_login: string;
24 author_kind: string;
25 body: string;
26 created_at: string;
27 updated_at: string;
28 review_id: string | null;
29 path: string | null;
30 line: number | null;
31 side: string | null;
32 commit_id: string | null;
33 in_reply_to: string | null;
34 resolved: number;
35 seq: number;
36 review_seq: number | null;
37 reply_seq: number | null;
38 minimized_reason: string | null;
39 minimized_by: string | null;
40 minimized_at: string | null;
41};
42
43/**
44 * Comment columns plus the numeric ids GitHub clients expect (row ids of the
45 * comment, its review and the comment it replies to). Use with `comments c`.
46 */
47export const COMMENT_SELECT = "c.rowid AS seq, (SELECT r.rowid FROM reviews r WHERE r.id = c.review_id) AS review_seq, (SELECT p.rowid FROM comments p WHERE p.id = c.in_reply_to) AS reply_seq, c.*";
48
49const toComment = (c: CommentRow): Comment => ({
50 id: c.id,
51 author: { id: c.author_id, login: c.author_login, kind: c.author_kind },
52 body: c.body,
53 createdAt: c.created_at,
54 updatedAt: c.updated_at,
55 reviewId: c.review_id,
56 path: c.path,
57 line: c.line,
58 side: c.side,
59 commitId: c.commit_id,
60 inReplyTo: c.in_reply_to,
61 resolved: !!c.resolved,
62 seq: c.seq,
63 reviewSeq: c.review_seq,
64 inReplyToSeq: c.reply_seq,
65 minimized: c.minimized_reason ? { reason: c.minimized_reason, by: c.minimized_by ?? "", at: c.minimized_at ?? "" } : null,
66});
67
68const LABEL_COLOR = /^[0-9a-fA-F]{6}$/;
69
70/**
71 * The issue and pull request sub-resources bots use: labels and assignees on
72 * one issue, locking, reactions, issue events and timelines, single comments,
73 * review comment replies, dismissing reviews and updating a PR branch.
74 */
75export class Social {
76 constructor(private repo: RepoDO) {}
77
78 private get sql() {
79 return this.repo.sql;
80 }
81
82 private issueRow(number: number): IssueRow {
83 const r = this.sql.exec<IssueRow>("SELECT * FROM issues WHERE number = ?", number).toArray()[0];
84 if (!r) throw new HttpError(404, `#${number} not found`);
85 return r;
86 }
87
88 private triage(caller: Caller): void {
89 if (!atLeast(caller.role, "triage")) throw new HttpError(403, "triage access is required");
90 }
91
92 // ---------------------------------------------------------------- issue events
93
94 record(number: number, event: string, actor: Pick<Caller, "id" | "login" | "kind">, detail: Record<string, unknown> = {}): void {
95 this.sql.exec("INSERT INTO issue_events (number, event, actor_id, actor_login, actor_kind, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", number, event, actor.id, actor.login, actor.kind, JSON.stringify(detail), now());
96 }
97
98 /** Called by Collab.update: turn a patch into labeled/unlabeled/assigned/... events. */
99 recordUpdate(before: IssueRow, patch: { title?: string; state?: string; stateReason?: string; labels?: string[]; assignees?: string[] }, caller: Pick<Caller, "id" | "login" | "kind">): void {
100 if (patch.labels) {
101 const old = new Set(JSON.parse(before.labels) as string[]);
102 const next = new Set(patch.labels);
103 const colors = new Map(this.labelRows().map((l) => [l.name, l.color]));
104 for (const l of next) if (!old.has(l)) this.record(before.number, "labeled", caller, { label: { name: l, color: colors.get(l) ?? "ededed" } });
105 for (const l of old) if (!next.has(l)) this.record(before.number, "unlabeled", caller, { label: { name: l, color: colors.get(l) ?? "ededed" } });
106 }
107 if (patch.assignees) {
108 const old = new Set(JSON.parse(before.assignees) as string[]);
109 const next = new Set(patch.assignees);
110 for (const a of next) if (!old.has(a)) this.record(before.number, "assigned", caller, { assignee: a });
111 for (const a of old) if (!next.has(a)) this.record(before.number, "unassigned", caller, { assignee: a });
112 }
113 if (patch.title !== undefined && patch.title !== before.title) this.record(before.number, "renamed", caller, { rename: { from: before.title, to: patch.title } });
114 if (patch.state && patch.state !== before.state) this.record(before.number, patch.state === "closed" ? "closed" : "reopened", caller, patch.state === "closed" ? { state_reason: patch.stateReason ?? "completed" } : {});
115 }
116
117 events(number: number): IssueEvent[] {
118 this.issueRow(number);
119 return this.sql.exec<Record<string, string | number>>("SELECT * FROM issue_events WHERE number = ? ORDER BY id", number).toArray().map(toEvent);
120 }
121
122 repoEvents(limit: number, offset: number): { items: IssueEvent[]; total: number } {
123 const total = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM issue_events").one().n;
124 return { items: this.sql.exec<Record<string, string | number>>("SELECT * FROM issue_events ORDER BY id DESC LIMIT ? OFFSET ?", Math.min(limit, 100), offset).toArray().map(toEvent), total };
125 }
126
127 event(id: number): IssueEvent {
128 const r = this.sql.exec<Record<string, string | number>>("SELECT * FROM issue_events WHERE id = ?", id).toArray()[0];
129 if (!r) throw new HttpError(404, "event not found");
130 return toEvent(r);
131 }
132
133 /** Comments, reviews and events of an issue in time order (GitHub's timeline, basic). */
134 timeline(number: number, hide?: string[]): TimelineItem[] {
135 const { comments, reviews } = this.repo.collab.get(number, hide);
136 const items: TimelineItem[] = [
137 ...this.events(number).map((e) => ({ kind: "event" as const, ...e })),
138 ...comments.filter((c) => !c.reviewId).map((c) => ({ kind: "comment" as const, comment: c, createdAt: c.createdAt })),
139 ...reviews.map((r) => ({ kind: "review" as const, review: r, createdAt: r.submittedAt })),
140 ];
141 return items.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
142 }
143
144 lockReason(number: number): string | null {
145 const e = this.sql.exec<{ detail: string }>("SELECT detail FROM issue_events WHERE number = ? AND event = 'locked' ORDER BY id DESC LIMIT 1", number).toArray()[0];
146 return e ? ((JSON.parse(e.detail) as { lock_reason?: string }).lock_reason ?? null) : null;
147 }
148
149 // ---------------------------------------------------------------- labels
150
151 labelRows(): LabelRow[] {
152 return this.sql.exec<LabelRow>("SELECT rowid AS id, name, color, description FROM labels ORDER BY name").toArray();
153 }
154
155 label(name: string): LabelRow {
156 const l = this.sql.exec<LabelRow>("SELECT rowid AS id, name, color, description FROM labels WHERE name = ?", name).toArray()[0];
157 if (!l) throw new HttpError(404, "label not found");
158 return l;
159 }
160
161 createLabel(input: { name?: string; color?: string; description?: string }, caller: Caller): LabelRow {
162 this.triage(caller);
163 const name = (input.name ?? "").trim();
164 if (!name || name.length > 100) throw new HttpError(422, "label name must be 1 to 100 characters", { errors: [{ resource: "Label", code: "invalid", field: "name" }] });
165 if (this.sql.exec("SELECT 1 FROM labels WHERE name = ?", name).toArray().length) throw new HttpError(422, "a label with this name already exists", { errors: [{ resource: "Label", code: "already_exists", field: "name" }] });
166 const color = (input.color ?? "ededed").replace(/^#/, "");
167 if (!LABEL_COLOR.test(color)) throw new HttpError(422, "color must be a 6-digit hex code", { errors: [{ resource: "Label", code: "invalid", field: "color" }] });
168 this.sql.exec("INSERT INTO labels (name, color, description) VALUES (?, ?, ?)", name, color.toLowerCase(), (input.description ?? "").slice(0, 100));
169 return this.label(name);
170 }
171
172 updateLabel(name: string, input: { new_name?: string; color?: string; description?: string }, caller: Caller): LabelRow {
173 this.triage(caller);
174 const old = this.label(name);
175 const next = input.new_name?.trim() || old.name;
176 const color = (input.color ?? old.color).replace(/^#/, "");
177 if (!LABEL_COLOR.test(color)) throw new HttpError(422, "color must be a 6-digit hex code", { errors: [{ resource: "Label", code: "invalid", field: "color" }] });
178 if (next !== old.name && this.sql.exec("SELECT 1 FROM labels WHERE name = ?", next).toArray().length) throw new HttpError(422, "a label with this name already exists", { errors: [{ resource: "Label", code: "already_exists", field: "name" }] });
179 this.sql.exec("UPDATE labels SET name = ?, color = ?, description = ? WHERE name = ?", next, color.toLowerCase(), input.description ?? old.description, old.name);
180 if (next !== old.name) this.renameOnIssues(old.name, next);
181 return this.label(next);
182 }
183
184 deleteLabel(name: string, caller: Caller): void {
185 this.triage(caller);
186 this.label(name);
187 this.sql.exec("DELETE FROM labels WHERE name = ?", name);
188 this.renameOnIssues(name, null);
189 }
190
191 private renameOnIssues(from: string, to: string | null): void {
192 for (const r of this.sql.exec<{ number: number; labels: string }>("SELECT number, labels FROM issues WHERE EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)", from).toArray()) {
193 const labels = (JSON.parse(r.labels) as string[]).flatMap((l) => (l === from ? (to ? [to] : []) : [l]));
194 this.sql.exec("UPDATE issues SET labels = ? WHERE number = ?", JSON.stringify([...new Set(labels)]), r.number);
195 }
196 }
197
198 /** Labels on an issue, creating any that don't exist yet (like GitHub). */
199 private ensureLabels(names: string[]): string[] {
200 const out: string[] = [];
201 for (const raw of names) {
202 const name = String(raw).trim();
203 if (!name) continue;
204 if (name.length > 100) throw new HttpError(422, "label names are at most 100 characters");
205 if (!this.sql.exec("SELECT 1 FROM labels WHERE name = ?", name).toArray().length) this.sql.exec("INSERT INTO labels (name, color, description) VALUES (?, 'ededed', '')", name);
206 if (!out.includes(name)) out.push(name);
207 }
208 return out;
209 }
210
211 issueLabels(number: number): LabelRow[] {
212 const names = JSON.parse(this.issueRow(number).labels) as string[];
213 const rows = new Map(this.labelRows().map((l) => [l.name, l]));
214 return names.map((n) => rows.get(n) ?? { id: 0, name: n, color: "ededed", description: "" });
215 }
216
217 addLabels(number: number, names: string[], caller: Caller): LabelRow[] {
218 this.triage(caller);
219 const current = JSON.parse(this.issueRow(number).labels) as string[];
220 const next = [...new Set([...current, ...this.ensureLabels(names)])];
221 if (next.length > 100) throw new HttpError(422, "an issue can have at most 100 labels");
222 this.repo.collab.update(number, { labels: next }, caller);
223 return this.issueLabels(number);
224 }
225
226 setLabels(number: number, names: string[], caller: Caller): LabelRow[] {
227 this.triage(caller);
228 this.issueRow(number);
229 this.repo.collab.update(number, { labels: this.ensureLabels(names) }, caller);
230 return this.issueLabels(number);
231 }
232
233 removeLabel(number: number, name: string, caller: Caller): LabelRow[] {
234 this.triage(caller);
235 const current = JSON.parse(this.issueRow(number).labels) as string[];
236 if (!current.includes(name)) throw new HttpError(404, "Label does not exist");
237 this.repo.collab.update(number, { labels: current.filter((l) => l !== name) }, caller);
238 return this.issueLabels(number);
239 }
240
241 // ---------------------------------------------------------------- assignees and locking
242
243 assign(number: number, logins: string[], add: boolean, caller: Caller): void {
244 this.triage(caller);
245 const current = JSON.parse(this.issueRow(number).assignees) as string[];
246 const set = new Set(current);
247 for (const l of logins.map(String).filter(Boolean)) {
248 if (add) set.add(l);
249 else for (const x of [...set]) if (x.toLowerCase() === l.toLowerCase()) set.delete(x);
250 }
251 if (set.size > 10) throw new HttpError(422, "an issue can have at most 10 assignees");
252 this.repo.collab.update(number, { assignees: [...set] }, caller);
253 }
254
255 lock(number: number, reason: string | null, caller: Caller): void {
256 this.triage(caller);
257 if (reason && !["off-topic", "too heated", "resolved", "spam"].includes(reason)) throw new HttpError(422, "lock_reason must be off-topic, too heated, resolved or spam");
258 this.issueRow(number);
259 this.sql.exec("UPDATE issues SET locked = 1, updated_at = ? WHERE number = ?", now(), number);
260 this.record(number, "locked", caller, { lock_reason: reason });
261 }
262
263 unlock(number: number, caller: Caller): void {
264 this.triage(caller);
265 this.issueRow(number);
266 this.sql.exec("UPDATE issues SET locked = 0, updated_at = ? WHERE number = ?", now(), number);
267 this.record(number, "unlocked", caller);
268 }
269
270 // ---------------------------------------------------------------- comments
271
272 /** A comment's internal id from either form clients send: GitHub's numeric id or Diffinery's own. */
273 commentKey(raw: string): string {
274 if (!/^\d{1,15}$/.test(raw)) return raw;
275 return this.sql.exec<{ id: string }>("SELECT id FROM comments WHERE rowid = ?", Number(raw)).toArray()[0]?.id ?? raw;
276 }
277
278 /** Reaction subjects keep comments under their internal id, whichever id the client used. */
279 private subjectKey(subject: string): string {
280 const i = subject.indexOf(":");
281 const kind = subject.slice(0, i);
282 return kind === "comment" || kind === "review_comment" ? `${kind}:${this.commentKey(subject.slice(i + 1))}` : subject;
283 }
284
285 private commentRow(id: string, review: boolean): CommentRow {
286 const c = this.sql.exec<CommentRow>(`SELECT ${COMMENT_SELECT} FROM comments c WHERE c.id = ? AND c.review_id IS ${review ? "NOT " : ""}NULL`, this.commentKey(id)).toArray()[0];
287 if (!c) throw new HttpError(404, "comment not found");
288 return c;
289 }
290
291 /** One issue comment (or, with `review`, one review comment) and the issue it belongs to. */
292 comment(id: string, review = false, hide?: string[]): { number: number; isPull: boolean; comment: Comment } {
293 const c = this.commentRow(id, review);
294 if (hide?.includes(c.author_id)) throw new HttpError(404, "comment not found");
295 return { number: c.number, isPull: this.issueRow(c.number).kind === "pull", comment: toComment(c) };
296 }
297
298 deleteComment(id: string, review: boolean, caller: Caller): void {
299 const c = this.commentRow(id, review);
300 if (c.author_id !== caller.id && !atLeast(caller.role, "maintain")) throw new HttpError(403, "permission denied");
301 this.sql.exec("DELETE FROM comments WHERE id = ?", c.id);
302 this.sql.exec("DELETE FROM reactions WHERE subject = ?", `${review ? "review_comment" : "comment"}:${c.id}`);
303 this.repo.broadcast(`pr:${c.number}`, { action: "comment", id: c.id });
304 }
305
306 /** Comments across the repository (issue comments, or review comments with `review`). */
307 repoComments(opts: { review: boolean; since?: string; sort?: string; direction?: string; limit: number; offset: number; hide?: string[] }): { items: Array<{ number: number; isPull: boolean; comment: Comment }>; total: number } {
308 const where = [`c.review_id IS ${opts.review ? "NOT " : ""}NULL`];
309 const args: unknown[] = [];
310 if (opts.hide?.length) {
311 where.push("c.author_id NOT IN (SELECT value FROM json_each(?))");
312 args.push(JSON.stringify(opts.hide));
313 }
314 if (opts.since) {
315 where.push("c.updated_at >= ?");
316 args.push(opts.since);
317 }
318 const col = opts.sort === "updated" ? "c.updated_at" : "c.created_at";
319 const dir = opts.direction === "desc" ? "DESC" : "ASC";
320 const w = `WHERE ${where.join(" AND ")}`;
321 const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM comments c ${w}`, ...args).one().n;
322 const rows = this.sql.exec<CommentRow & { kind: string }>(`SELECT ${COMMENT_SELECT}, i.kind FROM comments c JOIN issues i ON i.number = c.number ${w} ORDER BY ${col} ${dir}, c.rowid ${dir} LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit, 100), opts.offset).toArray();
323 return { items: rows.map((r) => ({ number: r.number, isPull: r.kind === "pull", comment: toComment(r) })), total };
324 }
325
326 /** A single review comment, or a reply to one (`in_reply_to`), as a one-comment COMMENTED review. */
327 createReviewComment(number: number, input: { body?: string; commit_id?: string; path?: string; line?: number; position?: number; side?: "LEFT" | "RIGHT"; in_reply_to?: string }, caller: Caller): Comment {
328 refuseBlocked(caller);
329 if (!input.body?.trim()) throw new HttpError(422, "body is required", { errors: [{ resource: "PullRequestReviewComment", code: "missing_field", field: "body" }] });
330 checkText("PullRequestReviewComment", input);
331 let path = input.path;
332 let line = input.line ?? input.position;
333 let side = input.side;
334 let replyTo: string | undefined;
335 if (input.in_reply_to) {
336 const parent = this.commentRow(String(input.in_reply_to), true);
337 replyTo = parent.id;
338 if (parent.number !== number) throw new HttpError(404, "comment not found");
339 path = parent.path ?? undefined;
340 line = parent.line ?? undefined;
341 side = (parent.side as "LEFT" | "RIGHT" | null) ?? undefined;
342 }
343 if (!path || !line) throw new HttpError(422, "path and line are required", { errors: [{ resource: "PullRequestReviewComment", code: "missing_field", field: "line" }] });
344 const review = this.repo.collab.submitReview(number, { event: "COMMENT", comments: [{ path, line: Number(line), side, body: input.body, inReplyTo: replyTo }] }, caller);
345 const row = this.sql.exec<CommentRow>(`SELECT ${COMMENT_SELECT} FROM comments c WHERE c.review_id = ? ORDER BY c.rowid DESC LIMIT 1`, review.id).one();
346 return toComment(row);
347 }
348
349 // ---------------------------------------------------------------- reviews and reviewers
350
351 /** A review by GitHub's numeric id or Diffinery's own. */
352 review(number: number, id: string): Review {
353 const r = this.repo.collab.get(number).reviews.find((x) => x.id === id || String(x.seq) === id);
354 if (!r) throw new HttpError(404, "review not found");
355 return r;
356 }
357
358 /** Dismissing a review can unblock a merge, so it takes maintain access, and never the pull request's author or an agent. */
359 dismissReview(number: number, id: string, message: string, caller: Caller): Review {
360 if (!atLeast(caller.role, "maintain")) throw new HttpError(403, "maintain access is required to dismiss reviews");
361 if (caller.kind === "agent") throw new HttpError(403, "agents can't dismiss reviews");
362 if (this.issueRow(number).author_id === caller.id) throw new HttpError(403, "you can't dismiss reviews on your own pull request");
363 const r = this.review(number, id);
364 if (r.state !== "APPROVED" && r.state !== "CHANGES_REQUESTED") throw new HttpError(422, "only approving or change-requesting reviews can be dismissed");
365 this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE id = ?", r.id);
366 this.record(number, "review_dismissed", caller, { dismissed_review: { state: r.state.toLowerCase(), review_id: r.seq ?? r.id, dismissal_message: message } });
367 this.repo.broadcast(`pr:${number}`, { action: "review", state: "DISMISSED" });
368 // A dismissed change request may have been the last thing holding back auto-merge.
369 this.repo.autoMerge.schedule({ number });
370 return this.review(number, r.id);
371 }
372
373 removeReviewers(number: number, logins: string[], caller: Caller): void {
374 const r = this.issueRow(number);
375 if (!atLeast(caller.role, "triage") && r.author_id !== caller.id) throw new HttpError(403, "permission denied");
376 const p = this.sql.exec<{ requested_reviewers: string }>("SELECT requested_reviewers FROM pulls WHERE number = ?", number).toArray()[0];
377 if (!p) throw new HttpError(404, "pull request not found");
378 const drop = new Set(logins.map((l) => String(l).toLowerCase()));
379 const left = (JSON.parse(p.requested_reviewers) as string[]).filter((l) => !drop.has(l.toLowerCase()));
380 this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify(left), number);
381 for (const l of logins) this.record(number, "review_request_removed", caller, { requested_reviewer: l });
382 }
383
384 /** Merge the base branch into the pull request's head branch (GitHub's "Update branch"). */
385 async updateBranch(number: number, expectedHeadSha: string | undefined, caller: Caller): Promise<{ sha: string }> {
386 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to update the branch");
387 const p = this.sql.exec<{ head_ref: string; head_sha: string; base_ref: string; merged: number }>("SELECT head_ref, head_sha, base_ref, merged FROM pulls WHERE number = ?", number).toArray()[0];
388 if (!p) throw new HttpError(404, "pull request not found");
389 if (this.issueRow(number).state !== "open") throw new HttpError(422, "pull request is closed");
390 if (expectedHeadSha && expectedHeadSha !== p.head_sha) throw new HttpError(422, "expected head sha didn't match current head ref.");
391 const store = this.repo.store;
392 const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
393 if (!baseSha) throw new HttpError(422, `base branch ${p.base_ref} no longer exists`);
394 if (await isAncestor(store, baseSha, p.head_sha)) throw new HttpError(422, "There are no new commits on the base branch.");
395 const mb = await mergeBase(store, baseSha, p.head_sha);
396 const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), mb ? readCommit(store, mb) : null]);
397 const merged = await mergeTrees(store, mt?.tree ?? null, ht.tree, bt.tree);
398 if (merged.conflicts.length || !merged.tree) throw new HttpError(422, "merge conflict between base and head");
399 const ts = Math.floor(Date.now() / 1000);
400 const sig = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" };
401 const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
402 const sha = await store.write("commit", serializeCommit({ tree: merged.tree, parents: [p.head_sha, baseSha], author: sig, committer: sig, message: `Merge branch '${p.base_ref}' into ${p.head_ref}\n`, extraHeaders: extra }));
403 const ref = `refs/heads/${p.head_ref}`;
404 const check = await this.repo.checkRefUpdate(ref, p.head_sha, sha, caller);
405 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
406 if (!this.repo.casRef(ref, p.head_sha, sha)) throw new HttpError(409, "head branch moved; try again");
407 await this.repo.afterRefUpdate(ref, p.head_sha, sha, caller);
408 return { sha };
409 }
410
411 /** Pull requests associated with a commit: merged by it or containing it as their head (basic). */
412 async pullsForCommit(sha: string): Promise<number[]> {
413 const direct = this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE merge_commit = ? OR head_sha = ? ORDER BY number DESC", sha, sha).toArray().map((r) => r.number);
414 const open = this.sql.exec<{ number: number; head_sha: string }>("SELECT p.number, p.head_sha FROM pulls p JOIN issues i ON i.number = p.number WHERE i.state = 'open' ORDER BY p.number DESC LIMIT 50").toArray();
415 const out = new Set(direct);
416 for (const p of open) if (!out.has(p.number) && this.repo.store.has(sha) && (await isAncestor(this.repo.store, sha, p.head_sha).catch(() => false))) out.add(p.number);
417 return [...out];
418 }
419
420 // ---------------------------------------------------------------- reactions
421
422 private subjectExists(subject: string): boolean {
423 const [kind, id] = [subject.slice(0, subject.indexOf(":")), subject.slice(subject.indexOf(":") + 1)];
424 if (kind === "issue") return this.sql.exec("SELECT 1 FROM issues WHERE number = ?", Number(id)).toArray().length > 0;
425 if (kind === "comment") return this.sql.exec("SELECT 1 FROM comments WHERE id = ? AND review_id IS NULL", id).toArray().length > 0;
426 if (kind === "review_comment") return this.sql.exec("SELECT 1 FROM comments WHERE id = ? AND review_id IS NOT NULL", id).toArray().length > 0;
427 if (kind === "release") return this.sql.exec("SELECT 1 FROM releases WHERE id = ?", Number(id)).toArray().length > 0;
428 return false;
429 }
430
431 reactions(subject: string, content?: string): Reaction[] {
432 subject = this.subjectKey(subject);
433 if (!this.subjectExists(subject)) throw new HttpError(404, "not found");
434 const rows = this.sql
435 .exec<Record<string, string | number>>(`SELECT * FROM reactions WHERE subject = ? ${content ? "AND content = ?" : ""} ORDER BY id`, ...(content ? [subject, content] : [subject]))
436 .toArray();
437 return rows.map((r) => ({ id: r.id as number, content: r.content as string, user: { id: r.user_id as string, login: r.user_login as string, kind: r.user_kind as string }, createdAt: r.created_at as string }));
438 }
439
440 /** The issue or pull request a reaction subject belongs to (null for releases). */
441 private subjectIssue(subject: string): number | null {
442 const [kind, id] = [subject.slice(0, subject.indexOf(":")), subject.slice(subject.indexOf(":") + 1)];
443 if (kind === "issue") return Number(id);
444 if (kind === "comment" || kind === "review_comment") return this.sql.exec<{ number: number }>("SELECT number FROM comments WHERE id = ?", id).toArray()[0]?.number ?? null;
445 return null;
446 }
447
448 react(subject: string, content: string, caller: Caller): { reaction: Reaction; created: boolean } {
449 subject = this.subjectKey(subject);
450 if (caller.id === "anonymous") throw new HttpError(401, "sign in to react");
451 refuseBlocked(caller);
452 if (!REACTION_CONTENT.includes(content)) throw new HttpError(422, `content must be one of ${REACTION_CONTENT.join(", ")}`, { errors: [{ resource: "Reaction", code: "invalid", field: "content" }] });
453 if (!this.subjectExists(subject)) throw new HttpError(404, "not found");
454 const number = this.subjectIssue(subject);
455 if (number !== null && this.issueRow(number).locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked");
456 const existing = this.sql.exec<{ id: number }>("SELECT id FROM reactions WHERE subject = ? AND content = ? AND user_id = ?", subject, content, caller.id).toArray()[0];
457 if (!existing) this.sql.exec("INSERT INTO reactions (subject, content, user_id, user_login, user_kind, created_at) VALUES (?, ?, ?, ?, ?, ?)", subject, content, caller.id, caller.login, caller.kind, now());
458 const id = existing?.id ?? this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id;
459 return { reaction: this.reactions(subject).find((r) => r.id === id)!, created: !existing };
460 }
461
462 unreact(subject: string, id: number, caller: Caller): void {
463 subject = this.subjectKey(subject);
464 const r = this.sql.exec<{ user_id: string }>("SELECT user_id FROM reactions WHERE subject = ? AND id = ?", subject, id).toArray()[0];
465 if (!r) throw new HttpError(404, "reaction not found");
466 if (r.user_id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "you can only delete your own reactions");
467 this.sql.exec("DELETE FROM reactions WHERE id = ?", id);
468 }
469
470 /** Reaction counts per subject, for embedding in issue, comment and release JSON. */
471 reactionCounts(subjects: string[]): Record<string, Record<string, number>> {
472 const out: Record<string, Record<string, number>> = {};
473 for (let i = 0; i < subjects.length; i += 50) {
474 const chunk = subjects.slice(i, i + 50);
475 const rows = this.sql.exec<{ subject: string; content: string; n: number }>(`SELECT subject, content, count(*) AS n FROM reactions WHERE subject IN (${chunk.map(() => "?").join(",")}) GROUP BY subject, content`, ...chunk).toArray();
476 for (const r of rows) (out[r.subject] ??= {})[r.content] = r.n;
477 }
478 return out;
479 }
480}
481
482function toEvent(r: Record<string, string | number>): IssueEvent {
483 return { id: r.id as number, number: r.number as number, event: r.event as string, actor: { id: r.actor_id as string, login: r.actor_login as string, kind: r.actor_kind as string }, detail: r.detail as string, createdAt: r.created_at as string };
484}
485