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:
@@ -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 || "{}")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user