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.

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