Vectrum
Sign In
310 lines · 18.5 KB · TypeScript
import "server-only";
import { getViewer } from "./auth";
import { agentKeyFromEmail } from "./actor";
import { defaultBranch, diffCommit, getCommit, listBranches, listTree, log, objectType, readBlob, resolveRefAndPath, type Commit } from "./git";
import { isClosedStatus, priorityLabel, statusLabel } from "./issue-meta";
import { getIssue, listComments, listIssues } from "./issues";
import { authenticateAccessToken } from "./oauth";
import { getPull, headLabel, listPullComments, listPulls, listReviews, pullCommits, pullDiff, pullStatus, threadsOf, type PullRequest } from "./pulls";
import { listReleases, releaseTitle } from "./releases";
import { cloneUrls } from "./repo";
import { canWrite, findRepo, listReposByOwner, repoAccess, type RepoRecord } from "./repos";
import { getRun, listJobs, listRuns } from "./runs";
import { authenticateToken, tokenCovers, type TokenScope } from "./tokens";
import { getUserByUsername } from "./users";
import { findPage, listPages, pageSlug, readPage, wikiHead } from "./wiki";

/*
 * Pages as Markdown, for agents that read the web instead of calling tools: any page asked for
 * with `Accept: text/markdown` (or ?format=md) is answered from here (see next.config.ts). The
 * permissions are the web UI's; besides the session cookie, a bearer token or an API token as
 * the Basic auth password signs the request in.
 */

type Viewer = { userId: string | null; scope: TokenScope | null };

/** Who is asking: a session, an OAuth access token or an API token. "invalid" for a bad token. */
export async function markdownViewer(request: Request): Promise<Viewer | "invalid"> {
  const header = request.headers.get("authorization") ?? "";
  let secret: string | null = null;
  if (/^Bearer\s+/i.test(header)) secret = header.replace(/^Bearer\s+/i, "").trim();
  else if (header.startsWith("Basic ")) {
    const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
    secret = decoded.slice(decoded.indexOf(":") + 1);
  }
  if (secret) {
    const grant = await authenticateAccessToken(secret);
    if (grant) return { userId: grant.user.id, scope: grant.scope };
    const token = await authenticateToken(secret);
    if (token) return { userId: token.user.id, scope: token.token };
    return "invalid";
  }
  const viewer = await getViewer();
  return { userId: viewer?.id ?? null, scope: null };
}

