Skip to content
137 changes: 94 additions & 43 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
buildProjectCreateCommand,
canCreateProjectInEnvironment,
findExistingAddProject,
filterGitHubRepositorySuggestions,
getAddProjectInitialQuery,
getCloneDestinationBrowsePath,
getCloneDestinationPath,
getCloneDirectoryName,
getDefaultCloneUrl,
isCompleteAddProjectRepositoryInput,
normalizePastedCloneUrl,
parseGitHubRepositorySuggestionInput,
resolveAddProjectPath,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
Expand Down Expand Up @@ -664,50 +667,78 @@ export function AddProjectRepositoryScreen(props: {
const [repositoryInput, setRepositoryInput] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

const lookupRepository = useCallback(async () => {
if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return;
setError(null);
setIsSubmitting(true);
const provider = addProjectRemoteSourceProvider(source);
if (!provider) {
const remoteUrl = normalizePastedCloneUrl(repositoryInput);
navigation.dispatch(
StackActions.push("AddProjectDestination", {
const suggestionInput =
source === "github" ? parseGitHubRepositorySuggestionInput(repositoryInput) : null;
const suggestionOwner = suggestionInput?.owner ?? null;
const repositorySuggestionsQuery = useEnvironmentQuery(
environment && suggestionOwner !== null
? sourceControlEnvironment.repositories({
environmentId: environment.environmentId,
source,
remoteUrl,
repositoryTitle: remoteUrl,
repositoryName: getCloneDirectoryName(remoteUrl),
}),
);
setIsSubmitting(false);
return;
}
input: { provider: "github", owner: suggestionOwner.toLowerCase() },
})
: null,
);
const repositorySuggestions = repositorySuggestionsQuery.data?.repositories ?? [];
const areRepositorySuggestionsTruncated = repositorySuggestionsQuery.data?.isTruncated ?? false;

const result = await lookupRepositoryQuery({
environmentId: environment.environmentId,
input: {
provider,
repository: repositoryInput.trim(),
},
});
if (AsyncResult.isFailure(result)) {
setError(errorMessage(Cause.squash(result.cause)));
} else {
const repository = result.value;
navigation.dispatch(
StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl: getDefaultCloneUrl(repository),
repositoryTitle: repository.nameWithOwner,
repositoryName: getCloneDirectoryName(repository.nameWithOwner),
}),
);
}
setIsSubmitting(false);
}, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
const matchingRepositorySuggestions = filterGitHubRepositorySuggestions(
repositorySuggestions,
repositoryInput,
);

const lookupRepository = useCallback(
async (repositoryOverride?: string) => {
const rawRepository = (repositoryOverride ?? repositoryInput).trim();
if (
!environment ||
isSubmitting ||
!isCompleteAddProjectRepositoryInput(source, rawRepository)
) {
return;
}
setError(null);
setIsSubmitting(true);
const provider = addProjectRemoteSourceProvider(source);
if (!provider) {
const remoteUrl = normalizePastedCloneUrl(rawRepository);
navigation.dispatch(
StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl,
repositoryTitle: remoteUrl,
repositoryName: getCloneDirectoryName(remoteUrl),
}),
);
setIsSubmitting(false);
return;
}

const result = await lookupRepositoryQuery({
environmentId: environment.environmentId,
input: {
provider,
repository: rawRepository,
},
});
if (AsyncResult.isFailure(result)) {
setError(errorMessage(Cause.squash(result.cause)));
} else {
const repository = result.value;
navigation.dispatch(
StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl: getDefaultCloneUrl(repository),
repositoryTitle: repository.nameWithOwner,
repositoryName: getCloneDirectoryName(repository.nameWithOwner),
}),
);
}
setIsSubmitting(false);
},
[environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source],
);

