refactor(coding-agent): split github tool into domain modules

- gh.ts held wire types, search, Actions run-watch, PR checkout/push/create,
  PR diff parsing and view fetch/format in 3958 lines.
- Split into gh-types, gh-search, gh-run-watch, gh-pr-checkout, gh-pr-diff,
  gh-view and a gh-common module holding the shared primitives and the single
  process-lifetime default-repo memo pair; gh.ts is now 246 lines.
- All 22 exports stay on gh.ts because tools/index.ts star-exports ./gh, so
  the issue:// and pr:// protocol handlers needed no edits.
This commit is contained in:
can1357
2026-08-08 06:32:01 +02:00
parent 7454b6e78f
commit cafe52cd9f
8 changed files with 3976 additions and 3742 deletions
@@ -0,0 +1,288 @@
import * as path from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { untilAborted } from "@oh-my-pi/pi-utils";
import * as git from "../utils/git";
import type { ToolSession } from ".";
import type { GhToolDetails } from "./gh";
import type { GhLabel, GhUser } from "./gh-types";
import { ToolError } from "./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 looksLikeGitHubUrl(value: string | undefined): boolean {
return value?.startsWith("https://github.com/") ?? false;
}
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 {
if (!repo || looksLikeGitHubUrl(identifier)) {
return;
}
args.push("--repo", repo);
}
export const REPO_API_URL_PREFIX = "https://api.github.com/repos/";
export const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/.*)?$/;
export const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/.*)?$/;
export async function requireCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise<string> {
const branch = await git.branch.current(cwd, signal);
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<string> {
const headSha = await git.head.sha(cwd, signal);
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 pushLine(lines: string[], label: string, value: string | number | boolean | undefined): void {
if (value === undefined || value === "") return;
lines.push(`${label}: ${value}`);
}
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: match[1],
prNumber: Number(match[2]),
};
}
/**
* 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: match[1],
issueNumber: Number(match[2]),
};
}
export function githubRepoSlugEquals(left: string | undefined, right: string): boolean {
if (left === undefined || left.length !== right.length) return false;
for (let idx = 0; idx < left.length; idx += 1) {
let leftCode = left.charCodeAt(idx);
let rightCode = right.charCodeAt(idx);
if (leftCode >= 65 && leftCode <= 90) leftCode += 32;
if (rightCode >= 65 && rightCode <= 90) rightCode += 32;
if (leftCode !== rightCode) return false;
}
return true;
}
export async function resolveGitHubRepo(
cwd: string,
repo: string | undefined,
runRepo: string | undefined,
signal?: AbortSignal,
): Promise<string> {
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;
}
const resolved = await git.github.text(
cwd,
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
signal,
);
return requireNonEmpty(resolved, "repo");
}
/**
* Process-lifetime cache of `gh repo view --json nameWithOwner` lookups keyed
* by absolute cwd. Avoids repeated `gh` chatter when the same protocol handler
* or tool call resolves the default repo many times in a row.
*
* The shared lookup is intentionally **not** bound to any caller's
* AbortSignal. Cancelling one caller would otherwise kill the underlying
* `gh repo view` for every concurrent waiter on the same cwd. Each caller's
* signal is honored at the wait point via `untilAborted` instead, so an abort
* unwinds only that caller.
*/
export const DEFAULT_REPO_RESOLVED = new Map<string, string>();
export const DEFAULT_REPO_INFLIGHT = new Map<string, Promise<string>>();
export async function resolveDefaultRepoMemoized(cwd: string, signal?: AbortSignal): Promise<string> {
const key = path.resolve(cwd);
const ready = DEFAULT_REPO_RESOLVED.get(key);
if (ready) return ready;
let pending = DEFAULT_REPO_INFLIGHT.get(key);
if (!pending) {
pending = (async () => {
// No caller signal: this lookup is shared across every concurrent
// waiter on the same cwd.
const resolved = await git.github.text(cwd, [
"repo",
"view",
"--json",
"nameWithOwner",
"-q",
".nameWithOwner",
]);
const value = requireNonEmpty(resolved, "repo");
DEFAULT_REPO_RESOLVED.set(key, value);
return value;
})();
// Drop the in-flight slot on settle so failures don't poison the cache
// and so a successful resolution survives only in `DEFAULT_REPO_RESOLVED`.
void pending.then(
() => DEFAULT_REPO_INFLIGHT.delete(key),
() => DEFAULT_REPO_INFLIGHT.delete(key),
);
DEFAULT_REPO_INFLIGHT.set(key, pending);
}
return untilAborted(signal, pending);
}
/**
* 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<string | undefined> {
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<string | undefined> {
try {
return await resolveGitHubRepo(cwd, undefined, undefined, signal);
} catch {
return undefined;
}
}
export async function saveArtifactText(
session: ToolSession,
toolType: string,
text: string,
): Promise<string | undefined> {
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<GhToolDetails> {
const builder = toolResult<GhToolDetails>(details).text(
appendArtifactReference(text, options?.artifactId, options?.artifactLabel ?? "Saved artifact"),
);
if (sourceUrl) {
builder.sourceUrl(sourceUrl);
}
if (options?.useless) {
builder.useless();
}
return builder.done();
}
@@ -0,0 +1,679 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { getWorktreeDir, hashPath, isEnoent } from "@oh-my-pi/pi-utils";
import * as git from "../utils/git";
import type { ToolSession } from ".";
import type { GhPrCheckoutSummary, GhToolDetails } from "./gh";
import {
appendRepoFlag,
buildTextResult,
formatAuthor,
formatLabels,
normalizeOptionalString,
normalizePrIdentifierList,
normalizeText,
parsePullRequestUrl,
pushLine,
requireCurrentGitBranch,
requireNonEmpty,
} from "./gh-common";
import { formatShortSha } from "./gh-format";
import type { GhPrViewData, GhRepoViewData, GithubInput } from "./gh-types";
import { GH_PR_FIELDS_NO_COMMENTS } from "./gh-view";
import { invalidateAllForNumber } from "./github-cache";
import { ToolError, throwIfAborted } from "./tool-errors";
export const GH_REPO_CLONE_FIELDS = ["nameWithOwner", "sshUrl", "url"];
export const GH_PR_CHECKOUT_FIELDS = [
"baseRefName",
"headRefName",
"headRefOid",
"headRepository",
"headRepositoryOwner",
"isCrossRepository",
"maintainerCanModify",
"number",
"title",
"url",
];
export function sanitizeRemoteName(value: string): string {
const sanitized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/g, "")
.replace(/-+$/g, "");
return sanitized.length > 0 ? `fork-${sanitized}` : "fork";
}
/** Maximum disambiguation suffixes we try before giving up on a worktree path. */
export const WORKTREE_PATH_MAX_SUFFIX = 100;
export function toLocalBranchRef(value: string): string {
return `refs/heads/${value}`;
}
export async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
const repoRoot = await git.repo.root(cwd, signal);
if (!repoRoot) {
throw new ToolError("Current git repository is unavailable.");
}
return repoRoot;
}
export async function requirePrimaryGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
const primaryRepoRoot = await git.repo.primaryRoot(cwd, signal);
if (!primaryRepoRoot) {
throw new ToolError("Current git repository is unavailable.");
}
return primaryRepoRoot;
}
/**
* Resolve a worktree path that is free of conflicts.
*
* Given a `basePath`, return either `basePath` itself or `${basePath}-2`,
* `${basePath}-3`, … up to {@link WORKTREE_PATH_MAX_SUFFIX} — whichever is the
* first variant that is **not** registered with git as another worktree and
* **not** present on disk. The numeric tail salvages two rare cases that
* would otherwise abort a checkout: stale leftover dirs from an interrupted
* `git worktree add`, and the (vanishingly unlikely) `hashPath` collision
* between two repos that happen to produce the same 7-hex digest.
*/
export async function resolveAvailableWorktreePath(
basePath: string,
existingWorktrees: git.GitWorktreeEntry[],
): Promise<string> {
const registered = new Set(existingWorktrees.map(entry => path.resolve(entry.path)));
for (let attempt = 0; attempt < WORKTREE_PATH_MAX_SUFFIX; attempt += 1) {
const candidate = attempt === 0 ? basePath : `${basePath}-${attempt + 1}`;
const normalized = path.resolve(candidate);
if (registered.has(normalized)) continue;
try {
await fs.stat(normalized);
} catch (error) {
if (isEnoent(error)) {
return candidate;
}
throw error;
}
}
throw new ToolError(
`could not find an unused worktree path under ${basePath} (tried ${WORKTREE_PATH_MAX_SUFFIX} suffixes)`,
);
}
export function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewData, "url" | "sshUrl">): string {
if (originUrl?.startsWith("http://") || originUrl?.startsWith("https://")) {
return normalizeOptionalString(repo.url) ?? normalizeOptionalString(repo.sshUrl) ?? "";
}
return normalizeOptionalString(repo.sshUrl) ?? normalizeOptionalString(repo.url) ?? "";
}
export async function getRemoteUrls(repoRoot: string, signal?: AbortSignal): Promise<Map<string, string>> {
const remotes = await git.remote.list(repoRoot, signal);
const urls = new Map<string, string>();
for (const remoteName of remotes) {
const remoteUrl = await git.remote.url(repoRoot, remoteName, signal);
if (remoteUrl) {
urls.set(remoteName, remoteUrl);
}
}
return urls;
}
export async function ensurePrRemote(
repoRoot: string,
data: GhPrViewData,
signal?: AbortSignal,
): Promise<{ name: string; url: string }> {
if (!data.isCrossRepository) {
const originUrl = await git.remote.url(repoRoot, "origin", signal);
if (!originUrl) {
throw new ToolError("origin remote is unavailable for this repository.");
}
return {
name: "origin",
url: originUrl,
};
}
const headRepository = requireNonEmpty(data.headRepository?.nameWithOwner, "head repository");
const repoSummary = await git.github.json<GhRepoViewData>(
repoRoot,
["repo", "view", headRepository, "--json", GH_REPO_CLONE_FIELDS.join(",")],
signal,
{ repoProvided: true },
);
const originUrl = await git.remote.url(repoRoot, "origin", signal);
const remoteUrl = selectPrCloneUrl(originUrl, repoSummary);
if (!remoteUrl) {
throw new ToolError(`Could not determine a clone URL for ${headRepository}.`);
}
const remotes = await getRemoteUrls(repoRoot, signal);
for (const [remoteName, url] of remotes) {
if (url === remoteUrl) {
return { name: remoteName, url };
}
}
const preferredRemoteName = sanitizeRemoteName(
data.headRepositoryOwner?.login ?? headRepository.split("/")[0] ?? "fork",
);
let remoteName = preferredRemoteName;
let suffix = 2;
while (remotes.has(remoteName)) {
remoteName = `${preferredRemoteName}-${suffix}`;
suffix += 1;
}
await git.remote.add(repoRoot, remoteName, remoteUrl, signal);
return {
name: remoteName,
url: remoteUrl,
};
}
export async function resolvePrBranchPushTarget(
repoRoot: string,
localBranch: string,
signal?: AbortSignal,
): Promise<{
remoteName: string;
remoteBranch: string;
remoteUrl?: string;
prUrl?: string;
maintainerCanModify?: boolean;
isCrossRepository: boolean;
}> {
const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
if (!headRef) {
throw new ToolError(`branch ${localBranch} has no PR push metadata; check it out via op: pr_checkout first`);
}
const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal);
const maintainerCanModifyValue = await git.config.getBranch(
repoRoot,
localBranch,
"ompPrMaintainerCanModify",
signal,
);
const isCrossRepositoryValue = await git.config.getBranch(repoRoot, localBranch, "ompPrIsCrossRepository", signal);
const remoteName = pushRemote ?? remote;
if (!remoteName) {
throw new ToolError(`branch ${localBranch} has no configured push remote`);
}
return {
remoteName,
remoteBranch: headRef,
remoteUrl: await git.remote.url(repoRoot, remoteName, signal),
prUrl,
maintainerCanModify:
maintainerCanModifyValue === undefined
? undefined
: ["1", "true", "yes", "on"].includes(maintainerCanModifyValue.toLowerCase()),
isCrossRepository: ["1", "true", "yes", "on"].includes((isCrossRepositoryValue ?? "").toLowerCase()),
};
}
export function formatPrCheckoutResult(options: {
data: GhPrViewData;
localBranch: string;
worktreePath: string;
remoteName: string;
remoteUrl: string;
reused: boolean;
}): string {
const { data, localBranch, worktreePath, remoteName, remoteUrl, reused } = options;
const lines: string[] = [
reused ? `# Pull Request #${data.number ?? "?"} Worktree` : `# Checked Out Pull Request #${data.number ?? "?"}`,
"",
];
pushLine(lines, "Title", data.title ?? undefined);
pushLine(lines, "URL", data.url);
pushLine(lines, "Base", data.baseRefName);
pushLine(lines, "Head", data.headRefName);
pushLine(lines, "Local branch", localBranch);
pushLine(lines, "Worktree", worktreePath);
pushLine(lines, "Remote", remoteName);
pushLine(lines, "Remote URL", remoteUrl);
pushLine(lines, "Cross repository", data.isCrossRepository);
pushLine(lines, "Maintainer can modify", data.maintainerCanModify);
lines.push("");
lines.push(
reused
? "Reused the existing PR worktree."
: "Created a dedicated worktree for this PR and configured the local branch to push back to the PR head branch.",
);
return lines.join("\n").trim();
}
export function formatPrPushResult(options: {
localBranch: string;
remoteName: string;
remoteBranch: string;
remoteUrl?: string;
prUrl?: string;
forceWithLease: boolean;
}): string {
const lines: string[] = ["# Pushed Pull Request Branch", ""];
pushLine(lines, "Local branch", options.localBranch);
pushLine(lines, "Remote", options.remoteName);
pushLine(lines, "Remote branch", options.remoteBranch);
pushLine(lines, "Remote URL", options.remoteUrl);
pushLine(lines, "PR", options.prUrl);
pushLine(lines, "Force with lease", options.forceWithLease);
lines.push("");
lines.push(`Pushed ${options.localBranch} to ${options.remoteName}:${options.remoteBranch}.`);
return lines.join("\n").trim();
}
export function joinSections(sections: string[]): string[] {
return sections.flatMap((section, idx) => (idx === 0 ? [section] : ["", "---", "", section]));
}
export async function executePrCheckout(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = normalizeOptionalString(params.repo);
const force = params.force ?? false;
const prList = normalizePrIdentifierList(params.pr);
const prRefs = prList.length > 0 ? prList : [undefined];
const isMulti = prRefs.length > 1;
const settled = await Promise.allSettled(
prRefs.map(prRef => checkoutPullRequest(session, signal, { prRef, repo, force })),
);
const outcomes: PrCheckoutOutcome[] = [];
const failures: Array<{ prRef: string | undefined; reason: unknown }> = [];
for (let i = 0; i < settled.length; i++) {
const entry = settled[i];
if (entry.status === "fulfilled") outcomes.push(entry.value);
else failures.push({ prRef: prRefs[i], reason: entry.reason });
}
if (failures.length > 0) {
throwIfAborted(signal);
const failureLines = failures.map(
f => `- ${f.prRef ?? "(current branch)"}: ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`,
);
if (outcomes.length === 0) {
if (failures.length === 1) throw failures[0].reason;
throw new ToolError(`all ${failures.length} PR checkouts failed:\n${failureLines.join("\n")}`);
}
// Partial success: report the worktrees that did get created alongside
// the failures so the agent does not lose track of them.
const sections = outcomes.map(formatPrCheckoutResult);
const header = `# ${outcomes.length}/${settled.length} Pull Request Worktrees checked out (${failures.length} failed)`;
const text = [header, "", ...joinSections(sections), "", "## Failed", ...failureLines].join("\n").trim();
return buildTextResult(text, undefined, {
repo,
checkouts: outcomes.map(outcomeToSummary),
});
}
if (!isMulti) {
const [outcome] = outcomes;
return buildTextResult(formatPrCheckoutResult(outcome), outcome.data.url, {
repo: repo ?? outcome.data.headRepository?.nameWithOwner,
branch: outcome.localBranch,
worktreePath: outcome.worktreePath,
remote: outcome.remoteName,
remoteBranch: outcome.headRefName,
checkouts: [outcomeToSummary(outcome)],
});
}
const sections = outcomes.map(formatPrCheckoutResult);
const reusedCount = outcomes.reduce((acc, o) => acc + (o.reused ? 1 : 0), 0);
const newCount = outcomes.length - reusedCount;
const headerParts: string[] = [];
if (newCount > 0) headerParts.push(`${newCount} checked out`);
if (reusedCount > 0) headerParts.push(`${reusedCount} reused`);
const header = `# ${outcomes.length} Pull Request Worktrees (${headerParts.join(", ")})`;
const text = [header, "", ...joinSections(sections)].join("\n").trim();
return buildTextResult(text, undefined, {
repo,
checkouts: outcomes.map(outcomeToSummary),
});
}
export interface PrCheckoutOptions {
prRef: string | undefined;
repo: string | undefined;
force: boolean;
}
export interface PrCheckoutOutcome {
data: GhPrViewData;
localBranch: string;
worktreePath: string;
remoteName: string;
remoteUrl: string;
headRefName: string;
reused: boolean;
}
export async function checkoutPullRequest(
session: ToolSession,
signal: AbortSignal | undefined,
options: PrCheckoutOptions,
): Promise<PrCheckoutOutcome> {
const { prRef, repo, force } = options;
if (prRef?.startsWith("-")) {
throw new ToolError(`invalid PR identifier: ${prRef}. Pass a PR number, URL, or branch name.`);
}
const args = ["pr", "view"];
if (prRef) args.push(prRef);
appendRepoFlag(args, repo, prRef);
args.push("--json", GH_PR_CHECKOUT_FIELDS.join(","));
const data = await git.github.json<GhPrViewData>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
const prNumber = data.number;
if (typeof prNumber !== "number") {
throw new ToolError("GitHub CLI did not return a pull request number.");
}
const headRefName = requireNonEmpty(data.headRefName, "head branch");
const headRefOid = requireNonEmpty(data.headRefOid, "head commit");
const repoRoot = await requireGitRepoRoot(session.cwd, signal);
const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
const localBranch = `pr-${prNumber}`;
const worktreePath = getWorktreeDir(`${prNumber}-${hashPath(primaryRepoRoot)}`);
// Every git mutation against `repoRoot` from here on must run under the
// per-repo lock. Worktrees of the same primary repo share `.git/config`,
// `commit-graph` chain, `packed-refs`, and worktree metadata files — git
// uses O_EXCL lock files for each, with no waiter. Concurrent in-process
// callers (e.g. parallel `pr_checkout` calls) would otherwise lose lock
// races and surface "could not lock config file" / "Another git process
// seems to be running" errors. The gh API call above stays outside the
// lock so multiple checkouts can fetch PR metadata in parallel.
return git.withRepoLock(
repoRoot,
async () => {
const existingWorktrees = await git.worktree.list(repoRoot, signal);
const existingWorktree = existingWorktrees.find(entry => entry.branch === toLocalBranchRef(localBranch));
const remote = await ensurePrRemote(repoRoot, data, signal);
await git.fetch(
repoRoot,
remote.name,
`refs/heads/${headRefName}`,
`refs/remotes/${remote.name}/${headRefName}`,
{ signal },
);
if (!existingWorktree) {
const localBranchRef = toLocalBranchRef(localBranch);
const localBranchExists = await git.ref.exists(repoRoot, localBranchRef, signal);
if (localBranchExists) {
const existingOid = await git.ref.resolve(repoRoot, localBranchRef, signal);
if (existingOid !== headRefOid) {
if (!force) {
throw new ToolError(
`local branch ${localBranch} already exists at ${formatShortSha(existingOid ?? undefined) ?? existingOid ?? "unknown commit"}; pass force=true to reset it`,
);
}
await git.branch.force(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
}
} else {
await git.branch.create(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
}
}
await git.config.setBranch(repoRoot, localBranch, "remote", remote.name, signal);
await git.config.setBranch(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal);
await git.config.setBranch(repoRoot, localBranch, "pushRemote", remote.name, signal);
await git.config.setBranch(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal);
await git.config.setBranch(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal);
await git.config.setBranch(
repoRoot,
localBranch,
"ompPrIsCrossRepository",
String(Boolean(data.isCrossRepository)),
signal,
);
await git.config.setBranch(
repoRoot,
localBranch,
"ompPrMaintainerCanModify",
String(Boolean(data.maintainerCanModify)),
signal,
);
let finalWorktreePath = existingWorktree?.path ?? worktreePath;
if (!existingWorktree) {
finalWorktreePath = await resolveAvailableWorktreePath(worktreePath, existingWorktrees);
await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
}
const resolvedWorktreePath = await fs.realpath(finalWorktreePath);
return {
data,
localBranch,
worktreePath: resolvedWorktreePath,
remoteName: remote.name,
remoteUrl: remote.url,
headRefName,
reused: Boolean(existingWorktree),
};
},
signal,
);
}
export function outcomeToSummary(outcome: PrCheckoutOutcome): GhPrCheckoutSummary {
return {
prNumber: typeof outcome.data.number === "number" ? outcome.data.number : undefined,
url: outcome.data.url ?? undefined,
branch: outcome.localBranch,
worktreePath: outcome.worktreePath,
remote: outcome.remoteName,
remoteBranch: outcome.headRefName,
reused: outcome.reused,
};
}
export async function executePrPush(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repoRoot = await requireGitRepoRoot(session.cwd, signal);
const localBranch = normalizeOptionalString(params.branch) ?? (await requireCurrentGitBranch(repoRoot, signal));
const refExists = await git.ref.exists(repoRoot, toLocalBranchRef(localBranch), signal);
if (!refExists) {
throw new ToolError(`local branch ${localBranch} does not exist`);
}
const target = await resolvePrBranchPushTarget(repoRoot, localBranch, signal);
const currentBranch = await git.branch.current(repoRoot, signal);
const sourceRef = currentBranch === localBranch ? "HEAD" : toLocalBranchRef(localBranch);
const refspec = `${sourceRef}:refs/heads/${target.remoteBranch}`;
await git.push(repoRoot, {
forceWithLease: params.forceWithLease,
refspec,
remote: target.remoteName,
signal,
});
// A successful push changes what `pr://N` and `pr://N/diff` should show;
// drop the cached rows so the canonical "push → re-read diff" flow sees
// fresh data instead of a soft-TTL stale snapshot.
const pushedPr = parsePullRequestUrl(target.prUrl);
if (pushedPr.prNumber !== undefined) {
invalidateAllForNumber(pushedPr.prNumber, pushedPr.repo);
}
return buildTextResult(
formatPrPushResult({
localBranch,
remoteName: target.remoteName,
remoteBranch: target.remoteBranch,
remoteUrl: target.remoteUrl,
prUrl: target.prUrl,
forceWithLease: params.forceWithLease ?? false,
}),
target.prUrl,
{
branch: localBranch,
remote: target.remoteName,
remoteBranch: target.remoteBranch,
},
);
}
export async function executePrCreate(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = normalizeOptionalString(params.repo);
const title = normalizeOptionalString(params.title);
const body = params.body;
const base = normalizeOptionalString(params.base);
const head = normalizeOptionalString(params.head);
const draft = params.draft ?? false;
const fill = params.fill ?? false;
const reviewers = normalizePrIdentifierList(params.reviewer);
const assignees = normalizePrIdentifierList(params.assignee);
const labels = normalizePrIdentifierList(params.label);
if (!fill && !title) {
throw new ToolError("title is required unless fill is true");
}
if (fill && (title || body !== undefined)) {
throw new ToolError("fill is mutually exclusive with title and body");
}
const args = ["pr", "create"];
appendRepoFlag(args, repo);
if (title) args.push("--title", title);
if (base) args.push("--base", base);
if (head) args.push("--head", head);
if (draft) args.push("--draft");
if (fill) args.push("--fill");
for (const reviewer of reviewers) args.push("--reviewer", reviewer);
for (const assignee of assignees) args.push("--assignee", assignee);
for (const label of labels) args.push("--label", label);
let bodyDir: string | undefined;
try {
if (!fill) {
if (body !== undefined && body.length > 0) {
// Route through a temp file so multi-KB bodies stay clear of any
// argv-length limits and shell-quoting hazards on uncommon platforms.
bodyDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-pr-body-"));
const bodyFile = path.join(bodyDir, "body.md");
await Bun.write(bodyFile, body);
args.push("--body-file", bodyFile);
} else {
// Avoid gh dropping into an interactive editor when no body is given.
args.push("--body", "");
}
}
const output = await git.github.text(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
const url =
output
.split("\n")
.map(line => line.trim())
.find(line => line.startsWith("https://github.com/")) ?? output.trim();
const parsed = parsePullRequestUrl(url);
const resolvedRepo = repo ?? parsed.repo;
let prView: GhPrViewData | undefined;
if (resolvedRepo && parsed.prNumber !== undefined) {
try {
prView = await git.github.json<GhPrViewData>(
session.cwd,
[
"pr",
"view",
String(parsed.prNumber),
"--repo",
resolvedRepo,
"--json",
GH_PR_FIELDS_NO_COMMENTS.join(","),
],
signal,
{ repoProvided: true },
);
} catch {
// Best-effort summary; PR creation already succeeded.
}
}
const text = formatPrCreateResult({
url,
prNumber: parsed.prNumber,
data: prView,
title,
base,
head,
draft,
});
return buildTextResult(text, url || prView?.url);
} finally {
if (bodyDir) {
await fs.rm(bodyDir, { recursive: true, force: true }).catch(() => {});
}
}
}
export function formatPrCreateResult(options: {
url: string;
prNumber?: number;
data?: GhPrViewData;
title?: string;
base?: string;
head?: string;
draft?: boolean;
}): string {
const number = options.prNumber ?? options.data?.number;
const headerTitle = options.data?.title ?? options.title ?? "Untitled";
const header =
number !== undefined
? `# Created Pull Request #${number}: ${headerTitle}`
: `# Created Pull Request: ${headerTitle}`;
const lines: string[] = [header, ""];
pushLine(lines, "URL", options.url || options.data?.url);
pushLine(lines, "State", options.data?.state);
pushLine(lines, "Draft", options.data?.isDraft ?? options.draft);
pushLine(lines, "Base", options.data?.baseRefName ?? options.base);
pushLine(lines, "Head", options.data?.headRefName ?? options.head);
pushLine(lines, "Author", formatAuthor(options.data?.author));
pushLine(lines, "Created", options.data?.createdAt);
pushLine(lines, "Labels", formatLabels(options.data?.labels));
const bodyText = normalizeText(options.data?.body);
if (bodyText) {
lines.push("");
lines.push("## Body");
lines.push("");
lines.push(bodyText);
}
return lines.join("\n").trim();
}
@@ -0,0 +1,473 @@
import { logger } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import * as git from "../utils/git";
import { appendRepoFlag } from "./gh-common";
import type { ViewLookupResult } from "./gh-view";
import { getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
import { ToolError } from "./tool-errors";
export const PR_DIFF_FILES_PAGE_SIZE = 100;
export const PR_DIFF_FILES_MAX = 3000;
// ────────────────────────────────────────────────────────────────────────────
// PR diff fetcher
//
// Used by the `pr://<n>/diff[/…]` internal-URL family. Stores the verbatim
// `gh pr diff` text plus a parsed file index so the listing, full-diff, and
// per-file slice variants all share one cache row.
// ────────────────────────────────────────────────────────────────────────────
export interface PrDiffFile {
/** Display path. Prefers the post-image (`b/<path>`) when present. */
path: string;
additions: number;
deletions: number;
changeType: "modified" | "added" | "deleted" | "renamed" | "binary";
/** Pre-image path for renames/deletes; same as `path` otherwise. */
oldPath?: string;
/** Byte offset of the section's `diff --git` line in the unified diff. */
startOffset: number;
/** Byte offset of the next section (or end-of-text). */
endOffset: number;
}
export interface PrDiffPayload {
/** Full unified diff text as returned by `gh pr diff --color never`. */
unified: string;
files: PrDiffFile[];
}
export interface PrDiffLookupOptions {
cwd: string;
repo: string;
number: number;
signal?: AbortSignal;
settings?: Settings;
cacheAuthKey?: string | null;
}
/**
* Split `gh pr diff` output on `^diff --git ` boundaries and parse per-file
* metadata. The unified diff is preserved verbatim so callers can slice it by
* byte offsets without re-running gh.
*/
export function parsePrUnifiedDiff(text: string): PrDiffPayload {
const files: PrDiffFile[] = [];
if (text.length === 0) {
return { unified: text, files };
}
// Walk match positions manually so we capture each section's byte range.
const sectionStarts: number[] = [];
const re = /^diff --git /gm;
let m: RegExpExecArray | null = re.exec(text);
while (m !== null) {
sectionStarts.push(m.index);
// Avoid zero-length match infinite loop (regex has fixed prefix, but
// be explicit).
if (re.lastIndex === m.index) re.lastIndex += 1;
m = re.exec(text);
}
for (let i = 0; i < sectionStarts.length; i += 1) {
const startOffset = sectionStarts[i] ?? 0;
const endOffset = sectionStarts[i + 1] ?? text.length;
const section = text.slice(startOffset, endOffset);
files.push(parsePrDiffSection(section, startOffset, endOffset));
}
return { unified: text, files };
}
export interface ParsedDiffHeaderToken {
value: string;
nextIndex: number;
}
export function skipDiffHeaderSpaces(text: string, index: number): number {
let i = index;
while (text.charAt(i) === " ") i += 1;
return i;
}
export function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffHeaderToken {
const next = text.charAt(slashIndex + 1);
if (next === "") return { value: "\\", nextIndex: slashIndex + 1 };
if (next >= "0" && next <= "7") {
let end = slashIndex + 1;
while (end < text.length && end < slashIndex + 4) {
const digit = text.charAt(end);
if (digit < "0" || digit > "7") break;
end += 1;
}
return {
value: String.fromCharCode(Number.parseInt(text.slice(slashIndex + 1, end), 8)),
nextIndex: end,
};
}
switch (next) {
case "a":
return { value: "\x07", nextIndex: slashIndex + 2 };
case "b":
return { value: "\b", nextIndex: slashIndex + 2 };
case "f":
return { value: "\f", nextIndex: slashIndex + 2 };
case "n":
return { value: "\n", nextIndex: slashIndex + 2 };
case "r":
return { value: "\r", nextIndex: slashIndex + 2 };
case "t":
return { value: "\t", nextIndex: slashIndex + 2 };
case "v":
return { value: "\v", nextIndex: slashIndex + 2 };
case "\\":
case '"':
return { value: next, nextIndex: slashIndex + 2 };
default:
return { value: next, nextIndex: slashIndex + 2 };
}
}
export function parseDiffQuotedToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
if (text.charAt(startIndex) !== '"') return undefined;
let value = "";
for (let i = startIndex + 1; i < text.length; i += 1) {
const ch = text.charAt(i);
if (ch === '"') return { value, nextIndex: i + 1 };
if (ch !== "\\") {
value += ch;
continue;
}
const escaped = parseDiffQuotedEscape(text, i);
value += escaped.value;
i = escaped.nextIndex - 1;
}
return undefined;
}
export function parseDiffHeaderToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
const start = skipDiffHeaderSpaces(text, startIndex);
if (start >= text.length) return undefined;
const quoted = parseDiffQuotedToken(text, start);
if (quoted) return quoted;
const end = text.indexOf(" ", start);
if (end === -1) return { value: text.slice(start), nextIndex: text.length };
return { value: text.slice(start, end), nextIndex: end };
}
export function stripPrDiffPathPrefix(value: string, prefix: "a/" | "b/"): string | undefined {
return value.startsWith(prefix) ? value.slice(prefix.length) : undefined;
}
export function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: string } {
const trail = header.slice("diff --git ".length);
if (trail.startsWith('"')) {
const oldToken = parseDiffQuotedToken(trail, 0);
if (!oldToken) return {};
const newToken = parseDiffHeaderToken(trail, oldToken.nextIndex);
if (!newToken) return {};
return {
oldPath: stripPrDiffPathPrefix(oldToken.value, "a/"),
newPath: stripPrDiffPathPrefix(newToken.value, "b/"),
};
}
const bIdx = trail.indexOf(" b/");
if (trail.startsWith("a/") && bIdx > 0) {
return {
oldPath: trail.slice(2, bIdx),
newPath: trail.slice(bIdx + 3),
};
}
return {};
}
export function isPrDiffFileHeaderLine(line: string): boolean {
return (
line === "--- /dev/null" ||
line === "+++ /dev/null" ||
line.startsWith("--- a/") ||
line.startsWith("+++ b/") ||
line.startsWith('--- "a/') ||
line.startsWith('+++ "b/')
);
}
export function parsePrDiffSection(section: string, startOffset: number, endOffset: number): PrDiffFile {
const lines = section.split("\n");
const header = lines[0] ?? "";
const headerPaths = parsePrDiffHeaderPaths(header);
let oldPath = headerPaths.oldPath;
let newPath = headerPaths.newPath;
let changeType: PrDiffFile["changeType"] = "modified";
let isBinary = false;
let additions = 0;
let deletions = 0;
let inHunk = false;
for (let li = 1; li < lines.length; li += 1) {
const line = lines[li] ?? "";
if (line.startsWith("new file mode")) {
changeType = "added";
continue;
}
if (line.startsWith("deleted file mode")) {
changeType = "deleted";
continue;
}
if (line.startsWith("rename from ")) {
changeType = "renamed";
oldPath = line.slice("rename from ".length);
continue;
}
if (line.startsWith("rename to ")) {
newPath = line.slice("rename to ".length);
continue;
}
if (line.startsWith("Binary files ") && line.endsWith(" differ")) {
isBinary = true;
continue;
}
if (line.startsWith("@@ ")) {
inHunk = true;
continue;
}
if (!inHunk && isPrDiffFileHeaderLine(line)) continue;
if (line.startsWith("+")) {
additions += 1;
} else if (line.startsWith("-")) {
deletions += 1;
}
}
if (isBinary) {
if (changeType === "modified") changeType = "binary";
additions = 0;
deletions = 0;
}
const displayPath =
changeType === "deleted" ? (oldPath ?? newPath ?? "(unknown)") : (newPath ?? oldPath ?? "(unknown)");
const file: PrDiffFile = {
path: displayPath,
additions,
deletions,
changeType,
startOffset,
endOffset,
};
if (oldPath && oldPath !== displayPath) {
file.oldPath = oldPath;
}
return file;
}
/**
* A single entry from `GET /repos/{owner}/{repo}/pulls/{n}/files`. `patch` is
* absent for binary files and for individual file diffs GitHub deems too large
* to render.
*/
export interface GhPrFileApi {
filename?: string;
previous_filename?: string;
status?: string;
additions?: number;
deletions?: number;
patch?: string;
}
export interface GhPrApi {
changed_files?: number;
}
/**
* GitHub rejects the aggregate PR diff endpoint with HTTP 406 once the diff
* exceeds 20,000 lines. Detect that specific failure so the caller can fall
* back to the per-file endpoint instead of aborting the whole review.
*/
export function isPrDiffTooLargeError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return (
/\bHTTP 406\b/.test(message) ||
/exceeded the maximum number of lines/i.test(message) ||
/\btoo_large\b/.test(message)
);
}
export function formatSyntheticDiffPath(prefix: "a/" | "b/", path: string): string {
const prefixedPath = `${prefix}${path}`;
if (!/[\u0000-\u001F\s"\\]/.test(prefixedPath)) return prefixedPath;
let escaped = "";
for (const char of prefixedPath) {
switch (char) {
case "\\":
escaped += "\\\\";
break;
case '"':
escaped += '\\"';
break;
case "\n":
escaped += "\\n";
break;
case "\r":
escaped += "\\r";
break;
case "\t":
escaped += "\\t";
break;
default: {
const code = char.charCodeAt(0);
escaped += code < 32 ? `\\${code.toString(8).padStart(3, "0")}` : char;
}
}
}
return `"${escaped}"`;
}
/**
* Reconstruct a `diff --git` section from a single files-API entry. The API's
* `patch` field carries only the hunk body, so the `diff --git`/`---`/`+++`
* headers are synthesized to match `gh pr diff` output — this keeps
* {@link parsePrUnifiedDiff} and the review parser producing identical section
* boundaries and byte offsets. Files whose `patch` is omitted (binary or
* too-large) stay visible with an explicit marker rather than being dropped.
*/
export function buildSyntheticDiffSection(file: GhPrFileApi): string | undefined {
const newPath = file.filename;
if (!newPath) return undefined;
const status = file.status ?? "modified";
const oldPath = file.previous_filename ?? newPath;
const oldDiffPath = formatSyntheticDiffPath("a/", oldPath);
const newDiffPath = formatSyntheticDiffPath("b/", newPath);
const lines: string[] = [`diff --git ${oldDiffPath} ${newDiffPath}`];
if (status === "added") {
lines.push("new file mode 100644");
} else if (status === "removed") {
lines.push("deleted file mode 100644");
} else if (status === "renamed" || file.previous_filename) {
lines.push(`rename from ${oldPath}`, `rename to ${newPath}`);
}
if (typeof file.patch === "string" && file.patch.length > 0) {
lines.push(status === "added" ? "--- /dev/null" : `--- ${oldDiffPath}`);
lines.push(status === "removed" ? "+++ /dev/null" : `+++ ${newDiffPath}`);
lines.push(file.patch);
} else {
lines.push(
`* patch unavailable (binary or too large); additions ${file.additions ?? 0}, deletions ${file.deletions ?? 0}`,
);
}
return lines.join("\n");
}
/**
* Fallback PR diff retrieval via the paginated per-file endpoint, used when the
* aggregate `gh pr diff` is rejected for exceeding GitHub's 20,000-line limit.
* The per-file patches are not subject to that aggregate cap, so even very
* large PRs can be reassembled into a synthetic unified diff.
*/
export async function fetchPrDiffViaFilesApi(
cwd: string,
repo: string,
number: number,
signal: AbortSignal | undefined,
): Promise<string> {
const pull = await git.github.json<GhPrApi>(
cwd,
["api", "--method", "GET", `/repos/${repo}/pulls/${number}`],
signal,
{ repoProvided: true },
);
if ((pull.changed_files ?? 0) > PR_DIFF_FILES_MAX) {
throw new ToolError(
`Pull request changes ${pull.changed_files} files, exceeding GitHub's ${PR_DIFF_FILES_MAX}-file limit for the per-file diff API.`,
);
}
const sections: string[] = [];
let page = 1;
while (true) {
const response = await git.github.json<GhPrFileApi[]>(
cwd,
[
"api",
"--method",
"GET",
`/repos/${repo}/pulls/${number}/files`,
"-F",
`per_page=${PR_DIFF_FILES_PAGE_SIZE}`,
"-F",
`page=${page}`,
],
signal,
{ repoProvided: true },
);
for (const file of response) {
const section = buildSyntheticDiffSection(file);
if (section) sections.push(section);
}
if (response.length < PR_DIFF_FILES_PAGE_SIZE) {
break;
}
page += 1;
}
// Trailing newline mirrors `gh pr diff` so downstream parsers splitting on
// `^diff --git ` see identical boundaries.
return sections.length > 0 ? `${sections.join("\n")}\n` : "";
}
export async function fetchPrDiffFresh(
cwd: string,
repo: string,
number: number,
signal: AbortSignal | undefined,
): Promise<{ rendered: string; sourceUrl: string | undefined; payload: PrDiffPayload }> {
const args = ["pr", "diff", String(number), "--color", "never"];
appendRepoFlag(args, repo, String(number));
let text: string;
try {
text = await git.github.text(cwd, args, signal, { repoProvided: true, trimOutput: false });
} catch (err) {
if (!isPrDiffTooLargeError(err)) throw err;
logger.debug("gh pr diff exceeded GitHub's aggregate line limit; falling back to per-file API", {
repo,
number,
err: String(err),
});
text = await fetchPrDiffViaFilesApi(cwd, repo, number, signal);
}
const payload = parsePrUnifiedDiff(text);
// `rendered` already carries the verbatim diff; blank the payload copy so
// the cache row stores a potentially huge diff once instead of twice.
// `getOrFetchPrDiff` rehydrates `unified` from `rendered`.
return { rendered: text, sourceUrl: undefined, payload: { unified: "", files: payload.files } };
}
/**
* Cache-aware PR diff fetcher. Stores the full unified diff plus a parsed
* file index in a single `pr-diff` cache row so the listing, full-diff, and
* per-file slice variants of `pr://<n>/diff` share one `gh pr diff`
* invocation.
*/
export async function getOrFetchPrDiff(options: PrDiffLookupOptions): Promise<ViewLookupResult<PrDiffPayload>> {
const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
const doFetch = () => fetchPrDiffFresh(options.cwd, options.repo, options.number, options.signal);
const lookup = await getOrFetchView<PrDiffPayload>({
repo: options.repo,
kind: "pr-diff",
number: options.number,
includeComments: false,
settings: options.settings,
authKey,
fetchFresh: doFetch,
});
return {
rendered: lookup.rendered,
sourceUrl: lookup.sourceUrl,
// Rehydrate the unified text from `rendered` (stored once per row).
payload: { unified: lookup.rendered, files: lookup.payload.files },
status: lookup.status,
fetchedAt: lookup.fetchedAt,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,500 @@
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import * as git from "../utils/git";
import type { ToolSession } from ".";
import type { GhToolDetails } from "./gh";
import {
buildTextResult,
formatAuthor,
formatLabels,
normalizeOptionalString,
normalizeText,
pushLine,
REPO_API_URL_PREFIX,
requireNonEmpty,
tryResolveCurrentRepo,
} from "./gh-common";
import { formatShortSha } from "./gh-format";
import type {
GhApiLabel,
GhApiSearchCodeItem,
GhApiSearchCommitItem,
GhApiSearchIssueItem,
GhApiSearchRepoItem,
GhApiSearchResponse,
GhApiUser,
GhLabel,
GhSearchCodeResult,
GhSearchCommitResult,
GhSearchRepoResult,
GhSearchResult,
GhUser,
GithubInput,
} from "./gh-types";
import { ToolError } from "./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<string, number> = {
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:>=<value>`.
*/
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 | undefined>): 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,
extraHeaders?: ReadonlyArray<string>,
): string[] {
const args = ["api", "-X", "GET", `/search/${endpoint}`, "-f", `q=${query}`, "-F", `per_page=${limit}`];
for (const header of extraHeaders ?? []) {
args.push("-H", header);
}
return args;
}
export function repoFromRepositoryUrl(value: string | undefined): string | undefined {
if (!value?.startsWith(REPO_API_URL_PREFIX)) return undefined;
return value.slice(REPO_API_URL_PREFIX.length);
}
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:<current>`
* 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<string | undefined> {
if (repo) return repo;
if (query && REPO_SCOPE_QUALIFIER_PATTERN.test(query)) return undefined;
return tryResolveCurrentRepo(cwd, signal);
}
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<AgentToolResult<GhToolDetails>> {
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 apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:issue"]);
const args = buildGhApiSearchArgs("issues", apiQuery, limit);
const response = await git.github.json<GhApiSearchResponse<GhApiSearchIssueItem>>(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<AgentToolResult<GhToolDetails>> {
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 apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:pr"]);
const args = buildGhApiSearchArgs("issues", apiQuery, limit);
const response = await git.github.json<GhApiSearchResponse<GhApiSearchIssueItem>>(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<AgentToolResult<GhToolDetails>> {
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 apiQuery = composeSearchQuery([query, repo ? `repo:${repo}` : undefined]);
const args = buildGhApiSearchArgs("code", apiQuery, limit, ["Accept: application/vnd.github.text-match+json"]);
const response = await git.github.json<GhApiSearchResponse<GhApiSearchCodeItem>>(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<AgentToolResult<GhToolDetails>> {
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 apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined]);
const args = buildGhApiSearchArgs("commits", apiQuery, limit);
const response = await git.github.json<GhApiSearchResponse<GhApiSearchCommitItem>>(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<AgentToolResult<GhToolDetails>> {
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 git.github.json<GhApiSearchResponse<GhApiSearchRepoItem>>(session.cwd, args, signal);
const items = (response.items ?? []).map(apiRepoToSearchResult);
return buildTextResult(formatSearchReposResults(query, items), undefined, undefined, {
useless: items.length === 0,
});
}
+379
View File
@@ -0,0 +1,379 @@
export interface GithubInput {
op:
| "repo_view"
| "file_read"
| "pr_create"
| "pr_checkout"
| "pr_push"
| "search_issues"
| "search_prs"
| "search_code"
| "search_commits"
| "search_repos"
| "run_watch";
repo?: string;
branch?: string;
path?: string;
pr?: string | string[];
force?: boolean;
forceWithLease?: boolean;
title?: string;
body?: string;
base?: string;
head?: string;
draft?: boolean;
fill?: boolean;
reviewer?: string[];
assignee?: string[];
label?: string[];
query?: string;
since?: string;
until?: string;
dateField?: "created" | "updated";
limit?: number;
run?: string;
tail?: number;
}
// /search/<endpoint> API response shapes (subset). Used when projecting raw
// REST results into the normalized `GhSearch*Result` shapes the formatters
// consume. We talk to the API directly because `gh search prs`/`issues`
// quotes multi-token positional queries (`is:"merged is:pr"`) and returns 0
// hits — see https://github.com/cli/cli for the upstream regression.
export interface GhApiSearchResponse<T> {
total_count?: number;
incomplete_results?: boolean;
items?: T[];
}
export interface GhApiUser {
login?: string;
name?: string | null;
}
export interface GhApiLabel {
name?: string;
}
export interface GhApiPullRequestRef {
merged_at?: string | null;
}
export interface GhApiSearchIssueItem {
number?: number;
title?: string;
state?: string;
state_reason?: string | null;
user?: GhApiUser | null;
labels?: GhApiLabel[];
created_at?: string;
updated_at?: string;
html_url?: string;
repository_url?: string;
pull_request?: GhApiPullRequestRef | null;
}
export interface GhApiSearchCodeItem {
name?: string;
path?: string;
sha?: string;
html_url?: string;
repository?: { full_name?: string } | null;
text_matches?: Array<{ fragment?: string; property?: string }>;
}
export interface GhApiSearchCommitGitActor {
name?: string;
email?: string;
date?: string;
}
export interface GhApiSearchCommitItem {
sha?: string;
node_id?: string;
html_url?: string;
author?: GhApiUser | null;
committer?: GhApiUser | null;
commit?: {
author?: GhApiSearchCommitGitActor | null;
committer?: GhApiSearchCommitGitActor | null;
message?: string;
} | null;
repository?: { full_name?: string } | null;
}
export interface GhApiSearchRepoItem {
full_name?: string;
description?: string | null;
language?: string | null;
stargazers_count?: number;
forks_count?: number;
open_issues_count?: number;
archived?: boolean;
fork?: boolean;
private?: boolean;
visibility?: string | null;
updated_at?: string;
created_at?: string;
html_url?: string;
owner?: GhApiUser | null;
}
export interface GhUser {
login?: string;
name?: string | null;
}
export interface GhLabel {
name?: string;
}
export interface GhComment {
author?: GhUser | null;
body?: string;
createdAt?: string;
url?: string;
isMinimized?: boolean;
minimizedReason?: string | null;
}
export interface GhRepoTopic {
name?: string;
topic?: { name?: string };
}
export interface GhRepoLanguage {
name?: string;
}
export interface GhRepoBranch {
name?: string;
}
export interface GhRepoViewData {
nameWithOwner?: string;
description?: string | null;
url?: string;
sshUrl?: string;
defaultBranchRef?: GhRepoBranch | null;
homepageUrl?: string | null;
forkCount?: number;
isArchived?: boolean;
isFork?: boolean;
primaryLanguage?: GhRepoLanguage | null;
repositoryTopics?: GhRepoTopic[];
stargazerCount?: number;
updatedAt?: string;
viewerPermission?: string | null;
visibility?: string | null;
}
export interface GhIssueViewData {
author?: GhUser | null;
body?: string | null;
comments?: GhComment[];
createdAt?: string;
labels?: GhLabel[];
number?: number;
state?: string;
stateReason?: string | null;
title?: string;
updatedAt?: string;
url?: string;
}
export interface GhPrFile {
path?: string;
additions?: number;
deletions?: number;
changeType?: string;
}
export interface GhPrViewData extends GhIssueViewData {
baseRefName?: string;
files?: GhPrFile[];
headRefName?: string;
headRefOid?: string;
headRepository?: GhRepoViewData | null;
headRepositoryOwner?: GhUser | null;
isCrossRepository?: boolean;
isDraft?: boolean;
maintainerCanModify?: boolean;
mergeStateStatus?: string;
reviewComments?: GhPrReviewComment[];
reviews?: GhPrReview[];
reviewDecision?: string;
}
export interface GhPrReviewCommit {
oid?: string | null;
}
export interface GhPrReview {
author?: GhUser | null;
body?: string | null;
commit?: GhPrReviewCommit | null;
state?: string | null;
submittedAt?: string | null;
}
export interface GhPrReviewCommentApi {
body?: string | null;
created_at?: string | null;
html_url?: string | null;
id?: number;
in_reply_to_id?: number | null;
line?: number | null;
original_line?: number | null;
path?: string | null;
side?: string | null;
user?: GhUser | null;
}
export interface GhPrReviewComment {
author?: GhUser | null;
body?: string | null;
createdAt?: string;
id: number;
inReplyToId?: number;
line?: number;
originalLine?: number;
path?: string;
side?: string;
url?: string;
}
export interface GhBranchApiResponse {
commit?: {
sha?: string | null;
} | null;
}
export interface GhSearchRepository {
nameWithOwner?: string;
}
export interface GhSearchResult {
author?: GhUser | null;
createdAt?: string;
labels?: GhLabel[];
number?: number;
repository?: GhSearchRepository | null;
state?: string;
title?: string;
updatedAt?: string;
url?: string;
}
export interface GhSearchCodeTextMatch {
fragment?: string;
property?: string;
}
export interface GhSearchCodeResult {
path?: string;
repository?: GhSearchRepository | null;
sha?: string;
textMatches?: GhSearchCodeTextMatch[];
url?: string;
}
export interface GhSearchCommitGitActor {
name?: string;
email?: string;
date?: string;
}
export interface GhSearchCommitDetail {
author?: GhSearchCommitGitActor | null;
committer?: GhSearchCommitGitActor | null;
message?: string;
}
export interface GhSearchCommitResult {
author?: GhUser | null;
commit?: GhSearchCommitDetail | null;
committer?: GhUser | null;
id?: string;
repository?: GhSearchRepository | null;
sha?: string;
url?: string;
}
export interface GhSearchRepoResult {
createdAt?: string;
description?: string | null;
forksCount?: number;
fullName?: string;
isArchived?: boolean;
isFork?: boolean;
isPrivate?: boolean;
language?: string | null;
openIssuesCount?: number;
owner?: GhUser | null;
stargazersCount?: number;
updatedAt?: string;
url?: string;
visibility?: string | null;
}
export interface GhRunReference {
repo?: string;
runId?: number;
}
export interface GhActionsRunListResponse {
workflow_runs?: GhActionsRunApi[];
}
export interface GhActionsRunApi {
id?: number;
name?: string | null;
display_title?: string | null;
status?: string | null;
conclusion?: string | null;
head_branch?: string | null;
head_sha?: string | null;
created_at?: string | null;
updated_at?: string | null;
html_url?: string | null;
}
export interface GhActionsJobsResponse {
total_count?: number;
jobs?: GhActionsJobApi[];
}
export interface GhActionsJobApi {
id?: number;
name?: string | null;
status?: string | null;
conclusion?: string | null;
started_at?: string | null;
completed_at?: string | null;
html_url?: string | null;
}
export interface GhRunJobSnapshot {
id: number;
name: string;
status?: string;
conclusion?: string;
startedAt?: string;
completedAt?: string;
url?: string;
}
export interface GhRunSnapshot {
id: number;
workflowName?: string;
displayTitle?: string;
status?: string;
conclusion?: string;
branch?: string;
headSha?: string;
createdAt?: string;
updatedAt?: string;
url?: string;
jobs: GhRunJobSnapshot[];
}
export interface GhFailedJobLog {
run: GhRunSnapshot;
job: GhRunJobSnapshot;
full?: string;
tail?: string;
available: boolean;
}
+612
View File
@@ -0,0 +1,612 @@
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import type { Settings } from "../config/settings";
import * as git from "../utils/git";
import type { ToolSession } from ".";
import type { GhToolDetails } from "./gh";
import {
appendRepoFlag,
buildTextResult,
formatAuthor,
formatLabels,
normalizeOptionalString,
normalizeText,
parseIssueUrl,
parsePositiveDecimalInt,
pushLine,
requireNonEmpty,
resolveDefaultRepoMemoized,
} from "./gh-common";
import { formatShortSha } from "./gh-format";
import { FILE_PREVIEW_LIMIT } from "./gh-search";
import type {
GhComment,
GhIssueViewData,
GhPrFile,
GhPrReview,
GhPrReviewComment,
GhPrReviewCommentApi,
GhPrViewData,
GhRepoViewData,
GithubInput,
} from "./gh-types";
import { type CacheStatus, getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
import { ToolError } from "./tool-errors";
export const GH_REPO_FIELDS = [
"nameWithOwner",
"description",
"url",
"defaultBranchRef",
"homepageUrl",
"forkCount",
"isArchived",
"isFork",
"primaryLanguage",
"repositoryTopics",
"stargazerCount",
"updatedAt",
"viewerPermission",
"visibility",
];
export const GH_ISSUE_FIELDS = [
"author",
"body",
"comments",
"createdAt",
"labels",
"number",
"state",
"stateReason",
"title",
"updatedAt",
"url",
];
export const GH_ISSUE_FIELDS_NO_COMMENTS = [
"author",
"body",
"createdAt",
"labels",
"number",
"state",
"stateReason",
"title",
"updatedAt",
"url",
];
export const GH_ISSUE_STATE_REASON_FIELD = "stateReason";
export function ghJsonErrorNamesField(err: unknown, field: string): boolean {
if (!(err instanceof Error) || !err.message.includes("Unknown JSON field")) return false;
return err.message.includes(`"${field}"`) || err.message.includes(`'${field}'`) || err.message.includes(field);
}
export function dropJsonField(args: readonly string[], field: string): string[] | undefined {
const next = [...args];
const jsonIndex = next.indexOf("--json");
if (jsonIndex < 0) return undefined;
const fields = next[jsonIndex + 1];
if (!fields) return undefined;
const splitFields = fields.split(",");
const kept = splitFields.filter(candidate => candidate !== field);
if (kept.length === splitFields.length) return undefined;
next[jsonIndex + 1] = kept.join(",");
return next;
}
/** Runs `gh --json` for issue data, retrying without optional stateReason on older gh releases. */
export async function githubIssueJsonWithStateReasonFallback<T>(
cwd: string,
args: readonly string[],
signal: AbortSignal | undefined,
options?: git.GhCommandOptions,
): Promise<T> {
try {
return await git.github.json<T>(cwd, [...args], signal, options);
} catch (err) {
if (!ghJsonErrorNamesField(err, GH_ISSUE_STATE_REASON_FIELD)) throw err;
const retryArgs = dropJsonField(args, GH_ISSUE_STATE_REASON_FIELD);
if (!retryArgs) throw err;
return await git.github.json<T>(cwd, retryArgs, signal, options);
}
}
export const GH_PR_FIELDS = [
"author",
"baseRefName",
"body",
"comments",
"createdAt",
"files",
"headRefName",
"isDraft",
"labels",
"mergeStateStatus",
"number",
"reviews",
"reviewDecision",
"state",
"title",
"updatedAt",
"url",
];
export const GH_PR_FIELDS_NO_COMMENTS = [
"author",
"baseRefName",
"body",
"createdAt",
"files",
"headRefName",
"isDraft",
"labels",
"mergeStateStatus",
"number",
"reviews",
"reviewDecision",
"state",
"title",
"updatedAt",
"url",
];
export const GH_REPO_CLONE_FIELDS = ["nameWithOwner", "sshUrl", "url"];
export const REVIEW_COMMENTS_PAGE_SIZE = 100;
export function normalizePrReviewComment(comment: GhPrReviewCommentApi): GhPrReviewComment | null {
if (typeof comment.id !== "number") {
return null;
}
return {
author: comment.user ?? null,
body: comment.body,
createdAt: normalizeOptionalString(comment.created_at),
id: comment.id,
inReplyToId: typeof comment.in_reply_to_id === "number" ? comment.in_reply_to_id : undefined,
line: typeof comment.line === "number" ? comment.line : undefined,
originalLine: typeof comment.original_line === "number" ? comment.original_line : undefined,
path: normalizeOptionalString(comment.path),
side: normalizeOptionalString(comment.side),
url: normalizeOptionalString(comment.html_url),
};
}
export async function fetchPrReviewComments(
cwd: string,
repo: string,
prNumber: number,
signal?: AbortSignal,
): Promise<GhPrReviewComment[]> {
const reviewComments: GhPrReviewComment[] = [];
let page = 1;
while (true) {
const response = await git.github.json<GhPrReviewCommentApi[]>(
cwd,
[
"api",
"--method",
"GET",
`/repos/${repo}/pulls/${prNumber}/comments`,
"-F",
`per_page=${REVIEW_COMMENTS_PAGE_SIZE}`,
"-F",
`page=${page}`,
],
signal,
{ repoProvided: true },
);
const pageComments = response
.map(comment => normalizePrReviewComment(comment))
.filter((comment): comment is GhPrReviewComment => comment !== null);
reviewComments.push(...pageComments);
// Compare the raw page length: a dropped malformed item must not end
// pagination early and silently lose the remaining pages.
if (response.length < REVIEW_COMMENTS_PAGE_SIZE) {
break;
}
page += 1;
}
return reviewComments;
}
export function formatCommentsSection(comments: GhComment[] | undefined): string[] {
if (!comments || comments.length === 0) {
return [];
}
const visible = comments.filter(comment => !comment.isMinimized);
const hiddenCount = comments.length - visible.length;
const lines: string[] = ["## Comments", ""];
if (visible.length === 0) {
lines.push(`No visible comments. Minimized comments omitted: ${hiddenCount}.`);
return lines;
}
lines[0] = `## Comments (${visible.length})`;
for (const comment of visible) {
const author = formatAuthor(comment.author) ?? "unknown";
const createdAt = comment.createdAt ? ` · ${comment.createdAt}` : "";
lines.push(`### ${author}${createdAt}`);
lines.push("");
lines.push(normalizeText(comment.body) || "No comment body.");
if (comment.url) {
lines.push("");
lines.push(`URL: ${comment.url}`);
}
lines.push("");
}
if (hiddenCount > 0) {
lines.push(`Minimized comments omitted: ${hiddenCount}.`);
}
return lines;
}
export function formatReviewsSection(reviews: GhPrReview[] | undefined): string[] {
if (!reviews || reviews.length === 0) {
return [];
}
const lines: string[] = [`## Reviews (${reviews.length})`, ""];
for (const review of reviews) {
const author = formatAuthor(review.author) ?? "unknown";
const submittedAt = review.submittedAt ? ` - ${review.submittedAt}` : "";
const state = review.state ? ` [${review.state}]` : "";
lines.push(`### ${author}${submittedAt}${state}`);
if (review.commit?.oid) {
lines.push("");
lines.push(`Commit: ${formatShortSha(review.commit.oid)}`);
}
lines.push("");
lines.push(normalizeText(review.body) || "No review body.");
lines.push("");
}
return lines;
}
export function formatReviewCommentLocation(comment: GhPrReviewComment): string | undefined {
if (!comment.path) {
return undefined;
}
const line = comment.line ?? comment.originalLine;
return line === undefined ? comment.path : `${comment.path}:${line}`;
}
export function formatReviewCommentsSection(comments: GhPrReviewComment[] | undefined): string[] {
if (!comments || comments.length === 0) {
return [];
}
const lines: string[] = [`## Review Comments (${comments.length})`, ""];
for (const comment of comments) {
const author = formatAuthor(comment.author) ?? "unknown";
const createdAt = comment.createdAt ? ` · ${comment.createdAt}` : "";
lines.push(`### ${author}${createdAt}`);
lines.push("");
pushLine(lines, "Location", formatReviewCommentLocation(comment));
pushLine(lines, "Side", comment.side);
pushLine(lines, "Reply to", comment.inReplyToId);
pushLine(lines, "URL", comment.url);
lines.push("");
lines.push(normalizeText(comment.body) || "No review comment body.");
lines.push("");
}
return lines;
}
export function formatRepoView(data: GhRepoViewData, input: { repo?: string; branch?: string }): string {
const lines: string[] = [];
const name = data.nameWithOwner ?? input.repo ?? "GitHub Repository";
lines.push(`# ${name}`);
lines.push("");
lines.push(normalizeText(data.description) || "No description provided.");
lines.push("");
pushLine(lines, "URL", data.url);
pushLine(lines, "Default branch", data.defaultBranchRef?.name);
pushLine(lines, "Branch", normalizeOptionalString(input.branch));
pushLine(lines, "Visibility", data.visibility ?? undefined);
pushLine(lines, "Viewer permission", data.viewerPermission ?? undefined);
pushLine(lines, "Primary language", data.primaryLanguage?.name);
pushLine(lines, "Stars", data.stargazerCount);
pushLine(lines, "Forks", data.forkCount);
pushLine(lines, "Archived", data.isArchived);
pushLine(lines, "Fork", data.isFork);
pushLine(lines, "Updated", data.updatedAt);
pushLine(lines, "Homepage", data.homepageUrl ?? undefined);
const topics = data.repositoryTopics
?.map(topic => topic.name ?? topic.topic?.name)
.filter((value): value is string => Boolean(value))
.join(", ");
pushLine(lines, "Topics", topics || undefined);
return lines.join("\n").trim();
}
export function formatIssueView(
data: GhIssueViewData,
input: { issue: string; repo?: string; comments?: boolean },
): string {
const lines: string[] = [];
const issueNumber = data.number ?? input.issue;
lines.push(`# Issue #${issueNumber}: ${data.title ?? "Untitled"}`);
lines.push("");
pushLine(lines, "State", data.state);
pushLine(lines, "State reason", data.stateReason ?? undefined);
pushLine(lines, "Author", formatAuthor(data.author));
pushLine(lines, "Created", data.createdAt);
pushLine(lines, "Updated", data.updatedAt);
pushLine(lines, "Labels", formatLabels(data.labels));
pushLine(lines, "URL", data.url);
lines.push("");
lines.push("## Body");
lines.push("");
lines.push(normalizeText(data.body) || "No description provided.");
if ((input.comments ?? true) && data.comments) {
const commentSection = formatCommentsSection(data.comments);
if (commentSection.length > 0) {
lines.push("");
lines.push(...commentSection);
}
}
return lines.join("\n").trim();
}
export function formatPrFiles(files: GhPrFile[] | undefined): string[] {
if (!files || files.length === 0) return [];
const lines: string[] = [`## Files (${files.length})`, ""];
for (const file of files.slice(0, FILE_PREVIEW_LIMIT)) {
const changeType = file.changeType ?? "CHANGED";
const additions = file.additions ?? 0;
const deletions = file.deletions ?? 0;
lines.push(`- ${file.path ?? "(unknown file)"} [${changeType}] (+${additions} -${deletions})`);
}
if (files.length > FILE_PREVIEW_LIMIT) {
lines.push(`[…${files.length - FILE_PREVIEW_LIMIT} files elided…]`);
}
return lines;
}
export function formatPrView(data: GhPrViewData, input: { pr?: string; repo?: string; comments?: boolean }): string {
const lines: string[] = [];
const prIdentifier = data.number ?? input.pr ?? "current";
lines.push(`# Pull Request #${prIdentifier}: ${data.title ?? "Untitled"}`);
lines.push("");
pushLine(lines, "State", data.state);
pushLine(lines, "Draft", data.isDraft);
pushLine(lines, "Author", formatAuthor(data.author));
pushLine(lines, "Base", data.baseRefName);
pushLine(lines, "Head", data.headRefName);
pushLine(lines, "Review decision", data.reviewDecision ?? undefined);
pushLine(lines, "Merge state", data.mergeStateStatus);
pushLine(lines, "Created", data.createdAt);
pushLine(lines, "Updated", data.updatedAt);
pushLine(lines, "Labels", formatLabels(data.labels));
pushLine(lines, "URL", data.url);
lines.push("");
lines.push("## Body");
lines.push("");
lines.push(normalizeText(data.body) || "No description provided.");
const fileSection = formatPrFiles(data.files);
if (fileSection.length > 0) {
lines.push("");
lines.push(...fileSection);
}
if ((input.comments ?? true) && data.reviews) {
const reviewSection = formatReviewsSection(data.reviews);
if (reviewSection.length > 0) {
lines.push("");
lines.push(...reviewSection);
}
}
if ((input.comments ?? true) && data.reviewComments) {
const reviewCommentsSection = formatReviewCommentsSection(data.reviewComments);
if (reviewCommentsSection.length > 0) {
lines.push("");
lines.push(...reviewCommentsSection);
}
}
if ((input.comments ?? true) && data.comments) {
const commentSection = formatCommentsSection(data.comments);
if (commentSection.length > 0) {
lines.push("");
lines.push(...commentSection);
}
}
return lines.join("\n").trim();
}
export async function executeRepoView(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = normalizeOptionalString(params.repo);
const branch = normalizeOptionalString(params.branch);
const args = ["repo", "view"];
if (repo) {
args.push(repo);
}
if (branch) {
args.push("--branch", branch);
}
args.push("--json", GH_REPO_FIELDS.join(","));
const data = await git.github.json<GhRepoViewData>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
}
// ────────────────────────────────────────────────────────────────────────────
// Cached issue/PR view fetchers
//
// Used by `executeIssueView`/`executePrView` and by the `issue://` / `pr://`
// internal-URL protocol handlers. The cache wrapper lives in `./github-cache`;
// the fresh fetchers stay here to share the existing formatter helpers.
// ────────────────────────────────────────────────────────────────────────────
export interface IssueViewLookupOptions {
cwd: string;
repo?: string;
/** Issue number or GitHub issue URL. */
issue: string;
includeComments?: boolean;
signal?: AbortSignal;
settings?: Settings;
cacheAuthKey?: string | null;
}
export interface PrViewLookupOptions {
cwd: string;
repo: string;
number: number;
includeComments?: boolean;
signal?: AbortSignal;
settings?: Settings;
cacheAuthKey?: string | null;
}
export interface ViewLookupResult<T> {
rendered: string;
sourceUrl: string | undefined;
payload: T;
status: CacheStatus;
fetchedAt: number;
}
export async function fetchIssueViewFresh(
cwd: string,
repo: string | undefined,
identifier: string,
includeComments: boolean,
signal: AbortSignal | undefined,
): Promise<{ rendered: string; sourceUrl: string | undefined; payload: GhIssueViewData }> {
const args = ["issue", "view", identifier];
appendRepoFlag(args, repo, identifier);
args.push("--json", (includeComments ? GH_ISSUE_FIELDS : GH_ISSUE_FIELDS_NO_COMMENTS).join(","));
const data = await githubIssueJsonWithStateReasonFallback<GhIssueViewData>(cwd, args, signal, {
repoProvided: Boolean(repo),
});
const rendered = formatIssueView(data, { issue: identifier, repo, comments: includeComments });
return { rendered, sourceUrl: data.url, payload: data };
}
export async function fetchPrViewFresh(
cwd: string,
repo: string,
number: number,
includeComments: boolean,
signal: AbortSignal | undefined,
): Promise<{ rendered: string; sourceUrl: string | undefined; payload: GhPrViewData }> {
const args = ["pr", "view", String(number)];
appendRepoFlag(args, repo, String(number));
args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
const data = await git.github.json<GhPrViewData>(cwd, args, signal, { repoProvided: true });
if (includeComments && typeof data.number === "number") {
data.reviewComments = await fetchPrReviewComments(cwd, repo, data.number, signal);
}
const rendered = formatPrView(data, { pr: String(number), repo, comments: includeComments });
return { rendered, sourceUrl: data.url, payload: data };
}
/**
* Cache-aware issue/view fetcher. Used by both the `github` tool op and the
* `issue://` protocol handler so a single shared row services both surfaces.
*/
export async function getOrFetchIssue(options: IssueViewLookupOptions): Promise<ViewLookupResult<GhIssueViewData>> {
const identifier = requireNonEmpty(options.issue, "issue");
if (identifier.startsWith("-")) {
throw new ToolError(`invalid issue identifier: ${identifier}. Pass an issue number or URL.`);
}
const includeComments = options.includeComments ?? true;
const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
const urlParse = parseIssueUrl(identifier);
// Prefer the URL's repo when the identifier is a full URL; fall back to the
// explicit `repo` option, then to the cwd's default repo.
let repo = urlParse.repo ?? normalizeOptionalString(options.repo);
let cacheNumber = urlParse.issueNumber;
if (cacheNumber === undefined) {
cacheNumber = parsePositiveDecimalInt(identifier);
}
if (cacheNumber !== undefined && !repo) {
try {
repo = await resolveDefaultRepoMemoized(options.cwd, options.signal);
} catch {
// Resolution failure leaves `repo` undefined: we'll fall through to a
// direct fetch below so gh produces its own error message instead of
// us masking it with a friendlier one.
repo = undefined;
}
}
const doFetch = () => fetchIssueViewFresh(options.cwd, repo, identifier, includeComments, options.signal);
if (!repo || cacheNumber === undefined) {
const fresh = await doFetch();
return { ...fresh, status: "miss", fetchedAt: Date.now() };
}
const lookup = await getOrFetchView<GhIssueViewData>({
repo,
kind: "issue",
number: cacheNumber,
includeComments,
settings: options.settings,
authKey,
fetchFresh: doFetch,
});
return {
rendered: lookup.rendered,
sourceUrl: lookup.sourceUrl,
payload: lookup.payload,
status: lookup.status,
fetchedAt: lookup.fetchedAt,
};
}
/**
* Cache-aware PR view fetcher. Caller must supply a numeric PR number;
* branch-name / current-branch lookups bypass the cache entirely upstream
* (see `executePrView`).
*/
export async function getOrFetchPr(options: PrViewLookupOptions): Promise<ViewLookupResult<GhPrViewData>> {
const includeComments = options.includeComments ?? true;
const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
const doFetch = () => fetchPrViewFresh(options.cwd, options.repo, options.number, includeComments, options.signal);
const lookup = await getOrFetchView<GhPrViewData>({
repo: options.repo,
kind: "pr",
number: options.number,
includeComments,
settings: options.settings,
authKey,
fetchFresh: doFetch,
});
return {
rendered: lookup.rendered,
sourceUrl: lookup.sourceUrl,
payload: lookup.payload,
status: lookup.status,
fetchedAt: lookup.fetchedAt,
};
}
File diff suppressed because it is too large Load Diff