import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger } from "@oh-my-pi/pi-utils"; import * as vcs from "@oh-my-pi/pi-natives/vcs"; import { CwdLookupMemo } from "../utils/forge-cli"; import { github } from "../utils/github"; import type { ToolSession } from "."; import type { GhToolDetails } from "@oh-my-pi/pi-tui/tools/github"; import type { GhLabel, GhUser } from "./gh-types"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { toolResult } from "./tool-result"; export function normalizeText(value: string | null | undefined): string { return (value ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\t", " ").trim(); } export function normalizeBlock(value: string | null | undefined): string { return (value ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\t", " ").trimEnd(); } export function normalizeOptionalString(value: string | null | undefined): string | undefined { const normalized = value?.trim(); return normalized ? normalized : undefined; } export function normalizePrIdentifierList(value: string | string[] | undefined): string[] { if (value === undefined) return []; const raw = typeof value === "string" ? [value] : value; const cleaned: string[] = []; for (const entry of raw) { const trimmed = entry?.trim(); if (trimmed) cleaned.push(trimmed); } return cleaned; } export function requireNonEmpty(value: string | null | undefined, label: string): string { const normalized = normalizeOptionalString(value); if (!normalized) { throw new ToolError(`${label} must not be empty`); } return normalized; } export function appendRepoFlag(args: string[], repo: string | undefined, identifier?: string): void { // A full URL identifier already names host, repo, and number; `gh` derives // all three from it and rejects a competing `--repo`. if (!repo || identifier?.startsWith("https://")) { return; } args.push("--repo", repo); } /** The host `gh` assumes when a ref names none and `GH_HOST` is unset. */ export const GITHUB_HOST = "github.com"; /** * A repository in the GitHub CLI's `[HOST/]OWNER/REPO` form. A ref that names * no host is left for `gh` to resolve against `GH_HOST` (github.com by * default), so a host that is known — including github.com itself — is worth * keeping: it is what pins the request to the right instance. */ export interface GhRepoRef { /** Host this repo lives on, or undefined when unknown. */ host?: string; /** `OWNER/REPO`, never host-qualified. */ slug: string; } /** Split `[HOST/]OWNER/REPO`; anything with another shape is taken as a slug. */ export function parseRepoRef(repo: string): GhRepoRef { const firstSlash = repo.indexOf("/"); if (firstSlash < 0) return { slug: repo }; const secondSlash = repo.indexOf("/", firstSlash + 1); if (secondSlash < 0 || repo.includes("/", secondSlash + 1)) return { slug: repo }; return { host: repo.slice(0, firstSlash), slug: repo.slice(firstSlash + 1) }; } /** Join a known host and `OWNER/REPO` into the form `--repo` accepts. */ export function formatRepoRef(host: string | undefined, slug: string): string { return host ? `${host}/${slug}` : slug; } /** * `gh api` endpoint paths carry no host, so a ref has to name its host with a * flag instead. */ export function ghApiHostArgs(ref: GhRepoRef): string[] { return ref.host ? ["--hostname", ref.host] : []; } const REPO_URL_PATTERN = /^https?:\/\/([^/]+)\/([^/]+)\/([^/?#]+)/; /** * `https://HOST/OWNER/REPO` → the repository's identity. The host is dropped * only when it is the one `gh` would have assumed anyway, so a bare identity * can never be redirected: with `GH_HOST` set elsewhere, even a github.com * checkout keeps its host. */ export function repoFromUrl(value: string | undefined): string | undefined { const match = REPO_URL_PATTERN.exec(value?.trim() ?? ""); if (!match) return undefined; const host = match[1].toLowerCase(); const slug = `${match[2]}/${match[3]}`; return host === defaultGhHost() ? slug : formatRepoRef(host, slug); } export const PR_URL_PATTERN = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/.*)?$/; export const ISSUE_URL_PATTERN = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/.*)?$/; export async function requireCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise { const repo = vcs.git(cwd); const branch = repo ? await repo.currentBranch(signal).catch(() => null) : null; if (!branch) { throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly."); } return branch; } export async function requireCurrentGitHead(cwd: string, signal?: AbortSignal): Promise { const repo = vcs.git(cwd); const headSha = repo ? await repo.headSha(signal).catch(() => null) : null; if (!headSha) { throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly."); } return headSha; } export function formatAuthor(author: GhUser | null | undefined): string | undefined { if (!author) return undefined; if (author.login) return `@${author.login}`; if (author.name) return author.name; return undefined; } export function formatLabels(labels: GhLabel[] | undefined): string | undefined { const names = labels?.map(label => label.name).filter((value): value is string => Boolean(value)) ?? []; if (names.length === 0) return undefined; return names.join(", "); } export function parsePullRequestUrl(value: string | undefined): { repo?: string; prNumber?: number } { const normalized = normalizeOptionalString(value); if (!normalized) { return {}; } const match = normalized.match(PR_URL_PATTERN); if (!match) { return {}; } return { repo: formatRepoRef(match[1], match[2]), prNumber: Number(match[3]), }; } /** * Parse a digit-only decimal positive integer or return undefined. Rejects * `1e2`, `0x10`, `12.0`, leading +/-, or any other shape `Number()` would * accept — those would otherwise key the cache against the wrong row. */ export function parsePositiveDecimalInt(value: string | undefined): number | undefined { if (!value || !/^\d+$/.test(value)) return undefined; const num = Number(value); if (!Number.isSafeInteger(num) || num <= 0) return undefined; return num; } export function parseIssueUrl(value: string | undefined): { repo?: string; issueNumber?: number } { const normalized = normalizeOptionalString(value); if (!normalized) return {}; const match = normalized.match(ISSUE_URL_PATTERN); if (!match) return {}; return { repo: formatRepoRef(match[1], match[2]), issueNumber: Number(match[3]), }; } /** The host `gh` falls back to for any ref that names none. */ export function defaultGhHost(): string { return (process.env.GH_HOST || GITHUB_HOST).toLowerCase(); } /** The host `gh` will send a ref to, whether or not the ref names one. */ function effectiveHost(ref: GhRepoRef): string { return ref.host?.toLowerCase() ?? defaultGhHost(); } /** * Case-insensitive repo comparison over the instance each ref actually * resolves to. A host-less ref is not a wildcard: `gh` sends it to `GH_HOST`, * so comparing slugs alone would call a bare `owner/repo` the same repository * as `ghe.example.com/owner/repo` and let a caller act on one while the * request goes to the other. */ export function githubRepoSlugEquals(left: string | undefined, right: string): boolean { if (left === undefined) return false; const leftRef = parseRepoRef(left); const rightRef = parseRepoRef(right); if (effectiveHost(leftRef) !== effectiveHost(rightRef)) return false; return leftRef.slug.toLowerCase() === rightRef.slug.toLowerCase(); } /** * Ask `gh` which repository the checkout points at, as `[HOST/]OWNER/REPO`. * * `nameWithOwner` alone would drop the host, and `gh` resolves a host-less * `--repo` against `GH_HOST` (github.com by default) — so an enterprise * checkout would silently be looked up on github.com. The repo URL carries * the host `gh` itself resolved from the remote. * * `gh`'s pick is corrected by {@link unrelatedOrigin}. */ async function resolveRepoFromCwd(cwd: string, signal?: AbortSignal): Promise { const url = requireNonEmpty(await github.text(cwd, ["repo", "view", "--json", "url", "-q", ".url"], signal), "repo"); const repo = repoFromUrl(url); if (!repo) { throw new ToolError(`GitHub CLI returned an unrecognized repository URL: ${url}`); } const origin = await unrelatedOrigin(cwd, repo, signal).catch(err => { logger.debug("gh: origin remote check failed; keeping gh's repo pick", { cwd, repo, err: String(err) }); return undefined; }); return origin ?? repo; } /** Whether `gh` ranks remote `name` above `origin` when no default repo is set. */ function outranksOrigin(name: string): boolean { const lower = name.toLowerCase(); return lower === "upstream" || lower === "github"; } /** `gh repo view --json url,parent`. */ interface GhOriginView { url: string; parent: { name: string; owner: { login: string } } | null; } /** * The `origin` repository when `gh` picked an unrelated remote over it, else * undefined. * * Without `gh repo set-default`, `gh` takes the first remote ranked * `upstream` > `github` > `origin`, assuming `upstream` is the parent of an * `origin` fork. When `origin` is not a fork of that pick — `upstream` is a * project merged from, not contributed to — PRs and issues live on `origin`. * Rejects when git or `gh` cannot answer (e.g. an `origin` on a non-GitHub * host or behind an SSH alias); the caller then keeps the pick. */ async function unrelatedOrigin(cwd: string, picked: string, signal?: AbortSignal): Promise { const repository = vcs.git(cwd); if (!repository) return undefined; const remotes = await repository.remoteList(signal); if (!remotes.includes("origin") || !remotes.some(outranksOrigin)) { return undefined; } // `gh repo set-default` marks the chosen remote `gh-resolved`. for (const name of remotes) { if (await repository.configGet(`remote.${name}.gh-resolved`, signal)) return undefined; } const originUrl = await repository.remoteUrl("origin", signal); if (!originUrl) return undefined; const view = await github.json(cwd, ["repo", "view", originUrl, "--json", "url,parent"], signal); const origin = repoFromUrl(view.url); if (!origin || githubRepoSlugEquals(origin, picked)) return undefined; if (view.parent) { const parent = formatRepoRef(parseRepoRef(origin).host, `${view.parent.owner.login}/${view.parent.name}`); if (githubRepoSlugEquals(parent, picked)) return undefined; } return origin; } export async function resolveGitHubRepo( cwd: string, repo: string | undefined, runRepo: string | undefined, signal?: AbortSignal, ): Promise { if (repo && runRepo && !githubRepoSlugEquals(repo, runRepo)) { throw new ToolError("run URL repository does not match the provided repo"); } if (repo) { return repo; } if (runRepo) { return runRepo; } return resolveRepoFromCwd(cwd, signal); } const defaultRepoMemo = new CwdLookupMemo(cwd => resolveRepoFromCwd(cwd)); /** `gh repo view` for `cwd`, memoized per absolute cwd (see {@link CwdLookupMemo}). */ export function resolveDefaultRepoMemoized(cwd: string, signal?: AbortSignal): Promise { return defaultRepoMemo.get(cwd, signal); } /** * Best-effort cached cwd → `owner/repo` resolution that swallows any failure * (not a git checkout, no GitHub remote, `gh` unauthenticated, …) into * `undefined`. Use where the cwd repo is a convenience fallback, not a safety * check. */ export async function tryResolveCurrentRepo(cwd: string, signal: AbortSignal | undefined): Promise { try { return await resolveDefaultRepoMemoized(cwd, signal); } catch { return undefined; } } /** * Best-effort fresh cwd → `owner/repo` resolution for safety checks that must * reflect the repository currently mounted at `cwd`, not the process-lifetime * default-repo cache. */ export async function tryResolveCurrentRepoFresh( cwd: string, signal: AbortSignal | undefined, ): Promise { try { return await resolveGitHubRepo(cwd, undefined, undefined, signal); } catch { return undefined; } } export async function saveArtifactText( session: ToolSession, toolType: string, text: string, ): Promise { const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.(toolType)) ?? {}; if (!artifactPath || !artifactId) { return undefined; } await Bun.write(artifactPath, text); return artifactId; } export function appendArtifactReference(text: string, artifactId: string | undefined, label: string): string { if (!artifactId) { return text; } return `${text}\n\n${label}: artifact://${artifactId}`; } export function buildTextResult( text: string, sourceUrl?: string, details?: GhToolDetails, options?: { artifactId?: string; artifactLabel?: string; useless?: boolean }, ): AgentToolResult { const builder = toolResult(details).text( appendArtifactReference(text, options?.artifactId, options?.artifactLabel ?? "Saved artifact"), ); if (sourceUrl) { builder.sourceUrl(sourceUrl); } if (options?.useless) { builder.useless(); } return builder.done(); }