Files
oh-my-pi/packages/coding-agent/src/tools/gh.ts
T
Miroslav Drbalandcan1357 416c5494fd Compress system prompt and tool descriptions for token efficiency
Reduces per-turn token cost of prompt and tool metadata by ~2,160
tokens (~8.6 KB across 12 prompt files) without losing instructional
signal. Moves literal default values from description text into
TypeBox's native `default:` keyword, and updates the strict-mode
sanitizer to preserve default info for providers that strip the
keyword.

Prompt compression

|File                                      |before |after  |delta |
|------------------------------------------|------:|------:|-----:|
|system-prompt.md                          |25,605 |21,530 |−16%  |
|prompts/tools/ast-grep.md                 | 4,892 | 3,880 |−21%  |
|prompts/tools/ast-edit.md                 | 4,141 | 3,549 |−14%  |
|prompts/tools/bash.md                     | 3,770 | 3,093 |−18%  |
|prompts/tools/task.md                     | 7,297 | 6,711 |−8%   |
|prompts/tools/read.md                     | 2,701 | 2,309 |−15%  |
|prompts/tools/debug.md                    | 2,791 | 2,412 |−14%  |
|prompts/tools/find.md                     |   860 |   561 |−35%  |
|prompts/tools/grep.md                     | 1,226 | 1,038 |−15%  |
|prompts/tools/todo-write.md               | 2,600 | 2,402 |−8%   |
|prompts/tools/python.md                   | 2,013 | 1,916 |−5%   |
|prompts/tools/hashline.md                 | 4,280 | 4,135 |−3%   |

Changes are textual compression only — grammar-scaffolding removed,
redundant bullets stripped, repeated facts consolidated. All
behavioral contracts, safety rules, worked examples, and
tool-precedence directives are preserved. The system prompt retains
RFC 2119 invocation, XML tag semantics, adversarial-caller guidance,
persistence doctrine, unit-of-change and no-forwarding-addresses
rules, completeness contract, DRY-at-2, earn-every-line, trust-
internal-code, tool precedence, AST tool priority, pattern-syntax
cheatsheet (via ast-grep.md/ast-edit.md), tool persistence,
outside-in code integrity, default follow-through, and procedural
steps 1-7. AST tool docs retain the class-wrapper +
method_definition sel example (the most error-prone usage pattern).
bash.md critical section retains MUST-weight on the ast_grep /
ast_edit directives.

Schema: defaults as first-class metadata

Moved 18 literal default values from `(default: X)` description
text into TypeBox's native `default:` keyword across:
ast-edit.ts, ast-grep.ts, bash.ts, browser.ts, find.ts, gh.ts,
grep.ts, python.ts, read.ts, ssh.ts. Runtime-resolved placeholders
(cwd, pr-<number>) remain as text since they cannot be literal
TypeBox defaults.

Added:
- `minItems: 1` on ast-edit.ts `ops` array — machine-enforces what
  the .md previously stated only in prose; handler already rejects
  empty ops arrays, this adds the schema-level constraint.
- `find.ts` `pattern` description enriched with facts previously
  only in find.md (comma-separated lists, simple patterns recurse
  from cwd).

Strict-mode sanitizer: inline `default` into `description`

OpenAI's Structured Outputs strict mode rejects schemas containing
`default` with HTTP 422 ("default is not permitted"). Affects
openai, azure, github-copilot, openrouter, cerebras, together,
zenmux, and deepseek providers.

`sanitizeSchemaForStrictMode` in packages/ai/src/utils/schema/
strict-mode.ts now appends ` (default: X)` to the sibling
`description` before stripping the `default` keyword. Non-strict
providers (Anthropic, Google) still see the native keyword.

Rules:
- Inline is skipped when description already contains `(default:`
  (prevents double-inlining on recursive calls)
- Inline is skipped when no sibling description exists (no
  synthesis)
- Formatting: strings as-is (`cwd`), other values via
  `JSON.stringify` (matches the conventional text form)

CONSTRAINTS.md documents the inlining rule alongside the existing
keyword-strip rule.

Regression tests

packages/ai/test/schema-strict-mode.test.ts gains 7 `it` blocks:
- number/bool/string default types inline correctly
- falsy defaults (`false`, `""`, `0`) are not confused with absent
- `null` default goes through JSON.stringify branch
- double-inline prevention when description already says `(default:`
- no synthesis when no description exists
- nested object property with default (recursion + cache path)
- type-array `[T, null]` branch with default on outer schema
  (variant-materialization path)

23/23 schema tests pass, 541/541 ai-package tests pass,
`bun check` clean.

Rationale

|Metric                          |Value          |
|--------------------------------|--------------:|
|Per-turn prompt savings         |~2,160 tok     |
|Files touched                   |25             |
|Lines changed                   |+325 / −296    |
|Schemas migrated to `default:`  |18             |
|New regression tests            |7              |
2026-04-23 22:59:37 +02:00

