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];