import "server-only";
import { person, type Actor } from "./actor";
import { checkSecondFactor, userForSession } from "./auth";
import { sendVerificationEmailLater } from "./emails";
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 { hasTwoFactor } from "./two-factor";
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,
/** More fields for the JSON answer, next to `error`. */
readonly extra: Record<string, unknown> = {},
) {
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({ ...e.extra, 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<User | null> {
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<Record<string, unknown>> {
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<string, unknown>;
}
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) });
sendVerificationEmailLater(user);
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;
}
}
/**
* With two-factor authentication on, the app sends the password again with `code`, a code from the
* authenticator app or a recovery code. Without one it's told so: a 401 with `twoFactor: true`.
*/
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.");
if (await hasTwoFactor(auth.user.id)) {
const code = text(body.code, 40);
if (!code) throw new ApiError(401, "Enter the code from your authenticator app.", { twoFactor: true });
const result = await checkSecondFactor(auth.user.id, code);
if (result === "locked") throw new ApiError(429, "Too many wrong codes. Wait 15 minutes, then try again.", { twoFactor: true });
if (result === "invalid") throw new ApiError(401, "That code didn't match. Try the newest code from your app.", { twoFactor: true });
}
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<string, unknown>;
const isPull = !!p.pull_request || e.kind.startsWith("pull_request");
const run = (p.workflow_run ?? {}) as Record<string, unknown>;
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<Access, null> };
async function openRepo(viewer: User | null, owner: string, name: string, need: "read" | "write" = "read"): Promise<Opened> {
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<ReturnType<typeof listTree>> = [];
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<ReturnType<typeof listTree>>[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<string, unknown>): 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)}`,
})),
})),
};
}