2535 lines
73 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 { abortableSleep, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import ghIssueViewDescription from "../prompts/tools/gh-issue-view.md" with { type: "text" };
import ghPrCheckoutDescription from "../prompts/tools/gh-pr-checkout.md" with { type: "text" };
import ghPrDiffDescription from "../prompts/tools/gh-pr-diff.md" with { type: "text" };
import ghPrPushDescription from "../prompts/tools/gh-pr-push.md" with { type: "text" };
import ghPrViewDescription from "../prompts/tools/gh-pr-view.md" with { type: "text" };
import ghRepoViewDescription from "../prompts/tools/gh-repo-view.md" with { type: "text" };
import ghRunWatchDescription from "../prompts/tools/gh-run-watch.md" with { type: "text" };
import ghSearchIssuesDescription from "../prompts/tools/gh-search-issues.md" with { type: "text" };
import ghSearchPrsDescription from "../prompts/tools/gh-search-prs.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 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 ghRepoViewSchema = Type.Object({
repo: Type.Optional(
Type.String({
description: "Repository in OWNER/REPO format. Defaults to the current GitHub repository context.",
}),
),
branch: Type.Optional(Type.String({ description: "Branch name to inspect instead of the default branch." })),
});
const ghIssueViewSchema = Type.Object({
issue: Type.String({ description: "Issue number or full GitHub issue URL." }),
repo: Type.Optional(
Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full issue URL." }),
),
comments: Type.Optional(Type.Boolean({ description: "Include issue comments.", default: true })),
});
const ghPrViewSchema = Type.Object({
pr: Type.Optional(
Type.String({
description:
"Pull request number, full GitHub pull request URL, or branch name. Defaults to the current branch PR.",
}),
),
repo: Type.Optional(
Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full pull request URL." }),
),
comments: Type.Optional(Type.Boolean({ description: "Include pull request comments.", default: true })),
});
const ghPrDiffSchema = Type.Object({
pr: Type.Optional(
Type.String({
description:
"Pull request number, full GitHub pull request URL, or branch name. Defaults to the current branch PR.",
}),
),
repo: Type.Optional(
Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full pull request URL." }),
),
nameOnly: Type.Optional(
Type.Boolean({ description: "Return only changed file names instead of unified diff output." }),
),
exclude: Type.Optional(
Type.Array(Type.String({ description: "Glob pattern for files to exclude from the diff." }), {
description: "File globs to exclude from the diff output.",
}),
),
});
const ghPrCheckoutSchema = Type.Object({
pr: Type.Optional(
Type.String({
description:
"Pull request number, full GitHub pull request URL, or branch name. Defaults to the current branch PR.",
}),
),
repo: Type.Optional(
Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full pull request URL." }),
),
branch: Type.Optional(Type.String({ description: "Local branch name to create or reuse (default: pr-<number>)." })),
worktree: Type.Optional(
Type.String({ description: "Worktree path to create. Defaults to <repo>/.worktrees/<branch>." }),
),
force: Type.Optional(
Type.Boolean({
description: "Reset an existing local branch to the PR head when it is not already checked out elsewhere.",
}),
),
});
const ghPrPushSchema = Type.Object({
branch: Type.Optional(
Type.String({
description: "Local branch name to push. Defaults to the current checked-out git branch.",
}),
),
forceWithLease: Type.Optional(Type.Boolean({ description: "Use --force-with-lease when pushing the PR branch." })),
});
const ghSearchIssuesSchema = Type.Object({
query: Type.String({ description: "GitHub issue search query. Supports GitHub search syntax." }),
repo: Type.Optional(Type.String({ description: "Repository in OWNER/REPO format to scope the search." })),
limit: Type.Optional(Type.Number({ description: "Maximum results to return (max: 50).", default: 10 })),
});
const ghSearchPrsSchema = Type.Object({
query: Type.String({ description: "GitHub pull request search query. Supports GitHub search syntax." }),
repo: Type.Optional(Type.String({ description: "Repository in OWNER/REPO format to scope the search." })),
limit: Type.Optional(Type.Number({ description: "Maximum results to return (max: 50).", default: 10 })),
});
const ghRunWatchSchema = Type.Object({
run: Type.Optional(
Type.String({
description:
"GitHub Actions run ID or full run URL. Omitting this watches the workflow runs for the current HEAD commit on the selected branch.",
}),
),
branch: Type.Optional(
Type.String({
description: "Branch to inspect when omitting `run`. Defaults to the current checked-out git branch.",
}),
),
tail: Type.Optional(
Type.Number({ description: "Number of log lines to include per failed job (max: 200).", default: 15 }),
),
});
type GhRepoViewInput = Static<typeof ghRepoViewSchema>;
type GhIssueViewInput = Static<typeof ghIssueViewSchema>;
type GhPrViewInput = Static<typeof ghPrViewSchema>;
type GhPrDiffInput = Static<typeof ghPrDiffSchema>;
type GhPrCheckoutInput = Static<typeof ghPrCheckoutSchema>;
type GhPrPushInput = Static<typeof ghPrPushSchema>;
type GhSearchIssuesInput = Static<typeof ghSearchIssuesSchema>;
type GhSearchPrsInput = Static<typeof ghSearchPrsSchema>;
type GhRunWatchInput = Static<typeof ghRunWatchSchema>;
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;
}
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 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 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);
}
function buildGhSearchArgs(
command: "issues" | "prs",
query: string,
limit: number,
repo: string | undefined,
): string[] {
const args = ["search", command, "--limit", String(limit), "--json", GH_SEARCH_FIELDS.join(",")];
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";
}
function toLocalBranchRef(value: string): string {
return `refs/heads/${value}`;
}
function stripHeadsRef(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
return value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : 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 pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
const mergeRef = await git.config.getBranch(repoRoot, localBranch, "merge", signal);
const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", 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`);
}
const remoteBranch = headRef ?? stripHeadsRef(mergeRef);
if (!remoteBranch) {
throw new ToolError(`branch ${localBranch} has no tracked PR head ref`);
}
return {
remoteName,
remoteBranch,
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: GhRepoViewInput): 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: GhIssueViewInput): 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: GhPrViewInput): 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();
}
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 GhRepoViewTool implements AgentTool<typeof ghRepoViewSchema, GhToolDetails> {
readonly name = "gh_repo_view";
readonly label = "GitHub Repo";
readonly description = prompt.render(ghRepoViewDescription);
readonly parameters = ghRepoViewSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhRepoViewTool | null {
if (!git.github.available()) return null;
return new GhRepoViewTool(session);
}
async execute(
_toolCallId: string,
params: GhRepoViewInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
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>(this.session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
});
}
}
export class GhIssueViewTool implements AgentTool<typeof ghIssueViewSchema, GhToolDetails> {
readonly name = "gh_issue_view";
readonly label = "GitHub Issue";
readonly description = prompt.render(ghIssueViewDescription);
readonly parameters = ghIssueViewSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhIssueViewTool | null {
if (!git.github.available()) return null;
return new GhIssueViewTool(session);
}
async execute(
_toolCallId: string,
params: GhIssueViewInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
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>(this.session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url);
});
}
}
export class GhPrViewTool implements AgentTool<typeof ghPrViewSchema, GhToolDetails> {
readonly name = "gh_pr_view";
readonly label = "GitHub PR";
readonly description = prompt.render(ghPrViewDescription);
readonly parameters = ghPrViewSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhPrViewTool | null {
if (!git.github.available()) return null;
return new GhPrViewTool(session);
}
async execute(
_toolCallId: string,
params: GhPrViewInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
const pr = normalizeOptionalString(params.pr);
const repo = normalizeOptionalString(params.repo);
const includeComments = params.comments ?? true;
const args = ["pr", "view"];
if (pr) {
args.push(pr);
}
appendRepoFlag(args, repo, pr);
args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
const data = await git.github.json<GhPrViewData>(this.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(this.session.cwd, resolvedRepo, data.number, signal);
}
return buildTextResult(formatPrView(data, { pr, repo, comments: includeComments }), data.url);
});
}
}
export class GhPrDiffTool implements AgentTool<typeof ghPrDiffSchema, GhToolDetails> {
readonly name = "gh_pr_diff";
readonly label = "GitHub PR Diff";
readonly description = prompt.render(ghPrDiffDescription);
readonly parameters = ghPrDiffSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhPrDiffTool | null {
if (!git.github.available()) return null;
return new GhPrDiffTool(session);
}
async execute(
_toolCallId: string,
params: GhPrDiffInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
const pr = normalizeOptionalString(params.pr);
const repo = normalizeOptionalString(params.repo);
const args = ["pr", "diff"];
if (pr) {
args.push(pr);
}
appendRepoFlag(args, repo, pr);
args.push("--color", "never");
if (params.nameOnly) {
args.push("--name-only");
}
for (const pattern of params.exclude ?? []) {
const normalizedPattern = requireNonEmpty(pattern, "exclude pattern");
args.push("--exclude", normalizedPattern);
}
const output = await git.github.text(this.session.cwd, args, signal, {
repoProvided: Boolean(repo),
trimOutput: false,
});
const title = params.nameOnly ? "# Pull Request Files" : "# Pull Request Diff";
const body = output.length > 0 ? output : params.nameOnly ? "No changed files." : "No diff output.";
return buildTextResult(`${title}\n\n${body}`);
});
}
}
export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, GhToolDetails> {
readonly name = "gh_pr_checkout";
readonly label = "GitHub PR Checkout";
readonly description = prompt.render(ghPrCheckoutDescription);
readonly parameters = ghPrCheckoutSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhPrCheckoutTool | null {
if (!git.github.available()) return null;
return new GhPrCheckoutTool(session);
}
async execute(
_toolCallId: string,
params: GhPrCheckoutInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
const pr = normalizeOptionalString(params.pr);
const repo = normalizeOptionalString(params.repo);
const requestedBranch = normalizeOptionalString(params.branch);
const requestedWorktree = normalizeOptionalString(params.worktree);
const force = params.force ?? false;
const args = ["pr", "view"];
if (pr) {
args.push(pr);
}
appendRepoFlag(args, repo, pr);
args.push("--json", GH_PR_CHECKOUT_FIELDS.join(","));
const data = await git.github.json<GhPrViewData>(this.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(this.session.cwd, signal);
const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
const localBranch = requestedBranch ?? `pr-${prNumber}`;
const worktreePath = requestedWorktree
? path.resolve(this.session.cwd, requestedWorktree)
: path.join(primaryRepoRoot, ".worktrees", localBranch);
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 buildTextResult(
formatPrCheckoutResult({
data,
localBranch,
worktreePath: resolvedWorktreePath,
remoteName: remote.name,
remoteUrl: remote.url,
reused: Boolean(existingWorktree),
}),
data.url,
{
repo: repo ?? data.headRepository?.nameWithOwner,
branch: localBranch,
worktreePath: resolvedWorktreePath,
remote: remote.name,
remoteBranch: headRefName,
},
);
});
}
}
export class GhPrPushTool implements AgentTool<typeof ghPrPushSchema, GhToolDetails> {
readonly name = "gh_pr_push";
readonly label = "GitHub PR Push";
readonly description = prompt.render(ghPrPushDescription);
readonly parameters = ghPrPushSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhPrPushTool | null {
if (!git.github.available()) return null;
return new GhPrPushTool(session);
}
async execute(
_toolCallId: string,
params: GhPrPushInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
const repoRoot = await requireGitRepoRoot(this.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,
},
);
});
}
}
export class GhSearchIssuesTool implements AgentTool<typeof ghSearchIssuesSchema, GhToolDetails> {
readonly name = "gh_search_issues";
readonly label = "GitHub Issue Search";
readonly description = prompt.render(ghSearchIssuesDescription);
readonly parameters = ghSearchIssuesSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhSearchIssuesTool | null {
if (!git.github.available()) return null;
return new GhSearchIssuesTool(session);
}
async execute(
_toolCallId: string,
params: GhSearchIssuesInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
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[]>(this.session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatSearchResults("issues", query, repo, items));
});
}
}
export class GhSearchPrsTool implements AgentTool<typeof ghSearchPrsSchema, GhToolDetails> {
readonly name = "gh_search_prs";
readonly label = "GitHub PR Search";
readonly description = prompt.render(ghSearchPrsDescription);
readonly parameters = ghSearchPrsSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhSearchPrsTool | null {
if (!git.github.available()) return null;
return new GhSearchPrsTool(session);
}
async execute(
_toolCallId: string,
params: GhSearchPrsInput,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
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[]>(this.session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
return buildTextResult(formatSearchResults("pull requests", query, repo, items));
});
}
}
export class GhRunWatchTool implements AgentTool<typeof ghRunWatchSchema, GhToolDetails> {
readonly name = "gh_run_watch";
readonly label = "GitHub Run Watch";
readonly description = prompt.render(ghRunWatchDescription);
readonly parameters = ghRunWatchSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): GhRunWatchTool | null {
if (!git.github.available()) return null;
return new GhRunWatchTool(session);
}
async execute(
_toolCallId: string,
params: GhRunWatchInput,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
return untilAborted(signal, async () => {
const branchInput = normalizeOptionalString(params.branch);
const runReference = parseRunReference(params.run);
const repo = await resolveGitHubRepo(this.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(this.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(this.session.cwd, repo, runId, signal);
}
const failedJobLogs = await fetchFailedJobLogs(
this.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(
this.session,
this.name,
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(this.session.cwd, signal));
const headSha = branchInput
? await resolveGitHubBranchHead(this.session.cwd, repo, branch, signal)
: await requireCurrentGitHead(this.session.cwd, signal);
let pollCount = 0;
let settledSuccessSignature: string | undefined;
while (true) {
throwIfAborted(signal);
pollCount += 1;
let runs = await fetchRunsForCommit(this.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(this.session.cwd, repo, headSha, branch, signal);
}
const failedJobLogs = await fetchFailedJobLogs(
this.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(
this.session,
this.name,
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);
}
});
}
}