return (
<AddProjectShell>
Expand All @@ -728,9 +759,29 @@ export function AddProjectRepositoryScreen(props: {
returnKeyType="next"
onSubmitEditing={() => void lookupRepository()}
/>
{matchingRepositorySuggestions.length > 0 ? (
<ListSection>
{matchingRepositorySuggestions.map((repository, index) => (
<ListRow
key={repository.nameWithOwner}
isFirst={index === 0}
title={repository.nameWithOwner.split("/").at(-1) ?? repository.nameWithOwner}
subtitle={repository.nameWithOwner}
icon={<SourceControlIcon kind="github" size={18} colorClassName="accent-icon" />}
onPress={() => void lookupRepository(repository.nameWithOwner)}
/>
))}
</ListSection>
) : null}
{areRepositorySuggestionsTruncated ? (
<Text className="px-1 text-xs leading-normal text-foreground-muted">
Showing the first 100 repositories. Enter the full owner/repository name to look up
another.
</Text>
) : null}
<PrimaryActionButton
label={source === "url" ? "Continue" : "Lookup repository"}
disabled={isSubmitting || repositoryInput.trim().length === 0}
disabled={isSubmitting || !isCompleteAddProjectRepositoryInput(source, repositoryInput)}
onPress={() => void lookupRepository()}
loading={isSubmitting}
/>
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlListRepositories]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
cwd: input.cwd,
args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
}).pipe(Effect.map((result) => JSON.parse(result.stdout))),
listRepositories: (input) =>
Effect.fail(
new GitHubCli.GitHubCliCommandError({
command: "gh",
cwd: input.cwd,
cause: new Error(`Unexpected repository list: ${input.owner}`),
}),
),
createRepository: (input) =>
Effect.fail(
new GitHubCli.GitHubCliCommandError({
Expand Down
65 changes: 65 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,71 @@ describe("GitHubCli.layer", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("lists repositories owned by a GitHub account", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
nameWithOwner: "octocat/hello-world",
url: "https://github.com/octocat/hello-world",
sshUrl: "git@github.com:octocat/hello-world.git",
},
]),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.listRepositories({ cwd: "/repo", owner: "octocat" });

assert.deepStrictEqual(result, {
repositories: [
{
nameWithOwner: "octocat/hello-world",
url: "https://github.com/octocat/hello-world",
sshUrl: "git@github.com:octocat/hello-world.git",
},
],
isTruncated: false,
});
expect(mockRun).toHaveBeenCalledWith({
operation: "GitHubCli.execute",
command: "gh",
args: ["repo", "list", "octocat", "--limit", "101", "--json", "nameWithOwner,url,sshUrl"],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

it.effect("reports when repository suggestions are truncated", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify(
Array.from({ length: 101 }, (_, index) => ({
nameWithOwner: `octocat/repository-${index}`,
url: `https://github.com/octocat/repository-${index}`,
sshUrl: `git@github.com:octocat/repository-${index}.git`,
})),
),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.listRepositories({ cwd: "/repo", owner: "octocat" });

expect(result.repositories).toHaveLength(100);
expect(result.isTruncated).toBe(true);
}).pipe(Effect.provide(layer)),
);

it.effect("creates repositories and parses clone URLs from create output", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi
}
}

export class GitHubRepositoryListDecodeError extends Schema.TaggedError<GitHubRepositoryListDecodeError>()(
"GitHubRepositoryListDecodeError",
gitHubCliDecodeFields,
) {
get detail(): string {
return "GitHub CLI returned invalid repository list JSON.";
}

override get message(): string {
return `GitHub CLI failed in listRepositories: ${this.detail}`;
}
}

export const GitHubCliError = Schema.Union([
GitHubCliUnavailableError,
GitHubCliAuthenticationError,
Expand All @@ -161,6 +174,7 @@ export const GitHubCliError = Schema.Union([
GitHubChangeRequestListDecodeError,
GitHubPullRequestDecodeError,
GitHubRepositoryDecodeError,
GitHubRepositoryListDecodeError,
]);
export type GitHubCliError = typeof GitHubCliError.Type;

Expand Down Expand Up @@ -256,6 +270,17 @@ export class GitHubCli extends Context.Service<
readonly repository: string;
}) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;

readonly listRepositories: (input: {
readonly cwd: string;
readonly owner: string;
}) => Effect.Effect<
{
readonly repositories: ReadonlyArray<GitHubRepositoryCloneUrls>;
readonly isTruncated: boolean;
},
GitHubCliError
>;

readonly createRepository: (input: {
readonly cwd: string;
readonly repository: string;
Expand Down Expand Up @@ -287,9 +312,14 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
url: TrimmedNonEmptyString,
sshUrl: TrimmedNonEmptyString,
});
const RawGitHubRepositoryListSchema = Schema.Array(RawGitHubRepositoryCloneUrlsSchema);
const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect(
Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema),
);
const decodeRawGitHubRepositoryList = Schema.decodeEffect(
Schema.fromJsonString(RawGitHubRepositoryListSchema),
);
const REPOSITORY_SUGGESTION_LIMIT = 100;

function normalizeRepositoryCloneUrls(
raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
Expand Down Expand Up @@ -443,6 +473,39 @@ export const make = Effect.gen(function* () {
),
Effect.map(normalizeRepositoryCloneUrls),
),
listRepositories: (input) =>
execute({
cwd: input.cwd,
args: [
"repo",
"list",
input.owner,
"--limit",
String(REPOSITORY_SUGGESTION_LIMIT + 1),
"--json",
"nameWithOwner,url,sshUrl",
],
}).pipe(
Effect.map((result) => result.stdout.trim()),
Effect.flatMap((raw) =>
decodeRawGitHubRepositoryList(raw).pipe(
Effect.mapError(
(cause) =>
new GitHubRepositoryListDecodeError({
command: "gh",
cwd: input.cwd,
cause,
}),
),
),
),
Effect.map((repositories) => ({
repositories: repositories
.slice(0, REPOSITORY_SUGGESTION_LIMIT)
.map(normalizeRepositoryCloneUrls),
isTruncated: repositories.length > REPOSITORY_SUGGESTION_LIMIT,
})),
),
createRepository: (input) =>
execute({
cwd: input.cwd,
Expand Down
Loading
Loading