Vectrum
Sign In
JjohannSoAdd wikisHistory
1291 lines · 55.0 KB · TypeScript
import "server-only";
import { dispatchWorkflow, onRefsUpdated, readWorkflows, rerunWorkflow, startWorkflowsInBackground } from "../ci";
import { encodePath } from "../format";
import {
  defaultBranch,
  diffCommit,
  isBranch,
  getCommit,
  git,
  lastCommit,
  listBranches,
  listFilesWithSizes,
  listTree,
  log,
  normalizeRepoName,
  objectType,
  readBlob,
  revParse,
  type FileDiff,
} from "../git";
import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "../git-write";
import { isClosedStatus, LABEL_COLORS, priorityLabel, STATUS_IDS, type IssuePatch, type IssueSummary } from "../issue-meta";
import {
  addComment,
  createIssue,
  createLabel,
  getIssue,
  listComments,
  listIssues,
  listLabels,
  listMembers,
  MAX_ISSUE_BODY,
  openIssueCount,
  updateIssue,
  validateIssuePatch,
} from "../issues";
import { logTail } from "../logs";
import { signUpload, UPLOAD_URL_MINUTES } from "../release-upload";
import {
  cleanAssetName,
  createRelease,
  deleteAsset,
  deleteRelease,
  findRelease,
  listReleases,
  releaseTitle,
  storeAsset,
  tagCommit,
  updateRelease,
  type Release,
  type ReleaseInput,
} from "../releases";
import { cloneUrls } from "../repo";
import {
  canWrite,
  createRepo,
  findRepo,
  listReposByOwner,
  listReposForUser,
  markPushed,
  repoAccess,
  searchRepos,
  updateRepo,
  type RepoRecord,
} from "../repos";
import { cancelRun, getRun, isFinished, listJobs, listRuns, listSteps, readJobLogs, type Run } from "../runs";
import { tokenCovers, type TokenScope } from "../tokens";
import { getUserByUsername, type User } from "../users";
import { contentPages, findPage, listPages, pageSlug, readPage, savePage, wikiHead } from "../wiki";
import { pageHref } from "../wiki-links";
import { WORKFLOW_DIR } from "../workflows";
import { array, boolean, integer, object, objectSchema, oneOf, parseArgs, string, ToolError, type Args, type Props } from "./params";

// The tools an agent gets on /mcp. Each checks the user's access to the repository the way the web
// UI does, and on top of that what the connection was approved for (lib/oauth.ts or an API token).

export type ToolContext = { user: User; scope: TokenScope; origin: string };

export type Tool = {
  name: string;
  title: string;
  description: string;
  /** "write" tools change something; read-only connections don't see them. */
  access: "read" | "write";
  destructive?: boolean;
  idempotent?: boolean;
  inputSchema: Record<string, unknown>;
  call: (args: unknown, ctx: ToolContext) => Promise<unknown>;
};

function tool<P extends Props>(def: Omit<Tool, "inputSchema" | "call"> & { params: P; run: (args: Args<P>, ctx: ToolContext) => Promise<unknown> }): Tool {
  const { params, run, ...rest } = def;
  return { ...rest, inputSchema: objectSchema(params), call: (args, ctx) => run(parseArgs(params, args), ctx) };
}

/** Longest text a tool answers with, so one call can't flood the agent's context. */
const MAX_OUTPUT = 100_000;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const ZERO_SHA = "0".repeat(40);

const iso = (ms: number | null) => (ms ? new Date(ms).toISOString() : null);

function truncate(text: string, hint: string) {
  return text.length <= MAX_OUTPUT ? text : `${text.slice(0, MAX_OUTPUT)}\n\n[Output truncated at ${MAX_OUTPUT} characters. ${hint}]`;
}

// ---------------------------------------------------------------------------
// Access

/** read: see it; comment: write to it as any reader may (issues, comments); write: push access; admin: the owner. */
type Need = "read" | "comment" | "write" | "admin";

async function openRepo(ctx: ToolContext, owner: string, name: string, need: Need) {
  const repo = await findRepo(owner, normalizeRepoName(name));
  const access = repo ? await repoAccess(repo, ctx.user.id) : null;
  if (!repo || !access) throw new ToolError(`Repository ${owner}/${name} doesn't exist, or you can't see it.`);
  const full = `${repo.owner}/${repo.name}`;
  if (!tokenCovers(ctx.scope, repo.id, false)) {
    throw new ToolError(`This connection doesn't include ${full}. Reconnect and select it, or allow all repositories.`);
  }
  if ((need === "write" || need === "admin") && !canWrite(access)) throw new ToolError(`You don't have push access to ${full}.`);
  if (need === "admin" && access !== "admin") throw new ToolError(`Only the owner of ${full} can change its settings.`);
  return { repo, access, full, url: `${ctx.origin}/${full}` };
}

const covered = (ctx: ToolContext, repos: RepoRecord[]) => repos.filter((r) => tokenCovers(ctx.scope, r.id, false));

const repoSummary = (ctx: ToolContext, r: RepoRecord) => ({
  full_name: `${r.owner}/${r.name}`,
  description: r.description || null,
  visibility: r.visibility,
  created_at: iso(r.createdAt),
  pushed_at: iso(r.pushedAt),
  url: `${ctx.origin}/${r.owner}/${r.name}`,
});

/** A ref the agent named (or the default branch) and the commit it points to. */
async function resolveRef(dir: string, ref: string | undefined) {
  if (ref === undefined) {
    const branch = await defaultBranch(dir);
    const sha = branch && (await revParse(dir, `refs/heads/${branch}`));
    if (!branch || !sha) throw new ToolError("The repository is empty: it has no commits yet.");
    return { ref: branch, sha };
  }
  const sha = await revParse(dir, ref);
  if (!sha) throw new ToolError(`There's no branch, tag or commit named ${ref}.`);
  return { ref, sha };
}

/** A path inside the repository; "" is the root. */
function repoPath(input: string | undefined) {
  const trimmed = (input ?? "").replace(/^\/+|\/+$/g, "");
  if (!trimmed) return "";
  const path = cleanRepoPath(trimmed);
  if (!path) throw new ToolError(`${input} isn't a valid path.`);
  return path;
}

/** Starts the workflows a ref update (refs/heads/… or refs/tags/…) triggers, as a push would. */
function afterPush(repo: RepoRecord, ctx: ToolContext, ref: string, before: string | null, after: string) {
  startWorkflowsInBackground(onRefsUpdated(repo, new Map(before ? [[ref, before]] : []), new Map([[ref, after]]), ctx.user.id));
}

