Files
paseo/packages/server/src/services/github-service.ts
T
f352072dac Fix coding-agent terminal shortcuts not working on Windows (#1509)
* Fix coding-agent terminal shortcuts not working on Windows

On Windows, opening a Terminal profile (Claude Code / Codex / OpenCode) did
nothing, and afterwards even a plain New Terminal stopped working until restart.

Three Windows-specific problems combined:

- A failed node-pty conpty spawn completes asynchronously on its conout worker
  thread; the uncaught exception escaped the per-request try/catch and crashed
  the whole terminal worker, severing every terminal. Add uncaughtException /
  unhandledRejection guards so a single bad spawn keeps the worker alive.
- Profile commands were passed to conpty unresolved. conpty's CreateProcess
  ignores PATHEXT, so bare codex (npm codex.cmd) wasn't found and .cmd/.bat
  shims can't run directly. Add resolveTerminalSpawnCommand() to resolve the
  real path and route .cmd/.bat through cmd.exe /c on Windows.
- winget-installed CLIs (e.g. Claude Code) live under the winget Packages dir
  and aren't on PATH. findExecutable() now falls back to those known install
  locations, so all providers and terminal profiles benefit.

Also surface a failed terminal create to the user via a toast instead of
silently dropping the error.

* Address review: simplify winget scan, narrow worker exception guard

- Drop the single-element WINGET_PACKAGE_BIN_SUBDIRS loop in executable.ts and
  map package dirs directly to the root <name>.exe candidate.
- Remove the unhandledRejection handler in the terminal worker (only the
  uncaughtException path is exercised by conpty's async spawn failure) and
  expand the comment to explain the keep-alive trade-off and the absence of a
  worker restart path.

* Refactor executable resolution out of utils

* Serialize terminal create requests in worker

---------

Co-authored-by: danniel <liminfhu@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-14 19:48:42 +08:00

2851 lines
83 KiB
TypeScript

import { z } from "zod";
import type { GitHubSearchKind } from "@getpaseo/protocol/messages";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { resolveGitHubRemote } from "../utils/github-remote.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { execCommand } from "../utils/spawn.js";
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
const CHECK_ANNOTATION_PAGE_MAX = 20;
const CHECK_LOG_TAIL_MAX_LINES = 200;
const CHECK_LOG_TAIL_MAX_BYTES = 16 * 1024;
const CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128;
const ACTIONS_JOB_PAGE_MAX = 100;
const FAILED_CHECK_JOB_LIMIT = 5;
export const GITHUB_POLL_FAST_INTERVAL_MS = 20_000;
export const GITHUB_POLL_SLOW_INTERVAL_MS = 120_000;
export const GITHUB_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
const GITHUB_ENV = {
GIT_TERMINAL_PROMPT: "0",
} as const;
const LabelSchema = z.object({
name: z.string().optional(),
});
const GitHubIssueSummarySchema = z.object({
number: z.number(),
title: z.string().catch(""),
url: z.string().catch(""),
state: z.string().catch(""),
body: z.string().nullable().catch(null),
labels: z.array(LabelSchema).catch([]),
updatedAt: z.string().catch(""),
});
const GitHubPullRequestSummarySchema = z.object({
number: z.number(),
title: z.string().catch(""),
url: z.string().catch(""),
state: z.string().catch(""),
body: z.string().nullable().catch(null),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
labels: z.array(LabelSchema).catch([]),
updatedAt: z.string().catch(""),
});
const PullRequestCheckRunNodeSchema = z.object({
__typename: z.literal("CheckRun"),
databaseId: z.number().nullable().optional(),
name: z.string(),
workflowName: z.string().nullable().optional(),
conclusion: z.string().nullable().optional(),
status: z.string().nullable().optional(),
detailsUrl: z.string().nullable().optional(),
startedAt: z.string().nullable().optional(),
completedAt: z.string().nullable().optional(),
checkSuite: z
.object({
workflowRun: z
.object({
databaseId: z.number().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
});
const PullRequestStatusContextNodeSchema = z.object({
__typename: z.literal("StatusContext"),
context: z.string(),
state: z.string().nullable().optional(),
targetUrl: z.string().nullable().optional(),
createdAt: z.string().nullable().optional(),
});
const PullRequestStatusCheckRollupNodeSchema = z.discriminatedUnion("__typename", [
PullRequestCheckRunNodeSchema,
PullRequestStatusContextNodeSchema,
]);
const PullRequestStatusCheckRollupArraySchema = z.array(z.unknown());
const LegacyPullRequestStatusCheckRollupSchema = z.object({
contexts: z.array(z.unknown()),
});
const GitHubCheckRunDetailsSchema = z.object({
id: z.number(),
name: z.string().catch(""),
status: z.string().nullable().optional(),
conclusion: z.string().nullable().optional(),
html_url: z.string().nullable().optional(),
details_url: z.string().nullable().optional(),
output: z
.object({
title: z.string().nullable().optional(),
summary: z.string().nullable().optional(),
text: z.string().nullable().optional(),
})
.nullable()
.optional(),
check_suite: z
.object({
workflow_run: z
.object({
id: z.number().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
});
const GitHubCheckAnnotationSchema = z.object({
path: z.string().optional(),
start_line: z.number().optional(),
end_line: z.number().optional(),
annotation_level: z.string().optional(),
message: z.string().optional(),
title: z.string().optional(),
raw_details: z.string().optional(),
});
const GitHubCheckAnnotationsSchema = z.array(GitHubCheckAnnotationSchema).catch([]);
const GitHubActionsJobSchema = z.object({
id: z.number(),
name: z.string().catch(""),
status: z.string().nullable().optional(),
conclusion: z.string().nullable().optional(),
html_url: z.string().nullable().optional(),
completed_at: z.string().nullable().optional(),
});
const GitHubActionsJobsSchema = z.object({
jobs: z.array(GitHubActionsJobSchema).catch([]),
});
const PullRequestReviewDecisionSchema = z
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"])
.nullable()
.catch(null);
const HeadRepositoryOwnerSchema = z
.object({
login: z.string().optional(),
})
.nullable()
.optional();
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN");
const GitHubAutoMergeRequestSchema = z
.object({
enabledAt: z.string().nullable().optional().catch(null),
mergeMethod: z.string().nullable().optional().catch(null),
enabledBy: z
.object({
login: z.string().nullable().optional().catch(null),
})
.nullable()
.optional()
.catch(null),
})
.nullable()
.optional()
.catch(null);
const GitHubPullRequestFactsGraphqlSchema = z.object({
data: z.object({
repository: z
.object({
autoMergeAllowed: z.boolean().optional().catch(false),
mergeCommitAllowed: z.boolean().optional().catch(false),
squashMergeAllowed: z.boolean().optional().catch(false),
rebaseMergeAllowed: z.boolean().optional().catch(false),
viewerDefaultMergeMethod: z.string().nullable().optional().catch(null),
pullRequest: z
.object({
mergeStateStatus: z.string().nullable().optional().catch(null),
autoMergeRequest: GitHubAutoMergeRequestSchema,
viewerCanEnableAutoMerge: z.boolean().optional().catch(false),
viewerCanDisableAutoMerge: z.boolean().optional().catch(false),
viewerCanMergeAsAdmin: z.boolean().optional().catch(false),
viewerCanUpdateBranch: z.boolean().optional().catch(false),
isMergeQueueEnabled: z.boolean().optional().catch(false),
isInMergeQueue: z.boolean().optional().catch(false),
})
.nullable()
.optional()
.catch(null),
})
.nullable()
.optional()
.catch(null),
}),
});
const CurrentPullRequestStatusSchema = z.object({
number: z.number().optional(),
url: z.string().catch(""),
title: z.string().catch(""),
state: z.string().catch(""),
isDraft: z.boolean().optional().catch(false),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
headRepositoryOwner: HeadRepositoryOwnerSchema,
});
const TimelineAuthorSchema = z
.object({
login: z.string().optional(),
url: z.string().nullable().optional(),
avatarUrl: z.string().nullable().optional(),
})
.nullable()
.optional();
const PullRequestTimelineReviewNodeSchema = z.object({
id: z.string().catch(""),
state: z.string().catch(""),
body: z.string().nullable().catch(null),
bodyHTML: z.string().nullable().catch(null),
url: z.string().catch(""),
submittedAt: z.string().nullable().catch(null),
author: TimelineAuthorSchema,
});
const PullRequestTimelineCommentNodeSchema = z.object({
id: z.string().catch(""),
body: z.string().nullable().catch(null),
bodyHTML: z.string().nullable().catch(null),
url: z.string().catch(""),
createdAt: z.string().nullable().catch(null),
author: TimelineAuthorSchema,
});
const PullRequestReviewThreadCommentNodeSchema = PullRequestTimelineCommentNodeSchema.extend({
pullRequestReview: z
.object({ id: z.string().catch("") })
.nullable()
.optional()
.catch(null),
});
const PullRequestReviewThreadNodeSchema = z.object({
id: z.string().catch(""),
path: z.string().catch(""),
line: z.number().nullable().optional().catch(null),
startLine: z.number().nullable().optional().catch(null),
isResolved: z.boolean().catch(false),
isOutdated: z.boolean().catch(false),
comments: z
.object({
nodes: z.array(PullRequestReviewThreadCommentNodeSchema).catch([]),
pageInfo: z.object({ hasNextPage: z.boolean().catch(false) }).catch({ hasNextPage: false }),
})
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
});
const PullRequestTimelinePageInfoSchema = z.object({
hasNextPage: z.boolean().catch(false),
});
const PullRequestTimelineGraphqlSchema = z.object({
data: z
.object({
repository: z
.object({
pullRequest: z
.object({
number: z.number().optional(),
reviews: z
.object({
nodes: z.array(PullRequestTimelineReviewNodeSchema).catch([]),
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
})
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
comments: z
.object({
nodes: z.array(PullRequestTimelineCommentNodeSchema).catch([]),
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
})
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
reviewThreads: z
.object({
nodes: z.array(PullRequestReviewThreadNodeSchema).catch([]),
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
})
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
})
.optional(),
});
const GitHubRepoViewSchema = z.object({
owner: z
.object({
login: z.string().optional(),
})
.nullable()
.optional(),
name: z.string().optional(),
parent: z
.object({
owner: z
.object({
login: z.string().optional(),
})
.nullable()
.optional(),
name: z.string().optional(),
})
.nullable()
.optional(),
});
const PullRequestCheckoutTargetSchema = z.object({
data: z.object({
repository: z.object({
pullRequest: z
.object({
number: z.number(),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
isCrossRepository: z.boolean().catch(false),
headRepositoryOwner: z
.object({
login: z.string().catch(""),
})
.nullable()
.optional(),
headRepository: z
.object({
sshUrl: z.string().nullable().optional(),
url: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable(),
}),
}),
});
const PULL_REQUEST_CHECKOUT_TARGET_QUERY = `
query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
number
baseRefName
headRefName
isCrossRepository
headRepositoryOwner {
login
}
headRepository {
sshUrl
url
}
}
}
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
query PullRequestStatusFacts($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
autoMergeAllowed
mergeCommitAllowed
squashMergeAllowed
rebaseMergeAllowed
viewerDefaultMergeMethod
pullRequest(number: $number) {
mergeStateStatus
autoMergeRequest {
enabledAt
mergeMethod
enabledBy {
login
}
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanMergeAsAdmin
viewerCanUpdateBranch
isMergeQueueEnabled
isInMergeQueue
}
}
}`;
const PULL_REQUEST_TIMELINE_QUERY = `
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
number
reviews(first: 100) {
nodes {
id
state
body
bodyHTML
url
submittedAt
author {
login
url
avatarUrl
}
}
pageInfo {
hasNextPage
}
}
comments(first: 100) {
nodes {
id
body
bodyHTML
url
createdAt
author {
login
url
avatarUrl
}
}
pageInfo {
hasNextPage
}
}
reviewThreads(first: 100) {
nodes {
id
path
line
startLine
isResolved
isOutdated
comments(first: 100) {
nodes {
id
body
bodyHTML
url
createdAt
author {
login
url
avatarUrl
}
pullRequestReview {
id
}
}
pageInfo {
hasNextPage
}
}
}
pageInfo {
hasNextPage
}
}
}
}
}`;
interface CacheEntry {
value: unknown;
expiresAt: number;
cwd: string;
}
interface GitHubServiceDependencies {
runner: GitHubCommandRunner;
resolveGhPath: () => Promise<string | null>;
now: () => number;
}
export interface GitHubCommandRunnerOptions {
cwd: string;
envOverlay?: Record<string, string>;
}
export interface GitHubCommandResult {
stdout: string;
stderr: string;
}
export type GitHubCommandRunner = (
args: string[],
options: GitHubCommandRunnerOptions,
) => Promise<GitHubCommandResult>;
export interface GitHubPullRequestSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
baseRefName: string;
headRefName: string;
labels: string[];
updatedAt: string;
}
export interface GitHubPullRequestCheckoutTarget {
number: number;
baseRefName: string;
headRefName: string;
headOwnerLogin: string | null;
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
isCrossRepository: boolean;
}
export interface GitHubIssueSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
labels: string[];
updatedAt: string;
}
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
export interface PullRequestCheck {
name: string;
status: PullRequestCheckStatus;
url: string | null;
workflow?: string;
duration?: string;
checkRunId?: number;
workflowRunId?: number;
}
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export interface GitHubPullRequestStatusFacts {
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
}
export interface GitHubCurrentPullRequestStatus {
number?: number;
repoOwner?: string;
repoName?: string;
url: string;
title: string;
state: string;
baseRefName: string;
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
github?: GitHubPullRequestStatusFacts;
}
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
interface PullRequestTimelineItemBase {
id: string;
author: string;
authorUrl: string | null;
avatarUrl: string | null;
body: string;
createdAt: number;
url: string;
}
export type PullRequestTimelineItem =
| (PullRequestTimelineItemBase & {
kind: "review";
reviewState: PullRequestTimelineReviewState;
})
| (PullRequestTimelineItemBase & {
kind: "comment";
reviewId?: string;
location?: PullRequestTimelineCommentLocation;
});
export interface PullRequestTimelineCommentLocation {
path: string;
line?: number;
startLine?: number;
threadId?: string;
isResolved?: boolean;
isOutdated?: boolean;
}
export type GitHubPullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
export interface GitHubPullRequestTimelineError {
kind: GitHubPullRequestTimelineErrorKind;
message: string;
}
export interface GitHubPullRequestTimeline {
prNumber: number;
repoOwner: string;
repoName: string;
items: PullRequestTimelineItem[];
truncated: boolean;
error: GitHubPullRequestTimelineError | null;
}
export interface GitHubPullRequestCreateResult {
url: string;
number: number;
}
export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase";
const DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
export interface GitHubPullRequestCommandStatus {
mergeable?: PullRequestMergeable;
github?: GitHubPullRequestStatusFacts;
}
export interface MergeGitHubPullRequestOptions {
cwd: string;
prNumber: number;
mergeMethod: GitHubPullRequestMergeMethod;
status?: GitHubPullRequestCommandStatus | null;
}
export interface EnableGitHubPullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
mergeMethod: GitHubPullRequestMergeMethod;
status?: GitHubPullRequestCommandStatus | null;
}
export interface DisableGitHubPullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
status?: GitHubPullRequestCommandStatus | null;
}
export interface GitHubPullRequestMergeResult {
success: true;
}
export interface GitHubPullRequestAutoMergeResult {
success: true;
}
export type GitHubReadOptions =
| {
force?: false;
reason?: string;
}
| {
force: true;
reason: string;
};
export type ListGitHubPullRequestsOptions = {
cwd: string;
query?: string;
limit?: number;
} & GitHubReadOptions;
export type ListGitHubIssuesOptions = {
cwd: string;
query?: string;
limit?: number;
} & GitHubReadOptions;
export type GetGitHubPullRequestOptions = {
cwd: string;
number: number;
} & GitHubReadOptions;
export type GetGitHubPullRequestTimelineOptions = {
cwd: string;
prNumber: number;
repoOwner: string;
repoName: string;
} & GitHubReadOptions;
export type GetGitHubCheckDetailsOptions = {
cwd: string;
repoOwner: string;
repoName: string;
checkRunId: number;
workflowRunId?: number;
} & GitHubReadOptions;
export interface GitHubCheckAnnotation {
path?: string;
startLine?: number;
endLine?: number;
annotationLevel?: string;
message?: string;
title?: string;
rawDetails?: string;
}
export interface GitHubCheckFailedJob {
jobId: number;
name: string;
status?: string | null;
conclusion?: string | null;
url?: string | null;
completedAt?: string;
logTail?: string;
logTruncated?: boolean;
}
export interface GitHubCheckDetails {
checkRunId: number;
workflowRunId?: number | null;
name: string;
status?: string | null;
conclusion?: string | null;
url?: string | null;
detailsUrl?: string | null;
output?: {
title?: string | null;
summary?: string | null;
text?: string | null;
} | null;
annotations: GitHubCheckAnnotation[];
failedJobs: GitHubCheckFailedJob[];
truncated: boolean;
}
export interface GitHubSearchResult {
items: Array<{
kind: "issue" | "pr";
number: number;
title: string;
url: string;
state: string;
body: string | null;
labels: string[];
baseRefName?: string | null;
headRefName?: string | null;
updatedAt?: string;
}>;
githubFeaturesEnabled: boolean;
}
export type SearchGitHubIssuesAndPrsOptions = {
cwd: string;
query: string;
limit?: number;
kinds?: GitHubSearchKind[];
} & GitHubReadOptions;
export interface CreateGitHubPullRequestOptions {
cwd: string;
repo: string;
title: string;
head: string;
base: string;
body?: string;
}
export interface GitHubService {
listPullRequests(options: ListGitHubPullRequestsOptions): Promise<GitHubPullRequestSummary[]>;
listIssues(options: ListGitHubIssuesOptions): Promise<GitHubIssueSummary[]>;
getPullRequest(options: GetGitHubPullRequestOptions): Promise<GitHubPullRequestSummary>;
getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise<string>;
getPullRequestCheckoutTarget?(
options: GetGitHubPullRequestOptions,
): Promise<GitHubPullRequestCheckoutTarget>;
getCurrentPullRequestStatus(
options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
} & GitHubReadOptions,
): Promise<GitHubCurrentPullRequestStatus | null>;
getPullRequestTimeline(
options: GetGitHubPullRequestTimelineOptions,
): Promise<GitHubPullRequestTimeline>;
getGitHubCheckDetails(options: GetGitHubCheckDetailsOptions): Promise<GitHubCheckDetails>;
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
enablePullRequestAutoMerge(
options: EnableGitHubPullRequestAutoMergeOptions,
): Promise<GitHubPullRequestAutoMergeResult>;
disablePullRequestAutoMerge(
options: DisableGitHubPullRequestAutoMergeOptions,
): Promise<GitHubPullRequestAutoMergeResult>;
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
invalidate(options: { cwd: string }): void;
dispose?(): void;
}
export class GitHubCliMissingError extends Error {
readonly kind = "missing-cli";
constructor() {
super("GitHub CLI (gh) is not installed or not in PATH");
this.name = "GitHubCliMissingError";
}
}
export class GitHubAuthenticationError extends Error {
readonly kind = "auth-failure";
readonly stderr: string;
constructor(params: { stderr: string }) {
super("GitHub CLI authentication failed");
this.name = "GitHubAuthenticationError";
this.stderr = params.stderr;
}
}
export class GitHubCommandError extends Error {
readonly kind = "command-error";
readonly args: string[];
readonly cwd: string;
readonly exitCode: number | null;
readonly stderr: string;
constructor(params: { args: string[]; cwd: string; exitCode: number | null; stderr: string }) {
super(`GitHub CLI command failed: gh ${params.args.join(" ")}`);
this.name = "GitHubCommandError";
this.args = [...params.args];
this.cwd = params.cwd;
this.exitCode = params.exitCode;
this.stderr = params.stderr;
}
}
interface CreateGitHubServiceOptions {
ttlMs?: number;
runner?: GitHubCommandRunner;
resolveGhPath?: () => Promise<string | null>;
now?: () => number;
}
interface CommandFailureLike {
code?: string | number | null;
stderr?: string | Buffer;
stdout?: string | Buffer;
message?: string;
}
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>;
type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchema>;
type GitHubPullRequestFactsGraphql = z.infer<typeof GitHubPullRequestFactsGraphqlSchema>;
type GitHubPullRequestFactsRepository = NonNullable<
GitHubPullRequestFactsGraphql["data"]["repository"]
>;
type GitHubPullRequestFactsPullRequest = NonNullable<
GitHubPullRequestFactsRepository["pullRequest"]
>;
interface InFlightCacheEntry {
cwd: string;
promise: Promise<unknown>;
force: boolean;
}
interface GitHubPollTarget {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
retainCount: number;
timer: NodeJS.Timeout | null;
latestStatus: GitHubCurrentPullRequestStatus | null;
consecutiveErrors: number;
callbacks: Set<(status: GitHubCurrentPullRequestStatus | null) => void>;
errorCallbacks: Set<(error: unknown) => void>;
}
interface ResolvedPullRequestCandidate {
status: GitHubCurrentPullRequestStatus;
headRepositoryOwner?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
const ttlMs = options.ttlMs ?? DEFAULT_GITHUB_CACHE_TTL_MS;
const deps: GitHubServiceDependencies = {
runner: options.runner ?? runGhCommand,
resolveGhPath: options.resolveGhPath ?? resolveGhPath,
now: options.now ?? Date.now,
};
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, InFlightCacheEntry>();
const pollTargets = new Map<string, GitHubPollTarget>();
const checkLogTailCache = new Map<string, { logTail: string; logTruncated: boolean }>();
let api!: GitHubService;
async function cached<T>(params: {
cwd: string;
method: string;
args: unknown;
readOptions?: GitHubReadOptions;
load: () => Promise<T>;
}): Promise<T> {
if (params.readOptions?.force && !params.readOptions.reason) {
throw new Error("GitHubService forced read requires a reason");
}
const key = buildCacheKey({
cwd: params.cwd,
method: params.method,
args: params.args,
});
const cachedEntry = cache.get(key);
const now = deps.now();
if (!params.readOptions?.force && cachedEntry && cachedEntry.expiresAt > now) {
return cachedEntry.value as T;
}
const existing = inFlight.get(key);
if (existing && (!params.readOptions?.force || existing.force)) {
return existing.promise as Promise<T>;
}
const request = params
.load()
.then((value) => {
if (inFlight.get(key)?.promise === request) {
cache.set(key, {
value,
cwd: params.cwd,
expiresAt: deps.now() + ttlMs,
});
}
return value;
})
.finally(() => {
if (inFlight.get(key)?.promise === request) {
inFlight.delete(key);
}
});
inFlight.set(key, {
cwd: params.cwd,
promise: request,
force: params.readOptions?.force === true,
});
return request;
}
async function run(args: string[], runOptions: GitHubCommandRunnerOptions): Promise<string> {
const ghPath = await deps.resolveGhPath();
if (!ghPath) {
throw new GitHubCliMissingError();
}
try {
const result = await deps.runner(args, runOptions);
return result.stdout.trim();
} catch (error) {
throw normalizeGitHubCommandError(error, {
args,
cwd: runOptions.cwd,
});
}
}
function getPollTargetKey(target: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
}): string {
return buildCacheKey({
cwd: target.cwd,
method: "getCurrentPullRequestStatus",
args: {
headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
},
});
}
function updatePollTargetAfterSuccess(update: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
status: GitHubCurrentPullRequestStatus | null;
notify: boolean;
}): void {
const target = pollTargets.get(getPollTargetKey(update));
if (!target) {
return;
}
target.latestStatus = update.status;
target.consecutiveErrors = 0;
if (update.notify) {
for (const callback of target.callbacks) {
callback(update.status);
}
}
scheduleGitHubPoll(target);
}
function scheduleGitHubPoll(target: GitHubPollTarget): void {
scheduleGitHubPollAfter(
target,
computeGithubNextInterval(target.latestStatus, target.consecutiveErrors),
);
}
function scheduleImmediateGitHubPoll(target: GitHubPollTarget): void {
scheduleGitHubPollAfter(target, 0);
}
function scheduleGitHubPollAfter(target: GitHubPollTarget, delayMs: number): void {
if (target.retainCount <= 0) {
return;
}
if (target.timer) {
clearTimeout(target.timer);
}
target.timer = setTimeout(() => {
target.timer = null;
void runGitHubPoll(target);
}, delayMs);
}
async function runGitHubPoll(target: GitHubPollTarget): Promise<void> {
try {
await api.getCurrentPullRequestStatus({
cwd: target.cwd,
headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
reason: "self-heal-github",
});
} catch (error) {
target.consecutiveErrors += 1;
for (const callback of target.errorCallbacks) {
callback(error);
}
scheduleGitHubPoll(target);
}
}
function closeGitHubPollTarget(target: GitHubPollTarget): void {
if (target.timer) {
clearTimeout(target.timer);
target.timer = null;
}
target.retainCount = 0;
target.callbacks.clear();
target.errorCallbacks.clear();
}
api = {
listPullRequests(input) {
return cached({
cwd: input.cwd,
method: "listPullRequests",
args: { query: input.query ?? "", limit: input.limit ?? 20 },
readOptions: input,
load: async () => {
const stdout = await run(
[
"pr",
"list",
"--search",
input.query ?? "",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
String(input.limit ?? 20),
],
{ cwd: input.cwd },
);
return parsePullRequestSummaries(stdout);
},
});
},
listIssues(input) {
return cached({
cwd: input.cwd,
method: "listIssues",
args: { query: input.query ?? "", limit: input.limit ?? 20 },
readOptions: input,
load: async () => {
const stdout = await run(
[
"issue",
"list",
"--search",
input.query ?? "",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
String(input.limit ?? 20),
],
{ cwd: input.cwd },
);
return parseIssueSummaries(stdout);
},
});
},
getPullRequest(input) {
return cached({
cwd: input.cwd,
method: "getPullRequest",
args: { number: input.number },
readOptions: input,
load: async () => {
const stdout = await run(
[
"pr",
"view",
String(input.number),
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
],
{ cwd: input.cwd },
);
return parsePullRequestSummary(stdout);
},
});
},
async getPullRequestHeadRef(input) {
const pullRequest = await this.getPullRequest(input);
return pullRequest.headRefName;
},
getPullRequestCheckoutTarget(input) {
return cached({
cwd: input.cwd,
method: "getPullRequestCheckoutTarget",
args: { number: input.number },
readOptions: input,
load: async () => {
const repo = await getGitHubRepoView({ cwd: input.cwd, run });
const owner = repo?.owner?.login;
const name = repo?.name;
if (!owner || !name) {
throw new Error("Unable to resolve GitHub repository for pull request checkout");
}
const stdout = await run(
[
"api",
"graphql",
"-f",
`query=${PULL_REQUEST_CHECKOUT_TARGET_QUERY}`,
"-F",
`owner=${owner}`,
"-F",
`name=${name}`,
"-F",
`number=${input.number}`,
],
{ cwd: input.cwd },
);
return parsePullRequestCheckoutTarget(stdout);
},
});
},
getCurrentPullRequestStatus(input) {
return cached({
cwd: input.cwd,
method: "getCurrentPullRequestStatus",
args: {
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
},
readOptions: input,
load: async () => {
const status = await resolveCurrentPullRequestView({
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
run,
});
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
},
}).then((status) => {
updatePollTargetAfterSuccess({
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
status,
notify: input.reason === "self-heal-github",
});
return status;
});
},
getPullRequestTimeline(input) {
return cached({
cwd: input.cwd,
method: "getPullRequestTimeline",
args: { prNumber: input.prNumber },
readOptions: input,
load: async () => {
try {
const stdout = await run(
[
"api",
"graphql",
"-f",
`query=${PULL_REQUEST_TIMELINE_QUERY}`,
"-F",
`owner=${input.repoOwner}`,
"-F",
`name=${input.repoName}`,
"-F",
`number=${input.prNumber}`,
],
{ cwd: input.cwd },
);
return parsePullRequestTimeline(stdout, {
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
});
} catch (error) {
return {
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
items: [],
truncated: false,
error: mapPullRequestTimelineError(error),
};
}
},
});
},
getGitHubCheckDetails(input) {
return cached({
cwd: input.cwd,
method: "getGitHubCheckDetails",
args: {
repoOwner: input.repoOwner,
repoName: input.repoName,
checkRunId: input.checkRunId,
workflowRunId: input.workflowRunId,
},
readOptions: input,
load: async () => {
const repoPath = `repos/${input.repoOwner}/${input.repoName}`;
const checkRun = parseGitHubCheckRunDetails(
await run(["api", `${repoPath}/check-runs/${input.checkRunId}`], { cwd: input.cwd }),
);
const annotations = parseGitHubCheckAnnotations(
await run(
[
"api",
`${repoPath}/check-runs/${input.checkRunId}/annotations`,
"-f",
`per_page=${CHECK_ANNOTATION_PAGE_MAX}`,
],
{
cwd: input.cwd,
},
),
);
const workflowRunId = input.workflowRunId ?? checkRun.workflowRunId ?? null;
const failedJobs: GitHubCheckFailedJob[] = [];
let truncated = annotations.length >= CHECK_ANNOTATION_PAGE_MAX;
if (typeof workflowRunId === "number") {
const jobs = parseGitHubActionsJobs(
await run(
[
"api",
`${repoPath}/actions/runs/${workflowRunId}/jobs`,
"-f",
`per_page=${ACTIONS_JOB_PAGE_MAX}`,
],
{
cwd: input.cwd,
},
),
);
const failed = jobs.filter(isFailedActionsJob);
truncated ||= jobs.length >= ACTIONS_JOB_PAGE_MAX;
truncated ||= failed.length > FAILED_CHECK_JOB_LIMIT;
for (const job of failed.slice(0, FAILED_CHECK_JOB_LIMIT)) {
const log = await getCachedCheckLogTail({
cwd: input.cwd,
repoPath,
job,
run,
cache: checkLogTailCache,
});
truncated ||= log.logTruncated;
failedJobs.push({
jobId: job.jobId,
name: job.name,
status: job.status,
conclusion: job.conclusion,
url: job.url,
logTail: log.logTail,
logTruncated: log.logTruncated,
});
}
}
return {
checkRunId: checkRun.checkRunId,
workflowRunId,
name: checkRun.name,
status: checkRun.status,
conclusion: checkRun.conclusion,
url: checkRun.url,
detailsUrl: checkRun.detailsUrl,
output: checkRun.output,
annotations,
failedJobs,
truncated,
};
},
});
},
async searchIssuesAndPrs(input) {
if (input.force && !input.reason) {
throw new Error("GitHubService forced read requires a reason");
}
const kinds = input.kinds ?? ["github-issue", "github-pr"];
const shouldFetchIssues = kinds.includes("github-issue");
const shouldFetchPullRequests = kinds.includes("github-pr");
const readOptions: GitHubReadOptions = input.force
? { force: true, reason: input.reason }
: { force: false, reason: input.reason };
const query = normalizeGitHubSearchQuery(input.query);
const [issuesResult, prsResult] = await Promise.allSettled([
shouldFetchIssues
? this.listIssues({
cwd: input.cwd,
query,
limit: input.limit,
...readOptions,
})
: Promise.resolve(null),
shouldFetchPullRequests
? this.listPullRequests({
cwd: input.cwd,
query,
limit: input.limit,
...readOptions,
})
: Promise.resolve(null),
]);
const items: GitHubSearchResult["items"] = [];
const requestedResults = [
shouldFetchIssues ? issuesResult : null,
shouldFetchPullRequests ? prsResult : null,
].filter((result) => result !== null);
if (
requestedResults.length > 0 &&
requestedResults.every(
(result) =>
result.status === "rejected" &&
(result.reason instanceof GitHubCliMissingError ||
result.reason instanceof GitHubAuthenticationError),
)
) {
return { items: [], githubFeaturesEnabled: false };
}
if (shouldFetchIssues && issuesResult.status === "fulfilled") {
for (const item of issuesResult.value ?? []) {
items.push({
kind: "issue",
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels,
baseRefName: null,
headRefName: null,
updatedAt: item.updatedAt,
});
}
}
if (shouldFetchPullRequests && prsResult.status === "fulfilled") {
for (const item of prsResult.value ?? []) {
items.push({
kind: "pr",
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels,
baseRefName: item.baseRefName,
headRefName: item.headRefName,
updatedAt: item.updatedAt,
});
}
}
items.sort((left, right) => {
const leftTime = parseOptionalTime(left.updatedAt ?? null);
const rightTime = parseOptionalTime(right.updatedAt ?? null);
return rightTime - leftTime;
});
return { items, githubFeaturesEnabled: true };
},
async createPullRequest(input) {
const args = ["api", "-X", "POST", `repos/${input.repo}/pulls`, "-f", `title=${input.title}`];
args.push("-f", `head=${input.head}`);
args.push("-f", `base=${input.base}`);
if (input.body) {
args.push("-f", `body=${input.body}`);
}
const stdout = await run(args, { cwd: input.cwd });
const parsed = z
.object({
url: z.string(),
number: z.number(),
})
.parse(JSON.parse(stdout || "{}"));
return parsed;
},
async mergePullRequest(input) {
assertDirectPullRequestMergeReady(input);
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
});
return { success: true };
},
async enablePullRequestAutoMerge(input) {
assertPullRequestAutoMergeEnableReady(input);
await run(["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
});
return { success: true };
},
async disablePullRequestAutoMerge(input) {
assertPullRequestAutoMergeDisableReady(input);
await run(["pr", "merge", String(input.prNumber), "--disable-auto"], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
});
return { success: true };
},
isAuthenticated(input) {
return cached({
cwd: input.cwd,
method: "isAuthenticated",
args: {},
readOptions: input,
load: async () => {
try {
await run(["auth", "status"], { cwd: input.cwd });
return true;
} catch (error) {
if (isGitHubAuthenticationError(error)) {
throw error;
}
if (error instanceof GitHubCommandError && isAuthFailureText(error.stderr)) {
throw new GitHubAuthenticationError({ stderr: error.stderr });
}
throw error;
}
},
});
},
retainCurrentPullRequestStatusPoll(input) {
const key = getPollTargetKey(input);
let target = pollTargets.get(key);
if (!target) {
target = {
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
retainCount: 0,
timer: null,
latestStatus: null,
consecutiveErrors: 0,
callbacks: new Set(),
errorCallbacks: new Set(),
};
pollTargets.set(key, target);
}
const isNewlyRetained = target.retainCount === 0;
target.retainCount += 1;
if (input.onStatus) {
target.callbacks.add(input.onStatus);
}
if (input.onError) {
target.errorCallbacks.add(input.onError);
}
if (isNewlyRetained) {
scheduleImmediateGitHubPoll(target);
} else {
scheduleGitHubPoll(target);
}
let unsubscribed = false;
return {
unsubscribe: () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
if (input.onStatus) {
target.callbacks.delete(input.onStatus);
}
if (input.onError) {
target.errorCallbacks.delete(input.onError);
}
target.retainCount -= 1;
if (target.retainCount > 0) {
return;
}
closeGitHubPollTarget(target);
pollTargets.delete(key);
},
};
},
invalidate(input) {
// Local checkout mutations that can alter the current PR identity or PR status
// must call this with the affected cwd before broadcasting fresh git state.
for (const [key, entry] of cache.entries()) {
if (entry.cwd === input.cwd) {
cache.delete(key);
}
}
for (const [key, entry] of inFlight.entries()) {
if (entry.cwd === input.cwd) {
inFlight.delete(key);
}
}
},
dispose() {
for (const target of pollTargets.values()) {
closeGitHubPollTarget(target);
}
pollTargets.clear();
},
};
return api;
}
function assertDirectPullRequestMergeReady(input: MergeGitHubPullRequestOptions): void {
const github = input.status?.github;
if (!github) {
throw new Error("GitHub merge facts are unavailable for this pull request");
}
if (!DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "")) {
throw new Error("GitHub does not report this pull request as ready for direct merge");
}
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
throw new Error("Direct merge is not available because this repository uses a merge queue");
}
if (github.autoMergeRequest !== null) {
throw new Error("Direct merge is not available because auto-merge is already enabled");
}
if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) {
throw new Error(`Direct merge is not available because ${input.mergeMethod} is disabled`);
}
}
export function assertPullRequestAutoMergeEnableReady(
input: Pick<EnableGitHubPullRequestAutoMergeOptions, "mergeMethod" | "status">,
): void {
const github = input.status?.github;
if (!github) {
throw new Error("GitHub auto-merge facts are unavailable for this pull request");
}
if (github.mergeStateStatus !== "BLOCKED") {
throw new Error("GitHub does not report this pull request as blocked for auto-merge");
}
if (!github.viewerCanEnableAutoMerge) {
throw new Error("GitHub does not allow this viewer to enable auto-merge");
}
if (!github.repository.autoMergeAllowed) {
throw new Error("Auto-merge is disabled for this repository");
}
if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) {
throw new Error(`Auto-merge is not available because ${input.mergeMethod} is disabled`);
}
if (github.autoMergeRequest !== null) {
throw new Error("Auto-merge is already enabled for this pull request");
}
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
throw new Error("Auto-merge is not available because this repository uses a merge queue");
}
if (input.status?.mergeable === "CONFLICTING") {
throw new Error("Auto-merge is not available because this pull request has conflicts");
}
}
export function assertPullRequestAutoMergeDisableReady(
input: Pick<DisableGitHubPullRequestAutoMergeOptions, "status">,
): void {
const github = input.status?.github;
if (!github) {
throw new Error("GitHub auto-merge facts are unavailable for this pull request");
}
if (github.autoMergeRequest === null) {
throw new Error("Auto-merge is not enabled for this pull request");
}
if (!github.viewerCanDisableAutoMerge) {
throw new Error("GitHub does not allow this viewer to disable auto-merge");
}
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
throw new Error("Auto-merge is not available because this repository uses a merge queue");
}
}
export function isPullRequestMergeMethodAllowed(
repository: GitHubPullRequestStatusFacts["repository"],
method: GitHubPullRequestMergeMethod,
): boolean {
if (method === "squash") {
return repository.squashMergeAllowed;
}
if (method === "merge") {
return repository.mergeCommitAllowed;
}
return repository.rebaseMergeAllowed;
}
export function computeGithubNextInterval(
status: GitHubCurrentPullRequestStatus | null,
consecutiveErrors: number,
): number {
const baseInterval = isGitHubStatusPending(status)
? GITHUB_POLL_FAST_INTERVAL_MS
: GITHUB_POLL_SLOW_INTERVAL_MS;
if (consecutiveErrors <= 1) {
return baseInterval;
}
return Math.min(baseInterval * 2 ** (consecutiveErrors - 1), GITHUB_POLL_ERROR_BACKOFF_CAP_MS);
}
function isGitHubStatusPending(status: GitHubCurrentPullRequestStatus | null): boolean {
if (!status) {
return false;
}
if (status.checksStatus === "pending") {
return true;
}
return status.checks.some((check) => check.status === "pending");
}
async function resolveGhPath(): Promise<string | null> {
return findExecutable("gh");
}
async function runGhCommand(
args: string[],
options: GitHubCommandRunnerOptions,
): Promise<GitHubCommandResult> {
return execCommand("gh", args, {
cwd: options.cwd,
envOverlay: { ...GITHUB_ENV, ...options.envOverlay },
maxBuffer: 10 * 1024 * 1024,
});
}
const GITHUB_ISSUE_OR_PR_URL_PATTERN =
/^https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+)(?:[/?#].*)?$/i;
function normalizeGitHubSearchQuery(query: string): string {
const trimmed = query.trim();
const match = trimmed.match(GITHUB_ISSUE_OR_PR_URL_PATTERN);
return match ? match[1] : query;
}
function buildCacheKey(params: { cwd: string; method: string; args: unknown }): string {
return `${params.cwd}:${params.method}:${stableStringify(params.args)}`;
}
function stableStringify(value: unknown): string {
return JSON.stringify(sortJsonValue(value));
}
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (!value || typeof value !== "object") {
return value;
}
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
const sorted: Record<string, unknown> = {};
for (const [key, entryValue] of entries) {
sorted[key] = sortJsonValue(entryValue);
}
return sorted;
}
function normalizeGitHubCommandError(
error: unknown,
context: { args: string[]; cwd: string },
): Error {
if (error instanceof GitHubAuthenticationError) {
return error;
}
if (error instanceof GitHubCommandError) {
if (isAuthFailureText(error.stderr)) {
return new GitHubAuthenticationError({ stderr: error.stderr });
}
return error;
}
const failure = toCommandFailureLike(error);
if (failure.code === "ENOENT") {
return new GitHubCliMissingError();
}
const stderr = bufferOrStringToString(failure.stderr);
const message = failure.message ?? "";
if (isAuthFailureText(stderr) || isAuthFailureText(message)) {
return new GitHubAuthenticationError({ stderr });
}
return new GitHubCommandError({
args: context.args,
cwd: context.cwd,
exitCode: typeof failure.code === "number" ? failure.code : null,
stderr: stderr || message,
});
}
function toCommandFailureLike(error: unknown): CommandFailureLike {
if (!error || typeof error !== "object") {
return { message: String(error) };
}
const record = error as Record<string, unknown>;
return {
code:
typeof record.code === "string" || typeof record.code === "number" || record.code === null
? record.code
: undefined,
stderr:
typeof record.stderr === "string" || Buffer.isBuffer(record.stderr)
? record.stderr
: undefined,
stdout:
typeof record.stdout === "string" || Buffer.isBuffer(record.stdout)
? record.stdout
: undefined,
message: typeof record.message === "string" ? record.message : undefined,
};
}
function bufferOrStringToString(value: string | Buffer | undefined): string {
if (Buffer.isBuffer(value)) {
return value.toString("utf8");
}
return value ?? "";
}
function isGitHubAuthenticationError(error: unknown): error is GitHubAuthenticationError {
return error instanceof GitHubAuthenticationError;
}
function isAuthFailureText(text: string): boolean {
const normalized = text.toLowerCase();
return (
normalized.includes("gh auth login") ||
normalized.includes("not logged into any github hosts") ||
normalized.includes("authentication failed") ||
normalized.includes("authentication required") ||
normalized.includes("bad credentials") ||
normalized.includes("http 401")
);
}
function isNoPullRequestFoundError(error: unknown): boolean {
if (!(error instanceof GitHubCommandError)) {
return false;
}
const text = error.stderr.toLowerCase();
return text.includes("no pull requests found");
}
function isStatusCheckRollupPermissionError(error: unknown): boolean {
if (!(error instanceof GitHubCommandError)) {
return false;
}
return error.stderr.toLowerCase().includes("statuscheckrollup");
}
async function resolveCurrentPullRequestView(options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubCurrentPullRequestStatus | null> {
const viewCandidate = await tryCurrentPullRequestView(options);
const viewMatch = viewCandidate
? pickPullRequestCandidate({
candidates: [viewCandidate],
headRef: options.headRef,
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch) {
return viewMatch.status;
}
let listHeadRef = options.headRef;
let listRepo: string | undefined;
let headRepositoryOwner = options.headRepositoryOwner;
if (!headRepositoryOwner) {
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner || !parentOwner || !parentName) {
return null;
}
listHeadRef = `${forkOwner}:${options.headRef}`;
listRepo = `${parentOwner}/${parentName}`;
headRepositoryOwner = forkOwner;
}
const candidates = await listCurrentPullRequestCandidates({
cwd: options.cwd,
headRef: listHeadRef,
run: options.run,
repo: listRepo,
});
const match = pickPullRequestCandidate({
candidates,
headRef: options.headRef,
headRepositoryOwner,
});
return match?.status ?? null;
}
async function addCurrentPullRequestGithubFacts(options: {
cwd: string;
status: GitHubCurrentPullRequestStatus | null;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubCurrentPullRequestStatus | null> {
const { status } = options;
if (!status?.repoOwner || !status.repoName || typeof status.number !== "number") {
return status;
}
const facts = await loadPullRequestGithubFacts({
cwd: options.cwd,
owner: status.repoOwner,
name: status.repoName,
number: status.number,
run: options.run,
});
if (!facts) {
return status;
}
return {
...status,
github: facts,
};
}
async function loadPullRequestGithubFacts(options: {
cwd: string;
owner: string;
name: string;
number: number;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubPullRequestStatusFacts | null> {
try {
const stdout = await options.run(
[
"api",
"graphql",
"-f",
`query=${PULL_REQUEST_STATUS_FACTS_QUERY}`,
"-F",
`owner=${options.owner}`,
"-F",
`name=${options.name}`,
"-F",
`number=${options.number}`,
],
{ cwd: options.cwd },
);
return parsePullRequestGithubFacts(stdout);
} catch (error) {
if (
error instanceof GitHubCommandError ||
error instanceof z.ZodError ||
error instanceof SyntaxError
) {
return null;
}
throw error;
}
}
async function tryCurrentPullRequestView(options: {
cwd: string;
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<ResolvedPullRequestCandidate | null> {
try {
const stdout = await runCurrentPullRequestStatusCommand({
cwd: options.cwd,
run: options.run,
args: ["pr", "view"],
});
return parseCurrentPullRequestCandidate(stdout, options.headRef);
} catch (error) {
if (isNoPullRequestFoundError(error)) {
return null;
}
throw error;
}
}
async function listCurrentPullRequestCandidates(options: {
cwd: string;
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
repo?: string;
}): Promise<ResolvedPullRequestCandidate[]> {
const args = ["pr", "list"];
if (options.repo) {
args.push("--repo", options.repo);
}
args.push("--state", "all", "--head", options.headRef, "--limit", "10");
try {
const stdout = await runCurrentPullRequestStatusCommand({
cwd: options.cwd,
run: options.run,
args,
});
return parseCurrentPullRequestCandidateList(stdout, options.headRef);
} catch (error) {
if (isNoPullRequestFoundError(error)) {
return [];
}
throw error;
}
}
async function runCurrentPullRequestStatusCommand(options: {
cwd: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
args: string[];
}): Promise<string> {
try {
return await options.run([...options.args, "--json", CURRENT_PR_STATUS_FIELDS], {
cwd: options.cwd,
});
} catch (error) {
if (!isStatusCheckRollupPermissionError(error)) {
throw error;
}
return options.run([...options.args, "--json", CURRENT_PR_STATUS_BASE_FIELDS], {
cwd: options.cwd,
});
}
}
async function getGitHubRepoView(options: {
cwd: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
try {
const stdout = await options.run(["repo", "view", "--json", "owner,name,parent"], {
cwd: options.cwd,
});
return GitHubRepoViewSchema.parse(JSON.parse(stdout || "{}"));
} catch {
return null;
}
}
function parseCurrentPullRequestCandidate(
stdout: string,
fallbackHeadRefName: string,
): ResolvedPullRequestCandidate | null {
const item = CurrentPullRequestStatusSchema.parse(JSON.parse(stdout || "{}"));
return toCurrentPullRequestCandidate(item, fallbackHeadRefName);
}
function parseCurrentPullRequestCandidateList(
stdout: string,
fallbackHeadRefName: string,
): ResolvedPullRequestCandidate[] {
const items = z.array(CurrentPullRequestStatusSchema).parse(JSON.parse(stdout || "[]"));
return items
.map((item) => toCurrentPullRequestCandidate(item, fallbackHeadRefName))
.filter((candidate): candidate is ResolvedPullRequestCandidate => candidate !== null);
}
function parsePullRequestGithubFacts(stdout: string): GitHubPullRequestStatusFacts | null {
const parsed = GitHubPullRequestFactsGraphqlSchema.parse(JSON.parse(stdout || "{}"));
const repository = parsed.data.repository;
const pullRequest = repository?.pullRequest;
if (!repository || !pullRequest) {
return null;
}
return {
mergeStateStatus: pullRequest.mergeStateStatus ?? null,
autoMergeRequest: toGitHubAutoMergeRequest(pullRequest.autoMergeRequest),
viewerCanEnableAutoMerge: pullRequest.viewerCanEnableAutoMerge ?? false,
viewerCanDisableAutoMerge: pullRequest.viewerCanDisableAutoMerge ?? false,
viewerCanMergeAsAdmin: pullRequest.viewerCanMergeAsAdmin ?? false,
viewerCanUpdateBranch: pullRequest.viewerCanUpdateBranch ?? false,
repository: toGitHubRepositoryMergePolicy(repository),
isMergeQueueEnabled: pullRequest.isMergeQueueEnabled ?? false,
isInMergeQueue: pullRequest.isInMergeQueue ?? false,
};
}
function toGitHubAutoMergeRequest(
request: GitHubPullRequestFactsPullRequest["autoMergeRequest"],
): GitHubPullRequestStatusFacts["autoMergeRequest"] {
if (!request) {
return null;
}
return {
enabledAt: request.enabledAt ?? null,
mergeMethod: request.mergeMethod ?? null,
enabledBy: request.enabledBy?.login ?? null,
};
}
function toGitHubRepositoryMergePolicy(
repository: GitHubPullRequestFactsRepository,
): GitHubPullRequestStatusFacts["repository"] {
return {
autoMergeAllowed: repository.autoMergeAllowed ?? false,
mergeCommitAllowed: repository.mergeCommitAllowed ?? false,
squashMergeAllowed: repository.squashMergeAllowed ?? false,
rebaseMergeAllowed: repository.rebaseMergeAllowed ?? false,
viewerDefaultMergeMethod: repository.viewerDefaultMergeMethod ?? null,
};
}
function toCurrentPullRequestCandidate(
item: CurrentPullRequestStatusItem,
fallbackHeadRefName: string,
): ResolvedPullRequestCandidate | null {
const status = toCurrentPullRequestStatus(item, fallbackHeadRefName);
if (!status) {
return null;
}
const headRepositoryOwner = item.headRepositoryOwner?.login;
return {
status,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
}
function isCandidateForHeadRef(candidate: ResolvedPullRequestCandidate, headRef: string): boolean {
return candidate.status.headRefName === headRef && hasResolvedRepoIdentity(candidate.status);
}
function hasResolvedRepoIdentity(status: GitHubCurrentPullRequestStatus): boolean {
return Boolean(status.repoOwner && status.repoName);
}
function pickPullRequestCandidate(options: {
candidates: ResolvedPullRequestCandidate[];
headRef: string;
headRepositoryOwner?: string;
}): ResolvedPullRequestCandidate | null {
const matching = options.candidates.filter((candidate) => {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (!options.headRepositoryOwner) {
return true;
}
return candidate.headRepositoryOwner === options.headRepositoryOwner;
});
matching.sort(comparePullRequestCandidatePreference);
return matching[0] ?? null;
}
function comparePullRequestCandidatePreference(
left: ResolvedPullRequestCandidate,
right: ResolvedPullRequestCandidate,
): number {
return getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
}
function getPullRequestStateRank(status: GitHubCurrentPullRequestStatus): number {
if (status.state === "open" || status.isDraft) {
return 0;
}
if (status.state === "merged") {
return 1;
}
return 2;
}
function parsePullRequestSummaries(stdout: string): GitHubPullRequestSummary[] {
const parsed = z.array(GitHubPullRequestSummarySchema).parse(JSON.parse(stdout || "[]"));
return parsed.map(toPullRequestSummary);
}
function parsePullRequestSummary(stdout: string): GitHubPullRequestSummary {
return toPullRequestSummary(GitHubPullRequestSummarySchema.parse(JSON.parse(stdout || "{}")));
}
function parsePullRequestCheckoutTarget(stdout: string): GitHubPullRequestCheckoutTarget {
const parsed = PullRequestCheckoutTargetSchema.parse(JSON.parse(stdout || "{}"));
const pullRequest = parsed.data.repository.pullRequest;
if (!pullRequest) {
throw new Error("Pull request not found");
}
return {
number: pullRequest.number,
baseRefName: pullRequest.baseRefName,
headRefName: pullRequest.headRefName,
headOwnerLogin: pullRequest.headRepositoryOwner?.login || null,
headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null,
headRepositoryUrl: pullRequest.headRepository?.url || null,
isCrossRepository: pullRequest.isCrossRepository,
};
}
function toPullRequestSummary(
item: z.infer<typeof GitHubPullRequestSummarySchema>,
): GitHubPullRequestSummary {
return {
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
baseRefName: item.baseRefName,
headRefName: item.headRefName,
labels: item.labels.map((label) => label.name ?? "").filter((name) => name.length > 0),
updatedAt: item.updatedAt,
};
}
function parseIssueSummaries(stdout: string): GitHubIssueSummary[] {
const parsed = z.array(GitHubIssueSummarySchema).parse(JSON.parse(stdout || "[]"));
return parsed.map((item) => ({
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels.map((label) => label.name ?? "").filter((name) => name.length > 0),
updatedAt: item.updatedAt,
}));
}
function parsePullRequestTimeline(
stdout: string,
identity: { prNumber: number; repoOwner: string; repoName: string },
): GitHubPullRequestTimeline {
const parsed = PullRequestTimelineGraphqlSchema.parse(JSON.parse(stdout || "{}"));
const pullRequest = parsed.data?.repository?.pullRequest;
const reviewThreadItems = pullRequest
? pullRequest.reviewThreads.nodes.flatMap(toPullRequestTimelineReviewThreadItems)
: [];
const reviewThreadItemIds = new Set(
reviewThreadItems.map((item) => item.id).filter((id) => id.length > 0),
);
const items = pullRequest
? [
...pullRequest.reviews.nodes.flatMap(toPullRequestTimelineReviewItem),
...pullRequest.comments.nodes
.filter((comment) => !reviewThreadItemIds.has(comment.id))
.map(toPullRequestTimelineCommentItem),
...reviewThreadItems,
].sort(compareTimelineItems)
: [];
return {
prNumber: pullRequest?.number ?? identity.prNumber,
repoOwner: identity.repoOwner,
repoName: identity.repoName,
items,
// S3 deliberately caps timeline fetches at the first 100 reviews, comments, and review threads.
truncated: Boolean(
pullRequest?.reviews.pageInfo.hasNextPage ||
pullRequest?.comments.pageInfo.hasNextPage ||
pullRequest?.reviewThreads.pageInfo.hasNextPage ||
pullRequest?.reviewThreads.nodes.some((thread) => thread.comments.pageInfo.hasNextPage),
),
error: pullRequest ? null : { kind: "not_found", message: "Pull request not found" },
};
}
function toPullRequestTimelineReviewItem(
review: z.infer<typeof PullRequestTimelineReviewNodeSchema>,
): PullRequestTimelineItem[] {
const reviewState = mapTimelineReviewState(review.state, review.body ?? "");
if (!reviewState) {
return [];
}
return [
{
kind: "review",
id: review.id,
author: review.author?.login ?? "unknown",
authorUrl: review.author?.url ?? null,
avatarUrl: review.author?.avatarUrl ?? null,
body: normalizeGitHubTimelineBody(review.body ?? "", review.bodyHTML ?? ""),
createdAt: parseOptionalTime(review.submittedAt ?? null),
url: review.url,
reviewState,
},
];
}
function toPullRequestTimelineCommentItem(
comment: z.infer<typeof PullRequestTimelineCommentNodeSchema>,
): PullRequestTimelineItem {
return {
kind: "comment",
id: comment.id,
author: comment.author?.login ?? "unknown",
authorUrl: comment.author?.url ?? null,
avatarUrl: comment.author?.avatarUrl ?? null,
body: normalizeGitHubTimelineBody(comment.body ?? "", comment.bodyHTML ?? ""),
createdAt: parseOptionalTime(comment.createdAt ?? null),
url: comment.url,
};
}
interface ImageSourceReference {
src: string;
start: number;
end: number;
}
const RAW_MARKDOWN_IMAGE_RE = /!\[[^\]]*\]\(\s*([^\s)]+)(?:\s+["'][^)]*["'])?\s*\)/g;
const HTML_IMAGE_RE = /<img\b[^>]*\bsrc\s*=\s*(["'])(.*?)\1[^>]*>/gi;
const GITHUB_RENDERED_IMAGE_HOSTS = new Set([
"camo.githubusercontent.com",
"private-user-images.githubusercontent.com",
]);
function normalizeGitHubTimelineBody(body: string, bodyHTML: string): string {
const rawImages = extractRawImageSourceReferences(body);
if (rawImages.length === 0) {
return body;
}
const renderedSources = extractRenderedImageSources(bodyHTML);
if (renderedSources.length !== rawImages.length) {
return body;
}
let cursor = 0;
let normalized = "";
for (let index = 0; index < rawImages.length; index += 1) {
const rawImage = rawImages[index];
const renderedSrc = renderedSources[index];
if (
!rawImage ||
!renderedSrc ||
!isRawGitHubAttachmentSource(rawImage.src) ||
!isGitHubRenderedImageSource(renderedSrc)
) {
return body;
}
normalized += body.slice(cursor, rawImage.start);
normalized += renderedSrc;
cursor = rawImage.end;
}
normalized += body.slice(cursor);
return normalized;
}
function extractRawImageSourceReferences(source: string): ImageSourceReference[] {
const references = [
...extractHtmlImageSourceReferences(source),
...extractMarkdownImageSourceReferences(source),
];
return references.sort((left, right) => left.start - right.start);
}
function extractRenderedImageSources(source: string): string[] {
return extractHtmlImageSourceReferences(source).map((reference) => reference.src);
}
function extractHtmlImageSourceReferences(source: string): ImageSourceReference[] {
const references: ImageSourceReference[] = [];
HTML_IMAGE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = HTML_IMAGE_RE.exec(source)) !== null) {
const src = decodeHtmlAttribute(match[2] ?? "");
if (!src) {
continue;
}
const rawAttributeSrc = match[2] ?? "";
const start = match.index + match[0].indexOf(rawAttributeSrc);
references.push({ src, start, end: start + rawAttributeSrc.length });
}
return references;
}
function extractMarkdownImageSourceReferences(source: string): ImageSourceReference[] {
const references: ImageSourceReference[] = [];
RAW_MARKDOWN_IMAGE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = RAW_MARKDOWN_IMAGE_RE.exec(source)) !== null) {
const src = match[1] ?? "";
if (!src) {
continue;
}
const start = match.index + match[0].indexOf(src);
references.push({ src, start, end: start + src.length });
}
return references;
}
function isRawGitHubAttachmentSource(src: string): boolean {
try {
const url = new URL(src);
return (
url.protocol === "https:" &&
url.hostname === "github.com" &&
url.pathname.startsWith("/user-attachments/assets/")
);
} catch {
return false;
}
}
function isGitHubRenderedImageSource(src: string): boolean {
try {
const url = new URL(src);
return url.protocol === "https:" && GITHUB_RENDERED_IMAGE_HOSTS.has(url.hostname);
} catch {
return false;
}
}
function decodeHtmlAttribute(value: string): string {
return value
.replaceAll("&amp;", "&")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
}
function toPullRequestTimelineReviewThreadItems(
thread: z.infer<typeof PullRequestReviewThreadNodeSchema>,
): PullRequestTimelineItem[] {
return thread.comments.nodes.map((comment) => ({
...toPullRequestTimelineCommentItem(comment),
...(comment.pullRequestReview?.id ? { reviewId: comment.pullRequestReview.id } : {}),
location: {
path: thread.path,
...(thread.line !== null && thread.line !== undefined ? { line: thread.line } : {}),
...(thread.startLine !== null && thread.startLine !== undefined
? { startLine: thread.startLine }
: {}),
...(thread.id ? { threadId: thread.id } : {}),
isResolved: thread.isResolved,
isOutdated: thread.isOutdated,
},
}));
}
function parseGitHubCheckRunDetails(stdout: string): GitHubCheckDetails {
const parsed = GitHubCheckRunDetailsSchema.parse(JSON.parse(stdout || "{}"));
return {
checkRunId: parsed.id,
workflowRunId: parsed.check_suite?.workflow_run?.id ?? null,
name: parsed.name,
status: parsed.status,
conclusion: parsed.conclusion,
url: parsed.html_url,
detailsUrl: parsed.details_url,
output: parsed.output,
annotations: [],
failedJobs: [],
truncated: false,
};
}
function parseGitHubCheckAnnotations(stdout: string): GitHubCheckAnnotation[] {
return GitHubCheckAnnotationsSchema.parse(JSON.parse(stdout || "[]")).map((annotation) => {
const result: GitHubCheckAnnotation = {};
if (annotation.path) result.path = annotation.path;
if (annotation.start_line !== undefined) result.startLine = annotation.start_line;
if (annotation.end_line !== undefined) result.endLine = annotation.end_line;
if (annotation.annotation_level) result.annotationLevel = annotation.annotation_level;
if (annotation.message) result.message = annotation.message;
if (annotation.title) result.title = annotation.title;
if (annotation.raw_details) result.rawDetails = annotation.raw_details;
return result;
});
}
function parseGitHubActionsJobs(stdout: string): GitHubCheckFailedJob[] {
return GitHubActionsJobsSchema.parse(JSON.parse(stdout || "{}")).jobs.map((job) => {
const result: GitHubCheckFailedJob = {
jobId: job.id,
name: job.name,
status: job.status,
conclusion: job.conclusion,
url: job.html_url,
};
if (job.completed_at) result.completedAt = job.completed_at;
return result;
});
}
function isFailedActionsJob(job: GitHubCheckFailedJob): boolean {
return (
job.conclusion === "failure" ||
job.conclusion === "cancelled" ||
job.conclusion === "timed_out" ||
job.conclusion === "action_required"
);
}
async function getCachedCheckLogTail(input: {
cwd: string;
repoPath: string;
job: GitHubCheckFailedJob & { completedAt?: string };
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
cache: Map<string, { logTail: string; logTruncated: boolean }>;
}): Promise<{ logTail: string; logTruncated: boolean }> {
const key = `${input.job.jobId}:${input.job.completedAt ?? ""}`;
const cached = input.cache.get(key);
if (cached) {
input.cache.delete(key);
input.cache.set(key, cached);
return cached;
}
const capped = capCheckLogTail(
await input.run(["api", `${input.repoPath}/actions/jobs/${input.job.jobId}/logs`], {
cwd: input.cwd,
}),
);
input.cache.set(key, capped);
while (input.cache.size > CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
const oldestKey = input.cache.keys().next().value;
if (!oldestKey) {
break;
}
input.cache.delete(oldestKey);
}
return capped;
}
function capCheckLogTail(log: string): { logTail: string; logTruncated: boolean } {
const lines = log.split("\n");
let truncated = lines.length > CHECK_LOG_TAIL_MAX_LINES;
let tail = lines.slice(-CHECK_LOG_TAIL_MAX_LINES).join("\n");
if (Buffer.byteLength(tail, "utf8") > CHECK_LOG_TAIL_MAX_BYTES) {
truncated = true;
tail = utf8SuffixWithinBytes(tail, CHECK_LOG_TAIL_MAX_BYTES);
}
return { logTail: tail, logTruncated: truncated };
}
function utf8SuffixWithinBytes(value: string, maxBytes: number): string {
let lowerBound = 0;
let upperBound = value.length;
while (lowerBound < upperBound) {
const midpoint = Math.floor((lowerBound + upperBound) / 2);
if (Buffer.byteLength(value.slice(midpoint), "utf8") > maxBytes) {
lowerBound = midpoint + 1;
} else {
upperBound = midpoint;
}
}
return value.slice(lowerBound);
}
function mapTimelineReviewState(
state: string,
body: string,
): PullRequestTimelineReviewState | null {
switch (state) {
case "APPROVED":
return "approved";
case "CHANGES_REQUESTED":
return "changes_requested";
case "COMMENTED":
return "commented";
case "DISMISSED":
case "PENDING":
return body.trim().length > 0 ? "commented" : null;
default:
return body.trim().length > 0 ? "commented" : null;
}
}
function compareTimelineItems(
left: PullRequestTimelineItem,
right: PullRequestTimelineItem,
): number {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
}
function mapPullRequestTimelineError(error: unknown): GitHubPullRequestTimelineError {
if (error instanceof GitHubCommandError) {
return {
kind: classifyPullRequestTimelineError(error.stderr),
message: error.stderr || error.message,
};
}
if (error instanceof GitHubAuthenticationError) {
return {
kind: "forbidden",
message: error.stderr || error.message,
};
}
return {
kind: "unknown",
message: error instanceof Error ? error.message : String(error),
};
}
function classifyPullRequestTimelineError(stderr: string): GitHubPullRequestTimelineErrorKind {
const normalized = stderr.toLowerCase();
if (
normalized.includes("could not resolve to a pullrequest") ||
normalized.includes("pull request not found") ||
normalized.includes("pullrequest not found")
) {
return "not_found";
}
if (
normalized.includes("forbidden") ||
normalized.includes("resource not accessible") ||
normalized.includes("permission") ||
normalized.includes("access denied") ||
normalized.includes("requires authentication") ||
normalized.includes("http 403")
) {
return "forbidden";
}
return "unknown";
}
function toCurrentPullRequestStatus(
item: CurrentPullRequestStatusItem,
fallbackHeadRefName: string,
): GitHubCurrentPullRequestStatus | null {
if (!item.url || !item.title) {
return null;
}
const repoIdentity = parseGitHubPullRequestRepo(item.url);
const mergedAt =
typeof item.mergedAt === "string" && item.mergedAt.trim().length > 0 ? item.mergedAt : null;
let state: string;
if (mergedAt !== null) {
state = "merged";
} else if (item.state.trim().length > 0) {
state = item.state.toLowerCase();
} else {
state = "";
}
const checks = parseStatusCheckRollup(item.statusCheckRollup);
return {
...(typeof item.number === "number" ? { number: item.number } : {}),
...(repoIdentity ? { repoOwner: repoIdentity.owner, repoName: repoIdentity.name } : {}),
url: item.url,
title: item.title,
state,
baseRefName: item.baseRefName,
headRefName: item.headRefName || fallbackHeadRefName,
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
checks,
checksStatus: computeChecksStatus(checks),
reviewDecision: mapReviewDecision(item.reviewDecision),
};
}
function parseGitHubPullRequestRepo(url: string): { owner: string; name: string } | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") {
return null;
}
const [owner, name, kind] = parsed.pathname.split("/").filter(Boolean);
if (!owner || !name || kind !== "pull") {
return null;
}
return { owner, name };
} catch {
return null;
}
}
export function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
const directContexts = PullRequestStatusCheckRollupArraySchema.safeParse(value);
if (!directContexts.success) {
const legacyContexts = LegacyPullRequestStatusCheckRollupSchema.safeParse(value);
if (!legacyContexts.success) {
return [];
}
return parseStatusCheckRollup(legacyContexts.data.contexts);
}
const dedupedChecks = new Map<string, PullRequestCheck & { recency: number }>();
for (const entry of directContexts.data) {
const parsed = PullRequestStatusCheckRollupNodeSchema.safeParse(entry);
if (!parsed.success) {
continue;
}
const check = buildPullRequestCheck(parsed.data);
if (!check) {
continue;
}
const existing = dedupedChecks.get(check.name);
if (!existing || check.recency > existing.recency) {
dedupedChecks.set(check.name, check);
}
}
return Array.from(dedupedChecks.values(), ({ recency: _recency, ...check }) => check);
}
function buildPullRequestCheck(
context: z.infer<typeof PullRequestStatusCheckRollupNodeSchema>,
): (PullRequestCheck & { recency: number }) | null {
if (context.__typename === "CheckRun") {
return {
name: context.name,
status: mapCheckRunStatus(context.status, context.conclusion),
url: typeof context.detailsUrl === "string" ? context.detailsUrl : null,
...(typeof context.workflowName === "string" && context.workflowName.trim().length > 0
? { workflow: context.workflowName }
: {}),
...(typeof context.databaseId === "number" ? { checkRunId: context.databaseId } : {}),
...(typeof context.checkSuite?.workflowRun?.databaseId === "number"
? { workflowRunId: context.checkSuite.workflowRun.databaseId }
: {}),
...formatCheckRunDuration(context),
recency: getCheckRunRecency(context),
};
}
if (context.__typename === "StatusContext") {
return {
name: context.context,
status: mapStatusContextState(context.state),
url: typeof context.targetUrl === "string" ? context.targetUrl : null,
recency: getStatusContextRecency(context),
};
}
return null;
}
function mapCheckRunStatus(status: unknown, conclusion: unknown): PullRequestCheckStatus {
if (status !== "COMPLETED") {
return "pending";
}
switch (conclusion) {
case "SUCCESS":
return "success";
case "FAILURE":
case "TIMED_OUT":
case "ACTION_REQUIRED":
return "failure";
case "CANCELLED":
return "cancelled";
case "SKIPPED":
case "NEUTRAL":
return "skipped";
default:
return "pending";
}
}
function mapStatusContextState(state: unknown): PullRequestCheckStatus {
switch (state) {
case "SUCCESS":
return "success";
case "FAILURE":
case "ERROR":
return "failure";
case "EXPECTED":
case "PENDING":
return "pending";
default:
return "pending";
}
}
function getCheckRunRecency(context: PullRequestCheckRunNode): number {
const workflowRunId = context.checkSuite?.workflowRun?.databaseId;
if (typeof workflowRunId === "number") {
return workflowRunId;
}
return parseOptionalTime(context.completedAt ?? context.startedAt ?? null);
}
function formatCheckRunDuration(context: PullRequestCheckRunNode): { duration?: string } {
const startedAt = parseOptionalTime(context.startedAt ?? null);
const completedAt = parseOptionalTime(context.completedAt ?? null);
if (startedAt <= 0 || completedAt <= 0 || completedAt < startedAt) {
return {};
}
const durationSeconds = Math.floor((completedAt - startedAt) / 1_000);
return { duration: formatDurationSeconds(durationSeconds) };
}
function formatDurationSeconds(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
if (seconds > 0 || parts.length === 0) {
parts.push(`${seconds}s`);
}
return parts.join(" ");
}
function getStatusContextRecency(context: PullRequestStatusContextNode): number {
return parseOptionalTime(context.createdAt ?? null);
}
function parseOptionalTime(timestamp: string | null): number {
if (!timestamp) {
return 0;
}
const time = Date.parse(timestamp);
return Number.isNaN(time) ? 0 : time;
}
function computeChecksStatus(checks: PullRequestCheck[]): PullRequestChecksStatus {
if (checks.length === 0) {
return "none";
}
if (checks.some((check) => check.status === "failure")) {
return "failure";
}
if (checks.some((check) => check.status === "pending")) {
return "pending";
}
return "success";
}
function mapReviewDecision(value: unknown): PullRequestReviewDecision {
const reviewDecision = PullRequestReviewDecisionSchema.parse(value);
if (reviewDecision === "APPROVED") {
return "approved";
}
if (reviewDecision === "CHANGES_REQUESTED") {
return "changes_requested";
}
if (reviewDecision === "REVIEW_REQUIRED") {
return "pending";
}
return null;
}
export async function resolveGitHubRepo(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
cwd,
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
});
const remote = await resolveGitHubRemote({ remoteUrl: stdout.trim() });
return remote?.repo ?? null;
} catch {
return null;
}
}