authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 00:18:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 00:18:22-07:00
log65d33177b777e014c56f01851bef446785b3aa28
treece5e398486599dd64efb1ae1c09b9b45eeebc4bf
parent57b3780770a829ff81e62d8dc658683bf51d5155
signature Signed by PGP key B5690EEEBB952194

feat(source-control): GitCafe lives in @t3tools/source-control-gitcafe (#17681)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

35 files changed, 4463 insertions(+), 17 deletions(-)

apps/server/package.json+1
......@@ -49,6 +49,7 @@
4949 "@t3tools/source-control-bitbucket": "workspace:*",
5050 "@t3tools/source-control-core": "workspace:*",
5151 "@t3tools/source-control-forgejo": "workspace:*",
52 "@t3tools/source-control-gitcafe": "workspace:*",
5253 "@t3tools/source-control-github": "workspace:*",
5354 "@t3tools/source-control-gitlab": "workspace:*",
5455 "effect": "catalog:",
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+21
......@@ -25,6 +25,7 @@ import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/Git
2525import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
2626import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2727import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
28import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
2829import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
2930import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
3031import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
......@@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: {
5354 Layer.mock(GitLabCli.GitLabCli)({}),
5455 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
5556 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
57 Layer.mock(GitCafeApi.GitCafeApi)({}),
5658 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
5759 Layer.mock(VcsProcess.VcsProcess)(input.process),
5860 ServerSourceControlHost.layer.pipe(
......@@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl
203205 auth: "unknown",
204206 account: Option.none(),
205207 },
208 {
209 kind: "gitcafe",
210 status: "missing",
211 auth: "unknown",
212 account: Option.none(),
213 },
206214 ],
207215 );
208216 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
......@@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user
266274 ) {
267275 return Effect.succeed(processOutput("azure-user@example.com\n"));
268276 }
277 if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") {
278 return Effect.succeed(
279 processOutput(
280 encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }),
281 ),
282 );
283 }
269284 return Effect.fail(
270285 new VcsProcessSpawnError({
271286 operation: input.operation,
......@@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user
341356 account: Option.some("forgejo-user"),
342357 detail: Option.none(),
343358 },
359 {
360 kind: "gitcafe",
361 auth: "authenticated",
362 account: Option.some("cafe-user"),
363 detail: Option.none(),
364 },
344365 ],
345366 );
346367 }).pipe(Effect.provide(layerTest));
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+2
......@@ -24,6 +24,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub
2424import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
2525import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2626import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
27import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
2728import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2829import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
2930
......@@ -126,6 +127,7 @@ function makeRegistry(input: {
126127 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
127128 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
128129 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
130 Layer.mock(GitCafeApi.GitCafeApi)({}),
129131 ServerConfig.layerTest(process.cwd(), {
130132 prefix: "t3-source-control-registry-test-",
131133 }).pipe(Layer.provide(NodeServices.layer)),
apps/server/src/sourceControl/builtInDrivers.ts+5
......@@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv
1515import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver";
1616import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
1717import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";
18import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
19import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials";
20import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver";
1821import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
1922import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
2023import * as GitHubDriver from "@t3tools/source-control-github/server/driver";
......@@ -32,6 +35,7 @@ const drivers = [
3235 AzureDevOpsDriver.driver,
3336 BitbucketDriver.driver,
3437 ForgejoDriver.driver,
38 GitCafeDriver.driver,
3539];
3640
3741/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */
......@@ -51,5 +55,6 @@ export const layer = Layer.mergeAll(
5155 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),
5256 BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)),
5357 ForgejoCli.layer,
58 GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)),
5459 GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)),
5560).pipe(Layer.provideMerge(ServerSourceControlHost.layer));
apps/web/src/components/CommandPalette.tsx+18-2
......@@ -187,7 +187,14 @@ import {
187187 CommandPaletteVirtualizedResults,
188188 scrollCommandPaletteRowIntoView,
189189} from "./CommandPaletteResults";
190import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
190import {
191 AzureDevOpsIcon,
192 BitbucketIcon,
193 GitCafeIcon,
194 GitHubIcon,
195 GitLabIcon,
196 ForgejoIcon,
197} from "./Icons";
191198import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
192199import { Checkbox } from "./ui/checkbox";
193200import { ProjectFavicon } from "./ProjectFavicon";
......@@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption {
252259
253260type AddProjectRemoteProviderKind = Extract<
254261 SourceControlProviderKind,
255 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
262 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"
256263>;
257264type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
258265
......@@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
278285 "forgejo",
279286 "bitbucket",
280287 "azure-devops",
288 "gitcafe",
281289];
282290const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
283291 "github",
......@@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKin
285293 "forgejo",
286294 "bitbucket",
287295 "azure-devops",
296 "gitcafe",
288297];
289298
290299function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
......@@ -299,6 +308,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
299308 return "Bitbucket";
300309 case "azure-devops":
301310 return "Azure DevOps";
311 case "gitcafe":
312 return "GitCafe";
302313 case "url":
303314 return "Git URL";
304315 }
......@@ -308,6 +319,7 @@ function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string {
308319 switch (source) {
309320 case "forgejo":
310321 case "github":
322 case "gitcafe":
311323 return "owner/repo";
312324 case "gitlab":
313325 return "group/project";
......@@ -338,6 +350,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
338350 return <BitbucketIcon className={className} />;
339351 case "azure-devops":
340352 return <AzureDevOpsIcon className={className} />;
353 case "gitcafe":
354 return <GitCafeIcon className={className} />;
341355 case "url":
342356 return <LinkIcon className={className} />;
343357 }
......@@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness(
392406 forgejo: unavailable,
393407 bitbucket: unavailable,
394408 "azure-devops": unavailable,
409 gitcafe: unavailable,
395410 };
396411
397412 if (!discovery) {
......@@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: {
20932108 "bitbucket",
20942109 "azure",
20952110 "devops",
2111 "gitcafe",
20962112 "url",
20972113 "environment",
20982114 ],
apps/web/src/components/GitActionsControl.tsx+11-1
......@@ -50,6 +50,7 @@ import {
5050 GitHubIcon,
5151 GitLabIcon,
5252 ForgejoIcon,
53 GitCafeIcon,
5354} from "~/components/Icons";
5455import { RadioGroup } from "~/components/ui/radio-group";
5556import { Spinner } from "~/components/ui/spinner";
......@@ -158,7 +159,7 @@ interface PendingDefaultBranchAction {
158159
159160type PublishProviderKind = Extract<
160161 SourceControlProviderKind,
161 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
162 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"
162163>;
163164
164165type GitActionToastId = ReturnType<typeof toastManager.add>;
......@@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [
239240 pathPlaceholder: "project/repository",
240241 Icon: AzureDevOpsIcon,
241242 },
243 {
244 value: "gitcafe",
245 label: "GitCafe",
246 description: "git.cafe",
247 host: "git.cafe",
248 pathPlaceholder: "owner/repo",
249 Icon: GitCafeIcon,
250 },
242251] as const satisfies ReadonlyArray<{
243252 readonly value: PublishProviderKind;
244253 readonly label: string;
......@@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
565574 forgejo: null,
566575 bitbucket: null,
567576 "azure-devops": null,
577 gitcafe: null,
568578 };
569579 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
570580 if (isPublishProviderKind(provider.kind)) {
apps/web/src/components/Icons.tsx+8
......@@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => (
693693 </svg>
694694);
695695
696// GitCafe's own mark, from https://git.cafe/favicon.svg, inset so the full-bleed square does not
697// read heavier than the round and cut-out marks beside it.
698export const GitCafeIcon: Icon = (props) => (
699 <svg viewBox="0 0 64 64" aria-hidden="true" {...props}>
700 <rect x="4.8" y="4.8" width="54.4" height="54.4" fill="#a78bfa" />
701 </svg>
702);
703
696704// macOS System Settings > Privacy & Security pane marks, so the SnapShot setup
697705// step points at the same icon the user is about to look for.
698706export const MacScreenRecordingIcon: Icon = (props) => {
apps/web/src/components/ThreadStatusIndicators.test.tsx+22
......@@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => {
7979 sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" },
8080 });
8181 });
82 it.each([
83 ["https://git.cafe/owner/repo/pulls/42", "gitcafe"],
84 ["https://staging.git.cafe/owner/repo/pulls/42", "gitcafe"],
85 ["https://codeberg.org/owner/repo/pulls/42", "forgejo"],
86 ["https://gitlab.example.com/acme/web/-/merge_requests/42", "gitlab"],
87 ["https://github.com/owner/repo/pull/42", "github"],
88 ])("classifies %s as %s", (url, kind) => {
89 const result = linkedPullRequestSnapshotStatus({
90 ...link,
91 url,
92 snapshot: {
93 state: "open",
94 title: "Change",
95 headBranch: "feature",
96 baseBranch: "main",
97 isDraft: false,
98 updatedAt: null,
99 syncedAt: "2026-01-03T00:00:00Z",
100 },
101 });
102 expect(result?.sourceControlProvider.kind).toBe(kind);
103 });
82104});
apps/web/src/components/ThreadStatusIndicators.tsx+14-9
......@@ -6,6 +6,7 @@ import {
66import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
77
88import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
9import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
910import {
1011 resolveEnvironmentMachineKind,
1112 type EnvironmentId,
......@@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus(
129130): LinkedThreadPullRequestStatus | null {
130131 const snapshot = link.snapshot;
131132 if (snapshot === null) return null;
132 const kind = link.url.includes("/-/merge_requests/")
133 ? "gitlab"
134 : link.url.includes("/pullrequest/")
135 ? "azure-devops"
136 : link.url.includes("/pull-requests/")
137 ? "bitbucket"
138 : link.url.includes("/pulls/")
139 ? "forgejo"
140 : "github";
133 // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first.
134 const kind =
135 detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe"
136 ? "gitcafe"
137 : link.url.includes("/-/merge_requests/")
138 ? "gitlab"
139 : link.url.includes("/pullrequest/")
140 ? "azure-devops"
141 : link.url.includes("/pull-requests/")
142 ? "bitbucket"
143 : link.url.includes("/pulls/")
144 ? "forgejo"
145 : "github";
141146 return {
142147 pr: {
143148 number: link.number,
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+2
......@@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand(
128128 : null;
129129 case "azure-devops":
130130 return `az repos pr checkout --id ${number}`;
131 case "gitcafe":
132 return `cafe pr checkout ${number}`;
131133 case "bitbucket": {
132134 if (
133135 !headRepositoryNameWithOwner ||
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts+1
......@@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {
1313 gitlab: "Open on GitLab",
1414 forgejo: "Open on Forgejo",
1515 bitbucket: "Open on Bitbucket",
16 gitcafe: "Open on GitCafe",
1617 "azure-devops": "Open on Azure DevOps",
1718};
1819
apps/web/src/components/settings/SourceControlSettings.tsx+2
......@@ -49,6 +49,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4949import {
5050 AzureDevOpsIcon,
5151 BitbucketIcon,
52 GitCafeIcon,
5253 GitHubIcon,
5354 GitIcon,
5455 GitLabIcon,
......@@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, I
8384 forgejo: ForgejoIcon,
8485 "azure-devops": AzureDevOpsIcon,
8586 bitbucket: BitbucketIcon,
87 gitcafe: GitCafeIcon,
8688};
8789
8890const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
apps/web/src/sourceControlPresentation.ts+7
......@@ -16,6 +16,7 @@ import {
1616 AzureDevOpsIcon,
1717 BitbucketIcon,
1818 ForgejoIcon,
19 GitCafeIcon,
1920 GitHubIcon,
2021 GitLabIcon,
2122} from "./components/Icons";
......@@ -62,6 +63,12 @@ export function getSourceControlPresentation(
6263 terminology: getChangeRequestTerminology(provider),
6364 Icon: BitbucketIcon,
6465 };
66 case "gitcafe":
67 return {
68 providerName: provider?.name || presentation.providerName,
69 terminology: getChangeRequestTerminology(provider),
70 Icon: GitCafeIcon,
71 };
6572 case "change-request":
6673 return {
6774 providerName: provider?.name || presentation.providerName,
apps/web/src/state/sourceControlActions.ts+1-1
......@@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
275275 );
276276 const action = useCallback(
277277 async (input: {
278 provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
278 provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe";
279279 repository: string;
280280 visibility: SourceControlRepositoryVisibility;
281281 remoteName: string;
docs/user/source-control.md+18-2
......@@ -1,7 +1,7 @@
11# Source control
22
3T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish
4repositories, create pull requests, and review changes.
3T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone
4and publish repositories, create pull requests, and review changes.
55
66## Review turn changes
77
......@@ -96,6 +96,22 @@ az extension add --name azure-devops
9696az login
9797```
9898
99### GitCafe
100
101Install the GitCafe CLI and sign in:
102
103```bash
104bun install -g @gitcafe/cli
105cafe auth login --host https://git.cafe/api
106```
107
108Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only
109sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on
110`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`.
111
112Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet.
113Merging a stack lands it through GitCafe, and updating a branch restacks the layers above it.
114
99115## Start, clone, or publish a project
100116
101117To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or
package.json+1-1
......@@ -28,7 +28,7 @@
2828 "tc": "vp run -r --concurrency-limit 2 typecheck",
2929 "lint": "vp lint --report-unused-disable-directives",
3030 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
3232 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
3333 "lint:mobile": "node scripts/mobile-native-static-check.ts",
3434 "test": "vp run -r test",
packages/contracts/src/sourceControl.ts+1
......@@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([
88 "forgejo",
99 "azure-devops",
1010 "bitbucket",
11 "gitcafe",
1112 "unknown",
1213]);
1314export type SourceControlProviderKind = typeof SourceControlProviderKind.Type;
packages/shared/src/changeRequestUrl.ts+7
......@@ -48,6 +48,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
4848 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
4949 const host = url.hostname.toLowerCase();
5050
51 // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n}
52 if (host === "git.cafe" || host === "staging.git.cafe") {
53 const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname);
54 return claim(host, match);
55 }
5156 // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
5257 if (isHostOf(host, "github.com", "github")) {
5358 const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
......@@ -96,6 +101,8 @@ export function changeRequestUrlFor(
96101 switch (kind) {
97102 case "github":
98103 return `https://${host}/${repository}/pull/${number}`;
104 case "gitcafe":
105 return `https://${host}/${repository}/pulls/${number}`;
99106 case "forgejo": {
100107 try {
101108 const remote = new URL(remoteUrl ?? "");
packages/shared/src/sourceControl.ts+30-1
......@@ -5,7 +5,14 @@ import type {
55} from "@t3tools/contracts";
66
77export interface ChangeRequestPresentation {
8 readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request";
8 readonly icon:
9 | "github"
10 | "gitlab"
11 | "forgejo"
12 | "azure-devops"
13 | "bitbucket"
14 | "gitcafe"
15 | "change-request";
916 readonly providerName: string;
1017 readonly shortName: string;
1118 readonly longName: string;
......@@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
7986 urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42",
8087};
8188
89const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
90 icon: "gitcafe",
91 providerName: "GitCafe",
92 shortName: "PR",
93 longName: "pull request",
94 pluralLongName: "pull requests",
95 providerLongName: "GitCafe pull request",
96 checkoutCommandExample: "cafe pr checkout 123",
97 urlExample: "https://git.cafe/owner/repo/pulls/42",
98};
99
82100const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
83101 icon: "change-request",
84102 providerName: "source control",
......@@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation(
104122 return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;
105123 case "bitbucket":
106124 return BITBUCKET_CHANGE_REQUEST_PRESENTATION;
125 case "gitcafe":
126 return GITCAFE_CHANGE_REQUEST_PRESENTATION;
107127 case "unknown":
108128 return GENERIC_CHANGE_REQUEST_PRESENTATION;
109129 }
......@@ -205,6 +225,11 @@ function isAzureDevOpsHost(host: string): boolean {
205225 );
206226}
207227
228/** GitCafe has no self-hosted installs: production and staging are the only hosts. */
229function isGitCafeHost(host: string): boolean {
230 return host === "git.cafe" || host === "staging.git.cafe";
231}
232
208233function isBitbucketHost(host: string): boolean {
209234 return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket");
210235}
......@@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl(
218243 }
219244 const hostname = parseHostName(host);
220245
246 if (isGitCafeHost(hostname)) {
247 return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) };
248 }
249
221250 if (
222251 hostname === "codeberg.org" ||
223252 hasDnsLabel(hostname, "forgejo") ||
packages/source-control-gitcafe/package.json created+52
......@@ -0,0 +1,52 @@
1{
2 "name": "@t3tools/source-control-gitcafe",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./server/driver": {
7 "types": "./src/server/driver.ts",
8 "import": "./src/server/driver.ts"
9 },
10 "./server/gitCafeHosts": {
11 "types": "./src/server/gitCafeHosts.ts",
12 "import": "./src/server/gitCafeHosts.ts"
13 },
14 "./server/GitCafeApi": {
15 "types": "./src/server/GitCafeApi.ts",
16 "import": "./src/server/GitCafeApi.ts"
17 },
18 "./server/GitCafeCredentials": {
19 "types": "./src/server/GitCafeCredentials.ts",
20 "import": "./src/server/GitCafeCredentials.ts"
21 },
22 "./server/gitCafePullRequestJson": {
23 "types": "./src/server/gitCafePullRequestJson.ts",
24 "import": "./src/server/gitCafePullRequestJson.ts"
25 },
26 "./server/GitCafePullRequestProvider": {
27 "types": "./src/server/GitCafePullRequestProvider.ts",
28 "import": "./src/server/GitCafePullRequestProvider.ts"
29 },
30 "./server/GitCafeSourceControlProvider": {
31 "types": "./src/server/GitCafeSourceControlProvider.ts",
32 "import": "./src/server/GitCafeSourceControlProvider.ts"
33 }
34 },
35 "scripts": {
36 "typecheck": "tsc --noEmit",
37 "test": "vp test run --config ../../vite.config.ts --dir ."
38 },
39 "dependencies": {
40 "@t3tools/contracts": "workspace:*",
41 "@t3tools/provider-core": "workspace:*",
42 "@t3tools/shared": "workspace:*",
43 "@t3tools/source-control-core": "workspace:*",
44 "effect": "catalog:"
45 },
46 "devDependencies": {
47 "@effect/vitest": "catalog:",
48 "@t3tools/source-control-testing": "workspace:*",
49 "@types/node": "catalog:",
50 "vite-plus": "catalog:"
51 }
52}
packages/source-control-gitcafe/src/server/GitCafeApi.ts created+176
......@@ -0,0 +1,176 @@
1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as FetchHttpClient from "effect/http/FetchHttpClient";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11
12import * as GitCafeCredentials from "./GitCafeCredentials.ts";
13import * as GitCafeHosts from "./gitCafeHosts.ts";
14
15const NO_REDIRECT: RequestInit = { redirect: "manual" };
16const TIMEOUT = Duration.seconds(30);
17
18/**
19 * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token
20 * disables the provider, a limit pauses the host, and the rest belong to the one request.
21 */
22export const GitCafeApiFailure = Schema.Literals([
23 "missing-tool",
24 "unauthenticated",
25 "rate-limited",
26 "not-found",
27 "failed",
28]);
29
30export class GitCafeApiError extends Schema.TaggedError<GitCafeApiError>()("GitCafeApiError", {
31 host: Schema.String,
32 operation: Schema.String,
33 /** GitCafe's HTTP status; null when no answer arrived. */
34 status: Schema.NullOr(Schema.Int),
35 reason: GitCafeApiFailure,
36 detail: Schema.String,
37 cause: Schema.optional(Schema.Defect()),
38}) {
39 override get message(): string {
40 return `GitCafe ${this.operation} failed on ${this.host}: ${this.detail}`;
41 }
42}
43
44export interface GitCafeApiRequest {
45 readonly host: string;
46 readonly operation: string;
47 readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
48 /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */
49 readonly path: string;
50 readonly body?: unknown;
51}
52
53/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */
54export class GitCafeApi extends Context.Service<
55 GitCafeApi,
56 {
57 /** The body of a successful answer. */
58 readonly request: (input: GitCafeApiRequest) => Effect.Effect<string, GitCafeApiError>;
59 }
60>()("@t3tools/source-control-gitcafe/server/GitCafeApi") {}
61
62/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */
63const decodeProblem = Schema.decodeUnknownOption(
64 Schema.fromJsonString(
65 Schema.Struct({
66 title: Schema.optional(Schema.String),
67 detail: Schema.optional(Schema.String),
68 }),
69 ),
70);
71
72const reasonForStatus = (status: number): typeof GitCafeApiFailure.Type =>
73 status === 401
74 ? "unauthenticated"
75 : status === 429
76 ? "rate-limited"
77 : status === 404
78 ? "not-found"
79 : "failed";
80
81const MAX_PROBLEM_TEXT = 300;
82const boundedProblemText = (text: string | undefined) => {
83 const line = text?.replace(/\s+/gu, " ").trim();
84 if (!line) return undefined;
85 return line.length > MAX_PROBLEM_TEXT ? `${line.slice(0, MAX_PROBLEM_TEXT)}…` : line;
86};
87
88const credentialFailure = (
89 error: GitCafeCredentials.GitCafeCredentialUnavailableError,
90): typeof GitCafeApiFailure.Type =>
91 error._tag === "GitCafeCliMissingError"
92 ? "missing-tool"
93 : error._tag === "GitCafeNotSignedInError"
94 ? "unauthenticated"
95 : "failed";
96
97/** @public Service construction is part of the canonical Effect module API. */
98export const make = Effect.gen(function* () {
99 const httpClient = yield* HttpClient.HttpClient;
100 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
101
102 const request = Effect.fn("GitCafeApi.request")(function* (input: GitCafeApiRequest) {
103 const fail = (
104 fields: Pick<GitCafeApiError, "status" | "reason" | "detail"> & { cause?: unknown },
105 ) =>
106 new GitCafeApiError({
107 host: input.host,
108 operation: input.operation,
109 status: fields.status,
110 reason: fields.reason,
111 detail: fields.detail,
112 ...(fields.cause === undefined ? {} : { cause: fields.cause }),
113 });
114 // The host comes from a remote URL, so a token never leaves for one GitCafe does not own.
115 const host = GitCafeHosts.gitCafeHost(input.host);
116 if (host === null)
117 return yield* fail({
118 status: null,
119 reason: "failed",
120 detail: `GitCafe lives on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}, not ${input.host}.`,
121 });
122 yield* Effect.annotateCurrentSpan({
123 "gitcafe.host": host,
124 "gitcafe.operation": input.operation,
125 "http.request.method": input.method ?? "GET",
126 });
127 const credential = yield* credentials
128 .get(host)
129 .pipe(
130 Effect.mapError((cause) =>
131 fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }),
132 ),
133 );
134 const base = HttpClientRequest.make(input.method ?? "GET")(
135 `https://${host}/api${input.path}`,
136 ).pipe(
137 HttpClientRequest.bearerToken(Redacted.value(credential.token)),
138 HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }),
139 );
140 const { status, text } = yield* Effect.gen(function* () {
141 const response = yield* httpClient
142 .execute(
143 input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)),
144 )
145 .pipe(
146 // The client's own span would record the query string, which can carry branch names.
147 Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
148 // fetch would follow a redirect itself and keep the bearer token on a same-origin hop;
149 // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure.
150 Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT),
151 );
152 return { status: response.status, text: yield* response.text };
153 }).pipe(
154 Effect.timeout(TIMEOUT),
155 Effect.mapError((cause) =>
156 fail({ status: null, reason: "failed", detail: "Could not reach GitCafe.", cause }),
157 ),
158 );
159 yield* Effect.annotateCurrentSpan({ "http.response.status_code": status });
160 if (status >= 200 && status < 300) return text;
161 // The source may hold a newer token than the one that was refused.
162 if (status === 401) yield* credentials.invalidate(host);
163 const problem = Option.getOrUndefined(decodeProblem(text));
164 return yield* fail({
165 status,
166 reason: reasonForStatus(status),
167 // GitCafe's own explanation is what the user acts on; it is kept short and single-line.
168 detail:
169 boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`,
170 });
171 });
172
173 return GitCafeApi.of({ request });
174});
175
176export const layer = Layer.effect(GitCafeApi, make);
packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts created+155
......@@ -0,0 +1,155 @@
1/**
2 * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates:
3 *
4 * - `class GitCafeCredentials extends Context.Service` keyed
5 * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with
6 * `get(host) => Effect<GitCafeCredential, GitCafeCredentialUnavailableError>` and
7 * `invalidate(host) => Effect<void>`.
8 * - `interface GitCafeCredential { host; token: Redacted<string>; source: "env" | "cafe" }`.
9 * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`,
10 * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`).
11 * - `environmentToken(host, env): string | null` — `CAFE_TOKEN` only for the host `CAFE_HOST`
12 * names (git.cafe when unset).
13 * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and
14 * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`.
15 */
16import { assert, describe, it } from "@effect/vitest";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as PlatformError from "effect/PlatformError";
20import * as Redacted from "effect/Redacted";
21import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
24import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
25
26import * as GitCafeCredentials from "./GitCafeCredentials.ts";
27
28const processOutput = TestSourceControlHost.processOutput;
29
30function harness(
31 run: SourceControlHost.SourceControlHost["Service"]["process"]["run"],
32 env: NodeJS.ProcessEnv = {},
33) {
34 const calls: Array<SourceControlHost.SourceControlProcessInput> = [];
35 const layer = GitCafeCredentials.layer.pipe(
36 Layer.provide(
37 TestSourceControlHost.layer({
38 process: {
39 run: (input) => {
40 calls.push(input);
41 return run(input);
42 },
43 },
44 }),
45 ),
46 Layer.provide(Layer.succeed(HostProcess.Environment, env)),
47 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
48 );
49 return { layer, calls };
50}
51
52describe("GitCafeCredentials", () => {
53 it("hands CAFE_TOKEN only to the host cafe itself targets", () => {
54 const token = { CAFE_TOKEN: "env-token" };
55 assert.strictEqual(GitCafeCredentials.environmentToken("git.cafe", token), "env-token");
56 assert.isNull(GitCafeCredentials.environmentToken("staging.git.cafe", token));
57 for (const CAFE_HOST of ["https://staging.git.cafe/api", "staging.git.cafe"]) {
58 const staging = { ...token, CAFE_HOST };
59 assert.strictEqual(
60 GitCafeCredentials.environmentToken("staging.git.cafe", staging),
61 "env-token",
62 );
63 assert.isNull(GitCafeCredentials.environmentToken("git.cafe", staging));
64 }
65 assert.isNull(GitCafeCredentials.environmentToken("git.cafe", { CAFE_TOKEN: " " }));
66 });
67
68 it.effect("prefers CAFE_TOKEN without running cafe", () => {
69 const { layer, calls } = harness(() => Effect.die("cafe must not run"), {
70 CAFE_TOKEN: "env-token",
71 });
72 return Effect.gen(function* () {
73 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
74 const credential = yield* credentials.get("git.cafe");
75 assert.strictEqual(Redacted.value(credential.token), "env-token");
76 assert.strictEqual(credential.source, "env");
77 assert.deepStrictEqual(calls, []);
78 }).pipe(Effect.provide(layer));
79 });
80
81 it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => {
82 const { layer, calls } = harness(() =>
83 Effect.succeed(
84 processOutput("protocol=https\nhost=staging.git.cafe\nusername=alice\npassword=gct_1\n"),
85 ),
86 );
87 return Effect.gen(function* () {
88 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
89 // A token for staging is never taken from an environment that targets production.
90 const credential = yield* credentials.get("Staging.Git.Cafe");
91 assert.strictEqual(Redacted.value(credential.token), "gct_1");
92 assert.strictEqual(credential.source, "cafe");
93 yield* credentials.get("staging.git.cafe");
94 assert.strictEqual(calls.length, 1);
95 assert.strictEqual(calls[0]?.command, "cafe");
96 assert.deepStrictEqual(calls[0]?.args, [
97 "auth",
98 "http",
99 "credential",
100 "--host",
101 "https://staging.git.cafe/api",
102 "get",
103 ]);
104 assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n");
105 // A refused token is asked for again rather than reused.
106 yield* credentials.invalidate("staging.git.cafe");
107 yield* credentials.get("staging.git.cafe");
108 assert.strictEqual(calls.length, 2);
109 }).pipe(Effect.provide(layer));
110 });
111
112 it.effect.each([
113 [
114 "a missing CLI",
115 Effect.fail(
116 new VcsProcessSpawnError({
117 operation: "GitCafeCredentials.get",
118 command: "cafe",
119 cwd: "/server",
120 cause: PlatformError.systemError({
121 _tag: "NotFound",
122 module: "ChildProcess",
123 method: "spawn",
124 }),
125 }),
126 ),
127 "GitCafeCliMissingError",
128 ],
129 [
130 "a helper with no login",
131 Effect.fail(
132 new VcsProcessExitError({
133 operation: "GitCafeCredentials.get",
134 command: "cafe",
135 cwd: "/server",
136 exitCode: 1,
137 detail: "not signed in",
138 }),
139 ),
140 "GitCafeNotSignedInError",
141 ],
142 [
143 "an answer without a password",
144 Effect.succeed(processOutput("username=alice\n")),
145 "GitCafeNotSignedInError",
146 ],
147 ] as const)("reports %s", ([, result, tag]) => {
148 const { layer } = harness(() => result);
149 return Effect.gen(function* () {
150 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
151 const error = yield* credentials.get("git.cafe").pipe(Effect.flip);
152 assert.strictEqual(error._tag, tag);
153 }).pipe(Effect.provide(layer));
154 });
155});
packages/source-control-gitcafe/src/server/GitCafeCredentials.ts created+166
......@@ -0,0 +1,166 @@
1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as PlatformError from "effect/PlatformError";
9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
13
14/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */
15const TOKEN_TTL = Duration.minutes(5);
16/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */
17const MISSING_TTL = Duration.seconds(10);
18
19export interface GitCafeCredential {
20 readonly host: string;
21 readonly token: Redacted.Redacted<string>;
22 readonly source: "env" | "cafe";
23}
24
25/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */
26export class GitCafeCliMissingError extends Schema.TaggedError<GitCafeCliMissingError>()(
27 "GitCafeCliMissingError",
28 { host: Schema.String },
29) {
30 override get message(): string {
31 return `No GitCafe credential for ${this.host}: set CAFE_TOKEN, or install the GitCafe CLI and run \`cafe auth login\`.`;
32 }
33}
34
35/** `cafe` is installed but holds no login for the host. */
36export class GitCafeNotSignedInError extends Schema.TaggedError<GitCafeNotSignedInError>()(
37 "GitCafeNotSignedInError",
38 { host: Schema.String },
39) {
40 override get message(): string {
41 return `No GitCafe credential for ${this.host}: run \`cafe auth login --host https://${this.host}/api\`.`;
42 }
43}
44
45/** `cafe` timed out or failed for a reason other than having no login. */
46export class GitCafeCliFailedError extends Schema.TaggedError<GitCafeCliFailedError>()(
47 "GitCafeCliFailedError",
48 { host: Schema.String, cause: Schema.Defect() },
49) {
50 override get message(): string {
51 return `The GitCafe CLI could not hand over a credential for ${this.host}. Check \`cafe auth status\` on the server.`;
52 }
53}
54
55export type GitCafeCredentialUnavailableError =
56 | GitCafeCliMissingError
57 | GitCafeNotSignedInError
58 | GitCafeCliFailedError;
59
60/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */
61export class GitCafeCredentials extends Context.Service<
62 GitCafeCredentials,
63 {
64 readonly get: (
65 host: string,
66 ) => Effect.Effect<GitCafeCredential, GitCafeCredentialUnavailableError>;
67 /** Drops the held token after GitCafe refused it, so the next read asks its source again. */
68 readonly invalidate: (host: string) => Effect.Effect<void>;
69 }
70>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {}
71
72const normalizeHost = (host: string) => host.trim().toLowerCase();
73
74/**
75 * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by
76 * default): a remote URL picks the host, and must not steer a production token to staging.
77 */
78export function environmentToken(
79 host: string,
80 env: Readonly<Record<string, string | undefined>>,
81): string | null {
82 const configured = env.CAFE_HOST?.trim();
83 const configuredHost = configured
84 ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host
85 : "git.cafe";
86 if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null;
87 return env.CAFE_TOKEN?.trim() || null;
88}
89
90/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */
91function credentialPassword(output: string): string | null {
92 for (const line of output.split(/\r?\n/u)) {
93 if (line.startsWith("password=")) return line.slice("password=".length).trim() || null;
94 }
95 return null;
96}
97
98/** @public Service construction is part of the canonical Effect module API. */
99export const make = Effect.gen(function* () {
100 const { process } = yield* SourceControlHost.SourceControlHost;
101 const environment = yield* HostProcess.Environment;
102 const workingDirectory = yield* HostProcess.WorkingDirectory;
103
104 /**
105 * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand
106 * the stored login to another program, and answers over stdin/stdout so the token never
107 * reaches an argument list or a log.
108 */
109 const fromCafe = (host: string) =>
110 process
111 .run({
112 operation: "GitCafeCredentials.get",
113 command: "cafe",
114 args: ["auth", "http", "credential", "--host", `https://${host}/api`, "get"],
115 cwd: workingDirectory,
116 stdin: `protocol=https\nhost=${host}\n\n`,
117 env: { CAFE_NO_UPDATE_CHECK: "1" },
118 timeoutMs: 10_000,
119 })
120 .pipe(
121 Effect.mapError((error) =>
122 error._tag === "VcsProcessSpawnError" &&
123 error.cause instanceof PlatformError.PlatformError &&
124 error.cause.reason._tag === "NotFound"
125 ? new GitCafeCliMissingError({ host })
126 : error._tag === "VcsProcessExitError"
127 ? new GitCafeNotSignedInError({ host })
128 : new GitCafeCliFailedError({ host, cause: error }),
129 ),
130 Effect.map((output) => credentialPassword(output.stdout)),
131 Effect.filterOrFail(
132 (token): token is string => token !== null,
133 () => new GitCafeNotSignedInError({ host }),
134 ),
135 );
136
137 const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) {
138 const fromEnv = environmentToken(host, environment);
139 const token = fromEnv ?? (yield* fromCafe(host));
140 return {
141 host,
142 token: Redacted.make(token),
143 source: fromEnv !== null ? "env" : "cafe",
144 } satisfies GitCafeCredential;
145 });
146
147 const cache = yield* Cache.makeWith(lookup, {
148 capacity: 4,
149 // A transient failure (a timeout, a locked keyring) is asked again on the next read.
150 timeToLive: (exit) =>
151 Exit.isSuccess(exit)
152 ? TOKEN_TTL
153 : Exit.findErrorOption(exit).pipe(
154 Option.exists((error) => error._tag === "GitCafeCliFailedError"),
155 )
156 ? Duration.zero
157 : MISSING_TTL,
158 });
159
160 return GitCafeCredentials.of({
161 get: (host) => Cache.get(cache, normalizeHost(host)),
162 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),
163 });
164});
165
166export const layer = Layer.effect(GitCafeCredentials, make);
packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts created+751
......@@ -0,0 +1,751 @@
1/**
2 * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer
3 * supports. Pass 2 creates:
4 *
5 * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`)
6 * with `request({ host, operation, method?, path, body? }) => Effect<string, GitCafeApiError>`
7 * (the response body), sending `https://<host>/api<path>` with the `GitCafeCredentials` bearer
8 * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`.
9 * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited.
10 * - `./GitCafePullRequestProvider.ts`: `make: Effect<PullRequestProviderApi, never, GitCafeApi>`
11 * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment,
12 * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by
13 * `expectedStackHeads`.
14 *
15 * Every write carries a fresh `requestId`, which GitCafe requires.
16 *
17 * Out of scope here (layer 3): review-revision fencing, async outcome polling.
18 */
19import { assert, describe, it } from "@effect/vitest";
20import * as Effect from "effect/Effect";
21import * as Context from "effect/Context";
22import * as Layer from "effect/Layer";
23import * as Schema from "effect/Schema";
24import * as FetchHttpClient from "effect/http/FetchHttpClient";
25import * as HttpClient from "effect/http/HttpClient";
26import type * as HttpClientRequest from "effect/http/HttpClientRequest";
27import * as HttpClientResponse from "effect/http/HttpClientResponse";
28import * as HostProcess from "@t3tools/shared/HostProcess";
29import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
30
31import * as GitCafeApi from "./GitCafeApi.ts";
32import * as GitCafeCredentials from "./GitCafeCredentials.ts";
33import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts";
34
35const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
36const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
37
38interface SentRequest {
39 readonly method: string;
40 /** The path below `/api`, without its query. */
41 readonly path: string;
42 readonly query: URLSearchParams;
43 readonly host: string;
44 readonly body: unknown;
45 /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */
46 readonly redirect: string | undefined;
47}
48
49/**
50 * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404
51 * problem document. Every request is recorded in order.
52 */
53function fakeGitCafe(
54 routes: Record<string, unknown | ((request: SentRequest) => unknown)>,
55 options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {},
56) {
57 const sent: Array<SentRequest> = [];
58 const client = HttpClient.make(
59 (request: HttpClientRequest.HttpClientRequest, _url, _signal, fiber) => {
60 const url = new URL(request.url);
61 assert.isTrue(url.pathname.startsWith("/api/"), url.pathname);
62 assert.strictEqual(request.headers.authorization, "Bearer env-token");
63 const entry: SentRequest = {
64 method: request.method,
65 path: url.pathname.slice("/api".length),
66 query: url.searchParams,
67 host: url.host,
68 body:
69 request.body._tag === "Uint8Array"
70 ? decodeJson(new TextDecoder().decode(request.body.body))
71 : undefined,
72 redirect: Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect,
73 };
74 sent.push(entry);
75 const route = routes[`${entry.method} ${entry.path}`];
76 const response =
77 route === undefined
78 ? new Response(encodeJson({ type: "https://cafe.sh/errors/not-found" }), { status: 404 })
79 : new Response(encodeJson(typeof route === "function" ? route(entry) : route), {
80 status: options.status ?? 200,
81 });
82 return Effect.succeed(HttpClientResponse.fromWeb(request, response));
83 },
84 );
85 const layer = GitCafeApi.layer.pipe(
86 Layer.provide(GitCafeCredentials.layer),
87 Layer.provide(TestSourceControlHost.layer()),
88 // An environment token, so no test runs `cafe`.
89 Layer.provide(
90 Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }),
91 ),
92 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
93 Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
94 );
95 const writes = () =>
96 sent.filter((request) => request.method !== "GET").map((r) => `${r.method} ${r.path}`);
97 return { layer, sent, writes };
98}
99
100const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 };
101const timestamp = "2026-09-12T12:00:00Z";
102const headOid = "0123456789abcdef0123456789abcdef01234567";
103const baseOid = "89abcdef0123456789abcdef0123456789abcdef";
104const actor = {
105 kind: "local",
106 actorId: "act_one",
107 handle: "alice",
108 displayName: null,
109 avatarUrl: "/avatars/one",
110};
111const pull = {
112 id: "pr_one",
113 number: 7,
114 title: "Change",
115 state: "open",
116 draft: false,
117 sourceBranch: "feature",
118 targetBranch: "main",
119 headOid,
120 observedBaseOid: baseOid,
121 mergeRoute: "provider",
122 author: actor,
123 createdAt: timestamp,
124 updatedAt: timestamp,
125 version: 4,
126 description: "Body",
127 closedAt: null,
128 mergedAt: null,
129 sourceRepo: null,
130 capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true },
131};
132const root = {
133 id: "root",
134 threadId: "root",
135 version: 2,
136 author: actor,
137 body: "Review",
138 path: "file.ts",
139 line: 3,
140 side: "right",
141 createdAt: timestamp,
142 commitOid: headOid,
143 resolvedAt: null,
144 capabilities: {
145 edit: true,
146 hide: false,
147 unhide: false,
148 delete: false,
149 resolve: true,
150 unresolve: false,
151 },
152};
153/**
154 * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged
155 * layer's head comes from its own pull.
156 */
157const stack = (
158 members: ReadonlyArray<{ number: number; headOid: string; state?: string }>,
159): Record<string, unknown> => ({
160 "GET /repos/owner/repo/pulls/7/stack": {
161 stack: {
162 id: "stack-one",
163 number: 3,
164 revision: 5,
165 landingBase: "main",
166 members: members.map((member, index) => ({
167 pullRequestNumber: member.number,
168 title: `Layer ${member.number}`,
169 state: member.state ?? "open",
170 draft: false,
171 sourceBranch: `layer-${member.number}`,
172 position: index + 1,
173 })),
174 },
175 },
176 ...Object.fromEntries(
177 members.map((member) => [
178 `GET /repos/owner/repo/pulls/${member.number}`,
179 { ...pull, number: member.number, headOid: member.headOid },
180 ]),
181 ),
182});
183
184/** GitCafe refuses a write without an idempotency key. */
185const assertRequestId = (body: unknown) =>
186 assert.match(
187 (body as { readonly requestId?: unknown } | undefined)?.requestId as string,
188 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
189 );
190
191describe("GitCafePullRequestProvider", () => {
192 it.effect("lists a repository's pull requests from its pulls collection", () => {
193 const server = fakeGitCafe({
194 "GET /repos/owner/repo/pulls": { items: [pull], next: null },
195 });
196 return Effect.gen(function* () {
197 const provider = yield* GitCafePullRequestProvider.make;
198 const page = yield* provider.listChangeRequests({
199 ...target,
200 state: "open",
201 involvement: "all",
202 viewer: "alice",
203 limit: 10,
204 });
205 assert.deepStrictEqual(
206 page.items.map((item) => item.number),
207 [7],
208 );
209 assert.deepStrictEqual(
210 server.sent.map((request) => `${request.method} ${request.path}`),
211 ["GET /repos/owner/repo/pulls"],
212 );
213 }).pipe(Effect.provide(server.layer));
214 });
215
216 it.effect("keeps a staging repository's requests and links on staging", () => {
217 // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target.
218 const server = fakeGitCafe(
219 { "GET /repos/owner/repo/pulls/7": pull },
220 { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } },
221 );
222 return Effect.gen(function* () {
223 const provider = yield* GitCafePullRequestProvider.make;
224 const summary = yield* provider.getChangeRequestSummary!({
225 ...target,
226 host: "staging.git.cafe",
227 });
228 assert.strictEqual(summary.url, "https://staging.git.cafe/owner/repo/pulls/7");
229 assert.isTrue(server.sent.every((request) => request.host === "staging.git.cafe"));
230 }).pipe(Effect.provide(server.layer));
231 });
232
233 it.effect("refuses a repository outside GitCafe's hosts before sending anything", () => {
234 const server = fakeGitCafe({});
235 return Effect.gen(function* () {
236 const provider = yield* GitCafePullRequestProvider.make;
237 const result = yield* provider
238 .getChangeRequest({ ...target, host: "github.com" })
239 .pipe(Effect.result);
240 assert.strictEqual(result._tag, "Failure");
241 assert.deepStrictEqual(server.sent, []);
242 }).pipe(Effect.provide(server.layer));
243 });
244
245 it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () =>
246 Effect.gen(function* () {
247 for (const [status, reason] of [
248 [401, "unauthenticated"],
249 [429, "rate-limited"],
250 ] as const) {
251 const server = fakeGitCafe(
252 { "GET /repos/owner/repo/pulls/7/stack": { stack: null } },
253 {
254 status,
255 },
256 );
257 const error = yield* GitCafePullRequestProvider.make.pipe(
258 Effect.flatMap((provider) => provider.getChangeRequestStack!(target)),
259 Effect.flip,
260 Effect.provide(server.layer),
261 );
262 assert.strictEqual(error.reason, reason);
263 }
264 }),
265 );
266
267 it.effect("never lets fetch follow a redirect with the token", () => {
268 const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7/stack": { stack: null } });
269 return GitCafePullRequestProvider.make.pipe(
270 Effect.flatMap((provider) => provider.getChangeRequestStack!(target)),
271 Effect.tap(() =>
272 Effect.sync(() =>
273 assert.deepStrictEqual(
274 server.sent.map((r) => r.redirect),
275 ["manual"],
276 ),
277 ),
278 ),
279 Effect.provide(server.layer),
280 );
281 });
282
283 it.effect("reads the stack a pull request belongs to, with each open layer's head", () => {
284 const { "GET /repos/owner/repo/pulls/6": _merged, ...routes } = stack([
285 { number: 6, headOid: baseOid, state: "merged" },
286 { number: 7, headOid },
287 ]);
288 const server = fakeGitCafe(routes);
289 return Effect.gen(function* () {
290 const provider = yield* GitCafePullRequestProvider.make;
291 const result = yield* provider.getChangeRequestStack!(target);
292 assert.strictEqual(result?.number, 3);
293 // A merged layer is not fenced on, so its pull is never read.
294 assert.deepStrictEqual(
295 result?.layers.map((layer) => [layer.number, layer.headSha]),
296 [
297 [6, undefined],
298 [7, headOid],
299 ],
300 );
301 }).pipe(Effect.provide(server.layer));
302 });
303
304 it.effect("reads activity with the host's edit and resolve permissions", () => {
305 const server = fakeGitCafe({
306 "GET /repos/owner/repo/pulls/7": pull,
307 "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null },
308 "GET /repos/owner/repo/pulls/7/reviews": { items: [], next: null },
309 "GET /repos/owner/repo/pulls/7/commits": {
310 items: [],
311 truncated: false,
312 next: null,
313 headOid,
314 },
315 "GET /repos/owner/repo/pulls/7/reactions/": { items: [], next: null },
316 });
317 return Effect.gen(function* () {
318 const provider = yield* GitCafePullRequestProvider.make;
319 const activity = yield* provider.getChangeRequestActivity(target);
320 assert.strictEqual(activity.comments[0]?.canEdit, true);
321 assert.strictEqual(activity.reviewThreads[0]?.canResolve, true);
322 assert.deepStrictEqual(server.writes(), []);
323 }).pipe(Effect.provide(server.layer));
324 });
325
326 it.effect("reads the viewer's permission to edit the pull request", () => {
327 const server = fakeGitCafe({
328 "GET /repos/owner/repo/pulls/7": {
329 ...pull,
330 capabilities: { ...pull.capabilities, edit: false },
331 },
332 });
333 return Effect.gen(function* () {
334 const provider = yield* GitCafePullRequestProvider.make;
335 const permissions = yield* provider.getViewerPermissions(target);
336 assert.strictEqual(permissions.editChangeRequest, false);
337 }).pipe(Effect.provide(server.layer));
338 });
339
340 it.effect("reads the diff from the pull request's changed files", () => {
341 const file = {
342 path: "file.ts",
343 oldPath: null,
344 status: "modified",
345 additions: 1,
346 deletions: 1,
347 binary: false,
348 };
349 const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid };
350 const server = fakeGitCafe({
351 "GET /repos/owner/repo/pulls/7": pull,
352 "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [file], next: null },
353 "POST /repos/owner/repo/pulls/7/diff-files": {
354 ...snapshot,
355 items: [
356 {
357 ...file,
358 hunks: [
359 {
360 oldStart: 1,
361 oldLines: 1,
362 newStart: 1,
363 newLines: 1,
364 lines: [
365 { origin: "-", content: "old\n" },
366 { origin: "+", content: "new\n" },
367 ],
368 },
369 ],
370 },
371 ],
372 },
373 });
374 return Effect.gen(function* () {
375 const provider = yield* GitCafePullRequestProvider.make;
376 const diff = yield* provider.getDiff(target);
377 assert.include(diff.patch, "-old\n+new\n");
378 assert.isNull(diff.nextCursor);
379 // The batched hunk read is a POST, but it writes nothing.
380 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]);
381 }).pipe(Effect.provide(server.layer));
382 });
383
384 it.effect("comments and replies to a thread on the pull request's comments", () => {
385 const written = { id: "c1", threadId: "root", version: 1 };
386 const server = fakeGitCafe({
387 "POST /repos/owner/repo/pulls/7/comments/": written,
388 "POST /repos/owner/repo/pulls/7/comments/root/replies": written,
389 });
390 return Effect.gen(function* () {
391 const provider = yield* GitCafePullRequestProvider.make;
392 yield* provider.comment({ ...target, body: "Hello" });
393 yield* provider.replyToThread({ ...target, threadId: "root", body: "Reply" });
394 assert.deepStrictEqual(server.writes(), [
395 "POST /repos/owner/repo/pulls/7/comments/",
396 "POST /repos/owner/repo/pulls/7/comments/root/replies",
397 ]);
398 assert.deepStrictEqual(
399 server.sent.map((request) => request.body),
400 [{ body: "Hello" }, { body: "Reply" }],
401 );
402 }).pipe(Effect.provide(server.layer));
403 });
404
405 it.effect("resolves a thread through its root at the root's version", () => {
406 const server = fakeGitCafe({
407 "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null },
408 "POST /repos/owner/repo/pulls/7/comments/root/resolve": {
409 id: "root",
410 threadId: "root",
411 version: 3,
412 },
413 });
414 return Effect.gen(function* () {
415 const provider = yield* GitCafePullRequestProvider.make;
416 yield* provider.setThreadResolution({ ...target, threadId: "root", resolved: true });
417 assert.deepStrictEqual(server.writes(), [
418 "POST /repos/owner/repo/pulls/7/comments/root/resolve",
419 ]);
420 assert.deepStrictEqual(server.sent.at(-1)?.body, { expectedVersion: 2 });
421 }).pipe(Effect.provide(server.layer));
422 });
423
424 it.effect("submits a review against the pull request's current head", () => {
425 const server = fakeGitCafe({
426 "GET /repos/owner/repo/pulls/7": pull,
427 "POST /repos/owner/repo/pulls/7/reviews": { id: "review-1" },
428 });
429 return Effect.gen(function* () {
430 const provider = yield* GitCafePullRequestProvider.make;
431 yield* provider.submitReview({
432 ...target,
433 verdict: "request-changes",
434 body: "Please fix",
435 comments: [],
436 });
437 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/reviews"]);
438 assert.deepInclude(server.sent.at(-1)?.body as object, {
439 verdict: "request_changes",
440 body: "Please fix",
441 commitOid: headOid,
442 });
443 assertRequestId(server.sent.at(-1)?.body);
444
445 // GitCafe rejects a null body; an empty review leaves it out.
446 yield* provider.submitReview({ ...target, verdict: "approve", body: "", comments: [] });
447 assert.notProperty(server.sent.at(-1)?.body as object, "body");
448 assertRequestId(server.sent.at(-1)?.body);
449 }).pipe(Effect.provide(server.layer));
450 });
451
452 it.effect.each([
453 ["completed", true],
454 ["failed", false],
455 ] as const)("merges synchronously and reads a %s outcome", ([state, succeeds]) => {
456 const server = fakeGitCafe({
457 "GET /repos/owner/repo/pulls/7": pull,
458 "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state, reason: "Blocked" },
459 });
460 return Effect.gen(function* () {
461 const provider = yield* GitCafePullRequestProvider.make;
462 const result = yield* provider
463 .runAction({ ...target, action: "merge", mergeMethod: "squash" })
464 .pipe(Effect.result);
465 assert.strictEqual(result._tag, succeeds ? "Success" : "Failure");
466 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/merge"]);
467 assert.deepInclude(server.sent.at(-1)?.body as object, {
468 strategy: "squash",
469 headOid,
470 expectedVersion: 4,
471 });
472 assertRequestId(server.sent.at(-1)?.body);
473 }).pipe(Effect.provide(server.layer));
474 });
475
476 it.effect(
477 "lands a stack through a layer only while its heads are the ones the reader saw",
478 () => {
479 const layers = [
480 { number: 6, headOid: baseOid },
481 { number: 7, headOid },
482 ];
483 const server = fakeGitCafe({
484 ...stack(layers),
485 "POST /repos/owner/repo/pulls/stacks/3/land-through": {
486 id: "land-1",
487 state: "completed",
488 landedCount: 2,
489 stepCount: 2,
490 stopReason: null,
491 error: null,
492 steps: [],
493 },
494 });
495 return Effect.gen(function* () {
496 const provider = yield* GitCafePullRequestProvider.make;
497 const heads = layers.map((layer) => ({ number: layer.number, headSha: layer.headOid }));
498 const stale = yield* provider
499 .runAction({
500 ...target,
501 action: "merge",
502 stackNumber: 3,
503 expectedStackHeads: [heads[0]!, { number: 7, headSha: baseOid }],
504 })
505 .pipe(Effect.result);
506 assert.strictEqual(stale._tag, "Failure");
507 assert.deepStrictEqual(server.writes(), []);
508
509 yield* provider.runAction({
510 ...target,
511 action: "merge",
512 stackNumber: 3,
513 expectedStackHeads: heads,
514 mergeMethod: "merge",
515 });
516 assert.deepStrictEqual(server.writes(), [
517 "POST /repos/owner/repo/pulls/stacks/3/land-through",
518 ]);
519 assert.deepInclude(server.sent.at(-1)?.body as object, {
520 expectedRevision: 5,
521 throughPullRequestNumber: 7,
522 strategy: "merge",
523 });
524 assertRequestId(server.sent.at(-1)?.body);
525 }).pipe(Effect.provide(server.layer));
526 },
527 );
528
529 it.effect("restacks from a layer on update-branch", () => {
530 const server = fakeGitCafe({
531 ...stack([{ number: 7, headOid }]),
532 "POST /repos/owner/repo/pulls/stacks/3/restack": {
533 id: "restack-1",
534 state: "completed",
535 stepCount: 1,
536 completedStepCount: 1,
537 pauseReason: null,
538 },
539 });
540 return Effect.gen(function* () {
541 const provider = yield* GitCafePullRequestProvider.make;
542 yield* provider.runAction({
543 ...target,
544 action: "update-branch",
545 updateMethod: "rebase",
546 stackNumber: 3,
547 expectedStackHeads: [{ number: 7, headSha: headOid }],
548 });
549 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/stacks/3/restack"]);
550 const { requestId, ...body } = server.sent.at(-1)?.body as { requestId: unknown };
551 assertRequestId({ requestId });
552 assert.deepStrictEqual(body, { expectedRevision: 5 });
553 }).pipe(Effect.provide(server.layer));
554 });
555
556 it.effect.each([
557 ["authored", "authors"],
558 ["reviewing", "reviewers"],
559 ] as const)("filters %s pull requests by the viewer's actor on GitCafe", ([involvement, key]) => {
560 const server = fakeGitCafe({
561 "GET /repos/owner/repo/filter-options": {
562 actors: [{ actorId: "act_one", handle: "Alice" }],
563 },
564 "GET /repos/owner/repo/pulls": { items: [pull], next: null },
565 });
566 return Effect.gen(function* () {
567 const provider = yield* GitCafePullRequestProvider.make;
568 const page = yield* provider.listChangeRequests({
569 ...target,
570 state: "open",
571 involvement,
572 viewer: "alice",
573 limit: 10,
574 });
575 assert.strictEqual(server.sent.at(-1)?.query.get(key), '["act_one"]');
576 assert.strictEqual(
577 page.items[0]?.reviewRequestLogins.includes("alice"),
578 involvement === "reviewing",
579 );
580 }).pipe(Effect.provide(server.layer));
581 });
582
583 it.effect("lists nothing for a viewer GitCafe has no actor for", () => {
584 const server = fakeGitCafe({
585 "GET /repos/owner/repo/filter-options": { actors: [] },
586 });
587 return Effect.gen(function* () {
588 const provider = yield* GitCafePullRequestProvider.make;
589 const page = yield* provider.listChangeRequests({
590 ...target,
591 state: "open",
592 involvement: "authored",
593 viewer: "alice",
594 limit: 10,
595 });
596 assert.deepStrictEqual(page.items, []);
597 assert.deepStrictEqual(
598 server.sent.map((request) => request.path),
599 ["/repos/owner/repo/filter-options"],
600 );
601 }).pipe(Effect.provide(server.layer));
602 });
603
604 it.effect.each(["authored", "reviewing"] as const)(
605 "lists %s unnarrowed when GitCafe can't say who the viewer is",
606 (involvement) => {
607 // No `filter-options` route: the fake answers 404, which is an ordinary failure.
608 const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } });
609 return Effect.gen(function* () {
610 const provider = yield* GitCafePullRequestProvider.make;
611 const page = yield* provider.listChangeRequests({
612 ...target,
613 state: "open",
614 involvement,
615 viewer: "alice",
616 limit: 10,
617 });
618 const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls");
619 assert.isDefined(listing);
620 assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors"));
621 // An unnarrowed row says nothing about who was asked to review it.
622 assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice");
623 }).pipe(Effect.provide(server.layer));
624 },
625 );
626
627 it.effect("reads line counts, open merge strategies and requested reviewers", () => {
628 const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid };
629 const files = (from: number, count: number) =>
630 Array.from({ length: count }, (_, index) => ({ path: `f${from + index}.ts` }));
631 let changePage = 0;
632 const server = fakeGitCafe({
633 "GET /repos/owner/repo/pulls/7": { ...pull, reviewers: [{ actor }] },
634 "GET /repos/owner/repo/pulls/7/status": {
635 merge: {
636 conflicts: "unknown",
637 fastForward: true,
638 strategies: ["merge", "squash", "rebase"],
639 blockers: [{ blockedStrategies: ["squash"] }, {}],
640 },
641 checks: { pending: 0, failing: 0, total: 0 },
642 },
643 // Checks continue on a cursor too; a failure on a later page must not be dropped.
644 [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({
645 items: [
646 {
647 name: request.query.has("after") ? "late" : "early",
648 status: "completed",
649 conclusion: request.query.has("after") ? "failure" : "success",
650 summary: null,
651 detailsUrl: null,
652 },
653 ],
654 next: request.query.has("after") ? null : "checks-1",
655 }),
656 // `/changes` counts no lines, and a pull past one page continues on a cursor.
657 "GET /repos/owner/repo/pulls/7/changes": () =>
658 changePage++ === 0
659 ? { ...snapshot, items: files(0, 2), next: "cursor-1" }
660 : { ...snapshot, items: files(2, 1), next: null },
661 "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({
662 ...snapshot,
663 items: (request.body as { paths: ReadonlyArray<string> }).paths.map((path) => ({
664 path,
665 additions: 2,
666 deletions: 1,
667 })),
668 }),
669 });
670 return Effect.gen(function* () {
671 const provider = yield* GitCafePullRequestProvider.make;
672 const detail = yield* provider.getChangeRequest(target);
673 assert.deepInclude(detail, { additions: 6, deletions: 3, changedFiles: 3 });
674 assert.strictEqual(
675 server.sent.filter((request) => request.path.endsWith("/changes"))[1]?.query.get("after"),
676 "cursor-1",
677 );
678 assert.deepStrictEqual(detail.mergeCapabilities, {
679 merge: true,
680 squash: false,
681 rebase: true,
682 });
683 assert.deepStrictEqual(
684 detail.checks.map((check) => check.name),
685 ["early", "late"],
686 );
687 // A requested reviewer shows before they have reviewed.
688 assert.deepStrictEqual(
689 detail.reviewers.map((reviewer) => reviewer.login),
690 ["alice"],
691 );
692 }).pipe(Effect.provide(server.layer));
693 });
694
695 it.effect("fences a native merge on the target branch's current commit", () => {
696 const branchOid = "fedcba9876543210fedcba9876543210fedcba98";
697 const server = fakeGitCafe({
698 "GET /repos/owner/repo/pulls/7": { ...pull, mergeRoute: "native", observedBaseOid: null },
699 "GET /repos/owner/repo/commit": { oid: branchOid },
700 "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state: "completed" },
701 });
702 return Effect.gen(function* () {
703 const provider = yield* GitCafePullRequestProvider.make;
704 yield* provider.runAction({ ...target, action: "merge" });
705 const branchRead = server.sent.find((request) => request.path === "/repos/owner/repo/commit");
706 assert.strictEqual(branchRead?.query.get("ref"), "refs/heads/main");
707 assert.deepInclude(server.sent.at(-1)?.body as object, { baseOid: branchOid, headOid });
708 }).pipe(Effect.provide(server.layer));
709 });
710
711 it.effect("refuses to drop a reaction whose id it cannot address", () => {
712 const server = fakeGitCafe({
713 "GET /repos/owner/repo/pulls/7/reactions/": {
714 items: [
715 {
716 subject: { kind: "pull_request_comment", id: "root" },
717 emoji: { kind: "unicode", value: "🚀" },
718 count: 1,
719 viewerReactionId: "../escape",
720 reactors: [actor],
721 },
722 ],
723 next: null,
724 },
725 });
726 return Effect.gen(function* () {
727 const provider = yield* GitCafePullRequestProvider.make;
728 const result = yield* provider
729 .setReaction({ ...target, subjectId: "root", content: "rocket", reacted: false })
730 .pipe(Effect.result);
731 assert.strictEqual(result._tag, "Failure");
732 assert.deepStrictEqual(server.writes(), []);
733 }).pipe(Effect.provide(server.layer));
734 });
735
736 it.effect("keeps GitCafe's explanation short and on one line", () => {
737 const server = fakeGitCafe(
738 {
739 "GET /repos/owner/repo/pulls/7": { title: "Conflict", detail: `Line\n${"x".repeat(900)}` },
740 },
741 { status: 409 },
742 );
743 return Effect.gen(function* () {
744 const provider = yield* GitCafePullRequestProvider.make;
745 const error = yield* provider.getChangeRequest(target).pipe(Effect.flip);
746 assert.notInclude(error.detail, "\n");
747 assert.isAtMost(error.detail.length, 310);
748 assert.isTrue(error.detail.startsWith("Line x"));
749 }).pipe(Effect.provide(server.layer));
750 });
751});
packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts created+867
......@@ -0,0 +1,867 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
5import {
6 NonNegativeInt,
7 TrimmedNonEmptyString,
8 type PullRequestCapabilities,
9 type PullRequestStackHead,
10} from "@t3tools/contracts";
11import {
12 PullRequestProviderError,
13 type ProviderRepositoryRef,
14 type PullRequestProviderApi,
15} from "@t3tools/source-control-core/server/PullRequestProvider";
16
17import * as GitCafeApi from "./GitCafeApi.ts";
18import * as GitCafeHosts from "./gitCafeHosts.ts";
19import * as Json from "./gitCafePullRequestJson.ts";
20
21const CAPABILITIES: PullRequestCapabilities = {
22 diff: true,
23 comment: true,
24 actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"],
25 mergeMethods: ["merge", "squash", "rebase"],
26 // Only through a stack: GitCafe's one branch update restacks every layer above it.
27 updateMethods: ["rebase"],
28 search: true,
29 stacks: true,
30 stackActions: true,
31 reactions: true,
32 labels: false,
33 // Line comments go through GitCafe's review drafts, which arrive with revision fencing.
34 review: {
35 inlineComment: false,
36 reply: true,
37 resolve: true,
38 verdicts: ["approve", "request-changes", "comment"],
39 },
40 reviewers: { request: false, listCandidates: false },
41 edit: { changeRequest: true, comment: true },
42};
43
44const PAGE_SIZE = 100;
45/** Conversation pages read before the list is reported as truncated. */
46const MAX_PAGES = 10;
47/** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */
48const DIFF_FILES_BATCH = 64;
49/**
50 * Line totals cost one hunk read per batch, so a pull request larger than this reports none
51 * rather than reading every file.
52 */
53const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4;
54/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */
55const MAX_CHANGE_PAGES = 20;
56
57const chunk = <A>(items: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> =>
58 Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
59 items.slice(index * size, (index + 1) * size),
60 );
61
62/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */
63const detailablePaths = (files: ReadonlyArray<Json.GitCafeDiffFile>) =>
64 files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path);
65
66/**
67 * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without
68 * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows
69 * GitCafe's own reason.
70 */
71const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) =>
72 status.merge.permitted !== false &&
73 status.merge.strategies.includes(strategy) &&
74 !(status.merge.blockers ?? []).some((blocker) =>
75 (blocker.blockedStrategies ?? []).includes(strategy),
76 );
77
78const Id = TrimmedNonEmptyString.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u));
79const isId = Schema.is(Id);
80const Principal = Schema.Struct({ handle: TrimmedNonEmptyString });
81const Written = Schema.Struct({ id: Id });
82const Versioned = Schema.Struct({ version: NonNegativeInt });
83const CommentDetail = Schema.Struct({ id: Id, version: NonNegativeInt });
84const Status = Schema.Struct({
85 merge: Schema.Struct({
86 conflicts: Schema.Literals(["unknown", "conflicting"]),
87 fastForward: Schema.NullOr(Schema.Boolean),
88 strategies: Schema.Array(Schema.String),
89 permitted: Schema.optional(Schema.Boolean),
90 blockers: Schema.optional(
91 Schema.Array(
92 Schema.Struct({ blockedStrategies: Schema.optional(Schema.Array(Schema.String)) }),
93 ),
94 ),
95 }),
96 checks: Schema.Struct({
97 pending: NonNegativeInt,
98 failing: NonNegativeInt,
99 total: NonNegativeInt,
100 }),
101});
102const FilterOptions = Schema.Struct({
103 actors: Schema.Array(
104 Schema.Struct({ actorId: TrimmedNonEmptyString, handle: TrimmedNonEmptyString }),
105 ),
106});
107const encodeActorIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
108const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString });
109const Snapshot = {
110 version: NonNegativeInt,
111 headOid: TrimmedNonEmptyString,
112 comparisonBaseOid: TrimmedNonEmptyString,
113};
114const Changes = Schema.Struct({
115 ...Snapshot,
116 items: Json.GitCafeDiffFiles,
117 next: Schema.NullOr(TrimmedNonEmptyString),
118});
119const DiffFiles = Schema.Struct({ ...Snapshot, items: Json.GitCafeDiffFiles });
120const DiffCursor = Schema.Struct({
121 version: NonNegativeInt,
122 headOid: TrimmedNonEmptyString,
123 baseOid: TrimmedNonEmptyString,
124 after: TrimmedNonEmptyString,
125});
126const encodeDiffCursor = Schema.encodeSync(Schema.fromJsonString(DiffCursor));
127const decodeDiffCursor = Schema.decodeEffect(Schema.fromJsonString(DiffCursor));
128const MergeOutcome = Schema.Struct({
129 id: Schema.String,
130 state: Schema.Literals(["accepted", "completed", "failed"]),
131 reason: Schema.optional(Schema.String),
132});
133const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String });
134/** Where a stack operation stopped short; anything else is underway or done. */
135const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]);
136
137type PullRef = ProviderRepositoryRef & { readonly number: number };
138type Paged<A> = { readonly items: ReadonlyArray<A>; readonly next: string | null };
139
140/** The heads a reader saw are still every unmerged layer the action moves. */
141function stackHeadsCurrent(
142 stack: Json.GitCafeStack,
143 input: { readonly number: number; readonly merge: boolean },
144 expected: ReadonlyArray<PullRequestStackHead>,
145): boolean {
146 const members = stack.members.toSorted((a, b) => a.position - b.position);
147 const index = members.findIndex((member) => member.pullRequestNumber === input.number);
148 if (index === -1) return false;
149 const moved = (input.merge ? members.slice(0, index + 1) : members).filter(
150 (member) => member.state !== "merged",
151 );
152 return (
153 moved.length === expected.length &&
154 moved.every(
155 (member) =>
156 member.headOid !== undefined &&
157 expected.some(
158 (head) => head.number === member.pullRequestNumber && head.headSha === member.headOid,
159 ),
160 )
161 );
162}
163
164/** Lays the detailed files over the structural page, keeping its order and anything omitted. */
165const withDetails = (
166 files: ReadonlyArray<Json.GitCafeDiffFile>,
167 details: ReadonlyArray<Json.GitCafeDiffFile>,
168) => {
169 const byPath = new Map(details.map((file) => [file.path, file]));
170 return files.map((file) => byPath.get(file.path) ?? file);
171};
172
173/** @public Service construction is part of the canonical Effect module API. */
174export const make = Effect.gen(function* () {
175 const api = yield* GitCafeApi.GitCafeApi;
176
177 const failure = (operation: string, detail: string, cause?: unknown) =>
178 new PullRequestProviderError({
179 provider: "gitcafe",
180 operation,
181 reason: "failed",
182 detail,
183 ...(cause === undefined ? {} : { cause }),
184 });
185
186 /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */
187 const repositoryPath = (
188 operation: string,
189 input: ProviderRepositoryRef,
190 ): Effect.Effect<string, PullRequestProviderError> =>
191 GitCafeHosts.isGitCafeHost(input.host) &&
192 GitCafeHosts.gitCafeRepositoryPath(input.repository) === input.repository
193 ? Effect.succeed(`/repos/${input.repository}`)
194 : Effect.fail(
195 failure(
196 operation,
197 `GitCafe serves owner/name repositories on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}.`,
198 ),
199 );
200
201 const send = (
202 operation: string,
203 input: Pick<ProviderRepositoryRef, "host">,
204 request: Omit<GitCafeApi.GitCafeApiRequest, "operation" | "host">,
205 ) =>
206 api.request({ ...request, host: input.host, operation }).pipe(
207 Effect.mapError(
208 (error) =>
209 new PullRequestProviderError({
210 provider: "gitcafe",
211 operation,
212 reason: error.reason,
213 detail: error.detail,
214 cause: error,
215 }),
216 ),
217 );
218
219 const read = <S extends Schema.Codec<unknown, unknown, never, never>>(
220 operation: string,
221 input: Pick<ProviderRepositoryRef, "host">,
222 request: Omit<GitCafeApi.GitCafeApiRequest, "operation" | "host">,
223 schema: S,
224 ) =>
225 send(operation, input, request).pipe(
226 Effect.flatMap((raw) =>
227 Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
228 Effect.mapError((cause) =>
229 failure(operation, "GitCafe returned an unreadable response.", cause),
230 ),
231 ),
232 ),
233 );
234
235 const pullPath = (operation: string, input: PullRef) =>
236 repositoryPath(operation, input).pipe(Effect.map((base) => `${base}/pulls/${input.number}`));
237
238 const readPull = (operation: string, input: PullRef) =>
239 pullPath(operation, input).pipe(
240 Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)),
241 );
242
243 /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */
244 const readPages = <S extends Schema.Codec<Paged<unknown>, unknown, never, never>>(
245 operation: string,
246 input: Pick<ProviderRepositoryRef, "host">,
247 path: string,
248 schema: S,
249 ) =>
250 Effect.gen(function* () {
251 const items: Array<S["Type"]["items"][number]> = [];
252 let next: string | null = null;
253 for (let page = 0; page < MAX_PAGES; page++) {
254 const query = new URLSearchParams({ limit: String(PAGE_SIZE) });
255 if (next !== null) query.set("after", next);
256 const batch: S["Type"] = yield* read(
257 operation,
258 input,
259 { path: `${path}?${query}` },
260 schema,
261 );
262 items.push(...batch.items);
263 const previous: string | null = next;
264 next = batch.next;
265 if (next === null || next === previous || batch.items.length === 0) break;
266 }
267 return { items, next };
268 });
269
270 const readComments = (operation: string, input: PullRef) =>
271 pullPath(operation, input).pipe(
272 Effect.flatMap((path) =>
273 readPages(operation, input, `${path}/comments`, Json.GitCafeComments),
274 ),
275 );
276
277 /**
278 * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no
279 * heads, and they are what a land or restack is fenced on, so each open layer's pull is read.
280 */
281 const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* (
282 operation: string,
283 input: PullRef,
284 ) {
285 const path = yield* pullPath(operation, input);
286 const { stack } = yield* read(
287 operation,
288 input,
289 { path: `${path}/stack` },
290 Json.GitCafeStackEnvelope,
291 );
292 if (stack === null) return null;
293 const members = yield* Effect.forEach(
294 stack.members,
295 (member) =>
296 member.state === "merged"
297 ? Effect.succeed(member)
298 : readPull(operation, { ...input, number: member.pullRequestNumber }).pipe(
299 Effect.map((pull) =>
300 pull.headOid == null ? member : { ...member, headOid: pull.headOid },
301 ),
302 ),
303 { concurrency: 4 },
304 );
305 return { ...stack, members };
306 });
307
308 /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */
309 const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* (
310 input: PullRef,
311 revision: { readonly version: number; readonly headOid: string; readonly baseOid: string },
312 paths: ReadonlyArray<string>,
313 ) {
314 if (paths.length === 0) return [];
315 const path = yield* pullPath("getDiff", input);
316 const files = yield* read(
317 "getDiff",
318 input,
319 {
320 method: "POST",
321 path: `${path}/diff-files`,
322 body: { paths, expectedVersion: revision.version },
323 },
324 DiffFiles,
325 );
326 if (
327 files.version !== revision.version ||
328 files.headOid !== revision.headOid ||
329 files.comparisonBaseOid !== revision.baseOid
330 )
331 return yield* failure(
332 "getDiff",
333 "GitCafe answered from a different revision of the pull request. Refresh the diff.",
334 );
335 return files.items;
336 });
337
338 const runStackAction = Effect.fn("GitCafePullRequestProvider.runStackAction")(function* (
339 input: Parameters<PullRequestProviderApi["runAction"]>[0] & { readonly stackNumber: number },
340 ) {
341 const land = input.action === "merge";
342 const operation = land ? "landStackThrough" : "restackStack";
343 if (!land && input.action !== "update-branch")
344 return yield* failure(operation, `GitCafe cannot ${input.action} a stack.`);
345 const stack = yield* readStack(operation, input);
346 if (
347 stack === null ||
348 stack.number !== input.stackNumber ||
349 input.expectedStackHeads === undefined ||
350 !stackHeadsCurrent(stack, { number: input.number, merge: land }, input.expectedStackHeads)
351 )
352 return yield* failure(
353 operation,
354 "The stack changed since it was read. Refresh it and try again.",
355 );
356 const base = yield* repositoryPath(operation, input);
357 const outcome = yield* read(
358 operation,
359 input,
360 {
361 method: "POST",
362 path: `${base}/pulls/stacks/${stack.number}/${land ? "land-through" : "restack"}`,
363 body: land
364 ? {
365 expectedRevision: stack.revision,
366 requestId: yield* randomUuidV4,
367 throughPullRequestNumber: input.number,
368 strategy: input.mergeMethod ?? "merge",
369 }
370 : { expectedRevision: stack.revision, requestId: yield* randomUuidV4 },
371 },
372 StackOutcome,
373 );
374 if (FAILED_STACK_STATES.has(outcome.state))
375 return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`);
376 });
377
378 /**
379 * Added and deleted line totals and the file count. `/changes` lists files without counting
380 * lines, so the counts come from batched hunk reads, and a pull request past the cap reports
381 * none rather than paying for every file.
382 */
383 const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* (
384 input: PullRef,
385 version: number,
386 ) {
387 const path = yield* pullPath("getChangeRequest", input);
388 const files: Array<Json.GitCafeDiffFile> = [];
389 let revision: { version: number; headOid: string; baseOid: string } | undefined;
390 let after: string | null = null;
391 for (let page = 0; page < MAX_CHANGE_PAGES; page++) {
392 const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" });
393 if (after !== null) query.set("after", after);
394 const batch: typeof Changes.Type = yield* read(
395 "getChangeRequest",
396 input,
397 { path: `${path}/changes?${query}` },
398 Changes,
399 );
400 revision ??= {
401 version: batch.version,
402 headOid: batch.headOid,
403 baseOid: batch.comparisonBaseOid,
404 };
405 files.push(...batch.items);
406 const previous: string | null = after;
407 after = batch.next;
408 if (after === null || after === previous || batch.items.length === 0) break;
409 }
410 // A hunk read that loses a race with a push leaves the counts at zero rather than dropping
411 // the file count with them.
412 const counted =
413 files.some((file) => file.additions !== undefined) ||
414 files.length > LINE_STATS_MAX_FILES ||
415 revision === undefined
416 ? files
417 : (yield* Effect.forEach(
418 chunk(detailablePaths(files), DIFF_FILES_BATCH),
419 (paths) => readDiffFiles(input, revision, paths),
420 { concurrency: 4 },
421 ).pipe(Effect.orElseSucceed(() => [[]]))).flat();
422 return {
423 changedFiles: files.length,
424 additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0),
425 deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0),
426 };
427 });
428
429 const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* (
430 input: Parameters<PullRequestProviderApi["runAction"]>[0],
431 ) {
432 const pull = yield* readPull("merge", input);
433 if (pull.state === "merged") return;
434 if (pull.mergeRoute === "unsupported")
435 return yield* failure("merge", "This GitCafe pull request has no supported merge route.");
436 const base = yield* repositoryPath("merge", input);
437 // GitCafe observes the base only for merges it routes to the provider; a native merge is
438 // fenced on the target branch's current commit.
439 const baseOid =
440 pull.mergeRoute === "provider"
441 ? pull.observedBaseOid
442 : (yield* read(
443 "merge",
444 input,
445 {
446 path: `${base}/commit?ref=${encodeURIComponent(`refs/heads/${pull.targetBranch}`)}`,
447 },
448 BranchCommit,
449 )).oid;
450 if (pull.headOid === null || baseOid == null)
451 return yield* failure(
452 "merge",
453 "GitCafe has not reported the pull request's head and base yet. Refresh and try again.",
454 );
455 const path = yield* pullPath("merge", input);
456 const outcome = yield* read(
457 "merge",
458 input,
459 {
460 method: "POST",
461 path: `${path}/merge`,
462 body: {
463 requestId: yield* randomUuidV4,
464 expectedVersion: pull.version,
465 headOid: pull.headOid,
466 baseOid,
467 strategy: input.mergeMethod ?? "merge",
468 },
469 },
470 MergeOutcome,
471 );
472 // An accepted merge is GitCafe's to finish; the pull request's state reports when it has.
473 if (outcome.state === "failed")
474 return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request.");
475 });
476
477 const provider: PullRequestProviderApi = {
478 kind: "gitcafe",
479 capabilities: CAPABILITIES,
480 getViewer: (input) =>
481 read(
482 "getViewer",
483 { host: input.host ?? "git.cafe" },
484 { path: "/auth/principal" },
485 Principal,
486 ).pipe(Effect.map((principal) => principal.handle)),
487 listChangeRequests: Effect.fn("GitCafePullRequestProvider.listChangeRequests")(
488 function* (input) {
489 const base = yield* repositoryPath("listChangeRequests", input);
490 const involved = input.involvement === "authored" || input.involvement === "reviewing";
491 let actorId: string | undefined;
492 if (involved) {
493 // A host narrows as far as it can: when the actor list can't be read, the listing comes
494 // back unnarrowed and the page's own involvement filter does the rest. A refused token
495 // or a rate limit still fails, since the service acts on those for the whole host.
496 const options = yield* read(
497 "listChangeRequests",
498 input,
499 { path: `${base}/filter-options` },
500 FilterOptions,
501 ).pipe(
502 Effect.map(Option.some),
503 Effect.catchTags({
504 PullRequestProviderError: (error) =>
505 error.reason === "unauthenticated" || error.reason === "rate-limited"
506 ? Effect.fail(error)
507 : Effect.succeed(Option.none()),
508 }),
509 );
510 if (Option.isSome(options)) {
511 actorId = options.value.actors.find(
512 (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(),
513 )?.actorId;
514 // A viewer GitCafe knows no actor for has authored and been asked to review nothing.
515 if (actorId === undefined) return { items: [], truncated: false, continues: false };
516 }
517 }
518 const limit = Math.max(1, input.limit);
519 const items: Array<Json.GitCafePull> = [];
520 let next: string | null = null;
521 do {
522 const query = new URLSearchParams({
523 limit: String(Math.min(PAGE_SIZE, limit - items.length)),
524 sort: "newest",
525 });
526 if (input.state !== "all") query.set("state", input.state);
527 if (input.query?.trim()) query.set("q", input.query.trim());
528 if (actorId !== undefined)
529 query.set(
530 input.involvement === "reviewing" ? "reviewers" : "authors",
531 encodeActorIds([actorId]),
532 );
533 if (next !== null) query.set("after", next);
534 const page: typeof Json.GitCafePulls.Type = yield* read(
535 "listChangeRequests",
536 input,
537 { path: `${base}/pulls?${query}` },
538 Json.GitCafePulls,
539 );
540 items.push(...page.items);
541 next = page.items.length === 0 ? null : page.next;
542 } while (next !== null && items.length < limit);
543 return {
544 items: items.slice(0, limit).map((pull) => {
545 const item = Json.toChangeRequest(pull, input);
546 // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An
547 // unnarrowed listing (no actor to filter by) says nothing about who was asked.
548 return input.involvement === "reviewing" && actorId !== undefined
549 ? {
550 ...item,
551 reviewRequestLogins: [...new Set([...item.reviewRequestLogins, input.viewer])],
552 }
553 : item;
554 }),
555 truncated: next !== null,
556 continues: false,
557 };
558 },
559 ),
560 getChangeRequestSummary: (input) =>
561 readPull("getChangeRequestSummary", input).pipe(
562 Effect.map((pull) => ({
563 ...Json.toChangeRequest(pull, input),
564 closedAt: pull.closedAt,
565 mergedAt: pull.mergedAt,
566 })),
567 ),
568 getChangeRequest: Effect.fn("GitCafePullRequestProvider.getChangeRequest")(function* (input) {
569 const pull = yield* readPull("getChangeRequest", input);
570 const path = yield* pullPath("getChangeRequest", input);
571 const base = yield* repositoryPath("getChangeRequest", input);
572 const [status, checks, changes] = yield* Effect.all(
573 [
574 read("getChangeRequest", input, { path: `${path}/status` }, Status),
575 pull.headOid === null
576 ? Effect.succeed(null)
577 : readPages(
578 "getChangeRequest",
579 input,
580 `${base}/commits/${encodeURIComponent(pull.headOid)}/checks`,
581 Json.GitCafeChecks,
582 ),
583 pull.headOid === null
584 ? Effect.succeed(null)
585 : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)),
586 ],
587 { concurrency: 3 },
588 );
589 const permissions = Json.toViewerPermissions(pull);
590 return {
591 ...Json.toChangeRequest(pull, input),
592 headSha: pull.headOid,
593 body: pull.description ?? "",
594 ...(pull.sourceRepo === null
595 ? {}
596 : { headRepositoryNameWithOwner: `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` }),
597 additions: changes?.additions ?? 0,
598 deletions: changes?.deletions ?? 0,
599 changedFiles: changes?.changedFiles ?? 0,
600 closedAt: pull.closedAt,
601 mergedAt: pull.mergedAt,
602 reviewers: Json.toReviewers(pull, input.host),
603 checks: checks === null ? [] : Json.toChecks(checks),
604 ...(status.checks.total === 0
605 ? {}
606 : {
607 checksState:
608 status.checks.failing > 0
609 ? ("failing" as const)
610 : status.checks.pending > 0
611 ? ("pending" as const)
612 : ("passing" as const),
613 }),
614 mergeability:
615 status.merge.conflicts === "conflicting"
616 ? ("conflicting" as const)
617 : ("unknown" as const),
618 baseComparison:
619 status.merge.fastForward === null
620 ? ("unknown" as const)
621 : status.merge.fastForward
622 ? ("up-to-date" as const)
623 : ("behind" as const),
624 mergeCapabilities: {
625 merge: mergeStrategyOpen(status, "merge"),
626 squash: mergeStrategyOpen(status, "squash"),
627 rebase: mergeStrategyOpen(status, "rebase"),
628 },
629 viewerPermissions:
630 status.merge.permitted === false
631 ? { ...permissions, actions: permissions.actions.filter((a) => a !== "merge") }
632 : permissions,
633 };
634 }),
635 getChangeRequestStack: (input) =>
636 readStack("getChangeRequestStack", input).pipe(
637 Effect.map((stack) => (stack === null ? null : Json.toStack(stack, input))),
638 ),
639 getChangeRequestActivity: Effect.fn("GitCafePullRequestProvider.getChangeRequestActivity")(
640 function* (input) {
641 const pull = yield* readPull("getChangeRequestActivity", input);
642 const path = yield* pullPath("getChangeRequestActivity", input);
643 const operation = "getChangeRequestActivity";
644 const [comments, reviews, commits, reactions] = yield* Effect.all(
645 [
646 readComments(operation, input),
647 readPages(operation, input, `${path}/reviews`, Json.GitCafeReviews),
648 pull.headOid === null
649 ? Effect.succeed({ items: [], next: null })
650 : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits),
651 // Reactions decorate the conversation; a failed read leaves them out, not it.
652 readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe(
653 Effect.orElseSucceed(() => undefined),
654 ),
655 ],
656 { concurrency: 4 },
657 );
658 return Json.toActivity({
659 comments,
660 reviews,
661 commits: { ...commits, truncated: commits.next !== null, headOid: pull.headOid ?? "" },
662 host: input.host,
663 ...(pull.headOid === null ? {} : { headOid: pull.headOid }),
664 ...(reactions === undefined ? {} : { reactions }),
665 });
666 },
667 ),
668 getViewerPermissions: (input) =>
669 readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)),
670 getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) {
671 if (input.commit !== undefined)
672 return yield* failure("getDiff", "GitCafe commit diffs are not supported yet.");
673 const cursor =
674 input.cursor === undefined
675 ? null
676 : yield* decodeDiffCursor(input.cursor).pipe(
677 Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)),
678 );
679 const version = cursor?.version ?? (yield* readPull("getDiff", input)).version;
680 const path = yield* pullPath("getDiff", input);
681 const query = new URLSearchParams({
682 expectedVersion: String(version),
683 limit: String(DIFF_FILES_BATCH),
684 });
685 if (cursor !== null) query.set("after", cursor.after);
686 const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes);
687 if (
688 page.version !== version ||
689 (cursor !== null &&
690 (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid))
691 )
692 return yield* failure(
693 "getDiff",
694 "GitCafe answered from a different revision of the pull request. Refresh the diff.",
695 );
696 const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid };
697 const details = yield* readDiffFiles(input, revision, detailablePaths(page.items));
698 return {
699 ...Json.toDiff({ items: withDetails(page.items, details) }),
700 nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }),
701 };
702 }),
703 runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) {
704 if (input.stackNumber !== undefined)
705 return yield* runStackAction({ ...input, stackNumber: input.stackNumber });
706 switch (input.action) {
707 case "merge":
708 return yield* merge(input);
709 case "ready":
710 case "draft":
711 case "close":
712 case "reopen": {
713 const pull = yield* readPull(input.action, input);
714 const path = yield* pullPath(input.action, input);
715 yield* send(input.action, input, {
716 method: "POST",
717 path: `${path}/${input.action}`,
718 body: { expectedVersion: pull.version },
719 });
720 return;
721 }
722 default:
723 return yield* failure(input.action, `GitCafe cannot ${input.action} from T3 Code.`);
724 }
725 }),
726 updateChangeRequest: Effect.fn("GitCafePullRequestProvider.updateChangeRequest")(
727 function* (input) {
728 const pull = yield* readPull("updateChangeRequest", input);
729 const path = yield* pullPath("updateChangeRequest", input);
730 yield* read(
731 "updateChangeRequest",
732 input,
733 {
734 method: "PATCH",
735 path,
736 body: {
737 expectedVersion: pull.version,
738 ...(input.title === undefined ? {} : { title: input.title }),
739 ...(input.body === undefined ? {} : { description: input.body }),
740 },
741 },
742 Versioned,
743 );
744 },
745 ),
746 comment: (input) =>
747 pullPath("comment", input).pipe(
748 Effect.flatMap((path) =>
749 read(
750 "comment",
751 input,
752 { method: "POST", path: `${path}/comments/`, body: { body: input.body } },
753 Written,
754 ),
755 ),
756 Effect.asVoid,
757 ),
758 updateComment: Effect.fn("GitCafePullRequestProvider.updateComment")(function* (input) {
759 if (!isId(input.commentId))
760 return yield* failure("updateComment", "GitCafe comment ids are opaque slugs.");
761 const path = `${yield* pullPath("updateComment", input)}/comments/${input.commentId}`;
762 const detail = yield* read("updateComment", input, { path: `${path}/detail` }, CommentDetail);
763 yield* read(
764 "updateComment",
765 input,
766 { method: "PATCH", path, body: { body: input.body, expectedVersion: detail.version } },
767 Written,
768 );
769 }),
770 submitReview: Effect.fn("GitCafePullRequestProvider.submitReview")(function* (input) {
771 if (input.comments.length > 0)
772 return yield* failure("submitReview", "GitCafe line comments are not supported yet.");
773 const pull = yield* readPull("submitReview", input);
774 if (pull.headOid === null)
775 return yield* failure("submitReview", "GitCafe has not reported the pull request's head.");
776 const path = yield* pullPath("submitReview", input);
777 yield* read(
778 "submitReview",
779 input,
780 {
781 method: "POST",
782 path: `${path}/reviews`,
783 body: {
784 verdict: input.verdict === "request-changes" ? "request_changes" : input.verdict,
785 ...(input.body.length === 0 ? {} : { body: input.body }),
786 commitOid: pull.headOid,
787 expectedVersion: pull.version,
788 requestId: yield* randomUuidV4,
789 },
790 },
791 Written,
792 );
793 }),
794 listReviewerCandidates: () =>
795 Effect.fail(
796 failure("listReviewerCandidates", "GitCafe reviewer requests are not supported."),
797 ),
798 setReviewerRequest: () =>
799 Effect.fail(failure("setReviewerRequest", "GitCafe reviewer requests are not supported.")),
800 replyToThread: Effect.fn("GitCafePullRequestProvider.replyToThread")(function* (input) {
801 if (!isId(input.threadId))
802 return yield* failure("replyToThread", "GitCafe thread ids are opaque slugs.");
803 const path = yield* pullPath("replyToThread", input);
804 yield* read(
805 "replyToThread",
806 input,
807 {
808 method: "POST",
809 path: `${path}/comments/${input.threadId}/replies`,
810 body: { body: input.body },
811 },
812 Written,
813 );
814 }),
815 setReaction: Effect.fn("GitCafePullRequestProvider.setReaction")(function* (input) {
816 const path = `${yield* pullPath("setReaction", input)}/reactions/`;
817 const subject =
818 input.subjectId === undefined
819 ? { kind: "pull_request" as const, id: (yield* readPull("setReaction", input)).id }
820 : { kind: "pull_request_comment" as const, id: input.subjectId };
821 const reactions = yield* readPages("setReaction", input, path, Json.GitCafeReactions);
822 const mine = reactions.items.find(
823 (item) =>
824 item.subject.kind === subject.kind &&
825 item.subject.id === subject.id &&
826 item.emoji.kind === "unicode" &&
827 Json.reactionContent(item.emoji.value) === input.content,
828 )?.viewerReactionId;
829 if (input.reacted === (mine != null)) return;
830 if (input.reacted) {
831 yield* send("setReaction", input, {
832 method: "POST",
833 path,
834 body: { subject, emoji: { kind: "unicode", value: Json.REACTION_EMOJI[input.content] } },
835 });
836 return;
837 }
838 if (mine == null) return;
839 if (!isId(mine))
840 return yield* failure("setReaction", "GitCafe returned an unreadable reaction id.");
841 yield* send("setReaction", input, { method: "DELETE", path: `${path}${mine}` });
842 }),
843 setThreadResolution: Effect.fn("GitCafePullRequestProvider.setThreadResolution")(
844 function* (input) {
845 const comments = yield* readComments("setThreadResolution", input);
846 const root = comments.items.find(
847 (comment) => comment.id === input.threadId && comment.threadId === input.threadId,
848 );
849 if (root === undefined || root.version === undefined || !isId(root.id))
850 return yield* failure("setThreadResolution", "GitCafe did not return the thread root.");
851 if (input.resolved === (root.resolvedAt !== null)) return;
852 const path = yield* pullPath("setThreadResolution", input);
853 yield* read(
854 "setThreadResolution",
855 input,
856 {
857 method: "POST",
858 path: `${path}/comments/${root.id}/${input.resolved ? "resolve" : "unresolve"}`,
859 body: { expectedVersion: root.version },
860 },
861 Written,
862 );
863 },
864 ),
865 };
866 return provider;
867});
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts created+339
......@@ -0,0 +1,339 @@
1/**
2 * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates:
3 *
4 * - `discovery: SourceControlCliDiscoverySpec` — kind `"gitcafe"`, executable `"cafe"`,
5 * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a
6 * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its
7 * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope.
8 * - `make: Effect<SourceControlProvider["Service"], never, GitCafeApi | SourceControlHost>` with
9 * kind `"gitcafe"`, resolving the repository and host from the checkout's remote.
10 *
11 * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests.
12 */
13import { assert, describe, it } from "@effect/vitest";
14import { GitCommandError } from "@t3tools/contracts";
15import * as Effect from "effect/Effect";
16import * as Fiber from "effect/Fiber";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20import * as TestClock from "effect/testing/TestClock";
21import * as HttpClient from "effect/http/HttpClient";
22import * as HttpClientResponse from "effect/http/HttpClientResponse";
23import { ChildProcessSpawner } from "effect/process";
24import * as HostProcess from "@t3tools/shared/HostProcess";
25import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
26
27import * as GitCafeApi from "./GitCafeApi.ts";
28import * as GitCafeCredentials from "./GitCafeCredentials.ts";
29import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts";
30
31const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
32
33const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({
34 stdout: input.stdout === undefined ? "" : encodeJson(input.stdout),
35 stderr: input.stderr === undefined ? "" : encodeJson(input.stderr),
36 exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0),
37});
38const failure = (code: string, status: number | null) => ({
39 schemaVersion: 1,
40 error: { code, status, message: `Failure ${code}` },
41});
42
43describe("GitCafeSourceControlProvider", () => {
44 it("probes the cafe CLI against production", () => {
45 const { discovery } = GitCafeSourceControlProvider;
46 assert.strictEqual(discovery.kind, "gitcafe");
47 assert.strictEqual(discovery.executable, "cafe");
48 assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]);
49 assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]);
50 });
51
52 it("reads a signed-in account and keeps transient failures apart from sign-out", () => {
53 const { parseAuth } = GitCafeSourceControlProvider.discovery;
54 const signedIn = parseAuth(
55 authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }),
56 );
57 assert.strictEqual(signedIn.status, "authenticated");
58 assert.deepStrictEqual(signedIn.account, Option.some("alice"));
59 assert.strictEqual(
60 parseAuth(authAnswer({ stderr: failure("AUTHENTICATION_REQUIRED", 401), exitCode: 1 }))
61 .status,
62 "unauthenticated",
63 );
64 for (const [code, status] of [
65 ["FORBIDDEN", 403],
66 ["NETWORK_ERROR", null],
67 ] as const) {
68 assert.strictEqual(
69 parseAuth(authAnswer({ stderr: failure(code, status), exitCode: 1 })).status,
70 "unknown",
71 );
72 }
73 });
74
75 it.effect.each([
76 ["https://staging.git.cafe/team/project.git", "staging.git.cafe"],
77 ["ssh@git.cafe:team/project.git", "git.cafe"],
78 ] as const)("resolves the repository from the remote %s", ([remoteUrl, host]) => {
79 const urls: Array<string> = [];
80 const client = HttpClient.make((request) => {
81 urls.push(request.url);
82 return Effect.succeed(
83 HttpClientResponse.fromWeb(
84 request,
85 new Response(encodeJson({ name: "project", defaultBranch: "trunk" })),
86 ),
87 );
88 });
89 const context = {
90 provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` },
91 remoteName: "origin",
92 remoteUrl,
93 };
94 return Effect.gen(function* () {
95 const provider = yield* GitCafeSourceControlProvider.make;
96 assert.strictEqual(provider.kind, "gitcafe");
97 assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk");
98 assert.deepStrictEqual(
99 yield* provider.getRepositoryCloneUrls({ cwd: "/repo", context, repository: remoteUrl }),
100 {
101 nameWithOwner: "team/project",
102 url: `https://${host}/team/project`,
103 sshUrl: `ssh@${host}:team/project.git`,
104 },
105 );
106 assert.deepStrictEqual(urls, [
107 `https://${host}/api/repos/team/project`,
108 `https://${host}/api/repos/team/project`,
109 ]);
110 }).pipe(
111 Effect.provide(
112 GitCafeApi.layer.pipe(
113 Layer.provide(GitCafeCredentials.layer),
114 Layer.provideMerge(TestSourceControlHost.layer()),
115 Layer.provide(
116 Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }),
117 ),
118 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
119 Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
120 ),
121 ),
122 );
123 });
124
125 it.effect(
126 "reads a bare owner/name on git.cafe without a remote, and no other host's pulls",
127 () => {
128 const urls: Array<string> = [];
129 const client = HttpClient.make((request) => {
130 urls.push(request.url);
131 return Effect.succeed(
132 HttpClientResponse.fromWeb(
133 request,
134 new Response(encodeJson({ name: "project", defaultBranch: "trunk" })),
135 ),
136 );
137 });
138 return Effect.gen(function* () {
139 const provider = yield* GitCafeSourceControlProvider.make;
140 // The checkout has no remote at all, as when a repository is about to be created.
141 assert.deepStrictEqual(
142 yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }),
143 {
144 nameWithOwner: "team/project",
145 url: "https://git.cafe/team/project",
146 sshUrl: "ssh@git.cafe:team/project.git",
147 },
148 );
149 const foreign = yield* provider
150 .getChangeRequest({
151 cwd: "/repo",
152 reference: "https://example.com/team/project/pulls/42",
153 })
154 .pipe(Effect.flip);
155 assert.strictEqual(foreign._tag, "SourceControlProviderError");
156 assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]);
157 }).pipe(
158 Effect.provide(
159 GitCafeApi.layer.pipe(
160 Layer.provide(GitCafeCredentials.layer),
161 Layer.provideMerge(
162 TestSourceControlHost.layer({
163 git: {
164 resolvePrimaryRemoteName: () =>
165 Effect.fail(
166 new GitCommandError({
167 operation: "resolvePrimaryRemoteName",
168 command: "git remote",
169 cwd: "/repo",
170 detail: "No git remote is configured.",
171 }),
172 ),
173 },
174 }),
175 ),
176 Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })),
177 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
178 Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
179 ),
180 ),
181 );
182 },
183 );
184
185 it.effect.each([
186 ["no context", undefined, "https://git.cafe/fork/project.git"],
187 ["an HTTPS remote", "https://git.cafe/team/project.git", "https://git.cafe/fork/project.git"],
188 ["an SSH remote", "ssh@git.cafe:team/project.git", "ssh@git.cafe:fork/project.git"],
189 ["an ssh:// remote", "ssh://ssh@git.cafe/team/project.git", "ssh@git.cafe:fork/project.git"],
190 [
191 "an HTTPS remote with a user",
192 "https://alice@git.cafe/team/project.git",
193 "https://git.cafe/fork/project.git",
194 ],
195 ] as const)("checks a fork out over the transport of %s", ([, remoteUrl, expected]) => {
196 const remotes: Array<string> = [];
197 const client = HttpClient.make((request) =>
198 Effect.succeed(
199 HttpClientResponse.fromWeb(
200 request,
201 new Response(
202 encodeJson({
203 number: 7,
204 title: "Change",
205 state: "open",
206 draft: false,
207 sourceBranch: "feature",
208 targetBranch: "main",
209 headOid: null,
210 updatedAt: null,
211 sourceRepo: { owner: "fork", name: "project" },
212 closedAt: null,
213 mergedAt: null,
214 }),
215 ),
216 ),
217 ),
218 );
219 const context =
220 remoteUrl === undefined
221 ? undefined
222 : {
223 provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" },
224 remoteName: "origin",
225 remoteUrl,
226 };
227 return Effect.gen(function* () {
228 const provider = yield* GitCafeSourceControlProvider.make;
229 yield* provider.checkoutChangeRequest({
230 cwd: "/repo",
231 reference: "https://git.cafe/team/project/pulls/7",
232 ...(context === undefined ? {} : { context }),
233 });
234 assert.deepStrictEqual(remotes, [expected]);
235 }).pipe(
236 Effect.provide(
237 GitCafeApi.layer.pipe(
238 Layer.provide(GitCafeCredentials.layer),
239 Layer.provideMerge(
240 TestSourceControlHost.layer({
241 git: {
242 ensureRemote: (input) => {
243 remotes.push(input.url);
244 return Effect.succeed("gitcafe");
245 },
246 listLocalBranchNames: () => Effect.succeed([]),
247 fetchRemoteBranch: () => Effect.void,
248 setBranchUpstream: () => Effect.void,
249 switchRef: () => Effect.succeed({ refName: null }),
250 },
251 }),
252 ),
253 Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })),
254 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
255 Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
256 ),
257 ),
258 );
259 });
260
261 it.effect("waits for a created repository's admission before handing out its URLs", () => {
262 const urls: Array<string> = [];
263 const states = ["running", "complete"];
264 const client = HttpClient.make((request) => {
265 urls.push(request.url);
266 return Effect.succeed(
267 HttpClientResponse.fromWeb(
268 request,
269 new Response(
270 encodeJson({
271 repoId: "repo_1",
272 state: states.shift(),
273 owner: "team",
274 name: "project",
275 }),
276 ),
277 ),
278 );
279 });
280 return Effect.gen(function* () {
281 const provider = yield* GitCafeSourceControlProvider.make;
282 const created = yield* provider
283 .createRepository({ cwd: "/repo", repository: "team/project", visibility: "private" })
284 .pipe(Effect.forkChild);
285 yield* TestClock.adjust("1 second");
286 assert.deepStrictEqual(yield* Fiber.join(created), {
287 nameWithOwner: "team/project",
288 url: "https://git.cafe/team/project",
289 sshUrl: "ssh@git.cafe:team/project.git",
290 });
291 assert.deepStrictEqual(urls, [
292 "https://git.cafe/api/orgs/team/admissions/repo_1",
293 "https://git.cafe/api/orgs/team/admissions/repo_1",
294 ]);
295 }).pipe(
296 Effect.provide(
297 GitCafeApi.layer.pipe(
298 Layer.provide(GitCafeCredentials.layer),
299 Layer.provideMerge(
300 TestSourceControlHost.layer({
301 process: {
302 run: () =>
303 Effect.succeed(
304 TestSourceControlHost.processOutput(
305 encodeJson({
306 schemaVersion: 1,
307 data: {
308 resource: {
309 repoId: "repo_1",
310 state: "running",
311 owner: "team",
312 name: "project",
313 },
314 },
315 }),
316 ),
317 ),
318 },
319 git: {
320 resolvePrimaryRemoteName: () =>
321 Effect.fail(
322 new GitCommandError({
323 operation: "resolvePrimaryRemoteName",
324 command: "git remote",
325 cwd: "/repo",
326 detail: "No git remote is configured.",
327 }),
328 ),
329 },
330 }),
331 ),
332 Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })),
333 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
334 Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
335 ),
336 ),
337 );
338 });
339});
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts created+503
......@@ -0,0 +1,503 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import * as Schedule from "effect/Schedule";
6import {
7 PositiveInt,
8 SourceControlProviderError,
9 TrimmedNonEmptyString,
10 type ChangeRequest,
11} from "@t3tools/contracts";
12import {
13 providerAuth,
14 type SourceControlCliDiscoverySpec,
15} from "@t3tools/source-control-core/server/discovery";
16import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
17import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
18
19import * as GitCafeApi from "./GitCafeApi.ts";
20import * as GitCafeHosts from "./gitCafeHosts.ts";
21
22/** Discovery reports the production account; repository operations pick their own host. */
23const AUTH_HOST = "git.cafe";
24/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */
25const ADMISSION_TIMEOUT = "30 seconds";
26const ADMISSION_POLL_INTERVAL = "500 millis";
27const cliArgs = (host: string) => [
28 "--host",
29 `https://${host}/api`,
30 "--no-input",
31 "--no-update-check",
32];
33
34const decodeAuth = Schema.decodeUnknownResult(
35 Schema.fromJsonString(
36 Schema.Struct({
37 schemaVersion: Schema.Literal(1),
38 data: Schema.Struct({ host: Schema.String, username: TrimmedNonEmptyString }),
39 }),
40 ),
41);
42/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */
43const decodeFailure = Schema.decodeUnknownResult(
44 Schema.fromJsonString(
45 Schema.Struct({
46 schemaVersion: Schema.Literal(1),
47 error: Schema.Struct({
48 code: Schema.String,
49 message: Schema.String,
50 status: Schema.NullOr(Schema.Finite),
51 }),
52 }),
53 ),
54);
55const cliFailure = (output: string) =>
56 [output.trim(), ...output.split(/\r?\n/u).toReversed()]
57 .map((line) => decodeFailure(line.trim()))
58 .find(Result.isSuccess)?.success.error;
59
60export const discovery = {
61 type: "cli",
62 kind: "gitcafe",
63 label: "GitCafe",
64 executable: "cafe",
65 versionArgs: ["--version"],
66 authArgs: [...cliArgs(AUTH_HOST), "auth", "status", "--json"],
67 parseAuth: (input) => {
68 const signedIn = decodeAuth(input.stdout.trim());
69 if (input.exitCode === 0 && Result.isSuccess(signedIn))
70 return providerAuth({
71 status: "authenticated",
72 host: AUTH_HOST,
73 account: signedIn.success.data.username,
74 });
75 const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout);
76 if (failure)
77 return providerAuth({
78 // Only a refusal says the login is gone; a forbidden or offline answer says nothing.
79 status:
80 failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401
81 ? "unauthenticated"
82 : "unknown",
83 host: AUTH_HOST,
84 detail: failure.message,
85 });
86 return providerAuth({
87 status: "unknown",
88 host: AUTH_HOST,
89 detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`,
90 });
91 },
92 installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, or set CAFE_TOKEN on the server.`,
93} satisfies SourceControlCliDiscoverySpec;
94
95const Repository = Schema.Struct({
96 name: TrimmedNonEmptyString,
97 defaultBranch: Schema.NullOr(TrimmedNonEmptyString),
98});
99const Pull = Schema.Struct({
100 number: PositiveInt,
101 title: TrimmedNonEmptyString,
102 state: Schema.Literals(["open", "closed", "merged"]),
103 draft: Schema.Boolean,
104 sourceBranch: TrimmedNonEmptyString,
105 targetBranch: TrimmedNonEmptyString,
106 headOid: Schema.optional(Schema.NullOr(Schema.String)),
107 updatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString),
108});
109const PullDetail = Schema.Struct({
110 ...Pull.fields,
111 sourceRepo: Schema.NullOr(
112 Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString }),
113 ),
114 closedAt: Schema.NullOr(Schema.String),
115 mergedAt: Schema.NullOr(Schema.String),
116});
117const Pulls = Schema.Struct({ items: Schema.Array(Pull), next: Schema.NullOr(Schema.String) });
118const RepositoryAdmission = Schema.Struct({
119 repoId: TrimmedNonEmptyString,
120 state: Schema.String,
121 owner: TrimmedNonEmptyString,
122 name: TrimmedNonEmptyString,
123});
124const CreatedRepository = Schema.Struct({
125 data: Schema.Struct({ resource: RepositoryAdmission }),
126});
127const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository));
128const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
129
130interface Target {
131 readonly host: GitCafeHosts.GitCafeHost;
132 readonly repository: string;
133}
134
135const cloneUrls = (target: Target) => ({
136 nameWithOwner: target.repository,
137 url: `https://${target.host}/${target.repository}`,
138 sshUrl: `ssh@${target.host}:${target.repository}.git`,
139});
140
141/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */
142const isSshRemote = (remoteUrl: string | undefined) =>
143 remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl));
144
145const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u;
146
147export const make = Effect.gen(function* () {
148 const api = yield* GitCafeApi.GitCafeApi;
149 const host = yield* SourceControlHost.SourceControlHost;
150
151 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>
152 new SourceControlProviderError({
153 provider: "gitcafe",
154 operation,
155 cwd,
156 detail,
157 ...(cause === undefined ? {} : { cause }),
158 });
159 const read = <S extends Schema.Codec<unknown, unknown, never, never>>(
160 operation: string,
161 cwd: string,
162 request: Omit<GitCafeApi.GitCafeApiRequest, "operation">,
163 schema: S,
164 ) =>
165 api.request({ ...request, operation }).pipe(
166 Effect.mapError((cause) => error(operation, cwd, cause.detail, cause)),
167 Effect.flatMap((raw) =>
168 Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
169 Effect.mapError((cause) =>
170 error(operation, cwd, "GitCafe returned an unreadable response.", cause),
171 ),
172 ),
173 ),
174 );
175 const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray<string>) =>
176 host.process
177 .run({
178 operation: `GitCafeSourceControlProvider.${operation}`,
179 command: "cafe",
180 cwd,
181 args: [...cliArgs(target.host), ...args],
182 env: { CAFE_OUTPUT: "json" },
183 timeoutMs: 30_000,
184 })
185 .pipe(
186 Effect.mapError((cause) =>
187 error(
188 operation,
189 cwd,
190 (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) ||
191 "GitCafe CLI command failed.",
192 cause,
193 ),
194 ),
195 );
196
197 /** The repository a call names, falling back to the checkout's own GitCafe remote. */
198 const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* (
199 operation: string,
200 input: {
201 readonly cwd: string;
202 readonly context?: SourceControlProvider.SourceControlProviderContext;
203 readonly repository?: string;
204 },
205 ) {
206 const named =
207 input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository);
208 if (named) return named;
209 // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no
210 // remote (a repository being created) still resolves, on git.cafe.
211 const readRemote = host.git
212 .resolvePrimaryRemoteName(input.cwd)
213 .pipe(
214 Effect.flatMap((remote) => host.git.readConfigValue(input.cwd, `remote.${remote}.url`)),
215 );
216 const remoteUrl =
217 input.context?.remoteUrl ??
218 (input.repository === undefined
219 ? yield* readRemote.pipe(
220 Effect.mapError((cause) =>
221 error(operation, input.cwd, "Could not read the GitCafe remote.", cause),
222 ),
223 )
224 : yield* readRemote.pipe(Effect.orElseSucceed(() => null)));
225 const remote = remoteUrl ? GitCafeHosts.parseGitCafeRemote(remoteUrl) : null;
226 if (input.repository === undefined) {
227 if (remote) return remote;
228 return yield* error(operation, input.cwd, "No GitCafe repository remote was found.");
229 }
230 // A bare owner/name lives on the checkout's own GitCafe host.
231 const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository);
232 if (repository !== null)
233 return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target;
234 return yield* error(
235 operation,
236 input.cwd,
237 "A GitCafe repository is named owner/name or by its git.cafe URL.",
238 );
239 });
240
241 const getPull = Effect.fn("GitCafeSourceControlProvider.getPull")(function* (input: {
242 readonly cwd: string;
243 readonly context?: SourceControlProvider.SourceControlProviderContext;
244 readonly reference: string;
245 }) {
246 const match = pullUrlPattern.exec(input.reference.trim());
247 const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null;
248 const target: Target =
249 urlHost && match?.[2]
250 ? { host: urlHost, repository: match[2] }
251 : yield* resolveTarget("getChangeRequest", input);
252 // A pull request URL on another host is not a GitCafe pull request number.
253 const number = match
254 ? urlHost
255 ? match[3]
256 : undefined
257 : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1];
258 if (number === undefined)
259 return yield* error(
260 "getChangeRequest",
261 input.cwd,
262 "A GitCafe pull request is a number or a git.cafe pull request URL.",
263 );
264 const pull = yield* read(
265 "getChangeRequest",
266 input.cwd,
267 { host: target.host, path: `/repos/${target.repository}/pulls/${number}` },
268 PullDetail,
269 );
270 const owner = target.repository.split("/")[0] ?? null;
271 return {
272 provider: "gitcafe",
273 number: pull.number,
274 title: pull.title,
275 url: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
276 baseRefName: pull.targetBranch,
277 headRefName: pull.sourceBranch,
278 ...(pull.headOid ? { headSha: pull.headOid } : {}),
279 state: pull.state,
280 ...(pull.draft ? { isDraft: true } : {}),
281 closedAt: pull.closedAt,
282 mergedAt: pull.mergedAt,
283 updatedAt: pull.updatedAt,
284 isCrossRepository: pull.sourceRepo !== null,
285 headRepositoryNameWithOwner: pull.sourceRepo
286 ? `${pull.sourceRepo.owner}/${pull.sourceRepo.name}`
287 : target.repository,
288 headRepositoryOwnerLogin: pull.sourceRepo?.owner ?? owner,
289 } satisfies ChangeRequest;
290 });
291
292 return SourceControlProvider.SourceControlProvider.of({
293 kind: "gitcafe",
294 repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null,
295 getChangeRequest: getPull,
296 listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")(
297 function* (input) {
298 const target = yield* resolveTarget("listChangeRequests", input);
299 const source = SourceControlProvider.sourceControlRefFromInput(input);
300 const branch = SourceControlProvider.sourceBranch(input);
301 const limit = input.limit ?? 20;
302 const items: Array<ChangeRequest> = [];
303 let after: string | null = null;
304 do {
305 const query = new URLSearchParams({
306 sourceBranches: encodeBranches([branch]),
307 limit: String(Math.min(limit, 100)),
308 sort: "newest",
309 });
310 if (input.state !== "all") query.set("state", input.state);
311 if (after !== null) query.set("after", after);
312 const page: typeof Pulls.Type = yield* read(
313 "listChangeRequests",
314 input.cwd,
315 { host: target.host, path: `/repos/${target.repository}/pulls?${query}` },
316 Pulls,
317 );
318 for (const pull of page.items) {
319 if (pull.sourceBranch !== branch || items.length >= limit) continue;
320 // List rows omit fork identity, so only the branch's own rows are read in full.
321 const detail = yield* getPull({
322 cwd: input.cwd,
323 reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
324 });
325 if (
326 (!source?.repository || detail.headRepositoryNameWithOwner === source.repository) &&
327 (!source?.owner || detail.headRepositoryOwnerLogin === source.owner)
328 )
329 items.push(detail);
330 }
331 after = page.items.length === 0 || page.next === after ? null : page.next;
332 } while (after !== null && items.length < limit);
333 return items;
334 },
335 ),
336 createChangeRequest: Effect.fn("GitCafeSourceControlProvider.createChangeRequest")(
337 function* (input) {
338 const target = yield* resolveTarget("createChangeRequest", {
339 ...input,
340 ...(input.target?.repository ? { repository: input.target.repository } : {}),
341 });
342 const source = SourceControlProvider.sourceControlRefFromInput(input);
343 const sourceRepository =
344 source?.repository ??
345 (source?.owner
346 ? `${source.owner}/${target.repository.split("/")[1]}`
347 : target.repository);
348 const branch = SourceControlProvider.sourceBranch(input);
349 yield* cafe("createChangeRequest", input.cwd, target, [
350 "pr",
351 "create",
352 "--json",
353 "--repo",
354 target.repository,
355 "--head",
356 sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`,
357 "--base",
358 input.target?.refName ?? input.baseRefName,
359 "--title",
360 input.title,
361 "--body-file",
362 input.bodyFile,
363 ]);
364 },
365 ),
366 getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")(
367 function* (input) {
368 const target = yield* resolveTarget("getRepositoryCloneUrls", input);
369 yield* read(
370 "getRepositoryCloneUrls",
371 input.cwd,
372 { host: target.host, path: `/repos/${target.repository}` },
373 Repository,
374 );
375 return cloneUrls(target);
376 },
377 ),
378 createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) {
379 const target = yield* resolveTarget("createRepository", input);
380 const [owner = "", name = ""] = target.repository.split("/");
381 const output = yield* cafe("createRepository", input.cwd, target, [
382 "repo",
383 "create",
384 name,
385 "--org",
386 owner,
387 "--visibility",
388 input.visibility,
389 "--json",
390 ]);
391 const created = yield* decodeCreatedRepository(output.stdout).pipe(
392 Effect.mapError((cause) =>
393 error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause),
394 ),
395 );
396 // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is
397 // still running, so wait for it to settle the way `cafe repo create --clone` does.
398 const settled = (state: string) => state === "complete" || state === "blocked";
399 const pending = created.data.resource;
400 const resource = settled(pending.state)
401 ? pending
402 : yield* read(
403 "createRepository",
404 input.cwd,
405 {
406 host: target.host,
407 path: `/orgs/${pending.owner}/admissions/${pending.repoId}`,
408 },
409 RepositoryAdmission,
410 ).pipe(
411 Effect.repeat({
412 until: (admission) => settled(admission.state),
413 schedule: Schedule.spaced(ADMISSION_POLL_INTERVAL),
414 }),
415 Effect.timeoutOption(ADMISSION_TIMEOUT),
416 Effect.map(Option.getOrElse(() => pending)),
417 );
418 // Anything short of complete is not yet usable.
419 if (resource.state !== "complete")
420 return yield* error(
421 "createRepository",
422 input.cwd,
423 `GitCafe repository ${target.repository} is ${resource.state}. Check it on GitCafe before retrying.`,
424 );
425 return cloneUrls({ host: target.host, repository: `${resource.owner}/${resource.name}` });
426 }),
427 getDefaultBranch: Effect.fn("GitCafeSourceControlProvider.getDefaultBranch")(function* (input) {
428 const target = yield* resolveTarget("getDefaultBranch", input);
429 const repository = yield* read(
430 "getDefaultBranch",
431 input.cwd,
432 { host: target.host, path: `/repos/${target.repository}` },
433 Repository,
434 );
435 return repository.defaultBranch;
436 }),
437 // `cafe` has no forced checkout, so the branch is fetched and switched to with git.
438 checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")(
439 function* (input) {
440 const pull = yield* getPull(input);
441 const pullHost = new URL(pull.url).host;
442 const sourceRepository = pull.headRepositoryNameWithOwner;
443 if (!sourceRepository)
444 return yield* error(
445 "checkoutChangeRequest",
446 input.cwd,
447 "GitCafe did not name the pull request's source repository.",
448 );
449 const contextRemote = input.context
450 ? GitCafeHosts.parseGitCafeRemote(input.context.remoteUrl)
451 : null;
452 const git = host.git;
453 yield* Effect.gen(function* () {
454 const remoteName =
455 input.context &&
456 contextRemote?.repository === sourceRepository &&
457 contextRemote.host === pullHost
458 ? input.context.remoteName
459 : yield* git.ensureRemote({
460 cwd: input.cwd,
461 preferredName: "gitcafe",
462 // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise.
463 url: isSshRemote(input.context?.remoteUrl)
464 ? `ssh@${pullHost}:${sourceRepository}.git`
465 : `https://${pullHost}/${sourceRepository}.git`,
466 });
467 const localBranch = pull.isCrossRepository
468 ? `pr-${pull.number}/${pull.headRefName}`
469 : pull.headRefName;
470 const branches = yield* git.listLocalBranchNames(input.cwd);
471 const remoteBranch = { cwd: input.cwd, remoteName, remoteBranch: pull.headRefName };
472 if (input.force !== true && !branches.includes(localBranch))
473 yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch });
474 else yield* git.fetchRemoteTrackingBranch(remoteBranch);
475 // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits.
476 if (input.force === true)
477 yield* git.execute({
478 operation: "GitCafeSourceControlProvider.checkoutChangeRequest",
479 cwd: input.cwd,
480 args: [
481 "checkout",
482 "-B",
483 localBranch,
484 `refs/remotes/${remoteName}/${pull.headRefName}`,
485 "--",
486 ],
487 });
488 yield* git.setBranchUpstream({ ...remoteBranch, branch: localBranch });
489 yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
490 }).pipe(
491 Effect.mapError((cause) =>
492 error(
493 "checkoutChangeRequest",
494 input.cwd,
495 "Could not check out the GitCafe pull request.",
496 cause,
497 ),
498 ),
499 );
500 },
501 ),
502 });
503});
packages/source-control-gitcafe/src/server/driver.ts created+20
......@@ -0,0 +1,20 @@
1/**
2 * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull
3 * requests through GitCafe's REST API on git.cafe and staging.git.cafe.
4 *
5 * @module source-control-gitcafe/server/driver
6 */
7import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver";
8import * as Effect from "effect/Effect";
9
10import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts";
11import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts";
12
13export const driver = defineSourceControlDriver({
14 kind: "gitcafe",
15 make: Effect.all({
16 sourceControl: GitCafeSourceControlProvider.make,
17 discovery: Effect.succeed(GitCafeSourceControlProvider.discovery),
18 pullRequests: GitCafePullRequestProvider.make,
19 }),
20});
packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts created+46
......@@ -0,0 +1,46 @@
1/**
2 * Pins `./gitCafeHosts.ts`, which pass 2 creates as a plain module (no service):
3 *
4 * - `GITCAFE_HOSTS: readonly ["git.cafe", "staging.git.cafe"]` — the only hosts GitCafe lives on.
5 * - `isGitCafeHost(host: string): boolean` — case-insensitive membership in `GITCAFE_HOSTS`.
6 * - `parseGitCafeRemote(url: string): { readonly host: GitCafeHost; readonly repository: string } | null`
7 * — the host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else.
8 */
9import { assert, describe, it } from "@effect/vitest";
10
11import * as GitCafeHosts from "./gitCafeHosts.ts";
12
13describe("gitCafeHosts", () => {
14 it("knows exactly the production and staging hosts", () => {
15 assert.deepStrictEqual([...GitCafeHosts.GITCAFE_HOSTS], ["git.cafe", "staging.git.cafe"]);
16 assert.isTrue(GitCafeHosts.isGitCafeHost("Git.Cafe"));
17 assert.isTrue(GitCafeHosts.isGitCafeHost("staging.git.cafe"));
18 for (const host of ["cafe", "evil.git.cafe", "git.cafe.example.com", "github.com"]) {
19 assert.isFalse(GitCafeHosts.isGitCafeHost(host), host);
20 }
21 });
22
23 it.each([
24 ["https://git.cafe/team/project.git", "git.cafe"],
25 ["https://git.cafe/team/project", "git.cafe"],
26 ["https://staging.git.cafe/team/project.git/", "staging.git.cafe"],
27 ["ssh@git.cafe:team/project.git", "git.cafe"],
28 ["git@staging.git.cafe:team/project.git", "staging.git.cafe"],
29 ["ssh://git@git.cafe/team/project.git", "git.cafe"],
30 ])("reads the host and repository of %s", (url, host) => {
31 assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), {
32 host,
33 repository: "team/project",
34 });
35 });
36
37 it.each([
38 "https://github.com/team/project.git",
39 "https://evil.git.cafe/team/project.git",
40 "https://git.cafe/team/nested/project.git",
41 "https://git.cafe/team",
42 "team/project",
43 ])("is not a GitCafe repository: %s", (url) => {
44 assert.isNull(GitCafeHosts.parseGitCafeRemote(url));
45 });
46});
packages/source-control-gitcafe/src/server/gitCafeHosts.ts created+49
......@@ -0,0 +1,49 @@
1/**
2 * The hosts GitCafe lives on, and how a remote URL names one of its repositories. GitCafe has no
3 * self-hosted installs, so the list is fixed rather than discovered.
4 *
5 * @module source-control-gitcafe/server/gitCafeHosts
6 */
7
8export const GITCAFE_HOSTS = ["git.cafe", "staging.git.cafe"] as const;
9export type GitCafeHost = (typeof GITCAFE_HOSTS)[number];
10
11/** The host as GitCafe spells it, or null for any host GitCafe does not live on. */
12export function gitCafeHost(host: string): GitCafeHost | null {
13 const normalized = host.trim().toLowerCase();
14 return GITCAFE_HOSTS.find((candidate) => candidate === normalized) ?? null;
15}
16
17export function isGitCafeHost(host: string): boolean {
18 return gitCafeHost(host) !== null;
19}
20
21const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
22const REMOTE_PATTERNS = [
23 /^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu,
24 /^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu,
25 /^[^@/:]+@([^/:]+):(.+)$/u,
26];
27
28/** An `owner/name` repository path, without the `.git` suffix a clone URL carries. */
29export function gitCafeRepositoryPath(path: string): string | null {
30 const repository = path
31 .trim()
32 .replace(/\/+$/u, "")
33 .replace(/\.git$/u, "");
34 return REPOSITORY.test(repository) ? repository : null;
35}
36
37/** The host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. */
38export function parseGitCafeRemote(
39 url: string,
40): { readonly host: GitCafeHost; readonly repository: string } | null {
41 for (const pattern of REMOTE_PATTERNS) {
42 const match = pattern.exec(url.trim());
43 if (!match?.[1] || !match[2]) continue;
44 const host = gitCafeHost(match[1]);
45 const repository = gitCafeRepositoryPath(match[2]);
46 return host && repository ? { host, repository } : null;
47 }
48 return null;
49}
packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts created+547
......@@ -0,0 +1,547 @@
1/**
2 * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral
3 * `Provider*` shapes. Pass 2 creates these exports:
4 *
5 * Schemas (REST bodies as GitCafe sends them):
6 * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt,
7 * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`),
8 * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions`
9 * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`),
10 * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`,
11 * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`),
12 * `GitCafeChecks` (`{ items, next }`).
13 *
14 * Conversions (all take the repository host, so staging stays on staging):
15 * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest`
16 * - `toViewerPermissions(detail): PullRequestViewerPermissions` — fills `editChangeRequest`.
17 * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }):
18 * ProviderChangeRequestActivity` — fills `canEdit` on both copies of a remark and `canResolve`
19 * on review threads, from each comment's `capabilities`.
20 * - `toStack(stack, { repository, host }): ProviderChangeRequestStack`
21 * - `toDiff(diff): ProviderDiffSlice`
22 * - `toChecks(checks): ReadonlyArray<PullRequestCheck>`
23 */
24import { assert, describe, it } from "@effect/vitest";
25import * as Schema from "effect/Schema";
26
27import * as GitCafePullRequestJson from "./gitCafePullRequestJson.ts";
28
29const decodePull = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePull);
30const decodePullDetail = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePullDetail);
31const decodeComments = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeComments);
32const decodeReviews = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReviews);
33const decodeCommits = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeCommits);
34const decodeReactions = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReactions);
35const decodeStackEnvelope = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeStackEnvelope);
36const decodeDiff = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeDiff);
37const decodeChecks = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeChecks);
38
39const timestamp = "2026-09-12T12:00:00Z";
40const actor = {
41 kind: "local",
42 actorId: "act_one",
43 handle: "alice",
44 displayName: null,
45 avatarUrl: "/avatars/one",
46};
47const pull = {
48 id: "pr_one",
49 number: 7,
50 title: "Change",
51 state: "open",
52 draft: true,
53 sourceBranch: "feature",
54 targetBranch: "main",
55 headOid: "abcdef",
56 author: actor,
57 createdAt: timestamp,
58 updatedAt: timestamp,
59 version: 1,
60};
61const detail = {
62 ...pull,
63 draft: false,
64 description: "Body",
65 closedAt: null,
66 mergedAt: null,
67 sourceRepo: null,
68 capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true },
69};
70const lineComment = {
71 author: actor,
72 body: "Review",
73 path: "file.ts",
74 line: 3,
75 side: "right",
76 createdAt: timestamp,
77 commitOid: "abcdef",
78 resolvedAt: null,
79};
80const capabilities = (overrides: Record<string, boolean> = {}) => ({
81 edit: false,
82 hide: false,
83 unhide: false,
84 delete: false,
85 resolve: false,
86 unresolve: false,
87 ...overrides,
88});
89const noReviews = decodeReviews({ items: [], next: null });
90const noCommits = decodeCommits({ items: [], headOid: "abcdef", truncated: false, next: null });
91const activity = (
92 comments: ReadonlyArray<Record<string, unknown>>,
93 extra: { readonly reactions?: unknown; readonly host?: string } = {},
94) =>
95 GitCafePullRequestJson.toActivity({
96 comments: decodeComments({ items: comments, next: null }),
97 reviews: noReviews,
98 commits: noCommits,
99 headOid: "abcdef",
100 host: extra.host ?? "git.cafe",
101 ...(extra.reactions === undefined ? {} : { reactions: decodeReactions(extra.reactions) }),
102 });
103
104describe("toChangeRequest", () => {
105 it("reads draft state, the repository host's URLs and absolute avatars", () => {
106 const row = GitCafePullRequestJson.toChangeRequest(decodePull(pull), {
107 repository: "owner/repo",
108 host: "staging.git.cafe",
109 });
110 assert.strictEqual(row.state, "open");
111 assert.isTrue(row.isDraft);
112 assert.strictEqual(row.url, "https://staging.git.cafe/owner/repo/pulls/7");
113 assert.deepStrictEqual(row.author, {
114 login: "alice",
115 name: null,
116 avatarUrl: "https://staging.git.cafe/avatars/one",
117 });
118 });
119
120 it("names linked GitHub reviewers by both logins and drops unavailable ones", () => {
121 const row = GitCafePullRequestJson.toChangeRequest(
122 decodePull({
123 ...pull,
124 author: { kind: "github", actorId: "act_remote", login: "remote-user", avatarUrl: null },
125 reviewers: [
126 { actor },
127 {
128 actor: {
129 kind: "github",
130 actorId: "act_github",
131 login: "upstream",
132 avatarUrl: null,
133 linkedProfile: { handle: "linked-local" },
134 },
135 },
136 { actor: { kind: "unavailable", actorId: "act_deleted" } },
137 ],
138 }),
139 { repository: "owner/repo", host: "git.cafe" },
140 );
141 assert.strictEqual(row.author?.login, "remote-user");
142 assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]);
143 });
144
145 it("requires capabilities on a detail response", () => {
146 assert.throws(() => decodePullDetail(pull));
147 });
148});
149
150describe("toViewerPermissions", () => {
151 const permissions = (overrides: Record<string, unknown>) =>
152 GitCafePullRequestJson.toViewerPermissions(decodePullDetail({ ...detail, ...overrides }));
153
154 it("lets an editor rewrite the title and description, and nobody else", () => {
155 assert.isTrue(permissions({}).editChangeRequest);
156 assert.isFalse(
157 permissions({
158 capabilities: { comment: true, review: true, merge: true, edit: false, moderate: true },
159 }).editChangeRequest,
160 );
161 });
162
163 it("offers lifecycle actions by state and never a standalone branch update", () => {
164 assert.deepStrictEqual(permissions({}).actions, ["draft", "close", "merge"]);
165 assert.deepStrictEqual(permissions({ state: "closed" }).actions, ["reopen"]);
166 assert.deepStrictEqual(permissions({ state: "merged" }).actions, []);
167 assert.isTrue(permissions({}).stackRebase);
168 });
169
170 it("keeps a locked conversation's composer for moderators only", () => {
171 const locked = (moderate: boolean) =>
172 permissions({
173 lockedAt: timestamp,
174 capabilities: { comment: true, review: false, merge: false, edit: false, moderate },
175 });
176 assert.isFalse(locked(false).comment);
177 assert.isFalse(locked(false).resolve);
178 assert.isTrue(locked(true).comment);
179 assert.deepStrictEqual(locked(true).verdicts, []);
180 });
181
182 it("offers no composer to a viewer GitCafe won't let comment", () => {
183 const silent = permissions({
184 capabilities: { comment: false, review: true, merge: true, edit: true, moderate: true },
185 });
186 assert.isFalse(silent.comment);
187 assert.isFalse(silent.resolve);
188 });
189});
190
191describe("toActivity", () => {
192 it("groups line comments by their persisted thread id and marks a paged list truncated", () => {
193 const result = GitCafePullRequestJson.toActivity({
194 comments: decodeComments({
195 items: [
196 { ...lineComment, id: "reply", threadId: "thread-one" },
197 { ...lineComment, id: "other", threadId: "thread-two" },
198 ],
199 next: "next",
200 }),
201 reviews: noReviews,
202 commits: noCommits,
203 headOid: "abcdef",
204 host: "git.cafe",
205 });
206 assert.deepStrictEqual(result.reviewThreads.map((thread) => thread.id).toSorted(), [
207 "thread-one",
208 "thread-two",
209 ]);
210 assert.isTrue(result.commentsTruncated);
211 });
212
213 it.each([timestamp, null])("takes the thread's resolution from its root: %s", (resolvedAt) => {
214 const result = activity([
215 { ...lineComment, id: "root", threadId: "root", resolvedAt },
216 { ...lineComment, id: "reply", threadId: "root", resolvedAt: null },
217 ]);
218 assert.strictEqual(result.reviewThreads.length, 1);
219 assert.strictEqual(result.reviewThreads[0]?.isResolved, resolvedAt !== null);
220 assert.strictEqual(result.reviewThreads[0]?.comments.length, 2);
221 });
222
223 it("carries per-remark edit permission onto both copies of a remark", () => {
224 const result = activity([
225 {
226 ...lineComment,
227 id: "root",
228 threadId: "root",
229 capabilities: capabilities({ edit: false, resolve: true }),
230 },
231 {
232 ...lineComment,
233 id: "reply",
234 threadId: "root",
235 capabilities: capabilities({ edit: true }),
236 },
237 { ...lineComment, id: "silent", threadId: "silent", path: null, line: null, side: null },
238 ]);
239 const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit;
240 assert.strictEqual(canEdit("root"), false);
241 assert.strictEqual(canEdit("reply"), true);
242 // A host that says nothing about a remark leaves the page to guess.
243 assert.isUndefined(canEdit("silent"));
244 assert.deepStrictEqual(
245 result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]),
246 [
247 ["root", false],
248 ["reply", true],
249 ],
250 );
251 });
252
253 it.each([
254 [null, { resolve: true, unresolve: false }, true],
255 [null, { resolve: false, unresolve: true }, false],
256 [timestamp, { resolve: true, unresolve: false }, false],
257 [timestamp, { resolve: false, unresolve: true }, true],
258 ] as const)(
259 "reads canResolve for a thread resolved at %s with %o as %s",
260 (resolvedAt, allowed, canResolve) => {
261 const result = activity([
262 {
263 ...lineComment,
264 id: "root",
265 threadId: "root",
266 resolvedAt,
267 capabilities: capabilities(allowed),
268 },
269 ]);
270 assert.strictEqual(result.reviewThreads[0]?.canResolve, canResolve);
271 },
272 );
273
274 it("leaves canResolve absent when the root says nothing", () => {
275 const result = activity([{ ...lineComment, id: "root", threadId: "root" }]);
276 assert.isUndefined(result.reviewThreads[0]?.canResolve);
277 });
278
279 it("maps review verdicts, dismissals and commits", () => {
280 const result = GitCafePullRequestJson.toActivity({
281 comments: decodeComments({ items: [], next: null }),
282 reviews: decodeReviews({
283 items: [
284 {
285 id: "r1",
286 author: actor,
287 body: "LGTM",
288 verdict: "approve",
289 dismissedAt: null,
290 createdAt: timestamp,
291 },
292 {
293 id: "r2",
294 author: actor,
295 body: null,
296 verdict: "request_changes",
297 dismissedAt: timestamp,
298 createdAt: "2026-09-12T13:00:00Z",
299 },
300 ],
301 next: null,
302 }),
303 commits: decodeCommits({
304 items: [{ oid: "abcdef", summary: "Change", time: 1_789_000_000 }],
305 headOid: "abcdef",
306 truncated: false,
307 next: null,
308 }),
309 host: "git.cafe",
310 });
311 assert.deepStrictEqual(
312 result.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]),
313 [
314 ["r1", "review", "approved"],
315 ["r2", "review", "dismissed"],
316 ],
317 );
318 assert.deepStrictEqual(
319 result.commits.map((commit) => commit.oid),
320 ["abcdef"],
321 );
322 });
323
324 it("maps supported reactions to the pull request and to matching comments in both views", () => {
325 const result = activity(
326 [
327 { ...lineComment, id: "comment-a", threadId: "comment-a" },
328 { ...lineComment, id: "comment-b", threadId: "comment-a" },
329 ],
330 {
331 reactions: {
332 next: null,
333 items: [
334 {
335 subject: { kind: "pull_request_comment", id: "comment-b" },
336 emoji: { kind: "unicode", value: "👍" },
337 count: 3,
338 viewerReactionId: "reaction-viewer",
339 reactors: [actor, { kind: "unavailable", actorId: "deleted" }],
340 },
341 {
342 subject: { kind: "pull_request", id: "pr_one" },
343 emoji: { kind: "unicode", value: "🚀" },
344 count: 1,
345 viewerReactionId: null,
346 reactors: [actor],
347 },
348 // Neither a custom emoji nor one outside the shared set is shown under another name.
349 {
350 subject: { kind: "pull_request", id: "pr_one" },
351 emoji: { kind: "custom", id: "party-parrot" },
352 count: 2,
353 viewerReactionId: null,
354 reactors: [actor],
355 },
356 {
357 subject: { kind: "pull_request", id: "pr_one" },
358 emoji: { kind: "unicode", value: "🔥" },
359 count: 1,
360 viewerReactionId: null,
361 reactors: [actor],
362 },
363 ],
364 },
365 },
366 );
367 const thumbsUp = {
368 content: "thumbs-up",
369 count: 3,
370 actors: ["alice"],
371 viewerHasReacted: true,
372 } as const;
373 assert.deepStrictEqual(result.reactions, [
374 { content: "rocket", count: 1, actors: ["alice"], viewerHasReacted: false },
375 ]);
376 assert.deepStrictEqual(
377 result.comments.map((comment) => [comment.id, comment.reactions]),
378 [
379 ["comment-a", []],
380 ["comment-b", [thumbsUp]],
381 ],
382 );
383 assert.deepStrictEqual(
384 result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.reactions]),
385 [
386 ["comment-a", []],
387 ["comment-b", [thumbsUp]],
388 ],
389 );
390 });
391});
392
393describe("toStack", () => {
394 it("orders members by position and reports heads only where GitCafe gives them", () => {
395 const { stack } = decodeStackEnvelope({
396 stack: {
397 id: "stack-one",
398 number: 3,
399 revision: 1,
400 landingBase: "main",
401 members: [
402 {
403 pullRequestNumber: 7,
404 title: "Top",
405 state: "open",
406 draft: true,
407 sourceBranch: "feature",
408 position: 2,
409 },
410 {
411 pullRequestNumber: 6,
412 title: "Base",
413 state: "merged",
414 draft: false,
415 sourceBranch: "base",
416 headOid: "0123456789abcdef0123456789abcdef01234567",
417 position: 1,
418 },
419 ],
420 },
421 });
422 assert.isNotNull(stack);
423 const result = GitCafePullRequestJson.toStack(stack!, {
424 repository: "owner/repo",
425 host: "staging.git.cafe",
426 });
427 assert.strictEqual(result.url, "https://staging.git.cafe/owner/repo/stacks/3");
428 assert.strictEqual(result.base, "main");
429 assert.deepStrictEqual(result.layers, [
430 {
431 number: 6,
432 title: "Base",
433 headBranch: "base",
434 state: "merged",
435 isDraft: false,
436 headSha: "0123456789abcdef0123456789abcdef01234567",
437 },
438 { number: 7, title: "Top", headBranch: "feature", state: "open", isDraft: true },
439 ]);
440 });
441
442 it("reads an unstacked pull request as no stack", () => {
443 assert.isNull(decodeStackEnvelope({ stack: null }).stack);
444 });
445});
446
447describe("toDiff", () => {
448 it("converts numeric hunk coordinates into a unified patch", () => {
449 const diff = decodeDiff({
450 items: [
451 {
452 path: "file.ts",
453 oldPath: null,
454 status: "modified",
455 additions: 1,
456 deletions: 1,
457 binary: false,
458 hunks: [
459 {
460 oldStart: 1,
461 oldLines: 1,
462 newStart: 1,
463 newLines: 1,
464 lines: [
465 { origin: "-", content: "old\n" },
466 { origin: "+", content: "new\n" },
467 ],
468 },
469 ],
470 },
471 ],
472 truncated: false,
473 });
474 assert.include(GitCafePullRequestJson.toDiff(diff).patch, "@@ -1,1 +1,1 @@\n-old\n+new\n");
475 });
476
477 it("marks an omitted patch incomplete even when GitCafe's own flag is false", () => {
478 const diff = decodeDiff({
479 items: [{ path: "large.ts", oldPath: null, status: "modified", tooLarge: true, hunks: [] }],
480 truncated: false,
481 });
482 const result = GitCafePullRequestJson.toDiff(diff);
483 assert.isTrue(result.truncated);
484 assert.deepStrictEqual(result.omittedFileStats, [
485 { path: "large.ts", additions: 0, deletions: 0 },
486 ]);
487 });
488
489 it("quotes renamed and binary paths", () => {
490 const { patch } = GitCafePullRequestJson.toDiff(
491 decodeDiff({
492 items: [
493 {
494 path: "new image.png",
495 oldPath: "old image.png",
496 status: "renamed",
497 binary: true,
498 hunks: [],
499 },
500 ],
501 truncated: false,
502 }),
503 );
504 // Git quotes a name for the bytes a header can't carry, not for a space.
505 assert.include(patch, "rename from old image.png");
506 assert.include(patch, "Binary files a/old image.png and b/new image.png differ");
507 });
508
509 it("C-escapes a name whose newline or tab would break the patch headers", () => {
510 const { patch } = GitCafePullRequestJson.toDiff(
511 decodeDiff({
512 items: [{ path: "two\nlines\tname.ts", status: "added", hunks: [] }],
513 truncated: false,
514 }),
515 );
516 assert.include(patch, 'diff --git "a/two\\nlines\\tname.ts" "b/two\\nlines\\tname.ts"');
517 assert.notInclude(patch, "two\nlines");
518 });
519});
520
521describe("toChecks", () => {
522 it("maps GitCafe's status and conclusion onto the shared check states", () => {
523 const check = (status: string, conclusion: string | null) => ({
524 name: `${status}-${conclusion}`,
525 status,
526 conclusion,
527 summary: null,
528 detailsUrl: null,
529 });
530 const checks = GitCafePullRequestJson.toChecks(
531 decodeChecks({
532 items: [
533 check("queued", null),
534 check("in_progress", null),
535 check("completed", "success"),
536 check("completed", "action_required"),
537 check("completed", "timed_out"),
538 ],
539 next: null,
540 }),
541 );
542 assert.deepStrictEqual(
543 checks.map((item) => item.status),
544 ["pending", "pending", "success", "action-required", "failure"],
545 );
546 });
547});
packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts created+578
......@@ -0,0 +1,578 @@
1/**
2 * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every
3 * conversion takes the repository's host, so a staging repository's links stay on staging.
4 *
5 * @module source-control-gitcafe/server/gitCafePullRequestJson
6 */
7import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
8import * as DateTime from "effect/DateTime";
9import * as Schema from "effect/Schema";
10import {
11 IsoDateTime,
12 NonNegativeInt,
13 PositiveInt,
14 TrimmedNonEmptyString,
15 type PullRequestActor,
16 type PullRequestCheck,
17 type PullRequestComment,
18 type PullRequestReaction,
19 type PullRequestReactionContent,
20 type PullRequestReviewThread,
21 type PullRequestViewerPermissions,
22} from "@t3tools/contracts";
23import type {
24 ProviderChangeRequest,
25 ProviderChangeRequestActivity,
26 ProviderChangeRequestStack,
27 ProviderDiffSlice,
28} from "@t3tools/source-control-core/server/PullRequestProvider";
29
30const Actor = Schema.Union([
31 Schema.Struct({
32 kind: Schema.Literal("local"),
33 actorId: Schema.String,
34 handle: TrimmedNonEmptyString,
35 displayName: Schema.NullOr(Schema.String),
36 avatarUrl: Schema.NullOr(Schema.String),
37 }),
38 Schema.Struct({
39 kind: Schema.Literal("github"),
40 actorId: Schema.String,
41 login: TrimmedNonEmptyString,
42 avatarUrl: Schema.NullOr(Schema.String),
43 linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))),
44 }),
45 Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }),
46]);
47type Actor = typeof Actor.Type;
48
49const PullState = Schema.Literals(["open", "closed", "merged"]);
50
51export const GitCafePull = Schema.Struct({
52 id: TrimmedNonEmptyString,
53 number: PositiveInt,
54 title: TrimmedNonEmptyString,
55 state: PullState,
56 draft: Schema.Boolean,
57 sourceBranch: Schema.String,
58 targetBranch: Schema.String,
59 headOid: Schema.NullOr(Schema.String),
60 author: Actor,
61 createdAt: IsoDateTime,
62 updatedAt: IsoDateTime,
63 version: NonNegativeInt,
64 reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))),
65 labels: Schema.optional(
66 Schema.Array(
67 Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }),
68 ),
69 ),
70});
71export type GitCafePull = typeof GitCafePull.Type;
72
73export const GitCafePulls = Schema.Struct({
74 items: Schema.Array(GitCafePull),
75 next: Schema.NullOr(Schema.String),
76});
77
78export const GitCafePullDetail = Schema.Struct({
79 ...GitCafePull.fields,
80 description: Schema.NullOr(Schema.String),
81 closedAt: Schema.NullOr(IsoDateTime),
82 mergedAt: Schema.NullOr(IsoDateTime),
83 sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })),
84 /** The base GitCafe last compared the pull request against, which a provider merge fences on. */
85 observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)),
86 mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])),
87 lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
88 capabilities: Schema.Struct({
89 comment: Schema.Boolean,
90 review: Schema.Boolean,
91 merge: Schema.Boolean,
92 edit: Schema.Boolean,
93 moderate: Schema.Boolean,
94 }),
95});
96export type GitCafePullDetail = typeof GitCafePullDetail.Type;
97
98const CommentCapabilities = Schema.Struct({
99 edit: Schema.Boolean,
100 hide: Schema.Boolean,
101 unhide: Schema.Boolean,
102 delete: Schema.Boolean,
103 resolve: Schema.Boolean,
104 unresolve: Schema.Boolean,
105});
106
107const GitCafeComment = Schema.Struct({
108 id: TrimmedNonEmptyString,
109 threadId: TrimmedNonEmptyString,
110 /** What a resolution or rewrite fences on; absent from older answers. */
111 version: Schema.optional(NonNegativeInt),
112 author: Actor,
113 body: Schema.NullOr(Schema.String),
114 createdAt: IsoDateTime,
115 path: Schema.NullOr(Schema.String),
116 // The API allows any integer here; only a positive line anchors a review thread.
117 line: Schema.NullOr(Schema.Int),
118 side: Schema.NullOr(Schema.Literals(["left", "right"])),
119 commitOid: Schema.NullOr(Schema.String),
120 resolvedAt: Schema.NullOr(IsoDateTime),
121 capabilities: Schema.optional(CommentCapabilities),
122});
123type GitCafeComment = typeof GitCafeComment.Type;
124
125export const GitCafeComments = Schema.Struct({
126 items: Schema.Array(GitCafeComment),
127 next: Schema.NullOr(Schema.String),
128});
129
130export const GitCafeReviews = Schema.Struct({
131 items: Schema.Array(
132 Schema.Struct({
133 id: TrimmedNonEmptyString,
134 author: Actor,
135 body: Schema.NullOr(Schema.String),
136 verdict: Schema.Literals(["approve", "request_changes", "comment"]),
137 dismissedAt: Schema.NullOr(IsoDateTime),
138 createdAt: IsoDateTime,
139 }),
140 ),
141 next: Schema.NullOr(Schema.String),
142});
143
144export const GitCafeCommits = Schema.Struct({
145 items: Schema.Array(
146 Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }),
147 ),
148 truncated: Schema.Boolean,
149 next: Schema.NullOr(Schema.String),
150 headOid: Schema.String,
151});
152
153export const GitCafeReactions = Schema.Struct({
154 items: Schema.Array(
155 Schema.Struct({
156 subject: Schema.Struct({
157 kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]),
158 id: TrimmedNonEmptyString,
159 }),
160 emoji: Schema.Union([
161 Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }),
162 Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }),
163 ]),
164 count: PositiveInt,
165 viewerReactionId: Schema.NullOr(Schema.String),
166 reactors: Schema.Array(Actor),
167 }),
168 ),
169 next: Schema.NullOr(Schema.String),
170});
171
172const GitCafeStack = Schema.Struct({
173 id: TrimmedNonEmptyString,
174 number: PositiveInt,
175 /** What a land or restack fences on. */
176 revision: PositiveInt,
177 landingBase: Schema.String,
178 members: Schema.Array(
179 Schema.Struct({
180 pullRequestNumber: PositiveInt,
181 title: Schema.String,
182 state: PullState,
183 draft: Schema.Boolean,
184 sourceBranch: Schema.String,
185 headOid: Schema.optional(Schema.String),
186 position: PositiveInt,
187 }),
188 ),
189});
190export type GitCafeStack = typeof GitCafeStack.Type;
191
192export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) });
193
194const GitCafeDiffFile = Schema.Struct({
195 path: TrimmedNonEmptyString,
196 oldPath: Schema.optional(Schema.NullOr(Schema.String)),
197 status: Schema.optional(
198 Schema.Literals([
199 "added",
200 "deleted",
201 "modified",
202 "typeChanged",
203 "conflicted",
204 "renamed",
205 "copied",
206 ]),
207 ),
208 additions: Schema.optional(NonNegativeInt),
209 deletions: Schema.optional(NonNegativeInt),
210 binary: Schema.optional(Schema.Boolean),
211 tooLarge: Schema.optional(Schema.Boolean),
212 hunksOmitted: Schema.optional(Schema.Boolean),
213 isSubmodule: Schema.optional(Schema.Boolean),
214 oldOid: Schema.optional(Schema.String),
215 newOid: Schema.optional(Schema.String),
216 // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them.
217 hunks: Schema.optional(
218 Schema.Array(
219 Schema.Struct({
220 oldStart: NonNegativeInt,
221 oldLines: NonNegativeInt,
222 newStart: NonNegativeInt,
223 newLines: NonNegativeInt,
224 lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })),
225 }),
226 ),
227 ),
228});
229export type GitCafeDiffFile = typeof GitCafeDiffFile.Type;
230export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile);
231
232export const GitCafeDiff = Schema.Struct({
233 items: GitCafeDiffFiles,
234 truncated: Schema.optional(Schema.Boolean),
235});
236
237export const GitCafeChecks = Schema.Struct({
238 items: Schema.Array(
239 Schema.Struct({
240 name: TrimmedNonEmptyString,
241 status: Schema.Literals(["queued", "in_progress", "completed"]),
242 conclusion: Schema.NullOr(Schema.String),
243 summary: Schema.NullOr(Schema.String),
244 detailsUrl: Schema.NullOr(Schema.String),
245 }),
246 ),
247 next: Schema.NullOr(Schema.String),
248});
249
250interface RepositoryOnHost {
251 readonly repository: string;
252 readonly host: string;
253}
254
255function toActor(actor: Actor, host: string): PullRequestActor | null {
256 if (actor.kind === "unavailable") return null;
257 return {
258 login: actor.kind === "local" ? actor.handle : actor.login,
259 name: actor.kind === "local" ? actor.displayName : null,
260 // GitCafe serves its own avatars by path, so a relative one is read against the host.
261 avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href,
262 };
263}
264
265/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */
266export function toReviewers(
267 pull: Pick<GitCafePull, "reviewers">,
268 host: string,
269): ReadonlyArray<PullRequestActor> {
270 return (pull.reviewers ?? []).flatMap(({ actor }) => {
271 const reviewer = toActor(actor, host);
272 return reviewer === null ? [] : [reviewer];
273 });
274}
275
276/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */
277function reviewerLogins(actor: Actor, host: string): ReadonlyArray<string> {
278 const reviewer = toActor(actor, host);
279 if (reviewer === null) return [];
280 return actor.kind === "github" && actor.linkedProfile
281 ? [reviewer.login, actor.linkedProfile.handle]
282 : [reviewer.login];
283}
284
285export function toChangeRequest(
286 pull: GitCafePull,
287 target: RepositoryOnHost,
288): ProviderChangeRequest {
289 return {
290 number: pull.number,
291 title: pull.title,
292 url: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
293 author: toActor(pull.author, target.host),
294 headBranch: pull.sourceBranch,
295 baseBranch: pull.targetBranch,
296 state: pull.state,
297 isDraft: pull.draft,
298 mergeability: "unknown",
299 additions: 0,
300 deletions: 0,
301 createdAt: pull.createdAt,
302 updatedAt: pull.updatedAt,
303 reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) =>
304 reviewerLogins(actor, target.host),
305 ),
306 labels: pull.labels ?? [],
307 };
308}
309
310/** What GitCafe's own capabilities let this viewer do with the pull request. */
311export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions {
312 const { comment, edit, review, merge, moderate } = detail.capabilities;
313 // A locked conversation keeps its composer for moderators only.
314 const open = comment && (detail.lockedAt == null || moderate);
315 return {
316 actions: [
317 // A merged pull request is settled; a closed one can only be reopened.
318 ...(edit && detail.state === "open"
319 ? ([detail.draft ? "ready" : "draft", "close"] as const)
320 : edit && detail.state === "closed"
321 ? (["reopen"] as const)
322 : []),
323 ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []),
324 ],
325 stackRebase: merge,
326 comment: open,
327 // Each thread's root narrows this further with its own `canResolve`.
328 resolve: open,
329 verdicts: review ? ["approve", "request-changes", "comment"] : [],
330 requestReviewers: false,
331 editChangeRequest: edit,
332 // GitCafe's only branch update moves a whole stack.
333 updateMethods: [],
334 };
335}
336
337const REACTIONS: Readonly<Record<string, PullRequestReactionContent>> = {
338 "👍": "thumbs-up",
339 "👎": "thumbs-down",
340 "😄": "laugh",
341 "🎉": "hooray",
342 "😕": "confused",
343 "❤": "heart",
344 "🚀": "rocket",
345 "👀": "eyes",
346};
347
348/** The emoji GitCafe takes for one of the shared reactions. */
349export const REACTION_EMOJI: Readonly<Record<PullRequestReactionContent, string>> = {
350 "thumbs-up": "👍",
351 "thumbs-down": "👎",
352 laugh: "😄",
353 hooray: "🎉",
354 confused: "😕",
355 heart: "❤️",
356 rocket: "🚀",
357 eyes: "👀",
358};
359
360/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */
361export function reactionContent(emoji: string): PullRequestReactionContent | undefined {
362 return REACTIONS[emoji.replaceAll("\uFE0F", "")];
363}
364
365function toReactions(data: typeof GitCafeReactions.Type, host: string) {
366 const reactions: Array<PullRequestReaction> = [];
367 const byComment = new Map<string, Array<PullRequestReaction>>();
368 for (const item of data.items) {
369 const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined;
370 if (content === undefined) continue;
371 const reaction: PullRequestReaction = {
372 content,
373 count: item.count,
374 actors: item.reactors.flatMap((actor) => {
375 const reactor = toActor(actor, host);
376 return reactor === null ? [] : [reactor.login];
377 }),
378 viewerHasReacted: item.viewerReactionId !== null,
379 };
380 if (item.subject.kind === "pull_request") reactions.push(reaction);
381 else if (item.subject.kind === "pull_request_comment")
382 byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]);
383 }
384 return { reactions, byComment };
385}
386
387const REVIEW_STATES = {
388 approve: "approved",
389 request_changes: "changes_requested",
390 comment: "commented",
391} as const;
392
393/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */
394export function toActivity(input: {
395 readonly comments: typeof GitCafeComments.Type;
396 readonly reviews: typeof GitCafeReviews.Type;
397 readonly commits: typeof GitCafeCommits.Type;
398 readonly reactions?: typeof GitCafeReactions.Type;
399 readonly headOid?: string;
400 readonly host: string;
401}): ProviderChangeRequestActivity {
402 const { host } = input;
403 const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host);
404 const remark = (comment: GitCafeComment) => ({
405 id: comment.id,
406 author: toActor(comment.author, host),
407 body: comment.body ?? "",
408 createdAt: comment.createdAt,
409 url: null,
410 ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }),
411 ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }),
412 });
413 // Stable, so remarks written in the same instant keep GitCafe's own order.
414 const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
415 const groups = new Map<string, Array<GitCafeComment>>();
416 for (const comment of ordered) {
417 if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null)
418 continue;
419 groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]);
420 }
421 const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => {
422 const first = group[0]!;
423 const latest = group.at(-1)!;
424 const root = group.find((comment) => comment.id === comment.threadId);
425 return {
426 id: first.threadId,
427 path: first.path!,
428 line: first.line,
429 side: first.side!,
430 isResolved: root?.resolvedAt != null,
431 isOutdated:
432 input.headOid !== undefined &&
433 latest.commitOid !== null &&
434 latest.commitOid !== input.headOid,
435 comments: group.map(remark),
436 ...(root?.capabilities === undefined
437 ? {}
438 : {
439 canResolve:
440 root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve,
441 }),
442 };
443 });
444 const comments: Array<PullRequestComment> = [
445 ...ordered.map((comment) => ({
446 ...remark(comment),
447 kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const),
448 path: comment.path,
449 reviewState: null,
450 })),
451 ...input.reviews.items.map((review) => ({
452 id: review.id,
453 kind: "review" as const,
454 author: toActor(review.author, host),
455 body: review.body ?? "",
456 createdAt: review.createdAt,
457 url: null,
458 path: null,
459 reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict],
460 })),
461 ];
462 return {
463 comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)),
464 commentCount: input.comments.items.length + input.reviews.items.length,
465 commentsTruncated: input.comments.next !== null || input.reviews.next !== null,
466 reviewThreads,
467 commits: input.commits.items.map((commit) => ({
468 oid: commit.oid,
469 messageHeadline: commit.summary,
470 committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)),
471 })),
472 ...(reactions === undefined ? {} : { reactions: reactions.reactions }),
473 };
474}
475
476export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack {
477 return {
478 id: stack.id,
479 number: stack.number,
480 url: `https://${target.host}/${target.repository}/stacks/${stack.number}`,
481 base: stack.landingBase,
482 layers: stack.members
483 .toSorted((a, b) => a.position - b.position)
484 .map((member) => ({
485 number: member.pullRequestNumber,
486 title: member.title,
487 headBranch: member.sourceBranch,
488 state: member.state,
489 isDraft: member.draft,
490 ...(member.headOid === undefined ? {} : { headSha: member.headOid }),
491 })),
492 };
493}
494
495/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */
496export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice {
497 const chunks: Array<string> = [];
498 const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = [];
499 for (const file of diff.items) {
500 const oldPath = file.oldPath ?? file.path;
501 const a = quoteGitPatchPath(`a/${oldPath}`);
502 const b = quoteGitPatchPath(`b/${file.path}`);
503 const before = file.status === "added" ? "/dev/null" : a;
504 const after = file.status === "deleted" ? "/dev/null" : b;
505 const mode = file.isSubmodule ? "160000" : "100644";
506 const lines = [`diff --git ${a} ${b}`];
507 if (file.status === "added") lines.push(`new file mode ${mode}`);
508 if (file.status === "deleted") lines.push(`deleted file mode ${mode}`);
509 if (file.status === "renamed" || file.status === "copied") {
510 const action = file.status === "renamed" ? "rename" : "copy";
511 lines.push(
512 `${action} from ${quoteGitPatchPath(oldPath)}`,
513 `${action} to ${quoteGitPatchPath(file.path)}`,
514 );
515 }
516 if (file.binary) {
517 lines.push(`Binary files ${before} and ${after} differ`);
518 } else if (file.tooLarge || file.hunksOmitted) {
519 omittedFileStats.push({
520 path: file.path,
521 additions: file.additions ?? 0,
522 deletions: file.deletions ?? 0,
523 });
524 } else if (file.isSubmodule) {
525 const hasOld = file.status !== "added" && file.oldOid !== undefined;
526 const hasNew = file.status !== "deleted" && file.newOid !== undefined;
527 lines.push(`--- ${before}`, `+++ ${after}`);
528 lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`);
529 if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`);
530 if (hasNew) lines.push(`+Subproject commit ${file.newOid}`);
531 } else {
532 const hunks = file.hunks ?? [];
533 if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`);
534 for (const hunk of hunks) {
535 lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
536 for (const line of hunk.lines) {
537 // GitCafe marks a missing final newline with libgit2's own origins.
538 if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file");
539 else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`);
540 }
541 }
542 }
543 chunks.push(lines.join("\n"));
544 }
545 return {
546 patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`,
547 truncated: diff.truncated === true || omittedFileStats.length > 0,
548 nextCursor: null,
549 ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }),
550 };
551}
552
553function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] {
554 if (check.status !== "completed") return "pending";
555 switch (check.conclusion) {
556 case "success":
557 return "success";
558 case "neutral":
559 return "neutral";
560 case "skipped":
561 return "skipped";
562 case "cancelled":
563 return "cancelled";
564 case "action_required":
565 return "action-required";
566 default:
567 return "failure";
568 }
569}
570
571export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray<PullRequestCheck> {
572 return checks.items.map((check) => ({
573 name: check.name,
574 status: checkStatus(check),
575 description: check.summary,
576 url: check.detailsUrl,
577 }));
578}
packages/source-control-gitcafe/tsconfig.json created+7
......@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
pnpm-lock.yaml+34
......@@ -660,6 +660,9 @@ importers:
660660 '@t3tools/source-control-forgejo':
661661 specifier: workspace:*
662662 version: link:../../packages/source-control-forgejo
663 '@t3tools/source-control-gitcafe':
664 specifier: workspace:*
665 version: link:../../packages/source-control-gitcafe
663666 '@t3tools/source-control-github':
664667 specifier: workspace:*
665668 version: link:../../packages/source-control-github
......@@ -1643,6 +1646,37 @@ importers:
16431646 specifier: 1.0.0
16441647 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
16451648
1649 packages/source-control-gitcafe:
1650 dependencies:
1651 '@t3tools/contracts':
1652 specifier: workspace:*
1653 version: link:../contracts
1654 '@t3tools/provider-core':
1655 specifier: workspace:*
1656 version: link:../provider-core
1657 '@t3tools/shared':
1658 specifier: workspace:*
1659 version: link:../shared
1660 '@t3tools/source-control-core':
1661 specifier: workspace:*
1662 version: link:../source-control-core
1663 effect:
1664 specifier: 4.0.2
1665 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1666 devDependencies:
1667 '@effect/vitest':
1668 specifier: 4.0.2
1669 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1670 '@t3tools/source-control-testing':
1671 specifier: workspace:*
1672 version: link:../source-control-testing
1673 '@types/node':
1674 specifier: 24.12.4
1675 version: 24.12.4
1676 vite-plus:
1677 specifier: 1.0.0
1678 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1679
16461680 packages/source-control-github:
16471681 dependencies:
16481682 '@t3tools/contracts':
scripts/release-smoke.ts+1
......@@ -41,6 +41,7 @@ const workspaceFiles = [
4141 "packages/source-control-bitbucket/package.json",
4242 "packages/source-control-core/package.json",
4343 "packages/source-control-forgejo/package.json",
44 "packages/source-control-gitcafe/package.json",
4445 "packages/source-control-github/package.json",
4546 "packages/source-control-gitlab/package.json",
4647 "packages/source-control-testing/package.json",