// Parameters most tools take.
const owner = string("The repository's owner (a username).", { min: 1, max: 39 });
const repo = string("The repository's name.", { min: 1, max: 100 });
const ref = string("A branch, tag or commit SHA. Defaults to the default branch.", { max: 255 }).optional();

// ---------------------------------------------------------------------------
// Account and repositories

const accountTools = [
  tool({
    name: "get_me",
    title: "Who am I",
    description: "The signed-in user, and what this connection may reach.",
    access: "read",
    params: {},
    run: async (_args, ctx) => ({
      username: ctx.user.username,
      name: ctx.user.name || null,
      email: ctx.user.email,
      profile_url: `${ctx.origin}/${ctx.user.username}`,
      connection: {
        permission: ctx.scope.permission === "write" ? "read & write" : "read-only",
        repositories: ctx.scope.allRepos ? "all" : ctx.scope.repos.map((r) => r.fullName),
      },
    }),
  }),

  tool({
    name: "list_repositories",
    title: "List repositories",
    description:
      "Repositories you own or collaborate on, most recently active first. With owner, that user's repositories that you can see instead.",
    access: "read",
    params: { owner: string("Only this user's repositories.", { max: 39 }).optional() },
    run: async ({ owner }, ctx) => {
      let repos: RepoRecord[];
      if (owner) {
        const user = await getUserByUsername(owner);
        if (!user) throw new ToolError(`There's no user named ${owner}.`);
        repos = await listReposByOwner(user.id, ctx.user.id);
      } else {
        repos = await listReposForUser(ctx.user.id);
      }
      return Promise.all(
        covered(ctx, repos).map(async (r) => ({ ...repoSummary(ctx, r), your_access: await repoAccess(r, ctx.user.id) })),
      );
    },
  }),

  tool({
    name: "search_repositories",
    title: "Search repositories",
    description: "Repositories you can see whose name, description or owner contains the query.",
    access: "read",
    params: {
      query: string("Text to look for.", { min: 1, max: 100 }),
      limit: integer("At most this many results.", { min: 1, max: 50 }).default(20),
    },
    run: async ({ query, limit }, ctx) => covered(ctx, await searchRepos(query, ctx.user.id, limit)).map((r) => repoSummary(ctx, r)),
  }),

  tool({
    name: "get_repository",
    title: "Get a repository",
    description: "A repository's details: default branch, branches, open issue count, clone URLs and your access level.",
    access: "read",
    params: { owner, repo },
    run: async (args, ctx) => {
      const { repo, access } = await openRepo(ctx, args.owner, args.repo, "read");
      const [branches, def, issues, clone] = await Promise.all([
        listBranches(repo.dir),
        defaultBranch(repo.dir),
        openIssueCount(repo.id),
        cloneUrls(repo),
      ]);
      return {
        ...repoSummary(ctx, repo),
        default_branch: def,
        branches: branches.slice(0, 100).map((b) => b.name),
        open_issues: issues,
        clone_urls: clone,
        your_access: access,
        empty: branches.length === 0,
      };
    },
  }),

  tool({
    name: "create_repository",
    title: "Create a repository",
    description: "Creates a repository owned by you. With readme (the default), it starts with a README.md commit on main.",
    access: "write",
    params: {
      name: string("Letters, digits, '.', '-' and '_'.", { min: 1, max: 100 }),
      description: string("A short description.", { max: 350 }).default(""),
      visibility: oneOf("Who can see it.", ["public", "private"] as const).default("private"),
      readme: boolean("Start with a README.md.").default(true),
    },
    run: async ({ name, description, visibility, readme }, ctx) => {
      if (!ctx.scope.allRepos) throw new ToolError("This connection is limited to selected repositories, so it can't create new ones.");
      const created = await createRepo(ctx.user, { name, description, visibility, readme });
      return { ...repoSummary(ctx, created), clone_urls: await cloneUrls(created) };
    },
  }),

  tool({
    name: "update_repository",
    title: "Update a repository",
    description: "Changes a repository's description or visibility. Only its owner can.",
    access: "write",
    idempotent: true,
    params: {
      owner,
      repo,
      description: string("The new description.", { max: 350 }).optional(),
      visibility: oneOf("Who can see it.", ["public", "private"] as const).optional(),
    },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "admin");
      await updateRepo(repo.id, { description: args.description ?? repo.description, visibility: args.visibility ?? repo.visibility });
      return repoSummary(ctx, { ...repo, description: args.description ?? repo.description, visibility: args.visibility ?? repo.visibility });
    },
  }),
];

// ---------------------------------------------------------------------------
// Code

function renderDiff(files: FileDiff[]) {
  return files
    .map((f) => {
      const head = [`diff --git a/${f.oldPath} b/${f.newPath}`];
      if (f.status === "added") head.push("new file");
      if (f.status === "deleted") head.push("deleted file");
      if (f.status === "renamed") head.push(`rename from ${f.oldPath}`, `rename to ${f.newPath}`);
      if (f.binary) return [...head, "Binary files differ"].join("\n");
      head.push(f.status === "added" ? "--- /dev/null" : `--- a/${f.oldPath}`, f.status === "deleted" ? "+++ /dev/null" : `+++ b/${f.newPath}`);
      const sign = { add: "+", del: "-", ctx: " ", hunk: "" } as const;
      return [...head, ...f.lines.map((l) => sign[l.kind] + l.text)].join("\n");
    })
    .join("\n");
}

