From 7941d8e49ecd2448779976132ffea067de0d3149 Mon Sep 17 00:00:00 2001 From: dr-baker Date: Tue, 10 Nov 2026 22:26:36 -0800 Subject: [PATCH 1/3] fix(clients): open GitCafe PR links without a GitCafe checkout --- .../pullRequest/PullRequestService.test.ts | 47 +++++++++++++++++++ .../src/changeRequestLinks.test.ts | 27 +++++++++++ .../client-runtime/src/changeRequestLinks.ts | 5 +- .../src/server/GitCafePullRequestProvider.ts | 1 + .../src/shared/definition.ts | 1 + 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index e559bc84dd978..f854baf105d45 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -11,6 +11,9 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, @@ -1982,6 +1985,50 @@ it.effect("routes a hosted reference to another repository through a project on }), ); +it.effect("reads a GitCafe link from a GitHub project without a GitCafe checkout", () => + Effect.gen(function* () { + const instance = yield* GitCafeDriver.driver.make.pipe( + Effect.provide( + Layer.mergeAll( + TestSourceControlHost.layer(), + SourceControlRateLimit.layer, + NodeServices.layer, + NodeCrypto.layer, + FetchHttpClient.layer, + ), + ), + ); + if (instance.pullRequests === null) return yield* Effect.die("Missing GitCafe PR driver"); + const seen: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "web", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], + providers: [ + { + ...instance.pullRequests, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`${input.host}/${input.repository}#${input.number}`); + return changeRequest(input.number, "2026-07-02T00:00:00Z"); + }), + }, + ], + }); + const summary = yield* service.summary( + { + projectId: "web" as ProjectId, + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 33106, + }, + { recoverTransientFailure: false }, + ); + assert.strictEqual(summary.number, 33106); + assert.deepStrictEqual(seen, ["git.cafe/pingdotgg/t3code#33106"]); + }), +); + it.effect("routes Azure reads and writes through the requested organization's checkout", () => Effect.gen(function* () { const seen: string[] = []; diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts index 9ea0ced700f4e..019d599f9b3a8 100644 --- a/packages/client-runtime/src/changeRequestLinks.test.ts +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -126,9 +126,36 @@ describe("resolveChangeRequestLinkTarget", () => { lendHostProject: true, }), ).toEqual({ project: sibling, host: "github.com", repository: "acme/app", number: 7 }); + for (const route of ["pull", "pulls"]) { + expect( + resolveChangeRequestLinkTarget({ + url: `https://git.cafe/pingdotgg/t3code/${route}/33106`, + projects: [sibling, elsewhere], + configLoaded: loaded, + environmentId: local, + lendHostProject: true, + }), + ).toEqual({ + project: sibling, + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 33106, + }); + } }); 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 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, }); From 1f3b617b52c2b866b94c837aad2bd7f0f71a24a8 Mon Sep 17 00:00:00 2001 From: dr-baker Date: Tue, 10 Nov 2026 22:48:49 -0800 Subject: [PATCH 2/3] fix(gitcafe): read public pull requests without credentials Allow anonymous GETs and the read-only diff-files POST when the CLI is absent or signed out. Keep mutations authenticated and preserve errors for refused credentials. Cover public reads, diff retrieval, private-read failures, and authenticated writes in the existing provider suite. --- .../src/server/GitCafeApi.ts | 35 ++++++++----- .../server/GitCafePullRequestProvider.test.ts | 50 +++++++++++++++++++ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts index 45e70f8b0ffde..b9f8da647ca7d 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeApi.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -138,26 +138,37 @@ export const make = Effect.gen(function* () { "gitcafe.operation": input.operation, "http.request.method": input.method ?? "GET", }); - const credential = yield* credentials - .get(host) - .pipe( - Effect.mapError((cause) => - 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..7982cca0882de 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -496,6 +496,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 [ From 9f68558e1526b5509d6cb0a6f33c24c56ff58ed7 Mon Sep 17 00:00:00 2001 From: dr-baker Date: Tue, 10 Nov 2026 23:43:20 -0800 Subject: [PATCH 3/3] test(gitcafe): consolidate PR link regression coverage --- .../pullRequest/PullRequestService.test.ts | 47 ------------------- .../src/changeRequestLinks.test.ts | 26 +++------- .../server/GitCafePullRequestProvider.test.ts | 1 + 3 files changed, 8 insertions(+), 66 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index f854baf105d45..e559bc84dd978 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -11,9 +11,6 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; -import * as FetchHttpClient from "effect/http/FetchHttpClient"; -import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; -import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, @@ -1985,50 +1982,6 @@ it.effect("routes a hosted reference to another repository through a project on }), ); -it.effect("reads a GitCafe link from a GitHub project without a GitCafe checkout", () => - Effect.gen(function* () { - const instance = yield* GitCafeDriver.driver.make.pipe( - Effect.provide( - Layer.mergeAll( - TestSourceControlHost.layer(), - SourceControlRateLimit.layer, - NodeServices.layer, - NodeCrypto.layer, - FetchHttpClient.layer, - ), - ), - ); - if (instance.pullRequests === null) return yield* Effect.die("Missing GitCafe PR driver"); - const seen: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "web", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [ - { - ...instance.pullRequests, - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`${input.host}/${input.repository}#${input.number}`); - return changeRequest(input.number, "2026-07-02T00:00:00Z"); - }), - }, - ], - }); - const summary = yield* service.summary( - { - projectId: "web" as ProjectId, - host: "git.cafe", - repository: "pingdotgg/t3code", - number: 33106, - }, - { recoverTransientFailure: false }, - ); - assert.strictEqual(summary.number, 33106); - assert.deepStrictEqual(seen, ["git.cafe/pingdotgg/t3code#33106"]); - }), -); - it.effect("routes Azure reads and writes through the requested organization's checkout", () => Effect.gen(function* () { const seen: string[] = []; diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts index 019d599f9b3a8..b42d1f9e5fa15 100644 --- a/packages/client-runtime/src/changeRequestLinks.test.ts +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -115,33 +115,21 @@ 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 }); - for (const route of ["pull", "pulls"]) { - expect( - resolveChangeRequestLinkTarget({ - url: `https://git.cafe/pingdotgg/t3code/${route}/33106`, - projects: [sibling, elsewhere], - configLoaded: loaded, - environmentId: local, - lendHostProject: true, - }), - ).toEqual({ - project: sibling, - host: "git.cafe", - repository: "pingdotgg/t3code", - number: 33106, - }); - } + ).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", () => { diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 7982cca0882de..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,