import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { github } from "../utils/github"; import type { ToolSession } from "."; import type { GhToolDetails } from "@oh-my-pi/pi-tui/tools/github"; import { buildTextResult, formatAuthor, formatLabels, normalizeOptionalString, normalizeText, parseRepoRef, requireNonEmpty, tryResolveCurrentRepo, } from "./gh-common"; import { pushLine } from "@oh-my-pi/pi-tui/tools/gh-format"; import { formatShortSha } from "@oh-my-pi/pi-tui/tools/gh-format"; import type { GhApiLabel, GhApiSearchCodeItem, GhApiSearchCommitItem, GhApiSearchIssueItem, GhApiSearchRepoItem, GhApiSearchResponse, GhApiUser, GhLabel, GhSearchCodeResult, GhSearchCommitResult, GhSearchRepoResult, GhSearchResult, GhUser, GithubInput, } from "./gh-types"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; export const SEARCH_LIMIT_DEFAULT = 10; export const SEARCH_LIMIT_MAX = 50; export const FILE_PREVIEW_LIMIT = 50; export function resolveSearchLimit(value: number | undefined): number { if (value === undefined) { return SEARCH_LIMIT_DEFAULT; } if (!Number.isFinite(value) || value <= 0) { throw new ToolError("limit must be a positive number"); } return Math.min(Math.floor(value), SEARCH_LIMIT_MAX); } export const RELATIVE_DURATION_PATTERN = /^(\d+)\s*(m|h|d|w|mo|y)$/i; export const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; export const FIXED_UNIT_MS: Record = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000, }; /** * Resolve a search date bound to a GitHub-search-compatible literal. Returns * either a `YYYY-MM-DD` date (relative durations and date-only inputs) or a * full ISO 8601 datetime string (datetime inputs), so the caller can drop it * straight into a qualifier like `created:>=`. */ export function parseSearchDateBound(raw: string, now: Date = new Date()): string { const trimmed = raw.trim(); if (!trimmed) { throw new ToolError("date bound must not be empty"); } const relMatch = trimmed.match(RELATIVE_DURATION_PATTERN); if (relMatch) { const count = Number(relMatch[1]); const unit = relMatch[2].toLowerCase(); const fixedMs = FIXED_UNIT_MS[unit]; let bound: Date; if (fixedMs !== undefined) { bound = new Date(now.getTime() - count * fixedMs); } else { bound = new Date(now); if (unit === "mo") { bound.setUTCMonth(bound.getUTCMonth() - count); } else { bound.setUTCFullYear(bound.getUTCFullYear() - count); } } return bound.toISOString().slice(0, 10); } if (ISO_DATE_PATTERN.test(trimmed)) { return trimmed; } const parsedMs = Date.parse(trimmed); if (!Number.isNaN(parsedMs)) { // GitHub search qualifiers accept seconds precision only // (`YYYY-MM-DDTHH:MM:SSZ`); strip the milliseconds toISOString emits. return new Date(parsedMs).toISOString().replace(/\.\d{3}Z$/, "Z"); } throw new ToolError( `invalid date bound: ${raw}. Expected a relative duration like "3d", "12h", "2w", an ISO date "YYYY-MM-DD", or an ISO datetime.`, ); } /** * Build the GitHub-search qualifier (e.g. `created:>=2026-05-09`) for the * provided bounds, or `undefined` if neither bound is set. */ export function buildSearchDateQualifier( field: string, since: string | undefined, until: string | undefined, now?: Date, ): string | undefined { const sinceVal = since ? parseSearchDateBound(since, now) : undefined; const untilVal = until ? parseSearchDateBound(until, now) : undefined; if (sinceVal && untilVal) { return `${field}:${sinceVal}..${untilVal}`; } if (sinceVal) { return `${field}:>=${sinceVal}`; } if (untilVal) { return `${field}:<=${untilVal}`; } return undefined; } export function resolveSearchDateField( command: "issues" | "prs" | "commits" | "repos", requested: "created" | "updated" | undefined, ): string { if (command === "commits") { return "committer-date"; } const dateField = requested ?? "created"; if (command === "repos" && dateField === "updated") { return "pushed"; } return dateField; } export function composeSearchQuery(parts: ReadonlyArray): string { const cleaned: string[] = []; for (const part of parts) { const trimmed = part?.trim(); if (trimmed) cleaned.push(trimmed); } if (cleaned.length === 0) { throw new ToolError("query is required (or pass since/until to filter by date)"); } return cleaned.join(" "); } export function buildGhApiSearchArgs( endpoint: "issues" | "code" | "commits" | "repositories", query: string, limit: number, options?: { host?: string; extraHeaders?: ReadonlyArray }, ): string[] { const args = ["api"]; if (options?.host) args.push("--hostname", options.host); args.push("-X", "GET", `/search/${endpoint}`, "-f", `q=${query}`, "-F", `per_page=${limit}`); for (const header of options?.extraHeaders ?? []) { args.push("-H", header); } return args; } /** Enterprise API roots prefix the path with `/api/v3`, so match the tail. */ const REPOSITORY_API_URL_PATTERN = /\/repos\/([^/]+\/[^/]+)$/; export function repoFromRepositoryUrl(value: string | undefined): string | undefined { return value ? (REPOSITORY_API_URL_PATTERN.exec(value)?.[1] ?? undefined) : undefined; } export function apiUserToGhUser(user: GhApiUser | null | undefined): GhUser | undefined { if (!user) return undefined; const login = user.login ?? undefined; const name = user.name ?? undefined; if (login === undefined && name === undefined) return undefined; return { login, name }; } export function apiLabelsToGhLabels(labels: GhApiLabel[] | undefined): GhLabel[] { return labels?.map(label => ({ name: label.name })) ?? []; } export function apiIssueToSearchResult(item: GhApiSearchIssueItem): GhSearchResult { const merged = Boolean(item.pull_request?.merged_at); return { author: apiUserToGhUser(item.user) ?? null, createdAt: item.created_at, labels: apiLabelsToGhLabels(item.labels), number: item.number, repository: { nameWithOwner: repoFromRepositoryUrl(item.repository_url) }, state: merged ? "merged" : item.state, title: item.title, updatedAt: item.updated_at, url: item.html_url, }; } export function apiCodeToSearchResult(item: GhApiSearchCodeItem): GhSearchCodeResult { return { path: item.path, repository: { nameWithOwner: item.repository?.full_name }, sha: item.sha, textMatches: item.text_matches?.map(match => ({ fragment: match.fragment, property: match.property })), url: item.html_url, }; } export function apiCommitToSearchResult(item: GhApiSearchCommitItem): GhSearchCommitResult { return { author: apiUserToGhUser(item.author) ?? null, commit: item.commit ? { author: item.commit.author ?? null, committer: item.commit.committer ?? null, message: item.commit.message, } : null, committer: apiUserToGhUser(item.committer) ?? null, id: item.node_id, repository: { nameWithOwner: item.repository?.full_name }, sha: item.sha, url: item.html_url, }; } export function apiRepoToSearchResult(item: GhApiSearchRepoItem): GhSearchRepoResult { return { createdAt: item.created_at, description: item.description, forksCount: item.forks_count, fullName: item.full_name, isArchived: item.archived, isFork: item.fork, isPrivate: item.private, language: item.language, openIssuesCount: item.open_issues_count, owner: apiUserToGhUser(item.owner) ?? null, stargazersCount: item.stargazers_count, updatedAt: item.updated_at, url: item.html_url, visibility: item.visibility ?? null, }; } /** * Matches search-query qualifiers that already scope to a repository, org, or * user. When present, callers should avoid layering a default `repo:` * on top — the user has already expressed an explicit scope. * * Only the leading `repo:`/`org:`/`user:`/`owner:` token is treated as a * scope marker; arbitrary substrings (e.g. inside quoted text) are ignored. */ export const REPO_SCOPE_QUALIFIER_PATTERN = /(?:^|\s)-?(?:repo|org|user|owner):\S/i; /** * Resolve the effective `repo:` scope for a search op. Returns the explicit * `repo` when set, `undefined` when the query already carries a scoping * qualifier, and otherwise the current checkout's `owner/repo` via * `resolveDefaultRepoMemoized`. Resolution failures (no git/gh context, no * configured remote) silently fall back to `undefined` so the search proceeds * across all of GitHub instead of throwing. */ export async function resolveSearchRepoScope( cwd: string, repo: string | undefined, query: string | undefined, signal: AbortSignal | undefined, ): Promise { if (repo) return repo; if (query && REPO_SCOPE_QUALIFIER_PATTERN.test(query)) return undefined; return tryResolveCurrentRepo(cwd, signal); } /** * Split a resolved scope into the `repo:` qualifier and the host to search. * GitHub's search qualifiers take a bare `owner/repo`, so an enterprise host * has to travel as a separate `--hostname` instead of inside the query. */ function searchScope(repo: string | undefined): { qualifier?: string; host?: string } { if (!repo) return {}; const ref = parseRepoRef(repo); return { qualifier: `repo:${ref.slug}`, host: ref.host }; } export function formatSearchResults( kind: "issues" | "pull requests", query: string, repo: string | undefined, items: GhSearchResult[], ): string { const lines: string[] = [`# GitHub ${kind} search`, "", `Query: ${query}`]; pushLine(lines, "Repository", repo); pushLine(lines, "Results", items.length); if (items.length === 0) { lines.push(""); lines.push(`No ${kind} found.`); return lines.join("\n").trim(); } for (const item of items) { lines.push(""); lines.push(`- #${item.number ?? "?"} ${item.title ?? "Untitled"}`); pushLine(lines, " Repo", item.repository?.nameWithOwner); pushLine(lines, " State", item.state); pushLine(lines, " Author", formatAuthor(item.author)); pushLine(lines, " Labels", formatLabels(item.labels)); pushLine(lines, " Created", item.createdAt); pushLine(lines, " Updated", item.updatedAt); pushLine(lines, " URL", item.url); } return lines.join("\n").trim(); } export function formatSearchCodeResults(query: string, repo: string | undefined, items: GhSearchCodeResult[]): string { const lines: string[] = [`# GitHub code search`, "", `Query: ${query}`]; pushLine(lines, "Repository", repo); pushLine(lines, "Results", items.length); if (items.length === 0) { lines.push(""); lines.push("No code matches found."); return lines.join("\n").trim(); } for (const item of items) { lines.push(""); lines.push(`- ${item.path ?? "(unknown path)"}`); pushLine(lines, " Repo", item.repository?.nameWithOwner); pushLine(lines, " Commit", formatShortSha(item.sha)); pushLine(lines, " URL", item.url); const fragment = item.textMatches?.find(match => match.fragment)?.fragment; if (fragment) { pushLine(lines, " Match", normalizeText(fragment).split("\n", 1)[0]); } } return lines.join("\n").trim(); } export function formatSearchCommitMessage(message: string | undefined): string | undefined { if (!message) return undefined; const firstLine = normalizeText(message).split("\n", 1)[0]; return firstLine || undefined; } export function formatSearchCommitsResults( query: string, repo: string | undefined, items: GhSearchCommitResult[], ): string { const lines: string[] = [`# GitHub commits search`, "", `Query: ${query}`]; pushLine(lines, "Repository", repo); pushLine(lines, "Results", items.length); if (items.length === 0) { lines.push(""); lines.push("No commits found."); return lines.join("\n").trim(); } for (const item of items) { lines.push(""); const sha = formatShortSha(item.sha) ?? "(unknown sha)"; const subject = formatSearchCommitMessage(item.commit?.message) ?? "(no commit message)"; lines.push(`- ${sha} ${subject}`); pushLine(lines, " Repo", item.repository?.nameWithOwner); pushLine(lines, " Author", formatAuthor(item.author) ?? item.commit?.author?.name); pushLine(lines, " Date", item.commit?.author?.date ?? item.commit?.committer?.date); pushLine(lines, " URL", item.url); } return lines.join("\n").trim(); } export function formatSearchReposResults(query: string, items: GhSearchRepoResult[]): string { const lines: string[] = [`# GitHub repositories search`, "", `Query: ${query}`]; pushLine(lines, "Results", items.length); if (items.length === 0) { lines.push(""); lines.push("No repositories found."); return lines.join("\n").trim(); } for (const item of items) { lines.push(""); lines.push(`- ${item.fullName ?? "(unknown repository)"}`); const description = normalizeText(item.description).split("\n", 1)[0]; if (description) { pushLine(lines, " Description", description); } pushLine(lines, " Language", item.language ?? undefined); pushLine(lines, " Stars", item.stargazersCount); pushLine(lines, " Forks", item.forksCount); pushLine(lines, " Open issues", item.openIssuesCount); pushLine(lines, " Visibility", item.visibility ?? undefined); pushLine(lines, " Archived", item.isArchived); pushLine(lines, " Fork", item.isFork); pushLine(lines, " Updated", item.updatedAt); pushLine(lines, " URL", item.url); } return lines.join("\n").trim(); } export async function executeSearchIssues( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const limit = resolveSearchLimit(params.limit); const dateField = resolveSearchDateField("issues", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); const displayQuery = composeSearchQuery([params.query, dateQualifier]); const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), displayQuery, signal); const scope = searchScope(repo); const apiQuery = composeSearchQuery([displayQuery, scope.qualifier, "is:issue"]); const args = buildGhApiSearchArgs("issues", apiQuery, limit, { host: scope.host }); const response = await github.json>(session.cwd, args, signal); const items = (response.items ?? []).map(apiIssueToSearchResult); return buildTextResult(formatSearchResults("issues", displayQuery, repo, items), undefined, undefined, { useless: items.length === 0, }); } export async function executeSearchPrs( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const limit = resolveSearchLimit(params.limit); const dateField = resolveSearchDateField("prs", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); const displayQuery = composeSearchQuery([params.query, dateQualifier]); const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), displayQuery, signal); const scope = searchScope(repo); const apiQuery = composeSearchQuery([displayQuery, scope.qualifier, "is:pr"]); const args = buildGhApiSearchArgs("issues", apiQuery, limit, { host: scope.host }); const response = await github.json>(session.cwd, args, signal); const items = (response.items ?? []).map(apiIssueToSearchResult); return buildTextResult(formatSearchResults("pull requests", displayQuery, repo, items), undefined, undefined, { useless: items.length === 0, }); } export async function executeSearchCode( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const query = requireNonEmpty(params.query, "query"); const since = normalizeOptionalString(params.since); const until = normalizeOptionalString(params.until); if (since !== undefined || until !== undefined) { throw new ToolError("search_code does not support since/until; GitHub code search has no date qualifier."); } const limit = resolveSearchLimit(params.limit); const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), query, signal); const scope = searchScope(repo); const apiQuery = composeSearchQuery([query, scope.qualifier]); const args = buildGhApiSearchArgs("code", apiQuery, limit, { host: scope.host, extraHeaders: ["Accept: application/vnd.github.text-match+json"], }); const response = await github.json>(session.cwd, args, signal); const items = (response.items ?? []).map(apiCodeToSearchResult); return buildTextResult(formatSearchCodeResults(query, repo, items), undefined, undefined, { useless: items.length === 0, }); } export async function executeSearchCommits( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const limit = resolveSearchLimit(params.limit); const dateField = resolveSearchDateField("commits", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); const displayQuery = composeSearchQuery([params.query, dateQualifier]); const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), displayQuery, signal); const scope = searchScope(repo); const apiQuery = composeSearchQuery([displayQuery, scope.qualifier]); const args = buildGhApiSearchArgs("commits", apiQuery, limit, { host: scope.host }); const response = await github.json>(session.cwd, args, signal); const items = (response.items ?? []).map(apiCommitToSearchResult); return buildTextResult(formatSearchCommitsResults(displayQuery, repo, items), undefined, undefined, { useless: items.length === 0, }); } export async function executeSearchRepos( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const limit = resolveSearchLimit(params.limit); const dateField = resolveSearchDateField("repos", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); const query = composeSearchQuery([params.query, dateQualifier]); const args = buildGhApiSearchArgs("repositories", query, limit); const response = await github.json>(session.cwd, args, signal); const items = (response.items ?? []).map(apiRepoToSearchResult); return buildTextResult(formatSearchReposResults(query, items), undefined, undefined, { useless: items.length === 0, }); }