const codeTools = [
  tool({
    name: "list_branches",
    title: "List branches",
    description: "A repository's branches, most recently committed to first, with their latest commit.",
    access: "read",
    params: { owner, repo },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
      const def = await defaultBranch(repo.dir);
      return (await listBranches(repo.dir)).map((b) => ({ name: b.name, default: b.name === def, sha: b.sha, date: iso(b.date), subject: b.subject }));
    },
  }),

  tool({
    name: "create_branch",
    title: "Create a branch",
    description: "Creates a branch at a commit (by default, the head of the default branch). commit_files can also create a branch as it commits.",
    access: "write",
    params: {
      owner,
      repo,
      branch: string("The new branch's name.", { min: 1, max: 255 }),
      from: string("Branch, tag or commit to start from. Defaults to the default branch.", { max: 255 }).optional(),
    },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
      if (!(await isValidBranchName(repo.dir, args.branch))) throw new ToolError(`${args.branch} isn't a valid branch name.`);
      const { sha } = await resolveRef(repo.dir, args.from);
      try {
        await git(repo.dir, ["update-ref", "-m", "create branch", `refs/heads/${args.branch}`, sha, ZERO_SHA]);
      } catch {
        throw new ToolError(`Branch ${args.branch} already exists.`);
      }
      await markPushed(repo.id);
      afterPush(repo, ctx, `refs/heads/${args.branch}`, null, sha);
      return { branch: args.branch, sha, url: `${url}/tree/${encodeURIComponent(args.branch)}` };
    },
  }),

  tool({
    name: "list_files",
    title: "List files",
    description:
      "The files and directories at a path (the root by default). With recursive, every file under the path, with sizes in bytes.",
    access: "read",
    params: {
      owner,
      repo,
      path: string("A directory in the repository. Defaults to the root.", { max: 1024 }).optional(),
      ref,
      recursive: boolean("List every file below the path.").default(false),
    },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
      const { ref, sha } = await resolveRef(repo.dir, args.ref);
      const path = repoPath(args.path);
      const type = await objectType(repo.dir, sha, path);
      if (type !== "tree") throw new ToolError(type ? `${path} is a file; read it with read_file.` : `There's no ${path} at ${ref}.`);
      if (args.recursive) {
        const prefix = path ? `${path}/` : "";
        const files = (await listFilesWithSizes(repo.dir, sha)).filter((f) => f.path.startsWith(prefix));
        const shown = files.slice(0, 5000);
        return {
          ref,
          commit: sha,
          path,
          files: shown,
          ...(files.length > shown.length ? { truncated: `Only the first ${shown.length} of ${files.length} files. List a subdirectory.` } : {}),
        };
      }
      const kinds = { blob: "file", tree: "dir", commit: "submodule" } as const;
      const entries = (await listTree(repo.dir, sha, path)).map((e) => ({ path: e.path, type: kinds[e.type], ...(e.size !== null ? { size: e.size } : {}) }));
      return { ref, commit: sha, path, entries };
    },
  }),

  tool({
    name: "read_file",
    title: "Read a file",
    description: "A text file's content. Long files are cut off; read them in parts with start_line and end_line (1-based, inclusive).",
    access: "read",
    params: {
      owner,
      repo,
      path: string("The file's path in the repository.", { min: 1, max: 1024 }),
      ref,
      start_line: integer("First line to return.", { min: 1 }).optional(),
      end_line: integer("Last line to return.", { min: 1 }).optional(),
    },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
      const { ref, sha } = await resolveRef(repo.dir, args.ref);
      const path = repoPath(args.path);
      const type = await objectType(repo.dir, sha, path);
      if (type !== "blob") throw new ToolError(type === "tree" ? `${path} is a directory; list it with list_files.` : `There's no file ${path} at ${ref}.`);
      const blob = await readBlob(repo.dir, sha, path);
      if (blob.tooLarge) throw new ToolError(`${path} is ${blob.size} bytes, too large to read here. Clone the repository instead.`);
      if (blob.binary) return `${path} is a binary file (${blob.size} bytes).`;
      const text = blob.content.toString("utf8");
      if (args.start_line === undefined && args.end_line === undefined) {
        return truncate(text, "Read the rest with start_line and end_line.");
      }
      const lines = text.split("\n");
      const start = args.start_line ?? 1;
      const end = Math.min(args.end_line ?? lines.length, lines.length);
      if (start > lines.length) throw new ToolError(`${path} has only ${lines.length} lines.`);
      return truncate(lines.slice(start - 1, end).join("\n"), "Ask for fewer lines.");
    },
  }),

  tool({
    name: "commit_files",
    title: "Commit files",
    description: [
      "Writes and deletes files in one commit on a branch, as you, and starts the workflows the push triggers.",
      "If the branch doesn't exist, it's created from base (or the default branch).",
      "Pass expected_head (the commit you read the files at) to refuse the commit if the branch has moved since.",
    ].join(" "),
    access: "write",
    params: {
      owner,
      repo,
      branch: string("The branch to commit to.", { min: 1, max: 255 }),
      message: string("The commit message.", { min: 1, max: 10_000 }),
      files: array(
        "Files to create or overwrite, with their whole new content.",
        object("A file.", {
          path: string("Path in the repository.", { min: 1, max: 1024 }),
          content: string("The full content.", { max: 15 * 1024 * 1024 }),
          encoding: oneOf("How content is encoded.", ["utf8", "base64"] as const).default("utf8"),
        }),
        { max: 500 },
      ).default([]),
      delete_paths: array("Files to delete.", string("Path in the repository.", { min: 1, max: 1024 }), { max: 500 }).default([]),
      base: string("Where a new branch starts: a branch, tag or commit. Ignored if the branch exists.", { max: 255 }).optional(),
      expected_head: string("The commit SHA the branch must still be at.", { max: 64 }).optional(),
    },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
      if (args.files.length + args.delete_paths.length === 0) throw new ToolError("Give at least one file to write or delete.");

      const head = await revParse(repo.dir, `refs/heads/${args.branch}`);
      let parent: string | null;
      if (head) {
        if (args.expected_head && (args.expected_head.length < 7 || !head.startsWith(args.expected_head.toLowerCase()))) {
          throw new ToolError(`${args.branch} is at ${head} now, not ${args.expected_head}. Read the files again and retry.`);
        }
        parent = head;
      } else {
        if (!(await isValidBranchName(repo.dir, args.branch))) throw new ToolError(`${args.branch} isn't a valid branch name.`);
        // A new branch starts from base, or the default branch; in an empty repository, from nothing.
        const empty = args.base === undefined && !(await defaultBranch(repo.dir));
        parent = empty ? null : (await resolveRef(repo.dir, args.base)).sha;
      }

      const seen = new Set<string>();
      const claim = (input: string) => {
        const path = cleanRepoPath(input);
        if (!path) throw new ToolError(`${input} isn't a valid path.`);
        if (seen.has(path)) throw new ToolError(`${path} appears more than once.`);
        seen.add(path);
        return path;
      };
      const changes: FileChange[] = [];
      for (const input of args.delete_paths) {
        const path = claim(input);
        const type = parent ? await objectType(repo.dir, parent, path) : null;
        if (type === "tree") throw new ToolError(`${path} is a directory. List its files and delete those.`);
        if (!type) throw new ToolError(`There's no file ${path} to delete.`);
        changes.push({ path, delete: true });
      }
      for (const file of args.files) {
        const content = Buffer.from(file.content, file.encoding);
        if (content.length > MAX_FILE_BYTES) throw new ToolError(`${file.path} is larger than 10 MB.`);
        changes.push({ path: claim(file.path), content });
      }

      const commit = await commitFiles(repo.dir, {
        branch: args.branch,
        parent,
        expectedHead: head,
        changes,
        message: `${args.message.trim()}\n`,
        author: { name: ctx.user.name || ctx.user.username, email: ctx.user.email },
      });
      await markPushed(repo.id);
      afterPush(repo, ctx, `refs/heads/${args.branch}`, head, commit);
      return { commit, branch: args.branch, created_branch: !head, url: `${url}/commit/${commit}` };
    },
  }),

  tool({
    name: "list_commits",
    title: "List commits",
    description: "Commits reachable from a ref, newest first, optionally only those touching a path.",
    access: "read",
    params: {
      owner,
      repo,
      ref,
      path: string("Only commits that change this file or directory.", { max: 1024 }).optional(),
      limit: integer("At most this many commits.", { min: 1, max: 100 }).default(20),
      skip: integer("Skip this many first (for paging).", { min: 0 }).default(0),
    },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
      const { sha } = await resolveRef(repo.dir, args.ref);
      const commits = await log(repo.dir, sha, { path: repoPath(args.path) || undefined, limit: args.limit, skip: args.skip });
      return commits.map((c) => ({ sha: c.sha, author: c.authorName, date: iso(c.date), subject: c.subject }));
    },
  }),

  tool({
    name: "get_commit",
    title: "Get a commit",
    description: "A commit's message, author and parents, and (unless diff is false) its changes as a unified diff.",
    access: "read",
    params: {
      owner,
      repo,
      sha: string("A commit SHA, or a branch or tag for its latest commit.", { min: 1, max: 255 }),
      diff: boolean("Include the changes.").default(true),
    },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
      const { sha } = await resolveRef(repo.dir, args.sha);
      const commit = await getCommit(repo.dir, sha);
      if (!commit) throw new ToolError(`There's no commit ${args.sha}.`);
      const header = [
        `commit ${commit.sha}`,
        ...(commit.parents.length ? [`parents ${commit.parents.join(" ")}`] : []),
        `author ${commit.authorName} <${commit.authorEmail}>`,
        `date ${iso(commit.date)}`,
        `url ${url}/commit/${commit.sha}`,
        "",
        commit.subject,
        ...(commit.body ? ["", commit.body] : []),
      ].join("\n");
      if (!args.diff) return header;
      return truncate(`${header}\n\n${renderDiff(await diffCommit(repo.dir, commit))}`, "Read the changed files with read_file instead.");
    },
  }),
];

