import { z } from "zod"; import { all } from "../../db"; import { listRepos } from "../../api/repos"; import { getRepo } from "../../api/repos"; import { REPO_SELECT, visibleReposClause } from "../../repos"; import { credentialAllows } from "../../token-scope"; import { repoJson } from "../../api/http"; import { blameFile, BlameUnavailable } from "../../code/blame"; import { contentTerms, ftsQuery, searchOwnerCode, searchRepoCode } from "../../code/search"; import { ensureCodeIndex } from "../../code/search-index"; import { withStore } from "../../git/store"; import { notFoundError } from "../../errors"; import { limitHeavy } from "../../limits"; import type { RepoRow } from "~/lib/types"; import { apiCtx, apiJson, defineTool, fileUri, owner, repoArgs, scopedRepo, truncateText } from "./base"; import { listTreeAt, readFileAt, resolveRef } from "./content"; const SEARCH_LIMIT = 30; const MAX_BLAME_LINES = 2000; const MAX_INLINE_BASE64 = 256 * 1024; const listReposTool = defineTool({ name: "list_repos", title: "List repositories", description: "List repositories of an owner (defaults to you) that this grant can read.", input: z.object({ owner: owner.optional() }), write: false, async run({ caller }, args) { const repos = await apiJson( listRepos(apiCtx(caller, { search: args.owner ? { owner: args.owner } : {} })), ); return { structured: { repos } }; }, }); const searchReposTool = defineTool({ name: "search_repos", title: "Search repositories", description: "Search repositories you can read by name or description.", input: z.object({ query: z.string().trim().min(1).max(100) }), write: false, async run({ caller }, args) { await limitHeavy(caller.viewer); const { clause, viewerId } = visibleReposClause(caller.viewer); const pattern = `%${args.query.replace(/[%_\\]/g, (char) => `\\${char}`)}%`; const rows = await all( `${REPO_SELECT} WHERE ${clause} AND (r.name LIKE ?2 ESCAPE '\\' OR r.description LIKE ?2 ESCAPE '\\') ORDER BY r.updated_at DESC LIMIT ${SEARCH_LIMIT * 2}`, viewerId, pattern, ); const allowed: RepoRow[] = []; for (const row of rows) { if (allowed.length >= SEARCH_LIMIT) break; if (await credentialAllows(row, caller.credential, "contents", "read")) allowed.push(row); } return { structured: { repos: await Promise.all(allowed.map(repoJson)) } }; }, }); const getRepoTool = defineTool({ name: "get_repo", title: "Get repository", description: "Repository metadata: default branch, visibility, fork parent and URLs.", input: z.object(repoArgs), write: false, async run({ caller }, args) { const repo = await apiJson>(getRepo(apiCtx(caller), args.owner, args.repo)); return { structured: { repo } }; }, }); const listTreeTool = defineTool({ name: "list_tree", title: "List directory", description: "List one directory of a repository at a ref (branch, tag or commit; default branch if omitted).", input: z.object({ ...repoArgs, ref: z.string().max(250).optional(), path: z.string().max(4096).default("") }), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const revision = await resolveRef(repo, args.ref); const listing = await listTreeAt(repo, revision, args.path); return { structured: { ref: revision.name, sha: revision.sha, path: args.path, entries: listing.entries.map((entry) => ({ name: entry.name, path: entry.path, type: entry.type })), truncated: listing.truncated, }, links: listing.entries .filter((entry) => entry.type !== "tree") .slice(0, 50) .map((entry) => ({ type: "resource_link" as const, uri: fileUri(repo.owner_name, repo.name, revision.name, entry.path), name: entry.path, })), repoId: repo.id, }; }, }); const readFileTool = defineTool({ name: "read_file", title: "Read file", description: "Read a file at a ref. Text is returned with an optional line window; binary files are base64. Max 1 MiB.", input: z.object({ ...repoArgs, path: z.string().min(1).max(4096), ref: z.string().max(250).optional(), start_line: z.number().int().min(1).optional(), end_line: z.number().int().min(1).optional(), }), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const revision = await resolveRef(repo, args.ref); const file = await readFileAt(repo, revision, args.path); let text = file.text; if (text !== null && (args.start_line || args.end_line)) { const lines = text.split("\n"); text = lines.slice((args.start_line ?? 1) - 1, args.end_line ?? lines.length).join("\n"); } const bounded = text === null ? null : truncateText(text); const inlineBase64 = file.base64 && file.base64.length <= MAX_INLINE_BASE64 ? file.base64 : null; return { structured: { ref: revision.name, commit: revision.sha, path: file.path, blob_sha: file.sha, size: file.size, binary: file.binary, content: bounded?.text ?? null, base64: inlineBase64, truncated: (bounded?.truncated ?? false) || (file.text === null && inlineBase64 === null), }, text: bounded?.text, links: [ { type: "resource_link", uri: fileUri(repo.owner_name, repo.name, revision.name, file.path), name: file.path }, ], repoId: repo.id, }; }, }); const blameTool = defineTool({ name: "blame_file", title: "Blame file", description: "Line-by-line authorship for a file. Deep histories may report pending; call again shortly.", input: z.object({ ...repoArgs, path: z.string().min(1).max(4096), ref: z.string().max(250).optional() }), write: false, async run({ caller }, args) { await limitHeavy(caller.viewer); const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const revision = await resolveRef(repo, args.ref); const outcome = await withStore(repo.artifacts_name, async (store) => { const commit = await store.commit(revision.sha); const entry = commit ? await store.entryAtPath(commit.treeSha, args.path) : null; if (!commit || !entry || entry.type === "tree") throw notFoundError("File not found"); try { return await blameFile(store, commit, args.path, entry.sha); } catch (error) { if (error instanceof BlameUnavailable) return { status: "unavailable" as const, reason: error.message }; throw error; } }); if (outcome.status !== "ready") return { structured: { ref: revision.name, path: args.path, ...outcome } }; const ranges = outcome.blame.ranges.map((range) => ({ start_line: range.start + 1, line_count: range.count, commit: outcome.blame.commits[range.sha] ?? { sha: range.sha }, })); return { structured: { ref: revision.name, path: args.path, status: "ready", line_count: outcome.blame.lines.length, ranges: ranges.filter((range) => range.start_line <= MAX_BLAME_LINES), truncated: outcome.blame.lines.length > MAX_BLAME_LINES, }, repoId: repo.id, }; }, }); const searchCodeTool = defineTool({ name: "search_code", title: "Search code", description: "Full-text code search on default branches, within one repo (owner+repo) or across one owner's repos (owner). Use path:x to filter.", input: z.object({ query: z.string().trim().min(3).max(200), owner, repo: z.string().optional() }), write: false, async run({ caller }, args) { await limitHeavy(caller.viewer); const query = ftsQuery(args.query); if (!query) return { structured: { hits: [], note: "Search terms need at least 3 characters" } }; const terms = contentTerms(args.query); if (args.repo) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const indexing = await ensureCodeIndex(repo); const ref = { id: repo.id, owner: repo.owner_name, name: repo.name, ref: repo.default_branch }; const { hits } = await searchRepoCode(ref, query, { terms }); return { structured: { hits, indexing }, repoId: repo.id }; } const { hits } = await searchOwnerCode(caller.viewer, args.owner, query, { terms }); const allowed = []; const cache = new Map(); for (const hit of hits) { const key = `${hit.owner}/${hit.repo}`; if (!cache.has(key)) { const scoped = await scopedRepo(caller, hit.owner, hit.repo, "contents", "read").catch(() => null); cache.set(key, scoped !== null); } if (cache.get(key)) allowed.push(hit); } return { structured: { hits: allowed } }; }, }); export const repoTools = [ listReposTool, searchReposTool, getRepoTool, listTreeTool, readFileTool, blameTool, searchCodeTool, ];