| 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,6 +49,7 @@ |
| 49 | "@t3tools/source-control-bitbucket": "workspace:*", | 49 | "@t3tools/source-control-bitbucket": "workspace:*", |
| 50 | "@t3tools/source-control-core": "workspace:*", | 50 | "@t3tools/source-control-core": "workspace:*", |
| 51 | "@t3tools/source-control-forgejo": "workspace:*", | 51 | "@t3tools/source-control-forgejo": "workspace:*", |
| 52 | "@t3tools/source-control-gitcafe": "workspace:*", | ||
| 52 | "@t3tools/source-control-github": "workspace:*", | 53 | "@t3tools/source-control-github": "workspace:*", |
| 53 | "@t3tools/source-control-gitlab": "workspace:*", | 54 | "@t3tools/source-control-gitlab": "workspace:*", |
| 54 | "effect": "catalog:", | 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,6 +25,7 @@ import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/Git |
| 25 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; | 25 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 26 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; | 26 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; |
| 27 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | 27 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 28 | import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; | ||
| 28 | import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; | 29 | import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; |
| 29 | import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; | 30 | import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; |
| 30 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; | 31 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; |
| ... | @@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: { | ... | @@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 53 | Layer.mock(GitLabCli.GitLabCli)({}), | 54 | Layer.mock(GitLabCli.GitLabCli)({}), |
| 54 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), | 55 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), |
| 55 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), | 56 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 57 | Layer.mock(GitCafeApi.GitCafeApi)({}), | ||
| 56 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), | 58 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), |
| 57 | Layer.mock(VcsProcess.VcsProcess)(input.process), | 59 | Layer.mock(VcsProcess.VcsProcess)(input.process), |
| 58 | ServerSourceControlHost.layer.pipe( | 60 | ServerSourceControlHost.layer.pipe( |
| ... | @@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl | ... | @@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl |
| 203 | auth: "unknown", | 205 | auth: "unknown", |
| 204 | account: Option.none(), | 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 | const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); | 216 | const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); |
| ... | @@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user | ... | @@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user |
| 266 | ) { | 274 | ) { |
| 267 | return Effect.succeed(processOutput("azure-user@example.com\n")); | 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 | return Effect.fail( | 284 | return Effect.fail( |
| 270 | new VcsProcessSpawnError({ | 285 | new VcsProcessSpawnError({ |
| 271 | operation: input.operation, | 286 | operation: input.operation, |
| ... | @@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user | ... | @@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user |
| 341 | account: Option.some("forgejo-user"), | 356 | account: Option.some("forgejo-user"), |
| 342 | detail: Option.none(), | 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 | }).pipe(Effect.provide(layerTest)); | 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,6 +24,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub |
| 24 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; | 24 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 25 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; | 25 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; |
| 26 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | 26 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 27 | import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; | ||
| 27 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 28 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 28 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | 29 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 29 | 30 | ||
| ... | @@ -126,6 +127,7 @@ function makeRegistry(input: { | ... | @@ -126,6 +127,7 @@ function makeRegistry(input: { |
| 126 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), | 127 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), |
| 127 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), | 128 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), |
| 128 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), | 129 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 130 | Layer.mock(GitCafeApi.GitCafeApi)({}), | ||
| 129 | ServerConfig.layerTest(process.cwd(), { | 131 | ServerConfig.layerTest(process.cwd(), { |
| 130 | prefix: "t3-source-control-registry-test-", | 132 | prefix: "t3-source-control-registry-test-", |
| 131 | }).pipe(Layer.provide(NodeServices.layer)), | 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,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv |
| 15 | import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; | 15 | import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; |
| 16 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | 16 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 17 | import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; | 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 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; | 21 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; |
| 19 | import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; | 22 | import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; |
| 20 | import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; | 23 | import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; |
| ... | @@ -32,6 +35,7 @@ const drivers = [ | ... | @@ -32,6 +35,7 @@ const drivers = [ |
| 32 | AzureDevOpsDriver.driver, | 35 | AzureDevOpsDriver.driver, |
| 33 | BitbucketDriver.driver, | 36 | BitbucketDriver.driver, |
| 34 | ForgejoDriver.driver, | 37 | ForgejoDriver.driver, |
| 38 | GitCafeDriver.driver, | ||
| 35 | ]; | 39 | ]; |
| 36 | 40 | ||
| 37 | /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ | 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,5 +55,6 @@ export const layer = Layer.mergeAll( |
| 51 | AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), | 55 | AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), |
| 52 | BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), | 56 | BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), |
| 53 | ForgejoCli.layer, | 57 | ForgejoCli.layer, |
| 58 | GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), | ||
| 54 | GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), | 59 | GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), |
| 55 | ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); | 60 | ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); |
apps/web/src/components/CommandPalette.tsx+18-2| ... | @@ -187,7 +187,14 @@ import { | ... | @@ -187,7 +187,14 @@ import { |
| 187 | CommandPaletteVirtualizedResults, | 187 | CommandPaletteVirtualizedResults, |
| 188 | scrollCommandPaletteRowIntoView, | 188 | scrollCommandPaletteRowIntoView, |
| 189 | } from "./CommandPaletteResults"; | 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 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 198 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 192 | import { Checkbox } from "./ui/checkbox"; | 199 | import { Checkbox } from "./ui/checkbox"; |
| 193 | import { ProjectFavicon } from "./ProjectFavicon"; | 200 | import { ProjectFavicon } from "./ProjectFavicon"; |
| ... | @@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption { | ... | @@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption { |
| 252 | 259 | ||
| 253 | type AddProjectRemoteProviderKind = Extract< | 260 | type AddProjectRemoteProviderKind = Extract< |
| 254 | SourceControlProviderKind, | 261 | SourceControlProviderKind, |
| 255 | "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | 262 | "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" |
| 256 | >; | 263 | >; |
| 257 | type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | 264 | type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; |
| 258 | 265 | ||
| ... | @@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [ | ... | @@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [ |
| 278 | "forgejo", | 285 | "forgejo", |
| 279 | "bitbucket", | 286 | "bitbucket", |
| 280 | "azure-devops", | 287 | "azure-devops", |
| 288 | "gitcafe", | ||
| 281 | ]; | 289 | ]; |
| 282 | const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | 290 | const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ |
| 283 | "github", | 291 | "github", |
| ... | @@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKin | ... | @@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKin |
| 285 | "forgejo", | 293 | "forgejo", |
| 286 | "bitbucket", | 294 | "bitbucket", |
| 287 | "azure-devops", | 295 | "azure-devops", |
| 296 | "gitcafe", | ||
| 288 | ]; | 297 | ]; |
| 289 | 298 | ||
| 290 | function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { | 299 | function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { |
| ... | @@ -299,6 +308,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { | ... | @@ -299,6 +308,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { |
| 299 | return "Bitbucket"; | 308 | return "Bitbucket"; |
| 300 | case "azure-devops": | 309 | case "azure-devops": |
| 301 | return "Azure DevOps"; | 310 | return "Azure DevOps"; |
| 311 | case "gitcafe": | ||
| 312 | return "GitCafe"; | ||
| 302 | case "url": | 313 | case "url": |
| 303 | return "Git URL"; | 314 | return "Git URL"; |
| 304 | } | 315 | } |
| ... | @@ -308,6 +319,7 @@ function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { | ... | @@ -308,6 +319,7 @@ function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { |
| 308 | switch (source) { | 319 | switch (source) { |
| 309 | case "forgejo": | 320 | case "forgejo": |
| 310 | case "github": | 321 | case "github": |
| 322 | case "gitcafe": | ||
| 311 | return "owner/repo"; | 323 | return "owner/repo"; |
| 312 | case "gitlab": | 324 | case "gitlab": |
| 313 | return "group/project"; | 325 | return "group/project"; |
| ... | @@ -338,6 +350,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri | ... | @@ -338,6 +350,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri |
| 338 | return <BitbucketIcon className={className} />; | 350 | return <BitbucketIcon className={className} />; |
| 339 | case "azure-devops": | 351 | case "azure-devops": |
| 340 | return <AzureDevOpsIcon className={className} />; | 352 | return <AzureDevOpsIcon className={className} />; |
| 353 | case "gitcafe": | ||
| 354 | return <GitCafeIcon className={className} />; | ||
| 341 | case "url": | 355 | case "url": |
| 342 | return <LinkIcon className={className} />; | 356 | return <LinkIcon className={className} />; |
| 343 | } | 357 | } |
| ... | @@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness( | ... | @@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness( |
| 392 | forgejo: unavailable, | 406 | forgejo: unavailable, |
| 393 | bitbucket: unavailable, | 407 | bitbucket: unavailable, |
| 394 | "azure-devops": unavailable, | 408 | "azure-devops": unavailable, |
| 409 | gitcafe: unavailable, | ||
| 395 | }; | 410 | }; |
| 396 | 411 | ||
| 397 | if (!discovery) { | 412 | if (!discovery) { |
| ... | @@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: { |
| 2093 | "bitbucket", | 2108 | "bitbucket", |
| 2094 | "azure", | 2109 | "azure", |
| 2095 | "devops", | 2110 | "devops", |
| 2111 | "gitcafe", | ||
| 2096 | "url", | 2112 | "url", |
| 2097 | "environment", | 2113 | "environment", |
| 2098 | ], | 2114 | ], |
apps/web/src/components/GitActionsControl.tsx+11-1| ... | @@ -50,6 +50,7 @@ import { | ... | @@ -50,6 +50,7 @@ import { |
| 50 | GitHubIcon, | 50 | GitHubIcon, |
| 51 | GitLabIcon, | 51 | GitLabIcon, |
| 52 | ForgejoIcon, | 52 | ForgejoIcon, |
| 53 | GitCafeIcon, | ||
| 53 | } from "~/components/Icons"; | 54 | } from "~/components/Icons"; |
| 54 | import { RadioGroup } from "~/components/ui/radio-group"; | 55 | import { RadioGroup } from "~/components/ui/radio-group"; |
| 55 | import { Spinner } from "~/components/ui/spinner"; | 56 | import { Spinner } from "~/components/ui/spinner"; |
| ... | @@ -158,7 +159,7 @@ interface PendingDefaultBranchAction { | ... | @@ -158,7 +159,7 @@ interface PendingDefaultBranchAction { |
| 158 | 159 | ||
| 159 | type PublishProviderKind = Extract< | 160 | type PublishProviderKind = Extract< |
| 160 | SourceControlProviderKind, | 161 | SourceControlProviderKind, |
| 161 | "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | 162 | "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" |
| 162 | >; | 163 | >; |
| 163 | 164 | ||
| 164 | type GitActionToastId = ReturnType<typeof toastManager.add>; | 165 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| ... | @@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ | ... | @@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ |
| 239 | pathPlaceholder: "project/repository", | 240 | pathPlaceholder: "project/repository", |
| 240 | Icon: AzureDevOpsIcon, | 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 | ] as const satisfies ReadonlyArray<{ | 251 | ] as const satisfies ReadonlyArray<{ |
| 243 | readonly value: PublishProviderKind; | 252 | readonly value: PublishProviderKind; |
| 244 | readonly label: string; | 253 | readonly label: string; |
| ... | @@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 565 | forgejo: null, | 574 | forgejo: null, |
| 566 | bitbucket: null, | 575 | bitbucket: null, |
| 567 | "azure-devops": null, | 576 | "azure-devops": null, |
| 577 | gitcafe: null, | ||
| 568 | }; | 578 | }; |
| 569 | for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { | 579 | for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { |
| 570 | if (isPublishProviderKind(provider.kind)) { | 580 | if (isPublishProviderKind(provider.kind)) { |
apps/web/src/components/Icons.tsx+8| ... | @@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => ( | ... | @@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => ( |
| 693 | </svg> | 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 | // macOS System Settings > Privacy & Security pane marks, so the SnapShot setup | 704 | // macOS System Settings > Privacy & Security pane marks, so the SnapShot setup |
| 697 | // step points at the same icon the user is about to look for. | 705 | // step points at the same icon the user is about to look for. |
| 698 | export const MacScreenRecordingIcon: Icon = (props) => { | 706 | export const MacScreenRecordingIcon: Icon = (props) => { |
apps/web/src/components/ThreadStatusIndicators.test.tsx+22| ... | @@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => { | ... | @@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => { |
| 79 | sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, | 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 +6,7 @@ import { |
| 6 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | 6 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 7 | 7 | ||
| 8 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; | 8 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; |
| 9 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | ||
| 9 | import { | 10 | import { |
| 10 | resolveEnvironmentMachineKind, | 11 | resolveEnvironmentMachineKind, |
| 11 | type EnvironmentId, | 12 | type EnvironmentId, |
| ... | @@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus( | ... | @@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus( |
| 129 | ): LinkedThreadPullRequestStatus | null { | 130 | ): LinkedThreadPullRequestStatus | null { |
| 130 | const snapshot = link.snapshot; | 131 | const snapshot = link.snapshot; |
| 131 | if (snapshot === null) return null; | 132 | if (snapshot === null) return null; |
| 132 | const kind = link.url.includes("/-/merge_requests/") | 133 | // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first. |
| 133 | ? "gitlab" | 134 | const kind = |
| 134 | : link.url.includes("/pullrequest/") | 135 | detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe" |
| 135 | ? "azure-devops" | 136 | ? "gitcafe" |
| 136 | : link.url.includes("/pull-requests/") | 137 | : link.url.includes("/-/merge_requests/") |
| 137 | ? "bitbucket" | 138 | ? "gitlab" |
| 138 | : link.url.includes("/pulls/") | 139 | : link.url.includes("/pullrequest/") |
| 139 | ? "forgejo" | 140 | ? "azure-devops" |
| 140 | : "github"; | 141 | : link.url.includes("/pull-requests/") |
| 142 | ? "bitbucket" | ||
| 143 | : link.url.includes("/pulls/") | ||
| 144 | ? "forgejo" | ||
| 145 | : "github"; | ||
| 141 | return { | 146 | return { |
| 142 | pr: { | 147 | pr: { |
| 143 | number: link.number, | 148 | number: link.number, |
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+2| ... | @@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand( | ... | @@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand( |
| 128 | : null; | 128 | : null; |
| 129 | case "azure-devops": | 129 | case "azure-devops": |
| 130 | return `az repos pr checkout --id ${number}`; | 130 | return `az repos pr checkout --id ${number}`; |
| 131 | case "gitcafe": | ||
| 132 | return `cafe pr checkout ${number}`; | ||
| 131 | case "bitbucket": { | 133 | case "bitbucket": { |
| 132 | if ( | 134 | if ( |
| 133 | !headRepositoryNameWithOwner || | 135 | !headRepositoryNameWithOwner || |
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts+1| ... | @@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = { | ... | @@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = { |
| 13 | gitlab: "Open on GitLab", | 13 | gitlab: "Open on GitLab", |
| 14 | forgejo: "Open on Forgejo", | 14 | forgejo: "Open on Forgejo", |
| 15 | bitbucket: "Open on Bitbucket", | 15 | bitbucket: "Open on Bitbucket", |
| 16 | gitcafe: "Open on GitCafe", | ||
| 16 | "azure-devops": "Open on Azure DevOps", | 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,6 +49,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 49 | import { | 49 | import { |
| 50 | AzureDevOpsIcon, | 50 | AzureDevOpsIcon, |
| 51 | BitbucketIcon, | 51 | BitbucketIcon, |
| 52 | GitCafeIcon, | ||
| 52 | GitHubIcon, | 53 | GitHubIcon, |
| 53 | GitIcon, | 54 | GitIcon, |
| 54 | GitLabIcon, | 55 | GitLabIcon, |
| ... | @@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, I | ... | @@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, I |
| 83 | forgejo: ForgejoIcon, | 84 | forgejo: ForgejoIcon, |
| 84 | "azure-devops": AzureDevOpsIcon, | 85 | "azure-devops": AzureDevOpsIcon, |
| 85 | bitbucket: BitbucketIcon, | 86 | bitbucket: BitbucketIcon, |
| 87 | gitcafe: GitCafeIcon, | ||
| 86 | }; | 88 | }; |
| 87 | 89 | ||
| 88 | const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { | 90 | const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
apps/web/src/sourceControlPresentation.ts+7| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | AzureDevOpsIcon, | 16 | AzureDevOpsIcon, |
| 17 | BitbucketIcon, | 17 | BitbucketIcon, |
| 18 | ForgejoIcon, | 18 | ForgejoIcon, |
| 19 | GitCafeIcon, | ||
| 19 | GitHubIcon, | 20 | GitHubIcon, |
| 20 | GitLabIcon, | 21 | GitLabIcon, |
| 21 | } from "./components/Icons"; | 22 | } from "./components/Icons"; |
| ... | @@ -62,6 +63,12 @@ export function getSourceControlPresentation( | ... | @@ -62,6 +63,12 @@ export function getSourceControlPresentation( |
| 62 | terminology: getChangeRequestTerminology(provider), | 63 | terminology: getChangeRequestTerminology(provider), |
| 63 | Icon: BitbucketIcon, | 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 | case "change-request": | 72 | case "change-request": |
| 66 | return { | 73 | return { |
| 67 | providerName: provider?.name || presentation.providerName, | 74 | providerName: provider?.name || presentation.providerName, |
apps/web/src/state/sourceControlActions.ts+1-1| ... | @@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi | ... | @@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi |
| 275 | ); | 275 | ); |
| 276 | const action = useCallback( | 276 | const action = useCallback( |
| 277 | async (input: { | 277 | async (input: { |
| 278 | provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; | 278 | provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; |
| 279 | repository: string; | 279 | repository: string; |
| 280 | visibility: SourceControlRepositoryVisibility; | 280 | visibility: SourceControlRepositoryVisibility; |
| 281 | remoteName: string; | 281 | remoteName: string; |
docs/user/source-control.md+18-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | # Source control | 1 | # Source control |
| 2 | 2 | ||
| 3 | T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish | 3 | T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone |
| 4 | repositories, create pull requests, and review changes. | 4 | and publish repositories, create pull requests, and review changes. |
| 5 | 5 | ||
| 6 | ## Review turn changes | 6 | ## Review turn changes |
| 7 | 7 | ||
| ... | @@ -96,6 +96,22 @@ az extension add --name azure-devops | ... | @@ -96,6 +96,22 @@ az extension add --name azure-devops |
| 96 | az login | 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 | ## Start, clone, or publish a project | 115 | ## Start, clone, or publish a project |
| 100 | 116 | ||
| 101 | To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or | 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,7 +28,7 @@ |
| 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | "lint": "vp lint --report-unused-disable-directives", | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", | 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 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | "test": "vp run -r test", | 34 | "test": "vp run -r test", |
packages/contracts/src/sourceControl.ts+1| ... | @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ | ... | @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ |
| 8 | "forgejo", | 8 | "forgejo", |
| 9 | "azure-devops", | 9 | "azure-devops", |
| 10 | "bitbucket", | 10 | "bitbucket", |
| 11 | "gitcafe", | ||
| 11 | "unknown", | 12 | "unknown", |
| 12 | ]); | 13 | ]); |
| 13 | export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; | 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,6 +48,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu |
| 48 | if (url.protocol !== "https:" && url.protocol !== "http:") return null; | 48 | if (url.protocol !== "https:" && url.protocol !== "http:") return null; |
| 49 | const host = url.hostname.toLowerCase(); | 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 | // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} | 56 | // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} |
| 52 | if (isHostOf(host, "github.com", "github")) { | 57 | if (isHostOf(host, "github.com", "github")) { |
| 53 | const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); | 58 | const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); |
| ... | @@ -96,6 +101,8 @@ export function changeRequestUrlFor( | ... | @@ -96,6 +101,8 @@ export function changeRequestUrlFor( |
| 96 | switch (kind) { | 101 | switch (kind) { |
| 97 | case "github": | 102 | case "github": |
| 98 | return `https://${host}/${repository}/pull/${number}`; | 103 | return `https://${host}/${repository}/pull/${number}`; |
| 104 | case "gitcafe": | ||
| 105 | return `https://${host}/${repository}/pulls/${number}`; | ||
| 99 | case "forgejo": { | 106 | case "forgejo": { |
| 100 | try { | 107 | try { |
| 101 | const remote = new URL(remoteUrl ?? ""); | 108 | const remote = new URL(remoteUrl ?? ""); |
packages/shared/src/sourceControl.ts+30-1| ... | @@ -5,7 +5,14 @@ import type { | ... | @@ -5,7 +5,14 @@ import type { |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | ||
| 7 | export interface ChangeRequestPresentation { | 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 | readonly providerName: string; | 16 | readonly providerName: string; |
| 10 | readonly shortName: string; | 17 | readonly shortName: string; |
| 11 | readonly longName: string; | 18 | readonly longName: string; |
| ... | @@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ... | @@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { |
| 79 | urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", | 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 | const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | 100 | const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { |
| 83 | icon: "change-request", | 101 | icon: "change-request", |
| 84 | providerName: "source control", | 102 | providerName: "source control", |
| ... | @@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation( | ... | @@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation( |
| 104 | return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; | 122 | return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; |
| 105 | case "bitbucket": | 123 | case "bitbucket": |
| 106 | return BITBUCKET_CHANGE_REQUEST_PRESENTATION; | 124 | return BITBUCKET_CHANGE_REQUEST_PRESENTATION; |
| 125 | case "gitcafe": | ||
| 126 | return GITCAFE_CHANGE_REQUEST_PRESENTATION; | ||
| 107 | case "unknown": | 127 | case "unknown": |
| 108 | return GENERIC_CHANGE_REQUEST_PRESENTATION; | 128 | return GENERIC_CHANGE_REQUEST_PRESENTATION; |
| 109 | } | 129 | } |
| ... | @@ -205,6 +225,11 @@ function isAzureDevOpsHost(host: string): boolean { | ... | @@ -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 | function isBitbucketHost(host: string): boolean { | 233 | function isBitbucketHost(host: string): boolean { |
| 209 | return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); | 234 | return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); |
| 210 | } | 235 | } |
| ... | @@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl( | ... | @@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl( |
| 218 | } | 243 | } |
| 219 | const hostname = parseHostName(host); | 244 | const hostname = parseHostName(host); |
| 220 | 245 | ||
| 246 | if (isGitCafeHost(hostname)) { | ||
| 247 | return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; | ||
| 248 | } | ||
| 249 | |||
| 221 | if ( | 250 | if ( |
| 222 | hostname === "codeberg.org" || | 251 | hostname === "codeberg.org" || |
| 223 | hasDnsLabel(hostname, "forgejo") || | 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,6 +660,9 @@ importers: |
| 660 | '@t3tools/source-control-forgejo': | 660 | '@t3tools/source-control-forgejo': |
| 661 | specifier: workspace:* | 661 | specifier: workspace:* |
| 662 | version: link:../../packages/source-control-forgejo | 662 | version: link:../../packages/source-control-forgejo |
| 663 | '@t3tools/source-control-gitcafe': | ||
| 664 | specifier: workspace:* | ||
| 665 | version: link:../../packages/source-control-gitcafe | ||
| 663 | '@t3tools/source-control-github': | 666 | '@t3tools/source-control-github': |
| 664 | specifier: workspace:* | 667 | specifier: workspace:* |
| 665 | version: link:../../packages/source-control-github | 668 | version: link:../../packages/source-control-github |
| ... | @@ -1643,6 +1646,37 @@ importers: | ... | @@ -1643,6 +1646,37 @@ importers: |
| 1643 | specifier: 1.0.0 | 1646 | specifier: 1.0.0 |
| 1644 | 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) | 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 | packages/source-control-github: | 1680 | packages/source-control-github: |
| 1647 | dependencies: | 1681 | dependencies: |
| 1648 | '@t3tools/contracts': | 1682 | '@t3tools/contracts': |
scripts/release-smoke.ts+1| ... | @@ -41,6 +41,7 @@ const workspaceFiles = [ | ... | @@ -41,6 +41,7 @@ const workspaceFiles = [ |
| 41 | "packages/source-control-bitbucket/package.json", | 41 | "packages/source-control-bitbucket/package.json", |
| 42 | "packages/source-control-core/package.json", | 42 | "packages/source-control-core/package.json", |
| 43 | "packages/source-control-forgejo/package.json", | 43 | "packages/source-control-forgejo/package.json", |
| 44 | "packages/source-control-gitcafe/package.json", | ||
| 44 | "packages/source-control-github/package.json", | 45 | "packages/source-control-github/package.json", |
| 45 | "packages/source-control-gitlab/package.json", | 46 | "packages/source-control-gitlab/package.json", |
| 46 | "packages/source-control-testing/package.json", | 47 | "packages/source-control-testing/package.json", |