| 1 | import { isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit } from "@diffinery/git-core"; |
| 2 | import { HttpError, checkText, now } from "../lib/util.js"; |
| 3 | import type { Comment, IssueRow, Review } from "./collab.js"; |
| 4 | import type { RepoDO } from "./RepoDO.js"; |
| 5 | import { atLeast, refuseBlocked, type Caller } from "./types.js"; |
| 6 | |
| 7 | export const REACTION_CONTENT = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"]; |
| 8 | |
| 9 | export type Person = { id: string; login: string; kind: string }; |
| 10 | export 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, ...). */ |
| 12 | export type IssueEvent = { id: number; number: number; event: string; actor: Person; detail: string; createdAt: string }; |
| 13 | export type LabelRow = { id: number; name: string; color: string; description: string }; |
| 14 | export type TimelineItem = |
| 15 | | ({ kind: "event" } & IssueEvent) |
| 16 | | { kind: "comment"; comment: Comment; createdAt: string } |
| 17 | | { kind: "review"; review: Review; createdAt: string }; |
| 18 | |
| 19 | type 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 | */ |
| 47 | export 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 | |
| 49 | const 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 | |
| 68 | const 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 | */ |
| 75 | export 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: this.repo.noreply(caller.login), 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 | |
| 482 | function 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 | |