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:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user