const iso = (ms: number | null) => (ms ? new Date(ms).toISOString().replace(/\.\d+Z$/, "Z") : "");
const fence = (text: string, lang = "") => {
  const ticks = "`".repeat(Math.max(3, ...[...text.matchAll(/`+/g)].map((m) => m[0].length + 1)));
  return `${ticks}${lang}\n${text.replace(/\n$/, "")}\n${ticks}`;
};
const quote = (text: string) => (text.trim() ? text.trim() : "_No description._");
const who = (person: string, agent: string | null | undefined) => (agent ? `${agent} (agent of ${person})` : person);
const committer = (c: Commit) => (agentKeyFromEmail(c.committerEmail) ? `${c.committerName} (agent of ${c.authorName})` : c.authorName);

const LANGS: Record<string, string> = { ts: "ts", tsx: "tsx", js: "js", mjs: "js", jsx: "jsx", py: "python", rb: "ruby", go: "go", rs: "rust", md: "markdown", json: "json", yml: "yaml", yaml: "yaml", sh: "sh", css: "css", html: "html", sql: "sql", java: "java", kt: "kotlin", c: "c", h: "c", cpp: "cpp", swift: "swift", toml: "toml" };
const langOf = (path: string) => LANGS[path.split(".").pop()?.toLowerCase() ?? ""] ?? "";

async function openRepo(viewer: Viewer, owner: string, name: string) {
  const repo = await findRepo(owner, name.replace(/\.git$/i, ""));
  const access = repo ? await repoAccess(repo, viewer.userId) : null;
  if (!repo || !access) return null;
  if (viewer.scope && !tokenCovers(viewer.scope, repo.id, false)) return null;
  return { repo, access };
}

/** The Markdown version of a page, by its path segments, or null if there's none (or it can't be seen). */
export async function renderMarkdown(viewer: Viewer, origin: string, segments: string[]): Promise<string | null> {
  if (segments.length === 0) return siteOverview(origin);
  const [owner, name, section, ...rest] = segments;
  if (!name) return userPage(viewer, origin, owner);
  const opened = await openRepo(viewer, owner, name);
  if (!opened) return null;
  const { repo } = opened;
  const base = `${origin}/${repo.owner}/${repo.name}`;
  switch (section) {
    case undefined:
      return repoPage(repo, base, opened.access);
    case "tree":
    case "blob":
      return codePage(repo, base, rest);
    case "commits":
      return commitsPage(repo, base, rest);
    case "commit":
      return commitPage(repo, base, rest[0]);
    case "branches":
      return branchesPage(repo, base);
    case "issues":
      return rest[0] ? issuePage(repo, base, Number(rest[0])) : issuesPage(repo, base);
    case "pulls":
      return rest[0] ? pullPage(repo, base, Number(rest[0]), rest[1]) : pullsPage(repo, base);
    case "releases":
      return releasesPage(repo, base, canWrite(opened.access));
    case "actions":
      return rest[0] === "runs" && rest[1] ? runPage(repo, base, Number(rest[1])) : runsPage(repo, base);
    case "wiki":
      return wikiPage(repo, base, rest[0] ?? "Home");
    default:
      return null;
  }
}

// ---------------------------------------------------------------------------

export function siteOverview(origin: string) {
  return `# Vectrum

> A self-hosted git forge built for agents to work in and people to review. Repositories, issues, pull requests with branch protection, workflows, releases and wikis.

Agents work here through the MCP server, the \`vectrum\` command line, or by reading pages like this one.

## For agents

- MCP server (Streamable HTTP, OAuth 2.1 sign-in): ${origin}/mcp. Claude Code: \`claude mcp add --transport http vectrum ${origin}/mcp\`
- Command line: \`curl -fsSLO ${origin}/vectrum.mjs && node vectrum.mjs auth login --host ${origin}\`
- Any page as Markdown: send \`Accept: text/markdown\` or add \`?format=md\`, e.g. ${origin}/<owner>/<repo>/pulls/1?format=md. Private repositories need \`Authorization: Bearer <token>\`.
- Clone with git over HTTPS (${origin}/<owner>/<repo>.git, a token as the password) or SSH.

## How work flows

1. Start with the MCP tool \`get_my_tasks\`: issues assigned to you and your open pull requests.
2. Read the repository's instructions (\`get_repository\` returns AGENTS.md or CLAUDE.md).
3. Commit where the repository lets you (often straight to the default branch: your commits are labeled and can be reverted), or on \`agent/<topic>\` with a pull request that says "Fixes #<issue>" when the change wants review.
4. \`wait_for_checks\` waits for the workflows; \`list_events\` waits for reviews and comments.
5. People approve and merge. Answer review threads with \`reply_to_review_comment\`.

## Pages

- ${origin}/<owner>/<repo>: a repository, its README and files
- ${origin}/<owner>/<repo>/issues and /issues/<number>
- ${origin}/<owner>/<repo>/pulls and /pulls/<number> (/pulls/<number>/files for the diff)
- ${origin}/<owner>/<repo>/commits/<branch> and /commit/<sha>
- ${origin}/<owner>/<repo>/tree/<ref>/<path> and /blob/<ref>/<path>
- ${origin}/<owner>/<repo>/actions/runs/<number>
- ${origin}/<owner>/<repo>/llms.txt: a repository's summary for agents
`;
}

async function userPage(viewer: Viewer, origin: string, username: string) {
  const user = await getUserByUsername(username);
  if (!user) return null;
  const repos = (await listReposByOwner(user.id, viewer.userId)).filter((r) => !viewer.scope || tokenCovers(viewer.scope, r.id, false));
  return `# ${user.name || user.username} (@${user.username})\n\n${user.bio || ""}\n\n## Repositories\n\n${
    repos.map((r) => `- [${r.owner}/${r.name}](${origin}/${r.owner}/${r.name})${r.visibility === "private" ? " (private)" : ""}${r.description ? `: ${r.description}` : ""}`).join("\n") || "_None._"
  }\n`;
}

/** A repository's summary, for its page and its llms.txt. */
export async function repoPage(repo: RepoRecord, base: string, access: string) {
  const branch = await defaultBranch(repo.dir);
  const urls = await cloneUrls(repo);
  const lines = [`# ${repo.owner}/${repo.name}`, "", ...(repo.description ? [`> ${repo.description}`, ""] : [])];
  lines.push(`- Visibility: ${repo.visibility}; your access: ${access}`);
  if (repo.forkOf) lines.push(`- Fork of ${repo.forkOf.visibility === "public" ? `${repo.forkOf.owner}/${repo.forkOf.name}` : "a private repository"}`);
  lines.push(`- Clone: ${urls.https} or ${urls.ssh}`);
  lines.push(`- Default branch: ${branch ?? "(empty repository)"}`);
  lines.push(`- Issues: ${base}/issues · Pull requests: ${base}/pulls · Workflow runs: ${base}/actions`);
  lines.push(`- Agents push to: ${repo.agentPush === "any" ? "any branch their person may" : repo.agentPush === "unprotected" ? "branches no rule protects" : "branches under agent/"}`);
  if (!branch) return `${lines.join("\n")}\n`;
  const ref = `refs/heads/${branch}`;
  const entries = await listTree(repo.dir, ref, "");
  lines.push("", "## Files", "", ...entries.map((e) => `- [${e.name}${e.type === "tree" ? "/" : ""}](${base}/${e.type === "tree" ? "tree" : "blob"}/${encodeURIComponent(branch)}/${e.path})`));
  for (const name of ["AGENTS.md", "CLAUDE.md"]) {
    if ((await objectType(repo.dir, ref, name)) !== "blob") continue;
    const blob = await readBlob(repo.dir, ref, name);
    if (!blob.binary && !blob.tooLarge) lines.push("", `## ${name} (instructions for agents)`, "", blob.content.toString("utf8").slice(0, 20_000));
  }
  const readme = entries.find((e) => e.type === "blob" && /^readme(\.md|\.markdown|\.txt)?$/i.test(e.name));
  if (readme) {
    const blob = await readBlob(repo.dir, ref, readme.path);
    if (!blob.binary && !blob.tooLarge) lines.push("", `## ${readme.name}`, "", blob.content.toString("utf8").slice(0, 50_000));
  }
  return `${lines.join("\n")}\n`;
}

async function codePage(repo: RepoRecord, base: string, segments: string[]) {
  const resolved = await resolveRefAndPath(repo.dir, segments);
  if (!resolved) return null;
  const { ref, path } = resolved;
  const type = await objectType(repo.dir, ref, path || ".");
  const kind = path ? type : "tree";
  if (kind === "tree") {
    const entries = await listTree(repo.dir, ref, path);
    return `# ${repo.owner}/${repo.name}/${path} at ${ref}\n\n${entries.map((e) => `- [${e.name}${e.type === "tree" ? "/" : ""}](${base}/${e.type === "tree" ? "tree" : "blob"}/${encodeURIComponent(ref)}/${e.path})`).join("\n")}\n`;
  }
  if (kind !== "blob") return null;
  const blob = await readBlob(repo.dir, ref, path);
  if (blob.binary || blob.tooLarge) return `# ${path} at ${ref}\n\nBinary or large file (${blob.size} bytes): ${base}/raw/${encodeURIComponent(ref)}/${path}\n`;
  return `# ${path} at ${ref}\n\n${fence(blob.content.toString("utf8"), langOf(path))}\n`;
}

async function commitsPage(repo: RepoRecord, base: string, segments: string[]) {
  const resolved = segments.length ? await resolveRefAndPath(repo.dir, segments) : null;
  const ref = resolved?.ref ?? (await defaultBranch(repo.dir));
  if (!ref) return "# Commits\n\n_No commits yet._\n";
  const commits = await log(repo.dir, ref, { path: resolved?.path || undefined, limit: 50 });
  return `# Commits on ${ref}\n\n${commits.map((c) => `- [${c.sha.slice(0, 7)}](${base}/commit/${c.sha}) ${c.subject} (${committer(c)}, ${iso(c.date)})`).join("\n")}\n`;
}

async function commitPage(repo: RepoRecord, base: string, sha: string | undefined) {
  const commit = sha ? await getCommit(repo.dir, sha) : null;
  if (!commit) return null;
  const files = await diffCommit(repo.dir, commit);
  const sign = { add: "+", del: "-", ctx: " ", hunk: "" } as const;
  const diff = files
    .map((f) => [`--- ${f.status === "added" ? "/dev/null" : `a/${f.oldPath}`}`, `+++ ${f.status === "deleted" ? "/dev/null" : `b/${f.newPath}`}`, ...(f.binary ? ["Binary files differ"] : f.lines.map((l) => sign[l.kind] + l.text))].join("\n"))
    .join("\n");
  return `# ${commit.subject}\n\n- Commit: ${commit.sha}\n- By: ${committer(commit)} <${commit.authorEmail}>, ${iso(commit.date)}\n- Parents: ${commit.parents.map((p) => `[${p.slice(0, 7)}](${base}/commit/${p})`).join(", ") || "none"}\n\n${commit.body}\n\n## Changes\n\n${fence(diff.slice(0, 200_000), "diff")}\n`;
}

async function branchesPage(repo: RepoRecord, base: string) {
  const branches = await listBranches(repo.dir);
  return `# Branches of ${repo.owner}/${repo.name}\n\n${branches.map((b) => `- [${b.name}](${base}/tree/${encodeURIComponent(b.name)}) at ${b.sha.slice(0, 7)}: ${b.subject} (${iso(b.date)})`).join("\n")}\n`;
}

async function issuesPage(repo: RepoRecord, base: string) {
  const issues = (await listIssues(repo.id)).filter((i) => !isClosedStatus(i.status));
  return `# Open issues of ${repo.owner}/${repo.name}\n\n${
    issues.map((i) => `- [#${i.number} ${i.title}](${base}/issues/${i.number}): ${statusLabel(i.status)}, ${priorityLabel(i.priority)}${i.assignee ? `, assigned to ${who(i.assigneeAgentOf ?? i.assignee, i.assigneeAgentOf ? i.assignee : null)}` : ""}${i.labels.length ? `, labels ${i.labels.map((l) => l.name).join(", ")}` : ""}`).join("\n") || "_None._"
  }\n`;
}

async function issuePage(repo: RepoRecord, base: string, number: number) {
  const issue = await getIssue(repo.id, number);
  if (!issue) return pullPage(repo, base, number);
  const comments = await listComments(issue.id);
  return [
    `# ${issue.title} (issue #${issue.number})`,
    "",
    `- Status: ${statusLabel(issue.status)}; priority: ${priorityLabel(issue.priority)}`,
    `- Opened by ${who(issue.author, issue.authorAgent)}, ${iso(issue.createdAt)}`,
    `- Assignee: ${issue.assignee ? who(issue.assigneeAgentOf ?? issue.assignee, issue.assigneeAgentOf ? issue.assignee : null) : "nobody"}`,
    `- Labels: ${issue.labels.map((l) => l.name).join(", ") || "none"}`,
    "",
    quote(issue.body),
    ...comments.flatMap((c) => ["", `## ${who(c.author, c.agent)}, ${iso(c.createdAt)}`, "", c.body]),
    "",
  ].join("\n");
}

const pullLine = (base: string, p: PullRequest) => `- [#${p.number} ${p.title}](${base}/pulls/${p.number}): ${p.state}${p.draft ? " (draft)" : ""}, ${headLabel(p)} → ${p.base}, by ${who(p.author, p.agent?.name)}`;

async function pullsPage(repo: RepoRecord, base: string) {
  const { pulls } = await listPulls(repo.id, { state: "open", perPage: 100 });
  return `# Open pull requests of ${repo.owner}/${repo.name}\n\n${pulls.map((p) => pullLine(base, p)).join("\n") || "_None._"}\n`;
}

async function pullPage(repo: RepoRecord, base: string, number: number, tab?: string) {
  const pr = await getPull(repo.id, number);
  if (!pr) return null;
  if (tab === "files") {
    const files = await pullDiff(repo, pr);
    const sign = { add: "+", del: "-", ctx: " ", hunk: "" } as const;
    const diff = files.map((f) => [`--- a/${f.oldPath}`, `+++ b/${f.newPath}`, ...(f.binary ? ["Binary files differ"] : f.lines.map((l) => sign[l.kind] + l.text))].join("\n")).join("\n");
    return `# Changes of #${pr.number} ${pr.title}\n\n${fence(diff.slice(0, 200_000), "diff")}\n`;
  }
  const [status, reviews, comments, commits] = await Promise.all([pullStatus(repo, pr), listReviews(pr.id), listPullComments(pr.id), pullCommits(repo, pr)]);
  const threads = threadsOf(comments, status.headSha);
  const lines = [
    `# ${pr.title} (pull request #${pr.number})`,
    "",
    `- State: ${pr.state}${pr.draft ? " (draft)" : ""}; ${headLabel(pr)} → ${pr.base}; head ${status.headSha.slice(0, 7)}`,
    `- Opened by ${who(pr.author, pr.agent?.name)}, ${iso(pr.createdAt)}`,
    `- Checks: ${status.checks}; approved by: ${status.approvals.join(", ") || "nobody"}${status.changesRequestedBy.length ? `; changes requested by ${status.changesRequestedBy.join(", ")}` : ""}`,
    `- Changes: ${base}/pulls/${pr.number}/files`,
    "",
    quote(pr.body),
    "",
    ...(pr.state === "open" ? ["## Merge requirements", "", ...status.requirements.map((r) => `- [${r.ok ? "x" : " "}] ${r.label}${r.detail ? ` (${r.detail})` : ""}`), ""] : []),
    "## Commits",
    "",
    ...commits.map((c) => `- ${c.sha.slice(0, 7)} ${c.subject} (${committer(c)})`),
  ];
  for (const r of reviews) lines.push("", `## Review by ${who(r.author, r.agent)}: ${r.state.replace(/_/g, " ")}, ${iso(r.createdAt)}`, "", r.body || "_No comment._");
  for (const t of threads) {
    lines.push("", `## Thread on ${t.root.path}:${t.root.line}${t.root.resolved ? " (resolved)" : ""}${t.outdated ? " (outdated)" : ""}`, "", fence(t.root.snippet ?? "", "diff"));
    for (const c of [t.root, ...t.replies]) lines.push("", `**${who(c.author, c.agent)}**: ${c.body}`);
  }
  for (const c of comments.filter((c) => !c.path)) lines.push("", `## ${who(c.author, c.agent)}, ${iso(c.createdAt)}`, "", c.body);
  return `${lines.join("\n")}\n`;
}

async function releasesPage(repo: RepoRecord, base: string, drafts: boolean) {
  const { releases } = await listReleases(repo.id, { drafts, page: 1, perPage: 30 });
  return `# Releases of ${repo.owner}/${repo.name}\n${releases
    .map((r) => `\n## ${releaseTitle(r)} (${r.tag})${r.draft ? " (draft)" : ""}${r.prerelease ? " (pre-release)" : ""}\n\n${r.body}\n\n${r.assets.map((a) => `- [${a.name}](${base}/releases/download/${encodeURIComponent(r.tag)}/${encodeURIComponent(a.name)}) (${a.size} bytes, sha256 ${a.sha256})`).join("\n")}`)
    .join("\n")}\n`;
}

async function runsPage(repo: RepoRecord, base: string) {
  const runs = await listRuns(repo.id, { limit: 30, offset: 0 });
  return `# Workflow runs of ${repo.owner}/${repo.name}\n\n${runs.map((r) => `- [#${r.number} ${r.workflowName}](${base}/actions/runs/${r.number}): ${r.status}, ${r.event} on ${r.ref.replace(/^refs\/(heads|tags)\//, "")} (${r.sha.slice(0, 7)}), ${iso(r.createdAt)}`).join("\n") || "_None._"}\n`;
}

async function runPage(repo: RepoRecord, base: string, number: number) {
  const run = await getRun(repo.id, number);
  if (!run) return null;
  const jobs = await listJobs(run.id);
  return `# ${run.workflowName} #${run.number}: ${run.status}\n\n- Event: ${run.event}; ref ${run.ref}; commit ${run.sha}\n- Started by: ${run.actor ? who(run.actor, run.agentName) : "Vectrum"}\n${run.error ? `- Error: ${run.error}\n` : ""}\n## Jobs\n\n${jobs.map((j) => `- ${j.key}: ${j.status} (logs: ${base}/actions/runs/${run.number}/logs/${j.key})`).join("\n")}\n`;
}

async function wikiPage(repo: RepoRecord, base: string, title: string) {
  if (!repo.wikiEnabled) return null;
  const { head } = await wikiHead(repo);
  if (!head) return null;
  const pages = await listPages(repo.wikiDir, head);
  const page = findPage(pages, pageSlug(title));
  if (!page) return null;
  const { text } = await readPage(repo.wikiDir, head, page.path);
  return `# ${page.title}\n\n${text ?? "_Too large to show._"}\n\n---\nOther pages: ${pages.map((p) => `[${p.title}](${base}/wiki/${encodeURIComponent(p.slug)})`).join(", ")}\n`;
}