Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 61 additions & 0 deletions apps/server/src/sourceControl/GitLabCli.test.ts
Original file line number Diff line number Diff line change
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.git",
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