| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Schema from "effect/Schema"; |
| 5 | import * as Result from "effect/Result"; |
| 6 | import { SourceControlProviderError } from "@t3tools/contracts"; |
| 7 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 8 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| 9 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 10 | import { |
| 11 | providerAuth, |
| 12 | probeSourceControlProvider, |
| 13 | type SourceControlCliDiscoverySpec, |
| 14 | type SourceControlManagedCliDiscoverySpec, |
| 15 | } from "@t3tools/source-control-core/server/discovery"; |
| 16 | import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts"; |
| 17 | |
| 18 | const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); |
| 19 | |
| 20 | export const discovery = { |
| 21 | type: "cli", |
| 22 | kind: SourceControlProviderKind.make("forgejo"), |
| 23 | label: "Forgejo / Gitea", |
| 24 | executable: "tea", |
| 25 | versionArgs: ["--version"], |
| 26 | authArgs: ["login", "status", "--output", "json"], |
| 27 | remoteRefinementArgs: ["login", "list", "--output", "json"], |
| 28 | parseAuth: (input) => { |
| 29 | const logins = ForgejoCli.parseForgejoLogins(input.stdout); |
| 30 | const login = logins.find((entry) => entry.default === "true") ?? logins[0]; |
| 31 | return login |
| 32 | ? providerAuth({ |
| 33 | status: login.valid === "true" ? "authenticated" : "unauthenticated", |
| 34 | account: login.user, |
| 35 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, |
| 36 | }) |
| 37 | : providerAuth({ |
| 38 | status: "unauthenticated", |
| 39 | detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.", |
| 40 | }); |
| 41 | }, |
| 42 | refineUnknownRemote: (input) => { |
| 43 | const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); |
| 44 | const login = |
| 45 | remote && |
| 46 | ForgejoCli.matchForgejoLogin( |
| 47 | ForgejoCli.parseForgejoLogins(input.auth.stdout), |
| 48 | remote, |
| 49 | input.context.requestedHost, |
| 50 | ); |
| 51 | return login |
| 52 | ? { |
| 53 | kind: SourceControlProviderKind.make("forgejo"), |
| 54 | name: "Forgejo / Gitea", |
| 55 | baseUrl: login.url, |
| 56 | } |
| 57 | : null; |
| 58 | }, |
| 59 | installHint: |
| 60 | "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.", |
| 61 | } satisfies SourceControlCliDiscoverySpec; |
| 62 | |
| 63 | export const makeDiscovery = Effect.gen(function* () { |
| 64 | const cli = yield* ForgejoCli.ForgejoCli; |
| 65 | const { process } = yield* SourceControlHost.SourceControlHost; |
| 66 | const listLogins = cli.listLogins; |
| 67 | if (!listLogins) return discovery; |
| 68 | return { |
| 69 | type: "managed-cli", |
| 70 | kind: SourceControlProviderKind.make("forgejo"), |
| 71 | label: discovery.label, |
| 72 | installHint: discovery.installHint, |
| 73 | probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) { |
| 74 | const remoteUrl = yield* process |
| 75 | .run({ |
| 76 | operation: "source-control.discovery.remote", |
| 77 | command: "git", |
| 78 | args: ["remote", "get-url", "origin"], |
| 79 | cwd, |
| 80 | allowNonZeroExit: true, |
| 81 | timeoutMs: 5_000, |
| 82 | maxOutputBytes: 8_000, |
| 83 | }) |
| 84 | .pipe( |
| 85 | Effect.map((result) => result.stdout.trim()), |
| 86 | Effect.orElseSucceed(() => ""), |
| 87 | ); |
| 88 | const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl })); |
| 89 | const logins = Result.isSuccess(credentials) ? credentials.success : []; |
| 90 | const remote = ForgejoCli.parseForgejoRemote(remoteUrl); |
| 91 | const login = |
| 92 | (remote && ForgejoCli.matchForgejoLogin(logins, remote)) || |
| 93 | logins.find((entry) => entry.default === "true") || |
| 94 | logins[0]; |
| 95 | const fj = yield* probeSourceControlProvider({ |
| 96 | cwd, |
| 97 | process, |
| 98 | spec: { |
| 99 | ...discovery, |
| 100 | executable: "fj", |
| 101 | versionArgs: ["version"], |
| 102 | authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"], |
| 103 | parseAuth: (result) => |
| 104 | Result.isFailure(credentials) |
| 105 | ? providerAuth({ |
| 106 | status: "unknown", |
| 107 | detail: "Could not read fj authentication storage. Authenticate again with fj.", |
| 108 | }) |
| 109 | : login && result.exitCode === 0 |
| 110 | ? providerAuth({ |
| 111 | status: "authenticated", |
| 112 | account: login.user, |
| 113 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, |
| 114 | }) |
| 115 | : providerAuth({ |
| 116 | status: "unauthenticated", |
| 117 | detail: |
| 118 | "Authenticate this server with `fj --host <server-url> auth add-token`.", |
| 119 | }), |
| 120 | }, |
| 121 | }); |
| 122 | // A configured fj account owns its requests, including authentication errors. |
| 123 | if (fj.status === "available" && (login || Result.isFailure(credentials))) { |
| 124 | if (login && fj.auth.status === "authenticated" && cli.getAccount) { |
| 125 | const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result); |
| 126 | return { |
| 127 | ...fj, |
| 128 | auth: Result.isSuccess(account) |
| 129 | ? providerAuth({ |
| 130 | status: "authenticated", |
| 131 | account: account.success, |
| 132 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, |
| 133 | }) |
| 134 | : providerAuth({ |
| 135 | status: "unknown", |
| 136 | detail: account.failure.detail, |
| 137 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, |
| 138 | }), |
| 139 | }; |
| 140 | } |
| 141 | return fj; |
| 142 | } |
| 143 | const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery }); |
| 144 | return tea.status === "available" || fj.status === "missing" ? tea : fj; |
| 145 | }), |
| 146 | refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")( |
| 147 | function* (input: { |
| 148 | readonly cwd: string; |
| 149 | readonly context: SourceControlProvider.SourceControlProviderContext; |
| 150 | }) { |
| 151 | const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); |
| 152 | if (!remote) return null; |
| 153 | for (const command of ["fj", "tea"] as const) { |
| 154 | const logins = yield* listLogins({ |
| 155 | cwd: input.cwd, |
| 156 | command, |
| 157 | remoteUrl: input.context.remoteUrl, |
| 158 | }).pipe(Effect.orElseSucceed(() => [])); |
| 159 | const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost); |
| 160 | if (login) |
| 161 | return { |
| 162 | kind: SourceControlProviderKind.make("forgejo"), |
| 163 | name: discovery.label, |
| 164 | baseUrl: login.url, |
| 165 | }; |
| 166 | } |
| 167 | return null; |
| 168 | }, |
| 169 | ), |
| 170 | } satisfies SourceControlManagedCliDiscoverySpec; |
| 171 | }); |
| 172 | |
| 173 | /** HTTP paths can include an installation mount; Forgejo's API always names owner/repo. */ |
| 174 | export function repositoryNameFromRemoteUrl(url: string): string | null { |
| 175 | const path = SourceControlProvider.repositoryPathFromRemoteUrl(url); |
| 176 | return path === null || !/^https?:\/\//iu.test(url.trim()) |
| 177 | ? path |
| 178 | : path.split("/").slice(-2).join("/"); |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * A Forgejo remote's URL can't say which server it belongs to (an SSH alias, an installation |
| 183 | * mount), so the identity's browser URL comes from the login that serves it. |
| 184 | */ |
| 185 | const refineRepositoryIdentity: NonNullable< |
| 186 | SourceControlProvider.SourceControlProvider["Service"]["refineRepositoryIdentity"] |
| 187 | > = Effect.fn("ForgejoSourceControlProvider.refineRepositoryIdentity")(function* ({ |
| 188 | identity, |
| 189 | resolveContext, |
| 190 | }) { |
| 191 | const remote = ForgejoCli.parseForgejoRemote(identity.locator.remoteUrl); |
| 192 | if ( |
| 193 | !remote || |
| 194 | !identity.rootPath || |
| 195 | (identity.provider !== undefined && |
| 196 | identity.provider !== "unknown" && |
| 197 | identity.provider !== "forgejo") |
| 198 | ) |
| 199 | return identity; |
| 200 | const context = yield* resolveContext({ |
| 201 | cwd: identity.rootPath, |
| 202 | context: { |
| 203 | provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, |
| 204 | remoteName: identity.locator.remoteName, |
| 205 | remoteUrl: identity.locator.remoteUrl, |
| 206 | }, |
| 207 | }); |
| 208 | if (context?.provider.kind !== "forgejo") return identity; |
| 209 | const baseUrl = context.provider.baseUrl.replace(/\/+$/, ""); |
| 210 | const basePath = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, ""); |
| 211 | const path = |
| 212 | !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`) |
| 213 | ? remote.path.slice(basePath.length + 1) |
| 214 | : remote.path; |
| 215 | return { |
| 216 | ...identity, |
| 217 | provider: SourceControlProviderKind.make("forgejo"), |
| 218 | webUrl: `${baseUrl}/${path}`, |
| 219 | }; |
| 220 | }); |
| 221 | |
| 222 | const RepositorySchema = Schema.Struct({ |
| 223 | full_name: Schema.String, |
| 224 | clone_url: Schema.String, |
| 225 | ssh_url: Schema.String, |
| 226 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), |
| 227 | }); |
| 228 | const cloneUrls = (raw: typeof RepositorySchema.Type) => ({ |
| 229 | nameWithOwner: raw.full_name, |
| 230 | url: raw.clone_url, |
| 231 | sshUrl: raw.ssh_url, |
| 232 | }); |
| 233 | const repositoryPath = (repository: string) => |
| 234 | `repos/${repository.split("/").map(encodeURIComponent).join("/")}`; |
| 235 | |
| 236 | export const make = Effect.gen(function* () { |
| 237 | const cli = yield* ForgejoCli.ForgejoCli; |
| 238 | const fs = yield* FileSystem.FileSystem; |
| 239 | const { process } = yield* SourceControlHost.SourceControlHost; |
| 240 | const request = <S extends Schema.Codec<unknown, unknown, never, never>>( |
| 241 | input: ForgejoCli.ForgejoApiInput, |
| 242 | schema: S, |
| 243 | ) => |
| 244 | cli.api(input).pipe( |
| 245 | Effect.flatMap((result) => |
| 246 | Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe( |
| 247 | Effect.mapError( |
| 248 | (cause) => |
| 249 | new ForgejoCli.ForgejoCliError({ |
| 250 | command: "tea", |
| 251 | cwd: input.cwd, |
| 252 | detail: "Forgejo API returned an invalid response.", |
| 253 | reason: "invalid-response", |
| 254 | cause, |
| 255 | }), |
| 256 | ), |
| 257 | ), |
| 258 | ), |
| 259 | ); |
| 260 | const mapError = (operation: string, cwd: string) => |
| 261 | Effect.mapError( |
| 262 | (cause: unknown) => |
| 263 | new SourceControlProviderError({ |
| 264 | provider: SourceControlProviderKind.make("forgejo"), |
| 265 | operation, |
| 266 | cwd, |
| 267 | ...(isForgejoCliError(cause) ? { command: cause.command } : {}), |
| 268 | detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.", |
| 269 | cause, |
| 270 | }), |
| 271 | ); |
| 272 | const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* ( |
| 273 | input: Parameters< |
| 274 | SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"] |
| 275 | >[0], |
| 276 | ) { |
| 277 | const repo = yield* cli.resolveRepository(input); |
| 278 | const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1]; |
| 279 | if (!number) |
| 280 | return yield* new ForgejoCli.ForgejoCliError({ |
| 281 | command: "tea", |
| 282 | cwd: input.cwd, |
| 283 | detail: "Specify a pull request number or Forgejo pull request URL.", |
| 284 | }); |
| 285 | return yield* request( |
| 286 | { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` }, |
| 287 | ForgejoPullRequestSchema, |
| 288 | ); |
| 289 | }); |
| 290 | return SourceControlProvider.SourceControlProvider.of({ |
| 291 | kind: SourceControlProviderKind.make("forgejo"), |
| 292 | repositoryNameFromRemoteUrl, |
| 293 | refineRepositoryIdentity, |
| 294 | listChangeRequests: (input) => |
| 295 | Effect.gen(function* () { |
| 296 | const repo = yield* cli.resolveRepository(input); |
| 297 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 298 | const branch = SourceControlProvider.sourceBranch(input); |
| 299 | const results: ReturnType<typeof toForgejoChangeRequest>[] = []; |
| 300 | const limit = input.limit ?? 20; |
| 301 | for (let page = 1; results.length < limit; page++) { |
| 302 | const items = yield* request( |
| 303 | { |
| 304 | ...input, |
| 305 | path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&head=${encodeURIComponent(branch)}&sort=recentupdate&limit=50&page=${page}`, |
| 306 | }, |
| 307 | Schema.Array(ForgejoPullRequestSchema), |
| 308 | ); |
| 309 | for (const item of items) { |
| 310 | if ( |
| 311 | item.head.ref !== branch || |
| 312 | (source?.repository && item.head.repo?.full_name !== source.repository) || |
| 313 | (source?.owner && item.head.repo?.owner.login !== source.owner) |
| 314 | ) |
| 315 | continue; |
| 316 | const normalized = toForgejoChangeRequest(item); |
| 317 | if (input.state === "all" || normalized.state === input.state) results.push(normalized); |
| 318 | } |
| 319 | if (items.length === 0) break; |
| 320 | } |
| 321 | return results.slice(0, limit); |
| 322 | }).pipe(mapError("listChangeRequests", input.cwd)), |
| 323 | getChangeRequest: (input) => |
| 324 | getPull(input).pipe( |
| 325 | Effect.map(toForgejoChangeRequest), |
| 326 | mapError("getChangeRequest", input.cwd), |
| 327 | ), |
| 328 | createChangeRequest: (input) => |
| 329 | Effect.gen(function* () { |
| 330 | const repo = yield* cli.resolveRepository(input); |
| 331 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 332 | const owner = source?.owner ?? source?.repository?.split("/")[0]; |
| 333 | const head = SourceControlProvider.sourceBranch(input); |
| 334 | yield* cli.api({ |
| 335 | ...input, |
| 336 | path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`, |
| 337 | method: "POST", |
| 338 | body: { |
| 339 | base: input.target?.refName ?? input.baseRefName, |
| 340 | head: owner ? `${owner}:${head}` : head, |
| 341 | title: input.title, |
| 342 | body: yield* fs.readFileString(input.bodyFile), |
| 343 | }, |
| 344 | }); |
| 345 | }).pipe(mapError("createChangeRequest", input.cwd)), |
| 346 | getRepositoryCloneUrls: (input) => |
| 347 | Effect.gen(function* () { |
| 348 | const repo = yield* cli.resolveRepository(input); |
| 349 | return cloneUrls( |
| 350 | yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema), |
| 351 | ); |
| 352 | }).pipe(mapError("getRepositoryCloneUrls", input.cwd)), |
| 353 | createRepository: (input) => |
| 354 | Effect.gen(function* () { |
| 355 | const repo = yield* cli.resolveRepository(input); |
| 356 | const user = yield* request( |
| 357 | { ...input, path: "user" }, |
| 358 | Schema.Struct({ login: Schema.String }), |
| 359 | ); |
| 360 | const [owner, name] = repo.repository.split("/"); |
| 361 | return cloneUrls( |
| 362 | yield* request( |
| 363 | { |
| 364 | ...input, |
| 365 | path: |
| 366 | owner === user.login |
| 367 | ? "user/repos" |
| 368 | : `orgs/${encodeURIComponent(owner ?? "")}/repos`, |
| 369 | method: "POST", |
| 370 | body: { name, private: input.visibility === "private", auto_init: false }, |
| 371 | }, |
| 372 | RepositorySchema, |
| 373 | ), |
| 374 | ); |
| 375 | }).pipe(mapError("createRepository", input.cwd)), |
| 376 | getDefaultBranch: (input) => |
| 377 | Effect.gen(function* () { |
| 378 | const repo = yield* cli.resolveRepository(input); |
| 379 | return ( |
| 380 | (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema)) |
| 381 | .default_branch ?? null |
| 382 | ); |
| 383 | }).pipe(mapError("getDefaultBranch", input.cwd)), |
| 384 | checkoutChangeRequest: (input) => |
| 385 | Effect.gen(function* () { |
| 386 | const repo = yield* cli.resolveRepository(input); |
| 387 | const pull = yield* getPull(input); |
| 388 | if (repo.command === "fj") { |
| 389 | // fj checkout cannot target a repository outside the local remotes. |
| 390 | const urls = yield* request( |
| 391 | { ...input, path: repositoryPath(repo.repository) }, |
| 392 | RepositorySchema, |
| 393 | ); |
| 394 | const remote = input.context?.remoteUrl; |
| 395 | const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh; |
| 396 | yield* process.run({ |
| 397 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", |
| 398 | command: "git", |
| 399 | cwd: input.cwd, |
| 400 | args: [ |
| 401 | "fetch", |
| 402 | "--", |
| 403 | useSsh ? urls.ssh_url : urls.clone_url, |
| 404 | `refs/pull/${pull.number}/head`, |
| 405 | ], |
| 406 | }); |
| 407 | const branch = `pulls/${pull.number}`; |
| 408 | const existing = yield* process.run({ |
| 409 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", |
| 410 | command: "git", |
| 411 | cwd: input.cwd, |
| 412 | args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], |
| 413 | allowNonZeroExit: true, |
| 414 | }); |
| 415 | yield* process.run({ |
| 416 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", |
| 417 | command: "git", |
| 418 | cwd: input.cwd, |
| 419 | args: |
| 420 | existing.exitCode === 0 |
| 421 | ? ["checkout", branch] |
| 422 | : ["checkout", "-b", branch, "FETCH_HEAD"], |
| 423 | }); |
| 424 | } else |
| 425 | yield* cli.execute({ |
| 426 | cwd: input.cwd, |
| 427 | args: [ |
| 428 | "pulls", |
| 429 | "checkout", |
| 430 | "--login", |
| 431 | repo.login, |
| 432 | "--repo", |
| 433 | repo.repository, |
| 434 | "--branch", |
| 435 | String(pull.number), |
| 436 | ], |
| 437 | }); |
| 438 | if (input.force) { |
| 439 | // tea leaves an existing PR branch at its old tip. Keep dirty files safe |
| 440 | // while bringing the selected branch to the PR revision we fetched. |
| 441 | yield* process.run({ |
| 442 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", |
| 443 | command: "git", |
| 444 | cwd: input.cwd, |
| 445 | args: ["reset", "--keep", pull.head.sha], |
| 446 | }); |
| 447 | } |
| 448 | }).pipe(mapError("checkoutChangeRequest", input.cwd)), |
| 449 | }); |
| 450 | }); |