Public: anyone can read this; the crew can change it.

The /api/v1/repos/:owner/:name/issues routes move out of src/http/api.ts into src/http/api/issues.ts, unchanged #7453

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7450-the-api-v1-repos-owner-name-issues-route → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/7450-the-api-v1-repos-owner-name-issues-route was merged into main as a87e196.

Review

Sign in to review this change.
2 files changed +510 −469
src/http/api.ts +13 −469
@@ -35,8 +35,6 @@ import { askCheck } from "../checks/again.ts";
3535 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
3636 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
3737 import { changeLimit, listLimits } from "../actions/limits.ts";
38 import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts";
39 import { teamsOfIssue } from "../core/issue_teams.ts";
4038 import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
4139 import { readScorecards } from "../actions/scorecard.ts";
4240 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
@@ -79,9 +77,6 @@ import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/t
7977 import { markReaders } from "../crew/direct.ts";
8078 import {
8179 addCommentThrough,
82 openIssueThrough,
83 setIssueLabelsThrough,
84 setIssueSealed,
8580 setIssueStateThrough,
8681 } from "../actions/issues.ts";
8782 import {
@@ -125,7 +120,6 @@ import {
125120 deleteRepo,
126121 repositoryLimitMessage,
127122 resolveOwner,
128 privateRepoForSealed,
129123 updateRepoSettings,
130124 } from "../actions/repos.ts";
131125 import {
@@ -149,10 +143,6 @@ import {
149143 getIssue,
150144 labelsFor,
151145 labelsForIssues,
152 listComments,
153 countThread,
154 EMBEDDED_COMMENTS,
155 listIssues,
156146 LIST_LIMIT,
157147 LIST_LIMIT_MAX,
158148 pageLimit,
@@ -259,14 +249,8 @@ import {
259249 } from "../actions/crew.ts";
260250 import { assignIssue, editIssue, TAKEN_UP } from "../actions/issues.ts";
261251 import { steerRepoRun } from "../actions/runs.ts";
262 import {
263 dependencyWords,
264 linkIssue,
265 unlinkIssue,
266 confirmIssue,
267 } from "../actions/dependencies.ts";
268252 import { openPlan, parsePlan, proposePlan } from "../actions/plan.ts";
269 import { dependenciesOf, repoGraph } from "../core/dependencies.ts";
253 import { repoGraph } from "../core/dependencies.ts";
270254 import { mapHeldFor } from "../crew/held.ts";
271255 import { pillarView } from "../core/pillars.ts";
272256 import { askToLiveFor } from "../web/ask_to_live.ts";
@@ -297,7 +281,6 @@ import { isEnvName } from "../apps/data.ts";
297281 import { getAppByRepo } from "../core/apps.ts";
298282 import { crewRole, isCrew, type Agent } from "../core/agents.ts";
299283 import { listApps, type AppRecord } from "../core/apps.ts";
300 import { closingFrom } from "../actions/issues.ts";
301284 import {
302285 checkSummary,
303286 checksForHead,
@@ -381,6 +364,7 @@ import { agentRoutes } from "./api/agents.ts";
381364 import { boardRoutes } from "./api/boards.ts";
382365 import { followingRoutes } from "./api/following.ts";
383366 import { labelsRoutes } from "./api/labels.ts";
367 import { issueRoutes, labelsHandler } from "./api/issues.ts";
384368 import { userRoutes } from "./api/user.ts";
385369 import { appNoteRoutes } from "./api/app_notes.ts";
386370 import { reportRoutes } from "./api/reports.ts";
@@ -434,7 +418,7 @@ export async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> {
434418 }
435419
436420 /** The 401 a stranger gets where an account is needed but a report is not. */
437 function reportInstead(ctx: Ctx, repo: Repo): Response {
421 export function reportInstead(ctx: Ctx, repo: Repo): Response {
438422 const page = `${ctx.url.origin}/${repo.ownerName}/${repo.name}/report`;
439423 const api = `${ctx.url.origin}/api/v1/repos/${repo.ownerName}/${repo.name}/reports`;
440424 return json(
@@ -478,7 +462,7 @@ export function withLabels<T extends Issue>(ctx: Ctx, issue: T) {
478462 * by hand reads the same warning (#619). Only the repository's crew is
479463 * told: to anyone else the mark would say which commenters cannot write.
480464 */
481 function readerMarked<
465 export function readerMarked<
482466 T extends { readonly authorName: string; readonly originAuthor?: string | null },
483467 >(ctx: Ctx, repoId: string, items: readonly T[]): (T & { readonly untrusted?: string })[] {
484468 const viewer = ctx.auth.user;
@@ -493,7 +477,7 @@ function readerMarked<
493477 * queries for the whole page. Never `map(withLabels)` over a list — that is
494478 * the N+1 these helpers exist to remove.
495479 */
496 function withLabelsMany<T extends Issue>(ctx: Ctx, issues: T[]) {
480 export function withLabelsMany<T extends Issue>(ctx: Ctx, issues: T[]) {
497481 const ids = issues.map((i) => i.id);
498482 const labels = labelsForIssues(ctx.app.db, ids);
499483 const comments = commentCounts(ctx.app.db, ids);
@@ -513,24 +497,6 @@ function withLabelsMany<T extends Issue>(ctx: Ctx, issues: T[]) {
513497 }));
514498 }
515499
516 /** An issue for a listing: what to pick from, without bodies. */
517 function briefIssue(i: ReturnType<typeof withLabelsMany>[number]) {
518 return {
519 number: i.number,
520 kind: i.kind,
521 title: i.title,
522 state: i.state,
523 labels: i.labels,
524 assignee: i.assigneeName,
525 author: i.originAuthor ?? i.authorName,
526 originAuthor: i.originAuthor,
527 ...(i.untrusted === undefined ? {} : { untrusted: i.untrusted }),
528 authorIsAgent: i.authorIsAgent,
529 createdAt: i.createdAt,
530 updatedAt: i.updatedAt,
531 };
532 }
533
534500 const NO_COUNTS: RepoCounts = { openIssues: 0, openPulls: 0 };
535501
536502 /**
@@ -701,7 +667,7 @@ export function originAware(error: string, status = 422): Response {
701667 * The words a PATCH on an issue or a pull brings: `title` and `body`, each
702668 * a string when given. Anything else in those keys is a bad request.
703669 */
704 function textFrom(
670 export function textFrom(
705671 input: Record<string, unknown>,
706672 ): { title?: string; body?: string } | undefined {
707673 const out: { title?: string; body?: string } = {};
@@ -715,10 +681,10 @@ function textFrom(
715681 }
716682
717683 /** An edit refused because someone who directs the crew acted on the words. */
718 const takenUp = () => apiError("taken_up", 403, TAKEN_UP);
684 export const takenUp = () => apiError("taken_up", 403, TAKEN_UP);
719685
720686 /** A PATCH that changes nothing it knows is a request with nothing in it. */
721 const NOTHING_TO_CHANGE = () =>
687 export const NOTHING_TO_CHANGE = () =>
722688 apiError(
723689 "nothing_to_change",
724690 422,
@@ -888,7 +854,7 @@ export function contributeFailure(error: unknown): Response {
888854 // ---- query parsing ----
889855
890856 /** `?state=&label=&author=&limit=&cursor=` for issue and pull lists. */
891 function listQuery(ctx: Ctx) {
857 export function listQuery(ctx: Ctx) {
892858 const q = ctx.url.searchParams;
893859 const stateParam = q.get("state");
894860 const state: IssueState | "all" =
@@ -1055,7 +1021,7 @@ async function pullHeadOid(ctx: Ctx, repo: Repo, pull: Pull): Promise<string> {
10551021 const pullDirOf = (ctx: Ctx, repo: Repo, pull: Pull): Promise<string> =>
10561022 pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull);
10571023
1058 function positiveInt(raw: string | undefined): number | undefined {
1024 export function positiveInt(raw: string | undefined): number | undefined {
10591025 const n = Number(raw);
10601026 return Number.isInteger(n) && n > 0 ? n : undefined;
10611027 }
@@ -1087,10 +1053,10 @@ function dirOf(ctx: Ctx, repo: Repo): string {
10871053 return repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
10881054 }
10891055
1090 type Found<T> = ({ repo: Repo } & T) | { error: Response };
1056 export type Found<T> = ({ repo: Repo } & T) | { error: Response };
10911057
10921058 /** A readable repo's issue or pull by number — the shared conversation row. */
1093 function loadConversation(ctx: Ctx): Found<{ issue: Issue }> {
1059 export function loadConversation(ctx: Ctx): Found<{ issue: Issue }> {
10941060 const repo = loadRepo(ctx);
10951061 if (!repo) return { error: notFound() };
10961062 const number = positiveInt(ctx.params.number);
@@ -1102,12 +1068,6 @@ function loadConversation(ctx: Ctx): Found<{ issue: Issue }> {
11021068 return { repo, issue };
11031069 }
11041070
1105 function loadIssue(ctx: Ctx): Found<{ issue: Issue }> {
1106 const found = loadConversation(ctx);
1107 if ("error" in found) return found;
1108 return found.issue.kind === "issue" ? found : { error: notFound() };
1109 }
1110
11111071 /** A hold as the API says it: who, why and since when. */
11121072 const holdJson = (hold: Hold) => ({
11131073 by: hold.heldByName,
@@ -2380,290 +2340,21 @@ export function apiRoutes(router: Router<Handler>): void {
23802340 // ── labels ───────────────────────────────────────────────────────────
23812341
23822342 labelsRoutes(router);
2383
2384 // Replace the label set of an issue or a pull — both live in one table, so
2385 // either number works here. Names or ids; an unknown label rejects the set.
2386 const labelsHandler = async (ctx: Ctx): Promise<Response> => {
2387 const denied = requireScope(ctx, "rw");
2388 if (denied) return denied;
2389 const found = loadConversation(ctx);
2390 if ("error" in found) return found.error;
2391 const input = await body(ctx.request);
2392 const labels = Array.isArray(input.labels)
2393 ? input.labels.filter((l): l is string => typeof l === "string")
2394 : [];
2395 const set = await setIssueLabelsThrough(
2396 ctx.app,
2397 ctx.auth.user,
2398 found.repo,
2399 found.issue,
2400 labels,
2401 );
2402 if (!set.ok) return originAware(set.error);
2403 return json(set.value);
2404 };
2405 router.add(
2406 "PUT",
2407 "/api/v1/repos/:owner/:name/issues/:number/labels",
2408 labelsHandler,
2409 );
24102343 router.add(
24112344 "PUT",
24122345 "/api/v1/repos/:owner/:name/pulls/:number/labels",
24132346 labelsHandler,
24142347 );
24152348
2416 // Name up to two crew teams on an issue: the pull that closes it is
2417 // reviewed by each (`core/issue_teams.ts`).
2418 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/teams", (ctx) => {
2419 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2420 const found = loadIssue(ctx);
2421 if ("error" in found) return found.error;
2422 const teams = timePhase(ctx.request, "teams", () => teamsOfIssue(ctx.app.db, found.issue.id));
2423 return timePhase(ctx.request, "render", () => json({ teams }));
2424 });
2425 router.add("PUT", "/api/v1/repos/:owner/:name/issues/:number/teams", async (ctx) => {
2426 const denied = requireScope(ctx, "rw");
2427 if (denied) return denied;
2428 const found = loadIssue(ctx);
2429 if ("error" in found) return found.error;
2430 const input = await body(ctx.request);
2431 const slugs = Array.isArray(input.teams) ? input.teams : undefined;
2432 if (slugs === undefined || !slugs.every((t): t is string => typeof t === "string"))
2433 return json({ error: "teams must be a list of team slugs" }, 400);
2434 const set = nameTeams(ctx.app, ctx.auth.user, found.repo, found.issue, slugs);
2435 if (!set.ok)
2436 return set.error === "too_many" || set.error === "no_such_team"
2437 ? apiError(set.error, 422, ISSUE_TEAMS_SAY[set.error]!)
2438 : originAware(set.error);
2439 return json({ teams: set.value });
2440 });
2441
24422349 // ── issues — the label-as-queue surface automation drives ────────────
24432350
2444 router.add("GET", "/api/v1/repos/:owner/:name/issues", (ctx) => {
2445 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2446 const repo = loadRepo(ctx);
2447 if (!repo) return notFound();
2448 const fields = ctx.url.searchParams.get("fields");
2449 if (fields !== null && fields !== "brief")
2450 return apiError("invalid_fields", 422, "fields must be brief, or left out");
2451 const query = listQuery(ctx);
2452 const listed = timePhase(ctx.request, "list", () =>
2453 listIssues(ctx.app.db, repo.id, {
2454 ...query,
2455 kind: "issue",
2456 ...sealedScope(ctx.app.db, repo, ctx.auth.user),
2457 }),
2458 );
2459 const items = timePhase(ctx.request, "labels", () => withLabelsMany(ctx, listed));
2460 return timePhase(ctx.request, "render", () => {
2461 const page = paged(items, query.limit, (i) => i.number);
2462 return json(
2463 fields === "brief" ? { ...page, items: page.items.map(briefIssue) } : page,
2464 );
2465 });
2466 });
2467
2468 router.add("POST", "/api/v1/repos/:owner/:name/issues", async (ctx) => {
2469 // A stranger on a public repository is told the door that is open to
2470 // them, in the body and as a link relation, not only that this one is shut.
2471 if (ctx.auth.user === undefined) {
2472 const repo = loadRepo(ctx);
2473 if (repo !== undefined && canRead(ctx.app.db, repo, undefined))
2474 return reportInstead(ctx, repo);
2475 }
2476 const denied = requireScope(ctx, "rw");
2477 if (denied) return denied;
2478 const repo = loadRepo(ctx);
2479 if (!repo) return notFound();
2480 const input = await body(ctx.request);
2481 const created = await openIssueThrough(ctx.app, ctx.auth.user, repo, {
2482 title: str(input.title),
2483 body: str(input.body),
2484 ...(input.sealed === true ? { sealed: true } : {}),
2485 });
2486 if (!created.ok) return originAware(created.error);
2487 return json(withLabels(ctx, created.value), 201);
2488 });
2351 issueRoutes(router);
24892352
24902353 reportRoutes(router);
24912354 appNoteRoutes(router);
24922355 rollbackRoutes(router);
24932356 takeBackRoutes(router);
24942357
2495 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => {
2496 // Numbers are shared with pulls: a pull's number answers as the same
2497 // shape, marked `kind: "pull"`, with the path where its diff lives (#3299).
2498 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2499 const found = timePhase(ctx.request, "issue", () => loadConversation(ctx));
2500 if ("error" in found) return found.error;
2501 const { repo, issue } = found;
2502 // The newest 50 comments, oldest first, and a cursor for the older rest
2503 // (#6198); comments=N (0 to 200) asks for another count (#4097).
2504 const asked = ctx.url.searchParams.get("comments");
2505 let tail = EMBEDDED_COMMENTS;
2506 if (asked !== null) {
2507 if (!/^\d{1,3}$/.test(asked) || Number(asked) > 200)
2508 return apiError("invalid_comments", 422, "comments must be a whole number from 0 to 200");
2509 tail = Number(asked);
2510 }
2511 const { total, shown } = timePhase(ctx.request, "comments", () => {
2512 const rows =
2513 tail === 0
2514 ? []
2515 : listComments(ctx.app.db, found.issue.id, { newest: true, limit: tail });
2516 return {
2517 total: countThread(ctx.app.db, found.issue.id),
2518 shown: readerMarked(ctx, found.repo.id, rows),
2519 };
2520 });
2521 const labelled = timePhase(ctx.request, "labels", () => withLabels(ctx, issue));
2522 return timePhase(ctx.request, "render", () =>
2523 json({
2524 ...labelled,
2525 ...(issue.kind === "pull"
2526 ? {
2527 pull: `/api/v1/repos/${repo.ownerName}/${repo.name}/pulls/${issue.number}`,
2528 }
2529 : {}),
2530 comments: shown,
2531 commentsTotal: total,
2532 commentsNext: shown.length > 0 && shown.length < total ? shown[0]!.id : null,
2533 ...(asked === null ? {} : { commentCount: total }),
2534 }),
2535 );
2536 });
2537
2538 // A thread, a page at a time: oldest first, `next` is the `after` of the
2539 // following page (#4150). A pull's number answers the same.
2540 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/comments", (ctx) => {
2541 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2542 const found = loadConversation(ctx);
2543 if ("error" in found) return found.error;
2544 const rawLimit = ctx.url.searchParams.get("limit");
2545 const limit = rawLimit === null ? 50 : /^\d{1,4}$/.test(rawLimit) ? Number(rawLimit) : 0;
2546 if (limit < 1 || limit > 100)
2547 return apiError("invalid_limit", 422, "limit is a whole number from 1 to 100");
2548 const after = ctx.url.searchParams.get("after");
2549 if (after !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(after))
2550 return apiError("invalid_after", 422, "after is a comment id");
2551 // `cursor` is what an issue's `commentsNext` hands over: the page just
2552 // older than that comment, still oldest first, and `next` the cursor of
2553 // the page older again (null at the first comment) (#6198).
2554 const cursor = ctx.url.searchParams.get("cursor");
2555 if (cursor !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(cursor))
2556 return apiError("invalid_cursor", 422, "cursor is a comment id from commentsNext");
2557 if (cursor !== null && after !== null)
2558 return apiError("invalid_cursor", 422, "cursor and after are two ways to page: send one");
2559 if (cursor !== null) {
2560 const older = timePhase(ctx.request, "comments", () =>
2561 listComments(ctx.app.db, found.issue.id, { before: cursor, newest: true, limit: limit + 1 }),
2562 );
2563 const page = older.slice(older.length - Math.min(limit, older.length));
2564 return timePhase(ctx.request, "render", () =>
2565 json({
2566 comments: readerMarked(ctx, found.repo.id, page),
2567 next: older.length > limit ? page[0]!.id : null,
2568 }),
2569 );
2570 }
2571 const { rows, page } = timePhase(ctx.request, "comments", () => {
2572 const read = listComments(ctx.app.db, found.issue.id, {
2573 limit: limit + 1,
2574 ...(after === null ? {} : { after }),
2575 });
2576 return { rows: read, page: read.slice(0, limit) };
2577 });
2578 return timePhase(ctx.request, "render", () =>
2579 json({
2580 comments: readerMarked(ctx, found.repo.id, page),
2581 next: rows.length > limit ? page[page.length - 1]!.id : null,
2582 }),
2583 );
2584 });
2585
2586 // What an issue waits for, and what waits for it.
2587 // The private repository a writer fixes a sealed report in by hand (#865).
2588 router.add("POST", "/api/v1/repos/:owner/:name/issues/:number/private", async (ctx) => {
2589 const denied = requireScope(ctx, "rw");
2590 if (denied) return denied;
2591 const found = loadIssue(ctx);
2592 if ("error" in found) return found.error;
2593 const made = await privateRepoForSealed(ctx.app, ctx.auth.user, found.repo, found.issue);
2594 if (!made.ok)
2595 return made.error === "not_found"
2596 ? notFound()
2597 : respondAccess(made.error, 422);
2598 const there = `${made.value.ownerName}/${made.value.name}`;
2599 return json({ repo: there, clone: `${ctx.url.origin}/${there}.git` }, 201);
2600 });
2601
2602 router.add(
2603 "GET",
2604 "/api/v1/repos/:owner/:name/issues/:number/dependencies",
2605 (ctx) => {
2606 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2607 const found = loadIssue(ctx);
2608 if ("error" in found) return found.error;
2609 const deps = timePhase(ctx.request, "dependencies", () =>
2610 dependenciesOf(ctx.app.db, found.issue.id),
2611 );
2612 const seen = (i: Issue) =>
2613 mayView(ctx.app.db, found.repo, i, ctx.auth.user);
2614 const brief = (i: Issue) => ({
2615 number: i.number,
2616 title: i.title,
2617 state: i.state,
2618 ...(i.closeReason === null ? {} : { closeReason: i.closeReason }),
2619 });
2620 return timePhase(ctx.request, "render", () =>
2621 json({
2622 blockedBy: deps.blockedBy.filter(seen).map(brief),
2623 blocks: deps.blocks.filter(seen).map(brief),
2624 }),
2625 );
2626 },
2627 );
2628 router.add(
2629 "POST",
2630 "/api/v1/repos/:owner/:name/issues/:number/dependencies",
2631 async (ctx) => {
2632 const denied = requireScope(ctx, "rw");
2633 if (denied) return denied;
2634 const found = loadIssue(ctx);
2635 if ("error" in found) return found.error;
2636 const input = await body(ctx.request);
2637 const number = Number(input.blockedBy);
2638 const blocker =
2639 Number.isInteger(number) && number > 0
2640 ? getIssue(ctx.app.db, found.repo.id, number)
2641 : undefined;
2642 if (!blocker)
2643 return apiError(
2644 "no_such_issue",
2645 422,
2646 `no issue #${String(input.blockedBy)}`,
2647 );
2648 const linked = linkIssue(
2649 ctx.app,
2650 ctx.auth.user,
2651 found.repo,
2652 found.issue,
2653 blocker,
2654 );
2655 if (!linked.ok)
2656 return ["self", "cycle", "cross_repo", "not_an_issue"].includes(
2657 linked.error,
2658 )
2659 ? apiError(linked.error, 422, dependencyWords[linked.error] ?? "")
2660 : respondAccess(linked.error, 422);
2661 return json(
2662 { issue: found.issue.number, blockedBy: blocker.number },
2663 201,
2664 );
2665 },
2666 );
26672358 // Which stage of the one flow each open issue is in, and what went live in
26682359 // the last day (src/flow).
26692360 router.add("GET", "/api/v1/repos/:owner/:name/flow", (ctx) => {
@@ -2707,58 +2398,6 @@ export function apiRoutes(router: Router<Handler>): void {
27072398 if (!planned.ok) return failed(planned.error);
27082399 return json(planned.value, 201);
27092400 });
2710 router.add(
2711 "DELETE",
2712 "/api/v1/repos/:owner/:name/issues/:number/dependencies/:blocker",
2713 (ctx) => {
2714 const denied = requireScope(ctx, "rw");
2715 if (denied) return denied;
2716 const found = loadIssue(ctx);
2717 if ("error" in found) return found.error;
2718 const number = positiveInt(ctx.params.blocker);
2719 const blocker =
2720 number === undefined
2721 ? undefined
2722 : getIssue(ctx.app.db, found.repo.id, number);
2723 if (!blocker) return notFound();
2724 const unlinked = unlinkIssue(
2725 ctx.app,
2726 ctx.auth.user,
2727 found.repo,
2728 found.issue,
2729 blocker,
2730 );
2731 if (!unlinked.ok) return respondAccess(unlinked.error, 422);
2732 return new Response(null, { status: 204 });
2733 },
2734 );
2735 // A person confirms an edge whose blocker closed dropped or split: the
2736 // plan still holds, so the dependent is no longer parked on it.
2737 router.add(
2738 "POST",
2739 "/api/v1/repos/:owner/:name/issues/:number/dependencies/:blocker/confirm",
2740 (ctx) => {
2741 const denied = requireScope(ctx, "rw");
2742 if (denied) return denied;
2743 const found = loadIssue(ctx);
2744 if ("error" in found) return found.error;
2745 const number = positiveInt(ctx.params.blocker);
2746 const blocker =
2747 number === undefined
2748 ? undefined
2749 : getIssue(ctx.app.db, found.repo.id, number);
2750 if (!blocker) return notFound();
2751 const confirmed = confirmIssue(
2752 ctx.app,
2753 ctx.auth.user,
2754 found.repo,
2755 found.issue,
2756 blocker,
2757 );
2758 if (!confirmed.ok) return respondAccess(confirmed.error, 422);
2759 return new Response(null, { status: 204 });
2760 },
2761 );
27622401 // Milestones as data: the plan's limbs with their weights, and one
27632402 // milestone opened. The same numbers as the pages.
27642403 router.add("GET", "/api/v1/repos/:owner/:name/milestones", async (ctx) => {
@@ -3039,101 +2678,6 @@ export function apiRoutes(router: Router<Handler>): void {
30392678 return timePhase(ctx.request, "render", () => json(read));
30402679 });
30412680
3042 router.add(
3043 "PATCH",
3044 "/api/v1/repos/:owner/:name/issues/:number",
3045 async (ctx) => {
3046 const denied = requireScope(ctx, "rw");
3047 if (denied) return denied;
3048 const found = loadIssue(ctx);
3049 if ("error" in found) return found.error;
3050 const input = await body(ctx.request);
3051 // Three acts a PATCH may carry, each its own authorization and each
3052 // alone a whole request: sealing (a writer's), new words, a new state.
3053 let issue = found.issue;
3054 if (typeof input.sealed === "boolean") {
3055 const sealed = setIssueSealed(
3056 ctx.app,
3057 ctx.auth.user,
3058 found.repo,
3059 issue,
3060 input.sealed,
3061 );
3062 if (!sealed.ok) return originAware(sealed.error, 409);
3063 issue = sealed.value;
3064 }
3065 const text = textFrom(input);
3066 if (text === undefined) return json({ error: "invalid_text" }, 422);
3067 const wanted = input.state === undefined ? undefined : str(input.state);
3068 if (wanted !== undefined && wanted !== "open" && wanted !== "closed")
3069 return json({ error: "invalid_state" }, 422);
3070 if (
3071 wanted === undefined &&
3072 text.title === undefined &&
3073 text.body === undefined
3074 )
3075 return typeof input.sealed === "boolean"
3076 ? json(withLabels(ctx, issue))
3077 : NOTHING_TO_CHANGE();
3078 if (text.title !== undefined || text.body !== undefined) {
3079 const edited = editIssue(
3080 ctx.app,
3081 ctx.auth.user,
3082 found.repo,
3083 issue,
3084 text,
3085 );
3086 if (!edited.ok)
3087 return edited.error === "taken_up"
3088 ? takenUp()
3089 : originAware(edited.error, 422);
3090 issue = edited.value;
3091 }
3092 if (wanted !== undefined) {
3093 const closing = closingFrom(input);
3094 if (closing === undefined)
3095 return json({ error: "invalid_reason" }, 422);
3096 const changed = await setIssueStateThrough(
3097 ctx.app,
3098 ctx.auth.user,
3099 found.repo,
3100 issue,
3101 wanted,
3102 closing,
3103 );
3104 if (!changed.ok) return originAware(changed.error, 409);
3105 issue = changed.value;
3106 }
3107 return json(withLabels(ctx, issue));
3108 },
3109 );
3110
3111 router.add(
3112 "POST",
3113 "/api/v1/repos/:owner/:name/issues/:number/comments",
3114 async (ctx) => {
3115 const denied = requireScope(ctx, "rw");
3116 if (denied) return denied;
3117 // A pull's number answers here too, as the OpenAPI document says.
3118 const issueFound = loadIssue(ctx);
3119 const found =
3120 "error" in issueFound
3121 ? loadPull(ctx)
3122 : { repo: issueFound.repo, thread: issueFound.issue };
3123 if ("error" in found) return found.error;
3124 const input = await body(ctx.request);
3125 const added = await addCommentThrough(
3126 ctx.app,
3127 ctx.auth.user,
3128 found.repo,
3129 "thread" in found ? found.thread : found.pull,
3130 str(input.body),
3131 );
3132 if (!added.ok) return originAware(added.error);
3133 return json(added.value, 201);
3134 },
3135 );
3136
31372681 // ── pull requests — so automation drives the review/merge loop ───────
31382682
31392683 router.add("GET", "/api/v1/repos/:owner/:name/pulls", async (ctx) => {
src/http/api/issues.ts added +497
@@ -0,0 +1,497 @@
1 /**
2 * An issue over the API: `GET` and `POST` `/api/v1/repos/:owner/:name/issues`,
3 * one issue read and changed at `…/issues/:number`, its thread, labels,
4 * teams, private fix repository and dependencies. Moved out of `api.ts`
5 * (#7450); the rules live in `actions/issues.ts` and its neighbours.
6 */
7
8 import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
9 import { confirmIssue, dependencyWords, linkIssue, unlinkIssue } from "../../actions/dependencies.ts";
10 import {
11 addCommentThrough,
12 closingFrom,
13 editIssue,
14 openIssueThrough,
15 setIssueLabelsThrough,
16 setIssueSealed,
17 setIssueStateThrough,
18 } from "../../actions/issues.ts";
19 import { privateRepoForSealed } from "../../actions/repos.ts";
20 import { dependenciesOf } from "../../core/dependencies.ts";
21 import { teamsOfIssue } from "../../core/issue_teams.ts";
22 import {
23 countThread,
24 EMBEDDED_COMMENTS,
25 getIssue,
26 listComments,
27 listIssues,
28 type Issue,
29 } from "../../core/issues.ts";
30 import { canRead } from "../../core/repos.ts";
31 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
32 import { mayView, sealedScope } from "../../core/sealed.ts";
33 import {
34 listQuery,
35 loadConversation,
36 loadPull,
37 loadRepo,
38 NOTHING_TO_CHANGE,
39 originAware,
40 positiveInt,
41 readerMarked,
42 reportInstead,
43 takenUp,
44 textFrom,
45 withLabels,
46 withLabelsMany,
47 type Found,
48 } from "../api.ts";
49 import type { Ctx, Handler } from "../context.ts";
50 import { apiError, body, json, notFound, paged, requireScope, respondAccess, str } from "../respond.ts";
51 import type { Router } from "../router.ts";
52
53 /** An issue for a listing: what to pick from, without bodies. */
54 function briefIssue(i: ReturnType<typeof withLabelsMany>[number]) {
55 return {
56 number: i.number,
57 kind: i.kind,
58 title: i.title,
59 state: i.state,
60 labels: i.labels,
61 assignee: i.assigneeName,
62 author: i.originAuthor ?? i.authorName,
63 originAuthor: i.originAuthor,
64 ...(i.untrusted === undefined ? {} : { untrusted: i.untrusted }),
65 authorIsAgent: i.authorIsAgent,
66 createdAt: i.createdAt,
67 updatedAt: i.updatedAt,
68 };
69 }
70
71 function loadIssue(ctx: Ctx): Found<{ issue: Issue }> {
72 const found = loadConversation(ctx);
73 if ("error" in found) return found;
74 return found.issue.kind === "issue" ? found : { error: notFound() };
75 }
76
77 // Replace the label set of an issue or a pull — both live in one table, so
78 // either number works here. Names or ids; an unknown label rejects the set.
79 export async function labelsHandler(ctx: Ctx): Promise<Response> {
80 const denied = requireScope(ctx, "rw");
81 if (denied) return denied;
82 const found = loadConversation(ctx);
83 if ("error" in found) return found.error;
84 const input = await body(ctx.request);
85 const labels = Array.isArray(input.labels)
86 ? input.labels.filter((l): l is string => typeof l === "string")
87 : [];
88 const set = await setIssueLabelsThrough(
89 ctx.app,
90 ctx.auth.user,
91 found.repo,
92 found.issue,
93 labels,
94 );
95 if (!set.ok) return originAware(set.error);
96 return json(set.value);
97 }
98
99 export function issueRoutes(router: Router<Handler>): void {
100 router.add(
101 "PUT",
102 "/api/v1/repos/:owner/:name/issues/:number/labels",
103 labelsHandler,
104 );
105
106 // Name up to two crew teams on an issue: the pull that closes it is
107 // reviewed by each (`core/issue_teams.ts`).
108 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/teams", (ctx) => {
109 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
110 const found = loadIssue(ctx);
111 if ("error" in found) return found.error;
112 const teams = timePhase(ctx.request, "teams", () => teamsOfIssue(ctx.app.db, found.issue.id));
113 return timePhase(ctx.request, "render", () => json({ teams }));
114 });
115 router.add("PUT", "/api/v1/repos/:owner/:name/issues/:number/teams", async (ctx) => {
116 const denied = requireScope(ctx, "rw");
117 if (denied) return denied;
118 const found = loadIssue(ctx);
119 if ("error" in found) return found.error;
120 const input = await body(ctx.request);
121 const slugs = Array.isArray(input.teams) ? input.teams : undefined;
122 if (slugs === undefined || !slugs.every((t): t is string => typeof t === "string"))
123 return json({ error: "teams must be a list of team slugs" }, 400);
124 const set = nameTeams(ctx.app, ctx.auth.user, found.repo, found.issue, slugs);
125 if (!set.ok)
126 return set.error === "too_many" || set.error === "no_such_team"
127 ? apiError(set.error, 422, ISSUE_TEAMS_SAY[set.error]!)
128 : originAware(set.error);
129 return json({ teams: set.value });
130 });
131
132 router.add("GET", "/api/v1/repos/:owner/:name/issues", (ctx) => {
133 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
134 const repo = loadRepo(ctx);
135 if (!repo) return notFound();
136 const fields = ctx.url.searchParams.get("fields");
137 if (fields !== null && fields !== "brief")
138 return apiError("invalid_fields", 422, "fields must be brief, or left out");
139 const query = listQuery(ctx);
140 const listed = timePhase(ctx.request, "list", () =>
141 listIssues(ctx.app.db, repo.id, {
142 ...query,
143 kind: "issue",
144 ...sealedScope(ctx.app.db, repo, ctx.auth.user),
145 }),
146 );
147 const items = timePhase(ctx.request, "labels", () => withLabelsMany(ctx, listed));
148 return timePhase(ctx.request, "render", () => {
149 const page = paged(items, query.limit, (i) => i.number);
150 return json(
151 fields === "brief" ? { ...page, items: page.items.map(briefIssue) } : page,
152 );
153 });
154 });
155
156 router.add("POST", "/api/v1/repos/:owner/:name/issues", async (ctx) => {
157 // A stranger on a public repository is told the door that is open to
158 // them, in the body and as a link relation, not only that this one is shut.
159 if (ctx.auth.user === undefined) {
160 const repo = loadRepo(ctx);
161 if (repo !== undefined && canRead(ctx.app.db, repo, undefined))
162 return reportInstead(ctx, repo);
163 }
164 const denied = requireScope(ctx, "rw");
165 if (denied) return denied;
166 const repo = loadRepo(ctx);
167 if (!repo) return notFound();
168 const input = await body(ctx.request);
169 const created = await openIssueThrough(ctx.app, ctx.auth.user, repo, {
170 title: str(input.title),
171 body: str(input.body),
172 ...(input.sealed === true ? { sealed: true } : {}),
173 });
174 if (!created.ok) return originAware(created.error);
175 return json(withLabels(ctx, created.value), 201);
176 });
177
178 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => {
179 // Numbers are shared with pulls: a pull's number answers as the same
180 // shape, marked `kind: "pull"`, with the path where its diff lives (#3299).
181 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
182 const found = timePhase(ctx.request, "issue", () => loadConversation(ctx));
183 if ("error" in found) return found.error;
184 const { repo, issue } = found;
185 // The newest 50 comments, oldest first, and a cursor for the older rest
186 // (#6198); comments=N (0 to 200) asks for another count (#4097).
187 const asked = ctx.url.searchParams.get("comments");
188 let tail = EMBEDDED_COMMENTS;
189 if (asked !== null) {
190 if (!/^\d{1,3}$/.test(asked) || Number(asked) > 200)
191 return apiError("invalid_comments", 422, "comments must be a whole number from 0 to 200");
192 tail = Number(asked);
193 }
194 const { total, shown } = timePhase(ctx.request, "comments", () => {
195 const rows =
196 tail === 0
197 ? []
198 : listComments(ctx.app.db, found.issue.id, { newest: true, limit: tail });
199 return {
200 total: countThread(ctx.app.db, found.issue.id),
201 shown: readerMarked(ctx, found.repo.id, rows),
202 };
203 });
204 const labelled = timePhase(ctx.request, "labels", () => withLabels(ctx, issue));
205 return timePhase(ctx.request, "render", () =>
206 json({
207 ...labelled,
208 ...(issue.kind === "pull"
209 ? {
210 pull: `/api/v1/repos/${repo.ownerName}/${repo.name}/pulls/${issue.number}`,
211 }
212 : {}),
213 comments: shown,
214 commentsTotal: total,
215 commentsNext: shown.length > 0 && shown.length < total ? shown[0]!.id : null,
216 ...(asked === null ? {} : { commentCount: total }),
217 }),
218 );
219 });
220
221 // A thread, a page at a time: oldest first, `next` is the `after` of the
222 // following page (#4150). A pull's number answers the same.
223 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/comments", (ctx) => {
224 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
225 const found = loadConversation(ctx);
226 if ("error" in found) return found.error;
227 const rawLimit = ctx.url.searchParams.get("limit");
228 const limit = rawLimit === null ? 50 : /^\d{1,4}$/.test(rawLimit) ? Number(rawLimit) : 0;
229 if (limit < 1 || limit > 100)
230 return apiError("invalid_limit", 422, "limit is a whole number from 1 to 100");
231 const after = ctx.url.searchParams.get("after");
232 if (after !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(after))
233 return apiError("invalid_after", 422, "after is a comment id");
234 // `cursor` is what an issue's `commentsNext` hands over: the page just
235 // older than that comment, still oldest first, and `next` the cursor of
236 // the page older again (null at the first comment) (#6198).
237 const cursor = ctx.url.searchParams.get("cursor");
238 if (cursor !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(cursor))
239 return apiError("invalid_cursor", 422, "cursor is a comment id from commentsNext");
240 if (cursor !== null && after !== null)
241 return apiError("invalid_cursor", 422, "cursor and after are two ways to page: send one");
242 if (cursor !== null) {
243 const older = timePhase(ctx.request, "comments", () =>
244 listComments(ctx.app.db, found.issue.id, { before: cursor, newest: true, limit: limit + 1 }),
245 );
246 const page = older.slice(older.length - Math.min(limit, older.length));
247 return timePhase(ctx.request, "render", () =>
248 json({
249 comments: readerMarked(ctx, found.repo.id, page),
250 next: older.length > limit ? page[0]!.id : null,
251 }),
252 );
253 }
254 const { rows, page } = timePhase(ctx.request, "comments", () => {
255 const read = listComments(ctx.app.db, found.issue.id, {
256 limit: limit + 1,
257 ...(after === null ? {} : { after }),
258 });
259 return { rows: read, page: read.slice(0, limit) };
260 });
261 return timePhase(ctx.request, "render", () =>
262 json({
263 comments: readerMarked(ctx, found.repo.id, page),
264 next: rows.length > limit ? page[page.length - 1]!.id : null,
265 }),
266 );
267 });
268
269 // What an issue waits for, and what waits for it.
270 // The private repository a writer fixes a sealed report in by hand (#865).
271 router.add("POST", "/api/v1/repos/:owner/:name/issues/:number/private", async (ctx) => {
272 const denied = requireScope(ctx, "rw");
273 if (denied) return denied;
274 const found = loadIssue(ctx);
275 if ("error" in found) return found.error;
276 const made = await privateRepoForSealed(ctx.app, ctx.auth.user, found.repo, found.issue);
277 if (!made.ok)

Not blocking, and it was already like this before the move: this line describes the dependencies GET below, not /private. Move it down to the dependencies route the next time this file is touched.

278 return made.error === "not_found"
279 ? notFound()
280 : respondAccess(made.error, 422);
281 const there = `${made.value.ownerName}/${made.value.name}`;
282 return json({ repo: there, clone: `${ctx.url.origin}/${there}.git` }, 201);
283 });
284
285 router.add(
286 "GET",
287 "/api/v1/repos/:owner/:name/issues/:number/dependencies",
288 (ctx) => {
289 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
290 const found = loadIssue(ctx);
291 if ("error" in found) return found.error;
292 const deps = timePhase(ctx.request, "dependencies", () =>
293 dependenciesOf(ctx.app.db, found.issue.id),
294 );
295 const seen = (i: Issue) =>
296 mayView(ctx.app.db, found.repo, i, ctx.auth.user);
297 const brief = (i: Issue) => ({
298 number: i.number,
299 title: i.title,
300 state: i.state,
301 ...(i.closeReason === null ? {} : { closeReason: i.closeReason }),
302 });
303 return timePhase(ctx.request, "render", () =>
304 json({
305 blockedBy: deps.blockedBy.filter(seen).map(brief),
306 blocks: deps.blocks.filter(seen).map(brief),
307 }),
308 );
309 },
310 );
311 router.add(
312 "POST",
313 "/api/v1/repos/:owner/:name/issues/:number/dependencies",
314 async (ctx) => {
315 const denied = requireScope(ctx, "rw");
316 if (denied) return denied;
317 const found = loadIssue(ctx);
318 if ("error" in found) return found.error;
319 const input = await body(ctx.request);
320 const number = Number(input.blockedBy);
321 const blocker =
322 Number.isInteger(number) && number > 0
323 ? getIssue(ctx.app.db, found.repo.id, number)
324 : undefined;
325 if (!blocker)
326 return apiError(
327 "no_such_issue",
328 422,
329 `no issue #${String(input.blockedBy)}`,
330 );
331 const linked = linkIssue(
332 ctx.app,
333 ctx.auth.user,
334 found.repo,
335 found.issue,
336 blocker,
337 );
338 if (!linked.ok)
339 return ["self", "cycle", "cross_repo", "not_an_issue"].includes(
340 linked.error,
341 )
342 ? apiError(linked.error, 422, dependencyWords[linked.error] ?? "")
343 : respondAccess(linked.error, 422);
344 return json(
345 { issue: found.issue.number, blockedBy: blocker.number },
346 201,
347 );
348 },
349 );
350 router.add(
351 "DELETE",
352 "/api/v1/repos/:owner/:name/issues/:number/dependencies/:blocker",
353 (ctx) => {
354 const denied = requireScope(ctx, "rw");
355 if (denied) return denied;
356 const found = loadIssue(ctx);
357 if ("error" in found) return found.error;
358 const number = positiveInt(ctx.params.blocker);
359 const blocker =
360 number === undefined
361 ? undefined
362 : getIssue(ctx.app.db, found.repo.id, number);
363 if (!blocker) return notFound();
364 const unlinked = unlinkIssue(
365 ctx.app,
366 ctx.auth.user,
367 found.repo,
368 found.issue,
369 blocker,
370 );
371 if (!unlinked.ok) return respondAccess(unlinked.error, 422);
372 return new Response(null, { status: 204 });
373 },
374 );
375 // A person confirms an edge whose blocker closed dropped or split: the
376 // plan still holds, so the dependent is no longer parked on it.
377 router.add(
378 "POST",
379 "/api/v1/repos/:owner/:name/issues/:number/dependencies/:blocker/confirm",
380 (ctx) => {
381 const denied = requireScope(ctx, "rw");
382 if (denied) return denied;
383 const found = loadIssue(ctx);
384 if ("error" in found) return found.error;
385 const number = positiveInt(ctx.params.blocker);
386 const blocker =
387 number === undefined
388 ? undefined
389 : getIssue(ctx.app.db, found.repo.id, number);
390 if (!blocker) return notFound();
391 const confirmed = confirmIssue(
392 ctx.app,
393 ctx.auth.user,
394 found.repo,
395 found.issue,
396 blocker,
397 );
398 if (!confirmed.ok) return respondAccess(confirmed.error, 422);
399 return new Response(null, { status: 204 });
400 },
401 );
402
403 router.add(
404 "PATCH",
405 "/api/v1/repos/:owner/:name/issues/:number",
406 async (ctx) => {
407 const denied = requireScope(ctx, "rw");
408 if (denied) return denied;
409 const found = loadIssue(ctx);
410 if ("error" in found) return found.error;
411 const input = await body(ctx.request);
412 // Three acts a PATCH may carry, each its own authorization and each
413 // alone a whole request: sealing (a writer's), new words, a new state.
414 let issue = found.issue;
415 if (typeof input.sealed === "boolean") {
416 const sealed = setIssueSealed(
417 ctx.app,
418 ctx.auth.user,
419 found.repo,
420 issue,
421 input.sealed,
422 );
423 if (!sealed.ok) return originAware(sealed.error, 409);
424 issue = sealed.value;
425 }
426 const text = textFrom(input);
427 if (text === undefined) return json({ error: "invalid_text" }, 422);
428 const wanted = input.state === undefined ? undefined : str(input.state);
429 if (wanted !== undefined && wanted !== "open" && wanted !== "closed")
430 return json({ error: "invalid_state" }, 422);
431 if (
432 wanted === undefined &&
433 text.title === undefined &&
434 text.body === undefined
435 )
436 return typeof input.sealed === "boolean"
437 ? json(withLabels(ctx, issue))
438 : NOTHING_TO_CHANGE();
439 if (text.title !== undefined || text.body !== undefined) {
440 const edited = editIssue(
441 ctx.app,
442 ctx.auth.user,
443 found.repo,
444 issue,
445 text,
446 );
447 if (!edited.ok)
448 return edited.error === "taken_up"
449 ? takenUp()
450 : originAware(edited.error, 422);
451 issue = edited.value;
452 }
453 if (wanted !== undefined) {
454 const closing = closingFrom(input);
455 if (closing === undefined)
456 return json({ error: "invalid_reason" }, 422);
457 const changed = await setIssueStateThrough(
458 ctx.app,
459 ctx.auth.user,
460 found.repo,
461 issue,
462 wanted,
463 closing,
464 );
465 if (!changed.ok) return originAware(changed.error, 409);
466 issue = changed.value;
467 }
468 return json(withLabels(ctx, issue));
469 },
470 );
471
472 router.add(
473 "POST",
474 "/api/v1/repos/:owner/:name/issues/:number/comments",
475 async (ctx) => {
476 const denied = requireScope(ctx, "rw");
477 if (denied) return denied;
478 // A pull's number answers here too, as the OpenAPI document says.
479 const issueFound = loadIssue(ctx);
480 const found =
481 "error" in issueFound
482 ? loadPull(ctx)
483 : { repo: issueFound.repo, thread: issueFound.issue };
484 if ("error" in found) return found.error;
485 const input = await body(ctx.request);
486 const added = await addCommentThrough(
487 ctx.app,
488 ctx.auth.user,
489 found.repo,
490 "thread" in found ? found.thread : found.pull,
491 str(input.body),
492 );
493 if (!added.ok) return originAware(added.error);
494 return json(added.value, 201);
495 },
496 );
497 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs