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; call: (args: unknown, ctx: ToolContext) => Promise; }; function tool

(def: Omit & { params: P; run: (args: Args

, ctx: ToolContext) => Promise }): 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(); 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> & { 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 { 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>["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 ""` 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 .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 .", { 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];