| author | |
| committer | |
| log | 0ce7e56e436ba31e2f0b51f3203a09758c866cd1 |
| tree | 2cb8383e094bca15387668c4e2e288e70d7e570d |
| parent | 350d76ee141004e202d6c365d29ffdb29bde66c6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 881 | hasUpstream: false, |
| 882 | 882 | aheadCount: 0, |
| 883 | 883 | behindCount: 0, |
| 884 | aheadOfDefaultCount: 0, | |
| 884 | 885 | pr: null, |
| 885 | 886 | }); |
| 886 | 887 | }), |
| ... | ... | @@ -910,6 +911,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 910 | 911 | hasUpstream: false, |
| 911 | 912 | aheadCount: 0, |
| 912 | 913 | behindCount: 0, |
| 914 | aheadOfDefaultCount: 0, | |
| 913 | 915 | pr: null, |
| 914 | 916 | }); |
| 915 | 917 | }), |
| ... | ... | @@ -1672,6 +1674,41 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1672 | 1674 | }), |
| 1673 | 1675 | ); |
| 1674 | 1676 | |
| 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 | 1712 | it.effect("create_pr pushes a clean branch before creating the PR when needed", () => |
| 1676 | 1713 | Effect.gen(function* () { |
| 1677 | 1714 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| ... | ... | @@ -2418,6 +2455,113 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2418 | 2455 | }), |
| 2419 | 2456 | ); |
| 2420 | 2457 | |
| 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 | 2565 | it.effect("prepares pull request threads in worktree mode on the PR head branch", () => |
| 2422 | 2566 | Effect.gen(function* () { |
| 2423 | 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 | 568 | localBranch = pullRequest.headBranch, |
| 569 | 569 | ) { |
| 570 | 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 | 587 | if (repositoryNameWithOwner.length === 0) { |
| 572 | 588 | return; |
| 573 | 589 | } |
| ... | ... | @@ -588,6 +604,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 588 | 604 | url: remoteUrl, |
| 589 | 605 | }); |
| 590 | 606 | |
| 607 | yield* gitCore.fetchRemoteTrackingBranch({ | |
| 608 | cwd, | |
| 609 | remoteName, | |
| 610 | remoteBranch: pullRequest.headBranch, | |
| 611 | }); | |
| 591 | 612 | yield* gitCore.setBranchUpstream({ |
| 592 | 613 | cwd, |
| 593 | 614 | branch: localBranch, |
| ... | ... | @@ -690,6 +711,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 690 | 711 | hasUpstream: false, |
| 691 | 712 | aheadCount: 0, |
| 692 | 713 | behindCount: 0, |
| 714 | aheadOfDefaultCount: 0, | |
| 693 | 715 | } satisfies GitStatusDetails; |
| 694 | 716 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { |
| 695 | 717 | const details = yield* gitCore |
| ... | ... | @@ -748,6 +770,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 748 | 770 | hasUpstream: details.hasUpstream, |
| 749 | 771 | aheadCount: details.aheadCount, |
| 750 | 772 | behindCount: details.behindCount, |
| 773 | aheadOfDefaultCount: details.aheadOfDefaultCount, | |
| 751 | 774 | pr, |
| 752 | 775 | } satisfies VcsStatusRemoteResult; |
| 753 | 776 | }); |
| ... | ... | @@ -1593,12 +1616,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 1593 | 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 | 1619 | if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { |
| 1603 | 1620 | return yield* gitManagerError( |
| 1604 | 1621 | "runStackedAction", |
apps/server/src/git/GitWorkflowService.test.ts+1| ... | ... | @@ -65,6 +65,7 @@ describe("GitWorkflowService", () => { |
| 65 | 65 | hasUpstream: false, |
| 66 | 66 | aheadCount: 0, |
| 67 | 67 | behindCount: 0, |
| 68 | aheadOfDefaultCount: 0, | |
| 68 | 69 | pr: null, |
| 69 | 70 | }); |
| 70 | 71 | }).pipe( |
apps/server/src/git/GitWorkflowService.ts+1| ... | ... | @@ -112,6 +112,7 @@ function nonRepositoryStatus(): VcsStatusResult { |
| 112 | 112 | hasUpstream: false, |
| 113 | 113 | aheadCount: 0, |
| 114 | 114 | behindCount: 0, |
| 115 | aheadOfDefaultCount: 0, | |
| 115 | 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 | 26 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; |
| 27 | 27 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; |
| 28 | 28 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| 29 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | |
| 29 | 30 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 30 | 31 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 31 | 32 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| ... | ... | @@ -165,7 +166,7 @@ const GitManagerLayerLive = GitManager.layer.pipe( |
| 165 | 166 | Layer.provideMerge(GitVcsDriver.layer), |
| 166 | 167 | Layer.provideMerge( |
| 167 | 168 | SourceControlProviderRegistry.layer.pipe( |
| 168 | Layer.provide(GitHubCli.layer), | |
| 169 | Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), | |
| 169 | 170 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| 170 | 171 | ), |
| 171 | 172 | ), |
apps/server/src/sourceControl/GitLabCli.test.ts created+266| ... | ... | @@ -0,0 +1,266 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Layer } from "effect"; | |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 4 | import { afterEach, expect, vi } from "vitest"; | |
| 5 | ||
| 6 | import { VcsProcessExitError } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts"; | |
| 9 | import * as GitLabCli from "./GitLabCli.ts"; | |
| 10 | ||
| 11 | const mockedRun = vi.fn<VcsProcessShape["run"]>(); | |
| 12 | const layer = it.layer( | |
| 13 | GitLabCli.layer.pipe( | |
| 14 | Layer.provide( | |
| 15 | Layer.mock(VcsProcess)({ | |
| 16 | run: mockedRun, | |
| 17 | }), | |
| 18 | ), | |
| 19 | ), | |
| 20 | ); | |
| 21 | ||
| 22 | function processOutput(stdout: string): VcsProcessOutput { | |
| 23 | return { | |
| 24 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 25 | stdout, | |
| 26 | stderr: "", | |
| 27 | stdoutTruncated: false, | |
| 28 | stderrTruncated: false, | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 32 | afterEach(() => { | |
| 33 | mockedRun.mockReset(); | |
| 34 | }); | |
| 35 | ||
| 36 | layer("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 @@ |
| 1 | import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; | |
| 2 | ||
| 3 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import { | |
| 6 | decodeGitLabMergeRequestJson, | |
| 7 | decodeGitLabMergeRequestListJson, | |
| 8 | formatGitLabJsonDecodeError, | |
| 9 | } from "./gitLabMergeRequests.ts"; | |
| 10 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | |
| 11 | import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; | |
| 12 | ||
| 13 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 14 | ||
| 15 | export 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 | ||
| 25 | export 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 | ||
| 38 | export interface GitLabRepositoryCloneUrls { | |
| 39 | readonly nameWithOwner: string; | |
| 40 | readonly url: string; | |
| 41 | readonly sshUrl: string; | |
| 42 | } | |
| 43 | ||
| 44 | export 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 | ||
| 90 | export class GitLabCli extends Context.Service<GitLabCli, GitLabCliShape>()( | |
| 91 | "t3/source-control/GitLabCli", | |
| 92 | ) {} | |
| 93 | ||
| 94 | function isVcsProcessSpawnError(error: unknown): boolean { | |
| 95 | return ( | |
| 96 | typeof error === "object" && | |
| 97 | error !== null && | |
| 98 | "_tag" in error && | |
| 99 | error._tag === "VcsProcessSpawnError" | |
| 100 | ); | |
| 101 | } | |
| 102 | ||
| 103 | function 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 | ||
| 153 | const 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 | ||
| 160 | const RawGitLabDefaultBranchSchema = Schema.Struct({ | |
| 161 | default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 162 | }); | |
| 163 | ||
| 164 | function 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 | ||
| 174 | function 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 | ||
| 192 | function 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 | ||
| 205 | function normalizeHeadSelector(headSelector: string): string { | |
| 206 | const trimmed = headSelector.trim(); | |
| 207 | const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed); | |
| 208 | return ownerBranch?.[1]?.trim() || trimmed; | |
| 209 | } | |
| 210 | ||
| 211 | function sourceRefName(input: { | |
| 212 | readonly headSelector: string; | |
| 213 | readonly source?: SourceControlRefSelector; | |
| 214 | }): string { | |
| 215 | return input.source?.refName ?? normalizeHeadSelector(input.headSelector); | |
| 216 | } | |
| 217 | ||
| 218 | function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null { | |
| 219 | return source?.repository ?? source?.owner ?? null; | |
| 220 | } | |
| 221 | ||
| 222 | function 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 | ||
| 231 | export 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 | ||
| 368 | export const layer = Layer.effect(GitLabCli, make()); |
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Layer, Option } from "effect"; | |
| 3 | ||
| 4 | import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts"; | |
| 5 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | |
| 6 | ||
| 7 | function makeProvider(gitlab: Partial<GitLabCliShape>) { | |
| 8 | return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab))); | |
| 9 | } | |
| 10 | ||
| 11 | it.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 | ||
| 49 | it.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 | ||
| 75 | it.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 @@ |
| 1 | import { Effect, Layer, Option } from "effect"; | |
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; | |
| 5 | import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; | |
| 6 | ||
| 7 | function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { | |
| 8 | return new SourceControlProviderError({ | |
| 9 | provider: "gitlab", | |
| 10 | operation, | |
| 11 | detail: cause.detail, | |
| 12 | cause, | |
| 13 | }); | |
| 14 | } | |
| 15 | ||
| 16 | function 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 | ||
| 38 | function 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 | ||
| 52 | export 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 | ||
| 106 | export 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 | 94 | }, |
| 95 | 95 | { |
| 96 | 96 | kind: "gitlab", |
| 97 | implemented: false, | |
| 97 | implemented: true, | |
| 98 | 98 | status: "missing", |
| 99 | 99 | auth: "unknown", |
| 100 | 100 | account: Option.none(), |
apps/server/src/sourceControl/SourceControlDiscovery.ts+1-1| ... | ... | @@ -83,7 +83,7 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ |
| 83 | 83 | versionArgs: ["--version"], |
| 84 | 84 | authArgs: ["auth", "status"], |
| 85 | 85 | parseAuth: parseGitLabAuth, |
| 86 | implemented: false, | |
| 86 | implemented: true, | |
| 87 | 87 | installHint: |
| 88 | 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 | 2 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { GitHubCli } from "./GitHubCli.ts"; |
| 5 | import { GitLabCli } from "./GitLabCli.ts"; | |
| 5 | 6 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 6 | 7 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; |
| 7 | 8 | import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; |
| ... | ... | @@ -50,7 +51,9 @@ function makeRegistry(input: { |
| 50 | 51 | }); |
| 51 | 52 | |
| 52 | 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 | } |
| 56 | 59 | |
| ... | ... | @@ -78,27 +81,16 @@ it.effect("routes directly by provider kind for remote-first workflows", () => |
| 78 | 81 | }), |
| 79 | 82 | ); |
| 80 | 83 | |
| 81 | it.effect( | |
| 82 | "detects GitLab remotes and returns an unsupported provider until one is registered", | |
| 83 | () => | |
| 84 | Effect.gen(function* () { | |
| 85 | const registry = yield* makeRegistry({ | |
| 86 | remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], | |
| 87 | }); | |
| 88 | ||
| 89 | const provider = yield* registry.resolve({ cwd: "/repo" }); | |
| 90 | ||
| 91 | assert.strictEqual(provider.kind, "gitlab"); | |
| 92 | const error = yield* Effect.flip( | |
| 93 | provider.listChangeRequests({ | |
| 94 | cwd: "/repo", | |
| 95 | headSelector: "feature/source-control", | |
| 96 | state: "open", | |
| 97 | }), | |
| 98 | ); | |
| 99 | ||
| 100 | assert.strictEqual(error.provider, "gitlab"); | |
| 101 | }), | |
| 84 | it.effect("routes GitLab remotes to the GitLab provider", () => | |
| 85 | Effect.gen(function* () { | |
| 86 | const registry = yield* makeRegistry({ | |
| 87 | remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], | |
| 88 | }); | |
| 89 | ||
| 90 | const provider = yield* registry.resolve({ cwd: "/repo" }); | |
| 91 | ||
| 92 | assert.strictEqual(provider.kind, "gitlab"); | |
| 93 | }), | |
| 102 | 94 | ); |
| 103 | 95 | |
| 104 | 96 | it.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 | 9 | type SourceControlProviderShape, |
| 10 | 10 | } from "./SourceControlProvider.ts"; |
| 11 | 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 12 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | |
| 12 | 13 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; |
| 13 | 14 | |
| 14 | 15 | const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; |
| ... | ... | @@ -151,11 +152,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 151 | 152 | |
| 152 | 153 | export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { |
| 153 | 154 | const github = yield* GitHubSourceControlProvider.make(); |
| 155 | const gitlab = yield* GitLabSourceControlProvider.make(); | |
| 154 | 156 | return yield* makeWithProviders([ |
| 155 | 157 | { |
| 156 | 158 | kind: "github", |
| 157 | 159 | provider: github, |
| 158 | 160 | }, |
| 161 | { | |
| 162 | kind: "gitlab", | |
| 163 | provider: gitlab, | |
| 164 | }, | |
| 159 | 165 | ]); |
| 160 | 166 | }); |
| 161 | 167 |
apps/server/src/sourceControl/gitLabMergeRequests.ts created+148| ... | ... | @@ -0,0 +1,148 @@ |
| 1 | import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; | |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | |
| 4 | ||
| 5 | export 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 | ||
| 18 | const 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 | ||
| 32 | const 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 | ||
| 46 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 47 | const trimmed = value?.trim() ?? ""; | |
| 48 | return trimmed.length > 0 ? trimmed : null; | |
| 49 | } | |
| 50 | ||
| 51 | function 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 | ||
| 64 | function 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 | ||
| 81 | function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null { | |
| 82 | const [owner] = pathWithNamespace?.split("/") ?? []; | |
| 83 | return trimOptionalString(owner); | |
| 84 | } | |
| 85 | ||
| 86 | function 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 | ||
| 113 | const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 114 | const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); | |
| 115 | const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); | |
| 116 | ||
| 117 | export const formatGitLabJsonDecodeError = formatSchemaError; | |
| 118 | ||
| 119 | export 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 | ||
| 140 | export 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 | 55 | hasUpstream: boolean; |
| 56 | 56 | aheadCount: number; |
| 57 | 57 | behindCount: number; |
| 58 | aheadOfDefaultCount: number; | |
| 58 | 59 | } |
| 59 | 60 | |
| 60 | 61 | export interface GitPreparedCommitContext { |
| ... | ... | @@ -133,6 +134,12 @@ export interface GitFetchRemoteBranchInput { |
| 133 | 134 | localBranch: string; |
| 134 | 135 | } |
| 135 | 136 | |
| 137 | export interface GitFetchRemoteTrackingBranchInput { | |
| 138 | cwd: string; | |
| 139 | remoteName: string; | |
| 140 | remoteBranch: string; | |
| 141 | } | |
| 142 | ||
| 136 | 143 | export interface GitSetBranchUpstreamInput { |
| 137 | 144 | cwd: string; |
| 138 | 145 | branch: string; |
| ... | ... | @@ -176,9 +183,13 @@ export interface GitVcsDriverShape { |
| 176 | 183 | input: GitFetchPullRequestBranchInput, |
| 177 | 184 | ) => Effect.Effect<void, GitCommandError>; |
| 178 | 185 | readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>; |
| 186 | readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>; | |
| 179 | 187 | readonly fetchRemoteBranch: ( |
| 180 | 188 | input: GitFetchRemoteBranchInput, |
| 181 | 189 | ) => Effect.Effect<void, GitCommandError>; |
| 190 | readonly fetchRemoteTrackingBranch: ( | |
| 191 | input: GitFetchRemoteTrackingBranchInput, | |
| 192 | ) => Effect.Effect<void, GitCommandError>; | |
| 182 | 193 | readonly setBranchUpstream: ( |
| 183 | 194 | input: GitSetBranchUpstreamInput, |
| 184 | 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 | 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 | }); |
| 106 | 151 | |
| 107 | 152 | describe("refName operations", () => { |
| ... | ... | @@ -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 | 62 | hasUpstream: false, |
| 63 | 63 | aheadCount: 0, |
| 64 | 64 | behindCount: 0, |
| 65 | aheadOfDefaultCount: 0, | |
| 65 | 66 | }); |
| 66 | 67 | |
| 67 | 68 | type TraceTailState = { |
| ... | ... | @@ -969,6 +970,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 969 | 970 | Effect.map(parseRemoteNamesInGitOrder), |
| 970 | 971 | ); |
| 971 | 972 | |
| 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 | 982 | const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { |
| 973 | 983 | if (yield* originRemoteExists(cwd)) { |
| 974 | 984 | return "origin"; |
| ... | ... | @@ -1213,6 +1223,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1213 | 1223 | let upstreamRef: string | null = null; |
| 1214 | 1224 | let aheadCount = 0; |
| 1215 | 1225 | let behindCount = 0; |
| 1226 | let aheadOfDefaultCount = 0; | |
| 1216 | 1227 | let hasWorkingTreeChanges = false; |
| 1217 | 1228 | const changedFilesWithoutNumstat = new Set<string>(); |
| 1218 | 1229 | |
| ... | ... | @@ -1241,13 +1252,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1241 | 1252 | } |
| 1242 | 1253 | } |
| 1243 | 1254 | |
| 1244 | if (!upstreamRef && refName) { | |
| 1245 | aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe( | |
| 1246 | Effect.catch(() => Effect.succeed(0)), | |
| 1247 | ); | |
| 1255 | const fallbackAheadCount = | |
| 1256 | !upstreamRef && refName | |
| 1257 | ? yield* computeAheadCountAgainstBase(cwd, refName).pipe( | |
| 1258 | Effect.catch(() => Effect.succeed(0)), | |
| 1259 | ) | |
| 1260 | : null; | |
| 1261 | ||
| 1262 | if (fallbackAheadCount !== null) { | |
| 1263 | aheadCount = fallbackAheadCount; | |
| 1248 | 1264 | behindCount = 0; |
| 1249 | 1265 | } |
| 1250 | 1266 | |
| 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 | 1280 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); |
| 1252 | 1281 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); |
| 1253 | 1282 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); |
| ... | ... | @@ -1277,10 +1306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1277 | 1306 | return { |
| 1278 | 1307 | isRepo: true, |
| 1279 | 1308 | hasOriginRemote: hasPrimaryRemote, |
| 1280 | isDefaultBranch: | |
| 1281 | refName !== null && | |
| 1282 | (refName === defaultBranch || | |
| 1283 | (defaultBranch === null && (refName === "main" || refName === "master"))), | |
| 1309 | isDefaultBranch, | |
| 1284 | 1310 | branch: refName, |
| 1285 | 1311 | upstreamRef, |
| 1286 | 1312 | hasWorkingTreeChanges, |
| ... | ... | @@ -1292,6 +1318,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1292 | 1318 | hasUpstream: upstreamRef !== null, |
| 1293 | 1319 | aheadCount, |
| 1294 | 1320 | behindCount, |
| 1321 | aheadOfDefaultCount, | |
| 1295 | 1322 | }; |
| 1296 | 1323 | }); |
| 1297 | 1324 | |
| ... | ... | @@ -1323,6 +1350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1323 | 1350 | hasUpstream: details.hasUpstream, |
| 1324 | 1351 | aheadCount: details.aheadCount, |
| 1325 | 1352 | behindCount: details.behindCount, |
| 1353 | aheadOfDefaultCount: details.aheadOfDefaultCount, | |
| 1326 | 1354 | pr: null, |
| 1327 | 1355 | })), |
| 1328 | 1356 | ); |
| ... | ... | @@ -1461,16 +1489,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1461 | 1489 | "Cannot push because no git remote is configured for this repository.", |
| 1462 | 1490 | ); |
| 1463 | 1491 | } |
| 1492 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); | |
| 1464 | 1493 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ |
| 1465 | 1494 | "push", |
| 1466 | 1495 | "-u", |
| 1467 | 1496 | publishRemoteName, |
| 1468 | `HEAD:refs/heads/${branch}`, | |
| 1497 | `HEAD:refs/heads/${publishBranch}`, | |
| 1469 | 1498 | ]); |
| 1470 | 1499 | return { |
| 1471 | 1500 | status: "pushed" as const, |
| 1472 | 1501 | branch, |
| 1473 | upstreamBranch: `${publishRemoteName}/${branch}`, | |
| 1502 | upstreamBranch: `${publishRemoteName}/${publishBranch}`, | |
| 1474 | 1503 | setUpstream: true, |
| 1475 | 1504 | }; |
| 1476 | 1505 | } |
| ... | ... | @@ -1482,7 +1511,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1482 | 1511 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ |
| 1483 | 1512 | "push", |
| 1484 | 1513 | currentUpstream.remoteName, |
| 1485 | `HEAD:${currentUpstream.upstreamRef}`, | |
| 1514 | `HEAD:refs/heads/${currentUpstream.branchName}`, | |
| 1486 | 1515 | ]); |
| 1487 | 1516 | return { |
| 1488 | 1517 | status: "pushed" as const, |
| ... | ... | @@ -1894,6 +1923,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1894 | 1923 | }, |
| 1895 | 1924 | ); |
| 1896 | 1925 | |
| 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 | 1938 | const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => |
| 1898 | 1939 | runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ |
| 1899 | 1940 | "branch", |
| ... | ... | @@ -2075,7 +2116,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2075 | 2116 | createWorktree, |
| 2076 | 2117 | fetchPullRequestBranch, |
| 2077 | 2118 | ensureRemote, |
| 2119 | resolvePrimaryRemoteName, | |
| 2078 | 2120 | fetchRemoteBranch, |
| 2121 | fetchRemoteTrackingBranch, | |
| 2079 | 2122 | setBranchUpstream, |
| 2080 | 2123 | removeWorktree, |
| 2081 | 2124 | renameBranch, |
apps/web/src/components/GitActionsControl.logic.test.ts+62| ... | ... | @@ -280,6 +280,26 @@ describe("when: source control provider uses merge requests", () => { |
| 280 | 280 | }); |
| 281 | 281 | |
| 282 | 282 | describe("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 | 303 | it("resolveQuickAction returns disabled no-action state", () => { |
| 284 | 304 | const quick = resolveQuickAction( |
| 285 | 305 | status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), |
| ... | ... | @@ -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 | }); |
| 448 | 510 | |
| 449 | 511 | describe("when: on default ref without open PR", () => { |
apps/web/src/components/GitActionsControl.logic.ts+12-2| ... | ... | @@ -103,12 +103,12 @@ export function buildMenuItems( |
| 103 | 103 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 104 | 104 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 105 | 105 | const isBehind = gitStatus.behindCount > 0; |
| 106 | const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; | |
| 106 | 107 | const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; |
| 107 | 108 | const canCommit = !isBusy && hasChanges; |
| 108 | 109 | const canPush = |
| 109 | 110 | !isBusy && |
| 110 | 111 | hasBranch && |
| 111 | !hasChanges && | |
| 112 | 112 | !isBehind && |
| 113 | 113 | gitStatus.aheadCount > 0 && |
| 114 | 114 | (gitStatus.hasUpstream || canPushWithoutUpstream); |
| ... | ... | @@ -117,7 +117,7 @@ export function buildMenuItems( |
| 117 | 117 | hasBranch && |
| 118 | 118 | !hasChanges && |
| 119 | 119 | !hasOpenPr && |
| 120 | gitStatus.aheadCount > 0 && | |
| 120 | hasDefaultBranchDelta && | |
| 121 | 121 | !isBehind && |
| 122 | 122 | (gitStatus.hasUpstream || canPushWithoutUpstream); |
| 123 | 123 | const canOpenPr = !isBusy && hasOpenPr; |
| ... | ... | @@ -181,6 +181,7 @@ export function resolveQuickAction( |
| 181 | 181 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 182 | 182 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 183 | 183 | const isAhead = gitStatus.aheadCount > 0; |
| 184 | const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; | |
| 184 | 185 | const isBehind = gitStatus.behindCount > 0; |
| 185 | 186 | const isDiverged = isAhead && isBehind; |
| 186 | 187 | const terminology = resolveChangeRequestTerminology(gitStatus); |
| ... | ... | @@ -286,6 +287,15 @@ export function resolveQuickAction( |
| 286 | 287 | return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; |
| 287 | 288 | } |
| 288 | 289 | |
| 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 | 299 | return { |
| 290 | 300 | label: "Commit", |
| 291 | 301 | disabled: true, |
apps/web/src/components/Icons.tsx+165-16| ... | ... | @@ -15,31 +15,180 @@ export const GitHubIcon: Icon = (props) => ( |
| 15 | 15 | </svg> |
| 16 | 16 | ); |
| 17 | 17 | |
| 18 | export const GitLabIcon: Icon = (props) => ( | |
| 19 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> | |
| 20 | <path d="M22.6 9.3 20.9 4c-.2-.7-1.2-.7-1.5-.1l-2 5.4H6.6l-2-5.4c-.2-.6-1.2-.6-1.5.1L1.4 9.3c-.2.6 0 1.2.5 1.6L12 18.7l10.1-7.8c.5-.4.7-1 .5-1.6Z" /> | |
| 21 | <path d="m12 18.7 4.1-9.4H7.9L12 18.7Z" opacity=".72" /> | |
| 22 | <path d="M7.9 9.3H1.5L12 18.7 7.9 9.3ZM16.1 9.3 12 18.7l10.5-9.4h-6.4Z" opacity=".88" /> | |
| 18 | export const GitIcon: Icon = (props) => ( | |
| 19 | <svg {...props} viewBox="0 0 256 256"> | |
| 20 | <path | |
| 21 | d="M251.17 116.6 139.4 4.82a16.49 16.49 0 0 0-23.31 0l-23.21 23.2 29.44 29.45a19.57 19.57 0 0 1 24.8 24.96l28.37 28.38a19.61 19.61 0 1 1-11.75 11.06L137.28 95.4v69.64a19.62 19.62 0 1 1-16.13-.57V94.2a19.61 19.61 0 0 1-10.65-25.73L81.46 39.44 4.83 116.08a16.49 16.49 0 0 0 0 23.32L116.6 251.17a16.49 16.49 0 0 0 23.32 0l111.25-111.25a16.5 16.5 0 0 0 0-23.33" | |
| 22 | fill="#DE4C36" | |
| 23 | /> | |
| 23 | 24 | </svg> |
| 24 | 25 | ); |
| 25 | 26 | |
| 26 | export const AzureDevOpsIcon: Icon = (props) => ( | |
| 27 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> | |
| 28 | <path d="M21 5.2v12.7L16 22l-8-2.8V22l-4.9-6.4 4.9.4V4.8L16 2l5 3.2ZM16 6.4l-5 1.4v8.4l5 1.4V6.4Z" /> | |
| 29 | <path d="m8 8.4-5 .8v5.6l5 .8V8.4Z" opacity=".72" /> | |
| 30 | </svg> | |
| 31 | ); | |
| 27 | export const JujutsuIcon: Icon = (props) => { | |
| 28 | const groupId = `${useId().replaceAll(":", "")}-jj-a`; | |
| 32 | 29 | |
| 33 | export const BitbucketIcon: Icon = (props) => ( | |
| 34 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> | |
| 35 | <path d="M2.4 3.5c-.4 0-.7.3-.6.8L4.3 20c.1.4.4.7.8.7h13.7c.4 0 .7-.3.8-.7l2.6-15.7c.1-.5-.2-.8-.7-.8H2.4Zm12.5 11.1H9.4L8.5 8.7h7.4l-1 5.9Z" /> | |
| 30 | return ( | |
| 31 | <svg {...props} viewBox="0 0 1024 1024"> | |
| 32 | <defs> | |
| 33 | <g id={groupId}> | |
| 34 | <path | |
| 35 | d="M380.7 632.3s-14.3 56-50.3 55.5c-12.1-.2-29-10.9-47.1-26.8-34.2 82.7-98.5 239-108.5 268.6-8.9 26.5 13 52 38.2 56 36.4 5.7 49-18.1 49-18.1s13.6 40.7 37.6 39.7c29.9-1.2 34.6-33 34.6-33s11.4 23.8 26.8 23.2c38.4-1.4 41.7-102.9 43.8-135.6 3.8-57.5 6.3-135.4 7.8-190.1-9 6.7-16.5 10.7-21.3 9.9-16.1-2.7-10.6-49.3-10.6-49.3z" | |
| 36 | fill="#42acde" | |
| 37 | /> | |
| 38 | <path | |
| 39 | d="M403.7 75.1c-89.7-.3-201.5 32.6-200.6 99.6 1.3 87.4 52.2 62.4 41.2 111.2-4.9 21.6-59.9 49.8-65.5 153.8 2.8.7 5.3 1 7.2 1.2 14.2.7 29.9-26.3 29.9-26.3s9.5 38 35 38 50.7-26.3 50.7-26.3 15 30.3 39.6 32c20 1.6 34-8 34-8s.8 15 14.6 14c13.9-1.2 37.6-27.8 37.6-27.8 16.5 30.3 31.9 21 54.7 5.1 0 0 6.2 26.6 36.8 23.7 6-.6 12.3-1 18.5-1.5l.9-11.6c2-58.8-20.4-129.8-50.4-183-21.7-38.7-52.5-83.4-49.6-107.3 3.5-28.3 46.7-28.4 46.7-28.4l-48.5-16 24-37a279.7 279.7 0 0 0-56.8-5.4Z" | |
| 40 | fill="#2f9fdf" | |
| 41 | /> | |
| 42 | <path | |
| 43 | d="M215.9 414.6s-15.7 27-30 26.3c-1.8-.1-4.3-.5-7-1.2l-.3 3c-2.3 52 7.7 100.3 29.7 132 35.1 50.7 92.6 112.6 122 113 36.1.6 50.4-55.4 50.4-55.4s-5.5 46.6 10.6 49.3c16 2.6 62.4-46.7 87.9-79.5 15.8-20.5 52-82.3 58.2-138.3-6.2.4-12.4 1-18.5 1.5-30.6 3-36.8-23.7-36.8-23.7-22.8 16-38.2 25.2-54.7-5 0 0-23.7 26.5-37.6 27.6-13.8 1.1-14.6-13.8-14.6-13.8s-14 9.5-34 8c-24.5-1.8-39.6-32.1-39.6-32.1s-25.2 26.3-50.7 26.3c-25.5 0-35-38-35-38z" | |
| 44 | fill="#0e254f" | |
| 45 | /> | |
| 46 | <path | |
| 47 | d="M309.5 418.5a1.5 1.5 0 0 0-.7 0c-.6.2-1.1.8-1.5 1.8a34.7 34.7 0 0 0 4 16.6c5.5 10.6 12.4 22 23.3 27.6 4 2 8.5 3.5 12.5 3.3a36 36 0 0 0 12.5-2.3c4-2 7.6-3.8 10.8-6.2 6-4.4 7.6-6.5 7.6-9.8 0-3.3-2.7-3.5-7.6-.5-5 3-14.6 6.1-18.8 6.1-11.2 0-21.2-8-33.1-26.4-4.3-6.6-7.1-9.9-9-10.2zm174 5c-1.2-.2-2.4.4-3.8 1.7-2.3 2.3-2.7 4-2.6 10.7.5 9 4.8 19.2 11.8 24.6 4.4 3.3 13.2 6 20.4 5.8 6.8-1.1 18.8-6.6 15.3-10.3-2-1.6-5 .7-9.9.7-10.6 0-15.3-3.4-19.6-9-3.8-5-5.4-8.7-7.2-16.8-1.1-4.6-2.6-7.1-4.4-7.4zm-67.5 2.2a1 1 0 0 0-.4 0c-.4.1-.7.4-1.2.9-2.4 2-2.5 5.1-.2 12.6a41.4 41.4 0 0 0 25.3 22c7.6 1.7 14.5.6 20.3-2.7 3.8-2.4 5.3-5 4.4-7.3-4.8-2.9-7.7-1-13.3-1A39.5 39.5 0 0 1 424 435c-4.8-5.9-7-9.3-8-9.4zm-199 .8c-1 .3-1.4 2.7-1 7.2.3 5.3 1.3 8.2 4 12 3 4.5 11.5 11 14.7 11.7 2.5 1 6.9 2.3 10.7 2.3 3.8 0 8-.6 11.2-1.3 3.2-.7 6.8-2 10.3-4l1.3-.7c4-2.1 8-5.1 10.9-7.9l1.4-1.2c6.3-5.4 9.9-10.7 9.9-14.6 0-2.4-5.3-.5-14 5-16 9.8-31.1 13.2-40.9 9l-2.2-1.3-8.8-7.7a32.9 32.9 0 0 1-3.6-4.8c-1.4-2.2-2.5-3.4-3.3-3.7a1 1 0 0 0-.5 0z" | |
| 48 | fill="#71beea" | |
| 49 | /> | |
| 50 | <path | |
| 51 | d="M221.6 468.8c-.5 0-.8.1-1.1.4-.3.2-.5.6-.6 1.2a10 10 0 0 0 .6 5.1 55 55 0 0 0 28.3 28.9 42.4 42.4 0 0 0 16.4 2c6.1-.2 12.3-1.4 16.6-3.4a48.3 48.3 0 0 0 13.7-10.6c.9-1 1.3-2 1.3-2.3 0-.6 0-.9-.3-1-.3-.3-.7-.4-1.6-.4-1.8 0-4.9.8-9.7 2.3-6.2 1.9-12.7 3-18.5 3.1-7.8.2-10.2-.4-17-3.7-4.5-2.2-12-7.7-17.7-12.8a232 232 0 0 0-7.2-6.3 63 63 0 0 0-2.4-2 12 12 0 0 0-.7-.4h-.1zm214.8 13.6c-.2 0-.5.7-.7 1.8v4a32 32 0 0 0 2.7 9.9 38.5 38.5 0 0 0 49.1 19.3c5.2-2.3 10-6.4 13-10.5 1.4-2.1 2.5-4.3 3-6.2.5-1.9.4-3.6-.3-4.9-.4-.8-.9-1.4-1.3-1.7-.3-.3-.6-.3-1-.2-.9.2-2.3 1.4-4.3 3.8-5 6.2-12.8 9.5-22.6 9.4-6.2 0-11-1.1-16-4.4-5-3.2-10.4-8.6-18-17.3-.8-1-1.7-2-2.5-2.4l-1-.6c-.1 0-.2 0 0 0zm-33.6 4.2-.6.2-1.4.7a53.5 53.5 0 0 0-4 2.5c-8 5.5-15.6 8.3-23.9 8.4-8.3 0-17.2-2.7-28-8.2a42 42 0 0 0-7.3-3c-2-.5-3.1-.5-3.6-.2-.2.1-.3.3-.4.6 0 .3 0 .8.2 1.5.4 1.3 1.4 3.1 3.1 5.5 4 5.4 10.5 10.1 19.7 14 9.3 3.9 23.8 3.5 32.4-1a42 42 0 0 0 9.1-6.5c2.6-2.6 4.5-6.5 5.3-9.6.5-1.5.6-3 .4-3.8 0-.4-.2-.7-.4-.9-.1-.2-.3-.2-.6-.2zm-132.5 30.8c-.3 0-.4 0-.5.2l-.1.5c0 .5.4 1.7 1.2 3.1a87.3 87.3 0 0 0 13.5 16.2c17 15.6 34.6 20.7 49 14.4a33.3 33.3 0 0 0 19-23c.4-2 .1-3.1-.2-3.5-.2-.2-.5-.3-1-.3l-1.7.5a24 24 0 0 0-5.7 4.6c-7.7 8-14.6 12-23 11.4-8.3-.4-18-5.2-31.7-14.3-5.2-3.4-9.4-6-12.5-7.6a15.5 15.5 0 0 0-6.3-2.2zm109.4 0c-.2 0-.3 0-.5.2-.1 0-.3.3-.4.7a9 9 0 0 0 0 3.3c.4 2.9 1.6 6.7 3.5 10.3 11 20.5 29.4 30.5 46.8 25.7a37.3 37.3 0 0 0 15.6-10c4.4-4.6 7.4-9.9 7.4-13.8 0-1.3-.1-2.3-.4-2.7 0-.3-.2-.4-.3-.4h-.5c-.6 0-1.5.4-2.7 1.2a57 57 0 0 0-4.7 3.7c-9 7.7-16.9 11.2-25.1 10-8.2-1.3-16.7-7.1-27-17.7a159.8 159.8 0 0 0-10.6-9.8 9.8 9.8 0 0 0-.9-.6l-.2-.1zm-140.2 43.5c-1 0-1.6.2-1.8.5-.2.4-.3 1 0 2.1.8 2.2 3.1 5.6 7.1 9.6a78.2 78.2 0 0 0 27.4 17.9c9.7 3.8 16.7 4.1 23.4 1.1 5.5-2.5 8.4-6.1 8.4-10.4a4 4 0 0 0-.4-2c-.3-.4-.6-.6-1-.7-.9-.1-2.5.4-4.6 1.8-2.7 2-6.4 2.3-11.3 1.2-5-1.2-11.3-3.8-19.5-8-8-4-17.5-9-21.3-11-2.9-1.4-5-2-6.4-2.1zm160.1 2.8c-.6-.1-1.7.2-3.1 1-1.4 1-3.2 2.3-5.2 4.1a58 58 0 0 1-12.7 8.7c-5.9 2.7-8.6 3.2-18.3 3.2-12.6 0-17.2-1.6-29.9-11.2a21 21 0 0 0-6.4-3.8c-.5 0-.5 0-.7.2-.2.3-.3 1-.3 2 0 2.2 1.5 5.7 3.8 9.2 2.4 3.4 5.7 7 9.2 9.5 8.1 6 14.3 7.8 26.2 7.4a34 34 0 0 0 18.3-4.1 51 51 0 0 0 13.1-9.8c3.8-3.8 6.5-8 7-10.7.4-2 .4-3.5.2-4.5a2 2 0 0 0-.5-1 1 1 0 0 0-.7-.2zm-2 21.4c-.5 0-.6 0-.8.4-.2.4-.3 1.2-.3 2.4 0 3.2 1.7 7.3 4.4 11 2.7 4 6.3 7.4 10 9.5a35 35 0 0 0 21.3 4.8c11-.9 17.8-3.4 23.8-12.6 1.5-2.5 2-4.8 1.4-6.7-.2-.7-.4-1.1-.8-1.4-.3-.2-.6-.3-1.2-.3-1 0-2.6.7-4.7 2.1-6 4.3-12 6.1-19.9 6.2-8.8 0-16.3-3-26.2-11-3.6-2.8-6-4.3-7-4.4zM290 605.5c-.6.3-.9.5-1 1a9 9 0 0 0 .1 3.3c1.8 11 13 25 24.4 30.2 8.5 4 20 4 28.5 0 7.7-3.5 16-11.1 19-17.3a25 25 0 0 0 2.2-6.3c.1-.7.1-1.3 0-1.6 0-.4-.2-.6-.4-.7-.3-.2-1.3-.2-2.8.5-1.6.7-3.6 2-6 4-10 8.2-18.6 12.1-27 11.4-8.6-.7-16.9-6-26.4-15.9a82.6 82.6 0 0 0-7.6-7.1 10 10 0 0 0-2-1.4c-.5-.2-.8-.2-1-.1z" | |
| 52 | fill="#309fdf" | |
| 53 | /> | |
| 54 | <path | |
| 55 | d="M290 125.7a61.4 61.4 0 0 0-19 2.3c-19.9 5.7-33.5 15.8-36.5 52.2-3 36.6-18 45.5-20.3 46.7 14.7 26.7 38 24.5 30.1 59-2.4 11-17.6 23.5-32.8 46.5 34-27 56.4-48 99.6-100.5 0 0 39-47.6 25.1-77a52 52 0 0 0-46.1-29.2z" | |
| 56 | fill="#e9f2f1" | |
| 57 | /> | |
| 58 | <path | |
| 59 | d="M288.5 120.8c-8.1 0-16.7 1.4-25.6 5.3-22.5 9.9-25 20.3-29.1 37a96.8 96.8 0 0 0-1.4 24.6 178 178 0 0 0-16.5-31.6s-19 19.8-36.5 18.6c-22.9-1.7-34 2-44.8 9.8-25.4 18.5-21.2 43.8-21.2 43.8s17.6-1.8 40.5 1c20.3 2.7 37.4 4.3 61.3 2.6 8.5-.6 15.8-10 19.3-19 4.5-4 2.9-22 4.8-24.5 3-3.4 8-4.7 10.6-5.2 3.7-.7 4.3 1.5 4.7 2.8a31 31 0 0 0 60.5-3.2c1-4.4 9-5 15.9-4.5 7 0 .9 15.9-1.7 22.3-5.4 13.2-24.9 39-24.9 39l124.3-85s-45.8 22-61.5 20c-19.7-2.3-25.7-34.8-43.2-44-9.9-5.1-22-9.6-35.5-9.8zm1.4 9.4a48.2 48.2 0 0 1 40.6 23.5c3.7 7.1 6.2 17-.3 20.2-7.6 3.7-14.7.7-15.5-2.8a31 31 0 0 0-30.2-24.1 30 30 0 0 0-30.8 27.4c-.4 5.2-9.1 7.7-12.7 3.7-2.7-3 1.7-16.8 3.7-22 6.2-16.2 27.8-25.7 45.2-26zm5.6 27.8a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11zm-22 29a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11zm40 523.4c-2.6-1.8-15.1 23.5-20 59-6.8 49-8.3 63.2-14.2 96-6 32.7-12.6 66.3-13.4 73.7-.6 4.8-1.7 19-.6 20.1 3.2 3.3 14.7-31.7 16.8-42 2-10.3 7.7-40.7 14.2-86.9 6.4-46.1 8.5-69.6 13.1-91.5 4.7-22 6.7-26.6 4.2-28.4zm66.3-27c-4.1-1.3-10.5 24.6-13.1 37.8-5.6 28.6-6.2 64-8.4 87a1099 1099 0 0 1-12 87.4c-2.7 15-8 29.4-9.8 44.6-1 9-5.2 26-.2 27.3 5.2 1.5 15.4-36.5 20-55.7 8.1-32.9 10.7-65.6 14.2-99.3 3.3-30.7 4.6-62.8 6.5-93.6.7-18.5 5.1-34.5 2.8-35.6z" | |
| 60 | fill="#0e254f" | |
| 61 | /> | |
| 62 | </g> | |
| 63 | </defs> | |
| 64 | <rect width="1024" height="1024" rx="270" fill="#a7bcd9" /> | |
| 65 | <use href={`#${groupId}`} transform="matrix(-1 0 0 1 1024 0)" /> | |
| 66 | <use href={`#${groupId}`} /> | |
| 67 | </svg> | |
| 68 | ); | |
| 69 | }; | |
| 70 | ||
| 71 | export 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 | 81 | <path |
| 37 | d="M21 8.7h-5.1l-1 5.9H9.4l-4.2 5c.2.6.5 1.1 1.2 1.1h12.4c.4 0 .7-.3.8-.7L21 8.7Z" | |
| 38 | opacity=".72" | |
| 82 | d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z" | |
| 83 | fill="#FCA326" | |
| 84 | /> | |
| 85 | <path | |
| 86 | d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z" | |
| 87 | fill="#FC6D26" | |
| 39 | 88 | /> |
| 40 | 89 | </svg> |
| 41 | 90 | ); |
| 42 | 91 | |
| 92 | export 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 | ||
| 161 | export 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 | ||
| 43 | 192 | export const CursorIcon: Icon = ({ className, ...props }) => ( |
| 44 | 193 | <svg |
| 45 | 194 | {...props} |
apps/web/src/components/settings/SourceControlSettings.tsx+26-125| ... | ... | @@ -1,11 +1,12 @@ |
| 1 | 1 | import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | 2 | import { Option } from "effect"; |
| 3 | import { type ReactNode, useId } from "react"; | |
| 3 | import { type ReactNode } from "react"; | |
| 4 | 4 | import type { |
| 5 | 5 | SourceControlProviderKind, |
| 6 | 6 | SourceControlDiscoveryResult, |
| 7 | 7 | SourceControlProviderAuth, |
| 8 | 8 | SourceControlProviderDiscoveryItem, |
| 9 | VcsDriverKind, | |
| 9 | 10 | VcsDiscoveryItem, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | |
| ... | ... | @@ -27,7 +28,15 @@ import { |
| 27 | 28 | import { Skeleton } from "../ui/skeleton"; |
| 28 | 29 | import { Switch } from "../ui/switch"; |
| 29 | 30 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 30 | import { GitHubIcon, type Icon } from "../Icons"; | |
| 31 | import { | |
| 32 | AzureDevOpsIcon, | |
| 33 | BitbucketIcon, | |
| 34 | GitHubIcon, | |
| 35 | GitIcon, | |
| 36 | GitLabIcon, | |
| 37 | JujutsuIcon, | |
| 38 | type Icon, | |
| 39 | } from "../Icons"; | |
| 31 | 40 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 32 | 41 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 33 | 42 | |
| ... | ... | @@ -36,127 +45,6 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| 36 | 45 | sourceControlProviders: [], |
| 37 | 46 | }; |
| 38 | 47 | |
| 39 | const 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 | ||
| 60 | const 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 | ||
| 129 | const 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 | ||
| 160 | 48 | const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = { |
| 161 | 49 | github: GitHubIcon, |
| 162 | 50 | gitlab: GitLabIcon, |
| ... | ... | @@ -164,6 +52,11 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, I |
| 164 | 52 | bitbucket: BitbucketIcon, |
| 165 | 53 | }; |
| 166 | 54 | |
| 55 | const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { | |
| 56 | git: GitIcon, | |
| 57 | jj: JujutsuIcon, | |
| 58 | }; | |
| 59 | ||
| 167 | 60 | function optionLabel(value: Option.Option<string>): string | null { |
| 168 | 61 | return Option.getOrNull(value); |
| 169 | 62 | } |
| ... | ... | @@ -211,7 +104,9 @@ function SourceControlItemMark({ |
| 211 | 104 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; |
| 212 | 105 | }) { |
| 213 | 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]; | |
| 215 | 110 | |
| 216 | 111 | if (!Icon) { |
| 217 | 112 | return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />; |
| ... | ... | @@ -342,7 +237,13 @@ function SourceControlSectionSkeleton({ |
| 342 | 237 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 343 | 238 | <div className="min-w-0 flex-1 space-y-2"> |
| 344 | 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 | 247 | <Skeleton className="h-4 w-28 rounded-full" /> |
| 347 | 248 | <Skeleton className="h-5 w-14 rounded-full" /> |
| 348 | 249 | </div> |
apps/web/src/rpc/wsRpcClient.test.ts+1| ... | ... | @@ -83,6 +83,7 @@ describe("wsRpcClient", () => { |
| 83 | 83 | hasUpstream: false, |
| 84 | 84 | aheadCount: 0, |
| 85 | 85 | behindCount: 0, |
| 86 | aheadOfDefaultCount: 0, | |
| 86 | 87 | pr: null, |
| 87 | 88 | }, |
| 88 | 89 | ], |
packages/contracts/src/git.ts+1| ... | ... | @@ -219,6 +219,7 @@ const VcsStatusRemoteShape = { |
| 219 | 219 | hasUpstream: Schema.Boolean, |
| 220 | 220 | aheadCount: NonNegativeInt, |
| 221 | 221 | behindCount: NonNegativeInt, |
| 222 | aheadOfDefaultCount: Schema.optional(NonNegativeInt), | |
| 222 | 223 | pr: Schema.NullOr(VcsStatusChangeRequest), |
| 223 | 224 | }; |
| 224 | 225 |
packages/shared/src/git.ts+4| ... | ... | @@ -202,6 +202,7 @@ const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = { |
| 202 | 202 | hasUpstream: false, |
| 203 | 203 | aheadCount: 0, |
| 204 | 204 | behindCount: 0, |
| 205 | aheadOfDefaultCount: 0, | |
| 205 | 206 | pr: null, |
| 206 | 207 | }; |
| 207 | 208 | |
| ... | ... | @@ -220,6 +221,9 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { |
| 220 | 221 | hasUpstream: status.hasUpstream, |
| 221 | 222 | aheadCount: status.aheadCount, |
| 222 | 223 | behindCount: status.behindCount, |
| 224 | ...(status.aheadOfDefaultCount === undefined | |
| 225 | ? {} | |
| 226 | : { aheadOfDefaultCount: status.aheadOfDefaultCount }), | |
| 223 | 227 | pr: status.pr, |
| 224 | 228 | }; |
| 225 | 229 | } |