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) => {...@@ -881,6 +881,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
881 hasUpstream: false,881 hasUpstream: false,
882 aheadCount: 0,882 aheadCount: 0,
883 behindCount: 0,883 behindCount: 0,
884 aheadOfDefaultCount: 0,
884 pr: null,885 pr: null,
885 });886 });
886 }),887 }),
...@@ -910,6 +911,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -910,6 +911,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
910 hasUpstream: false,911 hasUpstream: false,
911 aheadCount: 0,912 aheadCount: 0,
912 behindCount: 0,913 behindCount: 0,
914 aheadOfDefaultCount: 0,
913 pr: null,915 pr: null,
914 });916 });
915 }),917 }),
...@@ -1672,6 +1674,41 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1672,6 +1674,41 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1672 }),1674 }),
1673 );1675 );
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
1675 it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>1712 it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>
1676 Effect.gen(function* () {1713 Effect.gen(function* () {
1677 const repoDir = yield* makeTempDir("t3code-git-manager-");1714 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -2418,6 +2455,113 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2418,6 +2455,113 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2418 }),2455 }),
2419 );2456 );
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
2421 it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>2565 it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>
2422 Effect.gen(function* () {2566 Effect.gen(function* () {
2423 const repoDir = yield* makeTempDir("t3code-git-manager-");2567 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* () {...@@ -568,6 +568,22 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
568 localBranch = pullRequest.headBranch,568 localBranch = pullRequest.headBranch,
569 ) {569 ) {
570 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";570 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
571 if (repositoryNameWithOwner.length === 0) {587 if (repositoryNameWithOwner.length === 0) {
572 return;588 return;
573 }589 }
...@@ -588,6 +604,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -588,6 +604,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
588 url: remoteUrl,604 url: remoteUrl,
589 });605 });
590606
607 yield* gitCore.fetchRemoteTrackingBranch({
608 cwd,
609 remoteName,
610 remoteBranch: pullRequest.headBranch,
611 });
591 yield* gitCore.setBranchUpstream({612 yield* gitCore.setBranchUpstream({
592 cwd,613 cwd,
593 branch: localBranch,614 branch: localBranch,
...@@ -690,6 +711,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -690,6 +711,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
690 hasUpstream: false,711 hasUpstream: false,
691 aheadCount: 0,712 aheadCount: 0,
692 behindCount: 0,713 behindCount: 0,
714 aheadOfDefaultCount: 0,
693 } satisfies GitStatusDetails;715 } satisfies GitStatusDetails;
694 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {716 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
695 const details = yield* gitCore717 const details = yield* gitCore
...@@ -748,6 +770,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -748,6 +770,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
748 hasUpstream: details.hasUpstream,770 hasUpstream: details.hasUpstream,
749 aheadCount: details.aheadCount,771 aheadCount: details.aheadCount,
750 behindCount: details.behindCount,772 behindCount: details.behindCount,
773 aheadOfDefaultCount: details.aheadOfDefaultCount,
751 pr,774 pr,
752 } satisfies VcsStatusRemoteResult;775 } satisfies VcsStatusRemoteResult;
753 });776 });
...@@ -1593,12 +1616,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1593,12 +1616,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1593 "Feature-branch checkout is only supported for commit actions.",1616 "Feature-branch checkout is only supported for commit actions.",
1594 );1617 );
1595 }1618 }
1596 if (input.action === "push" && initialStatus.hasWorkingTreeChanges) {
1597 return yield* gitManagerError(
1598 "runStackedAction",
1599 "Commit or stash local changes before pushing.",
1600 );
1601 }
1602 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {1619 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
1603 return yield* gitManagerError(1620 return yield* gitManagerError(
1604 "runStackedAction",1621 "runStackedAction",
apps/server/src/git/GitWorkflowService.test.ts+1
...@@ -65,6 +65,7 @@ describe("GitWorkflowService", () => {...@@ -65,6 +65,7 @@ describe("GitWorkflowService", () => {
65 hasUpstream: false,65 hasUpstream: false,
66 aheadCount: 0,66 aheadCount: 0,
67 behindCount: 0,67 behindCount: 0,
68 aheadOfDefaultCount: 0,
68 pr: null,69 pr: null,
69 });70 });
70 }).pipe(71 }).pipe(
apps/server/src/git/GitWorkflowService.ts+1
...@@ -112,6 +112,7 @@ function nonRepositoryStatus(): VcsStatusResult {...@@ -112,6 +112,7 @@ function nonRepositoryStatus(): VcsStatusResult {
112 hasUpstream: false,112 hasUpstream: false,
113 aheadCount: 0,113 aheadCount: 0,
114 behindCount: 0,114 behindCount: 0,
115 aheadOfDefaultCount: 0,
115 pr: null,116 pr: null,
116 };117 };
117}118}
apps/server/src/server.ts+2-1
...@@ -26,6 +26,7 @@ import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";...@@ -26,6 +26,7 @@ import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
26import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";26import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
27import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";27import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
28import * as GitHubCli from "./sourceControl/GitHubCli.ts";28import * as GitHubCli from "./sourceControl/GitHubCli.ts";
29import * as GitLabCli from "./sourceControl/GitLabCli.ts";
29import * as TextGeneration from "./textGeneration/TextGeneration.ts";30import * as TextGeneration from "./textGeneration/TextGeneration.ts";
30import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";31import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
31import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";32import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
...@@ -165,7 +166,7 @@ const GitManagerLayerLive = GitManager.layer.pipe(...@@ -165,7 +166,7 @@ const GitManagerLayerLive = GitManager.layer.pipe(
165 Layer.provideMerge(GitVcsDriver.layer),166 Layer.provideMerge(GitVcsDriver.layer),
166 Layer.provideMerge(167 Layer.provideMerge(
167 SourceControlProviderRegistry.layer.pipe(168 SourceControlProviderRegistry.layer.pipe(
168 Layer.provide(GitHubCli.layer),169 Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
169 Layer.provideMerge(VcsDriverRegistryLayerLive),170 Layer.provideMerge(VcsDriverRegistryLayerLive),
170 ),171 ),
171 ),172 ),
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)...@@ -94,7 +94,7 @@ Logged in to github.com account juliusmarminge (keyring)
94 },94 },
95 {95 {
96 kind: "gitlab",96 kind: "gitlab",
97 implemented: false,97 implemented: true,
98 status: "missing",98 status: "missing",
99 auth: "unknown",99 auth: "unknown",
100 account: Option.none(),100 account: Option.none(),
apps/server/src/sourceControl/SourceControlDiscovery.ts+1-1
...@@ -83,7 +83,7 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [...@@ -83,7 +83,7 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [
83 versionArgs: ["--version"],83 versionArgs: ["--version"],
84 authArgs: ["auth", "status"],84 authArgs: ["auth", "status"],
85 parseAuth: parseGitLabAuth,85 parseAuth: parseGitLabAuth,
86 implemented: false,86 implemented: true,
87 installHint:87 installHint:
88 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",88 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",
89 },89 },
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+14-22
...@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";...@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
2import { DateTime, Effect, Layer, Option } from "effect";2import { DateTime, Effect, Layer, Option } from "effect";
33
4import { GitHubCli } from "./GitHubCli.ts";4import { GitHubCli } from "./GitHubCli.ts";
5import { GitLabCli } from "./GitLabCli.ts";
5import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";6import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
6import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";7import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
7import type { VcsDriverShape } from "../vcs/VcsDriver.ts";8import type { VcsDriverShape } from "../vcs/VcsDriver.ts";
...@@ -50,7 +51,9 @@ function makeRegistry(input: {...@@ -50,7 +51,9 @@ function makeRegistry(input: {
50 });51 });
5152
52 return SourceControlProviderRegistry.make().pipe(53 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 ),
54 );57 );
55}58}
5659
...@@ -78,27 +81,16 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>...@@ -78,27 +81,16 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>
78 }),81 }),
79);82);
8083
81it.effect(84it.effect("routes GitLab remotes to the GitLab provider", () =>
82 "detects GitLab remotes and returns an unsupported provider until one is registered",85 Effect.gen(function* () {
83 () =>86 const registry = yield* makeRegistry({
84 Effect.gen(function* () {87 remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }],
85 const registry = yield* makeRegistry({88 });
86 remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }],89
87 });90 const provider = yield* registry.resolve({ cwd: "/repo" });
8891
89 const provider = yield* registry.resolve({ cwd: "/repo" });92 assert.strictEqual(provider.kind, "gitlab");
9093 }),
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 }),
102);94);
10395
104it.effect("falls back to a non-origin remote when origin is not configured", () =>96it.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 {...@@ -9,6 +9,7 @@ import {
9 type SourceControlProviderShape,9 type SourceControlProviderShape,
10} from "./SourceControlProvider.ts";10} from "./SourceControlProvider.ts";
11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
12import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
12import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";13import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
1314
14const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;15const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
...@@ -151,11 +152,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -151,11 +152,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
151152
152export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {153export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {
153 const github = yield* GitHubSourceControlProvider.make();154 const github = yield* GitHubSourceControlProvider.make();
155 const gitlab = yield* GitLabSourceControlProvider.make();
154 return yield* makeWithProviders([156 return yield* makeWithProviders([
155 {157 {
156 kind: "github",158 kind: "github",
157 provider: github,159 provider: github,
158 },160 },
161 {
162 kind: "gitlab",
163 provider: gitlab,
164 },
159 ]);165 ]);
160});166});
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 {...@@ -55,6 +55,7 @@ export interface GitStatusDetails {
55 hasUpstream: boolean;55 hasUpstream: boolean;
56 aheadCount: number;56 aheadCount: number;
57 behindCount: number;57 behindCount: number;
58 aheadOfDefaultCount: number;
58}59}
5960
60export interface GitPreparedCommitContext {61export interface GitPreparedCommitContext {
...@@ -133,6 +134,12 @@ export interface GitFetchRemoteBranchInput {...@@ -133,6 +134,12 @@ export interface GitFetchRemoteBranchInput {
133 localBranch: string;134 localBranch: string;
134}135}
135136
137export interface GitFetchRemoteTrackingBranchInput {
138 cwd: string;
139 remoteName: string;
140 remoteBranch: string;
141}
142
136export interface GitSetBranchUpstreamInput {143export interface GitSetBranchUpstreamInput {
137 cwd: string;144 cwd: string;
138 branch: string;145 branch: string;
...@@ -176,9 +183,13 @@ export interface GitVcsDriverShape {...@@ -176,9 +183,13 @@ export interface GitVcsDriverShape {
176 input: GitFetchPullRequestBranchInput,183 input: GitFetchPullRequestBranchInput,
177 ) => Effect.Effect<void, GitCommandError>;184 ) => Effect.Effect<void, GitCommandError>;
178 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;185 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
186 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
179 readonly fetchRemoteBranch: (187 readonly fetchRemoteBranch: (
180 input: GitFetchRemoteBranchInput,188 input: GitFetchRemoteBranchInput,
181 ) => Effect.Effect<void, GitCommandError>;189 ) => Effect.Effect<void, GitCommandError>;
190 readonly fetchRemoteTrackingBranch: (
191 input: GitFetchRemoteTrackingBranchInput,
192 ) => Effect.Effect<void, GitCommandError>;
182 readonly setBranchUpstream: (193 readonly setBranchUpstream: (
183 input: GitSetBranchUpstreamInput,194 input: GitSetBranchUpstreamInput,
184 ) => Effect.Effect<void, GitCommandError>;195 ) => Effect.Effect<void, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.test.ts+84
...@@ -102,6 +102,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -102,6 +102,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
102 );102 );
103 }),103 }),
104 );104 );
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 );
105 });150 });
106151
107 describe("refName operations", () => {152 describe("refName operations", () => {
...@@ -242,5 +287,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -242,5 +287,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
242 });287 });
243 }),288 }),
244 );289 );
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 );
245 });329 });
246});330});
apps/server/src/vcs/GitVcsDriverCore.ts+54-11
...@@ -62,6 +62,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({...@@ -62,6 +62,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({
62 hasUpstream: false,62 hasUpstream: false,
63 aheadCount: 0,63 aheadCount: 0,
64 behindCount: 0,64 behindCount: 0,
65 aheadOfDefaultCount: 0,
65});66});
6667
67type TraceTailState = {68type TraceTailState = {
...@@ -969,6 +970,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -969,6 +970,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
969 Effect.map(parseRemoteNamesInGitOrder),970 Effect.map(parseRemoteNamesInGitOrder),
970 );971 );
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
972 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {982 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
973 if (yield* originRemoteExists(cwd)) {983 if (yield* originRemoteExists(cwd)) {
974 return "origin";984 return "origin";
...@@ -1213,6 +1223,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1213,6 +1223,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1213 let upstreamRef: string | null = null;1223 let upstreamRef: string | null = null;
1214 let aheadCount = 0;1224 let aheadCount = 0;
1215 let behindCount = 0;1225 let behindCount = 0;
1226 let aheadOfDefaultCount = 0;
1216 let hasWorkingTreeChanges = false;1227 let hasWorkingTreeChanges = false;
1217 const changedFilesWithoutNumstat = new Set<string>();1228 const changedFilesWithoutNumstat = new Set<string>();
12181229
...@@ -1241,13 +1252,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1241,13 +1252,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1241 }1252 }
1242 }1253 }
12431254
1244 if (!upstreamRef && refName) {1255 const fallbackAheadCount =
1245 aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe(1256 !upstreamRef && refName
1246 Effect.catch(() => Effect.succeed(0)),1257 ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(
1247 );1258 Effect.catch(() => Effect.succeed(0)),
1259 )
1260 : null;
1261
1262 if (fallbackAheadCount !== null) {
1263 aheadCount = fallbackAheadCount;
1248 behindCount = 0;1264 behindCount = 0;
1249 }1265 }
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
1251 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);1280 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
1252 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);1281 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
1253 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();1282 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
...@@ -1277,10 +1306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1277,10 +1306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1277 return {1306 return {
1278 isRepo: true,1307 isRepo: true,
1279 hasOriginRemote: hasPrimaryRemote,1308 hasOriginRemote: hasPrimaryRemote,
1280 isDefaultBranch:1309 isDefaultBranch,
1281 refName !== null &&
1282 (refName === defaultBranch ||
1283 (defaultBranch === null && (refName === "main" || refName === "master"))),
1284 branch: refName,1310 branch: refName,
1285 upstreamRef,1311 upstreamRef,
1286 hasWorkingTreeChanges,1312 hasWorkingTreeChanges,
...@@ -1292,6 +1318,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1292,6 +1318,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1292 hasUpstream: upstreamRef !== null,1318 hasUpstream: upstreamRef !== null,
1293 aheadCount,1319 aheadCount,
1294 behindCount,1320 behindCount,
1321 aheadOfDefaultCount,
1295 };1322 };
1296 });1323 });
12971324
...@@ -1323,6 +1350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1323,6 +1350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1323 hasUpstream: details.hasUpstream,1350 hasUpstream: details.hasUpstream,
1324 aheadCount: details.aheadCount,1351 aheadCount: details.aheadCount,
1325 behindCount: details.behindCount,1352 behindCount: details.behindCount,
1353 aheadOfDefaultCount: details.aheadOfDefaultCount,
1326 pr: null,1354 pr: null,
1327 })),1355 })),
1328 );1356 );
...@@ -1461,16 +1489,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1461,16 +1489,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1461 "Cannot push because no git remote is configured for this repository.",1489 "Cannot push because no git remote is configured for this repository.",
1462 );1490 );
1463 }1491 }
1492 const publishBranch = yield* resolvePublishBranchName(cwd, branch);
1464 yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [1493 yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [
1465 "push",1494 "push",
1466 "-u",1495 "-u",
1467 publishRemoteName,1496 publishRemoteName,
1468 `HEAD:refs/heads/${branch}`,1497 `HEAD:refs/heads/${publishBranch}`,
1469 ]);1498 ]);
1470 return {1499 return {
1471 status: "pushed" as const,1500 status: "pushed" as const,
1472 branch,1501 branch,
1473 upstreamBranch: `${publishRemoteName}/${branch}`,1502 upstreamBranch: `${publishRemoteName}/${publishBranch}`,
1474 setUpstream: true,1503 setUpstream: true,
1475 };1504 };
1476 }1505 }
...@@ -1482,7 +1511,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1482,7 +1511,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1482 yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [1511 yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [
1483 "push",1512 "push",
1484 currentUpstream.remoteName,1513 currentUpstream.remoteName,
1485 `HEAD:${currentUpstream.upstreamRef}`,1514 `HEAD:refs/heads/${currentUpstream.branchName}`,
1486 ]);1515 ]);
1487 return {1516 return {
1488 status: "pushed" as const,1517 status: "pushed" as const,
...@@ -1894,6 +1923,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1894,6 +1923,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1894 },1923 },
1895 );1924 );
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
1897 const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) =>1938 const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) =>
1898 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [1939 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [
1899 "branch",1940 "branch",
...@@ -2075,7 +2116,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2075,7 +2116,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2075 createWorktree,2116 createWorktree,
2076 fetchPullRequestBranch,2117 fetchPullRequestBranch,
2077 ensureRemote,2118 ensureRemote,
2119 resolvePrimaryRemoteName,
2078 fetchRemoteBranch,2120 fetchRemoteBranch,
2121 fetchRemoteTrackingBranch,
2079 setBranchUpstream,2122 setBranchUpstream,
2080 removeWorktree,2123 removeWorktree,
2081 renameBranch,2124 renameBranch,
apps/web/src/components/GitActionsControl.logic.test.ts+62
...@@ -280,6 +280,26 @@ describe("when: source control provider uses merge requests", () => {...@@ -280,6 +280,26 @@ describe("when: source control provider uses merge requests", () => {
280});280});
281281
282describe("when: ref is clean, up to date, and has no open PR", () => {282describe("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
283 it("resolveQuickAction returns disabled no-action state", () => {303 it("resolveQuickAction returns disabled no-action state", () => {
284 const quick = resolveQuickAction(304 const quick = resolveQuickAction(
285 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),305 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
...@@ -444,6 +464,48 @@ describe("when: working tree has local changes", () => {...@@ -444,6 +464,48 @@ describe("when: working tree has local changes", () => {
444 },464 },
445 ]);465 ]);
446 });466 });
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 });
447});509});
448510
449describe("when: on default ref without open PR", () => {511describe("when: on default ref without open PR", () => {
apps/web/src/components/GitActionsControl.logic.ts+12-2
...@@ -103,12 +103,12 @@ export function buildMenuItems(...@@ -103,12 +103,12 @@ export function buildMenuItems(
103 const hasChanges = gitStatus.hasWorkingTreeChanges;103 const hasChanges = gitStatus.hasWorkingTreeChanges;
104 const hasOpenPr = gitStatus.pr?.state === "open";104 const hasOpenPr = gitStatus.pr?.state === "open";
105 const isBehind = gitStatus.behindCount > 0;105 const isBehind = gitStatus.behindCount > 0;
106 const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;
106 const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream;107 const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream;
107 const canCommit = !isBusy && hasChanges;108 const canCommit = !isBusy && hasChanges;
108 const canPush =109 const canPush =
109 !isBusy &&110 !isBusy &&
110 hasBranch &&111 hasBranch &&
111 !hasChanges &&
112 !isBehind &&112 !isBehind &&
113 gitStatus.aheadCount > 0 &&113 gitStatus.aheadCount > 0 &&
114 (gitStatus.hasUpstream || canPushWithoutUpstream);114 (gitStatus.hasUpstream || canPushWithoutUpstream);
...@@ -117,7 +117,7 @@ export function buildMenuItems(...@@ -117,7 +117,7 @@ export function buildMenuItems(
117 hasBranch &&117 hasBranch &&
118 !hasChanges &&118 !hasChanges &&
119 !hasOpenPr &&119 !hasOpenPr &&
120 gitStatus.aheadCount > 0 &&120 hasDefaultBranchDelta &&
121 !isBehind &&121 !isBehind &&
122 (gitStatus.hasUpstream || canPushWithoutUpstream);122 (gitStatus.hasUpstream || canPushWithoutUpstream);
123 const canOpenPr = !isBusy && hasOpenPr;123 const canOpenPr = !isBusy && hasOpenPr;
...@@ -181,6 +181,7 @@ export function resolveQuickAction(...@@ -181,6 +181,7 @@ export function resolveQuickAction(
181 const hasChanges = gitStatus.hasWorkingTreeChanges;181 const hasChanges = gitStatus.hasWorkingTreeChanges;
182 const hasOpenPr = gitStatus.pr?.state === "open";182 const hasOpenPr = gitStatus.pr?.state === "open";
183 const isAhead = gitStatus.aheadCount > 0;183 const isAhead = gitStatus.aheadCount > 0;
184 const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;
184 const isBehind = gitStatus.behindCount > 0;185 const isBehind = gitStatus.behindCount > 0;
185 const isDiverged = isAhead && isBehind;186 const isDiverged = isAhead && isBehind;
186 const terminology = resolveChangeRequestTerminology(gitStatus);187 const terminology = resolveChangeRequestTerminology(gitStatus);
...@@ -286,6 +287,15 @@ export function resolveQuickAction(...@@ -286,6 +287,15 @@ export function resolveQuickAction(
286 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };287 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
287 }288 }
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
289 return {299 return {
290 label: "Commit",300 label: "Commit",
291 disabled: true,301 disabled: true,
apps/web/src/components/Icons.tsx+165-16
...@@ -15,31 +15,180 @@ export const GitHubIcon: Icon = (props) => (...@@ -15,31 +15,180 @@ export const GitHubIcon: Icon = (props) => (
15 </svg>15 </svg>
16);16);
1717
18export const GitLabIcon: Icon = (props) => (18export const GitIcon: Icon = (props) => (
19 <svg {...props} viewBox="0 0 24 24" fill="currentColor">19 <svg {...props} viewBox="0 0 256 256">
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" />20 <path
21 <path d="m12 18.7 4.1-9.4H7.9L12 18.7Z" opacity=".72" />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 <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" />22 fill="#DE4C36"
23 />
23 </svg>24 </svg>
24);25);
2526
26export const AzureDevOpsIcon: Icon = (props) => (27export const JujutsuIcon: Icon = (props) => {
27 <svg {...props} viewBox="0 0 24 24" fill="currentColor">28 const groupId = `${useId().replaceAll(":", "")}-jj-a`;
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);
3229
33export const BitbucketIcon: Icon = (props) => (30 return (
34 <svg {...props} viewBox="0 0 24 24" fill="currentColor">31 <svg {...props} viewBox="0 0 1024 1024">
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" />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 />
36 <path81 <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"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"
38 opacity=".72"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"
39 />88 />
40 </svg>89 </svg>
41);90);
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
43export const CursorIcon: Icon = ({ className, ...props }) => (192export const CursorIcon: Icon = ({ className, ...props }) => (
44 <svg193 <svg
45 {...props}194 {...props}
apps/web/src/components/settings/SourceControlSettings.tsx+26-125
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";1import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
2import { Option } from "effect";2import { Option } from "effect";
3import { type ReactNode, useId } from "react";3import { type ReactNode } from "react";
4import type {4import type {
5 SourceControlProviderKind,5 SourceControlProviderKind,
6 SourceControlDiscoveryResult,6 SourceControlDiscoveryResult,
7 SourceControlProviderAuth,7 SourceControlProviderAuth,
8 SourceControlProviderDiscoveryItem,8 SourceControlProviderDiscoveryItem,
9 VcsDriverKind,
9 VcsDiscoveryItem,10 VcsDiscoveryItem,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
1112
...@@ -27,7 +28,15 @@ import {...@@ -27,7 +28,15 @@ import {
27import { Skeleton } from "../ui/skeleton";28import { Skeleton } from "../ui/skeleton";
28import { Switch } from "../ui/switch";29import { Switch } from "../ui/switch";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";30import { 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";
31import { RedactedSensitiveText } from "./RedactedSensitiveText";40import { RedactedSensitiveText } from "./RedactedSensitiveText";
32import { SettingsPageContainer, SettingsSection } from "./settingsLayout";41import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
3342
...@@ -36,127 +45,6 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {...@@ -36,127 +45,6 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
36 sourceControlProviders: [],45 sourceControlProviders: [],
37};46};
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
160const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {48const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {
161 github: GitHubIcon,49 github: GitHubIcon,
162 gitlab: GitLabIcon,50 gitlab: GitLabIcon,
...@@ -164,6 +52,11 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, I...@@ -164,6 +52,11 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, I
164 bitbucket: BitbucketIcon,52 bitbucket: BitbucketIcon,
165};53};
16654
55const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
56 git: GitIcon,
57 jj: JujutsuIcon,
58};
59
167function optionLabel(value: Option.Option<string>): string | null {60function optionLabel(value: Option.Option<string>): string | null {
168 return Option.getOrNull(value);61 return Option.getOrNull(value);
169}62}
...@@ -211,7 +104,9 @@ function SourceControlItemMark({...@@ -211,7 +104,9 @@ function SourceControlItemMark({
211 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;104 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
212}) {105}) {
213 const dotClassName = itemStatusDot(item);106 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
216 if (!Icon) {111 if (!Icon) {
217 return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />;112 return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />;
...@@ -342,7 +237,13 @@ function SourceControlSectionSkeleton({...@@ -342,7 +237,13 @@ function SourceControlSectionSkeleton({
342 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">237 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
343 <div className="min-w-0 flex-1 space-y-2">238 <div className="min-w-0 flex-1 space-y-2">
344 <div className="flex items-center gap-2">239 <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>
346 <Skeleton className="h-4 w-28 rounded-full" />247 <Skeleton className="h-4 w-28 rounded-full" />
347 <Skeleton className="h-5 w-14 rounded-full" />248 <Skeleton className="h-5 w-14 rounded-full" />
348 </div>249 </div>
apps/web/src/rpc/wsRpcClient.test.ts+1
...@@ -83,6 +83,7 @@ describe("wsRpcClient", () => {...@@ -83,6 +83,7 @@ describe("wsRpcClient", () => {
83 hasUpstream: false,83 hasUpstream: false,
84 aheadCount: 0,84 aheadCount: 0,
85 behindCount: 0,85 behindCount: 0,
86 aheadOfDefaultCount: 0,
86 pr: null,87 pr: null,
87 },88 },
88 ],89 ],
packages/contracts/src/git.ts+1
...@@ -219,6 +219,7 @@ const VcsStatusRemoteShape = {...@@ -219,6 +219,7 @@ const VcsStatusRemoteShape = {
219 hasUpstream: Schema.Boolean,219 hasUpstream: Schema.Boolean,
220 aheadCount: NonNegativeInt,220 aheadCount: NonNegativeInt,
221 behindCount: NonNegativeInt,221 behindCount: NonNegativeInt,
222 aheadOfDefaultCount: Schema.optional(NonNegativeInt),
222 pr: Schema.NullOr(VcsStatusChangeRequest),223 pr: Schema.NullOr(VcsStatusChangeRequest),
223};224};
224225
packages/shared/src/git.ts+4
...@@ -202,6 +202,7 @@ const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = {...@@ -202,6 +202,7 @@ const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = {
202 hasUpstream: false,202 hasUpstream: false,
203 aheadCount: 0,203 aheadCount: 0,
204 behindCount: 0,204 behindCount: 0,
205 aheadOfDefaultCount: 0,
205 pr: null,206 pr: null,
206};207};
207208
...@@ -220,6 +221,9 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {...@@ -220,6 +221,9 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
220 hasUpstream: status.hasUpstream,221 hasUpstream: status.hasUpstream,
221 aheadCount: status.aheadCount,222 aheadCount: status.aheadCount,
222 behindCount: status.behindCount,223 behindCount: status.behindCount,
224 ...(status.aheadOfDefaultCount === undefined
225 ? {}
226 : { aheadOfDefaultCount: status.aheadOfDefaultCount }),
223 pr: status.pr,227 pr: status.pr,
224 };228 };
225}229}