Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 94 additions & 45 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { cn } from "../../lib/cn";
import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
import { filesystemEnvironment } from "../../state/filesystem";
import { projectEnvironment } from "../../state/projects";
import { useDebouncedValue } from "../../state/queries";
import { useEnvironmentQuery } from "../../state/query";
import { sourceControlEnvironment } from "../../state/sourceControl";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
Expand Down Expand Up @@ -720,50 +721,65 @@ 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 githubSearchQuery = useDebouncedValue(
source === "github" ? repositoryInput.trim() : "",
500,
);
const githubSearch = useEnvironmentQuery(
environment && githubSearchQuery.length >= 3
? sourceControlEnvironment.searchRepositories({
environmentId: environment.environmentId,
source,
remoteUrl,
repositoryTitle: remoteUrl,
repositoryName: getCloneDirectoryName(remoteUrl),
}),
);
setIsSubmitting(false);
return;
}
input: { provider: "github", query: githubSearchQuery },
})
: null,
);

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 lookupRepository = useCallback(
async (repositoryName: string) => {
if (!environment || repositoryName.length === 0 || isSubmitting) return;
setError(null);
setIsSubmitting(true);
const provider = addProjectRemoteSourceProvider(source);
if (!provider) {
const remoteUrl = normalizePastedCloneUrl(repositoryName);
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: repositoryName,
},
});
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, navigation, source],
);

