diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index e96b4e76..3d86e417 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -429,3 +429,10 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed. - E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions. - `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution. + +## 2026-09-13 — GitHub PR checks sidebar dev launch + +- `npm ci` completed successfully but left `node_modules/electron/dist/Electron.app` absent; restore the macOS payload with `node node_modules/electron/install.js` before running `npm run dev`. +- The default development user-data profile contained unreadable visual-artifact state and disabled chat mutations; use isolated `build/peer-dev-profile` and `build/peer-dev-config` paths for branch testing without modifying shared state. +- Whole-file formatting reflowed unrelated JSX and broke whitespace-sensitive sidebar source assertions; keep those assertions tolerant of formatter line wrapping during focused UI edits. +- OpenCode Workers passed the removed `opencode run --dir` flag to OpenCode v2.0.3, so the isolated review had to run directly from the worker worktree. diff --git a/main/handlers/workspaces.ts b/main/handlers/workspaces.ts index 6c3a5fce..0e330426 100644 --- a/main/handlers/workspaces.ts +++ b/main/handlers/workspaces.ts @@ -21,6 +21,7 @@ import { type GitDiffInput, type GitPushInput, } from "../services/git.js"; +import { githubCurrentPullRequest } from "../services/github-pull-request.js"; import { workspaceApplicationService } from "../services/workspace-application-service-main.js"; import { listWorkspaceFiles, @@ -177,6 +178,20 @@ export function registerWorkspaceHandlers(): void { ), ); + ipcMain.handle("git:pullRequestStatus", async (event, workspaceId: unknown) => + withOptionalWorkspaceOperation(event, workspaceId, async (resolved, signal) => { + if (!resolved) return { availability: "not-repo" as const, message: "This workspace has no accessible folder." }; + const info = await gitInfo(resolved.folderPath, signal); + if (!info.isRepo) { + return { availability: "not-repo" as const, message: "This workspace is not a Git repository." }; + } + if (!info.hasRemote) { + return { availability: "no-pull-request" as const, message: "This repository has no remote to inspect for pull requests." }; + } + return githubCurrentPullRequest(resolved.folderPath, signal); + }), + ); + // ── Environment panel: Files + Review ──────────────────────────────── ipcMain.handle("workspaces:files", async (event, workspaceId: unknown) => withWorkspaceOperation(event, workspaceId, (resolved, signal) => diff --git a/main/services/github-pull-request.test.ts b/main/services/github-pull-request.test.ts new file mode 100644 index 00000000..9ff202e4 --- /dev/null +++ b/main/services/github-pull-request.test.ts @@ -0,0 +1,333 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + GitHubPullRequestService, + dedupeGitHubChecks, + githubCliEnvironment, + normalizeGitHubCheckStatus, + parseGitHubPullRequest, + rollupGitHubChecksState, +} from "./github-pull-request.js"; + +test("normalizes GitHub status checks and conclusions", () => { + assert.equal(normalizeGitHubCheckStatus({ status: "IN_PROGRESS" }), "pending"); + assert.equal(normalizeGitHubCheckStatus({ conclusion: "SUCCESS" }), "success"); + assert.equal(normalizeGitHubCheckStatus({ conclusion: "ACTION_REQUIRED" }), "action-required"); + assert.equal(normalizeGitHubCheckStatus({ conclusion: "TIMED_OUT" }), "failure"); + assert.equal(normalizeGitHubCheckStatus({ conclusion: "CANCELLED" }), "cancelled"); + assert.equal(normalizeGitHubCheckStatus({ conclusion: "SKIPPED" }), "skipped"); + assert.equal(normalizeGitHubCheckStatus({ state: "EXPECTED" }), "pending"); + assert.equal(normalizeGitHubCheckStatus({ conclusion: "STALE" }), "neutral"); +}); + +test("dedupes rerun checks by workflow and check name while preserving row position", () => { + const checks = dedupeGitHubChecks([ + { + name: "test", + workflowName: "CI", + conclusion: "FAILURE", + startedAt: "2026-01-01T00:00:00Z", + detailsUrl: "https://example.test/old", + }, + { + name: "lint", + workflowName: "CI", + conclusion: "SUCCESS", + startedAt: "2026-01-01T00:00:01Z", + }, + { + name: "test", + workflowName: "CI", + status: "IN_PROGRESS", + startedAt: "2026-01-01T00:00:02Z", + detailsUrl: "https://example.test/new", + }, + ]); + + assert.deepEqual(checks.map((check) => [check.name, check.status, check.url]), [ + ["test", "pending", "https://example.test/new"], + ["lint", "success", undefined], + ]); +}); + +test("queued reruns replace older completed failures before timestamps regardless of rollup order", () => { + const completedFailure = { + name: "test", + workflowName: "CI", + conclusion: "FAILURE", + completedAt: "2026-01-01T00:00:00Z", + }; + const queuedRerun = { + name: "test", + workflowName: "CI", + status: "QUEUED", + }; + + for (const rawChecks of [[completedFailure, queuedRerun], [queuedRerun, completedFailure]]) { + const checks = dedupeGitHubChecks(rawChecks); + assert.deepEqual(checks.map((check) => [check.name, check.status]), [["test", "pending"]]); + } +}); + +test("qualifies same-named checks from different workflows", () => { + const checks = dedupeGitHubChecks([ + { name: "test", workflowName: "macOS", conclusion: "SUCCESS", startedAt: "2026-01-01T00:00:00Z" }, + { name: "test", workflowName: "Linux", conclusion: "FAILURE", startedAt: "2026-01-01T00:00:01Z" }, + ]); + + assert.deepEqual(checks.map((check) => check.name), ["macOS / test", "Linux / test"]); + assert.equal(rollupGitHubChecksState(checks), "failing"); +}); + +test("rollup treats no checks as absent and cancelled checks as non-passing", () => { + assert.equal(rollupGitHubChecksState([]), null); + assert.equal(rollupGitHubChecksState([{ name: "lint", status: "success" }]), "passing"); + assert.equal(rollupGitHubChecksState([{ name: "lint", status: "action-required" }]), "pending"); + assert.equal(rollupGitHubChecksState([{ name: "lint", status: "failure" }]), "failing"); + assert.equal(rollupGitHubChecksState([{ name: "lint", status: "cancelled" }]), "failing"); + assert.equal(rollupGitHubChecksState([ + { name: "lint", status: "success" }, + { name: "test", status: "cancelled" }, + ]), "failing"); +}); + +test("parses gh pr view statusCheckRollup into the renderer-safe summary", () => { + const pr = parseGitHubPullRequest(JSON.stringify({ + number: 42, + title: "Add source control checks", + url: "https://github.com/acme/app/pull/42", + state: "OPEN", + isDraft: false, + headRefName: "feature/source-control", + baseRefName: "main", + statusCheckRollup: { + contexts: { + nodes: [ + { name: "typecheck", conclusion: "SUCCESS", detailsUrl: "https://example.test/typecheck" }, + { context: "test", conclusion: "FAILURE", description: "Unit tests failed" }, + ], + }, + }, + })); + + assert.equal(pr.number, 42); + assert.equal(pr.state, "open"); + assert.equal(pr.checksState, "failing"); + assert.deepEqual(pr.checks.map((check) => [check.name, check.status, check.description, check.url]), [ + ["typecheck", "success", undefined, "https://example.test/typecheck"], + ["test", "failure", "Unit tests failed", undefined], + ]); +}); + +test("parses flat statusCheckRollup contexts and drops unsafe URLs", () => { + const pr = parseGitHubPullRequest(JSON.stringify({ + number: 43, + title: "Flattened checks", + url: "https://github.com/acme/app/pull/43", + state: "OPEN", + headRefName: "feature/flat", + baseRefName: "main", + statusCheckRollup: { + contexts: [ + { name: "safe", conclusion: "SUCCESS", detailsUrl: "https://checks.example.test/run" }, + { name: "unsafe", conclusion: "FAILURE", detailsUrl: "javascript:alert(1)" }, + { name: "credentials", conclusion: "SUCCESS", detailsUrl: "https://user:pass@example.test/run" }, + ], + }, + })); + + assert.deepEqual(pr.checks.map((check) => [check.name, check.status, check.url]), [ + ["safe", "success", "https://checks.example.test/run"], + ["unsafe", "failure", undefined], + ["credentials", "success", undefined], + ]); +}); + +test("sanitizes check text before dedupe keys and renderer output", () => { + const checks = dedupeGitHubChecks([ + { workflowName: "A", name: "B\u0000C", conclusion: "FAILURE", startedAt: "2026-01-01T00:00:00Z" }, + { workflowName: "A\u0000B", name: "C", conclusion: "SUCCESS", startedAt: "2026-01-01T00:00:01Z" }, + ]); + + assert.deepEqual(checks.map((check) => [check.name, check.status]), [ + ["B C", "failure"], + ["C", "success"], + ]); + assert.equal(rollupGitHubChecksState(checks), "failing"); +}); + +test("rollup uses every check even when the renderer row list is capped", () => { + const checks = Array.from({ length: 101 }, (_, index) => ({ + name: `check-${index}`, + conclusion: index === 100 ? "FAILURE" : "SUCCESS", + })); + const pr = parseGitHubPullRequest(JSON.stringify({ + number: 44, + title: "Many checks", + url: "https://github.com/acme/app/pull/44", + state: "OPEN", + headRefName: "feature/many-checks", + baseRefName: "main", + statusCheckRollup: checks, + })); + + assert.equal(pr.checks.length, 100); + assert.equal(pr.checksState, "failing"); +}); + +test("service returns actionable availability states for common gh failures", async () => { + const missing = new GitHubPullRequestService({ + runner: async () => { + const error = new Error("spawn gh ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }, + }); + assert.deepEqual(await missing.currentPullRequest("/repo"), { + availability: "missing-tool", + message: "Install GitHub CLI, then run `gh auth login`.", + }); + + const noPr = new GitHubPullRequestService({ + runner: async () => { + const error = new Error("no pull requests found for branch"); + throw error; + }, + }); + assert.deepEqual(await noPr.currentPullRequest("/repo"), { + availability: "no-pull-request", + message: "No GitHub pull request is linked to the current branch.", + }); + + const unauthenticated = new GitHubPullRequestService({ + runner: async () => { + throw new Error("authentication required; run gh auth login"); + }, + }); + assert.deepEqual(await unauthenticated.currentPullRequest("/repo"), { + availability: "unauthenticated", + message: "Run `gh auth login` on this Mac, then refresh source control.", + }); + + const unsupported = new GitHubPullRequestService({ + runner: async () => { + throw new Error("unknown JSON field: statusCheckRollup; available fields are number,title"); + }, + }); + assert.deepEqual(await unsupported.currentPullRequest("/repo"), { + availability: "unsupported", + message: "Update GitHub CLI so Aiden can read pull request status.", + }); + + const nonGitHubRemote = new GitHubPullRequestService({ + runner: async () => { + throw new Error( + "none of the git remotes configured for this repository point to a known GitHub host. To tell gh about a new GitHub host, please use `gh auth login`", + ); + }, + }); + assert.deepEqual(await nonGitHubRemote.currentPullRequest("/repo"), { + availability: "not-github", + message: "This repository's remote is not hosted on GitHub.", + }); +}); + +test("service redacts credentials and workspace paths from renderer-facing failures", async () => { + const service = new GitHubPullRequestService({ + runner: async () => { + throw new Error( + "Command failed: /opt/homebrew/bin/gh pr view in /Users/alice/project with https://alice:secret-token@example.test/repo?access_token=also-secret&private_token=hidden", + ); + }, + }); + + const result = await service.currentPullRequest("/Users/alice/project"); + assert.equal(result.availability, "error"); + assert.ok(result.message?.includes("the workspace")); + assert.ok(result.message?.includes("https://***@example.test/repo?access_token=***&private_token=***")); + assert.doesNotMatch(result.message ?? "", /secret-token|also-secret|hidden|\/Users\/alice\/project|\/opt\/homebrew\/bin\/gh/u); +}); + +test("service reports subprocess timeouts with the configured timeout", async () => { + const service = new GitHubPullRequestService({ + timeoutMs: 2_500, + runner: async () => { + const error = new Error("Command failed: gh pr view") as Error & { killed: boolean }; + error.killed = true; + throw error; + }, + }); + + assert.deepEqual(await service.currentPullRequest("/repo"), { + availability: "error", + message: "GitHub CLI did not answer within 3 seconds.", + }); +}); + +test("service rethrows aborted reads instead of caching them as GitHub errors", async () => { + const service = new GitHubPullRequestService({ + runner: async () => { + const error = new Error("The operation was aborted") as Error & { code: string; name: string }; + error.code = "ABORT_ERR"; + error.name = "AbortError"; + throw error; + }, + }); + + await assert.rejects(() => service.currentPullRequest("/repo"), /aborted/u); +}); + +test("service resolves a GitHub CLI binary before invoking the runner", async () => { + let observedBinary = ""; + const service = new GitHubPullRequestService({ + resolveBinary: async () => "/opt/homebrew/bin/gh", + runner: async (_cwd, _args, options) => { + observedBinary = options.binary; + return { + stderr: "", + stdout: JSON.stringify({ + number: 1, + title: "Ready", + url: "https://github.com/acme/app/pull/1", + state: "OPEN", + headRefName: "feature/ready", + baseRefName: "main", + statusCheckRollup: [], + }), + }; + }, + }); + + assert.equal((await service.currentPullRequest("/repo")).availability, "ready"); + assert.equal(observedBinary, "/opt/homebrew/bin/gh"); +}); + +test("GitHub CLI environment removes Git routing while preserving noninteractive auth lookup", () => { + const previous = { ...process.env }; + try { + process.env.GIT_DIR = "/tmp/wrong.git"; + process.env.GIT_WORK_TREE = "/tmp/wrong-worktree"; + process.env.GIT_CONFIG_COUNT = "1"; + process.env.GIT_CONFIG_PARAMETERS = "'core.sshCommand=bad'"; + process.env.GIT_CONFIG_KEY_0 = "remote.origin.url"; + process.env.GIT_CONFIG_VALUE_0 = "https://example.test/repo"; + process.env.GH_HOST = "github.example.test"; + process.env.GH_REPO = "owner/other"; + process.env.GH_TOKEN = "kept-for-gh"; + const env = githubCliEnvironment(); + assert.equal(env.GIT_DIR, undefined); + assert.equal(env.GIT_WORK_TREE, undefined); + assert.equal(env.GIT_CONFIG_COUNT, undefined); + assert.equal(env.GIT_CONFIG_PARAMETERS, undefined); + assert.equal(env.GIT_CONFIG_KEY_0, undefined); + assert.equal(env.GIT_CONFIG_VALUE_0, undefined); + assert.equal(env.GH_HOST, undefined); + assert.equal(env.GH_REPO, undefined); + assert.equal(env.GIT_TERMINAL_PROMPT, "0"); + assert.equal(env.LANG, "C"); + assert.equal(env.LC_ALL, "C"); + assert.equal(env.GH_TOKEN, "kept-for-gh"); + } finally { + process.env = previous; + } +}); diff --git a/main/services/github-pull-request.ts b/main/services/github-pull-request.ts new file mode 100644 index 00000000..eead71c1 --- /dev/null +++ b/main/services/github-pull-request.ts @@ -0,0 +1,398 @@ +// GitHub pull request/check status reads for workspace repositories. The service +// intentionally shells out through GitHub CLI (`gh`) instead of embedding a +// token-bearing API client so Aiden reuses the user's existing GitHub setup on +// this Mac. Outputs and renderer-facing fields are bounded. + +import { execFile, type ExecFileException } from "child_process"; +import { constants as fsConstants } from "fs"; +import { access } from "fs/promises"; +import * as os from "os"; +import { promisify } from "util"; +import type { + GitHubPullRequestCheck, + GitHubPullRequestCheckStatus, + GitHubPullRequestChecksState, + GitHubPullRequestStatus, + GitHubPullRequestSummary, + GitHubPullRequestAvailability, +} from "./types.js"; + +export type { + GitHubPullRequestCheck, + GitHubPullRequestCheckStatus, + GitHubPullRequestChecksState, + GitHubPullRequestStatus, + GitHubPullRequestSummary, + GitHubPullRequestAvailability, +} from "./types.js"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024; +const MAX_RENDERER_STRING_CHARS = 2_048; +const MAX_CHECKS = 100; +const CHECK_IDENTITY_SEPARATOR = "\u0000"; +const GH_BINARY_CANDIDATES = [ + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + "/opt/local/bin/gh", + "/usr/bin/gh", +] as const; +const GIT_ROUTING_ENV = [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_WORK_TREE", +] as const; + +interface CommandResult { + stdout: string; + stderr: string; +} + +interface CommandOptions { + binary: string; + signal?: AbortSignal; + timeoutMs: number; + maxBuffer: number; +} + +export interface GitHubPullRequestServiceOptions { + runner?: (cwd: string, args: string[], options: CommandOptions) => Promise; + resolveBinary?: () => Promise; + timeoutMs?: number; + maxBufferBytes?: number; +} + +interface RawStatusCheckNode { + name?: unknown; + context?: unknown; + state?: unknown; + status?: unknown; + conclusion?: unknown; + description?: unknown; + detailsUrl?: unknown; + targetUrl?: unknown; + workflowName?: unknown; + startedAt?: unknown; + completedAt?: unknown; +} + +interface RawPullRequest { + number?: unknown; + title?: unknown; + url?: unknown; + state?: unknown; + isDraft?: unknown; + headRefName?: unknown; + baseRefName?: unknown; + statusCheckRollup?: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown, maxLength = MAX_RENDERER_STRING_CHARS): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.replace(/\p{Cc}+/gu, " ").trim(); + return trimmed.length > 0 ? trimmed.slice(0, maxLength) : undefined; +} + +function replaceAllLiteral(value: string, search: string, replacement: string): string { + return search ? value.split(search).join(replacement) : value; +} + +function safeHttpUrl(value: unknown): string | undefined { + const candidate = boundedString(value); + if (!candidate) return undefined; + try { + const url = new URL(candidate); + if ((url.protocol !== "https:" && url.protocol !== "http:") || !url.hostname || url.username || url.password) { + return undefined; + } + return url.toString().slice(0, MAX_RENDERER_STRING_CHARS); + } catch { + return undefined; + } +} + +function redactAbsolutePaths(value: string): string { + return value.replace(/(^|[\s"'`=(:])\/(?:[\w.-]+\/)+[\w.-]+/gu, "$1[path]"); +} + +function publicCommandMessage(error: unknown, cwd: string): string { + const raw = error instanceof Error ? error.message : String(error || "GitHub CLI failed."); + const withoutWorkspace = replaceAllLiteral(raw, cwd, "the workspace"); + const withoutHome = replaceAllLiteral(withoutWorkspace, os.homedir(), "~"); + return redactAbsolutePaths(withoutHome) + .replace(/([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/gi, "$1***@") + .replace(/([?&](?:access_token|auth|key|password|private_token|signature|token)=)[^&\s]+/gi, "$1***") + .replace(/\p{Cc}+/gu, " ") + .trim() + .slice(0, 600) || "GitHub CLI failed."; +} + +function isAbortError(error: unknown): boolean { + const err = error as { code?: unknown; name?: unknown } | undefined; + return err?.code === "ABORT_ERR" || err?.name === "AbortError"; +} + +function commandFailureKind(error: unknown): Exclude { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") return "missing-tool"; + const execError = error as ExecFileException | undefined; + const combined = `${execError?.stdout ?? ""}\n${execError?.stderr ?? ""}\n${execError?.message ?? ""}`; + if (/gh(?:.*?)not found|spawn .*gh ENOENT|ENOENT/u.test(combined)) return "missing-tool"; + if (/none of the git remotes|no git remotes|not a github repository|point to a known github host/iu.test(combined)) { + return "not-github"; + } + if (/auth login|not logged into|authentication required|HTTP 401|unauthorized|could not authenticate/iu.test(combined)) { + return "unauthenticated"; + } + if (/unknown flag: --json|unknown (?:json )?field|available fields/iu.test(combined)) return "unsupported"; + if (/no pull requests? found|no open pull requests? found|could not find any pull requests?/iu.test(combined)) { + return "no-pull-request"; + } + return "error"; +} + +function checkTimestamp(value: unknown): number { + if (typeof value !== "string" || value === "0001-01-01T00:00:00Z") return 0; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : 0; +} + +export function normalizeGitHubCheckStatus(node: RawStatusCheckNode): GitHubPullRequestCheckStatus { + const status = boundedString(node.status)?.toUpperCase(); + if (status && status !== "COMPLETED") return "pending"; + const value = boundedString(node.conclusion)?.toUpperCase() ?? boundedString(node.state)?.toUpperCase(); + switch (value) { + case "SUCCESS": + return "success"; + case "ACTION_REQUIRED": + return "action-required"; + case "FAILURE": + case "ERROR": + case "TIMED_OUT": + case "STARTUP_FAILURE": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return "neutral"; + } +} + +function rawCheckName(node: RawStatusCheckNode): string | undefined { + return boundedString(node.name) ?? boundedString(node.context); +} + +function rawCheckUrl(node: RawStatusCheckNode): string | undefined { + return safeHttpUrl(node.detailsUrl) ?? safeHttpUrl(node.targetUrl); +} + +function extractRawChecks(value: unknown): RawStatusCheckNode[] { + const nodes = isRecord(value) && isRecord(value.contexts) && Array.isArray(value.contexts.nodes) + ? value.contexts.nodes + : isRecord(value) && Array.isArray(value.contexts) + ? value.contexts + : isRecord(value) && Array.isArray(value.nodes) + ? value.nodes + : Array.isArray(value) + ? value + : []; + return nodes.filter(isRecord) as RawStatusCheckNode[]; +} + +function shouldReplaceCheck( + previous: { check: GitHubPullRequestCheck; timestamp: number } | undefined, + next: { check: GitHubPullRequestCheck; timestamp: number }, +): boolean { + if (!previous) return true; + const isUnstartedPending = (entry: { check: GitHubPullRequestCheck; timestamp: number }) => + entry.timestamp === 0 && (entry.check.status === "pending" || entry.check.status === "action-required"); + if (isUnstartedPending(next)) return true; + if (isUnstartedPending(previous)) return false; + return next.timestamp >= previous.timestamp; +} + +export function dedupeGitHubChecks(rawChecks: RawStatusCheckNode[]): GitHubPullRequestCheck[] { + const entries = new Map(); + for (const raw of rawChecks) { + const name = rawCheckName(raw); + if (!name) continue; + const workflow = boundedString(raw.workflowName); + const key = `${workflow ?? ""}${CHECK_IDENTITY_SEPARATOR}${name}`; + const timestamp = Math.max(checkTimestamp(raw.completedAt), checkTimestamp(raw.startedAt)); + const url = rawCheckUrl(raw); + const description = boundedString(raw.description); + const check: GitHubPullRequestCheck = { + name, + status: normalizeGitHubCheckStatus(raw), + ...(description ? { description } : {}), + ...(url ? { url } : {}), + }; + const next = { check, workflow, timestamp }; + if (shouldReplaceCheck(entries.get(key), next)) entries.set(key, next); + } + + const nameCounts = new Map(); + for (const entry of entries.values()) { + nameCounts.set(entry.check.name, (nameCounts.get(entry.check.name) ?? 0) + 1); + } + + return [...entries.values()].map(({ check, workflow }) => ({ + ...check, + name: workflow && (nameCounts.get(check.name) ?? 0) > 1 ? `${workflow} / ${check.name}` : check.name, + })); +} + +export function rollupGitHubChecksState( + checks: readonly GitHubPullRequestCheck[], +): GitHubPullRequestChecksState | null { + if (checks.some((check) => check.status === "failure" || check.status === "cancelled")) return "failing"; + if (checks.some((check) => check.status === "pending" || check.status === "action-required")) { + return "pending"; + } + if (checks.some((check) => check.status === "success")) return "passing"; + return null; +} + +export function parseGitHubPullRequest(rawJson: string): GitHubPullRequestSummary { + const parsed = JSON.parse(rawJson) as unknown; + if (!isRecord(parsed)) throw new Error("GitHub CLI returned an invalid pull request response."); + const raw = parsed as RawPullRequest; + const number = typeof raw.number === "number" && Number.isInteger(raw.number) ? raw.number : 0; + const title = boundedString(raw.title); + const url = safeHttpUrl(raw.url); + const stateValue = boundedString(raw.state)?.toUpperCase(); + const headBranch = boundedString(raw.headRefName); + const baseBranch = boundedString(raw.baseRefName); + if (!number || !title || !url || !headBranch || !baseBranch) { + throw new Error("GitHub CLI returned an incomplete pull request response."); + } + const state = stateValue === "MERGED" ? "merged" : stateValue === "CLOSED" ? "closed" : "open"; + const allChecks = dedupeGitHubChecks(extractRawChecks(raw.statusCheckRollup)); + return { + number, + title, + url, + state, + ...(raw.isDraft === true ? { isDraft: true } : {}), + headBranch, + baseBranch, + checks: allChecks.slice(0, MAX_CHECKS), + checksState: rollupGitHubChecksState(allChecks), + }; +} + +export function githubCliEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of GIT_ROUTING_ENV) delete env[key]; + delete env.GIT_CONFIG_COUNT; + delete env.GIT_CONFIG_PARAMETERS; + delete env.GH_HOST; + delete env.GH_REPO; + for (const key of Object.keys(env)) { + if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) delete env[key]; + } + return { ...env, GIT_TERMINAL_PROMPT: "0", LANG: "C", LC_ALL: "C" }; +} + +async function resolveGitHubCliBinary(): Promise { + for (const candidate of GH_BINARY_CANDIDATES) { + try { + await access(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Try the next well-known macOS install location before falling back to PATH. + } + } + return "gh"; +} + +async function defaultRunner( + cwd: string, + args: string[], + options: CommandOptions, +): Promise { + const result = await execFileAsync(options.binary, args, { + cwd, + encoding: "utf8", + env: githubCliEnvironment(), + maxBuffer: options.maxBuffer, + signal: options.signal, + timeout: options.timeoutMs, + }); + return { stdout: String(result.stdout), stderr: String(result.stderr) }; +} + +export class GitHubPullRequestService { + private readonly runner: NonNullable; + private readonly resolveBinary: NonNullable; + private readonly timeoutMs: number; + private readonly maxBufferBytes: number; + + constructor(options: GitHubPullRequestServiceOptions = {}) { + this.runner = options.runner ?? defaultRunner; + this.resolveBinary = options.resolveBinary ?? resolveGitHubCliBinary; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; + } + + async currentPullRequest(cwd: string, signal?: AbortSignal): Promise { + try { + const result = await this.runner( + cwd, + [ + "pr", + "view", + "--json", + "number,title,url,state,isDraft,headRefName,baseRefName,statusCheckRollup", + ], + { + binary: await this.resolveBinary(), + signal, + timeoutMs: this.timeoutMs, + maxBuffer: this.maxBufferBytes, + }, + ); + return { availability: "ready", pullRequest: parseGitHubPullRequest(result.stdout) }; + } catch (error) { + if (signal?.aborted || isAbortError(error)) throw error; + const availability = commandFailureKind(error); + const code = (error as NodeJS.ErrnoException | undefined)?.code; + const fallbackMessages: Record = { + "missing-tool": "Install GitHub CLI, then run `gh auth login`.", + "unauthenticated": "Run `gh auth login` on this Mac, then refresh source control.", + "no-pull-request": "No GitHub pull request is linked to the current branch.", + "not-github": "This repository's remote is not hosted on GitHub.", + "unsupported": "Update GitHub CLI so Aiden can read pull request status.", + "error": + code === "ETIMEDOUT" || + code === "ERR_CHILD_PROCESS_TIMEOUT" || + (error as { killed?: unknown } | undefined)?.killed === true + ? `GitHub CLI did not answer within ${Math.max(1, Math.round(this.timeoutMs / 1000))} seconds.` + : error instanceof SyntaxError + ? "GitHub CLI returned an invalid pull request response." + : publicCommandMessage(error, cwd), + }; + return { availability, message: fallbackMessages[availability] }; + } + } +} + +const githubPullRequestService = new GitHubPullRequestService(); + +export const githubCurrentPullRequest = (folderPath: string, signal?: AbortSignal) => + githubPullRequestService.currentPullRequest(folderPath, signal); diff --git a/main/services/subagents/subagent-phase3-contract.test.ts b/main/services/subagents/subagent-phase3-contract.test.ts index 7db98e6d..72228058 100644 --- a/main/services/subagents/subagent-phase3-contract.test.ts +++ b/main/services/subagents/subagent-phase3-contract.test.ts @@ -648,7 +648,7 @@ test("every workspace path capability is renderer-document owned and mutation ad `${channel} must use workspace operation admission`, ); } - for (const channel of ["workspaces:gitInfo", "git:branches"]) { + for (const channel of ["workspaces:gitInfo", "git:branches", "git:pullRequestStatus"]) { assert.match( ipcHandlerSource(workspaces, channel), /withOptionalWorkspaceOperation\(\s*event,\s*workspaceId,/u, @@ -658,6 +658,7 @@ test("every workspace path capability is renderer-document owned and mutation ad assert.match(ipcHandlerSource(workspaces, "workspaces:gitInfo"), /gitInfo\(.+signal\)/u); assert.match(ipcHandlerSource(workspaces, "git:branches"), /gitBranches\(.+signal\)/u); + assert.match(ipcHandlerSource(workspaces, "git:pullRequestStatus"), /githubCurrentPullRequest\(.+signal\)/u); assert.match(ipcHandlerSource(workspaces, "git:worktrees"), /gitWorktrees\(.+signal\)/u); assert.match(git, /async info\(cwd: string, signal\?: AbortSignal\)/u); assert.match(git, /async branches\(cwd: string, signal\?: AbortSignal\)/u); diff --git a/main/services/types.ts b/main/services/types.ts index b7fa89fc..2f0dc539 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -126,6 +126,53 @@ export interface Workspace { updatedAt: number; } +/** GitHub pull request and check status reported for a workspace branch. */ +export type GitHubPullRequestCheckStatus = + | "pending" + | "action-required" + | "success" + | "failure" + | "skipped" + | "neutral" + | "cancelled"; + +export type GitHubPullRequestChecksState = "passing" | "failing" | "pending"; + +export type GitHubPullRequestAvailability = + | "ready" + | "not-repo" + | "missing-tool" + | "unauthenticated" + | "no-pull-request" + | "not-github" + | "unsupported" + | "error"; + +export interface GitHubPullRequestCheck { + name: string; + status: GitHubPullRequestCheckStatus; + description?: string; + url?: string; +} + +export interface GitHubPullRequestSummary { + number: number; + title: string; + url: string; + state: "open" | "closed" | "merged"; + isDraft?: boolean; + headBranch: string; + baseBranch: string; + checksState?: GitHubPullRequestChecksState | null; + checks: GitHubPullRequestCheck[]; +} + +export interface GitHubPullRequestStatus { + availability: GitHubPullRequestAvailability; + message?: string; + pullRequest?: GitHubPullRequestSummary; +} + /** Result of inspecting a folder for git status. */ export interface GitInfo { isRepo: boolean; diff --git a/package.json b/package.json index d66a3fc8..e127d360 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,8 @@ "test:voice": "tsx --test main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", - "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", - "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", + "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings", diff --git a/renderer/components/chat-sidebar.test.tsx b/renderer/components/chat-sidebar.test.tsx index 31afde60..4c951580 100644 --- a/renderer/components/chat-sidebar.test.tsx +++ b/renderer/components/chat-sidebar.test.tsx @@ -157,6 +157,58 @@ test("chat pane toolbar no longer exposes a duplicate new-chat control", () => { assert.doesNotMatch(pane, /\bnewChat\b/u); }); +test("workspace pull request indicators surface checks without owning GitHub secrets", () => { + const sidebar = source("./chat-sidebar.tsx"); + const queries = source("../lib/queries.ts"); + const ipc = source("../lib/ipc.ts"); + const indicator = between( + sidebar, + "function WorkspacePullRequestIndicator", + "\n}\n\nfunction updateRestartError", + ); + const pullRequestTrigger = between( + indicator, + "const remainingChecks", + '