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