return (
<AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}>
Expand All @@ -779,17 +795,50 @@ export function AddProjectRepositoryScreen(props: {
placeholder={
source === "url"
? "https://github.com/org/repo.git"
: addProjectRemoteSourcePathHint(source)
: source === "github"
? "Search or enter owner/repo"
: addProjectRemoteSourcePathHint(source)
}
returnKeyType="next"
onSubmitEditing={() => void lookupRepository()}
onSubmitEditing={() => void lookupRepository(repositoryInput.trim())}
/>
<PrimaryActionButton
label={source === "url" ? "Continue" : "Lookup repository"}
disabled={isSubmitting || repositoryInput.trim().length === 0}
onPress={() => void lookupRepository()}
onPress={() => void lookupRepository(repositoryInput.trim())}
loading={isSubmitting}
/>
{source === "github" && githubSearchQuery.length >= 3 ? (
<>
<SectionTitle>Repositories</SectionTitle>
{githubSearch.error ? <ErrorBanner message={githubSearch.error} /> : null}
<ListSection>
{githubSearch.isPending && githubSearch.data === null ? (
<View className="items-center py-5">
<ActivityIndicator colorClassName="accent-icon-muted" />
</View>
) : null}
{(githubSearch.data ?? []).map((repository, index) => (
<ListRow
key={repository.nameWithOwner}
title={repository.nameWithOwner}
subtitle={repository.description}
icon={
<SourceControlIcon
kind="github"
size={Platform.OS === "android" ? 24 : 18}
colorClassName="accent-icon-muted"
/>
}
isFirst={index === 0}
right={null}
disabled={isSubmitting}
onPress={() => void lookupRepository(repository.nameWithOwner)}
/>
))}
</ListSection>
</>
) : null}
</>
) : (
<EmptyEnvironmentState />
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 @@ -104,6 +104,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlSearchRepositories]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
cwd: input.cwd,
args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
}).pipe(Effect.map((result) => JSON.parse(result.stdout))),
searchRepositories: () => Effect.succeed([]),
createRepository: (input) =>
Effect.fail(
new GitHubCli.GitHubCliCommandError({
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,32 @@ describe("GitHubCli.layer", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("lists the account's own repository matches before global matches", () =>
Effect.gen(function* () {
const repository = (fullName: string) => ({ fullName, description: null });
mockRun.mockImplementation((input) =>
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify(
input.args.at(-1) === "sleepy user:@me"
? [repository("octocat/sleepytime")]
: [repository("sleepy-project/sleepy"), repository("octocat/sleepytime")],
),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.searchRepositories({ cwd: "/repo", query: "sleepy" });

assert.deepStrictEqual(result, [
{ nameWithOwner: "octocat/sleepytime", description: null },
{ nameWithOwner: "sleepy-project/sleepy", description: null },
]);
}).pipe(Effect.provide(layer)),
);

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

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

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

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

Expand Down Expand Up @@ -278,6 +292,11 @@ export interface GitHubRepositoryCloneUrls {
readonly sshUrl: string;
}

export interface GitHubRepositorySearchResult {
readonly nameWithOwner: string;
readonly description: string | null;
}

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

/** Searches the signed-in account's repositories first, then all of GitHub. */
readonly searchRepositories: (input: {
readonly cwd: string;
readonly query: string;
}) => Effect.Effect<ReadonlyArray<GitHubRepositorySearchResult>, GitHubCliError>;

readonly createRepository: (input: {
readonly cwd: string;
readonly repository: string;
Expand Down Expand Up @@ -357,6 +382,33 @@ function normalizeRepositoryCloneUrls(
};
}

const RawGitHubRepositorySearchSchema = Schema.Array(
Schema.Struct({
fullName: TrimmedNonEmptyString,
description: Schema.NullishOr(Schema.String),
}),
);
const decodeRawGitHubRepositorySearch = Schema.decodeEffect(
Schema.fromJsonString(RawGitHubRepositorySearchSchema),
);

/**
* Merges search result lists in order, keeping the first occurrence of each repository.
*
* @example mergeRepositorySearchResults([[a], [a, b]]); // [a, b]
*/
function mergeRepositorySearchResults(
lists: ReadonlyArray<ReadonlyArray<GitHubRepositorySearchResult>>,
): ReadonlyArray<GitHubRepositorySearchResult> {
const seen = new Set<string>();
return lists.flat().filter((repository) => {
const key = repository.nameWithOwner.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}

/**
* `gh repo create` prints the canonical URL of the new repository on stdout
* (e.g. `https://github.com/owner/repo`). Reading it back here avoids a
Expand Down Expand Up @@ -612,6 +664,41 @@ export const make = Effect.gen(function* () {
),
Effect.map(normalizeRepositoryCloneUrls),
),
searchRepositories: (input) => {
const search = (query: string, limit: number) =>
execute({
cwd: input.cwd,
args: [
Comment thread
markomitranic marked this conversation as resolved.
"search",
"repos",
"--limit",
String(limit),
"--json",
"fullName,description",
"--",
query,
],
Comment thread
markomitranic marked this conversation as resolved.
}).pipe(
Effect.flatMap((result) =>
decodeRawGitHubRepositorySearch(result.stdout.trim()).pipe(
Effect.mapError(
(cause) =>
new GitHubRepositorySearchDecodeError({ command: "gh", cwd: input.cwd, cause }),
),
),
),
Effect.map((repositories) =>
repositories.map((repository) => ({
nameWithOwner: repository.fullName,
description: repository.description ?? null,
})),
),
);

return Effect.all([search(`${input.query} user:@me`, 5), search(input.query, 20)], {
concurrency: "unbounded",
}).pipe(Effect.map(mergeRepositorySearchResults));
},
createRepository: (input) =>
execute({
cwd: input.cwd,
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,20 @@ export const make = Effect.gen(function* () {
}),
),
),
searchRepositories: (input) =>
github.searchRepositories(input).pipe(
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "searchRepositories",
command: error.command,
cwd: input.cwd,
detail: error.detail,
cause: error,
}),
),
),
createRepository: (input) =>
github.createRepository(input).pipe(
Effect.mapError(
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/sourceControl/SourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
SourceControlProviderInfo,
SourceControlProviderKind,
SourceControlRepositoryCloneUrls,
SourceControlRepositorySearchResult,
SourceControlRepositoryVisibility,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -126,6 +127,14 @@ export class SourceControlProvider extends Context.Service<
readonly context?: SourceControlProviderContext;
readonly repository: string;
}) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
/** Optional capability for finding repositories to clone by name. */
readonly searchRepositories?: (input: {
readonly cwd: string;
readonly query: string;
}) => Effect.Effect<
ReadonlyArray<SourceControlRepositorySearchResult>,
SourceControlProviderError
>;
readonly createRepository: (input: {
readonly cwd: string;
readonly repository: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ function bindProviderContext(
...input,
context: input.context ?? context,
}),
...(provider.searchRepositories ? { searchRepositories: provider.searchRepositories } : {}),
createRepository: (input) => provider.createRepository(input),
getDefaultBranch: (input) =>
provider.getDefaultBranch({
Expand Down
Loading
Loading