// ---------------------------------------------------------------------------
// Issues

const issueProps = {
  status: oneOf("Workflow status. done and canceled close the issue.", STATUS_IDS).optional(),
  priority: integer("0 no priority, 1 urgent, 2 high, 3 medium, 4 low.", { min: 0, max: 4 }).optional(),
  assignee: string("Username of the owner or a collaborator. An empty string unassigns.", { max: 39 }).optional(),
  labels: array("Label names. Replaces the issue's labels.", string("A label name.", { max: 50 }), { max: 50 }).optional(),
  estimate: integer("Points: 1, 2, 3, 5 or 8. 0 removes the estimate.", { min: 0, max: 8 }).optional(),
  due_date: string("YYYY-MM-DD. An empty string removes it.", { max: 10 }).optional(),
};

type IssueProps = Partial<Args<typeof issueProps>> & { title?: string; body?: string };

/** Turns usernames and label names into the ids an issue stores, then validates like the web UI. */
async function issuePatch(repo: RepoRecord, args: IssueProps): Promise<IssuePatch> {
  const patch: IssuePatch = { title: args.title, body: args.body, status: args.status, priority: args.priority };
  if (args.assignee !== undefined) {
    if (!args.assignee) patch.assigneeId = null;
    else {
      const members = await listMembers(repo.id, repo.ownerId);
      const member = members.find((m) => m.username.toLowerCase() === args.assignee!.toLowerCase());
      if (!member) throw new ToolError(`${args.assignee} can't be assigned. Only the owner and collaborators can: ${members.map((m) => m.username).join(", ")}.`);
      patch.assigneeId = member.id;
    }
  }
  if (args.labels !== undefined) {
    const all = await listLabels(repo.id);
    patch.labelIds = args.labels.map((name) => {
      const label = all.find((l) => l.name.toLowerCase() === name.toLowerCase());
      if (!label) {
        const known = all.length ? `Labels: ${all.map((l) => l.name).join(", ")}.` : "The repository has no labels yet.";
        throw new ToolError(`There's no label named ${name}. ${known} create_label adds one.`);
      }
      return label.id;
    });
  }
  if (args.estimate !== undefined) patch.estimate = args.estimate || null;
  if (args.due_date !== undefined) patch.dueDate = args.due_date || null;
  for (const key of Object.keys(patch) as (keyof IssuePatch)[]) if (patch[key] === undefined) delete patch[key];
  return validateIssuePatch(repo, patch);
}

const issueSummary = (i: IssueSummary) => ({
  number: i.number,
  title: i.title,
  status: i.status,
  priority: i.priority,
  priority_name: priorityLabel(i.priority),
  assignee: i.assignee,
  labels: i.labels.map((l) => l.name),
  estimate: i.estimate,
  due_date: i.dueDate,
  author: i.author,
  comments: i.commentCount,
  created_at: iso(i.createdAt),
  updated_at: iso(i.updatedAt),
});

const number = integer("The issue's number.", { min: 1 });

