| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 4 | import type { Repo } from "../lib/directory.js"; |
| 5 | import { HttpError } from "../lib/util.js"; |
| 6 | import type { IssueEvent, Reaction } from "../repo/social.js"; |
| 7 | import { baseOf, ghComment, ghIssue, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, setLinks, type IssueCtx } from "./gh.js"; |
| 8 | import { intParam, issueCtx, jsonBody, loadTarget, pullsOut, repoParams, type Stub } from "./ghctx.js"; |
| 9 | import { hiddenAuthors } from "./common.js"; |
| 10 | |
| 11 | /** |
| 12 | * Issue and pull request sub-resources bots and workflows use: labels and |
| 13 | * assignees on one issue, single and repository-wide comments, locking, |
| 14 | * reactions, events and timelines, review comment replies, dismissing reviews, |
| 15 | * removing requested reviewers and updating a pull request's branch. |
| 16 | */ |
| 17 | export const issues = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 18 | |
| 19 | const R = "/repos/:owner/:repo"; |
| 20 | const load = (c: AppContext, min: "read" | "triage" | "write" = "read") => loadTarget(c, min); |
| 21 | const labelJson = (c: AppContext, repo: Repo, l: { id?: number; name: string; color?: string; description?: string }) => ghLabel(baseOf(c), repo.owner_login, repo.name, l); |
| 22 | const names = (v: unknown): string[] => { |
| 23 | const list = Array.isArray(v) ? v : v && typeof v === "object" && Array.isArray((v as { labels?: unknown }).labels) ? (v as { labels: unknown[] }).labels : null; |
| 24 | if (!list) throw new HttpError(422, "labels must be an array of names", { errors: [{ resource: "Label", code: "invalid", field: "labels" }] }); |
| 25 | return list.map((l) => (typeof l === "string" ? l : String((l as { name?: unknown })?.name ?? ""))).filter(Boolean); |
| 26 | }; |
| 27 | |
| 28 | // ---------------------------------------------------------------- labels |
| 29 | |
| 30 | issues.get(`${R}/labels/:name`, async (c) => { |
| 31 | const { stub, repo } = await load(c); |
| 32 | return c.json(labelJson(c, repo, await stub.label(c.req.param("name")))); |
| 33 | }); |
| 34 | issues.patch(`${R}/labels/:name`, async (c) => { |
| 35 | const { stub, caller, repo } = await load(c, "triage"); |
| 36 | const b = await jsonBody<{ new_name?: string; color?: string; description?: string }>(c); |
| 37 | return c.json(labelJson(c, repo, await stub.updateLabel(c.req.param("name"), b, caller))); |
| 38 | }); |
| 39 | issues.delete(`${R}/labels/:name`, async (c) => { |
| 40 | const { stub, caller } = await load(c, "triage"); |
| 41 | await stub.deleteLabel(c.req.param("name"), caller); |
| 42 | return c.body(null, 204); |
| 43 | }); |
| 44 | |
| 45 | issues.get(`${R}/issues/:number/labels`, async (c) => { |
| 46 | const { stub, repo } = await load(c); |
| 47 | return c.json(paginate(c, (await stub.issueLabels(intParam(c, "number"))).map((l) => labelJson(c, repo, l)))); |
| 48 | }); |
| 49 | issues.post(`${R}/issues/:number/labels`, async (c) => { |
| 50 | const { stub, caller, repo } = await load(c, "triage"); |
| 51 | const list = await stub.addIssueLabels(intParam(c, "number"), names(await jsonBody(c)), caller); |
| 52 | return c.json(list.map((l) => labelJson(c, repo, l))); |
| 53 | }); |
| 54 | issues.put(`${R}/issues/:number/labels`, async (c) => { |
| 55 | const { stub, caller, repo } = await load(c, "triage"); |
| 56 | const list = await stub.setIssueLabels(intParam(c, "number"), names(await jsonBody(c, true)), caller); |
| 57 | return c.json(list.map((l) => labelJson(c, repo, l))); |
| 58 | }); |
| 59 | issues.delete(`${R}/issues/:number/labels`, async (c) => { |
| 60 | const { stub, caller } = await load(c, "triage"); |
| 61 | await stub.setIssueLabels(intParam(c, "number"), [], caller); |
| 62 | return c.body(null, 204); |
| 63 | }); |
| 64 | issues.delete(`${R}/issues/:number/labels/:name`, async (c) => { |
| 65 | const { stub, caller, repo } = await load(c, "triage"); |
| 66 | const list = await stub.removeIssueLabel(intParam(c, "number"), c.req.param("name"), caller); |
| 67 | return c.json(list.map((l) => labelJson(c, repo, l))); |
| 68 | }); |
| 69 | |
| 70 | // ---------------------------------------------------------------- assignees and locking |
| 71 | |
| 72 | const oneIssue = async (c: AppContext, repo: Repo, stub: Stub, n: number) => { |
| 73 | const { issue } = await stub.issue(n); |
| 74 | return ghIssue(await issueCtx(c, repo, stub, [issue]), issue); |
| 75 | }; |
| 76 | |
| 77 | issues.post(`${R}/issues/:number/assignees`, async (c) => { |
| 78 | const { stub, caller, repo } = await load(c, "triage"); |
| 79 | const n = intParam(c, "number"); |
| 80 | const b = await jsonBody<{ assignees?: unknown }>(c); |
| 81 | await stub.assignIssue(n, Array.isArray(b.assignees) ? b.assignees.map(String) : [], true, caller); |
| 82 | return c.json(await oneIssue(c, repo, stub, n), 201); |
| 83 | }); |
| 84 | issues.delete(`${R}/issues/:number/assignees`, async (c) => { |
| 85 | const { stub, caller, repo } = await load(c, "triage"); |
| 86 | const n = intParam(c, "number"); |
| 87 | const b = await jsonBody<{ assignees?: unknown }>(c, true); |
| 88 | await stub.assignIssue(n, Array.isArray(b.assignees) ? b.assignees.map(String) : [], false, caller); |
| 89 | return c.json(await oneIssue(c, repo, stub, n)); |
| 90 | }); |
| 91 | issues.get(`${R}/assignees`, async (c) => { |
| 92 | const { repo } = await load(c); |
| 93 | const people = (await c.get("dir").collaborators(repo.id)).filter((x) => x.role !== "read").map((x) => ghUser(baseOf(c), x.account)); |
| 94 | const owner = ghUser(baseOf(c), { id: repo.owner_id, login: repo.owner_login, kind: repo.owner_kind }); |
| 95 | return c.json(paginate(c, repo.owner_kind === "org" || people.some((p) => p?.login === owner?.login) ? people : [owner, ...people])); |
| 96 | }); |
| 97 | issues.get(`${R}/assignees/:login`, async (c) => { |
| 98 | const { repo } = await load(c); |
| 99 | const login = c.req.param("login").toLowerCase(); |
| 100 | if (login === repo.owner_login.toLowerCase()) return c.body(null, 204); |
| 101 | const ok = (await c.get("dir").collaborators(repo.id)).some((x) => x.account.login.toLowerCase() === login && x.role !== "read"); |
| 102 | if (!ok) throw new HttpError(404, "Not Found"); |
| 103 | return c.body(null, 204); |
| 104 | }); |
| 105 | |
| 106 | issues.put(`${R}/issues/:number/lock`, async (c) => { |
| 107 | const { stub, caller } = await load(c, "triage"); |
| 108 | const b = await jsonBody<{ lock_reason?: string }>(c, true); |
| 109 | await stub.lockIssue(intParam(c, "number"), b.lock_reason ?? null, caller); |
| 110 | return c.body(null, 204); |
| 111 | }); |
| 112 | issues.delete(`${R}/issues/:number/lock`, async (c) => { |
| 113 | const { stub, caller } = await load(c, "triage"); |
| 114 | await stub.unlockIssue(intParam(c, "number"), caller); |
| 115 | return c.body(null, 204); |
| 116 | }); |
| 117 | |
| 118 | // ---------------------------------------------------------------- comments (repository-wide and single) |
| 119 | |
| 120 | issues.get(`${R}/issues/comments`, async (c) => { |
| 121 | const { stub, repo } = await load(c); |
| 122 | const p = pageOf(c); |
| 123 | const res = await stub.repoComments({ review: false, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset, hide: await hiddenAuthors(c) }); |
| 124 | setLinks(c, p, res.total); |
| 125 | const x = await issueCtx(c, repo, stub, [], res.items.map((i) => `comment:${i.comment.id}`)); |
| 126 | return c.json(res.items.map((i) => ghComment(x, i.number, i.isPull, i.comment))); |
| 127 | }); |
| 128 | issues.get(`${R}/issues/comments/:id`, async (c) => { |
| 129 | const { stub, repo } = await load(c); |
| 130 | const found = await stub.comment(c.req.param("id"), false, await hiddenAuthors(c)); |
| 131 | return c.json(ghComment(await issueCtx(c, repo, stub, [], [`comment:${found.comment.id}`]), found.number, found.isPull, found.comment)); |
| 132 | }); |
| 133 | issues.delete(`${R}/issues/comments/:id`, async (c) => { |
| 134 | const { stub, caller } = await load(c); |
| 135 | await stub.deleteComment(c.req.param("id"), false, caller); |
| 136 | return c.body(null, 204); |
| 137 | }); |
| 138 | |
| 139 | // ---------------------------------------------------------------- pull request review comments |
| 140 | |
| 141 | issues.get(`${R}/pulls/comments`, async (c) => { |
| 142 | const { stub, repo } = await load(c); |
| 143 | const p = pageOf(c); |
| 144 | const res = await stub.repoComments({ review: true, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset, hide: await hiddenAuthors(c) }); |
| 145 | setLinks(c, p, res.total); |
| 146 | const x = await issueCtx(c, repo, stub, [], res.items.map((i) => `review_comment:${i.comment.id}`)); |
| 147 | return c.json(res.items.map((i) => ghReviewComment(x, i.number, i.comment))); |
| 148 | }); |
| 149 | issues.get(`${R}/pulls/comments/:id`, async (c) => { |
| 150 | const { stub, repo } = await load(c); |
| 151 | const found = await stub.comment(c.req.param("id"), true, await hiddenAuthors(c)); |
| 152 | return c.json(ghReviewComment(await issueCtx(c, repo, stub, [], [`review_comment:${found.comment.id}`]), found.number, found.comment)); |
| 153 | }); |
| 154 | issues.patch(`${R}/pulls/comments/:id`, async (c) => { |
| 155 | const { stub, caller, repo } = await load(c); |
| 156 | const b = await jsonBody<{ body?: string }>(c); |
| 157 | if (typeof b.body !== "string" || !b.body.trim()) throw new HttpError(422, "body is required", { errors: [{ resource: "PullRequestReviewComment", code: "missing_field", field: "body" }] }); |
| 158 | await stub.comment(c.req.param("id"), true); |
| 159 | await stub.editComment(c.req.param("id"), b.body, caller); |
| 160 | const found = await stub.comment(c.req.param("id"), true); |
| 161 | return c.json(ghReviewComment(await issueCtx(c, repo, stub, []), found.number, found.comment)); |
| 162 | }); |
| 163 | issues.delete(`${R}/pulls/comments/:id`, async (c) => { |
| 164 | const { stub, caller } = await load(c); |
| 165 | await stub.deleteComment(c.req.param("id"), true, caller); |
| 166 | return c.body(null, 204); |
| 167 | }); |
| 168 | issues.post(`${R}/pulls/:number/comments`, async (c) => { |
| 169 | const { stub, caller, repo } = await load(c); |
| 170 | const n = intParam(c, "number"); |
| 171 | const b = await jsonBody<{ body?: string; commit_id?: string; path?: string; line?: number; position?: number; side?: "LEFT" | "RIGHT"; in_reply_to?: string | number }>(c); |
| 172 | const cm = await stub.createReviewComment(n, { ...b, in_reply_to: b.in_reply_to === undefined ? undefined : String(b.in_reply_to) }, caller); |
| 173 | return c.json(ghReviewComment(await issueCtx(c, repo, stub, []), n, cm), 201); |
| 174 | }); |
| 175 | issues.post(`${R}/pulls/:number/comments/:id/replies`, async (c) => { |
| 176 | const { stub, caller, repo } = await load(c); |
| 177 | const n = intParam(c, "number"); |
| 178 | const b = await jsonBody<{ body?: string }>(c); |
| 179 | const cm = await stub.createReviewComment(n, { body: b.body, in_reply_to: c.req.param("id") }, caller); |
| 180 | return c.json(ghReviewComment(await issueCtx(c, repo, stub, []), n, cm), 201); |
| 181 | }); |
| 182 | |
| 183 | // ---------------------------------------------------------------- reviews and reviewers |
| 184 | |
| 185 | issues.get(`${R}/pulls/:number/reviews/:id`, async (c) => { |
| 186 | const { stub, repo } = await load(c); |
| 187 | const n = intParam(c, "number"); |
| 188 | return c.json(ghReview(await issueCtx(c, repo, stub, []), n, await stub.review(n, c.req.param("id")))); |
| 189 | }); |
| 190 | issues.put(`${R}/pulls/:number/reviews/:id/dismissals`, async (c) => { |
| 191 | const { stub, caller, repo } = await load(c, "write"); |
| 192 | const n = intParam(c, "number"); |
| 193 | const b = await jsonBody<{ message?: string; event?: string }>(c); |
| 194 | if (typeof b.message !== "string" || !b.message.trim()) throw new HttpError(422, "message is required", { errors: [{ resource: "PullRequestReview", code: "missing_field", field: "message" }] }); |
| 195 | return c.json(ghReview(await issueCtx(c, repo, stub, []), n, await stub.dismissReview(n, c.req.param("id"), b.message, caller))); |
| 196 | }); |
| 197 | issues.get(`${R}/pulls/:number/requested_reviewers`, async (c) => { |
| 198 | const { stub, repo } = await load(c); |
| 199 | const { issue } = await stub.issue(intParam(c, "number")); |
| 200 | if (!issue.pull) throw new HttpError(404, "Not Found"); |
| 201 | const [pull] = await pullsOut(c, repo, stub, [issue]); |
| 202 | return c.json({ users: pull!.requested_reviewers, teams: [] }); |
| 203 | }); |
| 204 | issues.delete(`${R}/pulls/:number/requested_reviewers`, async (c) => { |
| 205 | const { stub, caller, repo } = await load(c); |
| 206 | const n = intParam(c, "number"); |
| 207 | const b = await jsonBody<{ reviewers?: unknown }>(c, true); |
| 208 | await stub.removeReviewers(n, Array.isArray(b.reviewers) ? b.reviewers.map(String) : [], caller); |
| 209 | return c.json((await pullsOut(c, repo, stub, [(await stub.issue(n)).issue]))[0]); |
| 210 | }); |
| 211 | issues.put(`${R}/pulls/:number/update-branch`, async (c) => { |
| 212 | const { stub, caller } = await load(c, "write"); |
| 213 | const n = intParam(c, "number"); |
| 214 | const b = await jsonBody<{ expected_head_sha?: string }>(c, true); |
| 215 | await stub.updatePullBranch(n, b.expected_head_sha, caller); |
| 216 | return c.json({ message: "Updating pull request branch.", url: `${repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/pulls/${n}` }, 202); |
| 217 | }); |
| 218 | issues.get(`${R}/commits/:sha/pulls`, async (c) => { |
| 219 | const { stub, repo } = await load(c); |
| 220 | const sha = await stub.resolve(c.req.param("sha")); |
| 221 | if (!sha) throw new HttpError(422, `No commit found for SHA: ${c.req.param("sha")}`); |
| 222 | const numbers = paginate(c, await stub.pullsForCommit(sha)); |
| 223 | const list = []; |
| 224 | for (const n of numbers) list.push((await stub.issue(n)).issue); |
| 225 | return c.json(await pullsOut(c, repo, stub, list)); |
| 226 | }); |
| 227 | |
| 228 | // ---------------------------------------------------------------- reactions |
| 229 | |
| 230 | function reactionJson(c: AppContext, r: Reaction) { |
| 231 | return { id: r.id, node_id: nodeId("Reaction", r.id), user: ghUser(baseOf(c), r.user), content: r.content, created_at: isoSeconds(r.createdAt) }; |
| 232 | } |
| 233 | |
| 234 | type Loaded = Awaited<ReturnType<typeof load>>; |
| 235 | |
| 236 | /** Reaction routes for one kind of subject; `subject` maps the path to `issue:N`, `comment:ID`, ... and `visible` checks the caller may see it. */ |
| 237 | function reactionRoutes(path: string, subject: (c: AppContext) => string, visible?: (c: AppContext, r: Loaded) => Promise<unknown>) { |
| 238 | const open = async (c: AppContext) => { |
| 239 | const r = await load(c); |
| 240 | if (visible) await visible(c, r); |
| 241 | return r; |
| 242 | }; |
| 243 | issues.get(`${R}/${path}/reactions`, async (c) => { |
| 244 | const { stub } = await open(c); |
| 245 | return c.json(paginate(c, (await stub.reactions(subject(c), c.req.query("content") || undefined)).map((r) => reactionJson(c, r)))); |
| 246 | }); |
| 247 | issues.post(`${R}/${path}/reactions`, async (c) => { |
| 248 | const { stub, caller } = await open(c); |
| 249 | const b = await jsonBody<{ content?: string }>(c); |
| 250 | const r = await stub.react(subject(c), String(b.content ?? ""), caller); |
| 251 | return c.json(reactionJson(c, r.reaction), r.created ? 201 : 200); |
| 252 | }); |
| 253 | issues.delete(`${R}/${path}/reactions/:reaction`, async (c) => { |
| 254 | const { stub, caller } = await open(c); |
| 255 | await stub.unreact(subject(c), intParam(c, "reaction"), caller); |
| 256 | return c.body(null, 204); |
| 257 | }); |
| 258 | } |
| 259 | reactionRoutes("issues/comments/:id", (c) => `comment:${c.req.param("id")}`); |
| 260 | reactionRoutes("pulls/comments/:id", (c) => `review_comment:${c.req.param("id")}`); |
| 261 | reactionRoutes("issues/:number", (c) => `issue:${intParam(c, "number")}`); |
| 262 | // Draft releases are hidden from readers, and so are their reactions. |
| 263 | reactionRoutes("releases/:id", (c) => `release:${intParam(c, "id")}`, (c, r) => r.stub.getRelease(intParam(c, "id"), r.caller)); |
| 264 | |
| 265 | // ---------------------------------------------------------------- events and timeline |
| 266 | |
| 267 | function eventJson(x: IssueCtx, e: IssueEvent, withIssue?: ReturnType<typeof ghIssue>): Record<string, unknown> { |
| 268 | const api = repoApi(x.base, x.owner, x.repo); |
| 269 | const d = JSON.parse(e.detail) as Record<string, unknown>; |
| 270 | const commit = typeof d.commit_id === "string" ? d.commit_id : null; |
| 271 | const person = (login: unknown) => (typeof login === "string" ? ghUser(x.base, x.people?.get(login.toLowerCase()) ?? { id: login, login }) : undefined); |
| 272 | return { |
| 273 | id: e.id, |
| 274 | node_id: nodeId("IssueEvent", `${x.repoId}/${e.id}`), |
| 275 | url: `${api}/issues/events/${e.id}`, |
| 276 | actor: ghUser(x.base, e.actor), |
| 277 | event: e.event, |
| 278 | commit_id: commit, |
| 279 | commit_url: commit ? `${api}/commits/${commit}` : null, |
| 280 | created_at: isoSeconds(e.createdAt), |
| 281 | performed_via_github_app: null, |
| 282 | ...(d.label ? { label: d.label } : {}), |
| 283 | ...(d.assignee ? { assignee: person(d.assignee), assigner: ghUser(x.base, e.actor) } : {}), |
| 284 | ...(d.rename ? { rename: d.rename } : {}), |
| 285 | ...("lock_reason" in d ? { lock_reason: d.lock_reason ?? null } : {}), |
| 286 | ...(d.requested_reviewer ? { requested_reviewer: person(d.requested_reviewer), review_requester: ghUser(x.base, e.actor) } : {}), |
| 287 | ...(d.dismissed_review ? { dismissed_review: d.dismissed_review } : {}), |
| 288 | ...(d.state_reason ? { state_reason: d.state_reason } : {}), |
| 289 | ...(withIssue ? { issue: withIssue } : {}), |
| 290 | }; |
| 291 | } |
| 292 | |
| 293 | issues.get(`${R}/issues/events`, async (c) => { |
| 294 | const { stub, repo } = await load(c); |
| 295 | const p = pageOf(c); |
| 296 | const res = await stub.repoIssueEvents(p.perPage, p.offset); |
| 297 | setLinks(c, p, res.total); |
| 298 | const numbers = [...new Set(res.items.map((e) => e.number))]; |
| 299 | const hide = await hiddenAuthors(c); |
| 300 | const list = []; |
| 301 | // A suspended author's issues stay hidden here too: their events are left out. |
| 302 | for (const n of numbers) { |
| 303 | const found = await stub.issue(n, hide).catch((e) => (e instanceof Error && /not found/.test(e.message) ? null : Promise.reject(e))); |
| 304 | if (found) list.push(found.issue); |
| 305 | } |
| 306 | const x = await issueCtx(c, repo, stub, list); |
| 307 | const byNumber = new Map(list.map((i) => [i.number, ghIssue(x, i)])); |
| 308 | return c.json(res.items.filter((e) => byNumber.has(e.number)).map((e) => eventJson(x, e, byNumber.get(e.number)))); |
| 309 | }); |
| 310 | issues.get(`${R}/issues/events/:id`, async (c) => { |
| 311 | const { stub, repo } = await load(c); |
| 312 | const e = await stub.issueEvent(intParam(c, "id")); |
| 313 | const { issue } = await stub.issue(e.number, await hiddenAuthors(c)); |
| 314 | const x = await issueCtx(c, repo, stub, [issue]); |
| 315 | return c.json(eventJson(x, e, ghIssue(x, issue))); |
| 316 | }); |
| 317 | issues.get(`${R}/issues/:number/events`, async (c) => { |
| 318 | const { stub, repo } = await load(c); |
| 319 | await stub.issue(intParam(c, "number"), await hiddenAuthors(c)); |
| 320 | const x = await issueCtx(c, repo, stub, []); |
| 321 | return c.json(paginate(c, (await stub.issueEvents(intParam(c, "number"))).map((e) => eventJson(x, e)))); |
| 322 | }); |
| 323 | issues.get(`${R}/issues/:number/timeline`, async (c) => { |
| 324 | const { stub, repo } = await load(c); |
| 325 | const n = intParam(c, "number"); |
| 326 | const hide = await hiddenAuthors(c); |
| 327 | const { issue } = await stub.issue(n, hide); |
| 328 | const items = await stub.issueTimeline(n, hide); |
| 329 | const x = await issueCtx(c, repo, stub, [], items.flatMap((i) => (i.kind === "comment" ? [`comment:${i.comment.id}`] : []))); |
| 330 | const isPull = issue.kind === "pull"; |
| 331 | return c.json( |
| 332 | paginate( |
| 333 | c, |
| 334 | items.map((i) => |
| 335 | i.kind === "event" |
| 336 | ? eventJson(x, i) |
| 337 | : i.kind === "comment" |
| 338 | ? { event: "commented", actor: ghUser(x.base, i.comment.author), ...ghComment(x, n, isPull, i.comment) } |
| 339 | : { event: "reviewed", actor: ghUser(x.base, i.review.author), ...ghReview(x, n, i.review), state: i.review.state.toLowerCase() }, |
| 340 | ), |
| 341 | ), |
| 342 | ); |
| 343 | }); |