Skip to content
Merged
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
7 changes: 7 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,13 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
cwd: input.cwd,
args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
}).pipe(Effect.map((result) => JSON.parse(result.stdout))),
createRepository: (input) =>
Effect.fail(
new GitHubCliError({
operation: "createRepository",
detail: `Unexpected repository create: ${input.repository}`,
}),
),
checkoutPullRequest: (input) =>
execute({
cwd: input.cwd,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ import {
} from "./vcs/VcsDriverRegistry.ts";
import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts";
import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts";
import {
SourceControlRepositoryService,
type SourceControlRepositoryServiceShape,
} from "./sourceControl/SourceControlRepositoryService.ts";
import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";

Expand Down Expand Up @@ -330,6 +334,7 @@ const buildAppUnderTest = (options?: {
vcsDriverRegistry?: Partial<VcsDriverRegistryShape>;
gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>;
gitManager?: Partial<GitManagerShape>;
sourceControlRepositoryService?: Partial<SourceControlRepositoryServiceShape>;
vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>;
projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>;
terminalManager?: Partial<TerminalManagerShape>;
Expand Down Expand Up @@ -545,6 +550,11 @@ const buildAppUnderTest = (options?: {
Layer.provide(gitVcsDriverLayer),
Layer.provide(gitWorkflowLayer),
Layer.provide(vcsProvisioningLayer),
Layer.provide(
Layer.mock(SourceControlRepositoryService)({
...options?.layers?.sourceControlRepositoryService,
}),
),
Layer.provideMerge(vcsStatusBroadcasterLayer),
Layer.provide(
Layer.mock(ProjectSetupScriptRunner)({
Expand Down
19 changes: 13 additions & 6 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
import * as GitWorkflowService from "./git/GitWorkflowService.ts";
import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
import { ObservabilityLive } from "./observability/Layers/Observability.ts";
import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
Expand Down Expand Up @@ -161,15 +162,15 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(
Layer.provide(VcsProjectConfig.layer),
);

const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(
Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
Layer.provideMerge(VcsDriverRegistryLayerLive),
);

const GitManagerLayerLive = GitManager.layer.pipe(
Layer.provideMerge(ProjectSetupScriptRunnerLive),
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(
SourceControlProviderRegistry.layer.pipe(
Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
Layer.provideMerge(VcsDriverRegistryLayerLive),
),
),
Layer.provideMerge(SourceControlProviderRegistryLayerLive),
Layer.provideMerge(TextGeneration.layer),
);

Expand All @@ -183,11 +184,17 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe(
Layer.provideMerge(GitLayerLive),
);

const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe(
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(SourceControlProviderRegistryLayerLive),
);

const VcsLayerLive = Layer.empty.pipe(
Layer.provideMerge(VcsProjectConfig.layer),
Layer.provideMerge(VcsDriverRegistryLayerLive),
Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
Layer.provideMerge(GitWorkflowLayerLive),
Layer.provideMerge(SourceControlRepositoryServiceLayerLive),
Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))),
);

Expand Down
52 changes: 52 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("creates repositories and parses clone URLs from create output", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
"✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n",
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.createRepository({
cwd: "/repo",
repository: "octocat/codething-mvp",
visibility: "private",
});

assert.deepStrictEqual(result, {
nameWithOwner: "octocat/codething-mvp",
url: "https://github.com/octocat/codething-mvp",
sshUrl: "git@github.com:octocat/codething-mvp.git",
});
expect(mockRun).toHaveBeenCalledTimes(1);
expect(mockRun).toHaveBeenNthCalledWith(1, {
operation: "GitHubCli.execute",
command: "gh",
args: ["repo", "create", "octocat/codething-mvp", "--private"],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

it.effect("falls back to constructed URLs when create output omits a URL", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.createRepository({
cwd: "/repo",
repository: "octocat/codething-mvp",
visibility: "private",
});

assert.deepStrictEqual(result, {
nameWithOwner: "octocat/codething-mvp",
url: "https://github.com/octocat/codething-mvp",
sshUrl: "git@github.com:octocat/codething-mvp.git",
});
}).pipe(Effect.provide(layer)),
);

it.effect("surfaces a friendly error when the pull request is not found", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Expand Down
58 changes: 57 additions & 1 deletion apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";

import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
import {
TrimmedNonEmptyString,
type SourceControlRepositoryVisibility,
type VcsError,
} from "@t3tools/contracts";

import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
import {
Expand Down Expand Up @@ -62,6 +66,12 @@ export interface GitHubCliShape {
readonly repository: string;
}) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;

readonly createRepository: (input: {
readonly cwd: string;
readonly repository: string;
readonly visibility: SourceControlRepositoryVisibility;
}) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;

readonly createPullRequest: (input: {
readonly cwd: string;
readonly baseBranch: string;
Expand Down Expand Up @@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls(
};
}

/**
* `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
* follow-up `gh repo view`, which can race GitHub's GraphQL eventual
* consistency window and falsely report the just-created repo as missing.
*/
function deriveRepositoryCloneUrlsFromCreateOutput(
stdout: string,
repository: string,
): GitHubRepositoryCloneUrls {
const fallbackHost = "github.com";
const match = stdout.match(/https?:\/\/[^\s]+/);
if (match) {
const cleaned = match[0].replace(/\.git$/, "");
try {
const parsed = new URL(cleaned);
const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 2) {
const nameWithOwner = `${segments[0]}/${segments[1]}`;
return {
nameWithOwner,
url: `${parsed.origin}/${nameWithOwner}`,
sshUrl: `git@${parsed.host}:${nameWithOwner}.git`,
};
}
} catch {
// Fall through to the input-derived defaults below.
}
}
return {
nameWithOwner: repository,
url: `https://${fallbackHost}/${repository}`,
sshUrl: `git@${fallbackHost}:${repository}.git`,
};
}

function decodeGitHubJson<S extends Schema.Top>(
raw: string,
schema: S,
Expand Down Expand Up @@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
),
Effect.map(normalizeRepositoryCloneUrls),
),
createRepository: (input) =>
execute({
cwd: input.cwd,
args: ["repo", "create", input.repository, `--${input.visibility}`],
}).pipe(
Effect.map((result) =>
deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository),
),
),
createPullRequest: (input) =>
execute({
cwd: input.cwd,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
github
.getRepositoryCloneUrls(input)
.pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
createRepository: (input) =>
github
.createRepository(input)
.pipe(Effect.mapError((error) => providerError("createRepository", error))),
getDefaultBranch: (input) =>
github
.getDefaultBranch(input)
Expand Down
63 changes: 62 additions & 1 deletion apps/server/src/sourceControl/GitLabCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => {

assert.deepStrictEqual(result, {
nameWithOwner: "octocat/t3code",
url: "https://gitlab.com/octocat/t3code.git",
url: "https://gitlab.com/octocat/t3code",
sshUrl: "git@gitlab.com:octocat/t3code.git",
});
}),
Expand Down Expand Up @@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => {
}),
);

it.effect("creates repositories under an explicit namespace", () =>
Effect.gen(function* () {
mockedRun
.mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 }))))
.mockReturnValueOnce(
Effect.succeed(
processOutput(
JSON.stringify({
path_with_namespace: "octocat/t3code",
web_url: "https://gitlab.com/octocat/t3code",
http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
}),
),
),
);

const glab = yield* GitLabCli.GitLabCli;
const result = yield* glab.createRepository({
cwd: "/repo",
repository: "octocat/t3code",
visibility: "public",
});

assert.deepStrictEqual(result, {
nameWithOwner: "octocat/t3code",
url: "https://gitlab.com/octocat/t3code",
sshUrl: "git@gitlab.com:octocat/t3code.git",
});
Comment thread
cursor[bot] marked this conversation as resolved.
expect(mockedRun).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
command: "glab",
cwd: "/repo",
args: ["api", "namespaces/octocat"],
}),
);
expect(mockedRun).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: "glab",
cwd: "/repo",
args: [
"api",
"--method",
"POST",
"projects",
"--raw-field",
"path=t3code",
"--raw-field",
"name=t3code",
"--raw-field",
"visibility=public",
"--raw-field",
"namespace_id=1234",
],
}),
);
}),
);

it.effect("does not pass unsupported force flags when checking out merge requests", () =>
Effect.gen(function* () {
mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
Expand Down
Loading
Loading