const issueTools = [
  tool({
    name: "list_issues",
    title: "List issues",
    description: "A repository's issues, newest first, without their descriptions.",
    access: "read",
    params: {
      owner,
      repo,
      state: oneOf("open: not done or canceled.", ["open", "closed", "all"] as const).default("open"),
      status: oneOf("Only this status.", STATUS_IDS).optional(),
      assignee: string('Only issues assigned to this username, or "none" for unassigned ones.', { max: 39 }).optional(),
      label: string("Only issues with this label.", { max: 50 }).optional(),
      query: string("Only issues whose title contains this.", { max: 200 }).optional(),
      limit: integer("At most this many issues.", { min: 1, max: 200 }).default(50),
    },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
      const lower = (s: string | null | undefined) => (s ?? "").toLowerCase();
      const issues = (await listIssues(repo.id)).filter(
        (i) =>
          (args.state === "all" || (args.state === "closed") === isClosedStatus(i.status)) &&
          (!args.status || i.status === args.status) &&
          (args.assignee === undefined || (lower(args.assignee) === "none" ? !i.assignee : lower(i.assignee) === lower(args.assignee))) &&
          (!args.label || i.labels.some((l) => lower(l.name) === lower(args.label))) &&
          (!args.query || lower(i.title).includes(lower(args.query))),
      );
      return { total: issues.length, issues: issues.slice(0, args.limit).map(issueSummary) };
    },
  }),

  tool({
    name: "get_issue",
    title: "Get an issue",
    description: "An issue with its description and comments.",
    access: "read",
    params: { owner, repo, number },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
      const issue = await getIssue(repo.id, args.number);
      if (!issue) throw new ToolError(`There's no issue #${args.number}.`);
      const comments = await listComments(issue.id);
      return {
        ...issueSummary(issue),
        state: issue.state,
        url: `${url}/issues/${issue.number}`,
        body: issue.body,
        comment_list: comments.map((c) => ({ author: c.author, created_at: iso(c.createdAt), body: c.body })),
      };
    },
  }),

  tool({
    name: "create_issue",
    title: "Create an issue",
    description: "Opens an issue. Anyone who can see the repository can; setting status, priority, assignee, labels, estimate or due date needs push access.",
    access: "write",
    params: {
      owner,
      repo,
      title: string("A short summary.", { min: 1, max: 256 }),
      body: string("The description, in Markdown.", { max: MAX_ISSUE_BODY }).default(""),
      ...issueProps,
    },
    run: async ({ owner, repo: name, title, body, ...properties }, ctx) => {
      const { repo, access, url } = await openRepo(ctx, owner, name, "comment");
      if (!canWrite(access) && Object.values(properties).some((v) => v !== undefined)) {
        throw new ToolError("Only collaborators can set an issue's properties. Open it with just a title and body.");
      }
      const patch = await issuePatch(repo, { title, body, ...properties });
      const created = await createIssue(repo.id, ctx.user.id, { ...patch, title: patch.title!, body: patch.body ?? "" });
      return { number: created, url: `${url}/issues/${created}` };
    },
  }),

  tool({
    name: "update_issue",
    title: "Update an issue",
    description: "Changes an issue's title, description or properties. Only the fields you pass change. Needs push access.",
    access: "write",
    idempotent: true,
    params: {
      owner,
      repo,
      number,
      title: string("A new title.", { min: 1, max: 256 }).optional(),
      body: string("A new description, in Markdown.", { max: MAX_ISSUE_BODY }).optional(),
      ...issueProps,
    },
    run: async ({ owner, repo: name, number, ...changes }, ctx) => {
      const { repo, url } = await openRepo(ctx, owner, name, "write");
      const issue = await getIssue(repo.id, number);
      if (!issue) throw new ToolError(`There's no issue #${number}.`);
      await updateIssue(issue, await issuePatch(repo, changes), ctx.user.id);
      const updated = (await getIssue(repo.id, number))!;
      return { ...issueSummary(updated), state: updated.state, url: `${url}/issues/${number}` };
    },
  }),

  tool({
    name: "comment_on_issue",
    title: "Comment on an issue",
    description: "Adds a comment, in Markdown, to an issue.",
    access: "write",
    params: { owner, repo, number, body: string("The comment, in Markdown.", { min: 1, max: MAX_ISSUE_BODY }) },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "comment");
      const issue = await getIssue(repo.id, args.number);
      if (!issue) throw new ToolError(`There's no issue #${args.number}.`);
      await addComment(issue.id, { body: args.body.trim(), authorId: ctx.user.id });
      return { url: `${url}/issues/${issue.number}` };
    },
  }),

  tool({
    name: "list_labels",
    title: "List labels",
    description: "The labels issues in a repository can have.",
    access: "read",
    params: { owner, repo },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
      return (await listLabels(repo.id)).map((l) => ({ name: l.name, color: l.color }));
    },
  }),

  tool({
    name: "create_label",
    title: "Create a label",
    description: "Adds a label for the repository's issues. Needs push access.",
    access: "write",
    params: {
      owner,
      repo,
      name: string("The label's name.", { min: 1, max: 50 }),
      color: string("A color like #4cb782. Picked for you if left out.", { max: 7 }).optional(),
    },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "write");
      const used = new Set((await listLabels(repo.id)).map((l) => l.color));
      const color = args.color ?? LABEL_COLORS.find((c) => !used.has(c)) ?? LABEL_COLORS[0];
      const label = await createLabel(repo.id, { name: args.name, color });
      return { name: label.name, color: label.color };
    },
  }),
];

// ---------------------------------------------------------------------------
// Actions

/** "ci.yml" means .vectrum/workflows/ci.yml. */
const workflowPath = (input: string) => (input.includes("/") ? input.replace(/^\/+/, "") : `${WORKFLOW_DIR}/${input}`);

