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.

1441 lines · 59.0 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child, FC } from "hono/jsx";
4import type { AppContext } from "../auth/principal.js";
5import { loadRepo } from "../http/common.js";
6import { HttpError } from "../lib/util.js";
7import type { Check, Comment, Issue, Mergeability, Review } from "../repo/collab.js";
8import { atLeast, type Role } from "../repo/types.js";
9import { ICONS } from "./assets.js";
10import { KCheckbox, KRadio, KSelect } from "./kumo.js";
11import { csrfToken } from "./csrf.js";
12import { MailStore, type WatchLevel } from "../mail/prefs.js";
13import { mentions } from "../mail/notify.js";
14import { CheckName } from "./checks.js";
15import {
16 Actor,
17 Avatar,
18 CommitRow,
19 DiffStats,
20 DiffView,
21 FileSummary,
22 Form,
23 Label,
24 Markdown,
25 Pager,
26 Status,
27 Time,
28 act,
29 duration,
30 encPath,
31 flash,
32 onBehalfOf,
33 pageNum,
34 post,
35 qs,
36 render,
37 repoUrl,
38 shortSha,
39 statusOf,
40 type DiffFile,
41 type LineThread,
42 type WebApp,
43 prm,
44 principalAccount,
45} from "./ui.js";
46
47type Loaded = Awaited<ReturnType<typeof loadRepo>>;
48type LabelDef = { name: string; color: string; description: string };
49
50/** Load a repo for a state-changing action by a signed-in account. */
51async function forAction(c: AppContext, min: Role = "read"): Promise<Loaded> {
52 if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
53 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), min);
54 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
55 return l;
56}
57
58function issueNumber(c: AppContext): number {
59 const n = Number(prm(c, "n"));
60 if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found");
61 return n;
62}
63
64const kindPath = (i: Pick<Issue, "kind">) => (i.kind === "pull" ? "pull" : "issues");
65
66const StateBadge: FC<{ i: Issue }> = ({ i }) => {
67 if (i.kind === "pull") {
68 if (i.pull?.merged)
69 return (
70 <span class="state merged">
71 {raw(ICONS.prMerged)} Merged
72 </span>
73 );
74 if (i.state === "closed")
75 return (
76 <span class="state closed">
77 {raw(ICONS.prClosed)} Closed
78 </span>
79 );
80 if (i.pull?.draft)
81 return (
82 <span class="state draft">
83 {raw(ICONS.pr)} Draft
84 </span>
85 );
86 return (
87 <span class="state open">
88 {raw(ICONS.pr)} Open
89 </span>
90 );
91 }
92 return i.state === "open" ? (
93 <span class="state open">
94 {raw(ICONS.issueOpen)} Open
95 </span>
96 ) : (
97 <span class="state merged" style={i.stateReason === "not_planned" ? "background:var(--fg3)" : ""}>
98 {raw(ICONS.issueClosed)} Closed
99 </span>
100 );
101};
102
103const StateIcon: FC<{ i: Issue }> = ({ i }) => {
104 if (i.kind === "pull") {
105 if (i.pull?.merged) return <span class="st merged">{raw(ICONS.prMerged)}</span>;
106 if (i.state === "closed") return <span class="st bad">{raw(ICONS.prClosed)}</span>;
107 return <span class={`st ${i.pull?.draft ? "faint" : "ok"}`}>{raw(ICONS.pr)}</span>;
108 }
109 return i.state === "open" ? <span class="st ok">{raw(ICONS.issueOpen)}</span> : <span class="st merged">{raw(ICONS.issueClosed)}</span>;
110};
111
112// ---------------------------------------------------------------- lists
113
114async function listPage(c: AppContext, kind: "issue" | "pull"): Promise<Response> {
115 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
116 const base = repoUrl(repo);
117 const state = c.req.query("state") === "closed" ? "closed" : "open";
118 const label = c.req.query("label") ?? "";
119 const q = (c.req.query("q") ?? "").slice(0, 200);
120 const author = c.req.query("author") ?? "";
121 const page = pageNum(c);
122 const per = 25;
123 const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined };
124 const [list, other, labels] = await Promise.all([
125 stub.issues({ ...filter, state, limit: per, offset: (page - 1) * per }),
126 stub.issues({ ...filter, state: state === "open" ? "closed" : "open", limit: 1 }),
127 stub.labels(),
128 ]);
129 const colors = new Map(labels.map((l) => [l.name, l.color]));
130 const path = kind === "pull" ? "pulls" : "issues";
131 const href = (p: Record<string, string | number | undefined>) => `${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, label: label || undefined, q: q || undefined, author: author || undefined, ...p })}`;
132 const openCount = state === "open" ? list.total : other.total;
133 const closedCount = state === "closed" ? list.total : other.total;
134 const isAccount = c.get("principal").type === "account";
135 const behalf = await onBehalfOf(c, list.items.map((i) => i.author));
136 return render(
137 c,
138 { title: `${kind === "pull" ? "Pull requests" : "Issues"} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: kind === "pull" ? "pulls" : "issues" } },
139 <div>
140 <div class="row between mb">
141 <form method="get" action={`${base}/${path}`} class="row grow" style="max-width:640px">
142 <input type="hidden" name="state" value={state === "closed" ? "closed" : ""} />
143 {label ? <input type="hidden" name="label" value={label} /> : null}
144 <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" />
145 </form>
146 <div class="row">
147 <a class="btn" href={`${base}/labels`}>
148 {raw(ICONS.tag)} Labels
149 </a>
150 {kind === "pull" ? (
151 <a class="btn primary" href={`${base}/compare`}>
152 New pull request
153 </a>
154 ) : isAccount && !repo.archived ? (
155 <a class="btn primary" href={`${base}/issues/new`}>
156 New issue
157 </a>
158 ) : null}
159 </div>
160 </div>
161 <div class="box">
162 <div class="head">
163 <a href={href({ state: undefined, page: undefined })} style={state === "open" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}>
164 {raw(kind === "pull" ? ICONS.pr : ICONS.issueOpen)} {openCount} Open
165 </a>
166 <a href={href({ state: "closed", page: undefined })} style={state === "closed" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}>
167 {raw(ICONS.check)} {closedCount} Closed
168 </a>
169 <span class="grow" />
170 {label ? (
171 <span class="small">
172 Label: <Label name={label} color={colors.get(label)} /> <a href={`${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, q: q || undefined })}`}>clear</a>
173 </span>
174 ) : null}
175 {author ? (
176 <span class="small">
177 Author: <b>{author}</b> <a href={href({ author: undefined })}>clear</a>
178 </span>
179 ) : null}
180 {labels.length ? (
181 <details class="menu">
182 <summary class="btn sm">Label ▾</summary>
183 <div class="pop right">
184 {labels.map((l) => (
185 <a href={href({ label: l.name, page: undefined })}>
186 <Label name={l.name} color={l.color} />
187 <span class="muted small">{l.description}</span>
188 </a>
189 ))}
190 </div>
191 </details>
192 ) : null}
193 </div>
194 {list.items.length ? (
195 <ul class="list">
196 {list.items.map((i) => (
197 <li>
198 <StateIcon i={i} />
199 <div class="grow">
200 <a class="title" href={`${base}/${kindPath(i)}/${i.number}`}>
201 {i.title}
202 </a>{" "}
203 {i.labels.map((l) => (
204 <Label name={l} color={colors.get(l)} href={href({ label: l, page: undefined })} />
205 ))}
206 {i.pull?.draft && i.state === "open" ? <span class="badge">draft</span> : null}
207 <div class="meta">
208 #{i.number} {i.state === "open" ? "opened" : i.pull?.merged ? "merged" : "closed"} <Time at={i.state === "open" ? i.createdAt : (i.pull?.mergedAt ?? i.closedAt ?? i.updatedAt)} /> by{" "}
209 <a href={href({ author: i.author.login, page: undefined })}>{i.author.login}</a>
210 {i.author.kind === "agent" ? (
211 <>
212 {" "}
213 <span class="badge agent">agent</span>
214 {behalf.get(i.author.login) ? <span> on behalf of {behalf.get(i.author.login)}</span> : null}
215 </>
216 ) : null}
217 {i.pull ? (
218 <span class="mono">
219 {" "}
220 · {i.pull.head} → {i.pull.base}
221 </span>
222 ) : null}
223 </div>
224 </div>
225 {i.comments ? (
226 <a class="muted small nowrap" href={`${base}/${kindPath(i)}/${i.number}`}>
227 {raw(ICONS.comment)} {i.comments}
228 </a>
229 ) : null}
230 </li>
231 ))}
232 </ul>
233 ) : (
234 <div class="empty">
235 {q || label || author ? "No results match your filters." : state === "open" ? `There are no open ${kind === "pull" ? "pull requests" : "issues"}.` : `No closed ${kind === "pull" ? "pull requests" : "issues"}.`}
236 </div>
237 )}
238 </div>
239 <Pager page={page} hasNext={page * per < list.total} href={(p) => href({ page: p > 1 ? p : undefined })} />
240 </div>,
241 );
242}
243
244// ---------------------------------------------------------------- conversation pieces
245
246interface ConvCtx {
247 c: AppContext;
248 l: Loaded;
249 issue: Issue;
250 comments: Comment[];
251 reviews: Review[];
252 labels: LabelDef[];
253 behalf: Map<string, string>;
254 /** The signed-in person's thread subscription (explicit choice, if any) and repository watch level. */
255 sub?: { explicit: boolean | null; watch: WatchLevel };
256}
257
258const CommentCard: FC<{ x: ConvCtx; author: { login: string; kind: string }; at: string; body: string; verb?: string; id?: string; extra?: Child; cls?: string }> = ({ x, author, at, body, verb, id, extra, cls }) => (
259 <div class={`comment ${cls ?? ""}`} id={id}>
260 <div class="head">
261 <Actor login={author.login} kind={author.kind} onBehalfOf={x.behalf.get(author.login)} />
262 <span class="muted">
263 {verb ?? "commented"} <Time at={at} />
264 </span>
265 <span class="grow" />
266 {author.login === x.issue.author.login ? <span class="badge">Author</span> : null}
267 {extra}
268 </div>
269 <div class="body">
270 <Markdown text={body} base={{ owner: x.l.repo.owner_login, repo: x.l.repo.name, ref: x.l.repo.default_branch, dir: "" }} />
271 </div>
272 </div>
273);
274
275const LineComments: FC<{ x: ConvCtx; list: Comment[] }> = ({ x, list }) => {
276 const base = repoUrl(x.l.repo);
277 return (
278 <div>
279 {list.map((cm) => (
280 <div class="linec" id={`c-${cm.id}`}>
281 <div class="row small" style="gap:8px">
282 <a class="where" href={`${base}/pull/${x.issue.number}/files#diff-${encodeURIComponent(cm.path ?? "")}`}>
283 {cm.path}
284 {cm.line ? `:${cm.line}` : ""}
285 </a>
286 {cm.commitId && x.issue.pull && cm.commitId !== x.issue.pull.headSha ? <span class="badge">outdated</span> : null}
287 {cm.resolved ? <span class="badge green">resolved</span> : null}
288 <span class="grow" />
289 <Actor login={cm.author.login} kind={cm.author.kind} avatar={false} />
290 <Time at={cm.createdAt} />
291 </div>
292 <Markdown text={cm.body} />
293 </div>
294 ))}
295 </div>
296 );
297};
298
299const Timeline: FC<{ x: ConvCtx }> = ({ x }) => {
300 type Item = { at: string; node: Child };
301 const items: Item[] = [];
302 for (const cm of x.comments.filter((c) => !c.reviewId)) {
303 items.push({ at: cm.createdAt, node: <CommentCard x={x} author={cm.author} at={cm.createdAt} body={cm.body} id={`c-${cm.id}`} /> });
304 }
305 for (const rv of x.reviews) {
306 const lines = x.comments.filter((c) => c.reviewId === rv.id);
307 const cls = rv.state === "APPROVED" ? "approved" : rv.state === "CHANGES_REQUESTED" ? "changes" : "";
308 const verb = rv.state === "APPROVED" ? "approved these changes" : rv.state === "CHANGES_REQUESTED" ? "requested changes" : rv.state === "DISMISSED" ? "reviewed (dismissed)" : "reviewed";
309 items.push({
310 at: rv.submittedAt,
311 node: (
312 <div>
313 <div class={`event ${cls}`}>
314 <span class="ic">{raw(rv.state === "APPROVED" ? ICONS.check : rv.state === "CHANGES_REQUESTED" ? ICONS.x : ICONS.eye)}</span>
315 <Actor login={rv.author.login} kind={rv.author.kind} onBehalfOf={x.behalf.get(rv.author.login)} avatar={false} />
316 <span>
317 {verb} <Time at={rv.submittedAt} />
318 </span>
319 <span class="faint mono small">on {shortSha(rv.commitId)}</span>
320 </div>
321 {rv.body.trim() || lines.length ? (
322 <div class="comment" style="margin-left:24px">
323 <div class="body">
324 {rv.body.trim() ? <Markdown text={rv.body} /> : null}
325 {lines.length ? <LineComments x={x} list={lines} /> : null}
326 </div>
327 </div>
328 ) : null}
329 </div>
330 ),
331 });
332 }
333 if (x.issue.state === "closed" && x.issue.closedAt) {
334 items.push({
335 at: x.issue.closedAt,
336 node: (
337 <div class="event">
338 <span class="ic" style={x.issue.pull?.merged ? "color:var(--purple)" : ""}>
339 {raw(x.issue.pull?.merged ? ICONS.prMerged : ICONS.issueClosed)}
340 </span>
341 {x.issue.pull?.merged ? (
342 <span>
343 <b>{x.issue.pull.mergedBy}</b> merged commit{" "}
344 {x.issue.pull.mergeCommit ? (
345 <a class="sha" href={`${repoUrl(x.l.repo)}/commit/${x.issue.pull.mergeCommit}`}>
346 {shortSha(x.issue.pull.mergeCommit)}
347 </a>
348 ) : null}{" "}
349 into <span class="mono">{x.issue.pull.base}</span> <Time at={x.issue.pull.mergedAt} />
350 </span>
351 ) : (
352 <span>
353 Closed{x.issue.stateReason && x.issue.stateReason !== "completed" ? ` as ${x.issue.stateReason.replace(/_/g, " ")}` : ""} <Time at={x.issue.closedAt} />
354 </span>
355 )}
356 </div>
357 ),
358 });
359 }
360 items.sort((a, b) => a.at.localeCompare(b.at));
361 return <div class="timeline">{items.map((i) => i.node)}</div>;
362};
363
364const CommentForm: FC<{ x: ConvCtx }> = ({ x }) => {
365 const p = x.c.get("principal");
366 if (p.type !== "account") return <div class="blank">Sign in to join the conversation.</div>;
367 if (x.l.repo.archived) return <div class="blank">This repository is archived; the conversation is read-only.</div>;
368 const base = repoUrl(x.l.repo);
369 const canState = x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage");
370 const merged = !!x.issue.pull?.merged;
371 return (
372 <Form action={`${base}/issues/${x.issue.number}/comment`} t={csrfToken(x.c)} class="comment">
373 <div class="head">
374 <b>Add a comment</b>
375 <span class="muted small">Markdown supported</span>
376 </div>
377 <div class="body">
378 <textarea name="body" placeholder="Leave a comment" aria-label="Comment"></textarea>
379 <div class="row end mt">
380 {canState && !merged ? (
381 x.issue.state === "open" ? (
382 <>
383 {x.issue.kind === "issue" ? (
384 <button class="btn" name="action" value="not_planned">
385 Close as not planned
386 </button>
387 ) : null}
388 <button class="btn" name="action" value="close">
389 {raw(ICONS.issueClosed)} Close {x.issue.kind === "pull" ? "pull request" : "issue"}
390 </button>
391 </>
392 ) : (
393 <button class="btn" name="action" value="reopen">
394 Reopen
395 </button>
396 )
397 ) : null}
398 <button class="btn primary" name="action" value="comment">
399 Comment
400 </button>
401 </div>
402 </div>
403 </Form>
404 );
405};
406
407/** Why the signed-in person gets notifications for this thread, and a button to change it (like GitHub's sidebar). */
408const SubscriptionBox: FC<{ x: ConvCtx; sub: NonNullable<ConvCtx["sub"]> }> = ({ x, sub }) => {
409 const me = principalAccount(x.c)!;
410 const i = x.issue;
411 const why =
412 i.author.login === me.login
413 ? "you authored it"
414 : i.assignees.includes(me.login)
415 ? "you were assigned"
416 : i.pull?.requestedReviewers.includes(me.login)
417 ? "your review was requested"
418 : x.reviews.some((r) => r.author.login === me.login)
419 ? "you reviewed it"
420 : x.comments.some((c) => c.author.login === me.login)
421 ? "you commented"
422 : mentions([i.body, ...x.comments.map((c) => c.body)].join("\n")).includes(me.login.toLowerCase())
423 ? "you were mentioned"
424 : sub.watch === "all"
425 ? "you're watching this repository"
426 : null;
427 const on = sub.explicit ?? (why !== null && sub.watch !== "ignore");
428 const what = i.kind === "pull" ? "pull request" : "issue";
429 return (
430 <section>
431 <h3>Notifications</h3>
432 <Form action={`${repoUrl(x.l.repo)}/issues/${i.number}/subscription`} t={csrfToken(x.c)}>
433 <input type="hidden" name="subscribed" value={on ? "0" : "1"} />
434 <button class="btn sm" style="width:100%">
435 {raw(ICONS.bell)} {on ? "Unsubscribe" : "Subscribe"}
436 </button>
437 </Form>
438 <p class="muted small" style="margin:6px 0 0">
439 {sub.explicit === true
440 ? `You're subscribed to this ${what}.`
441 : sub.explicit === false
442 ? `You muted this ${what}. You'll still hear about it if you're @mentioned, assigned or asked to review.`
443 : on
444 ? `You're receiving notifications because ${why}.`
445 : `You're not receiving notifications for this ${what}.`}
446 </p>
447 </section>
448 );
449};
450
451const Sidebar: FC<{ x: ConvCtx }> = ({ x }) => {
452 const base = repoUrl(x.l.repo);
453 const t = csrfToken(x.c);
454 const canTriage = atLeast(x.l.caller.role, "triage") && !x.l.repo.archived && x.c.get("principal").type === "account";
455 const colors = new Map(x.labels.map((l) => [l.name, l.color]));
456 const participants = [...new Map([x.issue.author, ...x.comments.map((c) => c.author), ...x.reviews.map((r) => r.author)].map((a) => [a.login, a])).values()];
457 return (
458 <aside class="side">
459 {x.issue.pull ? (
460 <section>
461 <h3>Reviewers</h3>
462 {x.reviews.length || x.issue.pull.requestedReviewers.length ? (
463 <div>
464 {[...new Map(x.reviews.filter((r) => r.state !== "COMMENTED").map((r) => [r.author.login, r])).values()].map((r) => (
465 <div class="row small" style="margin:4px 0">
466 <Actor login={r.author.login} kind={r.author.kind} />
467 <span class="grow" />
468 <span class={r.state === "APPROVED" ? "ok" : r.state === "CHANGES_REQUESTED" ? "bad" : "faint"}>{r.state === "APPROVED" ? "approved" : r.state === "CHANGES_REQUESTED" ? "changes requested" : "dismissed"}</span>
469 </div>
470 ))}
471 {x.issue.pull.requestedReviewers.map((r) => (
472 <div class="row small" style="margin:4px 0">
473 <Actor login={r} />
474 <span class="grow" />
475 <span class="warn">pending</span>
476 </div>
477 ))}
478 </div>
479 ) : (
480 <p class="muted small m0">No reviews yet.</p>
481 )}
482 </section>
483 ) : null}
484 <section>
485 <h3>Labels</h3>
486 {x.issue.labels.length ? (
487 <div>
488 {x.issue.labels.map((l) => (
489 <Label name={l} color={colors.get(l)} href={`${base}/${x.issue.kind === "pull" ? "pulls" : "issues"}${qs({ label: l })}`} />
490 ))}
491 </div>
492 ) : (
493 <p class="muted small m0">None yet</p>
494 )}
495 {canTriage ? (
496 <details class="mt" style="margin-top:8px">
497 <summary class="small" style="cursor:pointer;color:var(--accent)">
498 Edit labels
499 </summary>
500 <Form action={`${base}/issues/${x.issue.number}/labels`} t={t} class="mt" style="margin-top:8px">
501 <div class="kstack">
502 {x.labels.map((l) => (
503 <KCheckbox name="labels" value={l.name} checked={x.issue.labels.includes(l.name)} label={<Label name={l.name} color={l.color} />} />
504 ))}
505 {x.issue.labels
506 .filter((l) => !colors.has(l))
507 .map((l) => (
508 <KCheckbox name="labels" value={l} checked label={<Label name={l} />} />
509 ))}
510 </div>
511 <input type="text" name="new_label" placeholder="New label…" style="margin:6px 0" />
512 <button class="btn sm">Apply</button>
513 </Form>
514 </details>
515 ) : null}
516 </section>
517 <section>
518 <h3>Assignees</h3>
519 {x.issue.assignees.length ? (
520 x.issue.assignees.map((a) => (
521 <div style="margin:4px 0">
522 <Actor login={a} />
523 </div>
524 ))
525 ) : (
526 <p class="muted small m0">No one assigned</p>
527 )}
528 {canTriage ? (
529 <details style="margin-top:8px">
530 <summary class="small" style="cursor:pointer;color:var(--accent)">
531 Edit assignees
532 </summary>
533 <Form action={`${base}/issues/${x.issue.number}/assignees`} t={t} style="margin-top:8px">
534 <input type="text" name="assignees" value={x.issue.assignees.join(", ")} placeholder="login, login" />
535 <button class="btn sm" style="margin-top:6px">
536 Save
537 </button>
538 </Form>
539 </details>
540 ) : null}
541 </section>
542 {x.sub ? <SubscriptionBox x={x} sub={x.sub} /> : null}
543 <section>
544 <h3>Participants</h3>
545 <div class="row" style="gap:4px">
546 {participants.map((a) => (
547 <a href={`/${encodeURIComponent(a.login)}`} title={a.login}>
548 <Avatar login={a.login} kind={a.kind} />
549 </a>
550 ))}
551 </div>
552 </section>
553 </aside>
554 );
555};
556
557const TitleEditor: FC<{ x: ConvCtx }> = ({ x }) => {
558 const can = x.c.get("principal").type === "account" && !x.l.repo.archived && (x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage"));
559 if (!can) return null;
560 return (
561 <details class="menu">
562 <summary class="btn">{raw(ICONS.edit)} Edit</summary>
563 <div class="pop right" style="width:min(560px,90vw)">
564 <Form action={`${repoUrl(x.l.repo)}/issues/${x.issue.number}/edit`} t={csrfToken(x.c)} style="padding:8px">
565 <div class="field">
566 <label>Title</label>
567 <input type="text" name="title" value={x.issue.title} required />
568 </div>
569 <div class="field">
570 <label>Description</label>
571 <textarea name="body">{x.issue.body}</textarea>
572 </div>
573 <button class="btn primary">Save</button>
574 </Form>
575 </div>
576 </details>
577 );
578};
579
580const IssueHeader: FC<{ x: ConvCtx }> = ({ x }) => {
581 const i = x.issue;
582 const base = repoUrl(x.l.repo);
583 return (
584 <div class="mb" data-fragment="header">
585 <div class="row between" style="align-items:flex-start">
586 <h1 class="grow" style="font-size:24px;font-weight:500">
587 {i.title} <span class="faint">#{i.number}</span>
588 </h1>
589 <div class="row">
590 <TitleEditor x={x} />
591 {i.kind === "issue" ? (
592 <a class="btn primary" href={`${base}/issues/new`}>
593 New issue
594 </a>
595 ) : null}
596 </div>
597 </div>
598 <div class="row" style="gap:8px">
599 <StateBadge i={i} />
600 <Actor login={i.author.login} kind={i.author.kind} onBehalfOf={x.behalf.get(i.author.login)} avatar={false} />
601 {i.pull ? (
602 <span class="muted">
603 {i.pull.merged ? "merged" : "wants to merge"} into <a class="sha" href={`${base}/tree/${encPath(i.pull.base)}`}>{i.pull.base}</a> from{" "}
604 <a class="sha" href={`${base}/tree/${encPath(i.pull.head)}`}>
605 {i.pull.head}
606 </a>
607 </span>
608 ) : (
609 <span class="muted">
610 opened this issue <Time at={i.createdAt} /> · {i.comments} comment{i.comments === 1 ? "" : "s"}
611 </span>
612 )}
613 </div>
614 </div>
615 );
616};
617
618const PullTabs: FC<{ x: ConvCtx; tab: "conversation" | "commits" | "files" | "checks" }> = ({ x, tab }) => {
619 const b = `${repoUrl(x.l.repo)}/pull/${x.issue.number}`;
620 return (
621 <nav class="tabs mb" style="border-bottom:1px solid var(--border);margin-top:0">
622 <a href={b} class={tab === "conversation" ? "on" : ""}>
623 {raw(ICONS.comment)} Conversation <span class="n">{x.issue.comments}</span>
624 </a>
625 <a href={`${b}/commits`} class={tab === "commits" ? "on" : ""}>
626 {raw(ICONS.commit)} Commits
627 </a>
628 <a href={`${b}/checks`} class={tab === "checks" ? "on" : ""}>
629 {raw(ICONS.play)} Checks
630 </a>
631 <a href={`${b}/files`} class={tab === "files" ? "on" : ""}>
632 {raw(ICONS.file)} Files changed
633 </a>
634 </nav>
635 );
636};
637
638// ---------------------------------------------------------------- merge box and review
639
640const MergeBox: FC<{ x: ConvCtx; m: Mergeability | null; checks: Check[] }> = ({ x, m, checks }) => {
641 const i = x.issue;
642 const p = i.pull!;
643 const base = repoUrl(x.l.repo);
644 const caller = x.l.caller;
645 const isAccount = x.c.get("principal").type === "account";
646 if (p.merged) {
647 return (
648 <div class="mergebox merged" data-fragment="merge">
649 <div class="row">
650 <span class="st merged">{raw(ICONS.prMerged)}</span>
651 <div>
652 <b>Pull request successfully merged and closed</b>
653 <div class="muted small">
654 {p.mergedBy} merged {p.mergeCommit ? <a href={`${base}/commit/${p.mergeCommit}`}>{shortSha(p.mergeCommit)}</a> : null} into {p.base} <Time at={p.mergedAt} />
655 </div>
656 </div>
657 </div>
658 </div>
659 );
660 }
661 if (i.state === "closed") {
662 return (
663 <div class="mergebox" data-fragment="merge">
664 <div>
665 <b>This pull request is closed.</b>
666 <span class="muted"> Reopen it to continue.</span>
667 </div>
668 </div>
669 );
670 }
671 if (!m) return <div class="mergebox" data-fragment="merge" />;
672 const headline =
673 m.state === "clean"
674 ? "This branch has no conflicts with the base branch"
675 : m.state === "conflicting"
676 ? "This branch has conflicts that must be resolved"
677 : m.state === "draft"
678 ? "This pull request is still a work in progress"
679 : "Merging is blocked";
680 const bypass = !m.mergeable && caller.role === "admin" && caller.kind === "user" && !m.enforceAdmins && m.conflicts.length === 0 && !p.draft;
681 const showChecks = checks.length ? checks : m.checks.map((ch) => ({ name: ch.name, status: ch.state === "queued" || ch.state === "in_progress" ? ch.state : "completed", conclusion: ch.state, runId: null, jobId: null, output: null }) as unknown as Check);
682 const failing = showChecks.filter((ch) => ch.status === "completed" && ch.conclusion !== "success" && ch.conclusion !== "skipped" && ch.conclusion !== "neutral").length;
683 const pending = showChecks.filter((ch) => ch.status !== "completed").length;
684 const canReady = p.draft && isAccount && (caller.id === i.author.id || atLeast(caller.role, "write")) && !x.l.repo.archived;
685 return (
686 <div class={`mergebox ${m.state}`} data-fragment="merge">
687 {canReady ? (
688 <Form action={`${base}/pull/${i.number}/ready`} t={csrfToken(x.c)} class="row" style="margin-bottom:8px">
689 <span class="grow">This pull request is still a draft and can't be merged yet.</span>
690 <button class="btn">Ready for review</button>
691 </Form>
692 ) : null}
693 {showChecks.length ? (
694 <div>
695 <div class="row mb" style="margin-bottom:6px">
696 <Status status={pending ? "in_progress" : "completed"} conclusion={failing ? "failure" : "success"} />
697 <b>{pending ? "Some checks haven't completed yet" : failing ? `${failing} failing check${failing === 1 ? "" : "s"}` : "All checks have passed"}</b>
698 <span class="muted small">
699 {showChecks.length} check{showChecks.length === 1 ? "" : "s"}
700 </span>
701 </div>
702 <ul class="checks">
703 {showChecks.map((ch) => (
704 <li>
705 <Status status={ch.status} conclusion={ch.conclusion} />
706 <CheckName base={base} ch={ch} />
707 <span class="muted small grow">{ch.output?.title && ch.output.title !== ch.conclusion ? ch.output.title : ""}</span>
708 <span class="muted small">{ch.startedAt ? duration(ch.startedAt, ch.completedAt) : ""}</span>
709 </li>
710 ))}
711 </ul>
712 </div>
713 ) : null}
714 <div>
715 <div class="row">
716 <Status conclusion={m.approvals >= m.requiredApprovals && !m.changesRequested.length ? "success" : "pending"} />
717 <span>
718 <b>
719 {m.approvals} approval{m.approvals === 1 ? "" : "s"}
720 </b>
721 {m.requiredApprovals ? <span class="muted"> of {m.requiredApprovals} required</span> : null}
722 {m.changesRequested.length ? <span class="bad"> · changes requested by {m.changesRequested.join(", ")}</span> : null}
723 </span>
724 </div>
725 </div>
726 <div>
727 <div class="row">
728 <Status conclusion={m.mergeable ? "success" : m.state === "conflicting" ? "failure" : "pending"} />
729 <b>{headline}</b>
730 </div>
731 {m.conflicts.length ? (
732 <ul class="small mono" style="margin:6px 0 0 24px">
733 {m.conflicts.map((f) => (
734 <li>{f}</li>
735 ))}
736 </ul>
737 ) : null}
738 {m.blockers.length ? (
739 <ul class="small" style="margin:6px 0 0 24px;color:var(--fg2)">
740 {m.blockers.map((b) => (
741 <li>{b}</li>
742 ))}
743 </ul>
744 ) : null}
745 </div>
746 {m.mergeQueue ? (
747 <QueueBox x={x} m={m} canAct={isAccount && atLeast(caller.role, "write") && !x.l.repo.archived} adminBypass={caller.role === "admin" && caller.kind === "user" && !m.enforceAdmins && m.conflicts.length === 0 && !p.draft} />
748 ) : isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? (
749 <div>
750 <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} class="row" confirm={bypass ? "Merge without meeting the branch requirements?" : undefined}>
751 <input type="hidden" name="expected_head" value={p.headSha} />
752 {bypass ? <input type="hidden" name="bypass_rules" value="1" /> : null}
753 <KSelect
754 name="method"
755 label="Merge method"
756 value={m.linearHistory ? "squash" : "merge"}
757 options={[
758 ...(m.linearHistory ? [] : [{ value: "merge", label: "Create a merge commit" }]),
759 { value: "squash", label: "Squash and merge" },
760 { value: "rebase", label: "Rebase and merge (fast-forward)" },
761 ]}
762 />
763 <KCheckbox
764 name="delete_branch"
765 checked
766 label={
767 <>
768 Delete <span class="mono">{p.head}</span> after merge
769 </>
770 }
771 />
772 <span class="grow" />
773 {m.mergeable ? (
774 <button class="btn green">Merge pull request</button>
775 ) : bypass ? (
776 <button class="btn danger">Merge without waiting for requirements (bypass)</button>
777 ) : (
778 <button class="btn" disabled>
779 Merge pull request
780 </button>
781 )}
782 </Form>
783 </div>
784 ) : null}
785 </div>
786 );
787};
788
789const METHOD_LABELS = { merge: "Create a merge commit", squash: "Squash and merge", rebase: "Rebase and merge" } as const;
790
791/** The merge box's footer when the base branch merges through a merge queue. */
792const QueueBox: FC<{ x: ConvCtx; m: Mergeability; canAct: boolean; adminBypass: boolean }> = ({ x, m, canAct, adminBypass }) => {
793 const i = x.issue;
794 const p = i.pull!;
795 const base = repoUrl(x.l.repo);
796 const q = m.mergeQueue!;
797 const e = q.entry;
798 const queueUrl = `${base}/queue/${encPath(p.base)}`;
799 return (
800 <div>
801 {e ? (
802 <div>
803 <div class="row">
804 <Status status="in_progress" />
805 <span>
806 <b>Queued to merge</b>
807 <span class="muted">
808 {" "}
809 · position {e.position} in the <a href={queueUrl}>{p.base} merge queue</a> ·{" "}
810 {e.state === "checking" && e.groupSha ? (
811 <>
812 checking merge group <a class="sha" href={`${base}/commit/${e.groupSha}`}>{shortSha(e.groupSha)}</a>
813 </>
814 ) : (
815 "waiting for the pull requests ahead of it"
816 )}
817 </span>
818 </span>
819 </div>
820 {e.checks.length ? (
821 <ul class="checks" style="margin-top:6px">
822 {e.checks.map((ch) => (
823 <li>
824 <Status status={ch.state === "queued" || ch.state === "in_progress" || ch.state === "expected" ? "in_progress" : "completed"} conclusion={ch.state} />
825 <span>{ch.name}</span>
826 <span class="muted small grow">{ch.state}</span>
827 </li>
828 ))}
829 </ul>
830 ) : null}
831 {canAct ? (
832 <Form action={`${base}/pull/${i.number}/dequeue`} t={csrfToken(x.c)} class="row" style="margin-top:8px">
833 <span class="muted small grow">It merges into {p.base} once its merge group passes the required checks.</span>
834 <button class="btn">Remove from queue</button>
835 </Form>
836 ) : null}
837 </div>
838 ) : canAct ? (
839 <div>
840 <Form action={`${base}/pull/${i.number}/enqueue`} t={csrfToken(x.c)} class="row">
841 <KSelect name="method" label="Merge method" value={q.method} options={(["merge", "squash", "rebase"] as const).map((v) => ({ value: v, label: METHOD_LABELS[v] }))} />
842 <KCheckbox
843 name="delete_branch"
844 checked
845 label={
846 <>
847 Delete <span class="mono">{p.head}</span> after merge
848 </>
849 }
850 />
851 <span class="grow" />
852 {m.mergeable ? (
853 <button class="btn green">Merge when ready</button>
854 ) : (
855 <button class="btn" disabled>
856 Merge when ready
857 </button>
858 )}
859 </Form>
860 <div class="row muted small" style="margin-top:6px">
861 <span class="grow">
862 {p.base} merges through a <a href={queueUrl}>merge queue</a>: the pull request is tested together with the ones ahead of it before it merges.
863 </span>
864 {adminBypass ? (
865 <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} confirm="Merge directly, bypassing the merge queue?">
866 <input type="hidden" name="expected_head" value={p.headSha} />
867 <input type="hidden" name="method" value={q.method} />
868 <input type="hidden" name="bypass_queue" value="1" />
869 <button class="btn sm danger">Merge now (bypass the queue)</button>
870 </Form>
871 ) : null}
872 </div>
873 </div>
874 ) : (
875 <div class="muted small">{p.base} merges through a merge queue.</div>
876 )}
877 </div>
878 );
879};
880
881const ReviewForm: FC<{ x: ConvCtx }> = ({ x }) => {
882 if (x.c.get("principal").type !== "account" || x.issue.state !== "open" || x.l.repo.archived) return null;
883 const own = x.issue.author.id === x.l.caller.id;
884 const canApprove = !own && atLeast(x.l.caller.role, "write");
885 return (
886 <details class="box">
887 <summary class="head" style="cursor:pointer">
888 {raw(ICONS.eye)} <b>Review changes</b>
889 <span class="muted small">{own ? "You can comment on your own pull request." : "Approve, request changes or leave a review comment."}</span>
890 </summary>
891 <Form action={`${repoUrl(x.l.repo)}/pull/${x.issue.number}/review`} t={csrfToken(x.c)} class="body">
892 <div class="field">
893 <textarea name="body" placeholder="Review summary (Markdown)"></textarea>
894 </div>
895 <div class="field">
896 <KRadio
897 name="event"
898 label="Review type"
899 value="COMMENT"
900 items={[
901 { value: "COMMENT", label: "Comment" },
902 { value: "APPROVE", label: <span class="ok">Approve</span>, disabled: !canApprove },
903 { value: "REQUEST_CHANGES", label: <span class="bad">Request changes</span>, disabled: !canApprove },
904 ]}
905 />
906 </div>
907 <button class="btn primary">Submit review</button>
908 </Form>
909 </details>
910 );
911};
912
913// ---------------------------------------------------------------- pages
914
915async function loadConv(c: AppContext): Promise<ConvCtx> {
916 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
917 const n = issueNumber(c);
918 const [data, labels] = await Promise.all([l.stub.issue(n), l.stub.labels()]);
919 const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]);
920 const me = principalAccount(c);
921 let sub: ConvCtx["sub"];
922 if (me?.kind === "user") {
923 const store = new MailStore(c.env.DB);
924 const [explicit, watch] = await Promise.all([store.threadSub(me.id, l.repo.id, n), store.watchLevel(me.id, l.repo.id)]);
925 sub = { explicit, watch };
926 }
927 return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf, sub };
928}
929
930async function issuePage(c: AppContext): Promise<Response> {
931 const x = await loadConv(c);
932 const base = repoUrl(x.l.repo);
933 if (x.issue.kind === "pull") return c.redirect(`${base}/pull/${x.issue.number}${new URL(c.req.url).search}`, 302);
934 return render(
935 c,
936 { title: `${x.issue.title} · Issue #${x.issue.number}`, repo: { repo: x.l.repo, caller: x.l.caller, tab: "issues" }, live: `pr:${x.issue.number}` },
937 <div>
938 <IssueHeader x={x} />
939 <div class="grid">
940 <div>
941 <div data-fragment="timeline">
942 <CommentCard x={x} author={x.issue.author} at={x.issue.createdAt} body={x.issue.body} verb="opened" />
943 <Timeline x={x} />
944 </div>
945 <CommentForm x={x} />
946 </div>
947 <Sidebar x={x} />
948 </div>
949 </div>,
950 );
951}
952
953async function pullPage(c: AppContext, tab: "conversation" | "commits" | "files" | "checks"): Promise<Response> {
954 const x = await loadConv(c);
955 const { repo, caller, stub } = x.l;
956 const base = repoUrl(repo);
957 const i = x.issue;
958 if (i.kind !== "pull" || !i.pull) return c.redirect(`${base}/issues/${i.number}`, 302);
959 const n = i.number;
960 const page = (body: Child, wide = false) =>
961 render(
962 c,
963 { title: `${i.title} · Pull request #${n}`, repo: { repo, caller, tab: "pulls", ref: i.pull!.head }, live: `pr:${n}`, wide },
964 <div>
965 <IssueHeader x={x} />
966 <PullTabs x={x} tab={tab} />
967 {body}
968 </div>,
969 );
970
971 if (tab === "conversation") {
972 const [m, checks] = await Promise.all([i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : Promise.resolve(null), stub.checksAndStatuses(i.pull.headSha).catch(() => [] as Check[])]);
973 return page(
974 <div class="grid">
975 <div>
976 <div data-fragment="timeline">
977 <CommentCard x={x} author={i.author} at={i.createdAt} body={i.body} verb="opened" />
978 {i.pull.intent ? (
979 <div class="comment intent">
980 <div class="head">
981 {raw(ICONS.zap)} <b>Intent</b>
982 <span class="muted small">what this change is meant to do</span>
983 </div>
984 <div class="body">
985 <Markdown text={i.pull.intent} />
986 </div>
987 </div>
988 ) : null}
989 <Timeline x={x} />
990 </div>
991 <MergeBox x={x} m={m} checks={checks} />
992 <ReviewForm x={x} />
993 <CommentForm x={x} />
994 </div>
995 <Sidebar x={x} />
996 </div>,
997 );
998 }
999
1000 if (tab === "commits") {
1001 const commits = await stub.pullCommits(n).catch(() => []);
1002 return page(
1003 <div class="box">
1004 {commits.length ? (
1005 <ul class="list">
1006 {commits.map((cm) => (
1007 <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} />
1008 ))}
1009 </ul>
1010 ) : (
1011 <div class="empty">No commits.</div>
1012 )}
1013 </div>,
1014 );
1015 }
1016
1017 if (tab === "checks") {
1018 const [checks, runs] = await Promise.all([stub.checksAndStatuses(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]);
1019 return page(
1020 <div data-fragment="checks">
1021 <div class="box">
1022 <div class="head">
1023 <h3>Checks for {shortSha(i.pull.headSha)}</h3>
1024 </div>
1025 {checks.length ? (
1026 <ul class="list">
1027 {checks.map((ch) => (
1028 <li>
1029 <Status status={ch.status} conclusion={ch.conclusion} />
1030 <div class="grow">
1031 <CheckName base={base} ch={ch} class="title" />
1032 <div class="meta">
1033 {ch.app} · {ch.status === "completed" ? (ch.conclusion ?? "neutral") : ch.status.replace("_", " ")}
1034 {ch.output?.summary ? ` · ${ch.output.summary.slice(0, 200)}` : ""}
1035 </div>
1036 </div>
1037 <span class="muted small">{duration(ch.startedAt, ch.completedAt)}</span>
1038 </li>
1039 ))}
1040 </ul>
1041 ) : (
1042 <div class="empty">No checks have reported for the latest commit.</div>
1043 )}
1044 </div>
1045 <div class="box">
1046 <div class="head">
1047 <h3>Workflow runs</h3>
1048 </div>
1049 {runs.runs.length ? (
1050 <ul class="list">
1051 {runs.runs.map((r) => (
1052 <li>
1053 <Status status={r.status} conclusion={r.conclusion} />
1054 <div class="grow">
1055 <a class="title" href={`${base}/actions/runs/${r.id}`}>
1056 {r.title}
1057 </a>
1058 <div class="meta">
1059 {r.workflowName} #{r.number} · {r.event} · {shortSha(r.sha)} · <Time at={r.createdAt} />
1060 </div>
1061 </div>
1062 </li>
1063 ))}
1064 </ul>
1065 ) : (
1066 <div class="empty">No workflow runs for this pull request.</div>
1067 )}
1068 </div>
1069 </div>,
1070 );
1071 }
1072
1073 // Files changed
1074 const diff = await stub.pullFiles(n);
1075 const threads = new Map<string, LineThread[]>();
1076 const byKey = new Map<string, Comment[]>();
1077 for (const cm of x.comments) {
1078 if (!cm.path || !cm.line) continue;
1079 const key = `${cm.path}\u0000${cm.side === "LEFT" ? "LEFT" : "RIGHT"}\u0000${cm.line}`;
1080 byKey.set(key, [...(byKey.get(key) ?? []), cm]);
1081 }
1082 for (const [key, list] of byKey) {
1083 const [path, side, line] = key.split("\u0000") as [string, "LEFT" | "RIGHT", string];
1084 threads.set(path, [...(threads.get(path) ?? []), { side, line: Number(line), node: <LineComments x={x} list={list} /> }]);
1085 }
1086 const canComment = c.get("principal").type === "account" && i.state === "open" && !repo.archived;
1087 const files = diff.files as DiffFile[];
1088 return page(
1089 <div>
1090 <div class="row between mb">
1091 <DiffStats files={diff.totalFiles} additions={diff.additions} deletions={diff.deletions} />
1092 <span class="muted small">
1093 {diff.mergeBase ? (
1094 <>
1095 base <span class="sha">{shortSha(diff.mergeBase)}</span> →{" "}
1096 </>
1097 ) : null}
1098 head <span class="sha">{shortSha(diff.headSha)}</span>
1099 </span>
1100 </div>
1101 {canComment ? (
1102 <template id="line-comment-tpl">
1103 <Form action={`${base}/pull/${n}/line-comment`} t={csrfToken(c)}>
1104 <input type="hidden" name="path" />
1105 <input type="hidden" name="line" />
1106 <input type="hidden" name="side" />
1107 <div class="small muted mb" style="margin-bottom:6px">
1108 Comment on <span class="where mono" />
1109 </div>
1110 <textarea name="body" required placeholder="Leave a comment on this line" style="min-height:80px"></textarea>
1111 <div class="row end" style="margin-top:6px">
1112 <button type="button" class="btn sm" data-cancel-line="1">
1113 Cancel
1114 </button>
1115 <button class="btn sm primary">Add comment</button>
1116 </div>
1117 </Form>
1118 </template>
1119 ) : null}
1120 <FileSummary files={files} />
1121 <DiffView files={files} opts={{ commentable: canComment, threads, viewHref: (p) => `${base}/blob/${diff.headSha}/${encPath(p)}` }} />
1122 {diff.truncated ? <div class="flash info">Only the first {files.length} files are shown.</div> : null}
1123 </div>,
1124 true,
1125 );
1126}
1127
1128// ---------------------------------------------------------------- routes
1129
1130export function registerCollab(web: WebApp): void {
1131 web.get("/:owner/:repo/issues", (c) => listPage(c, "issue"));
1132 web.get("/:owner/:repo/pulls", (c) => listPage(c, "pull"));
1133
1134 const NewIssue: FC<{ c: AppContext; l: Loaded; labels: LabelDef[]; v: { title: string; body: string; labels: string[] }; error?: string }> = ({ c, l, labels, v, error }) => (
1135 <div style="max-width:900px">
1136 <h1>New issue</h1>
1137 {error ? <div class="flash error">{error}</div> : null}
1138 <Form action={`${repoUrl(l.repo)}/issues`} t={csrfToken(c)} class="box">
1139 <div class="body">
1140 <div class="field">
1141 <label for="it">Title</label>
1142 <input id="it" name="title" type="text" required value={v.title} autofocus />
1143 </div>
1144 <div class="field">
1145 <label for="ib">Description</label>
1146 <textarea id="ib" name="body" style="min-height:220px" placeholder="Markdown is supported.">
1147 {v.body}
1148 </textarea>
1149 </div>
1150 {labels.length && atLeast(l.caller.role, "triage") ? (
1151 <div class="field">
1152 <label>Labels</label>
1153 <div class="row" style="gap:6px 16px">
1154 {labels.map((lb) => (
1155 <KCheckbox name="labels" value={lb.name} checked={v.labels.includes(lb.name)} label={<Label name={lb.name} color={lb.color} />} />
1156 ))}
1157 </div>
1158 </div>
1159 ) : null}
1160 <button class="btn green">Submit new issue</button>
1161 </div>
1162 </Form>
1163 </div>
1164 );
1165
1166 web.get("/:owner/:repo/issues/new", async (c) => {
1167 const l = await forAction(c);
1168 const labels = await l.stub.labels();
1169 return render(c, { title: `New issue · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "issues" } }, <NewIssue c={c} l={l} labels={labels} v={{ title: c.req.query("title") ?? "", body: "", labels: [] }} />);
1170 });
1171
1172 post(web, "/:owner/:repo/issues", async (c, f) => {
1173 const l = await forAction(c);
1174 const v = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), labels: atLeast(l.caller.role, "triage") ? f.all("labels") : [] };
1175 try {
1176 const issue = await l.stub.createIssue({ title: v.title, body: v.body, labels: v.labels }, l.caller);
1177 return c.redirect(`${repoUrl(l.repo)}/issues/${issue.number}`, 303);
1178 } catch (e) {
1179 const s = statusOf(e);
1180 if (s.status >= 500) throw e;
1181 const labels = await l.stub.labels();
1182 return render(c, { title: "New issue", repo: { repo: l.repo, caller: l.caller, tab: "issues" }, status: s.status }, <NewIssue c={c} l={l} labels={labels} v={v} error={s.message} />);
1183 }
1184 });
1185
1186 web.get("/:owner/:repo/issues/:n", (c) => issuePage(c));
1187 web.get("/:owner/:repo/pull/:n", (c) => pullPage(c, "conversation"));
1188 web.get("/:owner/:repo/pull/:n/commits", (c) => pullPage(c, "commits"));
1189 web.get("/:owner/:repo/pull/:n/files", (c) => pullPage(c, "files"));
1190 web.get("/:owner/:repo/pull/:n/checks", (c) => pullPage(c, "checks"));
1191 web.get("/:owner/:repo/pulls/:n", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/pull/${prm(c, "n")}`, 301));
1192
1193 const backTo = async (l: Loaded, n: number): Promise<string> => {
1194 const { issue } = await l.stub.issue(n);
1195 return `${repoUrl(l.repo)}/${kindPath(issue)}/${n}`;
1196 };
1197
1198 post(web, "/:owner/:repo/issues/:n/comment", async (c, f) => {
1199 const l = await forAction(c);
1200 const n = issueNumber(c);
1201 const back = await backTo(l, n);
1202 const body = f.s("body").replace(/\r\n/g, "\n");
1203 const action = f.s("action");
1204 return act(c, back, async () => {
1205 if (body.trim()) await l.stub.addComment(n, body, l.caller);
1206 else if (action === "comment") throw new HttpError(422, "write a comment first");
1207 if (action === "close" || action === "not_planned") await l.stub.updateIssue(n, { state: "closed", stateReason: action === "not_planned" ? "not_planned" : "completed" }, l.caller);
1208 if (action === "reopen") await l.stub.updateIssue(n, { state: "open" }, l.caller);
1209 });
1210 });
1211
1212 post(web, "/:owner/:repo/issues/:n/edit", async (c, f) => {
1213 const l = await forAction(c);
1214 const n = issueNumber(c);
1215 const back = await backTo(l, n);
1216 return act(c, back, async () => {
1217 const title = f.s("title").trim();
1218 if (!title) throw new HttpError(422, "title is required");
1219 await l.stub.updateIssue(n, { title, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller);
1220 });
1221 });
1222
1223 post(web, "/:owner/:repo/issues/:n/labels", async (c, f) => {
1224 const l = await forAction(c, "triage");
1225 const n = issueNumber(c);
1226 const back = await backTo(l, n);
1227 return act(c, back, async () => {
1228 const labels = new Set(f.all("labels").map((x) => x.trim()).filter(Boolean));
1229 const fresh = f.s("new_label").trim().slice(0, 50);
1230 if (fresh) {
1231 const existing = await l.stub.labels();
1232 if (!existing.some((x) => x.name === fresh)) await l.stub.setLabel(fresh, LABEL_COLORS[existing.length % LABEL_COLORS.length]!, "");
1233 labels.add(fresh);
1234 }
1235 await l.stub.updateIssue(n, { labels: [...labels] }, l.caller);
1236 });
1237 });
1238
1239 post(web, "/:owner/:repo/issues/:n/assignees", async (c, f) => {
1240 const l = await forAction(c, "triage");
1241 const n = issueNumber(c);
1242 const back = await backTo(l, n);
1243 return act(c, back, async () => {
1244 const assignees = [...new Set(f.s("assignees").split(/[\s,]+/).map((s) => s.replace(/^@/, "")).filter(Boolean))].slice(0, 10);
1245 await l.stub.updateIssue(n, { assignees }, l.caller);
1246 });
1247 });
1248
1249 post(web, "/:owner/:repo/pull/:n/review", async (c, f) => {
1250 const l = await forAction(c);
1251 const n = issueNumber(c);
1252 const event = (["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const).find((e) => e === f.s("event")) ?? "COMMENT";
1253 return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => {
1254 await l.stub.submitReview(n, { event, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller);
1255 });
1256 });
1257
1258 post(web, "/:owner/:repo/pull/:n/line-comment", async (c, f) => {
1259 const l = await forAction(c);
1260 const n = issueNumber(c);
1261 const line = parseInt(f.s("line"), 10);
1262 const side = f.s("side") === "LEFT" ? "LEFT" : "RIGHT";
1263 return act(c, `${repoUrl(l.repo)}/pull/${n}/files`, async () => {
1264 const body = f.s("body").replace(/\r\n/g, "\n");
1265 if (!body.trim()) throw new HttpError(422, "comment is empty");
1266 if (!Number.isInteger(line) || line <= 0 || !f.s("path")) throw new HttpError(422, "invalid line");
1267 await l.stub.submitReview(n, { event: "COMMENT", comments: [{ path: f.s("path"), line, side, body }] }, l.caller);
1268 });
1269 });
1270
1271 post(web, "/:owner/:repo/pull/:n/ready", async (c) => {
1272 const l = await forAction(c, "read");
1273 const n = issueNumber(c);
1274 return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => {
1275 await l.stub.setPullDraft(n, false, l.caller);
1276 }, "Pull request marked ready for review.");
1277 });
1278
1279 post(web, "/:owner/:repo/pull/:n/merge", async (c, f) => {
1280 const l = await forAction(c, "write");
1281 const n = issueNumber(c);
1282 const method = (["merge", "squash", "rebase"] as const).find((m) => m === f.s("method")) ?? "merge";
1283 return act(
1284 c,
1285 `${repoUrl(l.repo)}/pull/${n}`,
1286 async () => {
1287 await l.stub.mergePull(n, { method, deleteBranch: f.b("delete_branch"), expectedHeadSha: f.s("expected_head") || undefined, bypassMergeQueue: f.b("bypass_queue"), bypassRules: f.b("bypass_rules") }, l.caller);
1288 c.executionCtx.waitUntil(c.get("dir").touchPushed(l.repo.id));
1289 },
1290 "Pull request merged.",
1291 );
1292 });
1293
1294 post(web, "/:owner/:repo/pull/:n/enqueue", async (c, f) => {
1295 const l = await forAction(c, "write");
1296 const n = issueNumber(c);
1297 const method = (["merge", "squash", "rebase"] as const).find((m) => m === f.s("method"));
1298 return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => {
1299 const r = await l.stub.enqueuePull(n, { method, deleteBranch: f.b("delete_branch") }, l.caller);
1300 if (r.state === "removed") throw new HttpError(409, `the pull request left the merge queue: ${r.reason ?? "see the conversation"}`);
1301 flash(c, "notice", r.state === "merged" ? "Merged through the merge queue." : "Added to the merge queue.");
1302 });
1303 });
1304
1305 post(web, "/:owner/:repo/pull/:n/dequeue", async (c) => {
1306 const l = await forAction(c, "write");
1307 const n = issueNumber(c);
1308 return act(c, `${repoUrl(l.repo)}/pull/${n}`, () => l.stub.dequeuePull(n, l.caller), "Removed from the merge queue.");
1309 });
1310
1311 // Merge queue of a branch
1312 web.get("/:owner/:repo/queue/:branch{.+}", async (c) => {
1313 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1314 const base = repoUrl(repo);
1315 const branch = c.req.param("branch");
1316 const [settings, entries] = await Promise.all([stub.mergeQueueSettings(branch), stub.mergeQueue(branch)]);
1317 return render(
1318 c,
1319 { title: `Merge queue · ${branch}`, repo: { repo, caller, tab: "pulls" }, live: "repo" },
1320 <div>
1321 <h1>
1322 Merge queue <span class="mono muted">{branch}</span>
1323 </h1>
1324 <p class="muted">
1325 {settings
1326 ? `Pull requests merge into ${branch} in order once their merge group (the branch, everything ahead of them and their own changes) passes the required checks. Merge method: ${METHOD_LABELS[settings.method ?? "merge"].toLowerCase()}.`
1327 : `${branch} does not use a merge queue. Turn it on in the branch protection settings.`}
1328 </p>
1329 {entries.length === 0 ? (
1330 <div class="blank mb">The queue is empty.</div>
1331 ) : (
1332 <div class="box">
1333 <ul class="list">
1334 {entries.map((e) => (
1335 <li>
1336 <span class="muted mono">{e.position}</span>
1337 <Status status="in_progress" />
1338 <div class="grow">
1339 <a class="title" href={`${base}/pull/${e.number}`}>
1340 {e.title}
1341 </a>{" "}
1342 <span class="muted">#{e.number}</span>
1343 <div class="meta">
1344 queued by {e.enqueuedBy.login} <Time at={e.enqueuedAt} /> ·{" "}
1345 {e.groupSha ? (
1346 <>
1347 checking merge group <a class="sha" href={`${base}/commit/${e.groupSha}`}>{shortSha(e.groupSha)}</a>
1348 {e.checks.length ? ` · ${e.checks.map((ch) => `${ch.name}: ${ch.state}`).join(", ")}` : ""}
1349 </>
1350 ) : (
1351 "waiting to be built"
1352 )}
1353 </div>
1354 </div>
1355 </li>
1356 ))}
1357 </ul>
1358 </div>
1359 )}
1360 </div>,
1361 );
1362 });
1363
1364 // Labels
1365 web.get("/:owner/:repo/labels", async (c) => {
1366 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1367 const base = repoUrl(repo);
1368 const t = csrfToken(c);
1369 const labels = await stub.labels();
1370 const canEdit = atLeast(caller.role, "triage") && !repo.archived && c.get("principal").type === "account";
1371 return render(
1372 c,
1373 { title: `Labels · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "issues" } },
1374 <div style="max-width:900px">
1375 <h1>Labels</h1>
1376 <div class="box">
1377 {labels.length ? (
1378 <ul class="list">
1379 {labels.map((lb) => (
1380 <li style="align-items:center">
1381 <div style="width:220px">
1382 <Label name={lb.name} color={lb.color} href={`${base}/issues${qs({ label: lb.name })}`} />
1383 </div>
1384 <span class="grow muted small">{lb.description}</span>
1385 {canEdit ? (
1386 <Form action={`${base}/labels/delete`} t={t} class="inline" confirm={`Delete label ${lb.name}?`}>
1387 <input type="hidden" name="name" value={lb.name} />
1388 <button class="btn sm danger">Delete</button>
1389 </Form>
1390 ) : null}
1391 </li>
1392 ))}
1393 </ul>
1394 ) : (
1395 <div class="empty">No labels yet.</div>
1396 )}
1397 </div>
1398 {canEdit ? (
1399 <Form action={`${base}/labels`} t={t} class="box">
1400 <div class="head">
1401 <h3>New label</h3>
1402 </div>
1403 <div class="body row" style="align-items:flex-end">
1404 <div class="field m0">
1405 <label>Name</label>
1406 <input name="name" type="text" required maxlength={50} />
1407 </div>
1408 <div class="field grow m0">
1409 <label>Description</label>
1410 <input name="description" type="text" maxlength={100} />
1411 </div>
1412 <div class="field m0">
1413 <label>Color</label>
1414 <input name="color" type="color" value={`#${LABEL_COLORS[labels.length % LABEL_COLORS.length]}`} style="width:56px;height:36px;padding:2px" />
1415 </div>
1416 <button class="btn primary">Create label</button>
1417 </div>
1418 </Form>
1419 ) : null}
1420 </div>,
1421 );
1422 });
1423
1424 post(web, "/:owner/:repo/labels", async (c, f) => {
1425 const l = await forAction(c, "triage");
1426 return act(c, `${repoUrl(l.repo)}/labels`, async () => {
1427 const name = f.s("name").trim().slice(0, 50);
1428 const color = f.s("color").replace(/^#/, "");
1429 if (!name) throw new HttpError(422, "name is required");
1430 if (!/^[0-9a-fA-F]{6}$/.test(color)) throw new HttpError(422, "invalid color");
1431 await l.stub.setLabel(name, color, f.s("description").trim().slice(0, 100));
1432 });
1433 });
1434
1435 post(web, "/:owner/:repo/labels/delete", async (c, f) => {
1436 const l = await forAction(c, "triage");
1437 return act(c, `${repoUrl(l.repo)}/labels`, async () => l.stub.setLabel(f.s("name"), null));
1438 });
1439}
1440
1441const LABEL_COLORS = ["d73a4a", "0075ca", "a2eeef", "7057ff", "008672", "e4e669", "d876e3", "fbca04", "0e8a16", "5319e7", "b60205", "1d76db"];