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