const runSummary = (url: string, r: Run) => ({
  number: r.number,
  workflow: r.workflowName,
  workflow_path: r.workflowPath,
  event: r.event,
  ref: r.ref.replace(/^refs\/(heads|tags)\//, ""),
  sha: r.sha,
  title: r.title,
  status: r.status,
  actor: r.actor,
  error: r.error,
  created_at: iso(r.createdAt),
  started_at: iso(r.startedAt),
  finished_at: iso(r.finishedAt),
  url: `${url}/actions/runs/${r.number}`,
});

async function openRun(ctx: ToolContext, owner: string, name: string, number: number, need: Need) {
  const opened = await openRepo(ctx, owner, name, need);
  const run = await getRun(opened.repo.id, number);
  if (!run) throw new ToolError(`There's no workflow run #${number}.`);
  return { ...opened, run };
}

const runNumber = integer("The run's number.", { min: 1 });

const actionTools = [
  tool({
    name: "list_workflows",
    title: "List workflows",
    description: `The workflow files (${WORKFLOW_DIR}/*.yml) at a ref, whether they run on push or can be started by hand, and why they don't parse if they don't.`,
    access: "read",
    params: { owner, repo, ref },
    run: async (args, ctx) => {
      const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
      const { sha } = await resolveRef(repo.dir, args.ref);
      return (await readWorkflows(repo.dir, sha)).map((f) =>
        "workflow" in f
          ? { path: f.path, name: f.workflow.name, on_push: !!f.workflow.push, can_run_by_hand: f.workflow.dispatch, jobs: f.workflow.jobs.map((j) => j.key) }
          : { path: f.path, name: f.name, error: f.error },
      );
    },
  }),

  tool({
    name: "list_workflow_runs",
    title: "List workflow runs",
    description: "A repository's workflow runs, newest first.",
    access: "read",
    params: {
      owner,
      repo,
      workflow: string("Only runs of this workflow file, e.g. ci.yml.", { max: 1000 }).optional(),
      limit: integer("At most this many runs.", { min: 1, max: 100 }).default(20),
    },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
      const runs = await listRuns(repo.id, { workflowPath: args.workflow ? workflowPath(args.workflow) : null, limit: args.limit, offset: 0 });
      return runs.map((r) => runSummary(url, r));
    },
  }),

  tool({
    name: "get_workflow_run",
    title: "Get a workflow run",
    description: "A workflow run with its jobs and their steps.",
    access: "read",
    params: { owner, repo, number: runNumber },
    run: async (args, ctx) => {
      const { url, run } = await openRun(ctx, args.owner, args.repo, args.number, "read");
      const jobs = await listJobs(run.id);
      return {
        ...runSummary(url, run),
        jobs: await Promise.all(
          jobs.map(async (j) => ({
            key: j.key,
            name: j.name,
            status: j.status,
            runs_on: j.runsOn,
            needs: j.needs,
            runner: j.runnerName,
            started_at: iso(j.startedAt),
            finished_at: iso(j.finishedAt),
            steps: (await listSteps(j.id)).map((s) => ({ name: s.name, status: s.status, exit_code: s.exitCode })),
          })),
        ),
      };
    },
  }),

  tool({
    name: "get_job_logs",
    title: "Get job logs",
    description: "The output of a job's steps: the last tail_lines lines of each, without terminal colors.",
    access: "read",
    params: {
      owner,
      repo,
      number: runNumber,
      job: string("The job's key (its id in the workflow file) or name.", { min: 1, max: 1000 }),
      tail_lines: integer("Lines to keep from the end of each step.", { min: 1, max: 5000 }).default(200),
    },
    run: async (args, ctx) => {
      const { run } = await openRun(ctx, args.owner, args.repo, args.number, "read");
      const jobs = await listJobs(run.id);
      const job = jobs.find((j) => j.key === args.job) ?? jobs.find((j) => j.name === args.job);
      if (!job) throw new ToolError(`Run #${run.number} has no job ${args.job}. Jobs: ${jobs.map((j) => j.key).join(", ")}.`);
      const [logs, steps] = await Promise.all([readJobLogs(job.id), listSteps(job.id)]);
      const text = steps
        .map((s) => {
          const { lines, omitted } = logTail(logs.get(s.index) ?? "", args.tail_lines);
          const exit = s.exitCode !== null ? `, exit code ${s.exitCode}` : "";
          return [`##[${s.status}${exit}] ${s.name}`, ...(omitted ? [`[${omitted} earlier lines left out]`] : []), ...lines].join("\n");
        })
        .join("\n\n");
      // The end of a log is where failures show, so long logs keep their end.
      return text.length <= MAX_OUTPUT ? text : `[Earlier output left out; ask for fewer tail_lines.]\n${text.slice(-MAX_OUTPUT)}`;
    },
  }),

  tool({
    name: "run_workflow",
    title: "Run a workflow",
    description: "Starts a workflow that has on: workflow_dispatch, on a branch (the default branch unless given).",
    access: "write",
    params: {
      owner,
      repo,
      workflow: string("The workflow file, e.g. ci.yml or .vectrum/workflows/ci.yml.", { min: 1, max: 1000 }),
      branch: string("The branch to run on.", { max: 255 }).optional(),
    },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
      const branch = args.branch ?? (await defaultBranch(repo.dir));
      if (!branch) throw new ToolError("The repository is empty: it has no commits yet.");
      const number = await dispatchWorkflow(repo, workflowPath(args.workflow), branch, ctx.user.id);
      return { number, url: `${url}/actions/runs/${number}` };
    },
  }),

  tool({
    name: "cancel_workflow_run",
    title: "Cancel a workflow run",
    description: "Cancels a run's waiting and queued jobs and asks the runners of running ones to stop.",
    access: "write",
    destructive: true,
    idempotent: true,
    params: { owner, repo, number: runNumber },
    run: async (args, ctx) => {
      const { run } = await openRun(ctx, args.owner, args.repo, args.number, "write");
      if (isFinished(run.status)) return `Run #${run.number} already finished (${run.status}).`;
      await cancelRun(run.id);
      return `Cancelling run #${run.number}.`;
    },
  }),

  tool({
    name: "rerun_workflow_run",
    title: "Re-run a workflow run",
    description: "Runs a run's workflow again, as it was at the run's commit. Returns the new run's number.",
    access: "write",
    params: { owner, repo, number: runNumber },
    run: async (args, ctx) => {
      const { repo, url, run } = await openRun(ctx, args.owner, args.repo, args.number, "write");
      const number = await rerunWorkflow(repo, run, ctx.user.id);
      return { number, url: `${url}/actions/runs/${number}` };
    },
  }),
];

// ---------------------------------------------------------------------------
// Releases

async function releaseSummary(url: string, repo: RepoRecord, r: Release) {
  return {
    tag: r.tag,
    name: releaseTitle(r),
    draft: r.draft,
    prerelease: r.prerelease,
    // Until a draft is published, its tag may not exist yet; it's created from target then.
    tag_commit: await tagCommit(repo, r.tag),
    target: r.target || null,
    author: r.author,
    created_at: iso(r.createdAt),
    published_at: iso(r.publishedAt),
    url: `${url}/releases/tag/${encodePath(r.tag)}`,
    assets: r.assets.map((a) => ({
      name: a.name,
      size: a.size,
      sha256: a.sha256,
      downloads: a.downloads,
      url: `${url}/releases/download/${encodePath(r.tag)}/${encodeURIComponent(a.name)}`,
    })),
  };
}

/** A release by tag. Drafts are only there for people who can edit them, as in the web UI. */
async function openRelease(repo: RepoRecord, access: Awaited<ReturnType<typeof openRepo>>["access"], tag: string) {
  const release = await findRelease(repo.id, tag);
  if (!release || (release.draft && !canWrite(access))) throw new ToolError(`There's no release for the tag ${tag}.`);
  return release;
}

/**
 * What a release's tag is created from if it doesn't exist yet. The release code takes a branch
 * name or a full commit SHA, so any other ref is resolved to its commit.
 */
async function releaseTarget(repo: RepoRecord, target: string | undefined) {
  if (target === undefined) return (await defaultBranch(repo.dir)) ?? "";
  if (await isBranch(repo.dir, target)) return target;
  return (await resolveRef(repo.dir, target)).sha;
}

/** A tag the release code created on publishing starts workflows like a push of it would. */
async function afterRelease(repo: RepoRecord, ctx: ToolContext, tag: string, createdTag: string | null) {
  if (!createdTag) return;
  await markPushed(repo.id);
  afterPush(repo, ctx, `refs/tags/${tag}`, null, createdTag);
}

const tag = string("The release's tag, e.g. v1.2.0.", { min: 1, max: 255 });

/** Release files sent in a tool call travel through the agent's context, so they stay small. */
const MAX_INLINE_ASSET_MB = 10;

