diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index 4ae39f3ae9626..844321b4d043b 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -350,26 +350,47 @@ export const make = Effect.gen(function* () { readonly body?: string; }) { const base = new URL(`${input.baseUrl}/api/v1/`); - const url = new URL(input.path, base); - if ( - url.origin !== base.origin || - !url.pathname.startsWith(base.pathname) || - url.username || - url.password - ) + const apiUrl = (path: string, from: string) => { + const url = URL.parse(path, from); + return url && + url.origin === base.origin && + url.pathname.startsWith(base.pathname) && + !url.username && + !url.password + ? url + : undefined; + }; + const requested = apiUrl(input.path, base.href); + if (!requested) return yield* new ForgejoCliError({ command: "fj", cwd: input.cwd, detail: "Invalid Forgejo API path.", }); - let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe( - HttpClientRequest.setHeader("Authorization", `token ${input.token}`), - ); - if (input.body !== undefined) - request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json")); - const response = yield* httpClient - .execute(request) - .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + let url = requested; + const send = (target: URL) => { + let request = HttpClientRequest.make(input.method ?? "GET")(target.toString()).pipe( + HttpClientRequest.setHeader("Authorization", `token ${input.token}`), + ); + if (input.body !== undefined) + request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json")); + return httpClient + .execute(request) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + }; + let response = yield* send(url); + // Forgejo redirects a renamed repository's API paths before running the handler, + // so the same method and body are re-sent. The token never leaves the API base. + for (let hops = 0; hops < 5; hops++) { + const location = response.headers.location; + const next = + location && [301, 302, 307, 308].includes(response.status) + ? apiUrl(location, url.href) + : undefined; + if (!next) break; + url = next; + response = yield* send(url); + } const status = response.status; const body = status === 204 || status === 205 diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index ef0135272024b..e68fbb50ec476 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -1786,6 +1786,62 @@ it.effect("loads later fj review pages when the server caps pages below the requ ); }); +it.effect("re-sends an fj write with its method and body to a renamed repository's path", () => { + const requests: string[] = []; + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + yield* cli.api({ + cwd: "/repo", + repository: "https://forgejo.local/maria/old-name", + path: "repos/maria/old-name/issues/42/comments", + method: "POST", + body: { body: "only once" }, + }); + assert.deepStrictEqual(requests, [ + "https://forgejo.local/api/v1/repos/maria/old-name/issues/42/comments", + "https://forgejo.local/api/v1/repos/maria/new-name/issues/42/comments", + ]); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { "forgejo.local": { type: "Application", token: "test-token" } }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + assert.strictEqual(request.method, "POST"); + assert.strictEqual(request.headers.authorization, "token test-token"); + assert.deepStrictEqual(requestJson(request), { body: "only once" }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + requests.length === 1 + ? new Response(null, { + status: 301, + headers: { location: "/api/v1/repos/maria/new-name/issues/42/comments" }, + }) + : new Response("{}", { status: 201 }), + ), + ); + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { run: () => Effect.succeed(processOutput("")) }, + }), + ), + ); +}); + it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () => Effect.gen(function* () { for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) {