Add a keyboard-driven project setup flow (#2097)

* feat(projects): add keyboard-driven project setup

* test(app): update add project loading assertion

* fix(projects): harden GitHub project setup
This commit is contained in:
Mohamed Boudra
2026-07-15 21:16:10 +02:00
committed by GitHub
parent 90e0a0e353
commit dfe3330ef8
52 changed files with 3851 additions and 1023 deletions
@@ -0,0 +1,122 @@
import { access, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createProjectDirectory,
ProjectDirectoryRequestError,
validateDirectoryName,
} from "./project-directory-service.js";
describe("project directory creation", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function createRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "paseo-project-directory-"));
roots.push(root);
return root;
}
it.each(["", " ", ".", "..", "nested/name", "nested\\name", "/absolute", "C:\\absolute"])(
"rejects invalid single directory name %j",
(name) => {
expect(() => validateDirectoryName(name)).toThrow(ProjectDirectoryRequestError);
},
);
it("requires the selected parent directory to already exist", async () => {
const root = await createRoot();
await expect(
createProjectDirectory(
{ parentPath: join(root, "missing"), name: "project" },
{
registerProject: async () => {
throw new Error("registration should not be attempted");
},
},
),
).rejects.toMatchObject({ code: "parent_directory_not_found" });
});
it("does not substitute the daemon cwd for a missing parent selection", async () => {
await expect(
createProjectDirectory(
{ parentPath: " ", name: "project" },
{
registerProject: async () => {
throw new Error("registration should not be attempted");
},
},
),
).rejects.toMatchObject({
code: "parent_directory_not_found",
message: "Parent directory is required",
});
});
it("rejects collisions without changing the existing directory", async () => {
const root = await createRoot();
const existing = join(root, "existing");
await mkdir(existing);
let registrationAttempted = false;
await expect(
createProjectDirectory(
{ parentPath: root, name: "existing" },
{
registerProject: async () => {
registrationAttempted = true;
throw new Error("registration should not be attempted");
},
},
),
).rejects.toMatchObject({ code: "directory_exists", directoryPath: existing });
await expect(access(existing)).resolves.toBeUndefined();
expect(registrationAttempted).toBe(false);
});
it("rolls back the newly created directory when registration fails", async () => {
const root = await createRoot();
const directoryPath = join(root, "unregistered");
await expect(
createProjectDirectory(
{ parentPath: root, name: "unregistered" },
{
registerProject: async () => {
throw new Error("registry unavailable");
},
},
),
).rejects.toMatchObject({ code: "registration_failed", directoryPath });
await expect(access(directoryPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("returns the registered project and created path together", async () => {
const root = await createRoot();
const directoryPath = join(root, "new-project");
const project = {
projectId: `directory:${directoryPath}`,
rootPath: directoryPath,
kind: "non_git" as const,
displayName: "new-project",
customName: null,
createdAt: "2026-07-15T10:00:00Z",
updatedAt: "2026-07-15T10:00:00Z",
archivedAt: null,
};
await expect(
createProjectDirectory(
{ parentPath: root, name: "new-project" },
{ registerProject: async () => project },
),
).resolves.toEqual({ directoryPath, project });
await expect(access(directoryPath)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,215 @@
import { mkdir, rmdir, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep, win32 } from "node:path";
import type { ProjectCreateDirectoryErrorCode } from "@getpaseo/protocol/messages";
import { expandTilde } from "../utils/path.js";
import type { PersistedProjectRecord } from "./workspace-registry.js";
export class ProjectDirectoryRequestError extends Error {
constructor(
readonly code: ProjectCreateDirectoryErrorCode,
message: string,
readonly directoryPath: string | null = null,
) {
super(message);
this.name = "ProjectDirectoryRequestError";
}
}
export interface CreateProjectDirectoryInput {
parentPath: string;
name: string;
}
export interface CreateProjectDirectoryResult {
directoryPath: string;
project: PersistedProjectRecord;
}
interface ProjectDirectoryFileSystem {
stat(path: string): Promise<{ isDirectory(): boolean }>;
mkdir(path: string): Promise<void>;
rmdir(path: string): Promise<void>;
}
interface CreateProjectDirectoryDependencies {
filesystem?: ProjectDirectoryFileSystem;
registerProject(directoryPath: string): Promise<PersistedProjectRecord>;
}
const nodeProjectDirectoryFileSystem: ProjectDirectoryFileSystem = {
stat,
async mkdir(path) {
await mkdir(path);
},
rmdir,
};
export async function createProjectDirectory(
input: CreateProjectDirectoryInput,
dependencies: CreateProjectDirectoryDependencies,
): Promise<CreateProjectDirectoryResult> {
validateDirectoryName(input.name);
const requestedParentPath = input.parentPath.trim();
if (!requestedParentPath) {
throw new ProjectDirectoryRequestError(
"parent_directory_not_found",
"Parent directory is required",
);
}
const parentDirectory = resolve(expandTilde(requestedParentPath));
const directoryPath = resolve(parentDirectory, input.name);
if (!isPathInside(parentDirectory, directoryPath)) {
throw new ProjectDirectoryRequestError(
"invalid_name",
"Directory name must resolve inside the selected parent",
);
}
const filesystem = dependencies.filesystem ?? nodeProjectDirectoryFileSystem;
await requireExistingParent(filesystem, parentDirectory);
try {
await filesystem.mkdir(directoryPath);
} catch (error) {
throw mapCreateDirectoryError(error, directoryPath);
}
try {
const project = await dependencies.registerProject(directoryPath);
return { directoryPath, project };
} catch (registrationError) {
try {
await filesystem.rmdir(directoryPath);
} catch (rollbackError) {
throw new ProjectDirectoryRequestError(
"registration_failed",
`Failed to register project and roll back directory: ${errorMessage(rollbackError)}`,
directoryPath,
);
}
throw new ProjectDirectoryRequestError(
"registration_failed",
`Failed to register project: ${errorMessage(registrationError)}`,
directoryPath,
);
}
}
export function validateDirectoryName(name: string): void {
if (name.trim().length === 0) {
throw new ProjectDirectoryRequestError("invalid_name", "Directory name cannot be empty");
}
if (name !== name.trim()) {
throw new ProjectDirectoryRequestError(
"invalid_name",
"Directory name cannot start or end with whitespace",
);
}
if (name === "." || name === "..") {
throw new ProjectDirectoryRequestError("invalid_name", "Directory name cannot be . or ..");
}
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
throw new ProjectDirectoryRequestError(
"invalid_name",
"Directory name must be a single name without path separators",
);
}
if (isAbsolute(name) || win32.isAbsolute(name) || /^[A-Za-z]:/.test(name)) {
throw new ProjectDirectoryRequestError(
"invalid_name",
"Directory name cannot be an absolute path",
);
}
}
async function requireExistingParent(
filesystem: ProjectDirectoryFileSystem,
parentDirectory: string,
): Promise<void> {
try {
const parent = await filesystem.stat(parentDirectory);
if (parent.isDirectory()) {
return;
}
throw new ProjectDirectoryRequestError(
"parent_directory_not_found",
`Parent directory not found: ${parentDirectory}`,
);
} catch (error) {
if (error instanceof ProjectDirectoryRequestError) {
throw error;
}
const code = nodeErrorCode(error);
if (code === "EACCES" || code === "EPERM") {
throw new ProjectDirectoryRequestError(
"permission_denied",
`Permission denied reading parent directory: ${parentDirectory}`,
);
}
if (code === "ENOENT" || code === "ENOTDIR") {
throw new ProjectDirectoryRequestError(
"parent_directory_not_found",
`Parent directory not found: ${parentDirectory}`,
);
}
throw new ProjectDirectoryRequestError(
"filesystem_error",
`Failed to inspect parent directory: ${errorMessage(error)}`,
);
}
}
function mapCreateDirectoryError(
error: unknown,
directoryPath: string,
): ProjectDirectoryRequestError {
const code = nodeErrorCode(error);
if (code === "EEXIST") {
return new ProjectDirectoryRequestError(
"directory_exists",
`Directory already exists: ${directoryPath}`,
directoryPath,
);
}
if (code === "ENOENT" || code === "ENOTDIR") {
return new ProjectDirectoryRequestError(
"parent_directory_not_found",
`Parent directory not found: ${resolve(directoryPath, "..")}`,
directoryPath,
);
}
if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
return new ProjectDirectoryRequestError(
"permission_denied",
`Permission denied creating directory: ${directoryPath}`,
directoryPath,
);
}
return new ProjectDirectoryRequestError(
"filesystem_error",
`Failed to create directory: ${errorMessage(error)}`,
directoryPath,
);
}
function isPathInside(parentDirectory: string, directoryPath: string): boolean {
const relativePath = relative(parentDirectory, directoryPath);
return (
relativePath.length > 0 &&
relativePath !== ".." &&
!relativePath.startsWith(`..${sep}`) &&
!isAbsolute(relativePath)
);
}
function nodeErrorCode(error: unknown): string | null {
if (!error || typeof error !== "object" || !("code" in error)) {
return null;
}
return typeof error.code === "string" ? error.code : null;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+225 -1
View File
@@ -1,5 +1,5 @@
import { execSync } from "child_process";
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join, resolve as resolvePath } from "path";
import pino from "pino";
@@ -41,6 +41,11 @@ import type {
GitHubPullRequestStatusFacts,
GitHubService,
} from "../services/github-service.js";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
} from "../services/github-service.js";
interface SessionHandlerInternals {
interruptAgentIfRunning(agentId: string): Promise<void>;
@@ -273,6 +278,7 @@ interface SessionForTestOptions {
github?: Partial<GitHubService>;
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
workspaceGitService?: {
getCheckout?: ReturnType<typeof vi.fn>;
getCheckoutDiff?: ReturnType<typeof vi.fn>;
getSnapshot?: ReturnType<typeof vi.fn>;
suggestBranchesForCwd?: ReturnType<typeof vi.fn>;
@@ -315,6 +321,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
scheduleRefreshForCwd: vi.fn(),
};
const workspaceGitService = options.workspaceGitService ?? {
getCheckout: vi.fn(),
getCheckoutDiff: vi.fn(),
getSnapshot: vi.fn(),
suggestBranchesForCwd: vi.fn(),
@@ -387,6 +394,223 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
});
}
describe("project command-center RPCs", () => {
test("returns normalized repositories from the host GitHub service", async () => {
const messages: SessionOutboundMessage[] = [];
const searchRepositories = vi.fn().mockResolvedValue([
{
id: "R_paseo",
name: "paseo",
nameWithOwner: "getpaseo/paseo",
description: "Development environment in your pocket",
visibility: "public",
updatedAt: "2026-07-15T10:00:00Z",
cloneUrl: "git@github.com:getpaseo/paseo.git",
},
]);
const session = createSessionForTest({ messages, github: { searchRepositories } });
await session.handleMessage({
type: "workspace.github.search_repositories.request",
query: "paseo",
limit: 10,
requestId: "req-repositories",
});
expect(searchRepositories).toHaveBeenCalledWith({
cwd: expect.any(String),
query: "paseo",
limit: 10,
});
expect(messages).toEqual([
{
type: "workspace.github.search_repositories.response",
payload: {
status: "success",
requestId: "req-repositories",
repositories: [
{
id: "R_paseo",
name: "paseo",
nameWithOwner: "getpaseo/paseo",
description: "Development environment in your pocket",
visibility: "public",
updatedAt: "2026-07-15T10:00:00Z",
cloneUrl: "git@github.com:getpaseo/paseo.git",
},
],
available: true,
error: null,
},
},
]);
});
test.each([
{
error: new GitHubCliMissingError(),
expected: {
status: "unavailable",
requestId: "req-repositories-error",
reason: "gh_missing",
repositories: [],
available: false,
error: "GitHub CLI (gh) is not installed or not in PATH",
},
},
{
error: new GitHubAuthenticationError({ stderr: "gh auth login" }),
expected: {
status: "unauthenticated",
requestId: "req-repositories-error",
repositories: [],
available: false,
error: "GitHub CLI is not authenticated. Run gh auth login on the host.",
},
},
{
error: new GitHubCommandError({
args: ["search", "repos", "paseo"],
cwd: "/tmp",
exitCode: 1,
stderr: "GitHub API unavailable",
}),
expected: {
status: "error",
requestId: "req-repositories-error",
repositories: [],
available: true,
error: "GitHub API unavailable",
},
},
])("maps GitHub runtime failures to $expected.status", async ({ error, expected }) => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
github: { searchRepositories: vi.fn().mockRejectedValue(error) },
});
await session.handleMessage({
type: "workspace.github.search_repositories.request",
query: "paseo",
requestId: "req-repositories-error",
});
expect(messages).toEqual([
{
type: "workspace.github.search_repositories.response",
payload: expected,
},
]);
});
test("creates a directory and returns its normalized Project descriptor", async () => {
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
const directoryPath = join(parentDirectory, "new-project");
const messages: SessionOutboundMessage[] = [];
const projectUpsert = vi.fn().mockResolvedValue(undefined);
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
upsert: projectUpsert,
},
workspaceGitService: {
getCheckout: vi.fn(async (cwd: string) => ({
cwd,
isGit: false as const,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
})),
},
});
try {
await session.handleMessage({
type: "project.create_directory.request",
parentPath: parentDirectory,
name: "new-project",
requestId: "req-create-directory",
});
expect(existsSync(directoryPath)).toBe(true);
expect(projectUpsert).toHaveBeenCalledOnce();
expect(messages).toEqual([
{
type: "project.create_directory.response",
payload: {
requestId: "req-create-directory",
directoryPath,
project: {
projectId: directoryPath,
projectDisplayName: "new-project",
projectCustomName: null,
projectRootPath: directoryPath,
projectKind: "non_git",
},
error: null,
errorCode: null,
},
},
]);
} finally {
rmSync(parentDirectory, { recursive: true, force: true });
}
});
test("rolls back the directory when Project registration fails", async () => {
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
const directoryPath = join(parentDirectory, "unregistered");
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
},
workspaceGitService: {
getCheckout: vi.fn(async (cwd: string) => ({
cwd,
isGit: false as const,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
})),
},
});
try {
await session.handleMessage({
type: "project.create_directory.request",
parentPath: parentDirectory,
name: "unregistered",
requestId: "req-registration-failure",
});
expect(existsSync(directoryPath)).toBe(false);
expect(messages).toEqual([
{
type: "project.create_directory.response",
payload: {
requestId: "req-registration-failure",
directoryPath,
project: null,
error: "Failed to register project: registry unavailable",
errorCode: "registration_failed",
},
},
]);
} finally {
rmSync(parentDirectory, { recursive: true, force: true });
}
});
});
describe("file explorer binary responses", () => {
const tempDirs: string[] = [];
+141 -1
View File
@@ -184,7 +184,13 @@ import type pino from "pino";
import { FileBackedChatService } from "./chat/chat-service.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import {
createGitHubService,
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
type GitHubService,
} from "../services/github-service.js";
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
import {
summarizeFetchWorkspacesEntries,
@@ -217,6 +223,10 @@ import {
toWorktreeWireError,
} from "./worktree-errors.js";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import {
createProjectDirectory,
ProjectDirectoryRequestError,
} from "./project-directory-service.js";
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
@@ -362,6 +372,10 @@ type FetchWorkspacesResponsePayload = Extract<
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number];
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"];
type WorkspaceProjectDescriptorPayload = FetchWorkspacesResponsePayload["emptyProjects"][number];
type WorkspaceGithubSearchRepositoriesResponsePayload = Extract<
SessionOutboundMessage,
{ type: "workspace.github.search_repositories.response" }
>["payload"];
type WorkspaceUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
@@ -1632,6 +1646,10 @@ export class Session {
return this.handleOpenProjectRequest(msg);
case "project.add.request":
return this.handleProjectAddRequest(msg);
case "project.create_directory.request":
return this.handleProjectCreateDirectoryRequest(msg);
case "workspace.github.search_repositories.request":
return this.handleWorkspaceGithubSearchRepositoriesRequest(msg);
case "workspace.github.clone.request":
return this.handleWorkspaceGithubCloneRequest(msg);
case "archive_workspace_request":
@@ -4770,6 +4788,128 @@ export class Session {
}
}
private async handleProjectCreateDirectoryRequest(
request: Extract<SessionInboundMessage, { type: "project.create_directory.request" }>,
): Promise<void> {
try {
const result = await createProjectDirectory(
{ parentPath: request.parentPath, name: request.name },
{
registerProject: (directoryPath) =>
this.workspaceProvisioning.findOrCreateProjectForDirectory(directoryPath),
},
);
this.emit({
type: "project.create_directory.response",
payload: {
requestId: request.requestId,
directoryPath: result.directoryPath,
project: this.buildProjectDescriptor(result.project),
error: null,
errorCode: null,
},
});
} catch (error) {
const requestError =
error instanceof ProjectDirectoryRequestError
? error
: new ProjectDirectoryRequestError(
"registration_failed",
error instanceof Error ? error.message : "Failed to create project directory",
);
this.sessionLogger.error(
{
err: error,
parentPath: request.parentPath,
name: request.name,
errorCode: requestError.code,
},
"Failed to create project directory",
);
this.emit({
type: "project.create_directory.response",
payload: {
requestId: request.requestId,
directoryPath: requestError.directoryPath,
project: null,
error: requestError.message,
errorCode: requestError.code,
},
});
}
}
private async handleWorkspaceGithubSearchRepositoriesRequest(
request: Extract<
SessionInboundMessage,
{ type: "workspace.github.search_repositories.request" }
>,
): Promise<void> {
try {
const repositories = await this.github.searchRepositories({
cwd: homedir(),
query: request.query,
limit: request.limit,
});
this.emit({
type: "workspace.github.search_repositories.response",
payload: {
requestId: request.requestId,
repositories,
status: "success",
available: true,
error: null,
},
});
} catch (error) {
const missing = error instanceof GitHubCliMissingError;
const unauthenticated = error instanceof GitHubAuthenticationError;
const commandError = error instanceof GitHubCommandError ? error.stderr.trim() : "";
let message: string;
if (missing) {
message = "GitHub CLI (gh) is not installed or not in PATH";
} else if (unauthenticated) {
message = "GitHub CLI is not authenticated. Run gh auth login on the host.";
} else if (commandError) {
message = commandError;
} else {
message = error instanceof Error ? error.message : "GitHub search failed";
}
let payload: WorkspaceGithubSearchRepositoriesResponsePayload;
if (missing) {
payload = {
status: "unavailable",
requestId: request.requestId,
repositories: [],
reason: "gh_missing",
available: false,
error: message,
};
} else if (unauthenticated) {
payload = {
status: "unauthenticated",
requestId: request.requestId,
repositories: [],
available: false,
error: message,
};
} else {
payload = {
status: "error",
requestId: request.requestId,
repositories: [],
available: true,
error: message,
};
}
this.sessionLogger.warn({ err: error }, "GitHub repository search failed");
this.emit({
type: "workspace.github.search_repositories.response",
payload,
});
}
}
private async handleWorkspaceGithubCloneRequest(
request: Extract<SessionInboundMessage, { type: "workspace.github.clone.request" }>,
): Promise<void> {
@@ -1268,6 +1268,10 @@ export class VoiceAssistantWebSocketServer {
workspacePinning: true,
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
workspaceGithubClone: true,
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
workspaceGithubRepositorySearch: true,
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: true,
},
};
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
createGitHubService,
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
} from "./github-service.js";
interface RunnerCall {
args: string[];
options: GitHubCommandRunnerOptions;
}
function createRunner(outputs: string[]): { calls: RunnerCall[]; runner: GitHubCommandRunner } {
const calls: RunnerCall[] = [];
return {
calls,
runner: async (args, options) => {
calls.push({ args, options });
return { stdout: outputs.shift() ?? "", stderr: "" };
},
};
}
describe("GitHub repository search", () => {
it("lists recent owned repositories for an empty query and normalizes clone identity", async () => {
const runner = createRunner([
JSON.stringify([
{
id: " R_recent ",
name: " paseo ",
nameWithOwner: " getpaseo/paseo ",
description: null,
visibility: "PUBLIC",
updatedAt: "2026-07-15T12:00:00Z",
sshUrl: " git@github.com:getpaseo/paseo.git ",
url: "https://github.com/getpaseo/paseo",
},
]),
"ssh\n",
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
await expect(
service.searchRepositories({ cwd: "/tmp", query: " ", limit: 8 }),
).resolves.toEqual([
{
id: "R_recent",
name: "paseo",
nameWithOwner: "getpaseo/paseo",
description: null,
visibility: "public",
updatedAt: "2026-07-15T12:00:00Z",
cloneUrl: "git@github.com:getpaseo/paseo.git",
},
]);
expect(runner.calls).toEqual([
{
args: [
"repo",
"list",
"--json",
"id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url",
"--limit",
"8",
],
options: { cwd: "/tmp" },
},
{
args: ["config", "get", "git_protocol", "--host", "github.com"],
options: { cwd: "/tmp" },
},
]);
});
it("searches accessible repositories for a typed query", async () => {
const runner = createRunner([
JSON.stringify([
{
id: 42,
name: "private-repo",
fullName: "octo/private-repo",
description: "Private project",
visibility: "PRIVATE",
updatedAt: "2026-07-14T08:00:00Z",
url: "https://github.com/octo/private-repo",
},
]),
"https",
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
await expect(
service.searchRepositories({ cwd: "/tmp", query: " private project ", limit: 5 }),
).resolves.toEqual([
{
id: "42",
name: "private-repo",
nameWithOwner: "octo/private-repo",
description: "Private project",
visibility: "private",
updatedAt: "2026-07-14T08:00:00Z",
cloneUrl: "https://github.com/octo/private-repo",
},
]);
expect(runner.calls).toEqual([
{
args: [
"search",
"repos",
"private project",
"--json",
"id,name,fullName,description,visibility,updatedAt,url",
"--sort",
"updated",
"--order",
"desc",
"--limit",
"5",
],
options: { cwd: "/tmp" },
},
{
args: ["config", "get", "git_protocol", "--host", "github.com"],
options: { cwd: "/tmp" },
},
]);
});
});
@@ -45,6 +45,27 @@ const GitHubPullRequestSummarySchema = z.object({
updatedAt: z.string().catch(""),
});
const GitHubRepositoryListItemSchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
nameWithOwner: z.string(),
description: z.string().nullable().optional(),
visibility: z.string(),
updatedAt: z.string(),
sshUrl: z.string(),
url: z.string(),
});
const GitHubRepositorySearchItemSchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
fullName: z.string(),
description: z.string().nullable().optional(),
visibility: z.string(),
updatedAt: z.string(),
url: z.string(),
});
const PullRequestCheckRunNodeSchema = z.object({
__typename: z.literal("CheckRun"),
databaseId: z.number().nullable().optional(),
@@ -783,6 +804,22 @@ export interface GitHubSearchResult {
githubFeaturesEnabled: boolean;
}
export interface GitHubRepositorySummary {
id: string;
name: string;
nameWithOwner: string;
description: string | null;
visibility: "public" | "private" | "internal";
updatedAt: string;
cloneUrl: string;
}
export interface SearchGitHubRepositoriesOptions {
cwd: string;
query: string;
limit?: number;
}
export type SearchGitHubIssuesAndPrsOptions = {
cwd: string;
query: string;
@@ -819,6 +856,7 @@ export interface GitHubService {
): Promise<GitHubPullRequestTimeline>;
getGitHubCheckDetails(options: GetGitHubCheckDetailsOptions): Promise<GitHubCheckDetails>;
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
searchRepositories(options: SearchGitHubRepositoriesOptions): Promise<GitHubRepositorySummary[]>;
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
@@ -1368,6 +1406,49 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
});
},
async searchRepositories(input) {
const limit = input.limit ?? 20;
const query = input.query.trim();
if (query.length === 0) {
const [stdout, cloneProtocol] = await Promise.all([
run(
[
"repo",
"list",
"--json",
"id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url",
"--limit",
String(limit),
],
{ cwd: input.cwd },
),
resolveConfiguredCloneProtocol(input.cwd, run),
]);
return parseRepositoryList(stdout, cloneProtocol);
}
const [stdout, cloneProtocol] = await Promise.all([
run(
[
"search",
"repos",
query,
"--json",
"id,name,fullName,description,visibility,updatedAt,url",
"--sort",
"updated",
"--order",
"desc",
"--limit",
String(limit),
],
{ cwd: input.cwd },
),
resolveConfiguredCloneProtocol(input.cwd, run),
]);
return parseRepositorySearch(stdout, cloneProtocol);
},
async searchIssuesAndPrs(input) {
if (input.force && !input.reason) {
throw new Error("GitHubService forced read requires a reason");
@@ -2169,6 +2250,90 @@ function parsePullRequestSummaries(stdout: string): GitHubPullRequestSummary[] {
return parsed.map(toPullRequestSummary);
}
type GitHubCloneProtocol = "https" | "ssh";
async function resolveConfiguredCloneProtocol(
cwd: string,
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>,
): Promise<GitHubCloneProtocol> {
try {
const protocol = (
await run(["config", "get", "git_protocol", "--host", "github.com"], {
cwd,
})
)
.trim()
.toLowerCase();
return protocol === "ssh" ? "ssh" : "https";
} catch (error) {
if (error instanceof GitHubCommandError) {
return "https";
}
throw error;
}
}
function parseRepositoryList(
stdout: string,
cloneProtocol: GitHubCloneProtocol,
): GitHubRepositorySummary[] {
const parsed = z.array(GitHubRepositoryListItemSchema).parse(JSON.parse(stdout || "[]"));
return parsed.map((repository) =>
normalizeRepositorySummary({
...repository,
nameWithOwner: repository.nameWithOwner,
cloneUrl: cloneProtocol === "ssh" ? repository.sshUrl : repository.url,
}),
);
}
function parseRepositorySearch(
stdout: string,
cloneProtocol: GitHubCloneProtocol,
): GitHubRepositorySummary[] {
const parsed = z.array(GitHubRepositorySearchItemSchema).parse(JSON.parse(stdout || "[]"));
return parsed.map((repository) =>
normalizeRepositorySummary({
...repository,
nameWithOwner: repository.fullName,
cloneUrl:
cloneProtocol === "ssh" ? `git@github.com:${repository.fullName}.git` : repository.url,
}),
);
}
function normalizeRepositorySummary(repository: {
id: string | number;
name: string;
nameWithOwner: string;
description?: string | null;
visibility: string;
updatedAt: string;
cloneUrl: string;
}): GitHubRepositorySummary {
const nameWithOwner = repository.nameWithOwner.trim();
if (!nameWithOwner.includes("/")) {
throw new Error(`GitHub repository is missing owner identity: ${nameWithOwner}`);
}
return {
id: String(repository.id).trim(),
name: repository.name.trim(),
nameWithOwner,
description: repository.description ?? null,
visibility: normalizeRepositoryVisibility(repository.visibility),
updatedAt: repository.updatedAt,
cloneUrl: repository.cloneUrl.trim(),
};
}
function normalizeRepositoryVisibility(visibility: string): GitHubRepositorySummary["visibility"] {
const normalized = visibility.toLowerCase();
if (normalized === "public" || normalized === "private" || normalized === "internal") {
return normalized;
}
throw new Error(`Unknown GitHub repository visibility: ${visibility}`);
}
function parsePullRequestSummary(stdout: string): GitHubPullRequestSummary {
return toPullRequestSummary(GitHubPullRequestSummarySchema.parse(JSON.parse(stdout || "{}")));
}