const assetUrl = (url: string, release: Release, name: string) => `${url}/releases/download/${encodePath(release.tag)}/${encodeURIComponent(name)}`;

function assetName(input: string) {
  const name = cleanAssetName(input);
  if (!name) throw new ToolError(`${input} isn't a valid file name. Use a plain name without slashes.`);
  return name;
}

/** Strict base64: Buffer.from would silently skip anything else and store a broken file. */
function decodeBase64(input: string) {
  const compact = input.replace(/\s+/g, "");
  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) throw new ToolError("content isn't valid base64.");
  return Buffer.from(compact, "base64");
}

const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;

const releaseTools = [
  tool({
    name: "list_releases",
    title: "List releases",
    description: "A repository's releases, newest first, with their files. Drafts are listed (first) if you have push access.",
    access: "read",
    params: { owner, repo, limit: integer("At most this many releases.", { min: 1, max: 100 }).default(20) },
    run: async (args, ctx) => {
      const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "read");
      const { releases, total } = await listReleases(repo.id, { drafts: canWrite(access), page: 1, perPage: args.limit });
      return { total, releases: await Promise.all(releases.map((r) => releaseSummary(url, repo, r))) };
    },
  }),

  tool({
    name: "get_release",
    title: "Get a release",
    description: "A release with its notes and downloadable files.",
    access: "read",
    params: { owner, repo, tag },
    run: async (args, ctx) => {
      const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "read");
      const release = await openRelease(repo, access, args.tag);
      return { ...(await releaseSummary(url, repo, release)), body: release.body };
    },
  }),

  tool({
    name: "create_release",
    title: "Create a release",
    description: [
      "Publishes a release for a tag, with notes in Markdown. If the tag doesn't exist, it's created at target (by default the",
      "default branch) and starts the workflows a push of the tag would. With draft, nothing is published and no tag is created",
      "until update_release sets draft to false. Add files with upload_release_asset (small ones) or create_release_upload_url (anything else).",
    ].join(" "),
    access: "write",
    params: {
      owner,
      repo,
      tag,
      target: string("Branch or commit to create a missing tag from. Defaults to the default branch.", { max: 255 }).optional(),
      title: string("The release's name. Defaults to the tag.", { max: 256 }).default(""),
      body: string("Release notes, in Markdown.", { max: 65_536 }).default(""),
      prerelease: boolean("Mark it as a pre-release, so it isn't the latest release.").default(false),
      draft: boolean("Save it as a draft, visible only to people with push access.").default(false),
    },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
      const input: ReleaseInput = {
        tag: args.tag.trim(),
        target: await releaseTarget(repo, args.target),
        title: args.title,
        body: args.body,
        prerelease: args.prerelease,
        draft: args.draft,
      };
      const { release, createdTag } = await createRelease(repo, ctx.user.id, input);
      await afterRelease(repo, ctx, release.tag, createdTag);
      return { ...(await releaseSummary(url, repo, release)), created_tag: !!createdTag };
    },
  }),

  tool({
    name: "update_release",
    title: "Update a release",
    description:
      "Changes a release's tag, name, notes or pre-release mark. Only the fields you pass change. draft: false publishes a draft (creating its tag if needed); a published release can't go back to being a draft.",
    access: "write",
    idempotent: true,
    params: {
      owner,
      repo,
      tag: string("The release's current tag.", { min: 1, max: 255 }),
      new_tag: string("A different tag for the release. Created at target when published, if it doesn't exist.", { min: 1, max: 255 }).optional(),
      target: string("Branch or commit to create a missing tag from.", { max: 255 }).optional(),
      title: string("The release's name. An empty string shows the tag instead.", { max: 256 }).optional(),
      body: string("Release notes, in Markdown. Replaces the old notes.", { max: 65_536 }).optional(),
      prerelease: boolean("Mark it as a pre-release.").optional(),
      draft: boolean("false publishes a draft.").optional(),
    },
    run: async (args, ctx) => {
      const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
      const release = await openRelease(repo, access, args.tag);
      if (args.draft === true && !release.draft) throw new ToolError(`The release for ${release.tag} is published already and can't go back to being a draft.`);
      const newTag = args.new_tag?.trim() ?? release.tag;
      // Without a new target, the old one stays; one is only made up when the tag still has to be created.
      const target =
        args.target !== undefined ? await releaseTarget(repo, args.target) : release.target || ((await tagCommit(repo, newTag)) ? "" : await releaseTarget(repo, undefined));
      const input: ReleaseInput = {
        tag: newTag,
        target,
        title: args.title ?? release.title,
        body: args.body ?? release.body,
        prerelease: args.prerelease ?? release.prerelease,
        draft: args.draft ?? release.draft,
      };
      const { release: saved, createdTag } = await updateRelease(repo, release, input);
      await afterRelease(repo, ctx, saved.tag, createdTag);
      return { ...(await releaseSummary(url, repo, saved)), created_tag: !!createdTag };
    },
  }),

  tool({
    name: "delete_release",
    title: "Delete a release",
    description: "Deletes a release and its uploaded files. The tag itself stays in the repository.",
    access: "write",
    destructive: true,
    params: { owner, repo, tag },
    run: async (args, ctx) => {
      const { repo, access } = await openRepo(ctx, args.owner, args.repo, "write");
      const release = await openRelease(repo, access, args.tag);
      await deleteRelease(release);
      const files = release.assets.length;
      return `Deleted the release for ${release.tag}${files ? ` and its ${files} file${files === 1 ? "" : "s"}` : ""}. The tag ${release.tag} is still there.`;
    },
  }),

  tool({
    name: "upload_release_asset",
    title: "Upload a release file",
    description: [
      "Adds a file to a release, with its content in the call: text, or base64 for binary files. A file of the same name is replaced.",
      `Only for small files (up to ${MAX_INLINE_ASSET_MB} MB), since the content passes through your context. For build outputs`,
      "on disk, use create_release_upload_url and upload with curl instead.",
    ].join(" "),
    access: "write",
    idempotent: true,
    params: {
      owner,
      repo,
      tag,
      name: string("The file name people download it as, e.g. checksums.txt.", { min: 1, max: 255 }),
      content: string("The file's content.", { max: Math.ceil((MAX_INLINE_ASSET_MB * 1024 * 1024 * 4) / 3) + 4 }),
      encoding: oneOf("How content is encoded.", ["utf8", "base64"] as const).default("utf8"),
    },
    run: async (args, ctx) => {
      const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
      const release = await openRelease(repo, access, args.tag);
      const name = assetName(args.name);
      const data = args.encoding === "base64" ? decodeBase64(args.content) : Buffer.from(args.content, "utf8");
      if (data.length > MAX_INLINE_ASSET_MB * 1024 * 1024) {
        throw new ToolError(`That's more than ${MAX_INLINE_ASSET_MB} MB. Use create_release_upload_url and upload it with curl.`);
      }
      const asset = await storeAsset(release, name, new Response(new Uint8Array(data)).body!, ctx.user.id);
      return { name: asset.name, size: asset.size, sha256: asset.sha256, url: assetUrl(url, release, asset.name) };
    },
  }),

  tool({
    name: "create_release_upload_url",
    title: "Get a release upload URL",
    description: [
      `A URL to upload one file of a release to with a plain PUT, valid for ${UPLOAD_URL_MINUTES} minutes and needing no credentials:`,
      'run `curl -fT <local file> "<upload_url>"` in a shell. The file streams straight to the server, so it can be any size.',
      "Uploading again replaces the file.",
    ].join(" "),
    access: "write",
    params: { owner, repo, tag, name: string("The file name people download it as, e.g. app-linux-x64.tar.gz.", { min: 1, max: 255 }) },
    run: async (args, ctx) => {
      const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
      const release = await openRelease(repo, access, args.tag);
      const name = assetName(args.name);
      const download = assetUrl(url, release, name);
      const upload = `${download}?upload=${signUpload({ repoId: repo.id, releaseId: release.id, name, userId: ctx.user.id })}`;
      return {
        upload_url: upload,
        method: "PUT",
        expires_at: iso(Date.now() + UPLOAD_URL_MINUTES * 60e3),
        command: `curl -fT ${shellQuote(name)} ${shellQuote(upload)}`,
        download_url: download,
      };
    },
  }),

  tool({
    name: "delete_release_asset",
    title: "Delete a release file",
    description: "Removes a file from a release.",
    access: "write",
    destructive: true,
    idempotent: true,
    params: { owner, repo, tag, name: string("The file's name.", { min: 1, max: 255 }) },
    run: async (args, ctx) => {
      const { repo, access } = await openRepo(ctx, args.owner, args.repo, "write");
      const release = await openRelease(repo, access, args.tag);
      const asset = release.assets.find((a) => a.name === args.name);
      if (!asset) {
        const names = release.assets.map((a) => a.name);
        throw new ToolError(`The release for ${release.tag} has no file ${args.name}.${names.length ? ` Files: ${names.join(", ")}.` : ""}`);
      }
      await deleteAsset(release, asset);
      return `Deleted ${asset.name} from the release for ${release.tag}.`;
    },
  }),
];

