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,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 || "{}")));
}