import "server-only"; import { person, type Actor } from "./actor"; import { userForSession } from "./auth"; import { describeEvent } from "./event-text"; import { listEvents as listRepoEvents, type RepoEvent } from "./events"; import { commitCount, defaultBranch, diffCommit, getCommit, GitError, listBranches, listTags, listTree, log, normalizeRepoName, objectType, readBlob, revParse, type Commit, type FileDiff } from "./git"; import { cleanRepoPath } from "./git-write"; import type { IssuePatch } from "./issue-meta"; import { addComment, createIssue, getIssue, IssueError, LabelError, listComments, listEvents as listIssueEvents, listIssues, listLabels, listMembers, openIssueCount, updateIssue, validateIssuePatch, } from "./issues"; import { recentActivity, withActivity } from "./listing"; import { IMAGE_TYPES } from "./mime"; import { ProtectionError } from "./protection"; import { addPullComment, countOpenPulls, getPull, headLabel, listPullComments, listPullEvents, listPulls, listReviews, mergePull, pullCommits, pullDiff, PullError, pullStatus, submitReview, threadsOf, updatePull, type PullRequest, type PullState, type Review, } from "./pulls"; import { countReleases, listReleases, releaseTitle } from "./releases"; import { cloneUrls } from "./repo"; import { canWrite, findRepo, getRepoImage, listReposByOwner, listReposForUser, repoAccess, searchRepos, visibleParent, type Access, type RepoRecord } from "./repos"; import { serverOrigin } from "./request-origin"; import { commitStatuses, getRun, listJobs, listRuns, listSteps, readJobLogs, type Run } from "./runs"; import { SIGNUPS_ENABLED } from "./signups"; import { authenticateUser, createUser, getUserByUsername, UserError, type User } from "./users"; /* * The JSON API of the Vectrum mobile app (apps/mobile), under /api/v1. The app signs in with a username * and password and gets the same session token the web UI keeps in its cookie; it sends it as a * bearer token. Everything here acts as that person, with the web UI's permission checks (see * app/actions/*), so the app can do nothing the website wouldn't let them do. */ export class ApiError extends Error { constructor( readonly status: number, message: string, ) { super(message); } } const isUserError = (e: unknown) => e instanceof IssueError || e instanceof LabelError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError; /** Turns what a handler threw into a JSON answer: user errors become 4xx, anything else is rethrown. */ export function errorResponse(e: unknown) { if (e instanceof ApiError) return json({ error: e.message }, e.status); if (isUserError(e)) return json({ error: (e as Error).message }, 422); throw e; } export const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); // --------------------------------------------------------------------------- // Who is asking /** The signed-in person, null without credentials. A bearer token that isn't a valid session is a 401. */ export async function apiViewer(request: Request): Promise { const header = request.headers.get("authorization") ?? ""; if (!/^Bearer\s+/i.test(header)) return null; const user = await userForSession(header.replace(/^Bearer\s+/i, "").trim()); if (!user) throw new ApiError(401, "Your session has expired. Sign in again."); return user; } function requireUser(viewer: User | null) { if (!viewer) throw new ApiError(401, "Sign in to do that."); return viewer; } const MAX_BODY = 65_536; /** Largest request body read: issue and comment bodies are capped at 64 KB, so this leaves room for the rest. */ const MAX_REQUEST_BYTES = 256 * 1024; async function readJson(request: Request): Promise> { if (Number(request.headers.get("content-length") ?? 0) > MAX_REQUEST_BYTES) throw new ApiError(413, "That request is too large."); // Read with a cap too: the length header is optional. const reader = request.body?.getReader(); const chunks: Uint8Array[] = []; let size = 0; for (;;) { const part = reader ? await reader.read() : { done: true as const, value: undefined }; if (part.done) break; size += part.value.byteLength; if (size > MAX_REQUEST_BYTES) { await reader?.cancel(); throw new ApiError(413, "That request is too large."); } chunks.push(part.value); } const raw = Buffer.concat(chunks).toString("utf8"); const body = (() => { try { return JSON.parse(raw) as unknown; } catch { return null; } })(); if (!body || typeof body !== "object" || Array.isArray(body)) throw new ApiError(400, "Expected a JSON object."); return body as Record; } const text = (v: unknown, max = MAX_BODY) => (typeof v === "string" ? v.slice(0, max) : ""); /** What the app's setup shows about a server before signing in: that it's a Vectrum, and whether it takes sign-ups. */ export function serverInfo() { return { name: "Vectrum", api: 1, signups: SIGNUPS_ENABLED }; } /** Creates an account, as the website's sign-up form does, and signs it in. */ export async function signUp(request: Request) { if (!SIGNUPS_ENABLED) throw new ApiError(403, "Sign-ups are closed on this server. Ask the person who runs it for an account."); const body = await readJson(request); const password = text(body.password, 200); try { const user = await createUser({ username: text(body.username, 100), email: text(body.email, 200), password, name: text(body.name, 100) }); const auth = await authenticateUser(user.email, password); if (!auth) throw new ApiError(500, "The account was created, but signing in failed. Try signing in."); return { token: auth.token, user: userJson(auth.user) }; } catch (e) { if (e instanceof UserError) throw new ApiError(422, e.message); throw e; } } export async function signIn(request: Request) { const body = await readJson(request); const auth = await authenticateUser(text(body.login, 200), text(body.password, 200)); if (!auth) throw new ApiError(401, "Incorrect username or password."); return { token: auth.token, user: userJson(auth.user) }; } // --------------------------------------------------------------------------- // Shapes const userJson = (u: User) => ({ id: u.id, username: u.username, name: u.name, email: u.email, bio: u.bio, createdAt: u.createdAt }); type Listed = RepoRecord & { updatedAt?: number; empty?: boolean; parent?: RepoRecord["forkOf"] }; function repoJson(r: Listed) { return { id: r.id, owner: r.owner, name: r.name, description: r.description, visibility: r.visibility, // The repository a fork came from, when the viewer may see it. forkOf: r.parent ? { owner: r.parent.owner, name: r.parent.name } : null, isFork: !!r.forkOf, hasImage: r.imageVersion !== null, imageVersion: r.imageVersion, createdAt: r.createdAt, updatedAt: r.updatedAt ?? r.pushedAt ?? r.createdAt, empty: r.empty ?? false, }; } const commitJson = (c: Commit) => ({ sha: c.sha, parents: c.parents, authorName: c.authorName, authorEmail: c.authorEmail, committerName: c.committerName, date: c.date, subject: c.subject, body: c.body, // Commits an agent made for its person name the agent as committer. agent: /^agent\+/.test(c.committerEmail) ? c.committerName : null, }); /** Longest diff the app gets: enough to read on a phone, without sending megabytes. */ const MAX_DIFF_LINES = 8000; const MAX_FILE_LINES = 1500; function diffJson(files: FileDiff[]) { let budget = MAX_DIFF_LINES; return files.map((f) => { const keep = Math.max(0, Math.min(f.lines.length, MAX_FILE_LINES, budget)); budget -= keep; return { oldPath: f.oldPath, newPath: f.newPath, status: f.status, binary: f.binary, additions: f.additions, deletions: f.deletions, truncated: keep < f.lines.length, lines: f.lines.slice(0, keep).map((l) => ({ kind: l.kind, text: l.text, oldNo: l.oldNo ?? null, newNo: l.newNo ?? null })), }; }); } const pullJson = (p: PullRequest) => ({ number: p.number, title: p.title, body: p.body, author: p.author, agent: p.agent?.name ?? null, head: p.head, headLabel: headLabel(p), base: p.base, headSha: p.headSha, state: p.state, draft: p.draft, mergeSha: p.mergeSha, mergeMethod: p.mergeMethod, mergedBy: p.mergedBy, linkedIssues: p.linkedIssues, commentCount: p.commentCount, createdAt: p.createdAt, updatedAt: p.updatedAt, closedAt: p.closedAt, mergedAt: p.mergedAt, }); const runJson = (r: Run) => ({ number: r.number, workflowName: r.workflowName, workflowPath: r.workflowPath, event: r.event, ref: r.ref.replace(/^refs\/(heads|tags)\//, ""), sha: r.sha, title: r.title, actor: r.actor, agent: r.agentName, status: r.status, error: r.error, createdAt: r.createdAt, startedAt: r.startedAt, finishedAt: r.finishedAt, }); function eventJson(e: RepoEvent, repo: RepoRecord | undefined) { const number = /^#(\d+)$/.exec(e.subject)?.[1]; const p = e.payload as Record; const isPull = !!p.pull_request || e.kind.startsWith("pull_request"); const run = (p.workflow_run ?? {}) as Record; return { id: e.id, kind: e.kind, action: e.action, actor: e.actor, agent: e.agent?.name ?? null, text: describeEvent(e), createdAt: e.createdAt, repo: repo ? { owner: repo.owner, name: repo.name } : null, // What the event is about, for the app to open it. target: number ? { type: isPull ? "pull" : "issue", number: Number(number) } : e.kind === "push" && typeof p.after === "string" && p.after && !p.deleted ? { type: "commit", sha: p.after } : e.kind === "workflow_run" && typeof run.number === "number" ? { type: "run", number: run.number } : null, }; } // --------------------------------------------------------------------------- // Home, explore, people export async function home(viewer: User | null) { const user = requireUser(viewer); const mine = await withActivity(await listReposForUser(user.id), user.id); const byId = new Map(mine.map((r) => [r.id, r])); const [events, activity, discover] = await Promise.all([ listRepoEvents({ repoIds: mine.map((r) => r.id), limit: 40 }), recentActivity(mine, 10), searchRepos("", user.id, 30).then((rs) => withActivity(rs.filter((r) => r.ownerId !== user.id && r.visibility === "public").slice(0, 6), user.id)), ]); const counts = await Promise.all(mine.slice(0, 50).map(async (r) => ({ issues: await openIssueCount(r.id), pulls: await countOpenPulls(r.id) }))); return { user: userJson(user), repos: mine.map(repoJson), openIssues: counts.reduce((n, c) => n + c.issues, 0), openPulls: counts.reduce((n, c) => n + c.pulls, 0), events: events.reverse().map((e) => eventJson(e, byId.get(e.repoId))), commits: activity.map(({ repo, commit }) => ({ repo: { owner: repo.owner, name: repo.name }, commit: commitJson(commit) })), discover: discover.map(repoJson), }; } export async function explore(viewer: User | null, query: string) { const repos = await withActivity(await searchRepos(query.slice(0, 100), viewer?.id ?? null, 50), viewer?.id); return { repos: repos.map(repoJson) }; } export async function profile(viewer: User | null, username: string) { const user = await getUserByUsername(username); if (!user) throw new ApiError(404, "There's no such user."); const repos = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id); return { user: { ...userJson(user), email: viewer?.id === user.id ? user.email : "" }, repos: repos.map(repoJson) }; } // --------------------------------------------------------------------------- // Repositories type Opened = { repo: RepoRecord; access: Exclude }; async function openRepo(viewer: User | null, owner: string, name: string, need: "read" | "write" = "read"): Promise { const repo = await findRepo(owner, normalizeRepoName(name)); const access = repo ? await repoAccess(repo, viewer?.id) : null; // Repositories the viewer can't see are "not found", so private ones don't reveal they exist. if (!repo || !access) throw new ApiError(404, "There's no such repository, or you can't see it."); if (need === "write" && !canWrite(access)) throw new ApiError(403, "You don't have push access to this repository."); return { repo, access }; } /** A branch, tag or commit the app named, or the default branch. */ async function resolveRef(repo: RepoRecord, ref: string | null) { const name = ref || (await defaultBranch(repo.dir)); if (!name) return null; // Like the website: branch and tag names, commit SHAs and HEAD, not git's revision syntax (main^{/text}, :/text, @{1}). if (name.length > 255 || name.startsWith("-") || /[\s~^:?*[\\]|\.\.|@\{/.test(name)) throw new ApiError(400, "That isn't a valid branch, tag or commit."); const sha = name === "HEAD" ? await revParse(repo.dir, "HEAD") : ((await revParse(repo.dir, `refs/heads/${name}`)) ?? (await revParse(repo.dir, `refs/tags/${name}`)) ?? (/^[0-9a-f]{4,40}$/i.test(name) ? await revParse(repo.dir, name) : null)); if (!sha) throw new ApiError(404, `There's no branch, tag or commit named ${name}.`); return { ref: name, sha }; } function cleanPath(input: string | null) { const trimmed = (input ?? "").replace(/^\/+|\/+$/g, ""); if (!trimmed) return ""; const path = cleanRepoPath(trimmed); if (!path) throw new ApiError(400, "That isn't a valid path."); return path; } const README = /^readme(\.md|\.markdown|\.txt)?$/i; export async function repository(viewer: User | null, owner: string, name: string) { const { repo, access } = await openRepo(viewer, owner, name); const branch = await defaultBranch(repo.dir); const [parent, urls, branches, tags, issues, pulls, releases] = await Promise.all([ visibleParent(repo, viewer?.id), cloneUrls(repo), listBranches(repo.dir), listTags(repo.dir), openIssueCount(repo.id), countOpenPulls(repo.id), countReleases(repo.id), ]); let readme: { path: string; text: string } | null = null; let entries: Awaited> = []; let lastCommit: Commit | null = null; let commits = 0; if (branch) { const ref = `refs/heads/${branch}`; [entries, lastCommit, commits] = await Promise.all([listTree(repo.dir, ref, ""), log(repo.dir, ref, { limit: 1 }).then((c) => c[0] ?? null), commitCount(repo.dir, ref)]); const file = entries.find((e) => e.type === "blob" && README.test(e.name)); if (file) { const blob = await readBlob(repo.dir, ref, file.path); if (!blob.binary && !blob.tooLarge) readme = { path: file.path, text: blob.content.toString("utf8").slice(0, 200_000) }; } } return { repo: repoJson({ ...repo, parent, updatedAt: lastCommit?.date ?? repo.pushedAt ?? repo.createdAt, empty: !branch }), access, canWrite: canWrite(access), defaultBranch: branch, branches: branches.map((b) => b.name), tags: tags.slice(0, 100), cloneUrls: urls, counts: { issues, pulls, releases, commits, branches: branches.length }, lastCommit: lastCommit && commitJson(lastCommit), entries: entries.map(entryJson), readme, wikiEnabled: repo.wikiEnabled, }; } const entryJson = (e: Awaited>[number]) => ({ name: e.name, path: e.path, type: e.type, size: e.size }); export async function repoImage(viewer: User | null, owner: string, name: string) { const { repo } = await openRepo(viewer, owner, name); const image = await getRepoImage(repo.id); if (!image) throw new ApiError(404, "This repository has no image."); return new Response(new Uint8Array(image.data), { headers: { "Content-Type": image.contentType, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable" }, }); } export async function tree(viewer: User | null, owner: string, name: string, params: URLSearchParams) { const { repo } = await openRepo(viewer, owner, name); const resolved = await resolveRef(repo, params.get("ref")); if (!resolved) return { ref: null, path: "", entries: [] }; const path = cleanPath(params.get("path")); if (path && (await objectType(repo.dir, resolved.sha, path)) !== "tree") throw new ApiError(404, "There's no such folder."); return { ref: resolved.ref, path, entries: (await listTree(repo.dir, resolved.sha, path)).map(entryJson) }; } /** Largest file the app shows as text. */ const MAX_TEXT_BYTES = 1024 * 1024; export async function blob(viewer: User | null, owner: string, name: string, params: URLSearchParams) { const { repo } = await openRepo(viewer, owner, name); const resolved = await resolveRef(repo, params.get("ref")); const path = cleanPath(params.get("path")); if (!resolved || !path || (await objectType(repo.dir, resolved.sha, path)) !== "blob") throw new ApiError(404, "There's no such file."); const file = await readBlob(repo.dir, resolved.sha, path); const tooLarge = file.tooLarge || file.size > MAX_TEXT_BYTES; const showText = !file.binary && !tooLarge; const last = await log(repo.dir, resolved.sha, { path, limit: 1 }); return { ref: resolved.ref, path, size: file.size, binary: file.binary, tooLarge, image: isRasterImage(path) && !file.tooLarge, text: showText ? file.content.toString("utf8") : null, lastCommit: last[0] ? commitJson(last[0]) : null, }; } /** Images the app shows as pictures. SVGs are left out: they can carry scripts, and Android can't draw them. */ const imageType = (path: string) => { const type = IMAGE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""]; return type && type !== "image/svg+xml" ? type : null; }; const isRasterImage = (path: string) => imageType(path) !== null; /** A file's bytes, for images in the code browser and READMEs. */ export async function raw(viewer: User | null, owner: string, name: string, params: URLSearchParams) { const { repo } = await openRepo(viewer, owner, name); const resolved = await resolveRef(repo, params.get("ref")); const path = cleanPath(params.get("path")); if (!resolved || !path || (await objectType(repo.dir, resolved.sha, path)) !== "blob") throw new ApiError(404, "There's no such file."); const file = await readBlob(repo.dir, resolved.sha, path); if (file.tooLarge) throw new ApiError(413, "This file is too large to show."); // Only raster images are sent as what they are; anything else is plain bytes, so nothing scripted runs. return new Response(new Uint8Array(file.content), { headers: { "Content-Type": imageType(path) ?? "application/octet-stream", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; sandbox", "Cache-Control": "private, no-cache", }, }); } export async function commits(viewer: User | null, owner: string, name: string, params: URLSearchParams) { const { repo } = await openRepo(viewer, owner, name); const resolved = await resolveRef(repo, params.get("ref")); if (!resolved) return { ref: null, commits: [], hasMore: false }; const path = cleanPath(params.get("path")); const page = Math.max(1, Math.min(1000, Number(params.get("page")) || 1)); const perPage = 30; const list = await log(repo.dir, resolved.sha, { path: path || undefined, limit: perPage + 1, skip: (page - 1) * perPage }); const shown = list.slice(0, perPage); const statuses = await commitStatuses(repo.id, shown.map((c) => c.sha)); return { ref: resolved.ref, commits: shown.map((c) => ({ ...commitJson(c), status: statuses.get(c.sha) ?? null })), hasMore: list.length > perPage, }; } export async function commit(viewer: User | null, owner: string, name: string, sha: string) { const { repo } = await openRepo(viewer, owner, name); const found = await getCommit(repo.dir, sha); if (!found) throw new ApiError(404, "There's no such commit."); const files = await diffCommit(repo.dir, found); return { commit: commitJson(found), files: diffJson(files) }; } export async function branches(viewer: User | null, owner: string, name: string) { const { repo } = await openRepo(viewer, owner, name); const [list, branch] = await Promise.all([listBranches(repo.dir), defaultBranch(repo.dir)]); return { defaultBranch: branch, branches: list.map((b) => ({ name: b.name, sha: b.sha, date: b.date, subject: b.subject, isDefault: b.name === branch })), }; } export async function repoEvents(viewer: User | null, owner: string, name: string) { const { repo } = await openRepo(viewer, owner, name); const events = await listRepoEvents({ repoIds: [repo.id], limit: 50 }); return { events: events.reverse().map((e) => eventJson(e, repo)) }; } // --------------------------------------------------------------------------- // Issues // Anyone who can read a repository may open issues and comment; changing one needs push access. export async function issues(viewer: User | null, owner: string, name: string) { const { repo, access } = await openRepo(viewer, owner, name); const [list, labels, members] = await Promise.all([listIssues(repo.id), listLabels(repo.id), listMembers(repo.id, repo.ownerId)]); return { canWrite: canWrite(access), issues: list, labels, members }; } export async function issue(viewer: User | null, owner: string, name: string, number: number) { const { repo, access } = await openRepo(viewer, owner, name); const found = await getIssue(repo.id, number); if (!found) throw new ApiError(404, "There's no such issue."); const [comments, events, labels, members] = await Promise.all([listComments(found.id), listIssueEvents(found.id), listLabels(repo.id), listMembers(repo.id, repo.ownerId)]); return { canWrite: canWrite(access), issue: found, comments, events, labels, members }; } export async function openIssue(request: Request, viewer: User | null, owner: string, name: string) { const user = requireUser(viewer); const { repo, access } = await openRepo(user, owner, name); const body = await readJson(request); const input = issuePatchFrom(body); const { title, body: description, ...properties } = await validateIssuePatch(repo, { ...input, title: text(body.title, 300), body: text(body.body), sortOrder: undefined }); if (!title) throw new ApiError(422, "Title can't be blank."); // People without push access can open issues, but not triage them. const number = await createIssue(repo, person(user.id), { title, body: description ?? "", ...(canWrite(access) ? properties : {}) }); return { number }; } /** The issue fields a request changes. A field of the wrong type is refused, never read as "clear it". */ function issuePatchFrom(body: Record): IssuePatch { const patch: IssuePatch = {}; const wrong = (field: string): never => { throw new ApiError(422, `${field} has the wrong type.`); }; const has = (field: string) => field in body && body[field] !== undefined; if (has("title")) patch.title = typeof body.title === "string" ? body.title.slice(0, 300) : wrong("title"); if (has("body")) patch.body = typeof body.body === "string" ? body.body.slice(0, MAX_BODY) : wrong("body"); if (has("status")) patch.status = typeof body.status === "string" ? (body.status as IssuePatch["status"]) : wrong("status"); if (has("priority")) patch.priority = typeof body.priority === "number" ? body.priority : wrong("priority"); if (has("assigneeId")) patch.assigneeId = body.assigneeId === null || typeof body.assigneeId === "string" ? body.assigneeId : wrong("assigneeId"); if (has("estimate")) patch.estimate = body.estimate === null || typeof body.estimate === "number" ? body.estimate : wrong("estimate"); if (has("dueDate")) patch.dueDate = body.dueDate === null || typeof body.dueDate === "string" ? body.dueDate : wrong("dueDate"); if (has("labelIds")) { const ids = body.labelIds; patch.labelIds = Array.isArray(ids) && ids.every((id) => typeof id === "string") ? (ids as string[]) : wrong("labelIds"); } return patch; } export async function changeIssue(request: Request, viewer: User | null, owner: string, name: string, number: number) { const user = requireUser(viewer); const { repo, access } = await openRepo(user, owner, name); const found = await getIssue(repo.id, number); if (!found) throw new ApiError(404, "There's no such issue."); if (!canWrite(access)) throw new ApiError(403, "Only collaborators can change issues. You can still comment."); const patch = await validateIssuePatch(repo, issuePatchFrom(await readJson(request))); if (patch.title !== undefined && !patch.title.trim()) throw new ApiError(422, "Title can't be blank."); await updateIssue(repo, found, patch, person(user.id)); return issue(user, owner, name, number); } export async function commentOnIssue(request: Request, viewer: User | null, owner: string, name: string, number: number) { const user = requireUser(viewer); const { repo } = await openRepo(user, owner, name); const found = await getIssue(repo.id, number); if (!found) throw new ApiError(404, "There's no such issue."); const body = text((await readJson(request)).body).trim(); if (!body) throw new ApiError(422, "Write something first."); await addComment(repo, found, person(user.id), body); return issue(user, owner, name, number); } // --------------------------------------------------------------------------- // Pull requests // As on the website: anyone who can read may comment; the author and collaborators may close and // reopen; approving, requesting changes and merging need push access (and the base branch's rules). export async function pulls(viewer: User | null, owner: string, name: string, params: URLSearchParams) { const { repo, access } = await openRepo(viewer, owner, name); const state = params.get("state"); const valid: (PullState | "all")[] = ["open", "closed", "merged", "all"]; const { pulls: list } = await listPulls(repo.id, { state: valid.includes(state as PullState) ? (state as PullState | "all") : "open", perPage: 100 }); const counts = { open: await countOpenPulls(repo.id) }; return { canWrite: canWrite(access), counts, pulls: list.map(pullJson) }; } async function openPull(viewer: User | null, owner: string, name: string, number: number, need: "read" | "write" = "read") { const opened = await openRepo(viewer, owner, name, need); const pr = await getPull(opened.repo.id, number); if (!pr) throw new ApiError(404, "There's no such pull request."); return { ...opened, pr }; } export async function pull(viewer: User | null, owner: string, name: string, number: number) { const { repo, access, pr } = await openPull(viewer, owner, name, number); const actor: Actor | null = viewer ? person(viewer.id) : null; const [status, reviews, comments, events, commitList] = await Promise.all([ pullStatus(repo, pr, actor), listReviews(pr.id), listPullComments(pr.id), listPullEvents(pr.id), pullCommits(repo, pr), ]); const isAuthor = viewer?.id === pr.authorId; return { pull: pullJson(pr), canWrite: canWrite(access), canEdit: canWrite(access) || isAuthor, canMerge: canWrite(access) && pr.state === "open", status: { headSha: status.headSha, headExists: status.headExists, ahead: status.ahead, behind: status.behind, conflicts: status.conflicts, checks: status.checks, runs: status.runs.map(runJson), approvals: status.approvals, changesRequestedBy: status.changesRequestedBy, requirements: status.requirements, mergeable: status.mergeable, }, reviews: reviews.map((r: Review) => ({ id: r.id, author: r.author, agent: r.agent, state: r.state, body: r.body, commitSha: r.commitSha, createdAt: r.createdAt })), comments: comments.filter((c) => !c.path).map((c) => ({ id: c.id, author: c.author, agent: c.agent, body: c.body, createdAt: c.createdAt })), threads: threadsOf(comments, status.headSha).map((t) => ({ path: t.root.path, line: t.root.line, side: t.root.side, snippet: t.root.snippet, resolved: t.root.resolved, outdated: t.outdated, comments: [t.root, ...t.replies].map((c) => ({ id: c.id, author: c.author, agent: c.agent, body: c.body, createdAt: c.createdAt })), createdAt: t.root.createdAt, })), events: events.map((e) => ({ id: e.id, actor: e.actor, agent: e.agent, kind: e.kind, data: e.data, createdAt: e.createdAt })), commits: commitList.map(commitJson), }; } export async function pullFiles(viewer: User | null, owner: string, name: string, number: number) { const { repo, pr } = await openPull(viewer, owner, name, number); return { files: diffJson(await pullDiff(repo, pr)) }; } export async function commentOnPull(request: Request, viewer: User | null, owner: string, name: string, number: number) { const user = requireUser(viewer); const { repo, pr } = await openPull(user, owner, name, number); const body = text((await readJson(request)).body).trim(); if (!body) throw new ApiError(422, "Write something first."); await addPullComment(repo, pr, person(user.id), body); return pull(user, owner, name, number); } export async function reviewPull(request: Request, viewer: User | null, owner: string, name: string, number: number) { const user = requireUser(viewer); const { repo, access, pr } = await openPull(user, owner, name, number); const input = await readJson(request); const state = input.state as Review["state"]; if (!["approved", "changes_requested", "commented"].includes(state)) throw new ApiError(422, "Choose what the review says."); if (state !== "commented" && !canWrite(access)) throw new ApiError(403, "Only people with push access can approve or request changes."); await submitReview(repo, pr, person(user.id), { state, body: text(input.body).trim(), comments: [] }); return pull(user, owner, name, number); } export async function changePull(request: Request, viewer: User | null, owner: string, name: string, number: number) { const user = requireUser(viewer); const { repo, access, pr } = await openPull(user, owner, name, number); if (!canWrite(access) && pr.authorId !== user.id) throw new ApiError(403, "Only collaborators and its author can change a pull request."); const input = await readJson(request); const patch: { state?: "open" | "closed"; draft?: boolean; title?: string } = {}; if (input.state === "open" || input.state === "closed") patch.state = input.state; if (typeof input.draft === "boolean") patch.draft = input.draft; if (typeof input.title === "string") patch.title = input.title.slice(0, 300); await updatePull(repo, pr, person(user.id), patch); return pull(user, owner, name, number); } export async function mergePullRequest(request: Request, viewer: User | null, owner: string, name: string, number: number) { const user = requireUser(viewer); const { repo, pr } = await openPull(user, owner, name, number, "write"); const input = await readJson(request); const method = input.method === "squash" ? "squash" : input.method === "merge" || input.method === undefined ? "merge" : null; if (!method) throw new ApiError(422, "Unknown merge method."); await mergePull(repo, pr, person(user.id), { method, deleteBranch: input.deleteBranch === true, expectedHeadSha: typeof input.headSha === "string" && input.headSha ? input.headSha : undefined, }); return pull(user, owner, name, number); } // --------------------------------------------------------------------------- // Actions and releases export async function runs(viewer: User | null, owner: string, name: string, params: URLSearchParams) { const { repo } = await openRepo(viewer, owner, name); const page = Math.max(1, Math.min(1000, Number(params.get("page")) || 1)); const list = await listRuns(repo.id, { limit: 30, offset: (page - 1) * 30 }); return { runs: list.map(runJson), hasMore: list.length === 30 }; } /** The end of a long log is what tells why it failed. */ const MAX_LOG_CHARS = 60_000; export async function run(viewer: User | null, owner: string, name: string, number: number) { const { repo } = await openRepo(viewer, owner, name); const found = await getRun(repo.id, number); if (!found) throw new ApiError(404, "There's no such workflow run."); const jobs = await listJobs(found.id); const detailed = await Promise.all( jobs.map(async (j) => { const [steps, logs] = await Promise.all([listSteps(j.id), readJobLogs(j.id)]); return { key: j.key, name: j.name, status: j.status, runner: j.runnerName, startedAt: j.startedAt, finishedAt: j.finishedAt, steps: steps.map((s) => { const log = logs.get(s.index) ?? ""; return { ...s, log: log.length > MAX_LOG_CHARS ? log.slice(-MAX_LOG_CHARS) : log, logTruncated: log.length > MAX_LOG_CHARS }; }), }; }), ); return { run: runJson(found), jobs: detailed }; } export async function releases(viewer: User | null, owner: string, name: string) { const { repo, access } = await openRepo(viewer, owner, name); const origin = await serverOrigin(); const { releases: list } = await listReleases(repo.id, { drafts: canWrite(access), page: 1, perPage: 50 }); return { releases: list.map((r) => ({ tag: r.tag, title: releaseTitle(r), body: r.body, draft: r.draft, prerelease: r.prerelease, author: r.author, createdAt: r.createdAt, publishedAt: r.publishedAt, assets: r.assets.map((a) => ({ name: a.name, size: a.size, downloads: a.downloads, url: `${origin}/${repo.owner}/${repo.name}/releases/download/${encodeURIComponent(r.tag)}/${encodeURIComponent(a.name)}`, })), })), }; }