diff --git a/apps/server/package.json b/apps/server/package.json index 3dd02fcdcb5e568539910b2677d6a16dc6ca0646..1147b5e06233f8f7f136cdbd96cc18618ae7907d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-gitcafe": "workspace:*", "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b4503fb7535feeef3d4c28f4b7c312ed7a67811e..8fa9201b94ca8a89c1a5a5ed605d59d9468f9aeb 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -25,6 +25,7 @@ import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/Git import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; @@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( @@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "unknown", account: Option.none(), }, + { + kind: "gitcafe", + status: "missing", + auth: "unknown", + account: Option.none(), + }, ], ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); @@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user ) { return Effect.succeed(processOutput("azure-user@example.com\n")); } + if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") { + return Effect.succeed( + processOutput( + encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }), + ), + ); + } return Effect.fail( new VcsProcessSpawnError({ operation: input.operation, @@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user account: Option.some("forgejo-user"), detail: Option.none(), }, + { + kind: "gitcafe", + auth: "authenticated", + account: Option.some("cafe-user"), + detail: Option.none(), + }, ], ); }).pipe(Effect.provide(layerTest)); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index bfed745ca2955f839a8c92716b72e9ccf78742ed..1fab84a6acd0d439b0323c85eb14fdab5bf58f31 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -24,6 +24,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -126,6 +127,7 @@ function makeRegistry(input: { Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 2e830dec803e317cb2c354333aad3d1fd293e20c..1b891ce9259aa73da6d18c62b01eacbf8923dafc 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; @@ -32,6 +35,7 @@ const drivers = [ AzureDevOpsDriver.driver, BitbucketDriver.driver, ForgejoDriver.driver, + GitCafeDriver.driver, ]; /** 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( AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), ForgejoCli.layer, + GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37d80d33b7df026421e5cbc0ee7e621bcd2c1b1d..48c4b20a777f64dedae7d099552474ff534f64f6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -187,7 +187,14 @@ import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + GitCafeIcon, + GitHubIcon, + GitLabIcon, + ForgejoIcon, +} from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "forgejo", "bitbucket", "azure-devops", + "gitcafe", ]; const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ "github", @@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray; case "azure-devops": return ; + case "gitcafe": + return ; case "url": return ; } @@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness( forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, + gitcafe: unavailable, }; if (!discovery) { @@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: { "bitbucket", "azure", "devops", + "gitcafe", "url", "environment", ], diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index cfe45ff24cc4e60f25d3e07b0e70e594ff5994ac..2b684cd0cd4e843d24b7dfc9e98a8bdbf4ebbdd2 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -50,6 +50,7 @@ import { GitHubIcon, GitLabIcon, ForgejoIcon, + GitCafeIcon, } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; @@ -158,7 +159,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type GitActionToastId = ReturnType; @@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ pathPlaceholder: "project/repository", Icon: AzureDevOpsIcon, }, + { + value: "gitcafe", + label: "GitCafe", + description: "git.cafe", + host: "git.cafe", + pathPlaceholder: "owner/repo", + Icon: GitCafeIcon, + }, ] as const satisfies ReadonlyArray<{ readonly value: PublishProviderKind; readonly label: string; @@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { forgejo: null, bitbucket: null, "azure-devops": null, + gitcafe: null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { if (isPublishProviderKind(provider.kind)) { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 3e4d53d55b63f2ae584045ba5db8d3e08ad4e86f..909b9c7456f3d16c373256513278fd0a558614dc 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => ( ); +// GitCafe's own mark, from https://git.cafe/favicon.svg, inset so the full-bleed square does not +// read heavier than the round and cut-out marks beside it. +export const GitCafeIcon: Icon = (props) => ( + +); + // macOS System Settings > Privacy & Security pane marks, so the SnapShot setup // step points at the same icon the user is about to look for. export const MacScreenRecordingIcon: Icon = (props) => { diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 2ad3ef64448ed187ff911725abdbb4e73c606da2..3ae07b403063a1623e7528a31d7a978ff42804a5 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => { sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, }); }); + it.each([ + ["https://git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://staging.git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://codeberg.org/owner/repo/pulls/42", "forgejo"], + ["https://gitlab.example.com/acme/web/-/merge_requests/42", "gitlab"], + ["https://github.com/owner/repo/pull/42", "github"], + ])("classifies %s as %s", (url, kind) => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + url, + snapshot: { + state: "open", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result?.sourceControlProvider.kind).toBe(kind); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ad0c9c99a29212514c37d35b69478a801ad195b6..8368dbb0eac9fce136b88da1b1529d3272b36bdf 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,6 +6,7 @@ import { import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveEnvironmentMachineKind, type EnvironmentId, @@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus( ): LinkedThreadPullRequestStatus | null { const snapshot = link.snapshot; if (snapshot === null) return null; - const kind = link.url.includes("/-/merge_requests/") - ? "gitlab" - : link.url.includes("/pullrequest/") - ? "azure-devops" - : link.url.includes("/pull-requests/") - ? "bitbucket" - : link.url.includes("/pulls/") - ? "forgejo" - : "github"; + // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first. + const kind = + detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe" + ? "gitcafe" + : link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : link.url.includes("/pulls/") + ? "forgejo" + : "github"; return { pr: { number: link.number, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 00d3aafd2d2fb26106245799a9ef876419b72784..083f2c8db22ab4fc8342e6c5383c50aa789ee1a5 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand( : null; case "azure-devops": return `az repos pr checkout --id ${number}`; + case "gitcafe": + return `cafe pr checkout ${number}`; case "bitbucket": { if ( !headRepositoryNameWithOwner || diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16c4fa5d3559aa36700f5f75f85c0b9c8ebd3cd6..6b673ad9832523db0e2deacc18d0f9c6fe4e5528 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial> = { gitlab: "Open on GitLab", forgejo: "Open on Forgejo", bitbucket: "Open on Bitbucket", + gitcafe: "Open on GitCafe", "azure-devops": "Open on Azure DevOps", }; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 978d7a57105d025d8f96cc0dc0bfcc36f83faf3b..68465b06d0fd584303f724ebb041c344e647ba3e 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -49,6 +49,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AzureDevOpsIcon, BitbucketIcon, + GitCafeIcon, GitHubIcon, GitIcon, GitLabIcon, @@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 7a8d2cab8fe516f1651c4f604e7c0fd26ab2555c..61a1befd6099ab1cfc6c88662a362365c4b1d898 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -16,6 +16,7 @@ import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, + GitCafeIcon, GitHubIcon, GitLabIcon, } from "./components/Icons"; @@ -62,6 +63,12 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: BitbucketIcon, }; + case "gitcafe": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitCafeIcon, + }; case "change-request": return { providerName: provider?.name || presentation.providerName, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 23b44f7992bbe9607410f180471ae011a0d30cae..f760678136dc0e98a8571b208cd915d5fb25cd32 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 3730eb6a18d436ba2fc8007f11af5e4d39dda6cd..b0452974f1c1120a45be32ec28e70e36857d8824 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,7 +1,7 @@ # Source control -T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish -repositories, create pull requests, and review changes. +T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone +and publish repositories, create pull requests, and review changes. ## Review turn changes @@ -96,6 +96,22 @@ az extension add --name azure-devops az login ``` +### GitCafe + +Install the GitCafe CLI and sign in: + +```bash +bun install -g @gitcafe/cli +cafe auth login --host https://git.cafe/api +``` + +Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only +sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on +`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`. + +Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet. +Merging a stack lands it through GitCafe, and updating a branch restacks the layers above it. + ## Start, clone, or publish a project To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or diff --git a/package.json b/package.json index ba161940d2f7a3117b808150405ce45666318044..e21d895fd656d2ff08e23b43f8f00f25f3a7a7b2 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "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", + "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", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 06934e93c740b67fed8e4ef41c4bb483eefc5bcb..c4d48e3ccd03b3623c5fa5d87b945b0748c0c1c4 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ "forgejo", "azure-devops", "bitbucket", + "gitcafe", "unknown", ]); export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5be8d355a166d4262c251231bd24630f885d5760..265c31c157fbfe2a034f8729564c21b8c13363bc 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -48,6 +48,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); + // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n} + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -96,6 +101,8 @@ export function changeRequestUrlFor( switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; + case "gitcafe": + return `https://${host}/${repository}/pulls/${number}`; case "forgejo": { try { const remote = new URL(remoteUrl ?? ""); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index ddfa3bdf9b4659e12fdeba7a0d8b2f79f328db59..be09c017767e76e20ebd97557c6f826789903644 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,14 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: + | "github" + | "gitlab" + | "forgejo" + | "azure-devops" + | "bitbucket" + | "gitcafe" + | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; +const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitcafe", + providerName: "GitCafe", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitCafe pull request", + checkoutCommandExample: "cafe pr checkout 123", + urlExample: "https://git.cafe/owner/repo/pulls/42", +}; + const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "change-request", providerName: "source control", @@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation( return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": return BITBUCKET_CHANGE_REQUEST_PRESENTATION; + case "gitcafe": + return GITCAFE_CHANGE_REQUEST_PRESENTATION; case "unknown": return GENERIC_CHANGE_REQUEST_PRESENTATION; } @@ -205,6 +225,11 @@ function isAzureDevOpsHost(host: string): boolean { ); } +/** GitCafe has no self-hosted installs: production and staging are the only hosts. */ +function isGitCafeHost(host: string): boolean { + return host === "git.cafe" || host === "staging.git.cafe"; +} + function isBitbucketHost(host: string): boolean { return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } @@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl( } const hostname = parseHostName(host); + if (isGitCafeHost(hostname)) { + return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; + } + if ( hostname === "codeberg.org" || hasDnsLabel(hostname, "forgejo") || diff --git a/packages/source-control-gitcafe/package.json b/packages/source-control-gitcafe/package.json new file mode 100644 index 0000000000000000000000000000000000000000..eeda68c5e92de81eae5ebc791785fc47bd88cd51 --- /dev/null +++ b/packages/source-control-gitcafe/package.json @@ -0,0 +1,52 @@ +{ + "name": "@t3tools/source-control-gitcafe", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/gitCafeHosts": { + "types": "./src/server/gitCafeHosts.ts", + "import": "./src/server/gitCafeHosts.ts" + }, + "./server/GitCafeApi": { + "types": "./src/server/GitCafeApi.ts", + "import": "./src/server/GitCafeApi.ts" + }, + "./server/GitCafeCredentials": { + "types": "./src/server/GitCafeCredentials.ts", + "import": "./src/server/GitCafeCredentials.ts" + }, + "./server/gitCafePullRequestJson": { + "types": "./src/server/gitCafePullRequestJson.ts", + "import": "./src/server/gitCafePullRequestJson.ts" + }, + "./server/GitCafePullRequestProvider": { + "types": "./src/server/GitCafePullRequestProvider.ts", + "import": "./src/server/GitCafePullRequestProvider.ts" + }, + "./server/GitCafeSourceControlProvider": { + "types": "./src/server/GitCafeSourceControlProvider.ts", + "import": "./src/server/GitCafeSourceControlProvider.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..949ece637faade86a9a5d6c2a486b104cb10488c --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -0,0 +1,176 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +const NO_REDIRECT: RequestInit = { redirect: "manual" }; +const TIMEOUT = Duration.seconds(30); + +/** + * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token + * disables the provider, a limit pauses the host, and the rest belong to the one request. + */ +export const GitCafeApiFailure = Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", +]); + +export class GitCafeApiError extends Schema.TaggedError()("GitCafeApiError", { + host: Schema.String, + operation: Schema.String, + /** GitCafe's HTTP status; null when no answer arrived. */ + status: Schema.NullOr(Schema.Int), + reason: GitCafeApiFailure, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `GitCafe ${this.operation} failed on ${this.host}: ${this.detail}`; + } +} + +export interface GitCafeApiRequest { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */ + readonly path: string; + readonly body?: unknown; +} + +/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */ +export class GitCafeApi extends Context.Service< + GitCafeApi, + { + /** The body of a successful answer. */ + readonly request: (input: GitCafeApiRequest) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeApi") {} + +/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */ +const decodeProblem = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + title: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), + }), + ), +); + +const reasonForStatus = (status: number): typeof GitCafeApiFailure.Type => + status === 401 + ? "unauthenticated" + : status === 429 + ? "rate-limited" + : status === 404 + ? "not-found" + : "failed"; + +const MAX_PROBLEM_TEXT = 300; +const boundedProblemText = (text: string | undefined) => { + const line = text?.replace(/\s+/gu, " ").trim(); + if (!line) return undefined; + return line.length > MAX_PROBLEM_TEXT ? `${line.slice(0, MAX_PROBLEM_TEXT)}…` : line; +}; + +const credentialFailure = ( + error: GitCafeCredentials.GitCafeCredentialUnavailableError, +): typeof GitCafeApiFailure.Type => + error._tag === "GitCafeCliMissingError" + ? "missing-tool" + : error._tag === "GitCafeNotSignedInError" + ? "unauthenticated" + : "failed"; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + + const request = Effect.fn("GitCafeApi.request")(function* (input: GitCafeApiRequest) { + const fail = ( + fields: Pick & { cause?: unknown }, + ) => + new GitCafeApiError({ + host: input.host, + operation: input.operation, + status: fields.status, + reason: fields.reason, + detail: fields.detail, + ...(fields.cause === undefined ? {} : { cause: fields.cause }), + }); + // The host comes from a remote URL, so a token never leaves for one GitCafe does not own. + const host = GitCafeHosts.gitCafeHost(input.host); + if (host === null) + return yield* fail({ + status: null, + reason: "failed", + detail: `GitCafe lives on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}, not ${input.host}.`, + }); + yield* Effect.annotateCurrentSpan({ + "gitcafe.host": host, + "gitcafe.operation": input.operation, + "http.request.method": input.method ?? "GET", + }); + const credential = yield* credentials + .get(host) + .pipe( + Effect.mapError((cause) => + fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }), + ), + ); + const base = HttpClientRequest.make(input.method ?? "GET")( + `https://${host}/api${input.path}`, + ).pipe( + HttpClientRequest.bearerToken(Redacted.value(credential.token)), + HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }), + ); + const { status, text } = yield* Effect.gen(function* () { + const response = yield* httpClient + .execute( + input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), + ) + .pipe( + // The client's own span would record the query string, which can carry branch names. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + // fetch would follow a redirect itself and keep the bearer token on a same-origin hop; + // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure. + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + ); + return { status: response.status, text: yield* response.text }; + }).pipe( + Effect.timeout(TIMEOUT), + Effect.mapError((cause) => + fail({ status: null, reason: "failed", detail: "Could not reach GitCafe.", cause }), + ), + ); + yield* Effect.annotateCurrentSpan({ "http.response.status_code": status }); + if (status >= 200 && status < 300) return text; + // The source may hold a newer token than the one that was refused. + if (status === 401) yield* credentials.invalidate(host); + const problem = Option.getOrUndefined(decodeProblem(text)); + return yield* fail({ + status, + reason: reasonForStatus(status), + // GitCafe's own explanation is what the user acts on; it is kept short and single-line. + detail: + boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`, + }); + }); + + return GitCafeApi.of({ request }); +}); + +export const layer = Layer.effect(GitCafeApi, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0f8be74711f6d37e41104717b723b8606c9c34e --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -0,0 +1,155 @@ +/** + * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates: + * + * - `class GitCafeCredentials extends Context.Service` keyed + * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with + * `get(host) => Effect` and + * `invalidate(host) => Effect`. + * - `interface GitCafeCredential { host; token: Redacted; source: "env" | "cafe" }`. + * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`, + * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`). + * - `environmentToken(host, env): string | null` — `CAFE_TOKEN` only for the host `CAFE_HOST` + * names (git.cafe when unset). + * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and + * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; + +const processOutput = TestSourceControlHost.processOutput; + +function harness( + run: SourceControlHost.SourceControlHost["Service"]["process"]["run"], + env: NodeJS.ProcessEnv = {}, +) { + const calls: Array = []; + const layer = GitCafeCredentials.layer.pipe( + Layer.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(input); + return run(input); + }, + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, env)), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + ); + return { layer, calls }; +} + +describe("GitCafeCredentials", () => { + it("hands CAFE_TOKEN only to the host cafe itself targets", () => { + const token = { CAFE_TOKEN: "env-token" }; + assert.strictEqual(GitCafeCredentials.environmentToken("git.cafe", token), "env-token"); + assert.isNull(GitCafeCredentials.environmentToken("staging.git.cafe", token)); + for (const CAFE_HOST of ["https://staging.git.cafe/api", "staging.git.cafe"]) { + const staging = { ...token, CAFE_HOST }; + assert.strictEqual( + GitCafeCredentials.environmentToken("staging.git.cafe", staging), + "env-token", + ); + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", staging)); + } + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", { CAFE_TOKEN: " " })); + }); + + it.effect("prefers CAFE_TOKEN without running cafe", () => { + const { layer, calls } = harness(() => Effect.die("cafe must not run"), { + CAFE_TOKEN: "env-token", + }); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const credential = yield* credentials.get("git.cafe"); + assert.strictEqual(Redacted.value(credential.token), "env-token"); + assert.strictEqual(credential.source, "env"); + assert.deepStrictEqual(calls, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => { + const { layer, calls } = harness(() => + Effect.succeed( + processOutput("protocol=https\nhost=staging.git.cafe\nusername=alice\npassword=gct_1\n"), + ), + ); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + // A token for staging is never taken from an environment that targets production. + const credential = yield* credentials.get("Staging.Git.Cafe"); + assert.strictEqual(Redacted.value(credential.token), "gct_1"); + assert.strictEqual(credential.source, "cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]?.command, "cafe"); + assert.deepStrictEqual(calls[0]?.args, [ + "auth", + "http", + "credential", + "--host", + "https://staging.git.cafe/api", + "get", + ]); + assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n"); + // A refused token is asked for again rather than reused. + yield* credentials.invalidate("staging.git.cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 2); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each([ + [ + "a missing CLI", + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + "GitCafeCliMissingError", + ], + [ + "a helper with no login", + Effect.fail( + new VcsProcessExitError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + exitCode: 1, + detail: "not signed in", + }), + ), + "GitCafeNotSignedInError", + ], + [ + "an answer without a password", + Effect.succeed(processOutput("username=alice\n")), + "GitCafeNotSignedInError", + ], + ] as const)("reports %s", ([, result, tag]) => { + const { layer } = harness(() => result); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const error = yield* credentials.get("git.cafe").pipe(Effect.flip); + assert.strictEqual(error._tag, tag); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts new file mode 100644 index 0000000000000000000000000000000000000000..c47e9ce1153f37b0f1205d9a572596b040a5cfba --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -0,0 +1,166 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export interface GitCafeCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: "env" | "cafe"; +} + +/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ +export class GitCafeCliMissingError extends Schema.TaggedError()( + "GitCafeCliMissingError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: set CAFE_TOKEN, or install the GitCafe CLI and run \`cafe auth login\`.`; + } +} + +/** `cafe` is installed but holds no login for the host. */ +export class GitCafeNotSignedInError extends Schema.TaggedError()( + "GitCafeNotSignedInError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: run \`cafe auth login --host https://${this.host}/api\`.`; + } +} + +/** `cafe` timed out or failed for a reason other than having no login. */ +export class GitCafeCliFailedError extends Schema.TaggedError()( + "GitCafeCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitCafe CLI could not hand over a credential for ${this.host}. Check \`cafe auth status\` on the server.`; + } +} + +export type GitCafeCredentialUnavailableError = + | GitCafeCliMissingError + | GitCafeNotSignedInError + | GitCafeCliFailedError; + +/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */ +export class GitCafeCredentials extends Context.Service< + GitCafeCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} + +const normalizeHost = (host: string) => host.trim().toLowerCase(); + +/** + * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by + * default): a remote URL picks the host, and must not steer a production token to staging. + */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const configured = env.CAFE_HOST?.trim(); + const configuredHost = configured + ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host + : "git.cafe"; + if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null; + return env.CAFE_TOKEN?.trim() || null; +} + +/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */ +function credentialPassword(output: string): string | null { + for (const line of output.split(/\r?\n/u)) { + if (line.startsWith("password=")) return line.slice("password=".length).trim() || null; + } + return null; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + const environment = yield* HostProcess.Environment; + const workingDirectory = yield* HostProcess.WorkingDirectory; + + /** + * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand + * the stored login to another program, and answers over stdin/stdout so the token never + * reaches an argument list or a log. + */ + const fromCafe = (host: string) => + process + .run({ + operation: "GitCafeCredentials.get", + command: "cafe", + args: ["auth", "http", "credential", "--host", `https://${host}/api`, "get"], + cwd: workingDirectory, + stdin: `protocol=https\nhost=${host}\n\n`, + env: { CAFE_NO_UPDATE_CHECK: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.mapError((error) => + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitCafeCliMissingError({ host }) + : error._tag === "VcsProcessExitError" + ? new GitCafeNotSignedInError({ host }) + : new GitCafeCliFailedError({ host, cause: error }), + ), + Effect.map((output) => credentialPassword(output.stdout)), + Effect.filterOrFail( + (token): token is string => token !== null, + () => new GitCafeNotSignedInError({ host }), + ), + ); + + const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) { + const fromEnv = environmentToken(host, environment); + const token = fromEnv ?? (yield* fromCafe(host)); + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "cafe", + } satisfies GitCafeCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 4, + // A transient failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitCafeCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, + }); + + return GitCafeCredentials.of({ + get: (host) => Cache.get(cache, normalizeHost(host)), + invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + }); +}); + +export const layer = Layer.effect(GitCafeCredentials, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0603dfec80ec0d035a71854c241f9d4c9367ec10 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -0,0 +1,751 @@ +/** + * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer + * supports. Pass 2 creates: + * + * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`) + * with `request({ host, operation, method?, path, body? }) => Effect` + * (the response body), sending `https:///api` with the `GitCafeCredentials` bearer + * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`. + * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited. + * - `./GitCafePullRequestProvider.ts`: `make: Effect` + * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment, + * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by + * `expectedStackHeads`. + * + * Every write carries a fresh `requestId`, which GitCafe requires. + * + * Out of scope here (layer 3): review-revision fencing, async outcome polling. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +interface SentRequest { + readonly method: string; + /** The path below `/api`, without its query. */ + readonly path: string; + readonly query: URLSearchParams; + readonly host: string; + readonly body: unknown; + /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */ + readonly redirect: string | undefined; +} + +/** + * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404 + * problem document. Every request is recorded in order. + */ +function fakeGitCafe( + routes: Record unknown)>, + options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {}, +) { + const sent: Array = []; + const client = HttpClient.make( + (request: HttpClientRequest.HttpClientRequest, _url, _signal, fiber) => { + const url = new URL(request.url); + assert.isTrue(url.pathname.startsWith("/api/"), url.pathname); + assert.strictEqual(request.headers.authorization, "Bearer env-token"); + const entry: SentRequest = { + method: request.method, + path: url.pathname.slice("/api".length), + query: url.searchParams, + host: url.host, + body: + request.body._tag === "Uint8Array" + ? decodeJson(new TextDecoder().decode(request.body.body)) + : undefined, + redirect: Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect, + }; + sent.push(entry); + const route = routes[`${entry.method} ${entry.path}`]; + const response = + route === undefined + ? new Response(encodeJson({ type: "https://cafe.sh/errors/not-found" }), { status: 404 }) + : new Response(encodeJson(typeof route === "function" ? route(entry) : route), { + status: options.status ?? 200, + }); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }, + ); + const layer = GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provide(TestSourceControlHost.layer()), + // An environment token, so no test runs `cafe`. + Layer.provide( + Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ); + const writes = () => + sent.filter((request) => request.method !== "GET").map((r) => `${r.method} ${r.path}`); + return { layer, sent, writes }; +} + +const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 }; +const timestamp = "2026-09-12T12:00:00Z"; +const headOid = "0123456789abcdef0123456789abcdef01234567"; +const baseOid = "89abcdef0123456789abcdef0123456789abcdef"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid, + observedBaseOid: baseOid, + mergeRoute: "provider", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 4, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const root = { + id: "root", + threadId: "root", + version: 2, + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: headOid, + resolvedAt: null, + capabilities: { + edit: true, + hide: false, + unhide: false, + delete: false, + resolve: true, + unresolve: false, + }, +}; +/** + * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged + * layer's head comes from its own pull. + */ +const stack = ( + members: ReadonlyArray<{ number: number; headOid: string; state?: string }>, +): Record => ({ + "GET /repos/owner/repo/pulls/7/stack": { + stack: { + id: "stack-one", + number: 3, + revision: 5, + landingBase: "main", + members: members.map((member, index) => ({ + pullRequestNumber: member.number, + title: `Layer ${member.number}`, + state: member.state ?? "open", + draft: false, + sourceBranch: `layer-${member.number}`, + position: index + 1, + })), + }, + }, + ...Object.fromEntries( + members.map((member) => [ + `GET /repos/owner/repo/pulls/${member.number}`, + { ...pull, number: member.number, headOid: member.headOid }, + ]), + ), +}); + +/** GitCafe refuses a write without an idempotency key. */ +const assertRequestId = (body: unknown) => + assert.match( + (body as { readonly requestId?: unknown } | undefined)?.requestId as string, + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + +describe("GitCafePullRequestProvider", () => { + it.effect("lists a repository's pull requests from its pulls collection", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "all", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual( + page.items.map((item) => item.number), + [7], + ); + assert.deepStrictEqual( + server.sent.map((request) => `${request.method} ${request.path}`), + ["GET /repos/owner/repo/pulls"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps a staging repository's requests and links on staging", () => { + // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target. + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7": pull }, + { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const summary = yield* provider.getChangeRequestSummary!({ + ...target, + host: "staging.git.cafe", + }); + assert.strictEqual(summary.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.isTrue(server.sent.every((request) => request.host === "staging.git.cafe")); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses a repository outside GitCafe's hosts before sending anything", () => { + const server = fakeGitCafe({}); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .getChangeRequest({ ...target, host: "github.com" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.sent, []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [401, "unauthenticated"], + [429, "rate-limited"], + ] as const) { + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7/stack": { stack: null } }, + { + status, + }, + ); + const error = yield* GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.flip, + Effect.provide(server.layer), + ); + assert.strictEqual(error.reason, reason); + } + }), + ); + + it.effect("never lets fetch follow a redirect with the token", () => { + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7/stack": { stack: null } }); + return GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual( + server.sent.map((r) => r.redirect), + ["manual"], + ), + ), + ), + Effect.provide(server.layer), + ); + }); + + it.effect("reads the stack a pull request belongs to, with each open layer's head", () => { + const { "GET /repos/owner/repo/pulls/6": _merged, ...routes } = stack([ + { number: 6, headOid: baseOid, state: "merged" }, + { number: 7, headOid }, + ]); + const server = fakeGitCafe(routes); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getChangeRequestStack!(target); + assert.strictEqual(result?.number, 3); + // A merged layer is not fenced on, so its pull is never read. + assert.deepStrictEqual( + result?.layers.map((layer) => [layer.number, layer.headSha]), + [ + [6, undefined], + [7, headOid], + ], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads activity with the host's edit and resolve permissions", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "GET /repos/owner/repo/pulls/7/reviews": { items: [], next: null }, + "GET /repos/owner/repo/pulls/7/commits": { + items: [], + truncated: false, + next: null, + headOid, + }, + "GET /repos/owner/repo/pulls/7/reactions/": { items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const activity = yield* provider.getChangeRequestActivity(target); + assert.strictEqual(activity.comments[0]?.canEdit, true); + assert.strictEqual(activity.reviewThreads[0]?.canResolve, true); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the viewer's permission to edit the pull request", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { + ...pull, + capabilities: { ...pull.capabilities, edit: false }, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const permissions = yield* provider.getViewerPermissions(target); + assert.strictEqual(permissions.editChangeRequest, false); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the diff from the pull request's changed files", () => { + const file = { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + }; + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [file], next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": { + ...snapshot, + items: [ + { + ...file, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const diff = yield* provider.getDiff(target); + assert.include(diff.patch, "-old\n+new\n"); + assert.isNull(diff.nextCursor); + // The batched hunk read is a POST, but it writes nothing. + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("comments and replies to a thread on the pull request's comments", () => { + const written = { id: "c1", threadId: "root", version: 1 }; + const server = fakeGitCafe({ + "POST /repos/owner/repo/pulls/7/comments/": written, + "POST /repos/owner/repo/pulls/7/comments/root/replies": written, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.comment({ ...target, body: "Hello" }); + yield* provider.replyToThread({ ...target, threadId: "root", body: "Reply" }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/", + "POST /repos/owner/repo/pulls/7/comments/root/replies", + ]); + assert.deepStrictEqual( + server.sent.map((request) => request.body), + [{ body: "Hello" }, { body: "Reply" }], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("resolves a thread through its root at the root's version", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "POST /repos/owner/repo/pulls/7/comments/root/resolve": { + id: "root", + threadId: "root", + version: 3, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.setThreadResolution({ ...target, threadId: "root", resolved: true }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/root/resolve", + ]); + assert.deepStrictEqual(server.sent.at(-1)?.body, { expectedVersion: 2 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("submits a review against the pull request's current head", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/reviews": { id: "review-1" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.submitReview({ + ...target, + verdict: "request-changes", + body: "Please fix", + comments: [], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/reviews"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + verdict: "request_changes", + body: "Please fix", + commitOid: headOid, + }); + assertRequestId(server.sent.at(-1)?.body); + + // GitCafe rejects a null body; an empty review leaves it out. + yield* provider.submitReview({ ...target, verdict: "approve", body: "", comments: [] }); + assert.notProperty(server.sent.at(-1)?.body as object, "body"); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["completed", true], + ["failed", false], + ] as const)("merges synchronously and reads a %s outcome", ([state, succeeds]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state, reason: "Blocked" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .runAction({ ...target, action: "merge", mergeMethod: "squash" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, succeeds ? "Success" : "Failure"); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/merge"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + strategy: "squash", + headOid, + expectedVersion: 4, + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect( + "lands a stack through a layer only while its heads are the ones the reader saw", + () => { + const layers = [ + { number: 6, headOid: baseOid }, + { number: 7, headOid }, + ]; + const server = fakeGitCafe({ + ...stack(layers), + "POST /repos/owner/repo/pulls/stacks/3/land-through": { + id: "land-1", + state: "completed", + landedCount: 2, + stepCount: 2, + stopReason: null, + error: null, + steps: [], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const heads = layers.map((layer) => ({ number: layer.number, headSha: layer.headOid })); + const stale = yield* provider + .runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: [heads[0]!, { number: 7, headSha: baseOid }], + }) + .pipe(Effect.result); + assert.strictEqual(stale._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + + yield* provider.runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: heads, + mergeMethod: "merge", + }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/stacks/3/land-through", + ]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + expectedRevision: 5, + throughPullRequestNumber: 7, + strategy: "merge", + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("restacks from a layer on update-branch", () => { + const server = fakeGitCafe({ + ...stack([{ number: 7, headOid }]), + "POST /repos/owner/repo/pulls/stacks/3/restack": { + id: "restack-1", + state: "completed", + stepCount: 1, + completedStepCount: 1, + pauseReason: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ + ...target, + action: "update-branch", + updateMethod: "rebase", + stackNumber: 3, + expectedStackHeads: [{ number: 7, headSha: headOid }], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/stacks/3/restack"]); + const { requestId, ...body } = server.sent.at(-1)?.body as { requestId: unknown }; + assertRequestId({ requestId }); + assert.deepStrictEqual(body, { expectedRevision: 5 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["authored", "authors"], + ["reviewing", "reviewers"], + ] as const)("filters %s pull requests by the viewer's actor on GitCafe", ([involvement, key]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { + actors: [{ actorId: "act_one", handle: "Alice" }], + }, + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + assert.strictEqual(server.sent.at(-1)?.query.get(key), '["act_one"]'); + assert.strictEqual( + page.items[0]?.reviewRequestLogins.includes("alice"), + involvement === "reviewing", + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("lists nothing for a viewer GitCafe has no actor for", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { actors: [] }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "authored", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual(page.items, []); + assert.deepStrictEqual( + server.sent.map((request) => request.path), + ["/repos/owner/repo/filter-options"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["authored", "reviewing"] as const)( + "lists %s unnarrowed when GitCafe can't say who the viewer is", + (involvement) => { + // No `filter-options` route: the fake answers 404, which is an ordinary failure. + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls"); + assert.isDefined(listing); + assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors")); + // An unnarrowed row says nothing about who was asked to review it. + assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("reads line counts, open merge strategies and requested reviewers", () => { + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const files = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ path: `f${from + index}.ts` })); + let changePage = 0; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, reviewers: [{ actor }] }, + "GET /repos/owner/repo/pulls/7/status": { + merge: { + conflicts: "unknown", + fastForward: true, + strategies: ["merge", "squash", "rebase"], + blockers: [{ blockedStrategies: ["squash"] }, {}], + }, + checks: { pending: 0, failing: 0, total: 0 }, + }, + // Checks continue on a cursor too; a failure on a later page must not be dropped. + [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({ + items: [ + { + name: request.query.has("after") ? "late" : "early", + status: "completed", + conclusion: request.query.has("after") ? "failure" : "success", + summary: null, + detailsUrl: null, + }, + ], + next: request.query.has("after") ? null : "checks-1", + }), + // `/changes` counts no lines, and a pull past one page continues on a cursor. + "GET /repos/owner/repo/pulls/7/changes": () => + changePage++ === 0 + ? { ...snapshot, items: files(0, 2), next: "cursor-1" } + : { ...snapshot, items: files(2, 1), next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({ + ...snapshot, + items: (request.body as { paths: ReadonlyArray }).paths.map((path) => ({ + path, + additions: 2, + deletions: 1, + })), + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const detail = yield* provider.getChangeRequest(target); + assert.deepInclude(detail, { additions: 6, deletions: 3, changedFiles: 3 }); + assert.strictEqual( + server.sent.filter((request) => request.path.endsWith("/changes"))[1]?.query.get("after"), + "cursor-1", + ); + assert.deepStrictEqual(detail.mergeCapabilities, { + merge: true, + squash: false, + rebase: true, + }); + assert.deepStrictEqual( + detail.checks.map((check) => check.name), + ["early", "late"], + ); + // A requested reviewer shows before they have reviewed. + assert.deepStrictEqual( + detail.reviewers.map((reviewer) => reviewer.login), + ["alice"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("fences a native merge on the target branch's current commit", () => { + const branchOid = "fedcba9876543210fedcba9876543210fedcba98"; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, mergeRoute: "native", observedBaseOid: null }, + "GET /repos/owner/repo/commit": { oid: branchOid }, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state: "completed" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ ...target, action: "merge" }); + const branchRead = server.sent.find((request) => request.path === "/repos/owner/repo/commit"); + assert.strictEqual(branchRead?.query.get("ref"), "refs/heads/main"); + assert.deepInclude(server.sent.at(-1)?.body as object, { baseOid: branchOid, headOid }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses to drop a reaction whose id it cannot address", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/reactions/": { + items: [ + { + subject: { kind: "pull_request_comment", id: "root" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: "../escape", + reactors: [actor], + }, + ], + next: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .setReaction({ ...target, subjectId: "root", content: "rocket", reacted: false }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps GitCafe's explanation short and on one line", () => { + const server = fakeGitCafe( + { + "GET /repos/owner/repo/pulls/7": { title: "Conflict", detail: `Line\n${"x".repeat(900)}` }, + }, + { status: 409 }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getChangeRequest(target).pipe(Effect.flip); + assert.notInclude(error.detail, "\n"); + assert.isAtMost(error.detail.length, 310); + assert.isTrue(error.detail.startsWith("Line x")); + }).pipe(Effect.provide(server.layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ea6a1e6eb87f011d5dc8f578e4a2ca19a585854 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -0,0 +1,867 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { + NonNegativeInt, + TrimmedNonEmptyString, + type PullRequestCapabilities, + type PullRequestStackHead, +} from "@t3tools/contracts"; +import { + PullRequestProviderError, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; +import * as Json from "./gitCafePullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], + mergeMethods: ["merge", "squash", "rebase"], + // Only through a stack: GitCafe's one branch update restacks every layer above it. + updateMethods: ["rebase"], + search: true, + stacks: true, + stackActions: true, + reactions: true, + labels: false, + // Line comments go through GitCafe's review drafts, which arrive with revision fencing. + review: { + inlineComment: false, + reply: true, + resolve: true, + verdicts: ["approve", "request-changes", "comment"], + }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: true }, +}; + +const PAGE_SIZE = 100; +/** Conversation pages read before the list is reported as truncated. */ +const MAX_PAGES = 10; +/** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ +const DIFF_FILES_BATCH = 64; +/** + * Line totals cost one hunk read per batch, so a pull request larger than this reports none + * rather than reading every file. + */ +const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; +/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */ +const MAX_CHANGE_PAGES = 20; + +const chunk = (items: ReadonlyArray, size: number): Array> => + Array.from({ length: Math.ceil(items.length / size) }, (_, index) => + items.slice(index * size, (index + 1) * size), + ); + +/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */ +const detailablePaths = (files: ReadonlyArray) => + files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path); + +/** + * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without + * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows + * GitCafe's own reason. + */ +const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) => + status.merge.permitted !== false && + status.merge.strategies.includes(strategy) && + !(status.merge.blockers ?? []).some((blocker) => + (blocker.blockedStrategies ?? []).includes(strategy), + ); + +const Id = TrimmedNonEmptyString.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u)); +const isId = Schema.is(Id); +const Principal = Schema.Struct({ handle: TrimmedNonEmptyString }); +const Written = Schema.Struct({ id: Id }); +const Versioned = Schema.Struct({ version: NonNegativeInt }); +const CommentDetail = Schema.Struct({ id: Id, version: NonNegativeInt }); +const Status = Schema.Struct({ + merge: Schema.Struct({ + conflicts: Schema.Literals(["unknown", "conflicting"]), + fastForward: Schema.NullOr(Schema.Boolean), + strategies: Schema.Array(Schema.String), + permitted: Schema.optional(Schema.Boolean), + blockers: Schema.optional( + Schema.Array( + Schema.Struct({ blockedStrategies: Schema.optional(Schema.Array(Schema.String)) }), + ), + ), + }), + checks: Schema.Struct({ + pending: NonNegativeInt, + failing: NonNegativeInt, + total: NonNegativeInt, + }), +}); +const FilterOptions = Schema.Struct({ + actors: Schema.Array( + Schema.Struct({ actorId: TrimmedNonEmptyString, handle: TrimmedNonEmptyString }), + ), +}); +const encodeActorIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); +const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString }); +const Snapshot = { + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + comparisonBaseOid: TrimmedNonEmptyString, +}; +const Changes = Schema.Struct({ + ...Snapshot, + items: Json.GitCafeDiffFiles, + next: Schema.NullOr(TrimmedNonEmptyString), +}); +const DiffFiles = Schema.Struct({ ...Snapshot, items: Json.GitCafeDiffFiles }); +const DiffCursor = Schema.Struct({ + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + baseOid: TrimmedNonEmptyString, + after: TrimmedNonEmptyString, +}); +const encodeDiffCursor = Schema.encodeSync(Schema.fromJsonString(DiffCursor)); +const decodeDiffCursor = Schema.decodeEffect(Schema.fromJsonString(DiffCursor)); +const MergeOutcome = Schema.Struct({ + id: Schema.String, + state: Schema.Literals(["accepted", "completed", "failed"]), + reason: Schema.optional(Schema.String), +}); +const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String }); +/** Where a stack operation stopped short; anything else is underway or done. */ +const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]); + +type PullRef = ProviderRepositoryRef & { readonly number: number }; +type Paged = { readonly items: ReadonlyArray; readonly next: string | null }; + +/** The heads a reader saw are still every unmerged layer the action moves. */ +function stackHeadsCurrent( + stack: Json.GitCafeStack, + input: { readonly number: number; readonly merge: boolean }, + expected: ReadonlyArray, +): boolean { + const members = stack.members.toSorted((a, b) => a.position - b.position); + const index = members.findIndex((member) => member.pullRequestNumber === input.number); + if (index === -1) return false; + const moved = (input.merge ? members.slice(0, index + 1) : members).filter( + (member) => member.state !== "merged", + ); + return ( + moved.length === expected.length && + moved.every( + (member) => + member.headOid !== undefined && + expected.some( + (head) => head.number === member.pullRequestNumber && head.headSha === member.headOid, + ), + ) + ); +} + +/** Lays the detailed files over the structural page, keeping its order and anything omitted. */ +const withDetails = ( + files: ReadonlyArray, + details: ReadonlyArray, +) => { + const byPath = new Map(details.map((file) => [file.path, file])); + return files.map((file) => byPath.get(file.path) ?? file); +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + + const failure = (operation: string, detail: string, cause?: unknown) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */ + const repositoryPath = ( + operation: string, + input: ProviderRepositoryRef, + ): Effect.Effect => + GitCafeHosts.isGitCafeHost(input.host) && + GitCafeHosts.gitCafeRepositoryPath(input.repository) === input.repository + ? Effect.succeed(`/repos/${input.repository}`) + : Effect.fail( + failure( + operation, + `GitCafe serves owner/name repositories on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}.`, + ), + ); + + const send = ( + operation: string, + input: Pick, + request: Omit, + ) => + api.request({ ...request, host: input.host, operation }).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: error.reason, + detail: error.detail, + cause: error, + }), + ), + ); + + const read = >( + operation: string, + input: Pick, + request: Omit, + schema: S, + ) => + send(operation, input, request).pipe( + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + failure(operation, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + + const pullPath = (operation: string, input: PullRef) => + repositoryPath(operation, input).pipe(Effect.map((base) => `${base}/pulls/${input.number}`)); + + const readPull = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), + ); + + /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */ + const readPages = , unknown, never, never>>( + operation: string, + input: Pick, + path: string, + schema: S, + ) => + Effect.gen(function* () { + const items: Array = []; + let next: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (next !== null) query.set("after", next); + const batch: S["Type"] = yield* read( + operation, + input, + { path: `${path}?${query}` }, + schema, + ); + items.push(...batch.items); + const previous: string | null = next; + next = batch.next; + if (next === null || next === previous || batch.items.length === 0) break; + } + return { items, next }; + }); + + const readComments = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => + readPages(operation, input, `${path}/comments`, Json.GitCafeComments), + ), + ); + + /** + * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no + * heads, and they are what a land or restack is fenced on, so each open layer's pull is read. + */ + const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* ( + operation: string, + input: PullRef, + ) { + const path = yield* pullPath(operation, input); + const { stack } = yield* read( + operation, + input, + { path: `${path}/stack` }, + Json.GitCafeStackEnvelope, + ); + if (stack === null) return null; + const members = yield* Effect.forEach( + stack.members, + (member) => + member.state === "merged" + ? Effect.succeed(member) + : readPull(operation, { ...input, number: member.pullRequestNumber }).pipe( + Effect.map((pull) => + pull.headOid == null ? member : { ...member, headOid: pull.headOid }, + ), + ), + { concurrency: 4 }, + ); + return { ...stack, members }; + }); + + /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */ + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( + input: PullRef, + revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, + paths: ReadonlyArray, + ) { + if (paths.length === 0) return []; + const path = yield* pullPath("getDiff", input); + const files = yield* read( + "getDiff", + input, + { + method: "POST", + path: `${path}/diff-files`, + body: { paths, expectedVersion: revision.version }, + }, + DiffFiles, + ); + if ( + files.version !== revision.version || + files.headOid !== revision.headOid || + files.comparisonBaseOid !== revision.baseOid + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + return files.items; + }); + + const runStackAction = Effect.fn("GitCafePullRequestProvider.runStackAction")(function* ( + input: Parameters[0] & { readonly stackNumber: number }, + ) { + const land = input.action === "merge"; + const operation = land ? "landStackThrough" : "restackStack"; + if (!land && input.action !== "update-branch") + return yield* failure(operation, `GitCafe cannot ${input.action} a stack.`); + const stack = yield* readStack(operation, input); + if ( + stack === null || + stack.number !== input.stackNumber || + input.expectedStackHeads === undefined || + !stackHeadsCurrent(stack, { number: input.number, merge: land }, input.expectedStackHeads) + ) + return yield* failure( + operation, + "The stack changed since it was read. Refresh it and try again.", + ); + const base = yield* repositoryPath(operation, input); + const outcome = yield* read( + operation, + input, + { + method: "POST", + path: `${base}/pulls/stacks/${stack.number}/${land ? "land-through" : "restack"}`, + body: land + ? { + expectedRevision: stack.revision, + requestId: yield* randomUuidV4, + throughPullRequestNumber: input.number, + strategy: input.mergeMethod ?? "merge", + } + : { expectedRevision: stack.revision, requestId: yield* randomUuidV4 }, + }, + StackOutcome, + ); + if (FAILED_STACK_STATES.has(outcome.state)) + return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`); + }); + + /** + * Added and deleted line totals and the file count. `/changes` lists files without counting + * lines, so the counts come from batched hunk reads, and a pull request past the cap reports + * none rather than paying for every file. + */ + const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( + input: PullRef, + version: number, + ) { + const path = yield* pullPath("getChangeRequest", input); + const files: Array = []; + let revision: { version: number; headOid: string; baseOid: string } | undefined; + let after: string | null = null; + for (let page = 0; page < MAX_CHANGE_PAGES; page++) { + const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + if (after !== null) query.set("after", after); + const batch: typeof Changes.Type = yield* read( + "getChangeRequest", + input, + { path: `${path}/changes?${query}` }, + Changes, + ); + revision ??= { + version: batch.version, + headOid: batch.headOid, + baseOid: batch.comparisonBaseOid, + }; + files.push(...batch.items); + const previous: string | null = after; + after = batch.next; + if (after === null || after === previous || batch.items.length === 0) break; + } + // A hunk read that loses a race with a push leaves the counts at zero rather than dropping + // the file count with them. + const counted = + files.some((file) => file.additions !== undefined) || + files.length > LINE_STATS_MAX_FILES || + revision === undefined + ? files + : (yield* Effect.forEach( + chunk(detailablePaths(files), DIFF_FILES_BATCH), + (paths) => readDiffFiles(input, revision, paths), + { concurrency: 4 }, + ).pipe(Effect.orElseSucceed(() => [[]]))).flat(); + return { + changedFiles: files.length, + additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0), + deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0), + }; + }); + + const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* ( + input: Parameters[0], + ) { + const pull = yield* readPull("merge", input); + if (pull.state === "merged") return; + if (pull.mergeRoute === "unsupported") + return yield* failure("merge", "This GitCafe pull request has no supported merge route."); + const base = yield* repositoryPath("merge", input); + // GitCafe observes the base only for merges it routes to the provider; a native merge is + // fenced on the target branch's current commit. + const baseOid = + pull.mergeRoute === "provider" + ? pull.observedBaseOid + : (yield* read( + "merge", + input, + { + path: `${base}/commit?ref=${encodeURIComponent(`refs/heads/${pull.targetBranch}`)}`, + }, + BranchCommit, + )).oid; + if (pull.headOid === null || baseOid == null) + return yield* failure( + "merge", + "GitCafe has not reported the pull request's head and base yet. Refresh and try again.", + ); + const path = yield* pullPath("merge", input); + const outcome = yield* read( + "merge", + input, + { + method: "POST", + path: `${path}/merge`, + body: { + requestId: yield* randomUuidV4, + expectedVersion: pull.version, + headOid: pull.headOid, + baseOid, + strategy: input.mergeMethod ?? "merge", + }, + }, + MergeOutcome, + ); + // An accepted merge is GitCafe's to finish; the pull request's state reports when it has. + if (outcome.state === "failed") + return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request."); + }); + + const provider: PullRequestProviderApi = { + kind: "gitcafe", + capabilities: CAPABILITIES, + getViewer: (input) => + read( + "getViewer", + { host: input.host ?? "git.cafe" }, + { path: "/auth/principal" }, + Principal, + ).pipe(Effect.map((principal) => principal.handle)), + listChangeRequests: Effect.fn("GitCafePullRequestProvider.listChangeRequests")( + function* (input) { + const base = yield* repositoryPath("listChangeRequests", input); + const involved = input.involvement === "authored" || input.involvement === "reviewing"; + let actorId: string | undefined; + if (involved) { + // A host narrows as far as it can: when the actor list can't be read, the listing comes + // back unnarrowed and the page's own involvement filter does the rest. A refused token + // or a rate limit still fails, since the service acts on those for the whole host. + const options = yield* read( + "listChangeRequests", + input, + { path: `${base}/filter-options` }, + FilterOptions, + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PullRequestProviderError: (error) => + error.reason === "unauthenticated" || error.reason === "rate-limited" + ? Effect.fail(error) + : Effect.succeed(Option.none()), + }), + ); + if (Option.isSome(options)) { + actorId = options.value.actors.find( + (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(), + )?.actorId; + // A viewer GitCafe knows no actor for has authored and been asked to review nothing. + if (actorId === undefined) return { items: [], truncated: false, continues: false }; + } + } + const limit = Math.max(1, input.limit); + const items: Array = []; + let next: string | null = null; + do { + const query = new URLSearchParams({ + limit: String(Math.min(PAGE_SIZE, limit - items.length)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (input.query?.trim()) query.set("q", input.query.trim()); + if (actorId !== undefined) + query.set( + input.involvement === "reviewing" ? "reviewers" : "authors", + encodeActorIds([actorId]), + ); + if (next !== null) query.set("after", next); + const page: typeof Json.GitCafePulls.Type = yield* read( + "listChangeRequests", + input, + { path: `${base}/pulls?${query}` }, + Json.GitCafePulls, + ); + items.push(...page.items); + next = page.items.length === 0 ? null : page.next; + } while (next !== null && items.length < limit); + return { + items: items.slice(0, limit).map((pull) => { + const item = Json.toChangeRequest(pull, input); + // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An + // unnarrowed listing (no actor to filter by) says nothing about who was asked. + return input.involvement === "reviewing" && actorId !== undefined + ? { + ...item, + reviewRequestLogins: [...new Set([...item.reviewRequestLogins, input.viewer])], + } + : item; + }), + truncated: next !== null, + continues: false, + }; + }, + ), + getChangeRequestSummary: (input) => + readPull("getChangeRequestSummary", input).pipe( + Effect.map((pull) => ({ + ...Json.toChangeRequest(pull, input), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + })), + ), + getChangeRequest: Effect.fn("GitCafePullRequestProvider.getChangeRequest")(function* (input) { + const pull = yield* readPull("getChangeRequest", input); + const path = yield* pullPath("getChangeRequest", input); + const base = yield* repositoryPath("getChangeRequest", input); + const [status, checks, changes] = yield* Effect.all( + [ + read("getChangeRequest", input, { path: `${path}/status` }, Status), + pull.headOid === null + ? Effect.succeed(null) + : readPages( + "getChangeRequest", + input, + `${base}/commits/${encodeURIComponent(pull.headOid)}/checks`, + Json.GitCafeChecks, + ), + pull.headOid === null + ? Effect.succeed(null) + : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)), + ], + { concurrency: 3 }, + ); + const permissions = Json.toViewerPermissions(pull); + return { + ...Json.toChangeRequest(pull, input), + headSha: pull.headOid, + body: pull.description ?? "", + ...(pull.sourceRepo === null + ? {} + : { headRepositoryNameWithOwner: `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` }), + additions: changes?.additions ?? 0, + deletions: changes?.deletions ?? 0, + changedFiles: changes?.changedFiles ?? 0, + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + reviewers: Json.toReviewers(pull, input.host), + checks: checks === null ? [] : Json.toChecks(checks), + ...(status.checks.total === 0 + ? {} + : { + checksState: + status.checks.failing > 0 + ? ("failing" as const) + : status.checks.pending > 0 + ? ("pending" as const) + : ("passing" as const), + }), + mergeability: + status.merge.conflicts === "conflicting" + ? ("conflicting" as const) + : ("unknown" as const), + baseComparison: + status.merge.fastForward === null + ? ("unknown" as const) + : status.merge.fastForward + ? ("up-to-date" as const) + : ("behind" as const), + mergeCapabilities: { + merge: mergeStrategyOpen(status, "merge"), + squash: mergeStrategyOpen(status, "squash"), + rebase: mergeStrategyOpen(status, "rebase"), + }, + viewerPermissions: + status.merge.permitted === false + ? { ...permissions, actions: permissions.actions.filter((a) => a !== "merge") } + : permissions, + }; + }), + getChangeRequestStack: (input) => + readStack("getChangeRequestStack", input).pipe( + Effect.map((stack) => (stack === null ? null : Json.toStack(stack, input))), + ), + getChangeRequestActivity: Effect.fn("GitCafePullRequestProvider.getChangeRequestActivity")( + function* (input) { + const pull = yield* readPull("getChangeRequestActivity", input); + const path = yield* pullPath("getChangeRequestActivity", input); + const operation = "getChangeRequestActivity"; + const [comments, reviews, commits, reactions] = yield* Effect.all( + [ + readComments(operation, input), + readPages(operation, input, `${path}/reviews`, Json.GitCafeReviews), + pull.headOid === null + ? Effect.succeed({ items: [], next: null }) + : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits), + // Reactions decorate the conversation; a failed read leaves them out, not it. + readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe( + Effect.orElseSucceed(() => undefined), + ), + ], + { concurrency: 4 }, + ); + return Json.toActivity({ + comments, + reviews, + commits: { ...commits, truncated: commits.next !== null, headOid: pull.headOid ?? "" }, + host: input.host, + ...(pull.headOid === null ? {} : { headOid: pull.headOid }), + ...(reactions === undefined ? {} : { reactions }), + }); + }, + ), + getViewerPermissions: (input) => + readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), + getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { + if (input.commit !== undefined) + return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), + ); + const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; + const path = yield* pullPath("getDiff", input); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(DIFF_FILES_BATCH), + }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (cursor !== null && + (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + return { + ...Json.toDiff({ items: withDetails(page.items, details) }), + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }), + runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) { + if (input.stackNumber !== undefined) + return yield* runStackAction({ ...input, stackNumber: input.stackNumber }); + switch (input.action) { + case "merge": + return yield* merge(input); + case "ready": + case "draft": + case "close": + case "reopen": { + const pull = yield* readPull(input.action, input); + const path = yield* pullPath(input.action, input); + yield* send(input.action, input, { + method: "POST", + path: `${path}/${input.action}`, + body: { expectedVersion: pull.version }, + }); + return; + } + default: + return yield* failure(input.action, `GitCafe cannot ${input.action} from T3 Code.`); + } + }), + updateChangeRequest: Effect.fn("GitCafePullRequestProvider.updateChangeRequest")( + function* (input) { + const pull = yield* readPull("updateChangeRequest", input); + const path = yield* pullPath("updateChangeRequest", input); + yield* read( + "updateChangeRequest", + input, + { + method: "PATCH", + path, + body: { + expectedVersion: pull.version, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }, + }, + Versioned, + ); + }, + ), + comment: (input) => + pullPath("comment", input).pipe( + Effect.flatMap((path) => + read( + "comment", + input, + { method: "POST", path: `${path}/comments/`, body: { body: input.body } }, + Written, + ), + ), + Effect.asVoid, + ), + updateComment: Effect.fn("GitCafePullRequestProvider.updateComment")(function* (input) { + if (!isId(input.commentId)) + return yield* failure("updateComment", "GitCafe comment ids are opaque slugs."); + const path = `${yield* pullPath("updateComment", input)}/comments/${input.commentId}`; + const detail = yield* read("updateComment", input, { path: `${path}/detail` }, CommentDetail); + yield* read( + "updateComment", + input, + { method: "PATCH", path, body: { body: input.body, expectedVersion: detail.version } }, + Written, + ); + }), + submitReview: Effect.fn("GitCafePullRequestProvider.submitReview")(function* (input) { + if (input.comments.length > 0) + return yield* failure("submitReview", "GitCafe line comments are not supported yet."); + const pull = yield* readPull("submitReview", input); + if (pull.headOid === null) + return yield* failure("submitReview", "GitCafe has not reported the pull request's head."); + const path = yield* pullPath("submitReview", input); + yield* read( + "submitReview", + input, + { + method: "POST", + path: `${path}/reviews`, + body: { + verdict: input.verdict === "request-changes" ? "request_changes" : input.verdict, + ...(input.body.length === 0 ? {} : { body: input.body }), + commitOid: pull.headOid, + expectedVersion: pull.version, + requestId: yield* randomUuidV4, + }, + }, + Written, + ); + }), + listReviewerCandidates: () => + Effect.fail( + failure("listReviewerCandidates", "GitCafe reviewer requests are not supported."), + ), + setReviewerRequest: () => + Effect.fail(failure("setReviewerRequest", "GitCafe reviewer requests are not supported.")), + replyToThread: Effect.fn("GitCafePullRequestProvider.replyToThread")(function* (input) { + if (!isId(input.threadId)) + return yield* failure("replyToThread", "GitCafe thread ids are opaque slugs."); + const path = yield* pullPath("replyToThread", input); + yield* read( + "replyToThread", + input, + { + method: "POST", + path: `${path}/comments/${input.threadId}/replies`, + body: { body: input.body }, + }, + Written, + ); + }), + setReaction: Effect.fn("GitCafePullRequestProvider.setReaction")(function* (input) { + const path = `${yield* pullPath("setReaction", input)}/reactions/`; + const subject = + input.subjectId === undefined + ? { kind: "pull_request" as const, id: (yield* readPull("setReaction", input)).id } + : { kind: "pull_request_comment" as const, id: input.subjectId }; + const reactions = yield* readPages("setReaction", input, path, Json.GitCafeReactions); + const mine = reactions.items.find( + (item) => + item.subject.kind === subject.kind && + item.subject.id === subject.id && + item.emoji.kind === "unicode" && + Json.reactionContent(item.emoji.value) === input.content, + )?.viewerReactionId; + if (input.reacted === (mine != null)) return; + if (input.reacted) { + yield* send("setReaction", input, { + method: "POST", + path, + body: { subject, emoji: { kind: "unicode", value: Json.REACTION_EMOJI[input.content] } }, + }); + return; + } + if (mine == null) return; + if (!isId(mine)) + return yield* failure("setReaction", "GitCafe returned an unreadable reaction id."); + yield* send("setReaction", input, { method: "DELETE", path: `${path}${mine}` }); + }), + setThreadResolution: Effect.fn("GitCafePullRequestProvider.setThreadResolution")( + function* (input) { + const comments = yield* readComments("setThreadResolution", input); + const root = comments.items.find( + (comment) => comment.id === input.threadId && comment.threadId === input.threadId, + ); + if (root === undefined || root.version === undefined || !isId(root.id)) + return yield* failure("setThreadResolution", "GitCafe did not return the thread root."); + if (input.resolved === (root.resolvedAt !== null)) return; + const path = yield* pullPath("setThreadResolution", input); + yield* read( + "setThreadResolution", + input, + { + method: "POST", + path: `${path}/comments/${root.id}/${input.resolved ? "resolve" : "unresolve"}`, + body: { expectedVersion: root.version }, + }, + Written, + ); + }, + ), + }; + return provider; +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaaf5d0e90ffd35fa1072b9bff3fc5af515dfed6 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -0,0 +1,339 @@ +/** + * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: + * + * - `discovery: SourceControlCliDiscoverySpec` — kind `"gitcafe"`, executable `"cafe"`, + * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a + * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its + * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope. + * - `make: Effect` with + * kind `"gitcafe"`, resolving the repository and host from the checkout's remote. + * + * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests. + */ +import { assert, describe, it } from "@effect/vitest"; +import { GitCommandError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({ + stdout: input.stdout === undefined ? "" : encodeJson(input.stdout), + stderr: input.stderr === undefined ? "" : encodeJson(input.stderr), + exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0), +}); +const failure = (code: string, status: number | null) => ({ + schemaVersion: 1, + error: { code, status, message: `Failure ${code}` }, +}); + +describe("GitCafeSourceControlProvider", () => { + it("probes the cafe CLI against production", () => { + const { discovery } = GitCafeSourceControlProvider; + assert.strictEqual(discovery.kind, "gitcafe"); + assert.strictEqual(discovery.executable, "cafe"); + assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]); + assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]); + }); + + it("reads a signed-in account and keeps transient failures apart from sign-out", () => { + const { parseAuth } = GitCafeSourceControlProvider.discovery; + const signedIn = parseAuth( + authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), + ); + assert.strictEqual(signedIn.status, "authenticated"); + assert.deepStrictEqual(signedIn.account, Option.some("alice")); + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure("AUTHENTICATION_REQUIRED", 401), exitCode: 1 })) + .status, + "unauthenticated", + ); + for (const [code, status] of [ + ["FORBIDDEN", 403], + ["NETWORK_ERROR", null], + ] as const) { + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure(code, status), exitCode: 1 })).status, + "unknown", + ); + } + }); + + it.effect.each([ + ["https://staging.git.cafe/team/project.git", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ] as const)("resolves the repository from the remote %s", ([remoteUrl, host]) => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + const context = { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + assert.strictEqual(provider.kind, "gitcafe"); + assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk"); + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", context, repository: remoteUrl }), + { + nameWithOwner: "team/project", + url: `https://${host}/team/project`, + sshUrl: `ssh@${host}:team/project.git`, + }, + ); + assert.deepStrictEqual(urls, [ + `https://${host}/api/repos/team/project`, + `https://${host}/api/repos/team/project`, + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(TestSourceControlHost.layer()), + Layer.provide( + Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect( + "reads a bare owner/name on git.cafe without a remote, and no other host's pulls", + () => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + // The checkout has no remote at all, as when a repository is about to be created. + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }), + { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }, + ); + const foreign = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://example.com/team/project/pulls/42", + }) + .pipe(Effect.flip); + assert.strictEqual(foreign._tag, "SourceControlProviderError"); + assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }, + ); + + it.effect.each([ + ["no context", undefined, "https://git.cafe/fork/project.git"], + ["an HTTPS remote", "https://git.cafe/team/project.git", "https://git.cafe/fork/project.git"], + ["an SSH remote", "ssh@git.cafe:team/project.git", "ssh@git.cafe:fork/project.git"], + ["an ssh:// remote", "ssh://ssh@git.cafe/team/project.git", "ssh@git.cafe:fork/project.git"], + [ + "an HTTPS remote with a user", + "https://alice@git.cafe/team/project.git", + "https://git.cafe/fork/project.git", + ], + ] as const)("checks a fork out over the transport of %s", ([, remoteUrl, expected]) => { + const remotes: Array = []; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid: null, + updatedAt: null, + sourceRepo: { owner: "fork", name: "project" }, + closedAt: null, + mergedAt: null, + }), + ), + ), + ), + ); + const context = + remoteUrl === undefined + ? undefined + : { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + yield* provider.checkoutChangeRequest({ + cwd: "/repo", + reference: "https://git.cafe/team/project/pulls/7", + ...(context === undefined ? {} : { context }), + }); + assert.deepStrictEqual(remotes, [expected]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + ensureRemote: (input) => { + remotes.push(input.url); + return Effect.succeed("gitcafe"); + }, + listLocalBranchNames: () => Effect.succeed([]), + fetchRemoteBranch: () => Effect.void, + setBranchUpstream: () => Effect.void, + switchRef: () => Effect.succeed({ refName: null }), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect("waits for a created repository's admission before handing out its URLs", () => { + const urls: Array = []; + const states = ["running", "complete"]; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + repoId: "repo_1", + state: states.shift(), + owner: "team", + name: "project", + }), + ), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + const created = yield* provider + .createRepository({ cwd: "/repo", repository: "team/project", visibility: "private" }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(created), { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }); + assert.deepStrictEqual(urls, [ + "https://git.cafe/api/orgs/team/admissions/repo_1", + "https://git.cafe/api/orgs/team/admissions/repo_1", + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput( + encodeJson({ + schemaVersion: 1, + data: { + resource: { + repoId: "repo_1", + state: "running", + owner: "team", + name: "project", + }, + }, + }), + ), + ), + }, + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d55bd72bac3bd5b6cff91ab9d89eae53b67710a --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -0,0 +1,503 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; +import { + PositiveInt, + SourceControlProviderError, + TrimmedNonEmptyString, + type ChangeRequest, +} from "@t3tools/contracts"; +import { + providerAuth, + type SourceControlCliDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +/** Discovery reports the production account; repository operations pick their own host. */ +const AUTH_HOST = "git.cafe"; +/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */ +const ADMISSION_TIMEOUT = "30 seconds"; +const ADMISSION_POLL_INTERVAL = "500 millis"; +const cliArgs = (host: string) => [ + "--host", + `https://${host}/api`, + "--no-input", + "--no-update-check", +]; + +const decodeAuth = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + data: Schema.Struct({ host: Schema.String, username: TrimmedNonEmptyString }), + }), + ), +); +/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */ +const decodeFailure = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + error: Schema.Struct({ + code: Schema.String, + message: Schema.String, + status: Schema.NullOr(Schema.Finite), + }), + }), + ), +); +const cliFailure = (output: string) => + [output.trim(), ...output.split(/\r?\n/u).toReversed()] + .map((line) => decodeFailure(line.trim())) + .find(Result.isSuccess)?.success.error; + +export const discovery = { + type: "cli", + kind: "gitcafe", + label: "GitCafe", + executable: "cafe", + versionArgs: ["--version"], + authArgs: [...cliArgs(AUTH_HOST), "auth", "status", "--json"], + parseAuth: (input) => { + const signedIn = decodeAuth(input.stdout.trim()); + if (input.exitCode === 0 && Result.isSuccess(signedIn)) + return providerAuth({ + status: "authenticated", + host: AUTH_HOST, + account: signedIn.success.data.username, + }); + const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout); + if (failure) + return providerAuth({ + // Only a refusal says the login is gone; a forbidden or offline answer says nothing. + status: + failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401 + ? "unauthenticated" + : "unknown", + host: AUTH_HOST, + detail: failure.message, + }); + return providerAuth({ + status: "unknown", + host: AUTH_HOST, + detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`, + }); + }, + 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.`, +} satisfies SourceControlCliDiscoverySpec; + +const Repository = Schema.Struct({ + name: TrimmedNonEmptyString, + defaultBranch: Schema.NullOr(TrimmedNonEmptyString), +}); +const Pull = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed", "merged"]), + draft: Schema.Boolean, + sourceBranch: TrimmedNonEmptyString, + targetBranch: TrimmedNonEmptyString, + headOid: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString), +}); +const PullDetail = Schema.Struct({ + ...Pull.fields, + sourceRepo: Schema.NullOr( + Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString }), + ), + closedAt: Schema.NullOr(Schema.String), + mergedAt: Schema.NullOr(Schema.String), +}); +const Pulls = Schema.Struct({ items: Schema.Array(Pull), next: Schema.NullOr(Schema.String) }); +const RepositoryAdmission = Schema.Struct({ + repoId: TrimmedNonEmptyString, + state: Schema.String, + owner: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +const CreatedRepository = Schema.Struct({ + data: Schema.Struct({ resource: RepositoryAdmission }), +}); +const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository)); +const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +interface Target { + readonly host: GitCafeHosts.GitCafeHost; + readonly repository: string; +} + +const cloneUrls = (target: Target) => ({ + nameWithOwner: target.repository, + url: `https://${target.host}/${target.repository}`, + sshUrl: `ssh@${target.host}:${target.repository}.git`, +}); + +/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */ +const isSshRemote = (remoteUrl: string | undefined) => + remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl)); + +const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u; + +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + const host = yield* SourceControlHost.SourceControlHost; + + const error = (operation: string, cwd: string, detail: string, cause?: unknown) => + new SourceControlProviderError({ + provider: "gitcafe", + operation, + cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + const read = >( + operation: string, + cwd: string, + request: Omit, + schema: S, + ) => + api.request({ ...request, operation }).pipe( + Effect.mapError((cause) => error(operation, cwd, cause.detail, cause)), + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + error(operation, cwd, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray) => + host.process + .run({ + operation: `GitCafeSourceControlProvider.${operation}`, + command: "cafe", + cwd, + args: [...cliArgs(target.host), ...args], + env: { CAFE_OUTPUT: "json" }, + timeoutMs: 30_000, + }) + .pipe( + Effect.mapError((cause) => + error( + operation, + cwd, + (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) || + "GitCafe CLI command failed.", + cause, + ), + ), + ); + + /** The repository a call names, falling back to the checkout's own GitCafe remote. */ + const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* ( + operation: string, + input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }, + ) { + const named = + input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository); + if (named) return named; + // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no + // remote (a repository being created) still resolves, on git.cafe. + const readRemote = host.git + .resolvePrimaryRemoteName(input.cwd) + .pipe( + Effect.flatMap((remote) => host.git.readConfigValue(input.cwd, `remote.${remote}.url`)), + ); + const remoteUrl = + input.context?.remoteUrl ?? + (input.repository === undefined + ? yield* readRemote.pipe( + Effect.mapError((cause) => + error(operation, input.cwd, "Could not read the GitCafe remote.", cause), + ), + ) + : yield* readRemote.pipe(Effect.orElseSucceed(() => null))); + const remote = remoteUrl ? GitCafeHosts.parseGitCafeRemote(remoteUrl) : null; + if (input.repository === undefined) { + if (remote) return remote; + return yield* error(operation, input.cwd, "No GitCafe repository remote was found."); + } + // A bare owner/name lives on the checkout's own GitCafe host. + const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository); + if (repository !== null) + return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target; + return yield* error( + operation, + input.cwd, + "A GitCafe repository is named owner/name or by its git.cafe URL.", + ); + }); + + const getPull = Effect.fn("GitCafeSourceControlProvider.getPull")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) { + const match = pullUrlPattern.exec(input.reference.trim()); + const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null; + const target: Target = + urlHost && match?.[2] + ? { host: urlHost, repository: match[2] } + : yield* resolveTarget("getChangeRequest", input); + // A pull request URL on another host is not a GitCafe pull request number. + const number = match + ? urlHost + ? match[3] + : undefined + : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1]; + if (number === undefined) + return yield* error( + "getChangeRequest", + input.cwd, + "A GitCafe pull request is a number or a git.cafe pull request URL.", + ); + const pull = yield* read( + "getChangeRequest", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls/${number}` }, + PullDetail, + ); + const owner = target.repository.split("/")[0] ?? null; + return { + provider: "gitcafe", + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + baseRefName: pull.targetBranch, + headRefName: pull.sourceBranch, + ...(pull.headOid ? { headSha: pull.headOid } : {}), + state: pull.state, + ...(pull.draft ? { isDraft: true } : {}), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + updatedAt: pull.updatedAt, + isCrossRepository: pull.sourceRepo !== null, + headRepositoryNameWithOwner: pull.sourceRepo + ? `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` + : target.repository, + headRepositoryOwnerLogin: pull.sourceRepo?.owner ?? owner, + } satisfies ChangeRequest; + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitcafe", + repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null, + getChangeRequest: getPull, + listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")( + function* (input) { + const target = yield* resolveTarget("listChangeRequests", input); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const branch = SourceControlProvider.sourceBranch(input); + const limit = input.limit ?? 20; + const items: Array = []; + let after: string | null = null; + do { + const query = new URLSearchParams({ + sourceBranches: encodeBranches([branch]), + limit: String(Math.min(limit, 100)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (after !== null) query.set("after", after); + const page: typeof Pulls.Type = yield* read( + "listChangeRequests", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls?${query}` }, + Pulls, + ); + for (const pull of page.items) { + if (pull.sourceBranch !== branch || items.length >= limit) continue; + // List rows omit fork identity, so only the branch's own rows are read in full. + const detail = yield* getPull({ + cwd: input.cwd, + reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + }); + if ( + (!source?.repository || detail.headRepositoryNameWithOwner === source.repository) && + (!source?.owner || detail.headRepositoryOwnerLogin === source.owner) + ) + items.push(detail); + } + after = page.items.length === 0 || page.next === after ? null : page.next; + } while (after !== null && items.length < limit); + return items; + }, + ), + createChangeRequest: Effect.fn("GitCafeSourceControlProvider.createChangeRequest")( + function* (input) { + const target = yield* resolveTarget("createChangeRequest", { + ...input, + ...(input.target?.repository ? { repository: input.target.repository } : {}), + }); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const sourceRepository = + source?.repository ?? + (source?.owner + ? `${source.owner}/${target.repository.split("/")[1]}` + : target.repository); + const branch = SourceControlProvider.sourceBranch(input); + yield* cafe("createChangeRequest", input.cwd, target, [ + "pr", + "create", + "--json", + "--repo", + target.repository, + "--head", + sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`, + "--base", + input.target?.refName ?? input.baseRefName, + "--title", + input.title, + "--body-file", + input.bodyFile, + ]); + }, + ), + getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { + const target = yield* resolveTarget("getRepositoryCloneUrls", input); + yield* read( + "getRepositoryCloneUrls", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return cloneUrls(target); + }, + ), + createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) { + const target = yield* resolveTarget("createRepository", input); + const [owner = "", name = ""] = target.repository.split("/"); + const output = yield* cafe("createRepository", input.cwd, target, [ + "repo", + "create", + name, + "--org", + owner, + "--visibility", + input.visibility, + "--json", + ]); + const created = yield* decodeCreatedRepository(output.stdout).pipe( + Effect.mapError((cause) => + error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause), + ), + ); + // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is + // still running, so wait for it to settle the way `cafe repo create --clone` does. + const settled = (state: string) => state === "complete" || state === "blocked"; + const pending = created.data.resource; + const resource = settled(pending.state) + ? pending + : yield* read( + "createRepository", + input.cwd, + { + host: target.host, + path: `/orgs/${pending.owner}/admissions/${pending.repoId}`, + }, + RepositoryAdmission, + ).pipe( + Effect.repeat({ + until: (admission) => settled(admission.state), + schedule: Schedule.spaced(ADMISSION_POLL_INTERVAL), + }), + Effect.timeoutOption(ADMISSION_TIMEOUT), + Effect.map(Option.getOrElse(() => pending)), + ); + // Anything short of complete is not yet usable. + if (resource.state !== "complete") + return yield* error( + "createRepository", + input.cwd, + `GitCafe repository ${target.repository} is ${resource.state}. Check it on GitCafe before retrying.`, + ); + return cloneUrls({ host: target.host, repository: `${resource.owner}/${resource.name}` }); + }), + getDefaultBranch: Effect.fn("GitCafeSourceControlProvider.getDefaultBranch")(function* (input) { + const target = yield* resolveTarget("getDefaultBranch", input); + const repository = yield* read( + "getDefaultBranch", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return repository.defaultBranch; + }), + // `cafe` has no forced checkout, so the branch is fetched and switched to with git. + checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")( + function* (input) { + const pull = yield* getPull(input); + const pullHost = new URL(pull.url).host; + const sourceRepository = pull.headRepositoryNameWithOwner; + if (!sourceRepository) + return yield* error( + "checkoutChangeRequest", + input.cwd, + "GitCafe did not name the pull request's source repository.", + ); + const contextRemote = input.context + ? GitCafeHosts.parseGitCafeRemote(input.context.remoteUrl) + : null; + const git = host.git; + yield* Effect.gen(function* () { + const remoteName = + input.context && + contextRemote?.repository === sourceRepository && + contextRemote.host === pullHost + ? input.context.remoteName + : yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: "gitcafe", + // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise. + url: isSshRemote(input.context?.remoteUrl) + ? `ssh@${pullHost}:${sourceRepository}.git` + : `https://${pullHost}/${sourceRepository}.git`, + }); + const localBranch = pull.isCrossRepository + ? `pr-${pull.number}/${pull.headRefName}` + : pull.headRefName; + const branches = yield* git.listLocalBranchNames(input.cwd); + const remoteBranch = { cwd: input.cwd, remoteName, remoteBranch: pull.headRefName }; + if (input.force !== true && !branches.includes(localBranch)) + yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch }); + else yield* git.fetchRemoteTrackingBranch(remoteBranch); + // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits. + if (input.force === true) + yield* git.execute({ + operation: "GitCafeSourceControlProvider.checkoutChangeRequest", + cwd: input.cwd, + args: [ + "checkout", + "-B", + localBranch, + `refs/remotes/${remoteName}/${pull.headRefName}`, + "--", + ], + }); + yield* git.setBranchUpstream({ ...remoteBranch, branch: localBranch }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + error( + "checkoutChangeRequest", + input.cwd, + "Could not check out the GitCafe pull request.", + cause, + ), + ), + ); + }, + ), + }); +}); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..467614c2ec1c570c5d8a93ac43b5f46028bb0430 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull + * requests through GitCafe's REST API on git.cafe and staging.git.cafe. + * + * @module source-control-gitcafe/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "gitcafe", + make: Effect.all({ + sourceControl: GitCafeSourceControlProvider.make, + discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), + pullRequests: GitCafePullRequestProvider.make, + }), +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a828a99f6ff1475e05b47ddd98785a746d158299 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -0,0 +1,46 @@ +/** + * Pins `./gitCafeHosts.ts`, which pass 2 creates as a plain module (no service): + * + * - `GITCAFE_HOSTS: readonly ["git.cafe", "staging.git.cafe"]` — the only hosts GitCafe lives on. + * - `isGitCafeHost(host: string): boolean` — case-insensitive membership in `GITCAFE_HOSTS`. + * - `parseGitCafeRemote(url: string): { readonly host: GitCafeHost; readonly repository: string } | null` + * — the host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. + */ +import { assert, describe, it } from "@effect/vitest"; + +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +describe("gitCafeHosts", () => { + it("knows exactly the production and staging hosts", () => { + assert.deepStrictEqual([...GitCafeHosts.GITCAFE_HOSTS], ["git.cafe", "staging.git.cafe"]); + assert.isTrue(GitCafeHosts.isGitCafeHost("Git.Cafe")); + assert.isTrue(GitCafeHosts.isGitCafeHost("staging.git.cafe")); + for (const host of ["cafe", "evil.git.cafe", "git.cafe.example.com", "github.com"]) { + assert.isFalse(GitCafeHosts.isGitCafeHost(host), host); + } + }); + + it.each([ + ["https://git.cafe/team/project.git", "git.cafe"], + ["https://git.cafe/team/project", "git.cafe"], + ["https://staging.git.cafe/team/project.git/", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ["git@staging.git.cafe:team/project.git", "staging.git.cafe"], + ["ssh://git@git.cafe/team/project.git", "git.cafe"], + ])("reads the host and repository of %s", (url, host) => { + assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), { + host, + repository: "team/project", + }); + }); + + it.each([ + "https://github.com/team/project.git", + "https://evil.git.cafe/team/project.git", + "https://git.cafe/team/nested/project.git", + "https://git.cafe/team", + "team/project", + ])("is not a GitCafe repository: %s", (url) => { + assert.isNull(GitCafeHosts.parseGitCafeRemote(url)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts new file mode 100644 index 0000000000000000000000000000000000000000..a04b759b2d5f4601dd173e057cd0bd7dc9d524d0 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -0,0 +1,49 @@ +/** + * The hosts GitCafe lives on, and how a remote URL names one of its repositories. GitCafe has no + * self-hosted installs, so the list is fixed rather than discovered. + * + * @module source-control-gitcafe/server/gitCafeHosts + */ + +export const GITCAFE_HOSTS = ["git.cafe", "staging.git.cafe"] as const; +export type GitCafeHost = (typeof GITCAFE_HOSTS)[number]; + +/** The host as GitCafe spells it, or null for any host GitCafe does not live on. */ +export function gitCafeHost(host: string): GitCafeHost | null { + const normalized = host.trim().toLowerCase(); + return GITCAFE_HOSTS.find((candidate) => candidate === normalized) ?? null; +} + +export function isGitCafeHost(host: string): boolean { + return gitCafeHost(host) !== null; +} + +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const REMOTE_PATTERNS = [ + /^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu, + /^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu, + /^[^@/:]+@([^/:]+):(.+)$/u, +]; + +/** An `owner/name` repository path, without the `.git` suffix a clone URL carries. */ +export function gitCafeRepositoryPath(path: string): string | null { + const repository = path + .trim() + .replace(/\/+$/u, "") + .replace(/\.git$/u, ""); + return REPOSITORY.test(repository) ? repository : null; +} + +/** The host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. */ +export function parseGitCafeRemote( + url: string, +): { readonly host: GitCafeHost; readonly repository: string } | null { + for (const pattern of REMOTE_PATTERNS) { + const match = pattern.exec(url.trim()); + if (!match?.[1] || !match[2]) continue; + const host = gitCafeHost(match[1]); + const repository = gitCafeRepositoryPath(match[2]); + return host && repository ? { host, repository } : null; + } + return null; +} diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..41d578cdf1e0e9cebde1219f8d1582bd02f825e6 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -0,0 +1,547 @@ +/** + * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral + * `Provider*` shapes. Pass 2 creates these exports: + * + * Schemas (REST bodies as GitCafe sends them): + * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt, + * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`), + * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions` + * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`), + * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`, + * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`), + * `GitCafeChecks` (`{ items, next }`). + * + * Conversions (all take the repository host, so staging stays on staging): + * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest` + * - `toViewerPermissions(detail): PullRequestViewerPermissions` — fills `editChangeRequest`. + * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }): + * ProviderChangeRequestActivity` — fills `canEdit` on both copies of a remark and `canResolve` + * on review threads, from each comment's `capabilities`. + * - `toStack(stack, { repository, host }): ProviderChangeRequestStack` + * - `toDiff(diff): ProviderDiffSlice` + * - `toChecks(checks): ReadonlyArray` + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as GitCafePullRequestJson from "./gitCafePullRequestJson.ts"; + +const decodePull = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePull); +const decodePullDetail = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePullDetail); +const decodeComments = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeComments); +const decodeReviews = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReviews); +const decodeCommits = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeCommits); +const decodeReactions = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReactions); +const decodeStackEnvelope = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeStackEnvelope); +const decodeDiff = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeDiff); +const decodeChecks = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeChecks); + +const timestamp = "2026-09-12T12:00:00Z"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: true, + sourceBranch: "feature", + targetBranch: "main", + headOid: "abcdef", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 1, +}; +const detail = { + ...pull, + draft: false, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const lineComment = { + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: "abcdef", + resolvedAt: null, +}; +const capabilities = (overrides: Record = {}) => ({ + edit: false, + hide: false, + unhide: false, + delete: false, + resolve: false, + unresolve: false, + ...overrides, +}); +const noReviews = decodeReviews({ items: [], next: null }); +const noCommits = decodeCommits({ items: [], headOid: "abcdef", truncated: false, next: null }); +const activity = ( + comments: ReadonlyArray>, + extra: { readonly reactions?: unknown; readonly host?: string } = {}, +) => + GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: comments, next: null }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: extra.host ?? "git.cafe", + ...(extra.reactions === undefined ? {} : { reactions: decodeReactions(extra.reactions) }), + }); + +describe("toChangeRequest", () => { + it("reads draft state, the repository host's URLs and absolute avatars", () => { + const row = GitCafePullRequestJson.toChangeRequest(decodePull(pull), { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(row.state, "open"); + assert.isTrue(row.isDraft); + assert.strictEqual(row.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.deepStrictEqual(row.author, { + login: "alice", + name: null, + avatarUrl: "https://staging.git.cafe/avatars/one", + }); + }); + + it("names linked GitHub reviewers by both logins and drops unavailable ones", () => { + const row = GitCafePullRequestJson.toChangeRequest( + decodePull({ + ...pull, + author: { kind: "github", actorId: "act_remote", login: "remote-user", avatarUrl: null }, + reviewers: [ + { actor }, + { + actor: { + kind: "github", + actorId: "act_github", + login: "upstream", + avatarUrl: null, + linkedProfile: { handle: "linked-local" }, + }, + }, + { actor: { kind: "unavailable", actorId: "act_deleted" } }, + ], + }), + { repository: "owner/repo", host: "git.cafe" }, + ); + assert.strictEqual(row.author?.login, "remote-user"); + assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]); + }); + + it("requires capabilities on a detail response", () => { + assert.throws(() => decodePullDetail(pull)); + }); +}); + +describe("toViewerPermissions", () => { + const permissions = (overrides: Record) => + GitCafePullRequestJson.toViewerPermissions(decodePullDetail({ ...detail, ...overrides })); + + it("lets an editor rewrite the title and description, and nobody else", () => { + assert.isTrue(permissions({}).editChangeRequest); + assert.isFalse( + permissions({ + capabilities: { comment: true, review: true, merge: true, edit: false, moderate: true }, + }).editChangeRequest, + ); + }); + + it("offers lifecycle actions by state and never a standalone branch update", () => { + assert.deepStrictEqual(permissions({}).actions, ["draft", "close", "merge"]); + assert.deepStrictEqual(permissions({ state: "closed" }).actions, ["reopen"]); + assert.deepStrictEqual(permissions({ state: "merged" }).actions, []); + assert.isTrue(permissions({}).stackRebase); + }); + + it("keeps a locked conversation's composer for moderators only", () => { + const locked = (moderate: boolean) => + permissions({ + lockedAt: timestamp, + capabilities: { comment: true, review: false, merge: false, edit: false, moderate }, + }); + assert.isFalse(locked(false).comment); + assert.isFalse(locked(false).resolve); + assert.isTrue(locked(true).comment); + assert.deepStrictEqual(locked(true).verdicts, []); + }); + + it("offers no composer to a viewer GitCafe won't let comment", () => { + const silent = permissions({ + capabilities: { comment: false, review: true, merge: true, edit: true, moderate: true }, + }); + assert.isFalse(silent.comment); + assert.isFalse(silent.resolve); + }); +}); + +describe("toActivity", () => { + it("groups line comments by their persisted thread id and marks a paged list truncated", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ + items: [ + { ...lineComment, id: "reply", threadId: "thread-one" }, + { ...lineComment, id: "other", threadId: "thread-two" }, + ], + next: "next", + }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: "git.cafe", + }); + assert.deepStrictEqual(result.reviewThreads.map((thread) => thread.id).toSorted(), [ + "thread-one", + "thread-two", + ]); + assert.isTrue(result.commentsTruncated); + }); + + it.each([timestamp, null])("takes the thread's resolution from its root: %s", (resolvedAt) => { + const result = activity([ + { ...lineComment, id: "root", threadId: "root", resolvedAt }, + { ...lineComment, id: "reply", threadId: "root", resolvedAt: null }, + ]); + assert.strictEqual(result.reviewThreads.length, 1); + assert.strictEqual(result.reviewThreads[0]?.isResolved, resolvedAt !== null); + assert.strictEqual(result.reviewThreads[0]?.comments.length, 2); + }); + + it("carries per-remark edit permission onto both copies of a remark", () => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + capabilities: capabilities({ edit: false, resolve: true }), + }, + { + ...lineComment, + id: "reply", + threadId: "root", + capabilities: capabilities({ edit: true }), + }, + { ...lineComment, id: "silent", threadId: "silent", path: null, line: null, side: null }, + ]); + const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit; + assert.strictEqual(canEdit("root"), false); + assert.strictEqual(canEdit("reply"), true); + // A host that says nothing about a remark leaves the page to guess. + assert.isUndefined(canEdit("silent")); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]), + [ + ["root", false], + ["reply", true], + ], + ); + }); + + it.each([ + [null, { resolve: true, unresolve: false }, true], + [null, { resolve: false, unresolve: true }, false], + [timestamp, { resolve: true, unresolve: false }, false], + [timestamp, { resolve: false, unresolve: true }, true], + ] as const)( + "reads canResolve for a thread resolved at %s with %o as %s", + (resolvedAt, allowed, canResolve) => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + resolvedAt, + capabilities: capabilities(allowed), + }, + ]); + assert.strictEqual(result.reviewThreads[0]?.canResolve, canResolve); + }, + ); + + it("leaves canResolve absent when the root says nothing", () => { + const result = activity([{ ...lineComment, id: "root", threadId: "root" }]); + assert.isUndefined(result.reviewThreads[0]?.canResolve); + }); + + it("maps review verdicts, dismissals and commits", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: [], next: null }), + reviews: decodeReviews({ + items: [ + { + id: "r1", + author: actor, + body: "LGTM", + verdict: "approve", + dismissedAt: null, + createdAt: timestamp, + }, + { + id: "r2", + author: actor, + body: null, + verdict: "request_changes", + dismissedAt: timestamp, + createdAt: "2026-09-12T13:00:00Z", + }, + ], + next: null, + }), + commits: decodeCommits({ + items: [{ oid: "abcdef", summary: "Change", time: 1_789_000_000 }], + headOid: "abcdef", + truncated: false, + next: null, + }), + host: "git.cafe", + }); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]), + [ + ["r1", "review", "approved"], + ["r2", "review", "dismissed"], + ], + ); + assert.deepStrictEqual( + result.commits.map((commit) => commit.oid), + ["abcdef"], + ); + }); + + it("maps supported reactions to the pull request and to matching comments in both views", () => { + const result = activity( + [ + { ...lineComment, id: "comment-a", threadId: "comment-a" }, + { ...lineComment, id: "comment-b", threadId: "comment-a" }, + ], + { + reactions: { + next: null, + items: [ + { + subject: { kind: "pull_request_comment", id: "comment-b" }, + emoji: { kind: "unicode", value: "👍" }, + count: 3, + viewerReactionId: "reaction-viewer", + reactors: [actor, { kind: "unavailable", actorId: "deleted" }], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + // Neither a custom emoji nor one outside the shared set is shown under another name. + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "custom", id: "party-parrot" }, + count: 2, + viewerReactionId: null, + reactors: [actor], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🔥" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + ], + }, + }, + ); + const thumbsUp = { + content: "thumbs-up", + count: 3, + actors: ["alice"], + viewerHasReacted: true, + } as const; + assert.deepStrictEqual(result.reactions, [ + { content: "rocket", count: 1, actors: ["alice"], viewerHasReacted: false }, + ]); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + }); +}); + +describe("toStack", () => { + it("orders members by position and reports heads only where GitCafe gives them", () => { + const { stack } = decodeStackEnvelope({ + stack: { + id: "stack-one", + number: 3, + revision: 1, + landingBase: "main", + members: [ + { + pullRequestNumber: 7, + title: "Top", + state: "open", + draft: true, + sourceBranch: "feature", + position: 2, + }, + { + pullRequestNumber: 6, + title: "Base", + state: "merged", + draft: false, + sourceBranch: "base", + headOid: "0123456789abcdef0123456789abcdef01234567", + position: 1, + }, + ], + }, + }); + assert.isNotNull(stack); + const result = GitCafePullRequestJson.toStack(stack!, { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(result.url, "https://staging.git.cafe/owner/repo/stacks/3"); + assert.strictEqual(result.base, "main"); + assert.deepStrictEqual(result.layers, [ + { + number: 6, + title: "Base", + headBranch: "base", + state: "merged", + isDraft: false, + headSha: "0123456789abcdef0123456789abcdef01234567", + }, + { number: 7, title: "Top", headBranch: "feature", state: "open", isDraft: true }, + ]); + }); + + it("reads an unstacked pull request as no stack", () => { + assert.isNull(decodeStackEnvelope({ stack: null }).stack); + }); +}); + +describe("toDiff", () => { + it("converts numeric hunk coordinates into a unified patch", () => { + const diff = decodeDiff({ + items: [ + { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + truncated: false, + }); + assert.include(GitCafePullRequestJson.toDiff(diff).patch, "@@ -1,1 +1,1 @@\n-old\n+new\n"); + }); + + it("marks an omitted patch incomplete even when GitCafe's own flag is false", () => { + const diff = decodeDiff({ + items: [{ path: "large.ts", oldPath: null, status: "modified", tooLarge: true, hunks: [] }], + truncated: false, + }); + const result = GitCafePullRequestJson.toDiff(diff); + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.omittedFileStats, [ + { path: "large.ts", additions: 0, deletions: 0 }, + ]); + }); + + it("quotes renamed and binary paths", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "new image.png", + oldPath: "old image.png", + status: "renamed", + binary: true, + hunks: [], + }, + ], + truncated: false, + }), + ); + // Git quotes a name for the bytes a header can't carry, not for a space. + assert.include(patch, "rename from old image.png"); + assert.include(patch, "Binary files a/old image.png and b/new image.png differ"); + }); + + it("C-escapes a name whose newline or tab would break the patch headers", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [{ path: "two\nlines\tname.ts", status: "added", hunks: [] }], + truncated: false, + }), + ); + assert.include(patch, 'diff --git "a/two\\nlines\\tname.ts" "b/two\\nlines\\tname.ts"'); + assert.notInclude(patch, "two\nlines"); + }); +}); + +describe("toChecks", () => { + it("maps GitCafe's status and conclusion onto the shared check states", () => { + const check = (status: string, conclusion: string | null) => ({ + name: `${status}-${conclusion}`, + status, + conclusion, + summary: null, + detailsUrl: null, + }); + const checks = GitCafePullRequestJson.toChecks( + decodeChecks({ + items: [ + check("queued", null), + check("in_progress", null), + check("completed", "success"), + check("completed", "action_required"), + check("completed", "timed_out"), + ], + next: null, + }), + ); + assert.deepStrictEqual( + checks.map((item) => item.status), + ["pending", "pending", "success", "action-required", "failure"], + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts new file mode 100644 index 0000000000000000000000000000000000000000..453083a7a6b43f8d82cf004a32b14ce78b9da2c5 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -0,0 +1,578 @@ +/** + * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every + * conversion takes the repository's host, so a staging repository's links stay on staging. + * + * @module source-control-gitcafe/server/gitCafePullRequestJson + */ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestCheck, + type PullRequestComment, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewThread, + type PullRequestViewerPermissions, +} from "@t3tools/contracts"; +import type { + ProviderChangeRequest, + ProviderChangeRequestActivity, + ProviderChangeRequestStack, + ProviderDiffSlice, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +const Actor = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("local"), + actorId: Schema.String, + handle: TrimmedNonEmptyString, + displayName: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("github"), + actorId: Schema.String, + login: TrimmedNonEmptyString, + avatarUrl: Schema.NullOr(Schema.String), + linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))), + }), + Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }), +]); +type Actor = typeof Actor.Type; + +const PullState = Schema.Literals(["open", "closed", "merged"]); + +export const GitCafePull = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + targetBranch: Schema.String, + headOid: Schema.NullOr(Schema.String), + author: Actor, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + version: NonNegativeInt, + reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))), + labels: Schema.optional( + Schema.Array( + Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }), + ), + ), +}); +export type GitCafePull = typeof GitCafePull.Type; + +export const GitCafePulls = Schema.Struct({ + items: Schema.Array(GitCafePull), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafePullDetail = Schema.Struct({ + ...GitCafePull.fields, + description: Schema.NullOr(Schema.String), + closedAt: Schema.NullOr(IsoDateTime), + mergedAt: Schema.NullOr(IsoDateTime), + sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })), + /** The base GitCafe last compared the pull request against, which a provider merge fences on. */ + observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)), + mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), + lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + capabilities: Schema.Struct({ + comment: Schema.Boolean, + review: Schema.Boolean, + merge: Schema.Boolean, + edit: Schema.Boolean, + moderate: Schema.Boolean, + }), +}); +export type GitCafePullDetail = typeof GitCafePullDetail.Type; + +const CommentCapabilities = Schema.Struct({ + edit: Schema.Boolean, + hide: Schema.Boolean, + unhide: Schema.Boolean, + delete: Schema.Boolean, + resolve: Schema.Boolean, + unresolve: Schema.Boolean, +}); + +const GitCafeComment = Schema.Struct({ + id: TrimmedNonEmptyString, + threadId: TrimmedNonEmptyString, + /** What a resolution or rewrite fences on; absent from older answers. */ + version: Schema.optional(NonNegativeInt), + author: Actor, + body: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, + path: Schema.NullOr(Schema.String), + // The API allows any integer here; only a positive line anchors a review thread. + line: Schema.NullOr(Schema.Int), + side: Schema.NullOr(Schema.Literals(["left", "right"])), + commitOid: Schema.NullOr(Schema.String), + resolvedAt: Schema.NullOr(IsoDateTime), + capabilities: Schema.optional(CommentCapabilities), +}); +type GitCafeComment = typeof GitCafeComment.Type; + +export const GitCafeComments = Schema.Struct({ + items: Schema.Array(GitCafeComment), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeReviews = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + author: Actor, + body: Schema.NullOr(Schema.String), + verdict: Schema.Literals(["approve", "request_changes", "comment"]), + dismissedAt: Schema.NullOr(IsoDateTime), + createdAt: IsoDateTime, + }), + ), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeCommits = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }), + ), + truncated: Schema.Boolean, + next: Schema.NullOr(Schema.String), + headOid: Schema.String, +}); + +export const GitCafeReactions = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + subject: Schema.Struct({ + kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]), + id: TrimmedNonEmptyString, + }), + emoji: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }), + Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }), + ]), + count: PositiveInt, + viewerReactionId: Schema.NullOr(Schema.String), + reactors: Schema.Array(Actor), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +const GitCafeStack = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + /** What a land or restack fences on. */ + revision: PositiveInt, + landingBase: Schema.String, + members: Schema.Array( + Schema.Struct({ + pullRequestNumber: PositiveInt, + title: Schema.String, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + headOid: Schema.optional(Schema.String), + position: PositiveInt, + }), + ), +}); +export type GitCafeStack = typeof GitCafeStack.Type; + +export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) }); + +const GitCafeDiffFile = Schema.Struct({ + path: TrimmedNonEmptyString, + oldPath: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional( + Schema.Literals([ + "added", + "deleted", + "modified", + "typeChanged", + "conflicted", + "renamed", + "copied", + ]), + ), + additions: Schema.optional(NonNegativeInt), + deletions: Schema.optional(NonNegativeInt), + binary: Schema.optional(Schema.Boolean), + tooLarge: Schema.optional(Schema.Boolean), + hunksOmitted: Schema.optional(Schema.Boolean), + isSubmodule: Schema.optional(Schema.Boolean), + oldOid: Schema.optional(Schema.String), + newOid: Schema.optional(Schema.String), + // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them. + hunks: Schema.optional( + Schema.Array( + Schema.Struct({ + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, + lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })), + }), + ), + ), +}); +export type GitCafeDiffFile = typeof GitCafeDiffFile.Type; +export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile); + +export const GitCafeDiff = Schema.Struct({ + items: GitCafeDiffFiles, + truncated: Schema.optional(Schema.Boolean), +}); + +export const GitCafeChecks = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + status: Schema.Literals(["queued", "in_progress", "completed"]), + conclusion: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + detailsUrl: Schema.NullOr(Schema.String), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +interface RepositoryOnHost { + readonly repository: string; + readonly host: string; +} + +function toActor(actor: Actor, host: string): PullRequestActor | null { + if (actor.kind === "unavailable") return null; + return { + login: actor.kind === "local" ? actor.handle : actor.login, + name: actor.kind === "local" ? actor.displayName : null, + // GitCafe serves its own avatars by path, so a relative one is read against the host. + avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, + }; +} + +/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */ +export function toReviewers( + pull: Pick, + host: string, +): ReadonlyArray { + return (pull.reviewers ?? []).flatMap(({ actor }) => { + const reviewer = toActor(actor, host); + return reviewer === null ? [] : [reviewer]; + }); +} + +/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */ +function reviewerLogins(actor: Actor, host: string): ReadonlyArray { + const reviewer = toActor(actor, host); + if (reviewer === null) return []; + return actor.kind === "github" && actor.linkedProfile + ? [reviewer.login, actor.linkedProfile.handle] + : [reviewer.login]; +} + +export function toChangeRequest( + pull: GitCafePull, + target: RepositoryOnHost, +): ProviderChangeRequest { + return { + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + author: toActor(pull.author, target.host), + headBranch: pull.sourceBranch, + baseBranch: pull.targetBranch, + state: pull.state, + isDraft: pull.draft, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: pull.createdAt, + updatedAt: pull.updatedAt, + reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) => + reviewerLogins(actor, target.host), + ), + labels: pull.labels ?? [], + }; +} + +/** What GitCafe's own capabilities let this viewer do with the pull request. */ +export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions { + const { comment, edit, review, merge, moderate } = detail.capabilities; + // A locked conversation keeps its composer for moderators only. + const open = comment && (detail.lockedAt == null || moderate); + return { + actions: [ + // A merged pull request is settled; a closed one can only be reopened. + ...(edit && detail.state === "open" + ? ([detail.draft ? "ready" : "draft", "close"] as const) + : edit && detail.state === "closed" + ? (["reopen"] as const) + : []), + ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []), + ], + stackRebase: merge, + comment: open, + // Each thread's root narrows this further with its own `canResolve`. + resolve: open, + verdicts: review ? ["approve", "request-changes", "comment"] : [], + requestReviewers: false, + editChangeRequest: edit, + // GitCafe's only branch update moves a whole stack. + updateMethods: [], + }; +} + +const REACTIONS: Readonly> = { + "👍": "thumbs-up", + "👎": "thumbs-down", + "😄": "laugh", + "🎉": "hooray", + "😕": "confused", + "❤": "heart", + "🚀": "rocket", + "👀": "eyes", +}; + +/** The emoji GitCafe takes for one of the shared reactions. */ +export const REACTION_EMOJI: Readonly> = { + "thumbs-up": "👍", + "thumbs-down": "👎", + laugh: "😄", + hooray: "🎉", + confused: "😕", + heart: "❤️", + rocket: "🚀", + eyes: "👀", +}; + +/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */ +export function reactionContent(emoji: string): PullRequestReactionContent | undefined { + return REACTIONS[emoji.replaceAll("\uFE0F", "")]; +} + +function toReactions(data: typeof GitCafeReactions.Type, host: string) { + const reactions: Array = []; + const byComment = new Map>(); + for (const item of data.items) { + const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined; + if (content === undefined) continue; + const reaction: PullRequestReaction = { + content, + count: item.count, + actors: item.reactors.flatMap((actor) => { + const reactor = toActor(actor, host); + return reactor === null ? [] : [reactor.login]; + }), + viewerHasReacted: item.viewerReactionId !== null, + }; + if (item.subject.kind === "pull_request") reactions.push(reaction); + else if (item.subject.kind === "pull_request_comment") + byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]); + } + return { reactions, byComment }; +} + +const REVIEW_STATES = { + approve: "approved", + request_changes: "changes_requested", + comment: "commented", +} as const; + +/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */ +export function toActivity(input: { + readonly comments: typeof GitCafeComments.Type; + readonly reviews: typeof GitCafeReviews.Type; + readonly commits: typeof GitCafeCommits.Type; + readonly reactions?: typeof GitCafeReactions.Type; + readonly headOid?: string; + readonly host: string; +}): ProviderChangeRequestActivity { + const { host } = input; + const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host); + const remark = (comment: GitCafeComment) => ({ + id: comment.id, + author: toActor(comment.author, host), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: null, + ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }), + ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }), + }); + // Stable, so remarks written in the same instant keep GitCafe's own order. + const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + const groups = new Map>(); + for (const comment of ordered) { + if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null) + continue; + groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]); + } + const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => { + const first = group[0]!; + const latest = group.at(-1)!; + const root = group.find((comment) => comment.id === comment.threadId); + return { + id: first.threadId, + path: first.path!, + line: first.line, + side: first.side!, + isResolved: root?.resolvedAt != null, + isOutdated: + input.headOid !== undefined && + latest.commitOid !== null && + latest.commitOid !== input.headOid, + comments: group.map(remark), + ...(root?.capabilities === undefined + ? {} + : { + canResolve: + root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve, + }), + }; + }); + const comments: Array = [ + ...ordered.map((comment) => ({ + ...remark(comment), + kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const), + path: comment.path, + reviewState: null, + })), + ...input.reviews.items.map((review) => ({ + id: review.id, + kind: "review" as const, + author: toActor(review.author, host), + body: review.body ?? "", + createdAt: review.createdAt, + url: null, + path: null, + reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict], + })), + ]; + return { + comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)), + commentCount: input.comments.items.length + input.reviews.items.length, + commentsTruncated: input.comments.next !== null || input.reviews.next !== null, + reviewThreads, + commits: input.commits.items.map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.summary, + committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)), + })), + ...(reactions === undefined ? {} : { reactions: reactions.reactions }), + }; +} + +export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack { + return { + id: stack.id, + number: stack.number, + url: `https://${target.host}/${target.repository}/stacks/${stack.number}`, + base: stack.landingBase, + layers: stack.members + .toSorted((a, b) => a.position - b.position) + .map((member) => ({ + number: member.pullRequestNumber, + title: member.title, + headBranch: member.sourceBranch, + state: member.state, + isDraft: member.draft, + ...(member.headOid === undefined ? {} : { headSha: member.headOid }), + })), + }; +} + +/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */ +export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { + const chunks: Array = []; + const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; + for (const file of diff.items) { + const oldPath = file.oldPath ?? file.path; + const a = quoteGitPatchPath(`a/${oldPath}`); + const b = quoteGitPatchPath(`b/${file.path}`); + const before = file.status === "added" ? "/dev/null" : a; + const after = file.status === "deleted" ? "/dev/null" : b; + const mode = file.isSubmodule ? "160000" : "100644"; + const lines = [`diff --git ${a} ${b}`]; + if (file.status === "added") lines.push(`new file mode ${mode}`); + if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); + if (file.status === "renamed" || file.status === "copied") { + const action = file.status === "renamed" ? "rename" : "copy"; + lines.push( + `${action} from ${quoteGitPatchPath(oldPath)}`, + `${action} to ${quoteGitPatchPath(file.path)}`, + ); + } + if (file.binary) { + lines.push(`Binary files ${before} and ${after} differ`); + } else if (file.tooLarge || file.hunksOmitted) { + omittedFileStats.push({ + path: file.path, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + }); + } else if (file.isSubmodule) { + const hasOld = file.status !== "added" && file.oldOid !== undefined; + const hasNew = file.status !== "deleted" && file.newOid !== undefined; + lines.push(`--- ${before}`, `+++ ${after}`); + lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`); + if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`); + if (hasNew) lines.push(`+Subproject commit ${file.newOid}`); + } else { + const hunks = file.hunks ?? []; + if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); + for (const hunk of hunks) { + lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + for (const line of hunk.lines) { + // GitCafe marks a missing final newline with libgit2's own origins. + if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); + else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + } + } + } + chunks.push(lines.join("\n")); + } + return { + patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`, + truncated: diff.truncated === true || omittedFileStats.length > 0, + nextCursor: null, + ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }), + }; +} + +function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] { + if (check.status !== "completed") return "pending"; + switch (check.conclusion) { + case "success": + return "success"; + case "neutral": + return "neutral"; + case "skipped": + return "skipped"; + case "cancelled": + return "cancelled"; + case "action_required": + return "action-required"; + default: + return "failure"; + } +} + +export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray { + return checks.items.map((check) => ({ + name: check.name, + status: checkStatus(check), + description: check.summary, + url: check.detailsUrl, + })); +} diff --git a/packages/source-control-gitcafe/tsconfig.json b/packages/source-control-gitcafe/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/source-control-gitcafe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e44380dde31e7c95e034ef3f44281598b6dfd63..249f45a453cc958c2961f4476ff64d5984e9fd2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo + '@t3tools/source-control-gitcafe': + specifier: workspace:* + version: link:../../packages/source-control-gitcafe '@t3tools/source-control-github': specifier: workspace:* version: link:../../packages/source-control-github @@ -1643,6 +1646,37 @@ importers: specifier: 1.0.0 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) + packages/source-control-gitcafe: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + 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) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + 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) + packages/source-control-github: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 7bc6da4817163bfabefe0f9161daebd1073d999e..272026efd71e59506cf2988655aa30fd176107f8 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -41,6 +41,7 @@ const workspaceFiles = [ "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", + "packages/source-control-gitcafe/package.json", "packages/source-control-github/package.json", "packages/source-control-gitlab/package.json", "packages/source-control-testing/package.json",