authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-02 18:59:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-02 18:59:47-07:00
log0ce7e56e436ba31e2f0b51f3203a09758c866cd1
tree2cb8383e094bca15387668c4e2e288e70d7e570d
parent350d76ee141004e202d6c365d29ffdb29bde66c6
signature Signed by PGP key B5690EEEBB952194

feat(scm): Gitlab (#2462)

Co-authored-by: Julius Marminge <julius@macmini.local>

24 files changed, 1606 insertions(+), 185 deletions(-)

apps/server/src/git/GitManager.test.ts+144
......@@ -881,6 +881,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
881881 hasUpstream: false,
882882 aheadCount: 0,
883883 behindCount: 0,
884 aheadOfDefaultCount: 0,
884885 pr: null,
885886 });
886887 }),
......@@ -910,6 +911,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
910911 hasUpstream: false,
911912 aheadCount: 0,
912913 behindCount: 0,
914 aheadOfDefaultCount: 0,
913915 pr: null,
914916 });
915917 }),
......@@ -1672,6 +1674,41 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
16721674 }),
16731675 );
16741676
1677 it.effect("pushes existing commits without committing dirty worktree changes", () =>
1678 Effect.gen(function* () {
1679 const repoDir = yield* makeTempDir("t3code-git-manager-");
1680 yield* initRepo(repoDir);
1681 yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]);
1682 const remoteDir = yield* createBareRemote();
1683 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1684 fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n");
1685 yield* runGit(repoDir, ["add", "push-dirty.txt"]);
1686 yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]);
1687 fs.mkdirSync(path.join(repoDir, ".vercel"));
1688 fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n");
1689
1690 const { manager } = yield* makeManager();
1691 const result = yield* runStackedAction(manager, {
1692 cwd: repoDir,
1693 action: "push",
1694 });
1695
1696 expect(result.commit.status).toBe("skipped_not_requested");
1697 expect(result.push.status).toBe("pushed");
1698 expect(result.pr.status).toBe("skipped_not_requested");
1699 expect(
1700 yield* runGit(repoDir, ["status", "--porcelain"]).pipe(
1701 Effect.map((output) => output.stdout.trim()),
1702 ),
1703 ).toContain("?? .vercel/");
1704 expect(
1705 yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe(
1706 Effect.map((output) => output.stdout.trim()),
1707 ),
1708 ).toBe("Push dirty branch");
1709 }),
1710 );
1711
16751712 it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>
16761713 Effect.gen(function* () {
16771714 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -2418,6 +2455,113 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
24182455 }),
24192456 );
24202457
2458 it.effect(
2459 "restores same-repository upstream tracking after local PR checkout without a remote ref",
2460 () =>
2461 Effect.gen(function* () {
2462 const repoDir = yield* makeTempDir("t3code-git-manager-");
2463 yield* initRepo(repoDir);
2464 const remoteDir = yield* createBareRemote();
2465 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2466 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2467 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]);
2468 fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n");
2469 yield* runGit(repoDir, ["add", "upstream.txt"]);
2470 yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]);
2471 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]);
2472 yield* runGit(repoDir, ["checkout", "main"]);
2473 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]);
2474 yield* runGit(repoDir, [
2475 "update-ref",
2476 "-d",
2477 "refs/remotes/origin/feature/pr-local-upstream",
2478 ]);
2479
2480 const { manager } = yield* makeManager({
2481 ghScenario: {
2482 pullRequest: {
2483 number: 65,
2484 title: "Local upstream PR",
2485 url: "https://github.com/pingdotgg/codething-mvp/pull/65",
2486 baseRefName: "main",
2487 headRefName: "feature/pr-local-upstream",
2488 state: "open",
2489 isCrossRepository: false,
2490 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
2491 headRepositoryOwnerLogin: "pingdotgg",
2492 },
2493 repositoryCloneUrls: {
2494 "pingdotgg/codething-mvp": {
2495 url: remoteDir,
2496 sshUrl: remoteDir,
2497 },
2498 },
2499 },
2500 });
2501
2502 const result = yield* preparePullRequestThread(manager, {
2503 cwd: repoDir,
2504 reference: "65",
2505 mode: "local",
2506 });
2507
2508 expect(result.worktreePath).toBeNull();
2509 expect(result.branch).toBe("feature/pr-local-upstream");
2510 expect(
2511 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2512 ).toBe("origin/feature/pr-local-upstream");
2513 }),
2514 );
2515
2516 it.effect(
2517 "restores same-repository upstream tracking when provider omits head repository metadata",
2518 () =>
2519 Effect.gen(function* () {
2520 const repoDir = yield* makeTempDir("t3code-git-manager-");
2521 yield* initRepo(repoDir);
2522 const remoteDir = yield* createBareRemote();
2523 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2524 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2525 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]);
2526 fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n");
2527 yield* runGit(repoDir, ["add", "no-head-repo.txt"]);
2528 yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]);
2529 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]);
2530 yield* runGit(repoDir, ["checkout", "main"]);
2531 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]);
2532 yield* runGit(repoDir, [
2533 "update-ref",
2534 "-d",
2535 "refs/remotes/origin/feature/pr-local-no-head-repo",
2536 ]);
2537
2538 const { manager } = yield* makeManager({
2539 ghScenario: {
2540 pullRequest: {
2541 number: 66,
2542 title: "Local upstream PR without repo metadata",
2543 url: "https://github.com/pingdotgg/codething-mvp/pull/66",
2544 baseRefName: "main",
2545 headRefName: "feature/pr-local-no-head-repo",
2546 state: "open",
2547 },
2548 },
2549 });
2550
2551 const result = yield* preparePullRequestThread(manager, {
2552 cwd: repoDir,
2553 reference: "66",
2554 mode: "local",
2555 });
2556
2557 expect(result.worktreePath).toBeNull();
2558 expect(result.branch).toBe("feature/pr-local-no-head-repo");
2559 expect(
2560 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2561 ).toBe("origin/feature/pr-local-no-head-repo");
2562 }),
2563 );
2564
24212565 it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>
24222566 Effect.gen(function* () {
24232567 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+23-6
......@@ -568,6 +568,22 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
568568 localBranch = pullRequest.headBranch,
569569 ) {
570570 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
571 if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) {
572 const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd);
573 yield* gitCore.fetchRemoteTrackingBranch({
574 cwd,
575 remoteName,
576 remoteBranch: pullRequest.headBranch,
577 });
578 yield* gitCore.setBranchUpstream({
579 cwd,
580 branch: localBranch,
581 remoteName,
582 remoteBranch: pullRequest.headBranch,
583 });
584 return;
585 }
586
571587 if (repositoryNameWithOwner.length === 0) {
572588 return;
573589 }
......@@ -588,6 +604,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
588604 url: remoteUrl,
589605 });
590606
607 yield* gitCore.fetchRemoteTrackingBranch({
608 cwd,
609 remoteName,
610 remoteBranch: pullRequest.headBranch,
611 });
591612 yield* gitCore.setBranchUpstream({
592613 cwd,
593614 branch: localBranch,
......@@ -690,6 +711,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
690711 hasUpstream: false,
691712 aheadCount: 0,
692713 behindCount: 0,
714 aheadOfDefaultCount: 0,
693715 } satisfies GitStatusDetails;
694716 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
695717 const details = yield* gitCore
......@@ -748,6 +770,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
748770 hasUpstream: details.hasUpstream,
749771 aheadCount: details.aheadCount,
750772 behindCount: details.behindCount,
773 aheadOfDefaultCount: details.aheadOfDefaultCount,
751774 pr,
752775 } satisfies VcsStatusRemoteResult;
753776 });
......@@ -1593,12 +1616,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
15931616 "Feature-branch checkout is only supported for commit actions.",
15941617 );
15951618 }
1596 if (input.action === "push" && initialStatus.hasWorkingTreeChanges) {
1597 return yield* gitManagerError(
1598 "runStackedAction",
1599 "Commit or stash local changes before pushing.",
1600 );
1601 }
16021619 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
16031620 return yield* gitManagerError(
16041621 "runStackedAction",
apps/server/src/git/GitWorkflowService.test.ts+1
......@@ -65,6 +65,7 @@ describe("GitWorkflowService", () => {
6565 hasUpstream: false,
6666 aheadCount: 0,
6767 behindCount: 0,
68 aheadOfDefaultCount: 0,
6869 pr: null,
6970 });
7071 }).pipe(
apps/server/src/git/GitWorkflowService.ts+1
......@@ -112,6 +112,7 @@ function nonRepositoryStatus(): VcsStatusResult {
112112 hasUpstream: false,
113113 aheadCount: 0,
114114 behindCount: 0,
115 aheadOfDefaultCount: 0,
115116 pr: null,
116117 };
117118}
apps/server/src/server.ts+2-1
......@@ -26,6 +26,7 @@ import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
2626import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
2727import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
2828import * as GitHubCli from "./sourceControl/GitHubCli.ts";
29import * as GitLabCli from "./sourceControl/GitLabCli.ts";
2930import * as TextGeneration from "./textGeneration/TextGeneration.ts";
3031import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
3132import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
......@@ -165,7 +166,7 @@ const GitManagerLayerLive = GitManager.layer.pipe(
165166 Layer.provideMerge(GitVcsDriver.layer),
166167 Layer.provideMerge(
167168 SourceControlProviderRegistry.layer.pipe(
168 Layer.provide(GitHubCli.layer),
169 Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
169170 Layer.provideMerge(VcsDriverRegistryLayerLive),
170171 ),
171172 ),
apps/server/src/sourceControl/GitLabCli.test.ts created+266
......@@ -0,0 +1,266 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import { afterEach, expect, vi } from "vitest";
5
6import { VcsProcessExitError } from "@t3tools/contracts";
7
8import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts";
9import * as GitLabCli from "./GitLabCli.ts";
10
11const mockedRun = vi.fn<VcsProcessShape["run"]>();
12const layer = it.layer(
13 GitLabCli.layer.pipe(
14 Layer.provide(
15 Layer.mock(VcsProcess)({
16 run: mockedRun,
17 }),
18 ),
19 ),
20);
21
22function processOutput(stdout: string): VcsProcessOutput {
23 return {
24 exitCode: ChildProcessSpawner.ExitCode(0),
25 stdout,
26 stderr: "",
27 stdoutTruncated: false,
28 stderrTruncated: false,
29 };
30}
31
32afterEach(() => {
33 mockedRun.mockReset();
34});
35
36layer("GitLabCli.layer", (it) => {
37 it.effect("parses merge request view output", () =>
38 Effect.gen(function* () {
39 mockedRun.mockReturnValueOnce(
40 Effect.succeed(
41 processOutput(
42 JSON.stringify({
43 iid: 42,
44 title: "Add MR thread creation",
45 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
46 target_branch: "main",
47 source_branch: "feature/mr-threads",
48 state: "opened",
49 source_project_id: 101,
50 target_project_id: 100,
51 source_project: {
52 path_with_namespace: "octocat/t3code",
53 },
54 }),
55 ),
56 ),
57 );
58
59 const result = yield* Effect.gen(function* () {
60 const glab = yield* GitLabCli.GitLabCli;
61 return yield* glab.getMergeRequest({
62 cwd: "/repo",
63 reference: "42",
64 });
65 });
66
67 assert.deepStrictEqual(result, {
68 number: 42,
69 title: "Add MR thread creation",
70 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
71 baseRefName: "main",
72 headRefName: "feature/mr-threads",
73 state: "open",
74 isCrossRepository: true,
75 headRepositoryNameWithOwner: "octocat/t3code",
76 headRepositoryOwnerLogin: "octocat",
77 });
78 expect(mockedRun).toHaveBeenCalledWith(
79 expect.objectContaining({
80 command: "glab",
81 cwd: "/repo",
82 args: ["mr", "view", "42", "--output", "json"],
83 }),
84 );
85 }),
86 );
87
88 it.effect("skips invalid entries when parsing MR lists", () =>
89 Effect.gen(function* () {
90 mockedRun.mockReturnValueOnce(
91 Effect.succeed(
92 processOutput(
93 JSON.stringify([
94 {
95 iid: 0,
96 title: "invalid",
97 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0",
98 target_branch: "main",
99 source_branch: "feature/invalid",
100 },
101 {
102 iid: 43,
103 title: " Valid MR ",
104 web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ",
105 target_branch: " main ",
106 source_branch: " feature/mr-list ",
107 state: "merged",
108 },
109 ]),
110 ),
111 ),
112 );
113
114 const result = yield* Effect.gen(function* () {
115 const glab = yield* GitLabCli.GitLabCli;
116 return yield* glab.listMergeRequests({
117 cwd: "/repo",
118 headSelector: "feature/mr-list",
119 state: "all",
120 });
121 });
122
123 assert.deepStrictEqual(result, [
124 {
125 number: 43,
126 title: "Valid MR",
127 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43",
128 baseRefName: "main",
129 headRefName: "feature/mr-list",
130 state: "merged",
131 },
132 ]);
133 expect(mockedRun).toHaveBeenCalledWith(
134 expect.objectContaining({
135 command: "glab",
136 cwd: "/repo",
137 args: [
138 "mr",
139 "list",
140 "--source-branch",
141 "feature/mr-list",
142 "--all",
143 "--per-page",
144 "20",
145 "--output",
146 "json",
147 ],
148 }),
149 );
150 }),
151 );
152
153 it.effect("reads repository clone URLs", () =>
154 Effect.gen(function* () {
155 mockedRun.mockReturnValueOnce(
156 Effect.succeed(
157 processOutput(
158 JSON.stringify({
159 path_with_namespace: "octocat/t3code",
160 web_url: "https://gitlab.com/octocat/t3code",
161 http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
162 ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
163 }),
164 ),
165 ),
166 );
167
168 const result = yield* Effect.gen(function* () {
169 const glab = yield* GitLabCli.GitLabCli;
170 return yield* glab.getRepositoryCloneUrls({
171 cwd: "/repo",
172 repository: "octocat/t3code",
173 });
174 });
175
176 assert.deepStrictEqual(result, {
177 nameWithOwner: "octocat/t3code",
178 url: "https://gitlab.com/octocat/t3code.git",
179 sshUrl: "git@gitlab.com:octocat/t3code.git",
180 });
181 }),
182 );
183
184 it.effect("creates merge requests through the GitLab API without placing the body in argv", () =>
185 Effect.gen(function* () {
186 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
187
188 const glab = yield* GitLabCli.GitLabCli;
189 yield* glab.createMergeRequest({
190 cwd: "/repo",
191 baseBranch: "main",
192 headSelector: "owner:feature/provider",
193 title: "Provider MR",
194 bodyFile: "/tmp/t3-mr-body.md",
195 });
196
197 expect(mockedRun).toHaveBeenCalledWith(
198 expect.objectContaining({
199 command: "glab",
200 cwd: "/repo",
201 args: [
202 "api",
203 "--method",
204 "POST",
205 "projects/:fullpath/merge_requests",
206 "--raw-field",
207 "source_branch=feature/provider",
208 "--raw-field",
209 "target_branch=main",
210 "--raw-field",
211 "title=Provider MR",
212 "--field",
213 "description=@/tmp/t3-mr-body.md",
214 ],
215 }),
216 );
217 }),
218 );
219
220 it.effect("does not pass unsupported force flags when checking out merge requests", () =>
221 Effect.gen(function* () {
222 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
223
224 const glab = yield* GitLabCli.GitLabCli;
225 yield* glab.checkoutMergeRequest({
226 cwd: "/repo",
227 reference: "42",
228 force: true,
229 });
230
231 expect(mockedRun).toHaveBeenCalledWith(
232 expect.objectContaining({
233 command: "glab",
234 cwd: "/repo",
235 args: ["mr", "checkout", "42"],
236 }),
237 );
238 }),
239 );
240
241 it.effect("surfaces a friendly error when the merge request is not found", () =>
242 Effect.gen(function* () {
243 mockedRun.mockReturnValueOnce(
244 Effect.fail(
245 new VcsProcessExitError({
246 operation: "GitLabCli.execute",
247 command: "glab mr view 4888",
248 cwd: "/repo",
249 exitCode: 1,
250 detail: "GET 404 merge request not found",
251 }),
252 ),
253 );
254
255 const error = yield* Effect.gen(function* () {
256 const glab = yield* GitLabCli.GitLabCli;
257 return yield* glab.getMergeRequest({
258 cwd: "/repo",
259 reference: "4888",
260 });
261 }).pipe(Effect.flip);
262
263 assert.equal(error.message.includes("Merge request not found"), true);
264 }),
265 );
266});
apps/server/src/sourceControl/GitLabCli.ts created+368
......@@ -0,0 +1,368 @@
1import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect";
2
3import { TrimmedNonEmptyString } from "@t3tools/contracts";
4
5import {
6 decodeGitLabMergeRequestJson,
7 decodeGitLabMergeRequestListJson,
8 formatGitLabJsonDecodeError,
9} from "./gitLabMergeRequests.ts";
10import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
11import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
12
13const DEFAULT_TIMEOUT_MS = 30_000;
14
15export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", {
16 operation: Schema.String,
17 detail: Schema.String,
18 cause: Schema.optional(Schema.Defect),
19}) {
20 override get message(): string {
21 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
22 }
23}
24
25export interface GitLabMergeRequestSummary {
26 readonly number: number;
27 readonly title: string;
28 readonly url: string;
29 readonly baseRefName: string;
30 readonly headRefName: string;
31 readonly state?: "open" | "closed" | "merged";
32 readonly updatedAt?: Option.Option<DateTime.Utc>;
33 readonly isCrossRepository?: boolean;
34 readonly headRepositoryNameWithOwner?: string | null;
35 readonly headRepositoryOwnerLogin?: string | null;
36}
37
38export interface GitLabRepositoryCloneUrls {
39 readonly nameWithOwner: string;
40 readonly url: string;
41 readonly sshUrl: string;
42}
43
44export interface GitLabCliShape {
45 readonly execute: (input: {
46 readonly cwd: string;
47 readonly args: ReadonlyArray<string>;
48 readonly timeoutMs?: number;
49 }) => Effect.Effect<VcsProcessOutput, GitLabCliError>;
50
51 readonly listMergeRequests: (input: {
52 readonly cwd: string;
53 readonly headSelector: string;
54 readonly source?: SourceControlRefSelector;
55 readonly state: "open" | "closed" | "merged" | "all";
56 readonly limit?: number;
57 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
58
59 readonly getMergeRequest: (input: {
60 readonly cwd: string;
61 readonly reference: string;
62 }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;
63
64 readonly getRepositoryCloneUrls: (input: {
65 readonly cwd: string;
66 readonly repository: string;
67 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
68
69 readonly createMergeRequest: (input: {
70 readonly cwd: string;
71 readonly baseBranch: string;
72 readonly headSelector: string;
73 readonly source?: SourceControlRefSelector;
74 readonly target?: SourceControlRefSelector;
75 readonly title: string;
76 readonly bodyFile: string;
77 }) => Effect.Effect<void, GitLabCliError>;
78
79 readonly getDefaultBranch: (input: {
80 readonly cwd: string;
81 }) => Effect.Effect<string | null, GitLabCliError>;
82
83 readonly checkoutMergeRequest: (input: {
84 readonly cwd: string;
85 readonly reference: string;
86 readonly force?: boolean;
87 }) => Effect.Effect<void, GitLabCliError>;
88}
89
90export class GitLabCli extends Context.Service<GitLabCli, GitLabCliShape>()(
91 "t3/source-control/GitLabCli",
92) {}
93
94function isVcsProcessSpawnError(error: unknown): boolean {
95 return (
96 typeof error === "object" &&
97 error !== null &&
98 "_tag" in error &&
99 error._tag === "VcsProcessSpawnError"
100 );
101}
102
103function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError {
104 if (error instanceof Error) {
105 if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) {
106 return new GitLabCliError({
107 operation,
108 detail: "GitLab CLI (`glab`) is required but not available on PATH.",
109 cause: error,
110 });
111 }
112
113 const lower = error.message.toLowerCase();
114 if (
115 lower.includes("authentication failed") ||
116 lower.includes("not logged in") ||
117 lower.includes("glab auth login") ||
118 lower.includes("token")
119 ) {
120 return new GitLabCliError({
121 operation,
122 detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
123 cause: error,
124 });
125 }
126
127 if (
128 lower.includes("merge request not found") ||
129 lower.includes("not found") ||
130 lower.includes("404")
131 ) {
132 return new GitLabCliError({
133 operation,
134 detail: "Merge request not found. Check the MR number or URL and try again.",
135 cause: error,
136 });
137 }
138
139 return new GitLabCliError({
140 operation,
141 detail: `GitLab CLI command failed: ${error.message}`,
142 cause: error,
143 });
144 }
145
146 return new GitLabCliError({
147 operation,
148 detail: "GitLab CLI command failed.",
149 cause: error,
150 });
151}
152
153const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({
154 path_with_namespace: TrimmedNonEmptyString,
155 web_url: TrimmedNonEmptyString,
156 http_url_to_repo: TrimmedNonEmptyString,
157 ssh_url_to_repo: TrimmedNonEmptyString,
158});
159
160const RawGitLabDefaultBranchSchema = Schema.Struct({
161 default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
162});
163
164function normalizeRepositoryCloneUrls(
165 raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,
166): GitLabRepositoryCloneUrls {
167 return {
168 nameWithOwner: raw.path_with_namespace,
169 url: raw.http_url_to_repo || raw.web_url,
170 sshUrl: raw.ssh_url_to_repo,
171 };
172}
173
174function decodeGitLabJson<S extends Schema.Top>(
175 raw: string,
176 schema: S,
177 operation: "getRepositoryCloneUrls" | "getDefaultBranch",
178 invalidDetail: string,
179): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
180 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
181 Effect.mapError(
182 (error) =>
183 new GitLabCliError({
184 operation,
185 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
186 cause: error,
187 }),
188 ),
189 );
190}
191
192function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
193 switch (state) {
194 case "open":
195 return [];
196 case "closed":
197 return ["--closed"];
198 case "merged":
199 return ["--merged"];
200 case "all":
201 return ["--all"];
202 }
203}
204
205function normalizeHeadSelector(headSelector: string): string {
206 const trimmed = headSelector.trim();
207 const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed);
208 return ownerBranch?.[1]?.trim() || trimmed;
209}
210
211function sourceRefName(input: {
212 readonly headSelector: string;
213 readonly source?: SourceControlRefSelector;
214}): string {
215 return input.source?.refName ?? normalizeHeadSelector(input.headSelector);
216}
217
218function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null {
219 return source?.repository ?? source?.owner ?? null;
220}
221
222function toSummaryWithOptionalUpdatedAt(
223 record: GitLabMergeRequestSummary & {
224 readonly updatedAt: Option.Option<DateTime.Utc>;
225 },
226): GitLabMergeRequestSummary {
227 const { updatedAt, ...summary } = record;
228 return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;
229}
230
231export const make = Effect.fn("makeGitLabCli")(function* () {
232 const process = yield* VcsProcess;
233
234 const execute: GitLabCliShape["execute"] = (input) =>
235 process
236 .run({
237 operation: "GitLabCli.execute",
238 command: "glab",
239 args: input.args,
240 cwd: input.cwd,
241 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
242 })
243 .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error)));
244
245 return GitLabCli.of({
246 execute,
247 listMergeRequests: (input) =>
248 execute({
249 cwd: input.cwd,
250 args: [
251 "mr",
252 "list",
253 "--source-branch",
254 sourceRefName(input),
255 ...stateArgs(input.state),
256 "--per-page",
257 String(input.limit ?? 20),
258 "--output",
259 "json",
260 ],
261 }).pipe(
262 Effect.map((result) => result.stdout.trim()),
263 Effect.flatMap((raw) =>
264 raw.length === 0
265 ? Effect.succeed([])
266 : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe(
267 Effect.flatMap((decoded) => {
268 if (!Result.isSuccess(decoded)) {
269 return Effect.fail(
270 new GitLabCliError({
271 operation: "listMergeRequests",
272 detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`,
273 cause: decoded.failure,
274 }),
275 );
276 }
277
278 return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt));
279 }),
280 ),
281 ),
282 ),
283 getMergeRequest: (input) =>
284 execute({
285 cwd: input.cwd,
286 args: ["mr", "view", input.reference, "--output", "json"],
287 }).pipe(
288 Effect.map((result) => result.stdout.trim()),
289 Effect.flatMap((raw) =>
290 Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe(
291 Effect.flatMap((decoded) => {
292 if (!Result.isSuccess(decoded)) {
293 return Effect.fail(
294 new GitLabCliError({
295 operation: "getMergeRequest",
296 detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`,
297 cause: decoded.failure,
298 }),
299 );
300 }
301
302 return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success));
303 }),
304 ),
305 ),
306 ),
307 getRepositoryCloneUrls: (input) =>
308 execute({
309 cwd: input.cwd,
310 args: ["api", `projects/${encodeURIComponent(input.repository)}`],
311 }).pipe(
312 Effect.map((result) => result.stdout.trim()),
313 Effect.flatMap((raw) =>
314 decodeGitLabJson(
315 raw,
316 RawGitLabRepositoryCloneUrlsSchema,
317 "getRepositoryCloneUrls",
318 "GitLab CLI returned invalid repository JSON.",
319 ),
320 ),
321 Effect.map(normalizeRepositoryCloneUrls),
322 ),
323 createMergeRequest: (input) => {
324 const sourceProject = sourceProjectIdentifier(input.source);
325 return execute({
326 cwd: input.cwd,
327 args: [
328 "api",
329 "--method",
330 "POST",
331 "projects/:fullpath/merge_requests",
332 "--raw-field",
333 `source_branch=${sourceRefName(input)}`,
334 "--raw-field",
335 `target_branch=${input.target?.refName ?? input.baseBranch}`,
336 ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []),
337 "--raw-field",
338 `title=${input.title}`,
339 "--field",
340 `description=@${input.bodyFile}`,
341 ],
342 }).pipe(Effect.asVoid);
343 },
344 getDefaultBranch: (input) =>
345 execute({
346 cwd: input.cwd,
347 args: ["api", "projects/:fullpath"],
348 }).pipe(
349 Effect.map((result) => result.stdout.trim()),
350 Effect.flatMap((raw) =>
351 decodeGitLabJson(
352 raw,
353 RawGitLabDefaultBranchSchema,
354 "getDefaultBranch",
355 "GitLab CLI returned invalid repository JSON.",
356 ),
357 ),
358 Effect.map((value) => value.default_branch ?? null),
359 ),
360 checkoutMergeRequest: (input) =>
361 execute({
362 cwd: input.cwd,
363 args: ["mr", "checkout", input.reference],
364 }).pipe(Effect.asVoid),
365 });
366});
367
368export const layer = Layer.effect(GitLabCli, make());
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts created+105
......@@ -0,0 +1,105 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer, Option } from "effect";
3
4import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts";
5import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
6
7function makeProvider(gitlab: Partial<GitLabCliShape>) {
8 return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab)));
9}
10
11it.effect("maps GitLab MR summaries into provider-neutral change requests", () =>
12 Effect.gen(function* () {
13 const provider = yield* makeProvider({
14 getMergeRequest: () =>
15 Effect.succeed({
16 number: 42,
17 title: "Add GitLab provider",
18 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
19 baseRefName: "main",
20 headRefName: "feature/source-control",
21 state: "open",
22 isCrossRepository: true,
23 headRepositoryNameWithOwner: "fork/t3code",
24 headRepositoryOwnerLogin: "fork",
25 }),
26 });
27
28 const changeRequest = yield* provider.getChangeRequest({
29 cwd: "/repo",
30 reference: "42",
31 });
32
33 assert.deepStrictEqual(changeRequest, {
34 provider: "gitlab",
35 number: 42,
36 title: "Add GitLab provider",
37 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
38 baseRefName: "main",
39 headRefName: "feature/source-control",
40 state: "open",
41 updatedAt: Option.none(),
42 isCrossRepository: true,
43 headRepositoryNameWithOwner: "fork/t3code",
44 headRepositoryOwnerLogin: "fork",
45 });
46 }),
47);
48
49it.effect("lists GitLab MRs through provider-neutral input names", () =>
50 Effect.gen(function* () {
51 let listInput: Parameters<GitLabCliShape["listMergeRequests"]>[0] | null = null;
52 const provider = yield* makeProvider({
53 listMergeRequests: (input) => {
54 listInput = input;
55 return Effect.succeed([]);
56 },
57 });
58
59 yield* provider.listChangeRequests({
60 cwd: "/repo",
61 headSelector: "feature/provider",
62 state: "all",
63 limit: 10,
64 });
65
66 assert.deepStrictEqual(listInput, {
67 cwd: "/repo",
68 headSelector: "feature/provider",
69 state: "all",
70 limit: 10,
71 });
72 }),
73);
74
75it.effect("creates GitLab MRs through provider-neutral input names", () =>
76 Effect.gen(function* () {
77 let createInput: Parameters<GitLabCliShape["createMergeRequest"]>[0] | null = null;
78 const provider = yield* makeProvider({
79 createMergeRequest: (input) => {
80 createInput = input;
81 return Effect.void;
82 },
83 });
84
85 yield* provider.createChangeRequest({
86 cwd: "/repo",
87 baseRefName: "main",
88 headSelector: "owner:feature/provider",
89 title: "Provider MR",
90 bodyFile: "/tmp/body.md",
91 });
92
93 assert.deepStrictEqual(createInput, {
94 cwd: "/repo",
95 baseBranch: "main",
96 headSelector: "owner:feature/provider",
97 source: {
98 owner: "owner",
99 refName: "feature/provider",
100 },
101 title: "Provider MR",
102 bodyFile: "/tmp/body.md",
103 });
104 }),
105);
apps/server/src/sourceControl/GitLabSourceControlProvider.ts created+106
......@@ -0,0 +1,106 @@
1import { Effect, Layer, Option } from "effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3
4import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts";
5import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
6
7function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError {
8 return new SourceControlProviderError({
9 provider: "gitlab",
10 operation,
11 detail: cause.detail,
12 cause,
13 });
14}
15
16function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest {
17 return {
18 provider: "gitlab",
19 number: summary.number,
20 title: summary.title,
21 url: summary.url,
22 baseRefName: summary.baseRefName,
23 headRefName: summary.headRefName,
24 state: summary.state ?? "open",
25 updatedAt: summary.updatedAt ?? Option.none(),
26 ...(summary.isCrossRepository !== undefined
27 ? { isCrossRepository: summary.isCrossRepository }
28 : {}),
29 ...(summary.headRepositoryNameWithOwner !== undefined
30 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
31 : {}),
32 ...(summary.headRepositoryOwnerLogin !== undefined
33 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
34 : {}),
35 };
36}
37
38function sourceFromInput(input: {
39 readonly headSelector: string;
40 readonly source?: SourceControlRefSelector;
41}): SourceControlRefSelector | undefined {
42 if (input.source) {
43 return input.source;
44 }
45
46 const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
47 const owner = match?.[1]?.trim();
48 const refName = match?.[2]?.trim();
49 return owner && refName ? { owner, refName } : undefined;
50}
51
52export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
53 const gitlab = yield* GitLabCli;
54
55 return SourceControlProvider.of({
56 kind: "gitlab",
57 listChangeRequests: (input) => {
58 const source = sourceFromInput(input);
59 return gitlab
60 .listMergeRequests({
61 cwd: input.cwd,
62 headSelector: input.headSelector,
63 ...(source ? { source } : {}),
64 state: input.state,
65 ...(input.limit !== undefined ? { limit: input.limit } : {}),
66 })
67 .pipe(
68 Effect.map((items) => items.map(toChangeRequest)),
69 Effect.mapError((error) => providerError("listChangeRequests", error)),
70 );
71 },
72 getChangeRequest: (input) =>
73 gitlab.getMergeRequest(input).pipe(
74 Effect.map(toChangeRequest),
75 Effect.mapError((error) => providerError("getChangeRequest", error)),
76 ),
77 createChangeRequest: (input) => {
78 const source = sourceFromInput(input);
79 return gitlab
80 .createMergeRequest({
81 cwd: input.cwd,
82 baseBranch: input.baseRefName,
83 headSelector: input.headSelector,
84 ...(source ? { source } : {}),
85 ...(input.target ? { target: input.target } : {}),
86 title: input.title,
87 bodyFile: input.bodyFile,
88 })
89 .pipe(Effect.mapError((error) => providerError("createChangeRequest", error)));
90 },
91 getRepositoryCloneUrls: (input) =>
92 gitlab
93 .getRepositoryCloneUrls(input)
94 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
95 getDefaultBranch: (input) =>
96 gitlab
97 .getDefaultBranch(input)
98 .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))),
99 checkoutChangeRequest: (input) =>
100 gitlab
101 .checkoutMergeRequest(input)
102 .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))),
103 });
104});
105
106export const layer = Layer.effect(SourceControlProvider, make());
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+1-1
......@@ -94,7 +94,7 @@ Logged in to github.com account juliusmarminge (keyring)
9494 },
9595 {
9696 kind: "gitlab",
97 implemented: false,
97 implemented: true,
9898 status: "missing",
9999 auth: "unknown",
100100 account: Option.none(),
apps/server/src/sourceControl/SourceControlDiscovery.ts+1-1
......@@ -83,7 +83,7 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [
8383 versionArgs: ["--version"],
8484 authArgs: ["auth", "status"],
8585 parseAuth: parseGitLabAuth,
86 implemented: false,
86 implemented: true,
8787 installHint:
8888 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",
8989 },
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+14-22
......@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
22import { DateTime, Effect, Layer, Option } from "effect";
33
44import { GitHubCli } from "./GitHubCli.ts";
5import { GitLabCli } from "./GitLabCli.ts";
56import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
67import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
78import type { VcsDriverShape } from "../vcs/VcsDriver.ts";
......@@ -50,7 +51,9 @@ function makeRegistry(input: {
5051 });
5152
5253 return SourceControlProviderRegistry.make().pipe(
53 Effect.provide(Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}))),
54 Effect.provide(
55 Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})),
56 ),
5457 );
5558}
5659
......@@ -78,27 +81,16 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>
7881 }),
7982);
8083
81it.effect(
82 "detects GitLab remotes and returns an unsupported provider until one is registered",
83 () =>
84 Effect.gen(function* () {
85 const registry = yield* makeRegistry({
86 remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }],
87 });
88
89 const provider = yield* registry.resolve({ cwd: "/repo" });
90
91 assert.strictEqual(provider.kind, "gitlab");
92 const error = yield* Effect.flip(
93 provider.listChangeRequests({
94 cwd: "/repo",
95 headSelector: "feature/source-control",
96 state: "open",
97 }),
98 );
99
100 assert.strictEqual(error.provider, "gitlab");
101 }),
84it.effect("routes GitLab remotes to the GitLab provider", () =>
85 Effect.gen(function* () {
86 const registry = yield* makeRegistry({
87 remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }],
88 });
89
90 const provider = yield* registry.resolve({ cwd: "/repo" });
91
92 assert.strictEqual(provider.kind, "gitlab");
93 }),
10294);
10395
10496it.effect("falls back to a non-origin remote when origin is not configured", () =>
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+6
......@@ -9,6 +9,7 @@ import {
99 type SourceControlProviderShape,
1010} from "./SourceControlProvider.ts";
1111import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
12import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
1213import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
1314
1415const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
......@@ -151,11 +152,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
151152
152153export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {
153154 const github = yield* GitHubSourceControlProvider.make();
155 const gitlab = yield* GitLabSourceControlProvider.make();
154156 return yield* makeWithProviders([
155157 {
156158 kind: "github",
157159 provider: github,
158160 },
161 {
162 kind: "gitlab",
163 provider: gitlab,
164 },
159165 ]);
160166});
161167
apps/server/src/sourceControl/gitLabMergeRequests.ts created+148
......@@ -0,0 +1,148 @@
1import { Cause, DateTime, Exit, Option, Result, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
4
5export interface NormalizedGitLabMergeRequestRecord {
6 readonly number: number;
7 readonly title: string;
8 readonly url: string;
9 readonly baseRefName: string;
10 readonly headRefName: string;
11 readonly state: "open" | "closed" | "merged";
12 readonly updatedAt: Option.Option<DateTime.Utc>;
13 readonly isCrossRepository?: boolean;
14 readonly headRepositoryNameWithOwner?: string | null;
15 readonly headRepositoryOwnerLogin?: string | null;
16}
17
18const GitLabProjectReferenceSchema = Schema.Struct({
19 path_with_namespace: Schema.optional(Schema.String),
20 pathWithNamespace: Schema.optional(Schema.String),
21 namespace: Schema.optional(
22 Schema.NullOr(
23 Schema.Struct({
24 path: Schema.optional(Schema.String),
25 full_path: Schema.optional(Schema.String),
26 fullPath: Schema.optional(Schema.String),
27 }),
28 ),
29 ),
30});
31
32const GitLabMergeRequestSchema = Schema.Struct({
33 iid: PositiveInt,
34 title: TrimmedNonEmptyString,
35 web_url: TrimmedNonEmptyString,
36 source_branch: TrimmedNonEmptyString,
37 target_branch: TrimmedNonEmptyString,
38 state: Schema.optional(Schema.NullOr(Schema.String)),
39 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
40 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
41 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
42 source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
43 target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
44});
45
46function trimOptionalString(value: string | null | undefined): string | null {
47 const trimmed = value?.trim() ?? "";
48 return trimmed.length > 0 ? trimmed : null;
49}
50
51function normalizeGitLabMergeRequestState(
52 state: string | null | undefined,
53): "open" | "closed" | "merged" {
54 const normalized = state?.trim().toLowerCase();
55 if (normalized === "merged") {
56 return "merged";
57 }
58 if (normalized === "closed") {
59 return "closed";
60 }
61 return "open";
62}
63
64function projectPathWithNamespace(
65 project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined,
66): string | null {
67 const explicit =
68 trimOptionalString(project?.path_with_namespace) ??
69 trimOptionalString(project?.pathWithNamespace);
70 if (explicit) {
71 return explicit;
72 }
73
74 const namespacePath =
75 trimOptionalString(project?.namespace?.full_path) ??
76 trimOptionalString(project?.namespace?.fullPath) ??
77 trimOptionalString(project?.namespace?.path);
78 return namespacePath;
79}
80
81function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null {
82 const [owner] = pathWithNamespace?.split("/") ?? [];
83 return trimOptionalString(owner);
84}
85
86function normalizeGitLabMergeRequestRecord(
87 raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>,
88): NormalizedGitLabMergeRequestRecord {
89 const sourceProjectPath = projectPathWithNamespace(raw.source_project);
90 const targetProjectPath = projectPathWithNamespace(raw.target_project);
91 const isCrossRepository =
92 typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number"
93 ? raw.source_project_id !== raw.target_project_id
94 : sourceProjectPath !== null && targetProjectPath !== null
95 ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase()
96 : undefined;
97 const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath);
98
99 return {
100 number: raw.iid,
101 title: raw.title,
102 url: raw.web_url,
103 baseRefName: raw.target_branch,
104 headRefName: raw.source_branch,
105 state: normalizeGitLabMergeRequestState(raw.state),
106 updatedAt: raw.updated_at ?? Option.none(),
107 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
108 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
109 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
110 };
111}
112
113const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
114const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema);
115const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema);
116
117export const formatGitLabJsonDecodeError = formatSchemaError;
118
119export function decodeGitLabMergeRequestListJson(
120 raw: string,
121): Result.Result<
122 ReadonlyArray<NormalizedGitLabMergeRequestRecord>,
123 Cause.Cause<Schema.SchemaError>
124> {
125 const result = decodeGitLabMergeRequestList(raw);
126 if (Result.isSuccess(result)) {
127 const mergeRequests: NormalizedGitLabMergeRequestRecord[] = [];
128 for (const entry of result.success) {
129 const decodedEntry = decodeGitLabMergeRequestEntry(entry);
130 if (Exit.isFailure(decodedEntry)) {
131 continue;
132 }
133 mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value));
134 }
135 return Result.succeed(mergeRequests);
136 }
137 return Result.fail(result.failure);
138}
139
140export function decodeGitLabMergeRequestJson(
141 raw: string,
142): Result.Result<NormalizedGitLabMergeRequestRecord, Cause.Cause<Schema.SchemaError>> {
143 const result = decodeGitLabMergeRequest(raw);
144 if (Result.isSuccess(result)) {
145 return Result.succeed(normalizeGitLabMergeRequestRecord(result.success));
146 }
147 return Result.fail(result.failure);
148}
apps/server/src/vcs/GitVcsDriver.ts+11
......@@ -55,6 +55,7 @@ export interface GitStatusDetails {
5555 hasUpstream: boolean;
5656 aheadCount: number;
5757 behindCount: number;
58 aheadOfDefaultCount: number;
5859}
5960
6061export interface GitPreparedCommitContext {
......@@ -133,6 +134,12 @@ export interface GitFetchRemoteBranchInput {
133134 localBranch: string;
134135}
135136
137export interface GitFetchRemoteTrackingBranchInput {
138 cwd: string;
139 remoteName: string;
140 remoteBranch: string;
141}
142
136143export interface GitSetBranchUpstreamInput {
137144 cwd: string;
138145 branch: string;
......@@ -176,9 +183,13 @@ export interface GitVcsDriverShape {
176183 input: GitFetchPullRequestBranchInput,
177184 ) => Effect.Effect<void, GitCommandError>;
178185 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
186 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
179187 readonly fetchRemoteBranch: (
180188 input: GitFetchRemoteBranchInput,
181189 ) => Effect.Effect<void, GitCommandError>;
190 readonly fetchRemoteTrackingBranch: (
191 input: GitFetchRemoteTrackingBranchInput,
192 ) => Effect.Effect<void, GitCommandError>;
182193 readonly setBranchUpstream: (
183194 input: GitSetBranchUpstreamInput,
184195 ) => Effect.Effect<void, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.test.ts+84
......@@ -102,6 +102,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
102102 );
103103 }),
104104 );
105
106 it.effect("reports default-branch delta separately from upstream delta", () =>
107 Effect.gen(function* () {
108 const cwd = yield* makeTmpDir();
109 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
110 const { initialBranch } = yield* initRepoWithCommit(cwd);
111 yield* git(remote, ["init", "--bare"]);
112 yield* git(cwd, ["remote", "add", "origin", remote]);
113 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
114 yield* git(cwd, ["checkout", "-b", "feature/synced"]);
115 yield* writeTextFile(cwd, "feature.txt", "feature\n");
116 yield* git(cwd, ["add", "feature.txt"]);
117 yield* git(cwd, ["commit", "-m", "feature commit"]);
118 yield* git(cwd, ["push", "-u", "origin", "feature/synced"]);
119
120 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
121
122 assert.equal(status.hasUpstream, true);
123 assert.equal(status.aheadCount, 0);
124 assert.equal(status.behindCount, 0);
125 assert.equal(status.aheadOfDefaultCount, 1);
126 }),
127 );
128
129 it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>
130 Effect.gen(function* () {
131 const cwd = yield* makeTmpDir();
132 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
133 const { initialBranch } = yield* initRepoWithCommit(cwd);
134 yield* git(remote, ["init", "--bare"]);
135 yield* git(cwd, ["remote", "add", "origin", remote]);
136 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
137 yield* git(cwd, ["checkout", "-b", "feature/no-upstream"]);
138 yield* writeTextFile(cwd, "feature.txt", "feature\n");
139 yield* git(cwd, ["add", "feature.txt"]);
140 yield* git(cwd, ["commit", "-m", "feature commit"]);
141
142 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
143
144 assert.equal(status.hasUpstream, false);
145 assert.equal(status.aheadCount, 1);
146 assert.equal(status.behindCount, 0);
147 assert.equal(status.aheadOfDefaultCount, 1);
148 }),
149 );
105150 });
106151
107152 describe("refName operations", () => {
......@@ -242,5 +287,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
242287 });
243288 }),
244289 );
290
291 it.effect(
292 "pushes upstream branches to the remote branch name, not the upstream shorthand",
293 () =>
294 Effect.gen(function* () {
295 const cwd = yield* makeTmpDir();
296 const remote = yield* makeTmpDir("git-remote-");
297 yield* initRepoWithCommit(cwd);
298 const driver = yield* GitVcsDriver.GitVcsDriver;
299 yield* git(cwd, ["branch", "-M", "main"]);
300 yield* git(remote, ["init", "--bare"]);
301 yield* git(cwd, ["remote", "add", "origin", remote]);
302 yield* git(cwd, ["push", "-u", "origin", "main"]);
303 yield* writeTextFile(cwd, "upstream.txt", "upstream\n");
304 yield* driver.prepareCommitContext(cwd);
305 yield* driver.commit(cwd, "Add upstream update", "");
306
307 const pushed = yield* driver.pushCurrentBranch(cwd, null);
308
309 assert.deepInclude(pushed, {
310 status: "pushed",
311 branch: "main",
312 upstreamBranch: "origin/main",
313 setUpstream: false,
314 });
315 assert.equal(
316 yield* git(remote, ["log", "-1", "--pretty=%s", "main"]),
317 "Add upstream update",
318 );
319 const badBranch = yield* driver.execute({
320 operation: "GitVcsDriver.test.showBadRemoteBranch",
321 cwd: remote,
322 args: ["show-ref", "--verify", "--quiet", "refs/heads/origin/main"],
323 allowNonZeroExit: true,
324 timeoutMs: 10_000,
325 });
326 assert.notEqual(badBranch.exitCode, 0);
327 }),
328 );
245329 });
246330});
apps/server/src/vcs/GitVcsDriverCore.ts+54-11
......@@ -62,6 +62,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({
6262 hasUpstream: false,
6363 aheadCount: 0,
6464 behindCount: 0,
65 aheadOfDefaultCount: 0,
6566});
6667
6768type TraceTailState = {
......@@ -969,6 +970,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
969970 Effect.map(parseRemoteNamesInGitOrder),
970971 );
971972
973 const resolvePublishBranchName = Effect.fn("resolvePublishBranchName")(function* (
974 cwd: string,
975 branchName: string,
976 ) {
977 const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.catch(() => Effect.succeed([])));
978 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branchName, remoteNames);
979 return parsedRemoteRef?.branchName ?? branchName;
980 });
981
972982 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
973983 if (yield* originRemoteExists(cwd)) {
974984 return "origin";
......@@ -1213,6 +1223,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
12131223 let upstreamRef: string | null = null;
12141224 let aheadCount = 0;
12151225 let behindCount = 0;
1226 let aheadOfDefaultCount = 0;
12161227 let hasWorkingTreeChanges = false;
12171228 const changedFilesWithoutNumstat = new Set<string>();
12181229
......@@ -1241,13 +1252,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
12411252 }
12421253 }
12431254
1244 if (!upstreamRef && refName) {
1245 aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe(
1246 Effect.catch(() => Effect.succeed(0)),
1247 );
1255 const fallbackAheadCount =
1256 !upstreamRef && refName
1257 ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(
1258 Effect.catch(() => Effect.succeed(0)),
1259 )
1260 : null;
1261
1262 if (fallbackAheadCount !== null) {
1263 aheadCount = fallbackAheadCount;
12481264 behindCount = 0;
12491265 }
12501266
1267 const isDefaultBranch =
1268 refName !== null &&
1269 (refName === defaultBranch ||
1270 (defaultBranch === null && (refName === "main" || refName === "master")));
1271 if (refName && !isDefaultBranch) {
1272 aheadOfDefaultCount =
1273 fallbackAheadCount !== null
1274 ? fallbackAheadCount
1275 : yield* computeAheadCountAgainstBase(cwd, refName).pipe(
1276 Effect.catch(() => Effect.succeed(0)),
1277 );
1278 }
1279
12511280 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
12521281 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
12531282 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
......@@ -1277,10 +1306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
12771306 return {
12781307 isRepo: true,
12791308 hasOriginRemote: hasPrimaryRemote,
1280 isDefaultBranch:
1281 refName !== null &&
1282 (refName === defaultBranch ||
1283 (defaultBranch === null && (refName === "main" || refName === "master"))),
1309 isDefaultBranch,
12841310 branch: refName,
12851311 upstreamRef,
12861312 hasWorkingTreeChanges,
......@@ -1292,6 +1318,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
12921318 hasUpstream: upstreamRef !== null,
12931319 aheadCount,
12941320 behindCount,
1321 aheadOfDefaultCount,
12951322 };
12961323 });
12971324
......@@ -1323,6 +1350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
13231350 hasUpstream: details.hasUpstream,
13241351 aheadCount: details.aheadCount,
13251352 behindCount: details.behindCount,
1353 aheadOfDefaultCount: details.aheadOfDefaultCount,
13261354 pr: null,
13271355 })),
13281356 );
......@@ -1461,16 +1489,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14611489 "Cannot push because no git remote is configured for this repository.",
14621490 );
14631491 }
1492 const publishBranch = yield* resolvePublishBranchName(cwd, branch);
14641493 yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [
14651494 "push",
14661495 "-u",
14671496 publishRemoteName,
1468 `HEAD:refs/heads/${branch}`,
1497 `HEAD:refs/heads/${publishBranch}`,
14691498 ]);
14701499 return {
14711500 status: "pushed" as const,
14721501 branch,
1473 upstreamBranch: `${publishRemoteName}/${branch}`,
1502 upstreamBranch: `${publishRemoteName}/${publishBranch}`,
14741503 setUpstream: true,
14751504 };
14761505 }
......@@ -1482,7 +1511,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14821511 yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [
14831512 "push",
14841513 currentUpstream.remoteName,
1485 `HEAD:${currentUpstream.upstreamRef}`,
1514 `HEAD:refs/heads/${currentUpstream.branchName}`,
14861515 ]);
14871516 return {
14881517 status: "pushed" as const,
......@@ -1894,6 +1923,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
18941923 },
18951924 );
18961925
1926 const fetchRemoteTrackingBranch: GitVcsDriverShape["fetchRemoteTrackingBranch"] = Effect.fn(
1927 "fetchRemoteTrackingBranch",
1928 )(function* (input) {
1929 yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [
1930 "fetch",
1931 "--quiet",
1932 "--no-tags",
1933 input.remoteName,
1934 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1935 ]);
1936 });
1937
18971938 const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) =>
18981939 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [
18991940 "branch",
......@@ -2075,7 +2116,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
20752116 createWorktree,
20762117 fetchPullRequestBranch,
20772118 ensureRemote,
2119 resolvePrimaryRemoteName,
20782120 fetchRemoteBranch,
2121 fetchRemoteTrackingBranch,
20792122 setBranchUpstream,
20802123 removeWorktree,
20812124 renameBranch,
apps/web/src/components/GitActionsControl.logic.test.ts+62
......@@ -280,6 +280,26 @@ describe("when: source control provider uses merge requests", () => {
280280});
281281
282282describe("when: ref is clean, up to date, and has no open PR", () => {
283 it("enables create PR when synced with upstream but ahead of default", () => {
284 const syncedFeature = status({
285 aheadCount: 0,
286 behindCount: 0,
287 aheadOfDefaultCount: 1,
288 pr: null,
289 });
290
291 const quick = resolveQuickAction(syncedFeature, false);
292 assert.deepInclude(quick, {
293 label: "Create PR",
294 disabled: false,
295 kind: "run_action",
296 action: "create_pr",
297 });
298
299 const items = buildMenuItems(syncedFeature, false);
300 assert.equal(items.find((item) => item.id === "pr")?.disabled, false);
301 });
302
283303 it("resolveQuickAction returns disabled no-action state", () => {
284304 const quick = resolveQuickAction(
285305 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
......@@ -444,6 +464,48 @@ describe("when: working tree has local changes", () => {
444464 },
445465 ]);
446466 });
467
468 it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => {
469 const items = buildMenuItems(
470 status({
471 refName: "feature/test",
472 hasWorkingTreeChanges: true,
473 aheadCount: 1,
474 workingTree: {
475 files: [{ path: ".vercel/project.json", insertions: 1, deletions: 0 }],
476 insertions: 1,
477 deletions: 0,
478 },
479 }),
480 false,
481 );
482 assert.deepEqual(items, [
483 {
484 id: "commit",
485 label: "Commit",
486 disabled: false,
487 icon: "commit",
488 kind: "open_dialog",
489 dialogAction: "commit",
490 },
491 {
492 id: "push",
493 label: "Push",
494 disabled: false,
495 icon: "push",
496 kind: "open_dialog",
497 dialogAction: "push",
498 },
499 {
500 id: "pr",
501 label: "Create PR",
502 disabled: true,
503 icon: "pr",
504 kind: "open_dialog",
505 dialogAction: "create_pr",
506 },
507 ]);
508 });
447509});
448510
449511describe("when: on default ref without open PR", () => {
apps/web/src/components/GitActionsControl.logic.ts+12-2
......@@ -103,12 +103,12 @@ export function buildMenuItems(
103103 const hasChanges = gitStatus.hasWorkingTreeChanges;
104104 const hasOpenPr = gitStatus.pr?.state === "open";
105105 const isBehind = gitStatus.behindCount > 0;
106 const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;
106107 const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream;
107108 const canCommit = !isBusy && hasChanges;
108109 const canPush =
109110 !isBusy &&
110111 hasBranch &&
111 !hasChanges &&
112112 !isBehind &&
113113 gitStatus.aheadCount > 0 &&
114114 (gitStatus.hasUpstream || canPushWithoutUpstream);
......@@ -117,7 +117,7 @@ export function buildMenuItems(
117117 hasBranch &&
118118 !hasChanges &&
119119 !hasOpenPr &&
120 gitStatus.aheadCount > 0 &&
120 hasDefaultBranchDelta &&
121121 !isBehind &&
122122 (gitStatus.hasUpstream || canPushWithoutUpstream);
123123 const canOpenPr = !isBusy && hasOpenPr;
......@@ -181,6 +181,7 @@ export function resolveQuickAction(
181181 const hasChanges = gitStatus.hasWorkingTreeChanges;
182182 const hasOpenPr = gitStatus.pr?.state === "open";
183183 const isAhead = gitStatus.aheadCount > 0;
184 const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;
184185 const isBehind = gitStatus.behindCount > 0;
185186 const isDiverged = isAhead && isBehind;
186187 const terminology = resolveChangeRequestTerminology(gitStatus);
......@@ -286,6 +287,15 @@ export function resolveQuickAction(
286287 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
287288 }
288289
290 if (hasDefaultBranchDelta && !isDefaultRef) {
291 return {
292 label: `Create ${terminology.shortLabel}`,
293 disabled: false,
294 kind: "run_action",
295 action: "create_pr",
296 };
297 }
298
289299 return {
290300 label: "Commit",
291301 disabled: true,
apps/web/src/components/Icons.tsx+165-16
......@@ -15,31 +15,180 @@ export const GitHubIcon: Icon = (props) => (
1515 </svg>
1616);
1717
18export const GitLabIcon: Icon = (props) => (
19 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
20 <path d="M22.6 9.3 20.9 4c-.2-.7-1.2-.7-1.5-.1l-2 5.4H6.6l-2-5.4c-.2-.6-1.2-.6-1.5.1L1.4 9.3c-.2.6 0 1.2.5 1.6L12 18.7l10.1-7.8c.5-.4.7-1 .5-1.6Z" />
21 <path d="m12 18.7 4.1-9.4H7.9L12 18.7Z" opacity=".72" />
22 <path d="M7.9 9.3H1.5L12 18.7 7.9 9.3ZM16.1 9.3 12 18.7l10.5-9.4h-6.4Z" opacity=".88" />
18export const GitIcon: Icon = (props) => (
19 <svg {...props} viewBox="0 0 256 256">
20 <path
21 d="M251.17 116.6 139.4 4.82a16.49 16.49 0 0 0-23.31 0l-23.21 23.2 29.44 29.45a19.57 19.57 0 0 1 24.8 24.96l28.37 28.38a19.61 19.61 0 1 1-11.75 11.06L137.28 95.4v69.64a19.62 19.62 0 1 1-16.13-.57V94.2a19.61 19.61 0 0 1-10.65-25.73L81.46 39.44 4.83 116.08a16.49 16.49 0 0 0 0 23.32L116.6 251.17a16.49 16.49 0 0 0 23.32 0l111.25-111.25a16.5 16.5 0 0 0 0-23.33"
22 fill="#DE4C36"
23 />
2324 </svg>
2425);
2526
26export const AzureDevOpsIcon: Icon = (props) => (
27 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
28 <path d="M21 5.2v12.7L16 22l-8-2.8V22l-4.9-6.4 4.9.4V4.8L16 2l5 3.2ZM16 6.4l-5 1.4v8.4l5 1.4V6.4Z" />
29 <path d="m8 8.4-5 .8v5.6l5 .8V8.4Z" opacity=".72" />
30 </svg>
31);
27export const JujutsuIcon: Icon = (props) => {
28 const groupId = `${useId().replaceAll(":", "")}-jj-a`;
3229
33export const BitbucketIcon: Icon = (props) => (
34 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
35 <path d="M2.4 3.5c-.4 0-.7.3-.6.8L4.3 20c.1.4.4.7.8.7h13.7c.4 0 .7-.3.8-.7l2.6-15.7c.1-.5-.2-.8-.7-.8H2.4Zm12.5 11.1H9.4L8.5 8.7h7.4l-1 5.9Z" />
30 return (
31 <svg {...props} viewBox="0 0 1024 1024">
32 <defs>
33 <g id={groupId}>
34 <path
35 d="M380.7 632.3s-14.3 56-50.3 55.5c-12.1-.2-29-10.9-47.1-26.8-34.2 82.7-98.5 239-108.5 268.6-8.9 26.5 13 52 38.2 56 36.4 5.7 49-18.1 49-18.1s13.6 40.7 37.6 39.7c29.9-1.2 34.6-33 34.6-33s11.4 23.8 26.8 23.2c38.4-1.4 41.7-102.9 43.8-135.6 3.8-57.5 6.3-135.4 7.8-190.1-9 6.7-16.5 10.7-21.3 9.9-16.1-2.7-10.6-49.3-10.6-49.3z"
36 fill="#42acde"
37 />
38 <path
39 d="M403.7 75.1c-89.7-.3-201.5 32.6-200.6 99.6 1.3 87.4 52.2 62.4 41.2 111.2-4.9 21.6-59.9 49.8-65.5 153.8 2.8.7 5.3 1 7.2 1.2 14.2.7 29.9-26.3 29.9-26.3s9.5 38 35 38 50.7-26.3 50.7-26.3 15 30.3 39.6 32c20 1.6 34-8 34-8s.8 15 14.6 14c13.9-1.2 37.6-27.8 37.6-27.8 16.5 30.3 31.9 21 54.7 5.1 0 0 6.2 26.6 36.8 23.7 6-.6 12.3-1 18.5-1.5l.9-11.6c2-58.8-20.4-129.8-50.4-183-21.7-38.7-52.5-83.4-49.6-107.3 3.5-28.3 46.7-28.4 46.7-28.4l-48.5-16 24-37a279.7 279.7 0 0 0-56.8-5.4Z"
40 fill="#2f9fdf"
41 />
42 <path
43 d="M215.9 414.6s-15.7 27-30 26.3c-1.8-.1-4.3-.5-7-1.2l-.3 3c-2.3 52 7.7 100.3 29.7 132 35.1 50.7 92.6 112.6 122 113 36.1.6 50.4-55.4 50.4-55.4s-5.5 46.6 10.6 49.3c16 2.6 62.4-46.7 87.9-79.5 15.8-20.5 52-82.3 58.2-138.3-6.2.4-12.4 1-18.5 1.5-30.6 3-36.8-23.7-36.8-23.7-22.8 16-38.2 25.2-54.7-5 0 0-23.7 26.5-37.6 27.6-13.8 1.1-14.6-13.8-14.6-13.8s-14 9.5-34 8c-24.5-1.8-39.6-32.1-39.6-32.1s-25.2 26.3-50.7 26.3c-25.5 0-35-38-35-38z"
44 fill="#0e254f"
45 />
46 <path
47 d="M309.5 418.5a1.5 1.5 0 0 0-.7 0c-.6.2-1.1.8-1.5 1.8a34.7 34.7 0 0 0 4 16.6c5.5 10.6 12.4 22 23.3 27.6 4 2 8.5 3.5 12.5 3.3a36 36 0 0 0 12.5-2.3c4-2 7.6-3.8 10.8-6.2 6-4.4 7.6-6.5 7.6-9.8 0-3.3-2.7-3.5-7.6-.5-5 3-14.6 6.1-18.8 6.1-11.2 0-21.2-8-33.1-26.4-4.3-6.6-7.1-9.9-9-10.2zm174 5c-1.2-.2-2.4.4-3.8 1.7-2.3 2.3-2.7 4-2.6 10.7.5 9 4.8 19.2 11.8 24.6 4.4 3.3 13.2 6 20.4 5.8 6.8-1.1 18.8-6.6 15.3-10.3-2-1.6-5 .7-9.9.7-10.6 0-15.3-3.4-19.6-9-3.8-5-5.4-8.7-7.2-16.8-1.1-4.6-2.6-7.1-4.4-7.4zm-67.5 2.2a1 1 0 0 0-.4 0c-.4.1-.7.4-1.2.9-2.4 2-2.5 5.1-.2 12.6a41.4 41.4 0 0 0 25.3 22c7.6 1.7 14.5.6 20.3-2.7 3.8-2.4 5.3-5 4.4-7.3-4.8-2.9-7.7-1-13.3-1A39.5 39.5 0 0 1 424 435c-4.8-5.9-7-9.3-8-9.4zm-199 .8c-1 .3-1.4 2.7-1 7.2.3 5.3 1.3 8.2 4 12 3 4.5 11.5 11 14.7 11.7 2.5 1 6.9 2.3 10.7 2.3 3.8 0 8-.6 11.2-1.3 3.2-.7 6.8-2 10.3-4l1.3-.7c4-2.1 8-5.1 10.9-7.9l1.4-1.2c6.3-5.4 9.9-10.7 9.9-14.6 0-2.4-5.3-.5-14 5-16 9.8-31.1 13.2-40.9 9l-2.2-1.3-8.8-7.7a32.9 32.9 0 0 1-3.6-4.8c-1.4-2.2-2.5-3.4-3.3-3.7a1 1 0 0 0-.5 0z"
48 fill="#71beea"
49 />
50 <path
51 d="M221.6 468.8c-.5 0-.8.1-1.1.4-.3.2-.5.6-.6 1.2a10 10 0 0 0 .6 5.1 55 55 0 0 0 28.3 28.9 42.4 42.4 0 0 0 16.4 2c6.1-.2 12.3-1.4 16.6-3.4a48.3 48.3 0 0 0 13.7-10.6c.9-1 1.3-2 1.3-2.3 0-.6 0-.9-.3-1-.3-.3-.7-.4-1.6-.4-1.8 0-4.9.8-9.7 2.3-6.2 1.9-12.7 3-18.5 3.1-7.8.2-10.2-.4-17-3.7-4.5-2.2-12-7.7-17.7-12.8a232 232 0 0 0-7.2-6.3 63 63 0 0 0-2.4-2 12 12 0 0 0-.7-.4h-.1zm214.8 13.6c-.2 0-.5.7-.7 1.8v4a32 32 0 0 0 2.7 9.9 38.5 38.5 0 0 0 49.1 19.3c5.2-2.3 10-6.4 13-10.5 1.4-2.1 2.5-4.3 3-6.2.5-1.9.4-3.6-.3-4.9-.4-.8-.9-1.4-1.3-1.7-.3-.3-.6-.3-1-.2-.9.2-2.3 1.4-4.3 3.8-5 6.2-12.8 9.5-22.6 9.4-6.2 0-11-1.1-16-4.4-5-3.2-10.4-8.6-18-17.3-.8-1-1.7-2-2.5-2.4l-1-.6c-.1 0-.2 0 0 0zm-33.6 4.2-.6.2-1.4.7a53.5 53.5 0 0 0-4 2.5c-8 5.5-15.6 8.3-23.9 8.4-8.3 0-17.2-2.7-28-8.2a42 42 0 0 0-7.3-3c-2-.5-3.1-.5-3.6-.2-.2.1-.3.3-.4.6 0 .3 0 .8.2 1.5.4 1.3 1.4 3.1 3.1 5.5 4 5.4 10.5 10.1 19.7 14 9.3 3.9 23.8 3.5 32.4-1a42 42 0 0 0 9.1-6.5c2.6-2.6 4.5-6.5 5.3-9.6.5-1.5.6-3 .4-3.8 0-.4-.2-.7-.4-.9-.1-.2-.3-.2-.6-.2zm-132.5 30.8c-.3 0-.4 0-.5.2l-.1.5c0 .5.4 1.7 1.2 3.1a87.3 87.3 0 0 0 13.5 16.2c17 15.6 34.6 20.7 49 14.4a33.3 33.3 0 0 0 19-23c.4-2 .1-3.1-.2-3.5-.2-.2-.5-.3-1-.3l-1.7.5a24 24 0 0 0-5.7 4.6c-7.7 8-14.6 12-23 11.4-8.3-.4-18-5.2-31.7-14.3-5.2-3.4-9.4-6-12.5-7.6a15.5 15.5 0 0 0-6.3-2.2zm109.4 0c-.2 0-.3 0-.5.2-.1 0-.3.3-.4.7a9 9 0 0 0 0 3.3c.4 2.9 1.6 6.7 3.5 10.3 11 20.5 29.4 30.5 46.8 25.7a37.3 37.3 0 0 0 15.6-10c4.4-4.6 7.4-9.9 7.4-13.8 0-1.3-.1-2.3-.4-2.7 0-.3-.2-.4-.3-.4h-.5c-.6 0-1.5.4-2.7 1.2a57 57 0 0 0-4.7 3.7c-9 7.7-16.9 11.2-25.1 10-8.2-1.3-16.7-7.1-27-17.7a159.8 159.8 0 0 0-10.6-9.8 9.8 9.8 0 0 0-.9-.6l-.2-.1zm-140.2 43.5c-1 0-1.6.2-1.8.5-.2.4-.3 1 0 2.1.8 2.2 3.1 5.6 7.1 9.6a78.2 78.2 0 0 0 27.4 17.9c9.7 3.8 16.7 4.1 23.4 1.1 5.5-2.5 8.4-6.1 8.4-10.4a4 4 0 0 0-.4-2c-.3-.4-.6-.6-1-.7-.9-.1-2.5.4-4.6 1.8-2.7 2-6.4 2.3-11.3 1.2-5-1.2-11.3-3.8-19.5-8-8-4-17.5-9-21.3-11-2.9-1.4-5-2-6.4-2.1zm160.1 2.8c-.6-.1-1.7.2-3.1 1-1.4 1-3.2 2.3-5.2 4.1a58 58 0 0 1-12.7 8.7c-5.9 2.7-8.6 3.2-18.3 3.2-12.6 0-17.2-1.6-29.9-11.2a21 21 0 0 0-6.4-3.8c-.5 0-.5 0-.7.2-.2.3-.3 1-.3 2 0 2.2 1.5 5.7 3.8 9.2 2.4 3.4 5.7 7 9.2 9.5 8.1 6 14.3 7.8 26.2 7.4a34 34 0 0 0 18.3-4.1 51 51 0 0 0 13.1-9.8c3.8-3.8 6.5-8 7-10.7.4-2 .4-3.5.2-4.5a2 2 0 0 0-.5-1 1 1 0 0 0-.7-.2zm-2 21.4c-.5 0-.6 0-.8.4-.2.4-.3 1.2-.3 2.4 0 3.2 1.7 7.3 4.4 11 2.7 4 6.3 7.4 10 9.5a35 35 0 0 0 21.3 4.8c11-.9 17.8-3.4 23.8-12.6 1.5-2.5 2-4.8 1.4-6.7-.2-.7-.4-1.1-.8-1.4-.3-.2-.6-.3-1.2-.3-1 0-2.6.7-4.7 2.1-6 4.3-12 6.1-19.9 6.2-8.8 0-16.3-3-26.2-11-3.6-2.8-6-4.3-7-4.4zM290 605.5c-.6.3-.9.5-1 1a9 9 0 0 0 .1 3.3c1.8 11 13 25 24.4 30.2 8.5 4 20 4 28.5 0 7.7-3.5 16-11.1 19-17.3a25 25 0 0 0 2.2-6.3c.1-.7.1-1.3 0-1.6 0-.4-.2-.6-.4-.7-.3-.2-1.3-.2-2.8.5-1.6.7-3.6 2-6 4-10 8.2-18.6 12.1-27 11.4-8.6-.7-16.9-6-26.4-15.9a82.6 82.6 0 0 0-7.6-7.1 10 10 0 0 0-2-1.4c-.5-.2-.8-.2-1-.1z"
52 fill="#309fdf"
53 />
54 <path
55 d="M290 125.7a61.4 61.4 0 0 0-19 2.3c-19.9 5.7-33.5 15.8-36.5 52.2-3 36.6-18 45.5-20.3 46.7 14.7 26.7 38 24.5 30.1 59-2.4 11-17.6 23.5-32.8 46.5 34-27 56.4-48 99.6-100.5 0 0 39-47.6 25.1-77a52 52 0 0 0-46.1-29.2z"
56 fill="#e9f2f1"
57 />
58 <path
59 d="M288.5 120.8c-8.1 0-16.7 1.4-25.6 5.3-22.5 9.9-25 20.3-29.1 37a96.8 96.8 0 0 0-1.4 24.6 178 178 0 0 0-16.5-31.6s-19 19.8-36.5 18.6c-22.9-1.7-34 2-44.8 9.8-25.4 18.5-21.2 43.8-21.2 43.8s17.6-1.8 40.5 1c20.3 2.7 37.4 4.3 61.3 2.6 8.5-.6 15.8-10 19.3-19 4.5-4 2.9-22 4.8-24.5 3-3.4 8-4.7 10.6-5.2 3.7-.7 4.3 1.5 4.7 2.8a31 31 0 0 0 60.5-3.2c1-4.4 9-5 15.9-4.5 7 0 .9 15.9-1.7 22.3-5.4 13.2-24.9 39-24.9 39l124.3-85s-45.8 22-61.5 20c-19.7-2.3-25.7-34.8-43.2-44-9.9-5.1-22-9.6-35.5-9.8zm1.4 9.4a48.2 48.2 0 0 1 40.6 23.5c3.7 7.1 6.2 17-.3 20.2-7.6 3.7-14.7.7-15.5-2.8a31 31 0 0 0-30.2-24.1 30 30 0 0 0-30.8 27.4c-.4 5.2-9.1 7.7-12.7 3.7-2.7-3 1.7-16.8 3.7-22 6.2-16.2 27.8-25.7 45.2-26zm5.6 27.8a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11zm-22 29a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11zm40 523.4c-2.6-1.8-15.1 23.5-20 59-6.8 49-8.3 63.2-14.2 96-6 32.7-12.6 66.3-13.4 73.7-.6 4.8-1.7 19-.6 20.1 3.2 3.3 14.7-31.7 16.8-42 2-10.3 7.7-40.7 14.2-86.9 6.4-46.1 8.5-69.6 13.1-91.5 4.7-22 6.7-26.6 4.2-28.4zm66.3-27c-4.1-1.3-10.5 24.6-13.1 37.8-5.6 28.6-6.2 64-8.4 87a1099 1099 0 0 1-12 87.4c-2.7 15-8 29.4-9.8 44.6-1 9-5.2 26-.2 27.3 5.2 1.5 15.4-36.5 20-55.7 8.1-32.9 10.7-65.6 14.2-99.3 3.3-30.7 4.6-62.8 6.5-93.6.7-18.5 5.1-34.5 2.8-35.6z"
60 fill="#0e254f"
61 />
62 </g>
63 </defs>
64 <rect width="1024" height="1024" rx="270" fill="#a7bcd9" />
65 <use href={`#${groupId}`} transform="matrix(-1 0 0 1 1024 0)" />
66 <use href={`#${groupId}`} />
67 </svg>
68 );
69};
70
71export const GitLabIcon: Icon = (props) => (
72 <svg {...props} viewBox="0 0 32 32" fill="none">
73 <path
74 d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z"
75 fill="#E24329"
76 />
77 <path
78 d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z"
79 fill="#FC6D26"
80 />
3681 <path
37 d="M21 8.7h-5.1l-1 5.9H9.4l-4.2 5c.2.6.5 1.1 1.2 1.1h12.4c.4 0 .7-.3.8-.7L21 8.7Z"
38 opacity=".72"
82 d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z"
83 fill="#FCA326"
84 />
85 <path
86 d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z"
87 fill="#FC6D26"
3988 />
4089 </svg>
4190);
4291
92export const AzureDevOpsIcon: Icon = (props) => {
93 const id = useId().replaceAll(":", "");
94 const gradientA = `${id}-azure-a`;
95 const gradientB = `${id}-azure-b`;
96 const gradientC = `${id}-azure-c`;
97
98 return (
99 <svg {...props} viewBox="0 0 96 96">
100 <defs>
101 <linearGradient
102 id={gradientA}
103 x1="-1032.17"
104 x2="-1059.21"
105 y1="145.31"
106 y2="65.43"
107 gradientTransform="matrix(1 0 0 -1 1075 158)"
108 gradientUnits="userSpaceOnUse"
109 >
110 <stop offset="0" stopColor="#114a8b" />
111 <stop offset="1" stopColor="#0669bc" />
112 </linearGradient>
113 <linearGradient
114 id={gradientB}
115 x1="-1023.73"
116 x2="-1029.98"
117 y1="108.08"
118 y2="105.97"
119 gradientTransform="matrix(1 0 0 -1 1075 158)"
120 gradientUnits="userSpaceOnUse"
121 >
122 <stop offset="0" stopOpacity=".3" />
123 <stop offset=".07" stopOpacity=".2" />
124 <stop offset=".32" stopOpacity=".1" />
125 <stop offset=".62" stopOpacity=".05" />
126 <stop offset="1" stopOpacity="0" />
127 </linearGradient>
128 <linearGradient
129 id={gradientC}
130 x1="-1027.16"
131 x2="-997.48"
132 y1="147.64"
133 y2="68.56"
134 gradientTransform="matrix(1 0 0 -1 1075 158)"
135 gradientUnits="userSpaceOnUse"
136 >
137 <stop offset="0" stopColor="#3ccbf4" />
138 <stop offset="1" stopColor="#2892df" />
139 </linearGradient>
140 </defs>
141 <path
142 fill={`url(#${gradientA})`}
143 d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"
144 />
145 <path
146 fill="#0078d4"
147 d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"
148 />
149 <path
150 fill={`url(#${gradientB})`}
151 d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"
152 />
153 <path
154 fill={`url(#${gradientC})`}
155 d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"
156 />
157 </svg>
158 );
159};
160
161export const BitbucketIcon: Icon = (props) => {
162 const id = useId().replaceAll(":", "");
163 const gradientId = `${id}-bitbucket-a`;
164
165 return (
166 <svg {...props} viewBox="8.4 14.39 2481.29 2231.21">
167 <path fill="none" d="M989.97,1493.09h518.05l125.04-730.04H852.22L989.97,1493.09z" />
168 <path
169 fill="#2684FF"
170 d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z"
171 />
172 <linearGradient
173 id={gradientId}
174 gradientUnits="userSpaceOnUse"
175 x1="945.1094"
176 y1="1524.8389"
177 x2="944.4923"
178 y2="1524.1893"
179 gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)"
180 >
181 <stop offset="0.18" stopColor="#0052CC" />
182 <stop offset="1" stopColor="#2684FF" />
183 </linearGradient>
184 <path
185 fill={`url(#${gradientId})`}
186 d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z"
187 />
188 </svg>
189 );
190};
191
43192export const CursorIcon: Icon = ({ className, ...props }) => (
44193 <svg
45194 {...props}
apps/web/src/components/settings/SourceControlSettings.tsx+26-125
......@@ -1,11 +1,12 @@
11import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
22import { Option } from "effect";
3import { type ReactNode, useId } from "react";
3import { type ReactNode } from "react";
44import type {
55 SourceControlProviderKind,
66 SourceControlDiscoveryResult,
77 SourceControlProviderAuth,
88 SourceControlProviderDiscoveryItem,
9 VcsDriverKind,
910 VcsDiscoveryItem,
1011} from "@t3tools/contracts";
1112
......@@ -27,7 +28,15 @@ import {
2728import { Skeleton } from "../ui/skeleton";
2829import { Switch } from "../ui/switch";
2930import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
30import { GitHubIcon, type Icon } from "../Icons";
31import {
32 AzureDevOpsIcon,
33 BitbucketIcon,
34 GitHubIcon,
35 GitIcon,
36 GitLabIcon,
37 JujutsuIcon,
38 type Icon,
39} from "../Icons";
3140import { RedactedSensitiveText } from "./RedactedSensitiveText";
3241import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
3342
......@@ -36,127 +45,6 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
3645 sourceControlProviders: [],
3746};
3847
39const GitLabIcon: Icon = (props) => (
40 <svg {...props} viewBox="0 0 32 32" fill="none">
41 <path
42 d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z"
43 fill="#E24329"
44 />
45 <path
46 d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z"
47 fill="#FC6D26"
48 />
49 <path
50 d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z"
51 fill="#FCA326"
52 />
53 <path
54 d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z"
55 fill="#FC6D26"
56 />
57 </svg>
58);
59
60const AzureDevOpsIcon: Icon = (props) => {
61 const id = useId().replaceAll(":", "");
62 const gradientA = `${id}-azure-a`;
63 const gradientB = `${id}-azure-b`;
64 const gradientC = `${id}-azure-c`;
65
66 return (
67 <svg {...props} viewBox="0 0 96 96">
68 <defs>
69 <linearGradient
70 id={gradientA}
71 x1="-1032.17"
72 x2="-1059.21"
73 y1="145.31"
74 y2="65.43"
75 gradientTransform="matrix(1 0 0 -1 1075 158)"
76 gradientUnits="userSpaceOnUse"
77 >
78 <stop offset="0" stopColor="#114a8b" />
79 <stop offset="1" stopColor="#0669bc" />
80 </linearGradient>
81 <linearGradient
82 id={gradientB}
83 x1="-1023.73"
84 x2="-1029.98"
85 y1="108.08"
86 y2="105.97"
87 gradientTransform="matrix(1 0 0 -1 1075 158)"
88 gradientUnits="userSpaceOnUse"
89 >
90 <stop offset="0" stopOpacity=".3" />
91 <stop offset=".07" stopOpacity=".2" />
92 <stop offset=".32" stopOpacity=".1" />
93 <stop offset=".62" stopOpacity=".05" />
94 <stop offset="1" stopOpacity="0" />
95 </linearGradient>
96 <linearGradient
97 id={gradientC}
98 x1="-1027.16"
99 x2="-997.48"
100 y1="147.64"
101 y2="68.56"
102 gradientTransform="matrix(1 0 0 -1 1075 158)"
103 gradientUnits="userSpaceOnUse"
104 >
105 <stop offset="0" stopColor="#3ccbf4" />
106 <stop offset="1" stopColor="#2892df" />
107 </linearGradient>
108 </defs>
109 <path
110 fill={`url(#${gradientA})`}
111 d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"
112 />
113 <path
114 fill="#0078d4"
115 d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"
116 />
117 <path
118 fill={`url(#${gradientB})`}
119 d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"
120 />
121 <path
122 fill={`url(#${gradientC})`}
123 d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"
124 />
125 </svg>
126 );
127};
128
129const BitbucketIcon: Icon = (props) => {
130 const id = useId().replaceAll(":", "");
131 const gradientId = `${id}-bitbucket-a`;
132
133 return (
134 <svg {...props} viewBox="8.4 14.39 2481.29 2231.21">
135 <path fill="none" d="M989.97,1493.09h518.05l125.04-730.04H852.22L989.97,1493.09z" />
136 <path
137 fill="#2684FF"
138 d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z"
139 />
140 <linearGradient
141 id={gradientId}
142 gradientUnits="userSpaceOnUse"
143 x1="945.1094"
144 y1="1524.8389"
145 x2="944.4923"
146 y2="1524.1893"
147 gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)"
148 >
149 <stop offset="0.18" stopColor="#0052CC" />
150 <stop offset="1" stopColor="#2684FF" />
151 </linearGradient>
152 <path
153 fill={`url(#${gradientId})`}
154 d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z"
155 />
156 </svg>
157 );
158};
159
16048const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {
16149 github: GitHubIcon,
16250 gitlab: GitLabIcon,
......@@ -164,6 +52,11 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, I
16452 bitbucket: BitbucketIcon,
16553};
16654
55const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
56 git: GitIcon,
57 jj: JujutsuIcon,
58};
59
16760function optionLabel(value: Option.Option<string>): string | null {
16861 return Option.getOrNull(value);
16962}
......@@ -211,7 +104,9 @@ function SourceControlItemMark({
211104 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
212105}) {
213106 const dotClassName = itemStatusDot(item);
214 const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : null;
107 const Icon = isProviderDiscoveryItem(item)
108 ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind]
109 : VCS_ICONS[item.kind];
215110
216111 if (!Icon) {
217112 return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />;
......@@ -342,7 +237,13 @@ function SourceControlSectionSkeleton({
342237 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
343238 <div className="min-w-0 flex-1 space-y-2">
344239 <div className="flex items-center gap-2">
345 <Skeleton className="size-2 rounded-full" />
240 <span className="relative inline-flex size-5 shrink-0 items-center justify-center">
241 <Skeleton className="size-4.5 rounded-md" />
242 <Skeleton
243 className="pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background"
244 aria-hidden
245 />
246 </span>
346247 <Skeleton className="h-4 w-28 rounded-full" />
347248 <Skeleton className="h-5 w-14 rounded-full" />
348249 </div>
apps/web/src/rpc/wsRpcClient.test.ts+1
......@@ -83,6 +83,7 @@ describe("wsRpcClient", () => {
8383 hasUpstream: false,
8484 aheadCount: 0,
8585 behindCount: 0,
86 aheadOfDefaultCount: 0,
8687 pr: null,
8788 },
8889 ],
packages/contracts/src/git.ts+1
......@@ -219,6 +219,7 @@ const VcsStatusRemoteShape = {
219219 hasUpstream: Schema.Boolean,
220220 aheadCount: NonNegativeInt,
221221 behindCount: NonNegativeInt,
222 aheadOfDefaultCount: Schema.optional(NonNegativeInt),
222223 pr: Schema.NullOr(VcsStatusChangeRequest),
223224};
224225
packages/shared/src/git.ts+4
......@@ -202,6 +202,7 @@ const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = {
202202 hasUpstream: false,
203203 aheadCount: 0,
204204 behindCount: 0,
205 aheadOfDefaultCount: 0,
205206 pr: null,
206207};
207208
......@@ -220,6 +221,9 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
220221 hasUpstream: status.hasUpstream,
221222 aheadCount: status.aheadCount,
222223 behindCount: status.behindCount,
224 ...(status.aheadOfDefaultCount === undefined
225 ? {}
226 : { aheadOfDefaultCount: status.aheadOfDefaultCount }),
223227 pr: status.pr,
224228 };
225229}