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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7450-the-api-v1-repos-owner-name-issues-route → main 3 commits fork 1 thread · 1 unresolved
2 files
changed +510
−469
src/http/api.ts +13 −469
| @@ -35,8 +35,6 @@ import { askCheck } from "../checks/again.ts"; | ||
| 35 | 35 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 36 | 36 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 37 | 37 | 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"; | |
| 40 | 38 | import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; |
| 41 | 39 | import { readScorecards } from "../actions/scorecard.ts"; |
| 42 | 40 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| @@ -79,9 +77,6 @@ import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/t | ||
| 79 | 77 | import { markReaders } from "../crew/direct.ts"; |
| 80 | 78 | import { |
| 81 | 79 | addCommentThrough, |
| 82 | openIssueThrough, | |
| 83 | setIssueLabelsThrough, | |
| 84 | setIssueSealed, | |
| 85 | 80 | setIssueStateThrough, |
| 86 | 81 | } from "../actions/issues.ts"; |
| 87 | 82 | import { |
| @@ -125,7 +120,6 @@ import { | ||
| 125 | 120 | deleteRepo, |
| 126 | 121 | repositoryLimitMessage, |
| 127 | 122 | resolveOwner, |
| 128 | privateRepoForSealed, | |
| 129 | 123 | updateRepoSettings, |
| 130 | 124 | } from "../actions/repos.ts"; |
| 131 | 125 | import { |
| @@ -149,10 +143,6 @@ import { | ||
| 149 | 143 | getIssue, |
| 150 | 144 | labelsFor, |
| 151 | 145 | labelsForIssues, |
| 152 | listComments, | |
| 153 | countThread, | |
| 154 | EMBEDDED_COMMENTS, | |
| 155 | listIssues, | |
| 156 | 146 | LIST_LIMIT, |
| 157 | 147 | LIST_LIMIT_MAX, |
| 158 | 148 | pageLimit, |
| @@ -259,14 +249,8 @@ import { | ||
| 259 | 249 | } from "../actions/crew.ts"; |
| 260 | 250 | import { assignIssue, editIssue, TAKEN_UP } from "../actions/issues.ts"; |
| 261 | 251 | import { steerRepoRun } from "../actions/runs.ts"; |
| 262 | import { | |
| 263 | dependencyWords, | |
| 264 | linkIssue, | |
| 265 | unlinkIssue, | |
| 266 | confirmIssue, | |
| 267 | } from "../actions/dependencies.ts"; | |
| 268 | 252 | import { openPlan, parsePlan, proposePlan } from "../actions/plan.ts"; |
| 269 | import { dependenciesOf, repoGraph } from "../core/dependencies.ts"; | |
| 253 | import { repoGraph } from "../core/dependencies.ts"; | |
| 270 | 254 | import { mapHeldFor } from "../crew/held.ts"; |
| 271 | 255 | import { pillarView } from "../core/pillars.ts"; |
| 272 | 256 | import { askToLiveFor } from "../web/ask_to_live.ts"; |
| @@ -297,7 +281,6 @@ import { isEnvName } from "../apps/data.ts"; | ||
| 297 | 281 | import { getAppByRepo } from "../core/apps.ts"; |
| 298 | 282 | import { crewRole, isCrew, type Agent } from "../core/agents.ts"; |
| 299 | 283 | import { listApps, type AppRecord } from "../core/apps.ts"; |
| 300 | import { closingFrom } from "../actions/issues.ts"; | |
| 301 | 284 | import { |
| 302 | 285 | checkSummary, |
| 303 | 286 | checksForHead, |
| @@ -381,6 +364,7 @@ import { agentRoutes } from "./api/agents.ts"; | ||
| 381 | 364 | import { boardRoutes } from "./api/boards.ts"; |
| 382 | 365 | import { followingRoutes } from "./api/following.ts"; |
| 383 | 366 | import { labelsRoutes } from "./api/labels.ts"; |
| 367 | import { issueRoutes, labelsHandler } from "./api/issues.ts"; | |
| 384 | 368 | import { userRoutes } from "./api/user.ts"; |
| 385 | 369 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 386 | 370 | import { reportRoutes } from "./api/reports.ts"; |
| @@ -434,7 +418,7 @@ export async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 434 | 418 | } |
| 435 | 419 | |
| 436 | 420 | /** 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 { | |
| 438 | 422 | const page = `${ctx.url.origin}/${repo.ownerName}/${repo.name}/report`; |
| 439 | 423 | const api = `${ctx.url.origin}/api/v1/repos/${repo.ownerName}/${repo.name}/reports`; |
| 440 | 424 | return json( |
| @@ -478,7 +462,7 @@ export function withLabels<T extends Issue>(ctx: Ctx, issue: T) { | ||
| 478 | 462 | * by hand reads the same warning (#619). Only the repository's crew is |
| 479 | 463 | * told: to anyone else the mark would say which commenters cannot write. |
| 480 | 464 | */ |
| 481 | function readerMarked< | |
| 465 | export function readerMarked< | |
| 482 | 466 | T extends { readonly authorName: string; readonly originAuthor?: string | null }, |
| 483 | 467 | >(ctx: Ctx, repoId: string, items: readonly T[]): (T & { readonly untrusted?: string })[] { |
| 484 | 468 | const viewer = ctx.auth.user; |
| @@ -493,7 +477,7 @@ function readerMarked< | ||
| 493 | 477 | * queries for the whole page. Never `map(withLabels)` over a list — that is |
| 494 | 478 | * the N+1 these helpers exist to remove. |
| 495 | 479 | */ |
| 496 | function withLabelsMany<T extends Issue>(ctx: Ctx, issues: T[]) { | |
| 480 | export function withLabelsMany<T extends Issue>(ctx: Ctx, issues: T[]) { | |
| 497 | 481 | const ids = issues.map((i) => i.id); |
| 498 | 482 | const labels = labelsForIssues(ctx.app.db, ids); |
| 499 | 483 | const comments = commentCounts(ctx.app.db, ids); |
| @@ -513,24 +497,6 @@ function withLabelsMany<T extends Issue>(ctx: Ctx, issues: T[]) { | ||
| 513 | 497 | })); |
| 514 | 498 | } |
| 515 | 499 | |
| 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 | ||
| 534 | 500 | const NO_COUNTS: RepoCounts = { openIssues: 0, openPulls: 0 }; |
| 535 | 501 | |
| 536 | 502 | /** |
| @@ -701,7 +667,7 @@ export function originAware(error: string, status = 422): Response { | ||
| 701 | 667 | * The words a PATCH on an issue or a pull brings: `title` and `body`, each |
| 702 | 668 | * a string when given. Anything else in those keys is a bad request. |
| 703 | 669 | */ |
| 704 | function textFrom( | |
| 670 | export function textFrom( | |
| 705 | 671 | input: Record<string, unknown>, |
| 706 | 672 | ): { title?: string; body?: string } | undefined { |
| 707 | 673 | const out: { title?: string; body?: string } = {}; |
| @@ -715,10 +681,10 @@ function textFrom( | ||
| 715 | 681 | } |
| 716 | 682 | |
| 717 | 683 | /** 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); | |
| 719 | 685 | |
| 720 | 686 | /** 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 = () => | |
| 722 | 688 | apiError( |
| 723 | 689 | "nothing_to_change", |
| 724 | 690 | 422, |
| @@ -888,7 +854,7 @@ export function contributeFailure(error: unknown): Response { | ||
| 888 | 854 | // ---- query parsing ---- |
| 889 | 855 | |
| 890 | 856 | /** `?state=&label=&author=&limit=&cursor=` for issue and pull lists. */ |
| 891 | function listQuery(ctx: Ctx) { | |
| 857 | export function listQuery(ctx: Ctx) { | |
| 892 | 858 | const q = ctx.url.searchParams; |
| 893 | 859 | const stateParam = q.get("state"); |
| 894 | 860 | const state: IssueState | "all" = |
| @@ -1055,7 +1021,7 @@ async function pullHeadOid(ctx: Ctx, repo: Repo, pull: Pull): Promise<string> { | ||
| 1055 | 1021 | const pullDirOf = (ctx: Ctx, repo: Repo, pull: Pull): Promise<string> => |
| 1056 | 1022 | pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull); |
| 1057 | 1023 | |
| 1058 | function positiveInt(raw: string | undefined): number | undefined { | |
| 1024 | export function positiveInt(raw: string | undefined): number | undefined { | |
| 1059 | 1025 | const n = Number(raw); |
| 1060 | 1026 | return Number.isInteger(n) && n > 0 ? n : undefined; |
| 1061 | 1027 | } |
| @@ -1087,10 +1053,10 @@ function dirOf(ctx: Ctx, repo: Repo): string { | ||
| 1087 | 1053 | return repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); |
| 1088 | 1054 | } |
| 1089 | 1055 | |
| 1090 | type Found<T> = ({ repo: Repo } & T) | { error: Response }; | |
| 1056 | export type Found<T> = ({ repo: Repo } & T) | { error: Response }; | |
| 1091 | 1057 | |
| 1092 | 1058 | /** 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 }> { | |
| 1094 | 1060 | const repo = loadRepo(ctx); |
| 1095 | 1061 | if (!repo) return { error: notFound() }; |
| 1096 | 1062 | const number = positiveInt(ctx.params.number); |
| @@ -1102,12 +1068,6 @@ function loadConversation(ctx: Ctx): Found<{ issue: Issue }> { | ||
| 1102 | 1068 | return { repo, issue }; |
| 1103 | 1069 | } |
| 1104 | 1070 | |
| 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 | ||
| 1111 | 1071 | /** A hold as the API says it: who, why and since when. */ |
| 1112 | 1072 | const holdJson = (hold: Hold) => ({ |
| 1113 | 1073 | by: hold.heldByName, |
| @@ -2380,290 +2340,21 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2380 | 2340 | // ── labels ─────────────────────────────────────────────────────────── |
| 2381 | 2341 | |
| 2382 | 2342 | 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 | ); | |
| 2410 | 2343 | router.add( |
| 2411 | 2344 | "PUT", |
| 2412 | 2345 | "/api/v1/repos/:owner/:name/pulls/:number/labels", |
| 2413 | 2346 | labelsHandler, |
| 2414 | 2347 | ); |
| 2415 | 2348 | |
| 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 | ||
| 2442 | 2349 | // ── issues — the label-as-queue surface automation drives ──────────── |
| 2443 | 2350 | |
| 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); | |
| 2489 | 2352 | |
| 2490 | 2353 | reportRoutes(router); |
| 2491 | 2354 | appNoteRoutes(router); |
| 2492 | 2355 | rollbackRoutes(router); |
| 2493 | 2356 | takeBackRoutes(router); |
| 2494 | 2357 | |
| 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 | ); | |
| 2667 | 2358 | // Which stage of the one flow each open issue is in, and what went live in |
| 2668 | 2359 | // the last day (src/flow). |
| 2669 | 2360 | router.add("GET", "/api/v1/repos/:owner/:name/flow", (ctx) => { |
| @@ -2707,58 +2398,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2707 | 2398 | if (!planned.ok) return failed(planned.error); |
| 2708 | 2399 | return json(planned.value, 201); |
| 2709 | 2400 | }); |
| 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 | ); | |
| 2762 | 2401 | // Milestones as data: the plan's limbs with their weights, and one |
| 2763 | 2402 | // milestone opened. The same numbers as the pages. |
| 2764 | 2403 | router.add("GET", "/api/v1/repos/:owner/:name/milestones", async (ctx) => { |
| @@ -3039,101 +2678,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3039 | 2678 | return timePhase(ctx.request, "render", () => json(read)); |
| 3040 | 2679 | }); |
| 3041 | 2680 | |
| 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 | ||
| 3137 | 2681 | // ── pull requests — so automation drives the review/merge loop ─────── |
| 3138 | 2682 | |
| 3139 | 2683 | 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) | |
| 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 | } |
new above
offline
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.