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.

343 lines · 17.9 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import type { AppContext, AppVars } from "../auth/principal.js";
4import type { Repo } from "../lib/directory.js";
5import { HttpError } from "../lib/util.js";
6import type { IssueEvent, Reaction } from "../repo/social.js";
7import { baseOf, ghComment, ghIssue, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, setLinks, type IssueCtx } from "./gh.js";
8import { intParam, issueCtx, jsonBody, loadTarget, pullsOut, repoParams, type Stub } from "./ghctx.js";
9import { 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 */
17export const issues = new Hono<{ Bindings: Env; Variables: AppVars }>();
18
19const R = "/repos/:owner/:repo";
20const load = (c: AppContext, min: "read" | "triage" | "write" = "read") => loadTarget(c, min);
21const labelJson = (c: AppContext, repo: Repo, l: { id?: number; name: string; color?: string; description?: string }) => ghLabel(baseOf(c), repo.owner_login, repo.name, l);
22const 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
30issues.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});
34issues.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});
39issues.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
45issues.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});
49issues.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});
54issues.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});
59issues.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});
64issues.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
72const 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
77issues.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});
84issues.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});
91issues.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});
97issues.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
106issues.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});
112issues.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
120issues.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});
128issues.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});
133issues.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
141issues.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});
149issues.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});
154issues.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});
163issues.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});
168issues.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});
175issues.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
185issues.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});
190issues.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});
197issues.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});
204issues.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});
211issues.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});
218issues.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
230function 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
234type 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. */
237function 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}
259reactionRoutes("issues/comments/:id", (c) => `comment:${c.req.param("id")}`);
260reactionRoutes("pulls/comments/:id", (c) => `review_comment:${c.req.param("id")}`);
261reactionRoutes("issues/:number", (c) => `issue:${intParam(c, "number")}`);
262// Draft releases are hidden from readers, and so are their reactions.
263reactionRoutes("releases/:id", (c) => `release:${intParam(c, "id")}`, (c, r) => r.stub.getRelease(intParam(c, "id"), r.caller));
264
265// ---------------------------------------------------------------- events and timeline
266
267function 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
293issues.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});
310issues.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});
317issues.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});
323issues.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});