Files
oh-my-pi/packages/coding-agent/src/tools/gh.ts
T
can1357 58b895db23 feat(tools): added GitHub code, commit, and repo search support
- Added `search_code`, `search_commits`, and `search_repos` to GitHub tool schema, docs, and titles.
- Extended `GithubTool.execute` and search arg building to dispatch new search ops and handle args correctly.
- Added richer search formatters for code, commit, and repo results with short SHA and repository metadata.
- Added tests for code, commit, and repo search outputs and `--repo` argument behavior.
2026-05-03 07:39:52 +02:00

2713 lines
76 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { StringEnum } from "@oh-my-pi/pi-ai";
import { abortableSleep, getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import githubDescription from "../prompts/tools/github.md" with { type: "text" };
import * as git from "../utils/git";
import type { ToolSession } from ".";
import { formatShortSha } from "./gh-format";
import type { OutputMeta } from "./output-meta";
import { ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
const GH_REPO_FIELDS = [
"nameWithOwner",
"description",
"url",
"defaultBranchRef",
"homepageUrl",
"forkCount",
"isArchived",
"isFork",
"primaryLanguage",
"repositoryTopics",
"stargazerCount",
"updatedAt",
"viewerPermission",
"visibility",
];
const GH_ISSUE_FIELDS = [
"author",
"body",
"comments",
"createdAt",
"labels",
"number",
"state",
"stateReason",
"title",
"updatedAt",
"url",
];
const GH_ISSUE_FIELDS_NO_COMMENTS = [
"author",
"body",
"createdAt",
"labels",
"number",
"state",
"stateReason",
"title",
"updatedAt",
"url",
];
const GH_PR_FIELDS = [
"author",
"baseRefName",
"body",
"comments",
"createdAt",
"files",
"headRefName",
"isDraft",
"labels",
"mergeStateStatus",
"number",
"reviewDecision",
"state",
"title",
"updatedAt",
"url",
];
const GH_PR_FIELDS_NO_COMMENTS = [
"author",
"baseRefName",
"body",
"createdAt",
"files",
"headRefName",
"isDraft",
"labels",
"mergeStateStatus",
"number",
"reviews",
"reviewDecision",
"state",
"title",
"updatedAt",
"url",
];
const GH_REPO_CLONE_FIELDS = ["nameWithOwner", "sshUrl", "url"];
const GH_PR_CHECKOUT_FIELDS = [
"baseRefName",
"headRefName",
"headRefOid",
"headRepository",
"headRepositoryOwner",
"isCrossRepository",
"maintainerCanModify",
"number",
"title",
"url",
];
const GH_SEARCH_FIELDS = [
"author",
"createdAt",
"labels",
"number",
"repository",
"state",
"title",
"updatedAt",
"url",
];
const GH_SEARCH_CODE_FIELDS = ["path", "repository", "sha", "textMatches", "url"];
const GH_SEARCH_COMMITS_FIELDS = ["author", "commit", "committer", "id", "repository", "sha", "url"];
const GH_SEARCH_REPOS_FIELDS = [
"createdAt",
"description",
"forksCount",
"fullName",
"isArchived",
"isFork",
"isPrivate",
"language",
"openIssuesCount",
"owner",
"stargazersCount",
"updatedAt",
"url",
"visibility",
];
const SEARCH_LIMIT_DEFAULT = 10;
const SEARCH_LIMIT_MAX = 50;
const FILE_PREVIEW_LIMIT = 50;
const RUN_WATCH_INTERVAL_DEFAULT = 3;
const RUN_WATCH_GRACE_DEFAULT = 5;
const RUN_WATCH_TAIL_DEFAULT = 15;
const RUN_WATCH_TAIL_MAX = 200;
const REVIEW_COMMENTS_PAGE_SIZE = 100;
const RUN_JOBS_PAGE_SIZE = 100;
const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/.*)?$/;
const RUN_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/.*)?$/;
const RUN_SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure"]);
const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]);
const githubSchema = Type.Object({
op: StringEnum(
[
"repo_view",
"issue_view",
"pr_view",
"pr_diff",
"pr_checkout",
"pr_push",
"search_issues",
"search_prs",
"search_code",
"search_commits",
"search_repos",
"run_watch",
],
{ description: "github operation" },
),
repo: Type.Optional(
Type.String({
description: "owner/repo (any op)",
examples: ["facebook/react"],
}),
),
branch: Type.Optional(
Type.String({
description: "branch (repo_view, pr_push local branch, run_watch)",
examples: ["main", "develop"],
}),
),
issue: Type.Optional(
Type.String({
description: "issue number or url (issue_view)",
examples: ["123", "https://github.com/owner/repo/issues/123"],
}),
),
pr: Type.Optional(
Type.Union(
[
Type.String({ examples: ["123", "feature-branch"] }),
Type.Array(Type.String(), {
examples: [["123", "456"]],
}),
],
{
description:
"pr number, url, or branch (pr_view, pr_diff, pr_checkout); pass an array to batch-process multiple pull requests in one call",
},
),
),
comments: Type.Optional(Type.Boolean({ description: "include comments (issue_view, pr_view)", default: true })),
nameOnly: Type.Optional(Type.Boolean({ description: "return file names only (pr_diff)" })),
exclude: Type.Optional(
Type.Array(Type.String({ description: "glob to exclude" }), {
description: "file globs to exclude (pr_diff)",
}),
),
force: Type.Optional(Type.Boolean({ description: "reset existing local branch (pr_checkout)" })),
forceWithLease: Type.Optional(Type.Boolean({ description: "force-with-lease push (pr_push)" })),
query: Type.Optional(
Type.String({
description: "search query (search_issues, search_prs, search_code, search_commits, search_repos)",
examples: ["is:open label:bug"],
}),
),
limit: Type.Optional(
Type.Number({
description: "max results (search_issues, search_prs, search_code, search_commits, search_repos)",
default: 10,
}),
),
run: Type.Optional(Type.String({ description: "actions run id or url (run_watch)", examples: ["123456"] })),
tail: Type.Optional(Type.Number({ description: "log lines per failed job (run_watch)", default: 15 })),
});
type GithubInput = Static<typeof githubSchema>;
export interface GhToolDetails {
meta?: OutputMeta;
artifactId?: string;
repo?: string;
branch?: string;
worktreePath?: string;
remote?: string;
remoteBranch?: string;
headSha?: string;
runId?: number;
runIds?: number[];
status?: string;
conclusion?: string;
failedJobs?: string[];
watch?: GhRunWatchViewDetails;
checkouts?: GhPrCheckoutSummary[];
}
export interface GhPrCheckoutSummary {
prNumber?: number;
url?: string;
branch: string;
worktreePath: string;
remote: string;
remoteBranch: string;
reused: boolean;
}
export interface GhRunWatchJobDetails {
id: number;
name: string;
status?: string;
conclusion?: string;
durationSeconds?: number;
url?: string;
}
export interface GhRunWatchRunDetails {
id: number;
workflowName?: string;
displayTitle?: string;
status?: string;
conclusion?: string;
branch?: string;
headSha?: string;
url?: string;
jobs: GhRunWatchJobDetails[];
}
export interface GhRunWatchFailedLogDetails {
runId: number;
workflowName?: string;
jobName: string;
conclusion?: string;
tail?: string;
available: boolean;
}
export interface GhRunWatchViewDetails {
mode: "run" | "commit";
state: "watching" | "completed";
repo: string;
branch?: string;
headSha?: string;
pollCount?: number;
note?: string;
run?: GhRunWatchRunDetails;
runs?: GhRunWatchRunDetails[];
failedLogs?: GhRunWatchFailedLogDetails[];
}
interface GhUser {
login?: string;
name?: string | null;
}
interface GhLabel {
name?: string;
}
interface GhComment {
author?: GhUser | null;
body?: string;
createdAt?: string;
url?: string;
isMinimized?: boolean;
minimizedReason?: string | null;
}
interface GhRepoTopic {
name?: string;
topic?: { name?: string };
}
interface GhRepoLanguage {
name?: string;
}
interface GhRepoBranch {
name?: string;
}
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;
}
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;
}
interface GhPrFile {
path?: string;
additions?: number;
deletions?: number;
changeType?: string;
}
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;
}
interface GhPrReviewCommit {
oid?: string | null;
}
interface GhPrReview {
author?: GhUser | null;
body?: string | null;
commit?: GhPrReviewCommit | null;
state?: string | null;
submittedAt?: string | null;
}
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;
}
interface GhPrReviewComment {
author?: GhUser | null;
body?: string | null;
createdAt?: string;
id: number;
inReplyToId?: number;
line?: number;
originalLine?: number;
path?: string;
side?: string;
url?: string;
}
interface GhBranchApiResponse {
commit?: {
sha?: string | null;
} | null;
}
interface GhSearchRepository {
nameWithOwner?: string;
}
interface GhSearchResult {
author?: GhUser | null;
createdAt?: string;
labels?: GhLabel[];
number?: number;
repository?: GhSearchRepository | null;
state?: string;
title?: string;
updatedAt?: string;
url?: string;
}
interface GhSearchCodeTextMatch {
fragment?: string;
property?: string;
}
interface GhSearchCodeResult {
path?: string;
repository?: GhSearchRepository | null;
sha?: string;
textMatches?: GhSearchCodeTextMatch[];
url?: string;
}
interface GhSearchCommitGitActor {
name?: string;
email?: string;
date?: string;
}
interface GhSearchCommitDetail {
author?: GhSearchCommitGitActor | null;
committer?: GhSearchCommitGitActor | null;
message?: string;
}
interface GhSearchCommitResult {
author?: GhUser | null;
commit?: GhSearchCommitDetail | null;
committer?: GhUser | null;
id?: string;
repository?: GhSearchRepository | null;
sha?: string;
url?: string;
}
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;
}
interface GhRunReference {
repo?: string;
runId?: number;
}
interface GhActionsRunListResponse {
workflow_runs?: GhActionsRunApi[];
}
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;
}
interface GhActionsJobsResponse {
total_count?: number;
jobs?: GhActionsJobApi[];
}
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;
}
interface GhRunJobSnapshot {
id: number;
name: string;
status?: string;
conclusion?: string;
startedAt?: string;
completedAt?: string;
url?: string;
}
interface GhRunSnapshot {
id: number;
workflowName?: string;
displayTitle?: string;
status?: string;
conclusion?: string;
branch?: string;
headSha?: string;
createdAt?: string;
updatedAt?: string;
url?: string;
jobs: GhRunJobSnapshot[];
}
interface GhFailedJobLog {
run: GhRunSnapshot;
job: GhRunJobSnapshot;
full?: string;
tail?: string;
available: boolean;
}
function normalizeText(value: string | null | undefined): string {
return (value ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\t", " ").trim();
}
function normalizeBlock(value: string | null | undefined): string {
return (value ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\t", " ").trimEnd();
}
function looksLikeGitHubUrl(value: string | undefined): boolean {
return value?.startsWith("https://github.com/") ?? false;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
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;
}
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;
}
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);
}
function resolveTailLimit(value: number | undefined): number {
if (value === undefined) {
return RUN_WATCH_TAIL_DEFAULT;
}
if (!Number.isFinite(value) || value <= 0) {
throw new ToolError("tail must be a positive number");
}
return Math.min(Math.floor(value), RUN_WATCH_TAIL_MAX);
}
function appendRepoFlag(args: string[], repo: string | undefined, identifier?: string): void {
if (!repo || looksLikeGitHubUrl(identifier)) {
return;
}
args.push("--repo", repo);
}
const SEARCH_FIELDS_BY_COMMAND: Record<"issues" | "prs" | "code" | "commits" | "repos", readonly string[]> = {
issues: GH_SEARCH_FIELDS,
prs: GH_SEARCH_FIELDS,
code: GH_SEARCH_CODE_FIELDS,
commits: GH_SEARCH_COMMITS_FIELDS,
repos: GH_SEARCH_REPOS_FIELDS,
};
function buildGhSearchArgs(
command: "issues" | "prs" | "code" | "commits" | "repos",
query: string,
limit: number,
repo: string | undefined,
): string[] {
const fields = SEARCH_FIELDS_BY_COMMAND[command];
const args = ["search", command, "--limit", String(limit), "--json", fields.join(",")];
if (command !== "repos") {
appendRepoFlag(args, repo);
}
args.push("--", query);
return args;
}
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";
}
/**
* Encode an absolute repository path into a single filesystem-safe segment.
* Mirrors the legacy session-dir encoding used elsewhere in the project: drop
* the leading separator, then collapse `/`, `\\`, and `:` to `-`. The result
* is not strictly injective for pathological inputs (e.g. `/a/b` vs `/a-b`)
* but matches the rest of the codebase and stays human-readable.
*/
function encodeRepoPathForFilesystem(repoPath: string): string {
const resolved = path.resolve(repoPath);
const encoded = resolved.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
return encoded || "root";
}
function toLocalBranchRef(value: string): string {
return `refs/heads/${value}`;
}
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;
}
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;
}
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;
}
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;
}
async function ensureGitWorktreePathAvailable(
worktreePath: string,
existingWorktrees: git.GitWorktreeEntry[],
): Promise<void> {
const normalizedTarget = path.resolve(worktreePath);
const conflictingWorktree = existingWorktrees.find(entry => path.resolve(entry.path) === normalizedTarget);
if (conflictingWorktree) {
throw new ToolError(`worktree path is already registered: ${conflictingWorktree.path}`);
}
try {
await fs.stat(normalizedTarget);
throw new ToolError(`worktree path already exists: ${normalizedTarget}`);
} catch (error) {
if (isEnoent(error)) {
return;
}
throw error;
}
}
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) ?? "";
}
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;
}
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,
};
}
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()),
};
}
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;
}
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(", ");
}
function pushLine(lines: string[], label: string, value: string | number | boolean | undefined): void {
if (value === undefined || value === "") return;
lines.push(`${label}: ${value}`);
}
function parseRunReference(value: string | undefined): GhRunReference {
const run = normalizeOptionalString(value);
if (!run) {
return {};
}
if (/^\d+$/.test(run)) {
return { runId: Number(run) };
}
const match = run.match(RUN_URL_PATTERN);
if (!match) {
throw new ToolError("run must be a numeric workflow run ID or a full GitHub Actions run URL");
}
return {
repo: match[1],
runId: Number(match[2]),
};
}
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]),
};
}
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),
};
}
function normalizeRunJob(job: GhActionsJobApi): GhRunJobSnapshot | null {
if (typeof job.id !== "number") {
return null;
}
return {
id: job.id,
name: normalizeOptionalString(job.name) ?? `job-${job.id}`,
status: normalizeOptionalString(job.status),
conclusion: normalizeOptionalString(job.conclusion),
startedAt: normalizeOptionalString(job.started_at),
completedAt: normalizeOptionalString(job.completed_at),
url: normalizeOptionalString(job.html_url),
};
}
function normalizeRunSnapshot(run: GhActionsRunApi, jobs: GhRunJobSnapshot[]): GhRunSnapshot {
if (typeof run.id !== "number") {
throw new ToolError("GitHub Actions run response did not include a run ID.");
}
return {
id: run.id,
workflowName: normalizeOptionalString(run.name),
displayTitle: normalizeOptionalString(run.display_title),
status: normalizeOptionalString(run.status),
conclusion: normalizeOptionalString(run.conclusion),
branch: normalizeOptionalString(run.head_branch),
headSha: normalizeOptionalString(run.head_sha),
createdAt: normalizeOptionalString(run.created_at),
updatedAt: normalizeOptionalString(run.updated_at),
url: normalizeOptionalString(run.html_url),
jobs,
};
}
function getRunOutcome(value: string | undefined): "success" | "failure" | "pending" {
if (!value) {
return "pending";
}
if (RUN_SUCCESS_CONCLUSIONS.has(value)) {
return "success";
}
if (RUN_FAILURE_CONCLUSIONS.has(value)) {
return "failure";
}
return "pending";
}
function getRunSnapshotOutcome(run: GhRunSnapshot): "success" | "failure" | "pending" {
if (run.status !== "completed") {
return "pending";
}
return getRunOutcome(run.conclusion);
}
function getRunCollectionOutcome(runs: GhRunSnapshot[]): "success" | "failure" | "pending" {
if (runs.length === 0) {
return "pending";
}
let pending = false;
for (const run of runs) {
if (run.jobs.some(isFailedJob)) {
return "failure";
}
const outcome = getRunSnapshotOutcome(run);
if (outcome === "failure") {
return "failure";
}
if (outcome === "pending") {
pending = true;
}
}
return pending ? "pending" : "success";
}
function getRunCollectionSignature(runs: GhRunSnapshot[]): string {
return runs
.map(run => run.id)
.sort((left, right) => left - right)
.join(",");
}
function isFailedJob(job: GhRunJobSnapshot): boolean {
return job.conclusion !== undefined && JOB_FAILURE_CONCLUSIONS.has(job.conclusion);
}
function formatJobState(job: GhRunJobSnapshot): string {
return job.conclusion ?? job.status ?? "unknown";
}
function parseTimestampMs(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? undefined : timestamp;
}
function getJobDurationSeconds(job: GhRunJobSnapshot, observedAtMs: number): number | undefined {
const startedAtMs = parseTimestampMs(job.startedAt);
if (startedAtMs === undefined) {
return undefined;
}
const completedAtMs = parseTimestampMs(job.completedAt) ?? observedAtMs;
return Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000));
}
function buildRunWatchJobDetails(job: GhRunJobSnapshot, observedAtMs: number): GhRunWatchJobDetails {
return {
id: job.id,
name: job.name,
status: job.status,
conclusion: job.conclusion,
durationSeconds: getJobDurationSeconds(job, observedAtMs),
url: job.url,
};
}
function buildRunWatchRunDetails(run: GhRunSnapshot, observedAtMs: number): GhRunWatchRunDetails {
return {
id: run.id,
workflowName: run.workflowName,
displayTitle: run.displayTitle,
status: run.status,
conclusion: run.conclusion,
branch: run.branch,
headSha: run.headSha,
url: run.url,
jobs: run.jobs.map(job => buildRunWatchJobDetails(job, observedAtMs)),
};
}
function buildFailedLogDetails(failedJobLogs: GhFailedJobLog[]): GhRunWatchFailedLogDetails[] {
return failedJobLogs.map(entry => ({
runId: entry.run.id,
workflowName: entry.run.workflowName,
jobName: entry.job.name,
conclusion: entry.job.conclusion,
tail: entry.tail,
available: entry.available,
}));
}
function renderJobsSection(jobs: GhRunJobSnapshot[]): string[] {
if (jobs.length === 0) {
return ["## Jobs", "", "No jobs reported yet."];
}
const lines: string[] = [`## Jobs (${jobs.length})`, ""];
for (const job of jobs) {
lines.push(`- [${formatJobState(job)}] ${job.name}`);
if (job.startedAt) {
pushLine(lines, " Started", job.startedAt);
}
if (job.completedAt) {
pushLine(lines, " Completed", job.completedAt);
}
if (job.url) {
pushLine(lines, " URL", job.url);
}
}
return lines;
}
function renderFailedJobLogs(
failedJobLogs: GhFailedJobLog[],
options: { mode: "tail"; tail: number } | { mode: "full" },
): string[] {
if (failedJobLogs.length === 0) {
return [];
}
const lines: string[] = ["## Failed Jobs", ""];
for (const entry of failedJobLogs) {
lines.push(`### ${entry.job.name} [${entry.job.conclusion ?? "failed"}]`);
pushLine(lines, "Run", `#${entry.run.id}`);
pushLine(lines, "Workflow", entry.run.workflowName ?? undefined);
if (entry.job.startedAt) {
pushLine(lines, "Started", entry.job.startedAt);
}
if (entry.job.completedAt) {
pushLine(lines, "Completed", entry.job.completedAt);
}
if (entry.job.url) {
pushLine(lines, "URL", entry.job.url);
}
lines.push("");
const logText = options.mode === "full" ? entry.full : entry.tail;
if (entry.available && logText) {
lines.push(options.mode === "full" ? "Full log:" : `Last ${options.tail} log lines:`);
lines.push("```text");
lines.push(logText);
lines.push("```");
} else {
lines.push(options.mode === "full" ? "Full log unavailable." : "Log tail unavailable.");
}
lines.push("");
}
return lines;
}
function renderRunSection(run: GhRunSnapshot): string[] {
const label = run.workflowName ? `### Run #${run.id} - ${run.workflowName}` : `### Run #${run.id}`;
const lines: string[] = [label, ""];
pushLine(lines, "Title", run.displayTitle ?? undefined);
pushLine(lines, "Branch", run.branch ?? undefined);
pushLine(lines, "Commit", formatShortSha(run.headSha));
pushLine(lines, "Status", run.status);
pushLine(lines, "Conclusion", run.conclusion ?? undefined);
pushLine(lines, "Created", run.createdAt);
pushLine(lines, "Updated", run.updatedAt);
pushLine(lines, "URL", run.url);
lines.push("");
lines.push(...renderJobsSection(run.jobs));
return lines;
}
function formatRunWatchSnapshot(
repo: string,
run: GhRunSnapshot,
pollCount: number,
note?: string,
includeOutcome: boolean = false,
): string {
const failedJobs = run.jobs.filter(isFailedJob);
const lines: string[] = [`# Watching GitHub Actions Run #${run.id}`, ""];
pushLine(lines, "Repository", repo);
pushLine(lines, "Workflow", run.workflowName ?? undefined);
pushLine(lines, "Title", run.displayTitle ?? undefined);
pushLine(lines, "Branch", run.branch ?? undefined);
pushLine(lines, "Status", run.status);
pushLine(lines, "Conclusion", run.conclusion ?? undefined);
pushLine(lines, "Created", run.createdAt);
pushLine(lines, "Updated", run.updatedAt);
pushLine(lines, "URL", run.url);
pushLine(lines, "Poll", pollCount);
pushLine(lines, "Failed jobs", failedJobs.length || undefined);
if (note) {
lines.push("");
lines.push(`Note: ${note}`);
}
lines.push("");
lines.push(...renderJobsSection(run.jobs));
if (includeOutcome) {
lines.push("");
lines.push(failedJobs.length > 0 ? "Failures detected." : "All jobs passed.");
}
return lines.join("\n").trim();
}
function formatRunWatchResult(
repo: string,
run: GhRunSnapshot,
failedJobLogs: GhFailedJobLog[],
tail: number,
options?: { mode?: "tail" | "full" },
): string {
const failedJobs = run.jobs.filter(isFailedJob);
const lines: string[] = [`# GitHub Actions Run #${run.id}`, ""];
pushLine(lines, "Repository", repo);
pushLine(lines, "Workflow", run.workflowName ?? undefined);
pushLine(lines, "Title", run.displayTitle ?? undefined);
pushLine(lines, "Branch", run.branch ?? undefined);
pushLine(lines, "Status", run.status);
pushLine(lines, "Conclusion", run.conclusion ?? undefined);
pushLine(lines, "Created", run.createdAt);
pushLine(lines, "Updated", run.updatedAt);
pushLine(lines, "URL", run.url);
lines.push("");
lines.push(...renderJobsSection(run.jobs));
if (failedJobs.length > 0) {
lines.push("");
lines.push(
...renderFailedJobLogs(failedJobLogs, options?.mode === "full" ? { mode: "full" } : { mode: "tail", tail }),
);
lines.push("Run failed.");
} else if (getRunOutcome(run.conclusion) === "success") {
lines.push("");
lines.push("All jobs passed.");
} else {
lines.push("");
lines.push("Run completed without successful jobs, but no failed job logs were available.");
}
return lines.join("\n").trim();
}
function formatCommitRunWatchSnapshot(
repo: string,
headSha: string,
branch: string | undefined,
runs: GhRunSnapshot[],
pollCount: number,
note?: string,
): string {
const failedJobs = runs.flatMap(run => run.jobs.filter(isFailedJob));
const completedRuns = runs.filter(run => run.status === "completed").length;
const lines: string[] = [`# Watching GitHub Actions for ${formatShortSha(headSha) ?? headSha}`, ""];
pushLine(lines, "Repository", repo);
pushLine(lines, "Branch", branch);
pushLine(lines, "Commit", headSha);
pushLine(lines, "Poll", pollCount);
pushLine(lines, "Runs", runs.length);
pushLine(lines, "Completed runs", `${completedRuns}/${runs.length}`);
pushLine(lines, "Failed jobs", failedJobs.length || undefined);
if (note) {
lines.push("");
lines.push(`Note: ${note}`);
}
if (runs.length === 0) {
lines.push("");
lines.push("Waiting for workflow runs for this commit.");
return lines.join("\n").trim();
}
for (const run of runs) {
lines.push("");
lines.push(...renderRunSection(run));
}
return lines.join("\n").trim();
}
function formatCommitRunWatchResult(
repo: string,
headSha: string,
branch: string | undefined,
runs: GhRunSnapshot[],
failedJobLogs: GhFailedJobLog[],
tail: number,
options?: { mode?: "tail" | "full" },
): string {
const outcome = getRunCollectionOutcome(runs);
const lines: string[] = [`# GitHub Actions for ${formatShortSha(headSha) ?? headSha}`, ""];
pushLine(lines, "Repository", repo);
pushLine(lines, "Branch", branch);
pushLine(lines, "Commit", headSha);
pushLine(lines, "Runs", runs.length);
for (const run of runs) {
lines.push("");
lines.push(...renderRunSection(run));
}
if (failedJobLogs.length > 0) {
lines.push("");
lines.push(
...renderFailedJobLogs(failedJobLogs, options?.mode === "full" ? { mode: "full" } : { mode: "tail", tail }),
);
lines.push("Workflow runs for this commit failed.");
} else if (outcome === "success") {
lines.push("");
lines.push("All workflow runs for this commit passed.");
} else {
lines.push("");
lines.push("Workflow runs for this commit did not complete successfully.");
}
return lines.join("\n").trim();
}
function buildGhDetails(repo: string, run: GhRunSnapshot): GhToolDetails {
return {
repo,
branch: run.branch,
headSha: run.headSha,
runId: run.id,
runIds: [run.id],
status: run.status,
conclusion: run.conclusion,
failedJobs: run.jobs.filter(isFailedJob).map(job => job.name),
};
}
function buildRunWatchDetails(
repo: string,
run: GhRunSnapshot,
options?: {
state?: GhRunWatchViewDetails["state"];
pollCount?: number;
note?: string;
failedJobLogs?: GhFailedJobLog[];
},
): GhToolDetails {
const observedAtMs = Date.now();
return {
...buildGhDetails(repo, run),
watch: {
mode: "run",
state: options?.state ?? "completed",
repo,
branch: run.branch,
headSha: run.headSha,
pollCount: options?.pollCount,
note: options?.note,
run: buildRunWatchRunDetails(run, observedAtMs),
failedLogs: buildFailedLogDetails(options?.failedJobLogs ?? []),
},
};
}
function buildGhRunCollectionDetails(
repo: string,
headSha: string,
branch: string | undefined,
runs: GhRunSnapshot[],
): GhToolDetails {
const outcome = getRunCollectionOutcome(runs);
return {
repo,
branch,
headSha,
runIds: runs.map(run => run.id),
status: runs.length > 0 && runs.every(run => run.status === "completed") ? "completed" : "in_progress",
conclusion: outcome,
failedJobs: runs.flatMap(run =>
run.jobs.filter(isFailedJob).map(job => `${run.workflowName ?? `run ${run.id}`}: ${job.name}`),
),
};
}
function buildCommitRunWatchDetails(
repo: string,
headSha: string,
branch: string | undefined,
runs: GhRunSnapshot[],
options?: {
state?: GhRunWatchViewDetails["state"];
pollCount?: number;
note?: string;
failedJobLogs?: GhFailedJobLog[];
},
): GhToolDetails {
const observedAtMs = Date.now();
return {
...buildGhRunCollectionDetails(repo, headSha, branch, runs),
watch: {
mode: "commit",
state: options?.state ?? "completed",
repo,
branch,
headSha,
pollCount: options?.pollCount,
note: options?.note,
runs: runs.map(run => buildRunWatchRunDetails(run, observedAtMs)),
failedLogs: buildFailedLogDetails(options?.failedJobLogs ?? []),
},
};
}
async function resolveGitHubRepo(
cwd: string,
repo: string | undefined,
runRepo: string | undefined,
signal?: AbortSignal,
): Promise<string> {
if (repo && runRepo && 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");
}
async function resolveGitHubBranchHead(
cwd: string,
repo: string,
branch: string,
signal?: AbortSignal,
): Promise<string> {
const response = await git.github.json<GhBranchApiResponse>(
cwd,
["api", "--method", "GET", `/repos/${repo}/branches/${encodeURIComponent(branch)}`],
signal,
{ repoProvided: true },
);
return requireNonEmpty(response.commit?.sha, `head SHA for branch ${branch}`);
}
async function fetchRunsForCommit(
cwd: string,
repo: string,
headSha: string,
branch: string | undefined,
signal?: AbortSignal,
): Promise<GhRunSnapshot[]> {
const response = await git.github.json<GhActionsRunListResponse>(
cwd,
[
"api",
"--method",
"GET",
`/repos/${repo}/actions/runs`,
"-F",
`head_sha=${headSha}`,
"-F",
`per_page=${RUN_JOBS_PAGE_SIZE}`,
...(branch ? ["-F", `branch=${branch}`] : []),
],
signal,
{ repoProvided: true },
);
return Promise.all(
(response.workflow_runs ?? [])
.filter((run): run is GhActionsRunApi & { id: number } => typeof run.id === "number")
.map(async run => {
const jobs = await fetchRunJobs(cwd, repo, run.id, signal);
return normalizeRunSnapshot(run, jobs);
}),
);
}
async function fetchRunJobs(
cwd: string,
repo: string,
runId: number,
signal?: AbortSignal,
): Promise<GhRunJobSnapshot[]> {
const jobs: GhRunJobSnapshot[] = [];
let page = 1;
while (true) {
const response = await git.github.json<GhActionsJobsResponse>(
cwd,
[
"api",
"--method",
"GET",
`/repos/${repo}/actions/runs/${runId}/jobs`,
"-F",
`per_page=${RUN_JOBS_PAGE_SIZE}`,
"-F",
`page=${page}`,
],
signal,
{ repoProvided: true },
);
const pageJobs = (response.jobs ?? [])
.map(job => normalizeRunJob(job))
.filter((job): job is GhRunJobSnapshot => job !== null);
jobs.push(...pageJobs);
if (pageJobs.length < RUN_JOBS_PAGE_SIZE) {
break;
}
if ((response.total_count ?? 0) <= jobs.length) {
break;
}
page += 1;
}
return jobs;
}
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);
if (pageComments.length < REVIEW_COMMENTS_PAGE_SIZE) {
break;
}
page += 1;
}
return reviewComments;
}
async function fetchRunSnapshot(
cwd: string,
repo: string,
runId: number,
signal?: AbortSignal,
): Promise<GhRunSnapshot> {
const [run, jobs] = await Promise.all([
git.github.json<GhActionsRunApi>(
cwd,
["api", "--method", "GET", `/repos/${repo}/actions/runs/${runId}`],
signal,
{
repoProvided: true,
},
),
fetchRunJobs(cwd, repo, runId, signal),
]);
return normalizeRunSnapshot(run, jobs);
}
function tailLogLines(log: string, tail: number): string | undefined {
const normalized = normalizeBlock(log);
if (!normalized) {
return undefined;
}
const lines = normalized.split("\n");
return lines.slice(-tail).join("\n").trimEnd();
}
async function fetchFailedJobLogs(
cwd: string,
repo: string,
failedJobs: Array<{ run: GhRunSnapshot; job: GhRunJobSnapshot }>,
tail: number,
signal?: AbortSignal,
): Promise<GhFailedJobLog[]> {
return Promise.all(
failedJobs.map(async entry => {
const result = await git.github.run(cwd, ["api", `/repos/${repo}/actions/jobs/${entry.job.id}/logs`], signal);
const fullLog = result.exitCode === 0 ? normalizeBlock(result.stdout) : undefined;
const logTail = fullLog ? tailLogLines(fullLog, tail) : undefined;
return {
run: entry.run,
job: entry.job,
full: fullLog,
tail: logTail,
available: Boolean(fullLog),
};
}),
);
}
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;
}
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;
}
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}`;
}
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;
}
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();
}
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();
}
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} more files`);
}
return lines;
}
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();
}
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();
}
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();
}
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();
}
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();
}
function formatSearchCommitMessage(message: string | undefined): string | undefined {
if (!message) return undefined;
const firstLine = normalizeText(message).split("\n", 1)[0];
return firstLine || undefined;
}
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();
}
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();
}
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;
}
function appendArtifactReference(text: string, artifactId: string | undefined, label: string): string {
if (!artifactId) {
return text;
}
return `${text}\n\n${label}: artifact://${artifactId}`;
}
function buildTextResult(
text: string,
sourceUrl?: string,
details?: GhToolDetails,
options?: { artifactId?: string; artifactLabel?: string },
): AgentToolResult<GhToolDetails> {
const builder = toolResult<GhToolDetails>(details).text(
appendArtifactReference(text, options?.artifactId, options?.artifactLabel ?? "Saved artifact"),
);
if (sourceUrl) {
builder.sourceUrl(sourceUrl);
}
return builder.done();
}
export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails> {
readonly name = "github";
readonly label = "GitHub";
readonly description = prompt.render(githubDescription);
readonly parameters = githubSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GithubTool | null {
if (!git.github.available()) return null;
return new GithubTool(session);
}
async execute(
_toolCallId: string,
params: GithubInput,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
switch (params.op) {
case "repo_view":
return executeRepoView(this.session, params, signal);
case "issue_view":
return executeIssueView(this.session, params, signal);
case "pr_view":
return executePrView(this.session, params, signal);
case "pr_diff":
return executePrDiff(this.session, params, signal);
case "pr_checkout":
return executePrCheckout(this.session, params, signal);
case "pr_push":
return executePrPush(this.session, params, signal);
case "search_issues":
return executeSearchIssues(this.session, params, signal);
case "search_prs":
return executeSearchPrs(this.session, params, signal);
case "search_code":
return executeSearchCode(this.session, params, signal);
case "search_commits":
return executeSearchCommits(this.session, params, signal);
case "search_repos":
return executeSearchRepos(this.session, params, signal);
case "run_watch":
return executeRunWatch(this.session, this.name, params, signal, onUpdate);
}
});
}
}
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);
}
async function executeIssueView(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const issue = requireNonEmpty(params.issue, "issue");
const repo = normalizeOptionalString(params.repo);
const includeComments = params.comments ?? true;
const args = ["issue", "view", issue];
appendRepoFlag(args, repo, issue);
args.push("--json", (includeComments ? GH_ISSUE_FIELDS : GH_ISSUE_FIELDS_NO_COMMENTS).join(","));
const data = await git.github.json<GhIssueViewData>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url);
}
async function executePrView(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = normalizeOptionalString(params.repo);
const includeComments = params.comments ?? true;
const prList = normalizePrIdentifierList(params.pr);
const prRefs: (string | undefined)[] = prList.length > 0 ? prList : [undefined];
const views = await Promise.all(
prRefs.map(async prRef => {
const args = ["pr", "view"];
if (prRef) args.push(prRef);
appendRepoFlag(args, repo, prRef);
args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
const data = await git.github.json<GhPrViewData>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
const resolvedRepo = repo ?? parsePullRequestUrl(data.url).repo;
if (includeComments && resolvedRepo && typeof data.number === "number") {
data.reviewComments = await fetchPrReviewComments(session.cwd, resolvedRepo, data.number, signal);
}
return { prRef, data };
}),
);
if (views.length === 1) {
const [view] = views;
return buildTextResult(
formatPrView(view.data, { pr: view.prRef, repo, comments: includeComments }),
view.data.url,
);
}
const sections = views.map(view => formatPrView(view.data, { pr: view.prRef, repo, comments: includeComments }));
const text = [`# ${views.length} Pull Requests`, "", ...joinSections(sections)].join("\n").trim();
return buildTextResult(text);
}
async function executePrDiff(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = normalizeOptionalString(params.repo);
const prList = normalizePrIdentifierList(params.pr);
const prRefs: (string | undefined)[] = prList.length > 0 ? prList : [undefined];
const diffs = await Promise.all(
prRefs.map(async prRef => {
const args = ["pr", "diff"];
if (prRef) args.push(prRef);
appendRepoFlag(args, repo, prRef);
args.push("--color", "never");
if (params.nameOnly) args.push("--name-only");
for (const pattern of params.exclude ?? []) {
args.push("--exclude", requireNonEmpty(pattern, "exclude pattern"));
}
const output = await git.github.text(session.cwd, args, signal, {
repoProvided: Boolean(repo),
trimOutput: false,
});
return { prRef, output };
}),
);
const singleTitle = params.nameOnly ? "# Pull Request Files" : "# Pull Request Diff";
const emptyBody = params.nameOnly ? "No changed files." : "No diff output.";
if (diffs.length === 1) {
const [diff] = diffs;
const body = diff.output.length > 0 ? diff.output : emptyBody;
return buildTextResult(`${singleTitle}\n\n${body}`);
}
const header = params.nameOnly
? `# ${diffs.length} Pull Request File Lists`
: `# ${diffs.length} Pull Request Diffs`;
const sections = diffs.map(diff => {
const label = diff.prRef ? `PR ${diff.prRef}` : "PR (current branch)";
const body = diff.output.length > 0 ? diff.output : emptyBody;
return `## ${label}\n\n${body}`;
});
const text = [header, "", ...joinSections(sections)].join("\n").trim();
return buildTextResult(text);
}
function joinSections(sections: string[]): string[] {
return sections.flatMap((section, idx) => (idx === 0 ? [section] : ["", "---", "", section]));
}
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 outcomes = await Promise.all(
prRefs.map(prRef => checkoutPullRequest(session, signal, { prRef, repo, force })),
);
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),
});
}
interface PrCheckoutOptions {
prRef: string | undefined;
repo: string | undefined;
force: boolean;
}
interface PrCheckoutOutcome {
data: GhPrViewData;
localBranch: string;
worktreePath: string;
remoteName: string;
remoteUrl: string;
headRefName: string;
reused: boolean;
}
async function checkoutPullRequest(
session: ToolSession,
signal: AbortSignal | undefined,
options: PrCheckoutOptions,
): Promise<PrCheckoutOutcome> {
const { prRef, repo, force } = options;
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 = path.join(getWorktreesDir(), encodeRepoPathForFilesystem(primaryRepoRoot), localBranch);
// 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,
);
const finalWorktreePath = existingWorktree?.path ?? worktreePath;
if (!existingWorktree) {
await ensureGitWorktreePathAvailable(finalWorktreePath, 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,
);
}
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,
};
}
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,
});
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,
},
);
}
async function executeSearchIssues(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const args = buildGhSearchArgs("issues", query, limit, repo);
const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatSearchResults("issues", query, repo, items));
}
async function executeSearchPrs(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const args = buildGhSearchArgs("prs", query, limit, repo);
const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatSearchResults("pull requests", query, repo, items));
}
async function executeSearchCode(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const args = buildGhSearchArgs("code", query, limit, repo);
const items = await git.github.json<GhSearchCodeResult[]>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatSearchCodeResults(query, repo, items));
}
async function executeSearchCommits(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const args = buildGhSearchArgs("commits", query, limit, repo);
const items = await git.github.json<GhSearchCommitResult[]>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatSearchCommitsResults(query, repo, items));
}
async function executeSearchRepos(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const limit = resolveSearchLimit(params.limit);
const args = buildGhSearchArgs("repos", query, limit, undefined);
const items = await git.github.json<GhSearchRepoResult[]>(session.cwd, args, signal);
return buildTextResult(formatSearchReposResults(query, items));
}
async function executeRunWatch(
session: ToolSession,
toolName: string,
params: GithubInput,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<GhToolDetails> | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const branchInput = normalizeOptionalString(params.branch);
const runReference = parseRunReference(params.run);
const repo = await resolveGitHubRepo(session.cwd, undefined, runReference.repo, signal);
const intervalSeconds = RUN_WATCH_INTERVAL_DEFAULT;
const graceSeconds = RUN_WATCH_GRACE_DEFAULT;
const tail = resolveTailLimit(params.tail);
if (runReference.runId !== undefined) {
const runId = runReference.runId;
let pollCount = 0;
while (true) {
throwIfAborted(signal);
pollCount += 1;
let run = await fetchRunSnapshot(session.cwd, repo, runId, signal);
const details = buildRunWatchDetails(repo, run, {
state: "watching",
pollCount,
});
onUpdate?.({
content: [{ type: "text", text: formatRunWatchSnapshot(repo, run, pollCount) }],
details,
});
const failedJobs = run.jobs.filter(isFailedJob);
const runCompleted = run.status === "completed";
if (failedJobs.length > 0) {
if (!runCompleted && graceSeconds > 0) {
const note = `Failure detected. Waiting ${graceSeconds}s to capture concurrent failures before fetching logs.`;
onUpdate?.({
content: [
{
type: "text",
text: formatRunWatchSnapshot(repo, run, pollCount, note),
},
],
details: buildRunWatchDetails(repo, run, {
state: "watching",
pollCount,
note,
}),
});
await abortableSleep(graceSeconds * 1000, signal);
run = await fetchRunSnapshot(session.cwd, repo, runId, signal);
}
const failedJobLogs = await fetchFailedJobLogs(
session.cwd,
repo,
run.jobs.filter(isFailedJob).map(job => ({ run, job })),
tail,
signal,
);
const finalDetails = buildRunWatchDetails(repo, run, {
state: "completed",
failedJobLogs,
});
const artifactId = await saveArtifactText(
session,
toolName,
formatRunWatchResult(repo, run, failedJobLogs, tail, { mode: "full" }),
);
return buildTextResult(
formatRunWatchResult(repo, run, failedJobLogs, tail),
run.url,
{ ...finalDetails, artifactId },
{ artifactId, artifactLabel: "Full failed-job logs" },
);
}
if (runCompleted) {
const finalDetails = buildRunWatchDetails(repo, run, {
state: "completed",
});
return buildTextResult(formatRunWatchResult(repo, run, [], tail), run.url, finalDetails);
}
await abortableSleep(intervalSeconds * 1000, signal);
}
}
const branch = branchInput ?? (await requireCurrentGitBranch(session.cwd, signal));
const headSha = branchInput
? await resolveGitHubBranchHead(session.cwd, repo, branch, signal)
: await requireCurrentGitHead(session.cwd, signal);
let pollCount = 0;
let settledSuccessSignature: string | undefined;
while (true) {
throwIfAborted(signal);
pollCount += 1;
let runs = await fetchRunsForCommit(session.cwd, repo, headSha, branch, signal);
const details = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
state: "watching",
pollCount,
});
onUpdate?.({
content: [{ type: "text", text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount) }],
details,
});
const outcome = getRunCollectionOutcome(runs);
if (outcome === "failure") {
if (graceSeconds > 0) {
const note = `Failure detected. Waiting ${graceSeconds}s to capture concurrent failures before fetching logs.`;
onUpdate?.({
content: [
{
type: "text",
text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount, note),
},
],
details: buildCommitRunWatchDetails(repo, headSha, branch, runs, {
state: "watching",
pollCount,
note,
}),
});
await abortableSleep(graceSeconds * 1000, signal);
runs = await fetchRunsForCommit(session.cwd, repo, headSha, branch, signal);
}
const failedJobLogs = await fetchFailedJobLogs(
session.cwd,
repo,
runs.flatMap(run => run.jobs.filter(isFailedJob).map(job => ({ run, job }))),
tail,
signal,
);
const finalDetails = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
state: "completed",
failedJobLogs,
});
const artifactId = await saveArtifactText(
session,
toolName,
formatCommitRunWatchResult(repo, headSha, branch, runs, failedJobLogs, tail, { mode: "full" }),
);
return buildTextResult(
formatCommitRunWatchResult(repo, headSha, branch, runs, failedJobLogs, tail),
undefined,
{ ...finalDetails, artifactId },
{ artifactId, artifactLabel: "Full failed-job logs" },
);
}
if (outcome === "success") {
const signature = getRunCollectionSignature(runs);
if (signature === settledSuccessSignature) {
const finalDetails = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
state: "completed",
});
return buildTextResult(
formatCommitRunWatchResult(repo, headSha, branch, runs, [], tail),
undefined,
finalDetails,
);
}
settledSuccessSignature = signature;
const note = `All known workflow runs completed successfully. Waiting ${intervalSeconds}s to ensure no additional runs appear for this commit.`;
onUpdate?.({
content: [
{
type: "text",
text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount, note),
},
],
details: buildCommitRunWatchDetails(repo, headSha, branch, runs, {
state: "watching",
pollCount,
note,
}),
});
await abortableSleep(intervalSeconds * 1000, signal);
continue;
}
settledSuccessSignature = undefined;
await abortableSleep(intervalSeconds * 1000, signal);
}
}