diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts index 9ea0ced700f4e..b42d1f9e5fa15 100644 --- a/packages/client-runtime/src/changeRequestLinks.test.ts +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -115,20 +115,35 @@ describe("resolveChangeRequestLinkTarget", () => { const loaded = () => true; const url = "https://github.com/acme/app/pull/7"; - it("opens a mirror's link in its own environment under a project on the link's host", () => { + it.each([ + ["https://github.com/acme/app/pull/7", "github.com"], + ["https://git.cafe/acme/app/pull/7", "git.cafe"], + ["https://git.cafe/acme/app/pulls/7", "git.cafe"], + ])("opens %s in its own environment under a host-capable project", (url, host) => { expect( resolveChangeRequestLinkTarget({ url, - projects, + projects: host === "git.cafe" ? [sibling, elsewhere] : projects, configLoaded: loaded, environmentId: local, projectId: mirrored.id, lendHostProject: true, }), - ).toEqual({ project: sibling, host: "github.com", repository: "acme/app", number: 7 }); + ).toEqual({ project: sibling, host, repository: "acme/app", number: 7 }); }); it("only borrows a host project for views that can show any repository on the host", () => { + for (const host of ["staging.git.cafe", "git.cafe.evil.test"]) { + expect( + resolveChangeRequestLinkTarget({ + url: `https://${host}/acme/app/pull/7`, + projects: [sibling], + configLoaded: loaded, + environmentId: local, + lendHostProject: true, + }), + ).toBeNull(); + } expect( resolveChangeRequestLinkTarget({ url, diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts index 76f60a5953f2b..1bc305e377450 100644 --- a/packages/client-runtime/src/changeRequestLinks.ts +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -39,7 +39,10 @@ export function findProjectOnChangeRequestHost - fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }), - ), - ); - const origin = `https://${host}`; const method = input.method ?? "GET"; + const readOnly = + method === "GET" || + (method === "POST" && + /^\/repos\/[^/]+\/[^/]+\/pulls\/[1-9]\d*\/diff-files$/u.test(input.path)); + const credential = yield* credentials.get(host).pipe( + Effect.catchIf( + (error) => + readOnly && + (error._tag === "GitCafeCliMissingError" || error._tag === "GitCafeNotSignedInError"), + () => Effect.succeed(null), + ), + Effect.mapError((cause) => + fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }), + ), + ); + const origin = `https://${host}`; const send = (url: string) => { const base = HttpClientRequest.make(method)(url).pipe( - HttpClientRequest.bearerToken(Redacted.value(credential.token)), HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }), ); + const authenticated = + credential === null + ? base + : base.pipe(HttpClientRequest.bearerToken(Redacted.value(credential.token))); return Effect.gen(function* () { const response = yield* httpClient .execute( input.body === undefined - ? base - : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), + ? authenticated + : authenticated.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), ) .pipe( // Client spans expose query strings containing branch names. @@ -191,7 +202,7 @@ export const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "http.response.status_code": status }); if (status >= 200 && status < 300) return text; // The source may hold a newer token than the one that was refused. - if (status === 401) yield* credentials.invalidate(host); + if (status === 401 && credential !== null) yield* credentials.invalidate(host); const problem = Option.getOrUndefined(decodeProblem(text)); return yield* fail({ status, diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index cb2bf219c0b5e..92fc59af6fe34 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -460,6 +460,7 @@ describe("GitCafePullRequestProvider", () => { ...target, host: "staging.git.cafe", }; + assert.isTrue(provider.readsPublicHostWithoutCheckout); const preview = yield* provider.getChangeRequestPreview!(input); assert.deepInclude(preview, { number: 7, @@ -496,6 +497,56 @@ describe("GitCafePullRequestProvider", () => { }).pipe(Effect.provide(server.layer)); }); + it.effect("reads public PRs without credentials while keeping writes authenticated", () => + Effect.gen(function* () { + for (const missing of [ + new GitCafeCredentials.GitCafeCliMissingError({ host: "git.cafe" }), + new GitCafeCredentials.GitCafeNotSignedInError({ host: "git.cafe" }), + ]) { + let requests = 0; + const client = HttpClient.make((request) => { + requests += 1; + assert.isUndefined(request.headers.authorization); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("public PR", { status: request.url.endsWith("/private") ? 401 : 200 }), + ), + ); + }); + const api = yield* GitCafeApi.make.pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provide( + Layer.mock(GitCafeCredentials.GitCafeCredentials)({ + get: () => Effect.fail(missing), + }), + ), + ); + const input = { + host: "git.cafe", + operation: "getChangeRequest", + path: "/repos/owner/repo/pulls/7", + }; + assert.strictEqual(yield* api.request(input), "public PR"); + assert.strictEqual( + yield* api.request({ ...input, method: "POST", path: `${input.path}/diff-files` }), + "public PR", + ); + for (const method of ["POST", "PUT", "PATCH", "DELETE"] as const) { + const error = yield* api.request({ ...input, method }).pipe(Effect.flip); + assert.strictEqual( + error.reason, + missing._tag === "GitCafeCliMissingError" ? "missing-tool" : "unauthenticated", + ); + } + assert.strictEqual(requests, 2); + const privateRead = yield* api.request({ ...input, path: "/private" }).pipe(Effect.flip); + assert.strictEqual(privateRead.reason, "unauthenticated"); + assert.strictEqual(requests, 3); + } + }), + ); + it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () => Effect.gen(function* () { for (const [status, reason] of [ diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 314fc30902751..a40b0b383f629 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -794,6 +794,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("gitcafe"), capabilities: CAPABILITIES, + readsPublicHostWithoutCheckout: true, getViewer: (input) => read( "getViewer", diff --git a/packages/source-control-gitcafe/src/shared/definition.ts b/packages/source-control-gitcafe/src/shared/definition.ts index 2abc258fbf955..f6c71c4a9075a 100644 --- a/packages/source-control-gitcafe/src/shared/definition.ts +++ b/packages/source-control-gitcafe/src/shared/definition.ts @@ -101,6 +101,7 @@ export const definition = defineSourceControl({ isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => + link.host === "git.cafe" || isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, });