// ---------------------------------------------------------------------------
// Wiki

/** A repository's wiki, its latest commit and pages; the wiki may not have any yet. */
async function openWiki(ctx: ToolContext, owner: string, name: string, need: Need) {
  const opened = await openRepo(ctx, owner, name, need);
  if (!opened.repo.wikiEnabled) throw new ToolError(`${opened.full} has its wiki turned off.`);
  const { head } = await wikiHead(opened.repo);
  const pages = head ? await listPages(opened.repo.wikiDir, head) : [];
  return { ...opened, head, pages, wiki: `${opened.url}/wiki` };
}

const pageTitleParam = string("The page's title, e.g. Getting Started (or its name, Getting-Started).", { min: 1, max: 200 });

const wikiTools = [
  tool({
    name: "list_wiki_pages",
    title: "List wiki pages",
    description: "A repository's wiki pages, Home first. The wiki is a git repository of its own, cloned as <repo>.wiki.git.",
    access: "read",
    params: { owner, repo },
    run: async (args, ctx) => {
      const { repo, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "read");
      return {
        pages: contentPages(pages).map((p) => ({ title: p.title, path: p.path, url: pageHref(wiki, p.slug) })),
        has_sidebar: pages.some((p) => p.slug === "_Sidebar"),
        has_footer: pages.some((p) => p.slug === "_Footer"),
        clone_url: `${ctx.origin}/${repo.owner}/${repo.name}.wiki.git`,
      };
    },
  }),

  tool({
    name: "read_wiki_page",
    title: "Read a wiki page",
    description: "A wiki page's Markdown, with who changed it last. _Sidebar and _Footer are the pages shown around every page.",
    access: "read",
    params: { owner, repo, title: pageTitleParam.default("Home") },
    run: async (args, ctx) => {
      const { repo, head, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "read");
      const page = findPage(pages, pageSlug(args.title));
      if (!head || !page) throw new ToolError(`The wiki has no page ${args.title}.${pages.length ? " List them with list_wiki_pages." : " It has no pages yet."}`);
      const [{ text, size }, edit] = await Promise.all([readPage(repo.wikiDir, head, page.path), lastCommit(repo.wikiDir, head, page.path)]);
      if (text === null) throw new ToolError(`${page.title} is ${size} bytes, too large to read here. Clone the wiki instead.`);
      return {
        title: page.title,
        path: page.path,
        url: pageHref(wiki, page.slug),
        last_edited_by: edit?.authorName ?? null,
        last_edited_at: iso(edit?.date ?? null),
        content: truncate(text, "Clone the wiki to read all of it."),
      };
    },
  }),

  tool({
    name: "write_wiki_page",
    title: "Write a wiki page",
    description: [
      "Creates a wiki page, or replaces an existing page's Markdown, in one commit to the wiki as you. The first page creates the wiki.",
      "Link pages with [[Page Title]] or [[Text|Page Title]]. new_title renames the page.",
    ].join(" "),
    access: "write",
    idempotent: true,
    params: {
      owner,
      repo,
      title: pageTitleParam,
      content: string("The page's Markdown. Replaces what the page had.", { max: 1024 * 1024 }),
      new_title: string("A new title for an existing page.", { min: 1, max: 200 }).optional(),
      message: string("A message for the page's history. Defaults to Create/Update <title>.", { max: 500 }).optional(),
    },
    run: async (args, ctx) => {
      const { repo, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "write");
      const existing = findPage(pages, pageSlug(args.title));
      if (args.new_title && !existing) throw new ToolError(`The wiki has no page ${args.title} to rename.`);
      const slug = await savePage(
        repo,
        { name: ctx.user.name || ctx.user.username, email: ctx.user.email },
        { originalPath: existing?.path ?? null, title: args.new_title ?? existing?.title ?? args.title, content: args.content, message: args.message },
      );
      return { created: !existing, url: pageHref(wiki, slug) };
    },
  }),
];

export const TOOLS: Tool[] = [...accountTools, ...codeTools, ...issueTools, ...releaseTools, ...actionTools, ...wikiTools];