From d3b364131534459b88fafd0a1b559577b6d24ad4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:15:16 -0700 Subject: [PATCH 01/23] [codex] Structure persistence error correlation (#3439) Co-authored-by: codex (cherry picked from commit 57d25c934b74986423896a7dc433595ed09b9c8d) --- apps/server/src/persistence/Errors.ts | 53 +++- .../persistence/Layers/AuthPairingLinks.ts | 91 ++++++- .../src/persistence/Layers/AuthSessions.ts | 76 +++++- .../Layers/ProviderSessionRuntime.ts | 73 +++-- .../RepositoryErrorCorrelation.test.ts | 256 ++++++++++++++++++ 5 files changed, 500 insertions(+), 49 deletions(-) create mode 100644 apps/server/src/persistence/RepositoryErrorCorrelation.test.ts diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 2a3d7aff189..acb097c2c52 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,20 +1,45 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; +function summarizeSchemaIssue(issue: SchemaIssue.Issue): string { + switch (issue._tag) { + case "Filter": + case "Encoding": + case "Pointer": + return `${issue._tag}(${summarizeSchemaIssue(issue.issue)})`; + case "Composite": + case "AnyOf": + return `${issue._tag}(${issue.issues.map(summarizeSchemaIssue).join(",")})`; + default: + return issue._tag; + } +} + // =============================== // Core Persistence Errors // =============================== +export const PersistenceErrorCorrelation = Schema.Union([ + Schema.Struct({ sessionId: Schema.String }), + Schema.Struct({ currentSessionId: Schema.String }), + Schema.Struct({ pairingLinkId: Schema.String }), + Schema.Struct({ threadId: Schema.String }), +]); +export type PersistenceErrorCorrelation = typeof PersistenceErrorCorrelation.Type; + export class PersistenceSqlError extends Schema.TaggedErrorClass()( "PersistenceSqlError", { operation: Schema.String, - detail: Schema.String, + detail: Schema.optional(Schema.String), + correlation: Schema.optional(PersistenceErrorCorrelation), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `SQL error in ${this.operation}: ${this.detail}`; + return this.detail === undefined + ? `SQL error in ${this.operation}` + : `SQL error in ${this.operation}: ${this.detail}`; } } @@ -23,9 +48,23 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass - new PersistenceDecodeError({ - operation, - issue: SchemaIssue.makeFormatterDefault()(error.issue), - cause: error, - }); + return (cause: Schema.SchemaError): PersistenceDecodeError => + PersistenceDecodeError.fromSchemaError(operation, cause); } export function toPersistenceDecodeCauseError(operation: string) { @@ -107,4 +142,4 @@ export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | Persis export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; -export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; \ No newline at end of file diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts index 9d2760d1449..6818e360d57 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -2,11 +2,13 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, type AuthPairingLinkRepositoryError, } from "../Errors.ts"; import { @@ -20,11 +22,35 @@ import { RevokeAuthPairingLinkInput, } from "../Services/AuthPairingLinks.ts"; -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +const AuthPairingLinkRawDbRow = Schema.Struct({ + id: Schema.String, + credential: Schema.Unknown, + method: Schema.Unknown, + scopes: Schema.Unknown, + subject: Schema.Unknown, + label: Schema.Unknown, + proofKeyThumbprint: Schema.Unknown, + createdAt: Schema.Unknown, + expiresAt: Schema.Unknown, + consumedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord); + +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthPairingLinkRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } const makeAuthPairingLinkRepository = Effect.gen(function* () { @@ -65,7 +91,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => sql` UPDATE auth_pairing_links @@ -95,7 +121,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { const listActivePairingLinkRows = SqlSchema.findAll({ Request: ListActiveAuthPairingLinksInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ now }) => sql` SELECT @@ -134,7 +160,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { const getPairingLinkRowByCredential = SqlSchema.findOneOption({ Request: GetAuthPairingLinkByCredentialInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential }) => sql` SELECT @@ -160,6 +186,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.create:query", "AuthPairingLinkRepository.create:encodeRequest", + { pairingLinkId: input.id }, ), ), ); @@ -172,6 +199,22 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { "AuthPairingLinkRepository.consumeAvailable:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => @@ -182,6 +225,19 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { "AuthPairingLinkRepository.listActive:decodeRows", ), ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.listActive:decodeRows", + cause, + { pairingLinkId: row.id }, + ), + ), + ), + ), + ), ); const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => @@ -190,6 +246,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.revoke:query", "AuthPairingLinkRepository.revoke:decodeRows", + { pairingLinkId: input.id }, ), ), Effect.map((rows) => rows.length > 0), @@ -203,6 +260,22 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { "AuthPairingLinkRepository.getByCredential:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.getByCredential:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); return { @@ -217,4 +290,4 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { export const AuthPairingLinkRepositoryLive = Layer.effect( AuthPairingLinkRepository, makeAuthPairingLinkRepository, -); +); \ No newline at end of file diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts index ab84e3fa041..f0e85c983d7 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -7,8 +7,9 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, type AuthSessionRepositoryError, } from "../Errors.ts"; import { @@ -40,6 +41,25 @@ const AuthSessionDbRow = Schema.Struct({ revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); +const AuthSessionRawDbRow = Schema.Struct({ + sessionId: Schema.String, + subject: Schema.Unknown, + scopes: Schema.Unknown, + method: Schema.Unknown, + clientLabel: Schema.Unknown, + clientIpAddress: Schema.Unknown, + clientUserAgent: Schema.Unknown, + clientDeviceType: Schema.Unknown, + clientOs: Schema.Unknown, + clientBrowser: Schema.Unknown, + issuedAt: Schema.Unknown, + expiresAt: Schema.Unknown, + lastConnectedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); + function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { return { sessionId: row.sessionId, @@ -61,11 +81,19 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco }; } -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthSessionRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } const makeAuthSessionRepository = Effect.gen(function* () { @@ -110,7 +138,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ sessionId }) => sql` SELECT @@ -135,7 +163,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ now }) => sql` SELECT @@ -203,6 +231,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.create:query", "AuthSessionRepository.create:encodeRequest", + { sessionId: input.sessionId }, ), ), ); @@ -213,12 +242,23 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.getById:query", "AuthSessionRepository.getById:decodeRow", + { sessionId: input.sessionId }, ), ), Effect.flatMap((rowOption) => Option.match(rowOption, { onNone: () => Effect.succeed(Option.none()), - onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), + onSome: (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.getById:decodeRow", + cause, + { sessionId: input.sessionId }, + ), + ), + Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))), + ), }), ), ); @@ -231,7 +271,20 @@ const makeAuthSessionRepository = Effect.gen(function* () { "AuthSessionRepository.listActive:decodeRows", ), ), - Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.listActive:decodeRows", + cause, + { sessionId: row.sessionId }, + ), + ), + Effect.map(toAuthSessionRecord), + ), + ), + ), ); const revoke: AuthSessionRepositoryShape["revoke"] = (input) => @@ -240,6 +293,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows", + { sessionId: input.sessionId }, ), ), Effect.map((rows) => rows.length > 0), @@ -251,6 +305,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows", + { currentSessionId: input.currentSessionId }, ), ), Effect.map((rows) => rows.map((row) => row.sessionId)), @@ -262,6 +317,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest", + { sessionId: input.sessionId }, ), ), ); @@ -279,4 +335,4 @@ const makeAuthSessionRepository = Effect.gen(function* () { export const AuthSessionRepositoryLive = Layer.effect( AuthSessionRepository, makeAuthSessionRepository, -); +); \ No newline at end of file diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 9ee5c82bb53..a3624cd76d5 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -8,8 +8,9 @@ import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; import { - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, type ProviderSessionRuntimeRepositoryError, } from "../Errors.ts"; import { @@ -25,7 +26,19 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( }), ); -const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); +const ProviderSessionRuntimeRawDbRowSchema = Schema.Struct({ + threadId: Schema.String, + providerName: Schema.Unknown, + providerInstanceId: Schema.Unknown, + adapterKey: Schema.Unknown, + runtimeMode: Schema.Unknown, + status: Schema.Unknown, + lastSeenAt: Schema.Unknown, + resumeCursor: Schema.Unknown, + runtimePayload: Schema.Unknown, +}); + +const decodeRuntimeRow = Schema.decodeUnknownEffect(ProviderSessionRuntimeDbRowSchema); const GetRuntimeRequestSchema = Schema.Struct({ threadId: ThreadId, @@ -33,11 +46,19 @@ const GetRuntimeRequestSchema = Schema.Struct({ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): ProviderSessionRuntimeRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } const makeProviderSessionRuntimeRepository = Effect.gen(function* () { @@ -84,7 +105,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { const getRuntimeRowByThreadId = SqlSchema.findOneOption({ Request: GetRuntimeRequestSchema, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: ({ threadId }) => sql` SELECT @@ -104,7 +125,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { const listRuntimeRows = SqlSchema.findAll({ Request: Schema.Void, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: () => sql` SELECT @@ -137,6 +158,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", "ProviderSessionRuntimeRepository.upsert:encodeRequest", + { threadId: runtime.threadId }, ), ), ); @@ -147,16 +169,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.getByThreadId:query", "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + { threadId: input.threadId }, ), ), Effect.flatMap((runtimeRowOption) => Option.match(runtimeRowOption, { onNone: () => Effect.succeed(Option.none()), onSome: (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + cause, + { threadId: input.threadId }, ), ), Effect.map((runtime) => Option.some(runtime)), @@ -174,15 +199,16 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ), ), Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), + Effect.forEach(rows, (row) => + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, ), ), - { concurrency: "unbounded" }, + ), ), ), ); @@ -190,7 +216,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => deleteRuntimeByThreadId(input).pipe( Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), + (cause) => + new PersistenceSqlError({ + operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + correlation: { threadId: input.threadId }, + cause, + }), ), ); @@ -205,4 +236,4 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { export const ProviderSessionRuntimeRepositoryLive = Layer.effect( ProviderSessionRuntimeRepository, makeProviderSessionRuntimeRepository, -); +); \ No newline at end of file diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts new file mode 100644 index 00000000000..ed5afa1efaf --- /dev/null +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -0,0 +1,256 @@ +import { AuthSessionId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as PersistenceErrors from "./Errors.ts"; +import { AuthPairingLinkRepositoryLive } from "./Layers/AuthPairingLinks.ts"; +import { AuthSessionRepositoryLive } from "./Layers/AuthSessions.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "./Layers/ProviderSessionRuntime.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import { AuthPairingLinkRepository } from "./Services/AuthPairingLinks.ts"; +import { AuthSessionRepository } from "./Services/AuthSessions.ts"; +import { ProviderSessionRuntimeRepository } from "./Services/ProviderSessionRuntime.ts"; + +const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); +const expiresAt = DateTime.makeUnsafe("2027-06-20T00:00:00.000Z"); +const now = DateTime.makeUnsafe("2026-06-21T00:00:00.000Z"); +const scopes: ReadonlyArray = ["access:read"]; + +const authSessionLayer = AuthSessionRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const authPairingLinkLayer = AuthPairingLinkRepositoryLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); +const providerSessionRuntimeLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); + +describe("persistence error correlation", () => { + it.effect("correlates auth session SQL and row-decode failures without sensitive fields", () => + Effect.gen(function* () { + const sessions = yield* AuthSessionRepository; + const sql = yield* SqlClient.SqlClient; + const sessionId = AuthSessionId.make("session-correlation"); + const currentSessionId = AuthSessionId.make("current-session-correlation"); + const subject = "session-subject-secret-sentinel"; + + yield* sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }); + yield* sql` + UPDATE auth_sessions + SET scopes = ${"session-scopes-secret-sentinel"} + WHERE session_id = ${sessionId} + `; + + const decodeError = yield* Effect.flip(sessions.listActive({ now })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { sessionId }); + assert.equal( + decodeError.message, + `Decode error in AuthSessionRepository.listActive:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, "session-scopes-secret-sentinel"); + assert.notInclude(decodeError.message, subject); + + yield* sql`DROP TABLE auth_sessions`; + const createError = yield* Effect.flip( + sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { sessionId }); + assert.equal(createError.message, "SQL error in AuthSessionRepository.create:query"); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeOtherError = yield* Effect.flip( + sessions.revokeAllExcept({ currentSessionId, revokedAt: now }), + ); + assert.instanceOf(revokeOtherError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeOtherError.correlation, { currentSessionId }); + assert.equal( + revokeOtherError.message, + "SQL error in AuthSessionRepository.revokeAllExcept:query", + ); + assert.notInclude(revokeOtherError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authSessionLayer)), + ); + + it.effect("correlates pairing-link create and revoke failures by id only", () => + Effect.gen(function* () { + const pairingLinks = yield* AuthPairingLinkRepository; + const sql = yield* SqlClient.SqlClient; + const id = "pairing-link-correlation"; + const credential = "pairing-credential-secret-sentinel"; + const subject = "pairing-subject-secret-sentinel"; + const scopesPayload = "pairing-scopes-secret-sentinel"; + + yield* sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + scopes, + subject, + label, + proof_key_thumbprint, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${id}, + ${credential}, + ${"one-time-token"}, + ${scopesPayload}, + ${subject}, + NULL, + NULL, + ${DateTime.formatIso(issuedAt)}, + ${DateTime.formatIso(expiresAt)}, + NULL, + NULL + ) + `; + + const decodeError = yield* Effect.flip(pairingLinks.getByCredential({ credential })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { pairingLinkId: id }); + assert.equal( + decodeError.message, + `Decode error in AuthPairingLinkRepository.getByCredential:decodeRow: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, credential); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, scopesPayload); + assert.notInclude(decodeError.message, DateTime.formatIso(issuedAt)); + + yield* sql`DROP TABLE auth_pairing_links`; + const createError = yield* Effect.flip( + pairingLinks.create({ + id, + credential, + method: "one-time-token", + scopes, + subject, + label: null, + proofKeyThumbprint: null, + createdAt: issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { pairingLinkId: id }); + assert.notInclude(createError.message, credential); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeError = yield* Effect.flip(pairingLinks.revoke({ id, revokedAt: now })); + assert.instanceOf(revokeError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeError.correlation, { pairingLinkId: id }); + assert.notInclude(revokeError.message, credential); + assert.notInclude(revokeError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authPairingLinkLayer)), + ); + + it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + Effect.gen(function* () { + const runtimes = yield* ProviderSessionRuntimeRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-correlation"); + const runtimePayload = "runtime-payload-secret-sentinel"; + const lastSeenAt = "2026-06-20T00:00:00.000Z"; + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${threadId}, + ${"codex"}, + NULL, + ${"codex"}, + ${"invalid-runtime-mode"}, + ${"running"}, + ${lastSeenAt}, + NULL, + ${`{"secret":"${runtimePayload}"}`} + ) + `; + + const decodeError = yield* Effect.flip(runtimes.list()); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { threadId }); + assert.equal( + decodeError.message, + `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, runtimePayload); + assert.notInclude(decodeError.message, runtimePayload); + assert.notInclude(decodeError.message, lastSeenAt); + + yield* sql`DROP TABLE provider_session_runtime`; + const sqlFailure = yield* Effect.flip( + runtimes.upsert({ + threadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: { secret: runtimePayload }, + }), + ); + assert.instanceOf(sqlFailure, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(sqlFailure.correlation, { threadId }); + assert.equal( + sqlFailure.message, + "SQL error in ProviderSessionRuntimeRepository.upsert:query", + ); + assert.notInclude(sqlFailure.message, runtimePayload); + assert.notInclude(sqlFailure.message, lastSeenAt); + }).pipe(Effect.provide(providerSessionRuntimeLayer)), + ); +}); From 8fb9c4b5346dedbbdfb00ca2f14a2a5b828788b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:15:22 -0700 Subject: [PATCH 02/23] Preserve asset access failure causes (#3342) Co-authored-by: codex (cherry picked from commit 90dc76b11eac811e8bc89cad3f60bb5c7b56514f) --- .../Layers/ProjectFaviconResolver.test.ts | 127 +++++++++++++++++- .../project/Layers/ProjectFaviconResolver.ts | 124 +++++++++++++---- .../Services/ProjectFaviconResolver.ts | 27 +++- 3 files changed, 249 insertions(+), 29 deletions(-) diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index c983aca4ba7..a539b43bff4 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -4,12 +4,16 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; -import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; +import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; +import { WorkspaceRootNotExistsError } from "../../workspace/Services/WorkspacePaths.ts"; +import { makeProjectFaviconResolver, ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(NodeServices.layer), ); @@ -34,6 +38,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => + makeProjectFaviconResolver.pipe( + Effect.provide(WorkspacePathsLive), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => @@ -73,5 +83,118 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); + + it.effect("preserves workspace normalization context", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const missingCwd = `${cwd}/missing`; + + const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "normalize-workspace", + workspaceRoot: missingCwd, + }); + expect(error.cause).toBeInstanceOf(WorkspaceRootNotExistsError); + }), + ); + + it.effect("preserves non-missing candidate stat failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const faviconPath = path.join(cwd, "favicon.svg"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: faviconPath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "stat-candidate", + workspaceRoot: cwd, + relativePath: "favicon.svg", + absolutePath: faviconPath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("preserves icon source read failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const sourcePath = path.join(cwd, "index.html"); + yield* writeTextFile(cwd, "index.html", ''); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: sourcePath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (filePath, options) => + filePath === sourcePath + ? Effect.fail(cause) + : fileSystem.readFileString(filePath, options), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "read-source", + workspaceRoot: cwd, + relativePath: "index.html", + absolutePath: sourcePath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("skips icon metadata paths outside the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + + it.effect("continues to later sources after an outside-root icon href", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); }); -}); +}); \ No newline at end of file diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index cdfddd5438a..2f1b671f4ea 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -1,12 +1,19 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { + ProjectFaviconResolutionError, ProjectFaviconResolver, type ProjectFaviconResolverShape, } from "../Services/ProjectFaviconResolver.ts"; +import { + WorkspacePaths, + WorkspacePathOutsideRootError, +} from "../../workspace/Services/WorkspacePaths.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -58,31 +65,63 @@ function extractIconHref(source: string): string | null { return null; } +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + export const makeProjectFaviconResolver = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; - const resolveIconHref = (projectCwd: string, href: string): string[] => { + const resolveIconHref = (href: string): ReadonlyArray => { const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; - }; - - const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return [path.join("public", clean), clean]; }; const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, - candidates: ReadonlyArray, - ): Effect.fn.Return { - for (const candidate of candidates) { - if (!isPathWithinProject(projectCwd, candidate)) { + relativeCandidates: ReadonlyArray, + ): Effect.fn.Return { + for (const relativePath of relativeCandidates) { + const candidate = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), + ); + if (Option.isNone(candidate)) { continue; } - const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { - return candidate; + const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + relativePath, + absolutePath: candidate.value.absolutePath, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return candidate.value.absolutePath; } } return null; @@ -90,28 +129,63 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd: string): Effect.fn.Return { + )(function* (cwd: string) { + const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause, + }), + ), + ); for (const candidate of FAVICON_CANDIDATES) { - const resolved = path.join(cwd, candidate); - const existing = yield* findExistingFile(cwd, [resolved]); + const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { return existing; } } for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = path.join(cwd, sourceFile); - const source = yield* fileSystem - .readFileString(sourcePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath: sourceFile, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath: sourceFile, + cause, + }), + ), + ); + const source = yield* optionOnNotFound( + fileSystem.readFileString(sourcePath.absolutePath), + ).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-source", + workspaceRoot: projectCwd, + relativePath: sourceFile, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) { continue; } - const href = extractIconHref(source); + const href = extractIconHref(source.value); if (!href) { continue; } - const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); if (existing) { return existing; } @@ -128,4 +202,4 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { export const ProjectFaviconResolverLive = Layer.effect( ProjectFaviconResolver, makeProjectFaviconResolver, -); +); \ No newline at end of file diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts index ad1b466e2c7..118b75ed461 100644 --- a/apps/server/src/project/Services/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Services/ProjectFaviconResolver.ts @@ -8,6 +8,27 @@ */ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "ProjectFaviconResolutionError", + { + operation: Schema.Literals([ + "normalize-workspace", + "resolve-path", + "stat-candidate", + "read-source", + ]), + workspaceRoot: Schema.String, + relativePath: Schema.optional(Schema.String), + absolutePath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; + } +} /** * ProjectFaviconResolverShape - Service API for project favicon lookup. @@ -18,7 +39,9 @@ export interface ProjectFaviconResolverShape { * * Returns `null` when no candidate icon file can be found. */ - readonly resolvePath: (cwd: string) => Effect.Effect; + readonly resolvePath: ( + cwd: string, + ) => Effect.Effect; } /** @@ -27,4 +50,4 @@ export interface ProjectFaviconResolverShape { export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, ProjectFaviconResolverShape ->()("t3/project/Services/ProjectFaviconResolver") {} +>()("t3/project/Services/ProjectFaviconResolver") {} \ No newline at end of file From 68f8a6ce64fcb221d09fbeb3f1690e4361863191 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:38:56 -0700 Subject: [PATCH 03/23] [codex] Preserve PR materialization failure chains (#3443) Co-authored-by: codex (cherry picked from commit 5edf7c56bafeeaf4d5af1a63f010dde45e6652dd) --- apps/server/src/git/GitManager.test.ts | 59 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 49 +++++++++++++++------ packages/contracts/src/git.ts | 17 ++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bee861677a5..581d9f82253 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2687,6 +2687,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 93, + title: "Missing fork branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/93", + baseRefName: "main", + headRefName: "feature/missing-fork-branch", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: missingForkDir, + sshUrl: missingForkDir, + }, + }, + }, + }); + + const error = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + }).pipe(Effect.flip); + + if (error._tag !== "GitPullRequestMaterializationError") { + return yield* Effect.die(error); + } + expect(error).toMatchObject({ + cwd: repoDir, + pullRequestNumber: 93, + headRepository: "octocat/codething-mvp", + headBranch: "feature/missing-fork-branch", + localBranch: "t3code/pr-93/feature/missing-fork-branch", + }); + if (!(error.cause instanceof AggregateError)) { + return yield* Effect.die(error.cause); + } + expect(error.cause.errors).toHaveLength(2); + expect(error.cause.errors).toEqual([ + expect.objectContaining({ _tag: "GitCommandError" }), + expect.objectContaining({ _tag: "GitCommandError" }), + ]); + expect(error.cause.cause).toBe(error.cause.errors[0]); + }), + ); + it.effect("launches setup only when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 3c7dc704a2f..cb24afb6c60 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -41,7 +41,7 @@ import { type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { GitManagerError } from "@t3tools/contracts"; +import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; @@ -629,9 +629,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) => configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", { + cwd, + localBranch, + headBranch: pullRequest.headBranch, + cause: error, + }).pipe(Effect.asVoid), ), ); @@ -689,12 +692,30 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { localBranch = pullRequest.headBranch, ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( - Effect.catch(() => - gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }), + Effect.catch((primaryCause) => + gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), + ), + ), ), ); const fileSystem = yield* FileSystem.FileSystem; @@ -1411,9 +1432,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }) .pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.preparePullRequestThread setup script failed", { + threadId: input.threadId, + worktreePath, + cause: error, + }).pipe(Effect.asVoid), ), ); }; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..31b76c2be27 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -352,8 +352,25 @@ export class GitManagerError extends Schema.TaggedErrorClass()( } } +export class GitPullRequestMaterializationError extends Schema.TaggedErrorClass()( + "GitPullRequestMaterializationError", + { + cwd: TrimmedNonEmptyStringSchema, + pullRequestNumber: PositiveInt, + headRepository: Schema.NullOr(TrimmedNonEmptyStringSchema), + headBranch: TrimmedNonEmptyStringSchema, + localBranch: TrimmedNonEmptyStringSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to materialize pull request #${this.pullRequestNumber} branch ${this.headBranch} as ${this.localBranch}.`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, + GitPullRequestMaterializationError, GitCommandError, SourceControlProviderError, TextGenerationError, From 29d5ab35c8e05c1fb1f81ea174fc02774c4181de Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:44:22 -0700 Subject: [PATCH 04/23] [codex] Structure pull request link failures (#3445) Co-authored-by: codex (cherry picked from commit a9460bb772a8e25d39213ae62e73934152d44495) --- apps/web/src/components/GitActionsControl.tsx | 4 +- apps/web/src/lib/openPullRequestLink.test.ts | 30 ++++++++ apps/web/src/lib/openPullRequestLink.ts | 75 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/openPullRequestLink.test.ts create mode 100644 apps/web/src/lib/openPullRequestLink.ts diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 8c7356e2829..36773500554 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -79,6 +79,7 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { openPullRequestLink } from "~/lib/openPullRequestLink"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; @@ -1208,7 +1209,8 @@ export default function GitActionsControl({ }); return; } - void api.shell.openExternal(prUrl).catch((err: unknown) => { + void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { + console.error(err); toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts new file mode 100644 index 00000000000..756e1ed6ad9 --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openPullRequestLink, PullRequestLinkOpenError } from "./openPullRequestLink"; + +describe("openPullRequestLink", () => { + it("opens the requested pull request URL", async () => { + const openExternal = vi.fn(async () => undefined); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; + + await openPullRequestLink({ openExternal }, targetUrl); + + expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); + }); + + it("reports bridge failures with a safe target origin", async () => { + const cause = new Error("desktop shell unavailable"); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; + const openExternal = vi.fn(async () => Promise.reject(cause)); + + const result = openPullRequestLink({ openExternal }, targetUrl); + + await expect(result).rejects.toEqual( + new PullRequestLinkOpenError({ + targetOrigin: "https://github.com", + cause, + }), + ); + await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts new file mode 100644 index 00000000000..acd3c5a062b --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -0,0 +1,75 @@ +import type { LocalApi } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { type MouseEvent, useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { readLocalApi } from "../localApi"; + +export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( + "PullRequestLinkOpenError", + { + targetOrigin: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { + let targetOrigin: string | null = null; + try { + targetOrigin = new URL(targetUrl).origin; + } catch { + // Keep malformed URLs out of diagnostics while preserving the open failure below. + } + return new PullRequestLinkOpenError({ targetOrigin, cause }); + } + + override get message(): string { + return this.targetOrigin === null + ? "Unable to open pull request link." + : `Unable to open pull request link at ${this.targetOrigin}.`; + } +} + +export async function openPullRequestLink( + shell: Pick, + targetUrl: string, +): Promise { + try { + await shell.openExternal(targetUrl); + } catch (cause) { + throw PullRequestLinkOpenError.fromCause(targetUrl, cause); + } +} + +/** + * Returns a click handler that opens a pull request URL in the system browser. + * + * Stops event propagation/default so activating the link does not also trigger + * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a + * toast when the local API is unavailable or the open fails. + */ +export function useOpenPrLink() { + return useCallback((event: MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + + void openPullRequestLink(api.shell, prUrl).catch((error) => { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open pull request link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, []); +} From 08feafd994ccb98d43c0579b85f371e050d77a72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:41:25 -0700 Subject: [PATCH 05/23] [codex] Structure GitLab CLI failures (#3458) Co-authored-by: codex (cherry picked from commit 1a59277dc84105e2131c2bc828deb51c0b12d7f6) --- .../src/sourceControl/GitLabCli.test.ts | 54 +- apps/server/src/sourceControl/GitLabCli.ts | 523 ++++++++++++------ .../GitLabSourceControlProvider.test.ts | 52 +- 3 files changed, 441 insertions(+), 188 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index c075027151a..87621e5c8bc 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -8,7 +8,7 @@ import { VcsProcessExitError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; -const mockedRun = vi.fn(); +const mockedRun = vi.fn(); const layer = it.layer( GitLabCli.layer.pipe( Layer.provide( @@ -313,17 +313,15 @@ layer("GitLabCli.layer", (it) => { it.effect("surfaces a friendly error when the merge request is not found", () => Effect.gen(function* () { - mockedRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab mr view 4888", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 merge request not found", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); const error = yield* Effect.gen(function* () { const glab = yield* GitLabCli.GitLabCli; @@ -333,7 +331,37 @@ layer("GitLabCli.layer", (it) => { }); }).pipe(Effect.flip); - assert.equal(error.message.includes("Merge request not found"), true); + assert.equal(error.message.includes("Merge request 4888 was not found"), true); + assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); + assert.strictEqual(error.command, "glab"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); + }), + ); + + it.effect("keeps non-merge-request not-found failures generic", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 project not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "missing/project", + }); + }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitLabCliCommandError"); + assert.strictEqual(error.cause, cause); }), ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index bd430d9d01a..3e3bbe742c1 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,30 +1,225 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import type * as DateTime from "effect/DateTime"; -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; +import { + decodeGitLabMergeRequestJson, + decodeGitLabMergeRequestListJson, +} from "./gitLabMergeRequests.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { +const gitLabCliExecutionErrorContext = { + operation: Schema.Literal("execute"), + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +const gitLabCliDecodeErrorContext = { + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +export class GitLabCliUnavailableError extends Schema.TaggedErrorClass()( + "GitLabCliUnavailableError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI (`glab`) is required but not available on PATH."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitLabCliAuthenticationError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestNotFoundError", + { + ...gitLabCliExecutionErrorContext, + reference: Schema.String, + }, +) { + get detail(): string { + return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + readonly reference: string; + }, + error: VcsError, + ): GitLabCliError { + if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { + return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); + } + + return GitLabCliCommandError.fromVcsError( + { + operation: context.operation, + command: context.command, + cwd: context.cwd, + }, + error, + ); + } +} + +export class GitLabCliCommandError extends Schema.TaggedErrorClass()( + "GitLabCliCommandError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI command failed."; + } + override get message(): string { return `GitLab CLI failed in ${this.operation}: ${this.detail}`; } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + }, + error: VcsError, + ): GitLabCliError { + return Match.valueTags(error, { + VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), + VcsProcessExitError: (cause) => { + switch (cause.failureKind) { + case "authentication": + return new GitLabCliAuthenticationError({ ...context, cause }); + case "not-found": + case "command-failed": + case undefined: + return new GitLabCliCommandError({ ...context, cause }); + } + }, + VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), + }); + } } +export class GitLabMergeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestListDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("listMergeRequests"), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid MR list JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("getMergeRequest"), + reference: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid merge request JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitLabRepositoryDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), + repository: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid repository JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass()( + "GitLabNamespaceDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("createRepository"), + namespacePath: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid namespace JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const GitLabCliError = Schema.Union([ + GitLabCliUnavailableError, + GitLabCliAuthenticationError, + GitLabMergeRequestNotFoundError, + GitLabCliCommandError, + GitLabMergeRequestListDecodeError, + GitLabMergeRequestDecodeError, + GitLabRepositoryDecodeError, + GitLabNamespaceDecodeError, +]); +export type GitLabCliError = typeof GitLabCliError.Type; +export const isGitLabCliError = Schema.is(GitLabCliError); + export interface GitLabMergeRequestSummary { readonly number: number; readonly title: string; @@ -44,120 +239,60 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } -export interface GitLabCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listMergeRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, GitLabCliError>; - - readonly getMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createMergeRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class GitLabCli extends Context.Service()( - "t3/sourceControl/GitLabCli", -) {} - -function isVcsProcessSpawnError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "VcsProcessSpawnError" - ); -} - -function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI (`glab`) is required but not available on PATH.", - cause: error, - }); - } - - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("glab auth login") || - lower.includes("token") - ) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("merge request not found") || - lower.includes("not found") || - lower.includes("404") - ) { - return new GitLabCliError({ - operation, - detail: "Merge request not found. Check the MR number or URL and try again.", - cause: error, - }); - } - - return new GitLabCliError({ - operation, - detail: `GitLab CLI command failed: ${error.message}`, - cause: error, - }); +export class GitLabCli extends Context.Service< + GitLabCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitLabCliError>; + + readonly getMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createMergeRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; } - - return new GitLabCliError({ - operation, - detail: "GitLab CLI command failed.", - cause: error, - }); -} +>()("t3/sourceControl/GitLabCli") {} const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, @@ -174,6 +309,14 @@ const RawGitLabNamespaceSchema = Schema.Struct({ id: Schema.Number, }); +const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), +); +const decodeGitLabDefaultBranch = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabDefaultBranchSchema), +); +const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitLabRepositoryCloneUrls { @@ -184,24 +327,6 @@ function normalizeRepositoryCloneUrls( }; } -function decodeGitLabJson( - raw: string, - schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitLabCliError({ - operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, - }), - ), - ); -} - function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { switch (state) { case "open": @@ -259,10 +384,13 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } -export const make = Effect.fn("makeGitLabCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitLabCliShape["execute"] = (input) => + const run = ( + input: Parameters[0], + mapError: (error: VcsError) => GitLabCliError, + ) => process .run({ operation: "GitLabCli.execute", @@ -271,7 +399,32 @@ export const make = Effect.fn("makeGitLabCli")(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); + .pipe(Effect.mapError(mapError)); + + const execute: GitLabCli["Service"]["execute"] = (input) => + run(input, (error) => + GitLabCliCommandError.fromVcsError( + { operation: "execute", command: "glab", cwd: input.cwd }, + error, + ), + ); + + const executeMergeRequest = (input: { + readonly cwd: string; + readonly reference: string; + readonly args: ReadonlyArray; + }) => + run(input, (error) => + GitLabMergeRequestNotFoundError.fromVcsError( + { + operation: "execute", + command: "glab", + cwd: input.cwd, + reference: input.reference, + }, + error, + ), + ); return GitLabCli.of({ execute, @@ -294,13 +447,14 @@ export const make = Effect.fn("makeGitLabCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( + : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestListDecodeError({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, cause: decoded.failure, }), ); @@ -312,19 +466,22 @@ export const make = Effect.fn("makeGitLabCli")(function* () { ), ), getMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "view", input.reference, "--output", "json"], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( + Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestDecodeError({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, + reference: input.reference, cause: decoded.failure, }), ); @@ -342,11 +499,17 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitLab CLI returned invalid repository JSON.", + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getRepositoryCloneUrls", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -360,11 +523,17 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabNamespaceSchema, - "createRepository", - "GitLab CLI returned invalid namespace JSON.", + decodeGitLabNamespace(raw).pipe( + Effect.mapError( + (cause) => + new GitLabNamespaceDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + namespacePath, + cause, + }), + ), ), ), Effect.map((namespace) => namespace.id), @@ -394,11 +563,17 @@ export const make = Effect.fn("makeGitLabCli")(function* () { ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "createRepository", - "GitLab CLI returned invalid repository JSON.", + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -432,21 +607,27 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabDefaultBranchSchema, - "getDefaultBranch", - "GitLab CLI returned invalid repository JSON.", + decodeGitLabDefaultBranch(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getDefaultBranch", + command: "glab", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map((value) => value.default_branch ?? null), ), checkoutMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), }); }); -export const layer = Layer.effect(GitLabCli, make()); +export const layer = Layer.effect(GitLabCli, make); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 842cf4a17cf..0d06e066521 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -8,8 +8,8 @@ import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -function makeProvider(gitlab: Partial) { - return GitLabSourceControlProvider.make().pipe( +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make.pipe( Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), ); } @@ -52,9 +52,52 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = }), ); +it.effect("adds repository context while retaining GitLab CLI causes", () => + Effect.gen(function* () { + const cause = new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + createRepository: () => Effect.fail(cause), + }); + + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "gitlab", + operation: "createRepository", + command: "glab", + cwd: "/repo", + repository: "owner/repo", + detail: "GitLab CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listMergeRequests: (input) => { listInput = input; @@ -80,7 +123,8 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createMergeRequest: (input) => { createInput = input; From f52a7593d3ebfe3a318be0ccda9c7bffc0640a16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:47:12 -0700 Subject: [PATCH 06/23] [codex] Structure Bitbucket API errors (#3457) Co-authored-by: codex (cherry picked from commit a3dadc04bf38b49bc8476a8e6c59bda7dc81d2e4) --- .../src/sourceControl/BitbucketApi.test.ts | 191 ++++++++- apps/server/src/sourceControl/BitbucketApi.ts | 378 ++++++++++++------ .../BitbucketSourceControlProvider.test.ts | 50 ++- 3 files changed, 464 insertions(+), 155 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index eb3001b3d06..5a9759ace0b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -6,8 +6,14 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; - +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -53,41 +59,46 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; - readonly git?: Partial; + readonly requestFailure?: ( + request: HttpClientRequest.HttpClientRequest, + ) => HttpClientError.HttpClientError; + readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => - Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + input.requestFailure + ? Effect.fail(input.requestFailure(request)) + : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { - readConfigValue: vi.fn(() => + readConfigValue: vi.fn(() => Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), ), - resolvePrimaryRemoteName: vi.fn( - () => Effect.succeed("origin"), - ), - ensureRemote: vi.fn(() => + resolvePrimaryRemoteName: vi.fn< + GitVcsDriver.GitVcsDriver["Service"]["resolvePrimaryRemoteName"] + >(() => Effect.succeed("origin")), + ensureRemote: vi.fn(() => Effect.succeed("octocat"), ), - fetchRemoteBranch: vi.fn( + fetchRemoteBranch: vi.fn( () => Effect.void, ), - fetchRemoteTrackingBranch: vi.fn( + fetchRemoteTrackingBranch: vi.fn< + GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] + >(() => Effect.void), + setBranchUpstream: vi.fn( () => Effect.void, ), - setBranchUpstream: vi.fn( - () => Effect.void, - ), - switchRef: vi.fn((request) => + switchRef: vi.fn((request) => Effect.succeed({ refName: request.refName }), ), - listLocalBranchNames: vi.fn(() => + listLocalBranchNames: vi.fn(() => Effect.succeed([]), ), }; const git = { ...gitMock, ...input.git, - } satisfies Partial; + } satisfies Partial; const driver = { listRemotes: () => @@ -106,7 +117,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const layer = BitbucketApi.layer.pipe( Layer.provide( @@ -130,7 +141,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriver.VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], }), }), ), @@ -139,9 +150,9 @@ function makeLayer(input: { ConfigProvider.layer( ConfigProvider.fromEnv({ env: { - MORECODE_T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", - MORECODE_T3CODE_BITBUCKET_EMAIL: "user@example.com", - MORECODE_T3CODE_BITBUCKET_API_TOKEN: "token", + T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", + T3CODE_BITBUCKET_EMAIL: "user@example.com", + T3CODE_BITBUCKET_API_TOKEN: "token", }, }), ), @@ -497,6 +508,97 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { + const transportCause = new Error("socket reset by peer"); + let requestFailure: HttpClientError.HttpClientError | undefined; + const { layer } = makeLayer({ + response: () => Response.json({}), + requestFailure: (request) => { + requestFailure = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: transportCause, + }), + }); + return requestFailure; + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.getPullRequest({ + cwd: "/repo", + reference: "42", + }), + ); + + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", + ); + assert.strictEqual(error.cause, requestFailure); + assert.strictEqual(requestFailure?.cause, transportCause); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { + const responseBody = '{"error":{"message":"credential=secret-value"}}'; + const { layer } = makeLayer({ + response: () => new Response(responseBody, { status: 403 }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .checkoutPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 403); + assert.strictEqual(error.responseBodyLength, responseBody.length); + assert.notProperty(error, "responseBody"); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.", + ); + assert.notInclude(error.message, "secret-value"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { + const cause = new Error("response stream failed"); + const { layer } = makeLayer({ + response: () => + new Response( + new ReadableStream({ + start: (controller) => controller.error(cause), + }), + { status: 502 }, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .getPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 502); + assert.instanceOf(error.cause, HttpClientError.HttpClientError); + assert.strictEqual(error.cause.cause, cause); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", + ); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -549,6 +651,51 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket }).pipe(Effect.provide(layer)); }); +it.effect("preserves Git checkout failures without deriving the domain message from them", () => { + const gitCause = new GitCommandError({ + operation: "fetchRemoteBranch", + command: "git fetch origin feature/source-control", + cwd: "/repo", + detail: "remote rejected the request", + }); + const { layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, + }), + git: { + fetchRemoteBranch: () => Effect.fail(gitCause), + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + force: true, + }), + ); + + assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "42"); + assert.strictEqual( + error.message, + "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", + ); + assert.strictEqual(error.cause, gitCause); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index d287c9c5527..f7d7f6671a4 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, @@ -15,7 +16,12 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { sanitizeBranchFragment } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import { + BitbucketPullRequestListSchema, + BitbucketPullRequestSchema, + normalizeBitbucketPullRequestRecord, + type NormalizedBitbucketPullRequestRecord, +} from "./bitbucketPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -23,28 +29,164 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; const BitbucketApiEnvConfig = Config.all({ - baseUrl: Config.string("MORECODE_T3CODE_BITBUCKET_API_BASE_URL").pipe( + baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( Config.withDefault(DEFAULT_API_BASE_URL), ), - accessToken: Config.string("MORECODE_T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), - email: Config.string("MORECODE_T3CODE_BITBUCKET_EMAIL").pipe(Config.option), - apiToken: Config.string("MORECODE_T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), + accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), + email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), + apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), }); -export class BitbucketApiError extends Schema.TaggedErrorClass()( - "BitbucketApiError", +const BitbucketApiOperation = Schema.Literals([ + "resolveRepository", + "getRepository", + "getBranchingModel", + "getPullRequest", + "listPullRequests", + "createRepository", + "createPullRequest", + "probeAuth", + "checkoutPullRequest", +]); +type BitbucketApiOperation = typeof BitbucketApiOperation.Type; + +export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryLocatorError", + { + repository: Schema.String, + }, +) { + override get message(): string { + return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository."; + } +} + +export class BitbucketRequestError extends Schema.TaggedErrorClass()( + "BitbucketRequestError", + { + operation: BitbucketApiOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Failed to send the Bitbucket request.`; + } +} + +export class BitbucketResponseError extends Schema.TaggedErrorClass()( + "BitbucketResponseError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + responseBodyLength: NonNegativeInt, + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketResponseBodyReadError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass()( + "BitbucketResponseDecodeError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned invalid JSON for the requested resource.`; + } +} + +export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryVcsResolveError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to resolve VCS repository for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemotesListError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to list remotes for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemoteNotFoundError", + { + cwd: Schema.String, + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: No Bitbucket repository remote was detected for ${this.cwd}.`; + } +} + +export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketPullRequestBodyReadError", { - operation: Schema.String, - detail: Schema.String, - status: Schema.optional(Schema.Number), - cause: Schema.optional(Schema.Defect()), + cwd: Schema.String, + bodyFile: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + return `Bitbucket API failed in createPullRequest: Failed to read pull request body file ${this.bodyFile}.`; } } -const isBitbucketApiErrorValue = Schema.is(BitbucketApiError); + +export class BitbucketCheckoutError extends Schema.TaggedErrorClass()( + "BitbucketCheckoutError", + { + cwd: Schema.String, + reference: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request."; + } +} + +export const BitbucketApiError = Schema.Union([ + BitbucketRepositoryLocatorError, + BitbucketRequestError, + BitbucketResponseError, + BitbucketResponseBodyReadError, + BitbucketResponseDecodeError, + BitbucketRepositoryVcsResolveError, + BitbucketRepositoryRemotesListError, + BitbucketRepositoryRemoteNotFoundError, + BitbucketPullRequestBodyReadError, + BitbucketCheckoutError, +]); +export type BitbucketApiError = typeof BitbucketApiError.Type; +export const isBitbucketApiError = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({ full_name: TrimmedNonEmptyString, @@ -100,62 +242,55 @@ export interface BitbucketRepositoryLocator { readonly repoSlug: string; } -export interface BitbucketApiShape { - readonly probeAuth: Effect.Effect; - readonly listPullRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect< - ReadonlyArray, - BitbucketApiError - >; - readonly getPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect< - BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, - BitbucketApiError - >; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly createPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class BitbucketApi extends Context.Service()( - "t3/sourceControl/BitbucketApi", -) {} +export class BitbucketApi extends Context.Service< + BitbucketApi, + { + readonly probeAuth: Effect.Effect; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, BitbucketApiError>; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/BitbucketApi") {} function nonEmpty(value: string | undefined): Option.Option { const trimmed = value?.trim(); @@ -211,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator } function requireRepositoryLocator( - operation: string, repository: string, ): Effect.Effect { const locator = parseBitbucketRepositorySlug(repository); return locator ? Effect.succeed(locator) : Effect.fail( - new BitbucketApiError({ - operation, - detail: "Bitbucket repositories must be specified as workspace/repository.", + new BitbucketRepositoryLocatorError({ + repository, }), ); } @@ -299,9 +432,7 @@ function checkoutBranchName(input: { } function repositoryNameWithOwner( - repository: Schema.Schema.Type< - typeof BitbucketPullRequests.BitbucketPullRequestSchema - >["source"]["repository"], + repository: Schema.Schema.Type["source"]["repository"], ): string | null { const fullName = repository?.full_name?.trim() ?? ""; return fullName.length > 0 ? fullName : null; @@ -337,45 +468,37 @@ function authFromConfig( account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set MORECODE_T3CODE_BITBUCKET_EMAIL and MORECODE_T3CODE_BITBUCKET_API_TOKEN, or MORECODE_T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", ), }; } -function requestError(operation: string, cause: unknown): BitbucketApiError { - return new BitbucketApiError({ - operation, - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }); -} - -function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { - return isBitbucketApiErrorValue(cause); -} - function responseError( - operation: string, + operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { return response.text.pipe( - Effect.orElseSucceed(() => ""), + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation, + status: response.status, + cause, + }), + ), Effect.flatMap((body) => Effect.fail( - new BitbucketApiError({ + new BitbucketResponseError({ operation, status: response.status, - detail: - body.trim().length > 0 - ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` - : `Bitbucket returned HTTP ${response.status}.`, + responseBodyLength: body.length, }), ), ), ); } -export const make = Effect.fn("makeBitbucketApi")(function* () { +export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; @@ -395,7 +518,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }; const decodeResponse = ( - operation: string, + operation: BitbucketApiOperation, schema: S, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect => @@ -404,9 +527,9 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { HttpClientResponse.schemaBodyJson(schema)(success).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketResponseDecodeError({ operation, - detail: "Bitbucket returned invalid JSON for the requested resource.", + status: success.status, cause, }), ), @@ -415,12 +538,18 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { })(response); const executeJson = ( - operation: string, + operation: BitbucketApiOperation, request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( - Effect.mapError((cause) => requestError(operation, cause)), + Effect.mapError( + (cause) => + new BitbucketRequestError({ + operation, + cause, + }), + ), Effect.flatMap((response) => decodeResponse(operation, schema, response)), ); @@ -442,9 +571,8 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to resolve VCS repository for ${input.cwd}.`, + new BitbucketRepositoryVcsResolveError({ + cwd: input.cwd, cause, }), ), @@ -452,9 +580,8 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to list remotes for ${input.cwd}.`, + new BitbucketRepositoryRemotesListError({ + cwd: input.cwd, cause, }), ), @@ -466,9 +593,8 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { if (parsed) return parsed; } - return yield* new BitbucketApiError({ - operation: "resolveRepository", - detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + return yield* new BitbucketRepositoryRemoteNotFoundError({ + cwd: input.cwd, }); }); @@ -511,7 +637,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, ), ), - BitbucketPullRequests.BitbucketPullRequestSchema, + BitbucketPullRequestSchema, ); const getRawPullRequest = (input: { @@ -599,21 +725,17 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { ), { urlParams: query }, ), - BitbucketPullRequests.BitbucketPullRequestListSchema, + BitbucketPullRequestListSchema, ); }), - Effect.map((list) => - list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), - ), + Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), ), getPullRequest: (input) => - getRawPullRequest(input).pipe( - Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), - ), + getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => - requireRepositoryLocator("createRepository", input.repository).pipe( + requireRepositoryLocator(input.repository).pipe( Effect.flatMap((repository) => executeJson( "createRepository", @@ -638,9 +760,9 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const description = yield* fileSystem.readFileString(input.bodyFile).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "createPullRequest", - detail: `Failed to read pull request body file ${input.bodyFile}.`, + new BitbucketPullRequestBodyReadError({ + cwd: input.cwd, + bodyFile: input.bodyFile, cause, }), ), @@ -675,7 +797,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, ), ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), - BitbucketPullRequests.BitbucketPullRequestSchema, + BitbucketPullRequestSchema, ); }), getDefaultBranch: (input) => @@ -756,9 +878,9 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { Effect.mapError((cause) => isBitbucketApiError(cause) ? cause - : new BitbucketApiError({ - operation: "checkoutPullRequest", - detail: cause instanceof Error ? cause.message : String(cause), + : new BitbucketCheckoutError({ + cwd: input.cwd, + reference: input.reference, cause, }), ), @@ -766,4 +888,4 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }); }); -export const layer = Layer.effect(BitbucketApi, make()); +export const layer = Layer.effect(BitbucketApi, make); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 07a3d386a35..eeb4c8fbdd2 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -function makeProvider(bitbucket: Partial) { - return BitbucketSourceControlProvider.make().pipe( +function makeProvider(bitbucket: Partial) { + return BitbucketSourceControlProvider.make.pipe( Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), ); } @@ -51,9 +51,48 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( }), ); +it.effect("adds repository context while retaining Bitbucket API causes", () => + Effect.gen(function* () { + const upstreamCause = new Error("raw upstream failure"); + const cause = new BitbucketApi.BitbucketRequestError({ + operation: "getRepository", + cause: upstreamCause, + }); + const provider = yield* makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(cause), + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + command: undefined, + cwd: "/repo", + repository: "owner/repo", + detail: "Failed to get repository clone URLs.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(upstreamCause.message), false); + }), +); + it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ listPullRequests: (input) => { listInput = input; @@ -79,8 +118,9 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () => it.effect("creates Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; From 6d981b731468168c91b8e5b006536ec960a51ee4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:19 -0700 Subject: [PATCH 07/23] [codex] Structure Azure DevOps CLI failures (#3460) Co-authored-by: codex (cherry picked from commit d389cfd4e39ec1b76a6898a04d1b90190155b618) --- .../src/sourceControl/AzureDevOpsCli.test.ts | 78 +++- .../src/sourceControl/AzureDevOpsCli.ts | 403 +++++++++++------- .../AzureDevOpsSourceControlProvider.test.ts | 49 ++- 3 files changed, 373 insertions(+), 157 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f3078fcd06c..1cd4b388552 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -4,7 +4,9 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -17,7 +19,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const supportLayer = Layer.mergeAll( Layer.mock(VcsProcess.VcsProcess)({ @@ -329,4 +331,78 @@ describe("AzureDevOpsCli.layer", () => { }); }).pipe(Effect.provide(layer)), ); + + it.effect("preserves VCS causes without copying upstream details into messages", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "AzureDevOpsCli.execute", + command: "az repos list --organization sensitive-upstream-detail", + cwd: "/repo", + exitCode: 1, + detail: "sensitive-upstream-detail", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.operation, "execute"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.argumentCount, 2); + assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("sensitive-upstream-detail"), false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not report a missing working directory as a missing Azure CLI", () => + Effect.gen(function* () { + const cwd = "/missing/repo"; + const platformCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "chdir", + pathOrDescriptor: cwd, + }); + const cause = new VcsProcessSpawnError({ + operation: "AzureDevOpsCli.execute", + command: "az", + cwd, + argumentCount: 2, + cause: platformCause, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.cwd, cwd); + assert.strictEqual(error.cause, cause); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps invalid pull request output diagnostics structured", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.outputLength, 8); + assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); + assert.exists(error.cause); + assert.strictEqual( + error.message, + "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", + ); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index e39ce9f0100..609efe4df4c 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,161 +1,254 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + type NormalizedAzureDevOpsPullRequestRecord, +} from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( - "AzureDevOpsCliError", - { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, +const azureDevOpsCommandErrorFields = { + operation: Schema.Literal("execute"), + command: Schema.Literal("az"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsCliUnavailableError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliUnavailableError", + azureDevOpsCommandErrorFields, ) { + get detail(): string { + return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; + } + override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } -export interface AzureDevOpsRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; +export class AzureDevOpsCliAuthenticationError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliAuthenticationError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } } -export interface AzureDevOpsCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect< - ReadonlyArray, - AzureDevOpsCliError - >; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect< - AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, - AzureDevOpsCliError - >; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly remoteName?: string; - }) => Effect.Effect; +export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestNotFoundError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } } -export class AzureDevOpsCli extends Context.Service()( - "t3/sourceControl/AzureDevOpsCli", -) {} +export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( + "AzureDevOpsCommandFailedError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI command failed."; + } -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } - return String(error); + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "az"; + readonly cwd: string; + readonly argumentCount: number; + }, + cause: VcsError, + ): AzureDevOpsCliError { + const fields = { ...context, cause }; + + if ( + cause._tag === "VcsProcessSpawnError" && + cause.cause instanceof PlatformError.PlatformError && + cause.cause.reason._tag === "NotFound" && + cause.cause.reason.pathOrDescriptor !== context.cwd && + cause.cause.reason.syscall !== "chdir" + ) { + return new AzureDevOpsCliUnavailableError(fields); + } + + if (cause._tag === "VcsProcessExitError") { + if (cause.failureKind === "authentication") { + return new AzureDevOpsCliAuthenticationError(fields); + } + if (cause.failureKind === "not-found") { + return new AzureDevOpsPullRequestNotFoundError(fields); + } + } + + return new AzureDevOpsCommandFailedError(fields); + } } -function normalizeAzureDevOpsCliError( - operation: "execute", - error: VcsError | unknown, -): AzureDevOpsCliError { - const text = errorText(error); - const lower = text.toLowerCase(); - - if (lower.includes("command not found: az") || lower.includes("enoent")) { - return new AzureDevOpsCliError({ - operation, - detail: - "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", - cause: error, - }); +const azureDevOpsDecodeErrorFields = { + command: Schema.Literal("az"), + cwd: Schema.String, + outputLength: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestListDecodeError", + { + operation: Schema.Literal("listPullRequests"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid PR list JSON."; } - if ( - lower.includes("az devops login") || - lower.includes("please run az login") || - lower.includes("not logged in") || - lower.includes("authentication failed") || - lower.includes("unauthorized") - ) { - return new AzureDevOpsCliError({ - operation, - detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", - cause: error, - }); + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } +} - if ( - lower.includes("pull request") && - (lower.includes("not found") || lower.includes("does not exist")) - ) { - return new AzureDevOpsCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); +export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestDecodeError", + { + operation: Schema.Literal("getPullRequest"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid pull request JSON."; } - return new AzureDevOpsCliError({ - operation, - detail: text, - cause: error, - }); + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ + "getRepositoryCloneUrls", + "getDefaultBranch", + "createRepository", +]); + +export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsRepositoryDecodeError", + { + operation: AzureDevOpsRepositoryDecodeOperation, + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const AzureDevOpsCliError = Schema.Union([ + AzureDevOpsCliUnavailableError, + AzureDevOpsCliAuthenticationError, + AzureDevOpsPullRequestNotFoundError, + AzureDevOpsCommandFailedError, + AzureDevOpsPullRequestListDecodeError, + AzureDevOpsPullRequestDecodeError, + AzureDevOpsRepositoryDecodeError, +]); +export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; + +export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); + +export interface AzureDevOpsRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; } +export class AzureDevOpsCli extends Context.Service< + AzureDevOpsCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, AzureDevOpsCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; + } +>()("t3/sourceControl/AzureDevOpsCli") {} + function normalizeChangeRequestId(reference: string): string { const trimmed = reference.trim().replace(/^#/, ""); const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); @@ -224,25 +317,27 @@ function parseRepositorySpecifier(repository: string): { function decodeAzureDevOpsJson( raw: string, schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, -): Effect.Effect { + operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, + cwd: string, +): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( - (error) => - new AzureDevOpsCliError({ + (cause) => + new AzureDevOpsRepositoryDecodeError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, + command: "az", + cwd, + outputLength: raw.length, + cause, }), ), ); } -export const make = Effect.fn("makeAzureDevOpsCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: AzureDevOpsCliShape["execute"] = (input) => + const execute: AzureDevOpsCli["Service"]["execute"] = (input) => process .run({ operation: "AzureDevOpsCli.execute", @@ -251,9 +346,21 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); + .pipe( + Effect.mapError((error) => + AzureDevOpsCommandFailedError.fromVcsError( + { + operation: "execute", + command: "az", + cwd: input.cwd, + argumentCount: input.args.length, + }, + error, + ), + ), + ); - const executeJson = (input: Parameters[0]) => + const executeJson = (input: Parameters[0]) => execute({ ...input, args: [...input.args, "--only-show-errors", "--output", "json"], @@ -282,15 +389,15 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => - AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), - ).pipe( + : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestListDecodeError({ operation: "listPullRequests", - detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + outputLength: raw.length, cause: decoded.failure, }), ); @@ -316,13 +423,15 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestDecodeError({ operation: "getPullRequest", - detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + outputLength: raw.length, cause: decoded.failure, }), ); @@ -344,7 +453,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { raw, RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", - "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -369,12 +478,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "createRepository", - "Azure DevOps CLI returned invalid repository JSON.", - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), ), Effect.map(normalizeRepositoryCloneUrls), ); @@ -406,12 +510,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "getDefaultBranch", - "Azure DevOps CLI returned invalid repository JSON.", - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ), @@ -434,4 +533,4 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }); }); -export const layer = Layer.effect(AzureDevOpsCli, make()); +export const layer = Layer.effect(AzureDevOpsCli, make); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 4ba3777159b..21db25e7991 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -function makeProvider(azure: Partial) { - return AzureDevOpsSourceControlProvider.make().pipe( +function makeProvider(azure: Partial) { + return AzureDevOpsSourceControlProvider.make.pipe( Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), ); } @@ -46,10 +46,51 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); +it.effect("adds change-request context while retaining Azure CLI causes", () => + Effect.gen(function* () { + const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ + operation: "execute", + command: "az", + cwd: "/repo", + argumentCount: 2, + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + checkoutPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: "az", + cwd: "/repo", + reference: "#42", + detail: "Azure DevOps CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; From 0e0e01beac32829dd1b19deee77f39862ca9affe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:14:35 -0700 Subject: [PATCH 08/23] [codex] Structure GitHub CLI failures (#3456) Co-authored-by: codex (cherry picked from commit 61e6d89d69240120dd08fae72459e5fd7e7a2908) --- apps/server/src/git/GitManager.test.ts | 346 +++++++++------- .../src/sourceControl/GitHubCli.test.ts | 53 ++- apps/server/src/sourceControl/GitHubCli.ts | 373 +++++++++++------- .../GitHubSourceControlProvider.test.ts | 48 ++- .../GitHubSourceControlProvider.ts | 198 +++++++--- 5 files changed, 660 insertions(+), 358 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 581d9f82253..e1924c03ade 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -20,27 +20,16 @@ import type { } from "@t3tools/contracts"; import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; -import { type GitManagerShape } from "./GitManager.ts"; -import { - GitHubCliError, - type GitHubCliShape, - type GitHubPullRequestSummary, - GitHubCli, -} from "../sourceControl/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import { makeGitManager } from "./GitManager.ts"; -import { ServerConfig } from "../config.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; -import { - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerError, - type ProjectSetupScriptRunnerInput, - type ProjectSetupScriptRunnerShape, -} from "../project/Services/ProjectSetupScriptRunner.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitManager from "./GitManager.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -60,7 +49,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCliError; + failWith?: GitHubCli.GitHubCliError; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -108,7 +97,7 @@ interface FakeGitTextGeneration { type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { +function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -175,25 +164,15 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary } function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { - const result = spawnSync("git", args, { + const result = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8", }); if (result.status === 0) { return; } - throw new GitHubCliError({ - operation: "execute", - detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, - }); -} - -function isGitHubCliError(error: unknown): error is GitHubCliError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - (error as { _tag?: unknown })._tag === "GitHubCliError" + throw new Error( + `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, ); } @@ -265,7 +244,7 @@ function initRepo( yield* runGit(cwd, ["init", "--initial-branch=main"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); - yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); + yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); }); @@ -312,7 +291,9 @@ function configureVisibleRemoteUrlWithLocalRewrite( }); } -function createTextGeneration(overrides: Partial = {}): TextGenerationShape { +function createTextGeneration( + overrides: Partial = {}, +): TextGeneration.TextGeneration["Service"] { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => Effect.succeed({ @@ -385,7 +366,7 @@ function createTextGeneration(overrides: Partial = {}): T } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCliShape; + service: GitHubCli.GitHubCli["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -397,7 +378,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - const execute: GitHubCliShape["execute"] = (input) => { + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -468,7 +449,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { try: () => { const headBranch = scenario.pullRequest?.headRefName; if (headBranch) { - const existingBranch = spawnSync( + const existingBranch = NodeChildProcess.spawnSync( "git", ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], { @@ -485,14 +466,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - isGitHubCliError(error) + GitHubCli.isGitHubCliError(error) ? error - : new GitHubCliError({ - operation: "execute", - detail: - error instanceof Error - ? `Failed to simulate gh checkout: ${error.message}` - : "Failed to simulate gh checkout.", + : new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: error, }), }); } @@ -503,9 +482,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCliError({ - operation: "execute", - detail: `Unexpected repository lookup: ${repository}`, + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected repository lookup: ${repository}`), }), ); } @@ -523,9 +503,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCliError({ - operation: "execute", - detail: `Unexpected gh command: ${args.join(" ")}`, + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), ); }; @@ -553,7 +534,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubPullRequestSummary => entry !== null), + .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), ), ), createPullRequest: (input) => @@ -592,7 +573,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], - }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), + ), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, @@ -600,9 +583,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCliError({ - operation: "createRepository", - detail: `Unexpected repository create: ${input.repository}`, + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected repository create: ${input.repository}`), }), ), checkoutPullRequest: (input) => @@ -616,7 +600,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } function runStackedAction( - manager: GitManagerShape, + manager: GitManager.GitManager["Service"], input: { cwd: string; action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; @@ -625,7 +609,7 @@ function runStackedAction( featureBranch?: boolean; filePaths?: readonly string[]; }, - options?: Parameters[1], + options?: Parameters[1], ) { return manager.runStackedAction( { @@ -636,12 +620,15 @@ function runStackedAction( ); } -function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { +function resolvePullRequest( + manager: GitManager.GitManager["Service"], + input: { cwd: string; reference: string }, +) { return manager.resolvePullRequest(input); } function preparePullRequestThread( - manager: GitManagerShape, + manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); @@ -650,24 +637,24 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; - setupScriptRunner?: ProjectSetupScriptRunnerShape; + setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const vcsDriverLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(serverConfigLayer), ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make().pipe( + GitHubSourceControlProvider.make.pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -676,14 +663,14 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), + Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)), ), ); const managerLayer = Layer.mergeAll( - Layer.succeed(TextGeneration, textGeneration), + Layer.succeed(TextGeneration.TextGeneration, textGeneration), Layer.succeed( - ProjectSetupScriptRunner, + ProjectSetupScriptRunner.ProjectSetupScriptRunner, input?.setupScriptRunner ?? { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, @@ -692,7 +679,7 @@ function makeManager(input?: { serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); - return makeGitManager().pipe( + return GitManager.make.pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); @@ -920,7 +907,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); - const cwd = path.join(rootDir, "deleted-repo"); + const cwd = NodePath.join(rootDir, "deleted-repo"); yield* makeDirectory(cwd); yield* removePath(cwd); const { manager } = yield* makeManager(); @@ -1030,7 +1017,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-pr.txt"), "fork pr\n"); yield* runGit(repoDir, ["add", "fork-pr.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); @@ -1335,9 +1322,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), }), }, }); @@ -1352,7 +1340,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1387,7 +1375,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1430,8 +1418,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); - fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "a.txt"), "file a\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "b.txt"), "file b\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1458,7 +1446,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nfeature-branch\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1518,7 +1506,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom-feature\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1581,16 +1569,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* initRepo(repoDir); const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); + }).pipe(Effect.flip); - expect(errorMessage).toContain("no changes to commit"); + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "runFeatureBranchStep", + cwd: repoDir, + }); + expect(error.message).toContain("no changes to commit"); }), ); @@ -1601,7 +1591,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1630,7 +1620,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -1701,7 +1691,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "push-only.txt"), "push only\n"); yield* runGit(repoDir, ["add", "push-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); @@ -1729,11 +1719,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "push-dirty.txt"), "push dirty\n"); yield* runGit(repoDir, ["add", "push-dirty.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); - fs.mkdirSync(path.join(repoDir, ".vercel")); - fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n"); + NodeFS.mkdirSync(NodePath.join(repoDir, ".vercel")); + NodeFS.writeFileSync(NodePath.join(repoDir, ".vercel", "project.json"), "{}\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1764,7 +1754,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); @@ -1809,7 +1799,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); - fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "provider-fallback.txt"), "fallback\n"); yield* runGit(repoDir, ["add", "provider-fallback.txt"]); yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); const remoteDir = yield* createBareRemote(); @@ -1986,7 +1976,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); @@ -2204,7 +2194,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); @@ -2243,6 +2233,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("generates PR content against the remote base when the local base is stale", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]); + + const peerDir = yield* makeTempDir("t3code-git-peer-"); + yield* runGit(peerDir, ["clone", remoteDir, "."]); + yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]); + yield* runGit(peerDir, ["config", "user.name", "Peer User"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "remote.txt"), "remote\n"); + yield* runGit(peerDir, ["add", "remote.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, [ + "checkout", + "--no-track", + "-b", + "feature/remote-base", + "origin/main", + ]); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + yield* runGit(repoDir, ["add", "feature.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); + yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + + let generatedCommitSummary = ""; + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: ["[]", "[]"], + }, + textGeneration: { + generatePrContent: (input) => { + generatedCommitSummary = input.commitSummary; + return Effect.succeed({ title: "Feature PR", body: "Feature body" }); + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(generatedCommitSummary).toContain("Feature commit"); + expect(generatedCommitSummary).not.toContain("Remote base commit"); + }), + ); + it.effect( "creates a new PR instead of reusing an unrelated fork PR with the same head branch", () => @@ -2252,7 +2298,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); @@ -2324,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); @@ -2417,9 +2463,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), }), }, }); @@ -2446,9 +2493,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + failWith: new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not authenticated"), }), }, }); @@ -2504,7 +2552,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); - fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "local.txt"), "local\n"); yield* runGit(repoDir, ["add", "local.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); @@ -2545,7 +2593,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); - fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "upstream.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "upstream.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); @@ -2603,7 +2651,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); - fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "no-head-repo.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "no-head-repo.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); @@ -2650,7 +2698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); - fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "worktree.txt"), "worktree\n"); yield* runGit(repoDir, ["add", "worktree.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); @@ -2678,7 +2726,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch).toBe("feature/pr-worktree"); expect(result.worktreePath).not.toBeNull(); - expect(fs.existsSync(result.worktreePath as string)).toBe(true); + expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); const worktreeBranch = (yield* runGit(result.worktreePath as string, [ "branch", "--show-current", @@ -2754,14 +2802,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); - fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "setup.txt"), "setup\n"); yield* runGit(repoDir, ["add", "setup.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); yield* runGit(repoDir, ["checkout", "main"]); - const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -2809,7 +2857,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); - fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork.txt"), "fork\n"); yield* runGit(repoDir, ["add", "fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); @@ -2871,7 +2919,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); - fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-fork.txt"), "local fork\n"); yield* runGit(repoDir, ["add", "local-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); @@ -2924,7 +2972,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); - fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "derived-fork.txt"), "derived fork\n"); yield* runGit(repoDir, ["add", "derived-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); yield* runGit(repoDir, [ @@ -2976,14 +3024,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); - fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "existing.txt"), "existing\n"); yield* runGit(repoDir, ["add", "existing.txt"]); yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-existing-${path.basename(repoDir)}`); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-existing-${NodePath.basename(repoDir)}`, + ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); - const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -3011,8 +3063,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { threadId: asThreadId("thread-pr-existing-worktree"), }); - expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( - fs.realpathSync.native(worktreePath), + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); expect(setupCalls).toHaveLength(0); @@ -3031,7 +3083,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main.txt"), "fork main\n"); yield* runGit(repoDir, ["add", "fork-main.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); @@ -3091,7 +3143,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main-second.txt"), "fork main second\n"); yield* runGit(repoDir, ["add", "fork-main-second.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); @@ -3149,12 +3201,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); - fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-fork.txt"), "reused fork\n"); yield* runGit(repoDir, ["add", "reused-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${path.basename(repoDir)}`); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-fork-${NodePath.basename(repoDir)}`, + ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); @@ -3186,8 +3242,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { mode: "worktree", }); - expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( - fs.realpathSync.native(worktreePath), + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), ); expect( (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), @@ -3203,7 +3259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); - fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "setup-failure.txt"), "setup failure\n"); yield* runGit(repoDir, ["add", "setup-failure.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); @@ -3222,8 +3278,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, setupScriptRunner: { - runForThread: () => - Effect.fail(new ProjectSetupScriptRunnerError({ message: "terminal start failed" })), + runForThread: (input) => + Effect.fail( + new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + operation: "openTerminal", + cause: new Error("terminal start failed"), + }), + ), }, }); @@ -3236,7 +3299,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch).toBe("feature/pr-setup-failure"); expect(result.worktreePath).not.toBeNull(); - expect(fs.existsSync(result.worktreePath as string)).toBe(true); + expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); }), ); @@ -3276,9 +3339,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), + NodeFS.writeFileSync(NodePath.join(repoDir, "hooked.txt"), "hooked\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', { mode: 0o755 }, ); @@ -3339,9 +3402,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), + NodeFS.writeFileSync(NodePath.join(repoDir, "hook-failure.txt"), "broken\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', { mode: 0o755 }, ); @@ -3369,13 +3432,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.map((error) => error.message), ); - expect(errorMessage).toContain("hook: fail"); + expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); + expect(errorMessage).not.toContain("hook: fail"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "hook_started", hookName: "pre-commit", }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: fail", + }), expect.objectContaining({ kind: "action_failed", phase: "commit", @@ -3392,7 +3460,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "pr only\n"); yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..5df4862b409 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,8 +1,9 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -15,7 +16,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( @@ -30,6 +31,27 @@ afterEach(() => { }); describe("GitHubCli.layer", () => { + it("does not classify a missing cwd as an unavailable gh executable", () => { + const context = { command: "gh", cwd: "/repo" } as const; + const missingCwd = new VcsProcessSpawnError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: context.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "access", + pathOrDescriptor: context.cwd, + }), + }); + + const commandFailure = GitHubCli.fromVcsError(context, missingCwd); + + assert.equal(commandFailure._tag, "GitHubCliCommandError"); + assert.strictEqual(commandFailure.cause, missingCwd); + assert.notProperty(commandFailure, "operation"); + }); + it.effect("parses pull request view output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -269,18 +291,16 @@ describe("GitHubCli.layer", () => { it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh pr view", - cwd: "/repo", - exitCode: 1, - detail: - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + failureKind: "not-found", + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); const gh = yield* GitHubCli.GitHubCli; const error = yield* gh @@ -291,6 +311,11 @@ describe("GitHubCli.layer", () => { .pipe(Effect.flip); assert.equal(error.message.includes("Pull request not found"), true); + assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); + assert.strictEqual(error.command, "gh"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d6c858c28bd..bf3f27378b5 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,9 +1,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { TrimmedNonEmptyString, @@ -12,154 +12,249 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, +} from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { +const gitHubCliFailureFields = { + command: Schema.Literal("gh"), + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubCliUnavailableError extends Schema.TaggedErrorClass()( + "GitHubCliUnavailableError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI (`gh`) is required but not available on PATH."; + } + override get message(): string { - return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + return `GitHub CLI failed in execute: ${this.detail}`; } } -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state?: "open" | "closed" | "merged"; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} +export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitHubCliAuthenticationError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; + } -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } } -export interface GitHubCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; +export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubPullRequestNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; +export class GitHubCliCommandError extends Schema.TaggedErrorClass()( + "GitHubCliCommandError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI command failed."; + } - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; +const gitHubCliDecodeFields = { + command: Schema.Literal("gh"), + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubPullRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid PR list JSON."; + } - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; + override get message(): string { + return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; + } +} - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; +export class GitHubChangeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubChangeRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid change request JSON."; + } - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; + override get message(): string { + return `GitHub CLI failed in listChangeRequests: ${this.detail}`; + } } -export class GitHubCli extends Context.Service()( - "t3/sourceControl/GitHubCli", -) {} - -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); +export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request JSON."; } - return String(error); + override get message(): string { + return `GitHub CLI failed in getPullRequest: ${this.detail}`; + } } -function normalizeGitHubCliError( - operation: "execute" | "stdout", - error: VcsError | unknown, -): GitHubCliError { - const text = errorText(error); - const lower = text.toLowerCase(); - - if (lower.includes("command not found: gh") || lower.includes("enoent")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); +export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubRepositoryDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository JSON."; } - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); + override get message(): string { + return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; } +} +export const GitHubCliError = Schema.Union([ + GitHubCliUnavailableError, + GitHubCliAuthenticationError, + GitHubPullRequestNotFoundError, + GitHubCliCommandError, + GitHubPullRequestListDecodeError, + GitHubChangeRequestListDecodeError, + GitHubPullRequestDecodeError, + GitHubRepositoryDecodeError, +]); +export type GitHubCliError = typeof GitHubCliError.Type; + +export const isGitHubCliError = Schema.is(GitHubCliError); + +export function fromVcsError( + context: { + readonly command: "gh"; + readonly cwd: string; + }, + error: VcsError, +): GitHubCliError { if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" && + error.cause.reason.module === "ChildProcess" && + error.cause.reason.method === "spawn" ) { - return new GitHubCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); + return new GitHubCliUnavailableError({ ...context, cause: error }); } - return new GitHubCliError({ - operation, - detail: text, - cause: error, - }); + if (error._tag === "VcsProcessExitError") { + if (error.failureKind === "authentication") { + return new GitHubCliAuthenticationError({ ...context, cause: error }); + } + if (error.failureKind === "not-found") { + return new GitHubPullRequestNotFoundError({ ...context, cause: error }); + } + } + + return new GitHubCliCommandError({ ...context, cause: error }); } +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export class GitHubCli extends Context.Service< + GitHubCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/GitHubCli") {} + const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); +const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), +); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, @@ -208,28 +303,10 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } -function decodeGitHubJson( - raw: string, - schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", - invalidDetail: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitHubCliError({ - operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, - }), - ), - ); -} - -export const make = Effect.fn("makeGitHubCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitHubCliShape["execute"] = (input) => + const execute: GitHubCli["Service"]["execute"] = (input) => process .run({ operation: "GitHubCli.execute", @@ -238,7 +315,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); return GitHubCli.of({ execute, @@ -262,13 +339,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + new GitHubPullRequestListDecodeError({ + command: "gh", + cwd: input.cwd, cause: decoded.failure, }), ); @@ -294,13 +371,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + new GitHubPullRequestDecodeError({ + command: "gh", + cwd: input.cwd, cause: decoded.failure, }), ); @@ -320,11 +397,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitHub CLI returned invalid repository JSON.", + decodeRawGitHubRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositoryDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -372,4 +453,4 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make()); +export const layer = Layer.effect(GitHubCli, make); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 32fd1a91ce3..9e8a6829566 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -24,8 +24,8 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider(github: Partial) { - return GitHubSourceControlProvider.make().pipe( +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make.pipe( Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), ); } @@ -68,6 +68,47 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); +it.effect("adds safe request context while retaining GitHub CLI causes", () => + Effect.gen(function* () { + const cause = new GitHubCli.GitHubPullRequestNotFoundError({ + command: "gh", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + getPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "github", + operation: "getChangeRequest", + command: "gh", + cwd: "/repo", + reference: "https://github.com/pingdotgg/t3code/pull/42", + detail: "Pull request not found. Check the PR number or URL and try again.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("uses gh json listing for non-open change request state queries", () => Effect.gen(function* () { let executeArgs: ReadonlyArray = []; @@ -139,7 +180,8 @@ it.effect("treats empty non-open change request listing output as no results", ( it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 41329b97f75..b5d5d3a55f8 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; import { SourceControlProviderError, type ChangeRequest, @@ -11,22 +10,15 @@ import { import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; -import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -const isSourceControlProviderError = Schema.is(SourceControlProviderError); - -function providerError( - operation: string, - cause: GitHubCli.GitHubCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "github", - operation, - detail: cause.detail, - cause, - }); -} +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { @@ -50,14 +42,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } -function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { - const output = SourceControlProviderDiscovery.combinedAuthOutput(input); +function parseGitHubAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; if (authenticatedAccount) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "authenticated", account: authenticatedAccount.account, host, @@ -66,7 +58,7 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host: failedAccount?.host, detail: @@ -76,21 +68,17 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth } if (input.exitCode !== 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "Run `gh auth login` to authenticate GitHub CLI.", + detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", }); } - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unknown", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "GitHub CLI auth status could not be parsed.", + detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", }); } @@ -104,12 +92,12 @@ export const discovery = { parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", -} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; +} satisfies SourceControlCliDiscoverySpec; -export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; - const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = + const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { if (input.state === "open") { return github @@ -120,7 +108,20 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); } @@ -147,7 +148,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { if (raw.length === 0) { return Effect.succeed([]); } - return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( @@ -157,20 +158,28 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { })), ) : Effect.fail( - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - detail: "GitHub CLI returned invalid change request JSON.", + new GitHubCli.GitHubChangeRequestListDecodeError({ + command: "gh", + cwd: input.cwd, cause: decoded.failure, }), ), ), ); }), - Effect.mapError((error) => - isSourceControlProviderError(error) - ? error - : providerError("listChangeRequests", error), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), ), ); }; @@ -181,7 +190,20 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => github @@ -192,24 +214,88 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ), getRepositoryCloneUrls: (input) => - github - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + github.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - github - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + github.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - github - .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + github.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => - github - .checkoutPullRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + github.checkoutPullRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); From 9a5c027c14decc30d0e1ea791f7e23ff08d637c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:19:59 -0700 Subject: [PATCH 09/23] [codex] Structure VCS process boundary errors (#3476) Co-authored-by: codex (cherry picked from commit 3c246f56b477e0d7c39f92cfc0a69ca3f6debd4b) --- apps/server/src/sourceControl/GitLabCli.ts | 5 +- apps/server/src/vcs/VcsProcess.test.ts | 138 +++++++++++++++++- apps/server/src/vcs/VcsProcess.ts | 108 +++++++++++---- packages/contracts/src/vcs.ts | 154 ++++++++++++++------- 4 files changed, 320 insertions(+), 85 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 3e3bbe742c1..a2926afd0ef 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -133,7 +133,10 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass new GitLabCliCommandError({ ...context, cause }), - VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index b58d64e435a..675d20cb82c 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -6,7 +6,12 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { TestClock } from "effect/testing"; -import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import { + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; +import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -20,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const provideLive = (effect: Effect.Effect) => effect.pipe(Effect.provide(liveLayer)); +const baseInput = { + operation: "test.process-boundary", + command: "git", + args: ["status", "--short"], + cwd: "/workspace", +} satisfies VcsProcess.VcsProcessInput; + +const captureProcessResult = ( + result: Effect.Effect, +) => + VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: () => result }), + ), + Effect.flatMap((service) => service.run(baseInput)), + Effect.flip, + ); + describe("VcsProcess.run", () => { it.effect("collects stdout", () => Effect.gen(function* () { @@ -61,17 +85,127 @@ describe("VcsProcess.run", () => { it.effect("fails with VcsProcessExitError for non-zero exits by default", () => Effect.gen(function* () { + const secretArgument = "--token=super-secret-token"; + const secretStderr = "remote rejected super-secret-token"; const error = yield* run({ operation: "test.exit", command: "node", - args: ["-e", "process.stderr.write('boom'); process.exit(2)"], + args: [ + "-e", + "process.stderr.write(process.argv[1]); process.exit(2)", + secretStderr, + secretArgument, + ], cwd: process.cwd(), }).pipe(Effect.flip); expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + operation: "test.exit", + command: "node", + argumentCount: 4, + exitCode: 2, + detail: "Process exited with a non-zero status.", + failureKind: "command-failed", + stderrLength: secretStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(secretArgument); + expect(error.message).not.toContain(secretStderr); }).pipe(provideLive), ); + it.effect("classifies authentication failures without retaining stderr", () => + Effect.gen(function* () { + const secretStderr = "authentication failed for token super-secret-token"; + const error = yield* run({ + operation: "test.authentication", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + operation: "test.authentication", + command: "node", + exitCode: 1, + detail: "Authentication failed.", + failureKind: "authentication", + stderrLength: secretStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(secretStderr); + expect(error.message).not.toContain("super-secret-token"); + }).pipe(provideLive), + ); + + it.effect("retains spawn causes without exposing process arguments in the error message", () => + Effect.gen(function* () { + const secretArgument = "--token=super-secret-token"; + const error = yield* run({ + operation: "test.spawn", + command: "definitely-not-a-t3code-executable", + args: [secretArgument], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessSpawnError); + expect(error).toMatchObject({ + operation: "test.spawn", + command: "definitely-not-a-t3code-executable", + argumentCount: 1, + }); + expect(error).toHaveProperty("cause"); + expect(error.message).not.toContain(secretArgument); + }).pipe(provideLive), + ); + + it.effect("preserves real boundary causes without manufacturing structural ones", () => + Effect.gen(function* () { + const cause = new Error("secret stdin failure"); + const error = yield* captureProcessResult( + Effect.fail( + new ProcessRunner.ProcessStdinError({ + command: baseInput.command, + argumentCount: baseInput.args.length, + cwd: baseInput.cwd, + stdinBytes: 47, + cause, + }), + ), + ); + + expect(error).toMatchObject({ + _tag: "VcsProcessStdinWriteError", + operation: baseInput.operation, + stdinBytes: 47, + cause, + }); + expect(error.message).not.toContain(cause.message); + + const missingExitCodeError = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + expect(missingExitCodeError).toMatchObject({ + _tag: "VcsProcessMissingExitCodeError", + operation: baseInput.operation, + command: baseInput.command, + cwd: baseInput.cwd, + argumentCount: baseInput.args.length, + }); + expect(missingExitCodeError).not.toHaveProperty("cause"); + }), + ); + it.effect("returns output when non-zero exits are allowed", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index a4caf7d3230..52db6f9b1fb 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -1,17 +1,21 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - VcsOutputDecodeError, type VcsError, VcsProcessExitError, + type VcsProcessExitFailureKind, + VcsProcessMissingExitCodeError, + VcsProcessOutputLimitError, + VcsProcessOutputReadError, VcsProcessSpawnError, + VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; -import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts"; -import * as Match from "effect/Match"; +import * as ProcessRunner from "../processRunner.ts"; export interface VcsProcessInput { readonly operation: string; @@ -35,31 +39,62 @@ export interface VcsProcessOutput { readonly stderrTruncated: boolean; } -export interface VcsProcessShape { - readonly run: (input: VcsProcessInput) => Effect.Effect; -} - -export class VcsProcess extends Context.Service()( - "t3/vcs/VcsProcess", -) {} +export class VcsProcess extends Context.Service< + VcsProcess, + { + readonly run: (input: VcsProcessInput) => Effect.Effect; + } +>()("t3/vcs/VcsProcess") {} const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -function commandLabel(command: string, args: ReadonlyArray): string { - return [command, ...args].join(" "); -} +const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { + const normalized = stderr.toLowerCase(); -export const make = Effect.fn("makeVcsProcess")(function* () { - const processRunner = yield* ProcessRunner; + if ( + normalized.includes("authentication failed") || + normalized.includes("not logged in") || + normalized.includes("gh auth login") || + normalized.includes("glab auth login") || + normalized.includes("az devops login") || + normalized.includes("please run az login") || + normalized.includes("no oauth token") || + normalized.includes("unauthorized") + ) { + return "authentication"; + } + + if ( + (command === "gh" && + (normalized.includes("could not resolve to a pullrequest") || + normalized.includes("repository.pullrequest") || + normalized.includes("no pull requests found for branch") || + normalized.includes("pull request not found"))) || + (command === "glab" && + (normalized.includes("merge request not found") || + normalized.includes("not found") || + normalized.includes("404"))) || + (command === "az" && + normalized.includes("pull request") && + (normalized.includes("not found") || normalized.includes("does not exist"))) + ) { + return "not-found"; + } + + return "command-failed"; +}; + +export const make = Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { - const label = commandLabel(input.command, input.args); const baseError = { operation: input.operation, - command: label, + command: input.command, cwd: input.cwd, + argumentCount: input.args.length, }; const result = yield* processRunner @@ -82,29 +117,44 @@ export const make = Effect.fn("makeVcsProcess")(function* () { ProcessSpawnError: (error) => VcsProcessSpawnError.fromProcessSpawnError(baseError, error), ProcessOutputLimitError: (error) => - VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), + new VcsProcessOutputLimitError({ + ...baseError, + stream: error.stream, + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }), ProcessTimeoutError: (error) => VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), ProcessStdinError: (error) => - VcsOutputDecodeError.fromProcessStdinError(baseError, error), + new VcsProcessStdinWriteError({ + ...baseError, + stdinBytes: error.stdinBytes, + cause: error.cause, + }), ProcessReadError: (error) => - VcsOutputDecodeError.fromProcessReadError(baseError, error), + new VcsProcessOutputReadError({ + ...baseError, + stream: error.stream, + cause: error.cause, + }), }), ), ); if (result.code === null) { - return yield* VcsOutputDecodeError.missingExitCode(baseError); + return yield* new VcsProcessMissingExitCodeError(baseError); } if (!input.allowNonZeroExit && result.code !== 0) { - return yield* new VcsProcessExitError({ - operation: input.operation, - command: label, - cwd: input.cwd, - exitCode: result.code, - detail: result.stderr.trim() || `${label} exited with code ${result.code}.`, - }); + return yield* VcsProcessExitError.fromProcessExit( + baseError, + { + exitCode: result.code, + stderr: result.stderr, + stderrTruncated: result.stderrTruncated, + }, + classifyNonZeroExit(input.command, result.stderr), + ); } return { @@ -119,4 +169,4 @@ export const make = Effect.fn("makeVcsProcess")(function* () { return VcsProcess.of({ run }); }); -export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive)); +export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 40deeb77da6..c1090f4f39a 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); export type VcsDriverKind = typeof VcsDriverKind.Type; @@ -62,28 +62,28 @@ export interface VcsProcessErrorContext { readonly operation: string; readonly command: string; readonly cwd: string; + readonly argumentCount?: number; } export interface VcsProcessSpawnFailure { readonly cause: unknown; } -export interface VcsProcessStdinFailure { - readonly cause: unknown; -} - -export interface VcsProcessReadFailure { - readonly stream: "stdout" | "stderr" | "exitCode"; - readonly cause: unknown; +export interface VcsProcessTimeoutFailure { + readonly timeoutMs: number; } -export interface VcsProcessOutputLimitFailure { - readonly stream: "stdout" | "stderr"; - readonly maxBytes: number; -} +export const VcsProcessExitFailureKind = Schema.Literals([ + "authentication", + "not-found", + "command-failed", +]); +export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type; -export interface VcsProcessTimeoutFailure { - readonly timeoutMs: number; +export interface VcsProcessExitFailure { + readonly exitCode: number; + readonly stderr: string; + readonly stderrTruncated: boolean; } export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( @@ -92,6 +92,7 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( @@ -128,6 +159,7 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( - "VcsOutputDecodeError", +const VcsProcessBoundaryErrorFields = { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + argumentCount: Schema.optional(NonNegativeInt), +}; + +export class VcsProcessStdinWriteError extends Schema.TaggedErrorClass()( + "VcsProcessStdinWriteError", { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...VcsProcessBoundaryErrorFields, + stdinBytes: NonNegativeInt, + cause: Schema.Defect(), }, ) { override get message(): string { - return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } - - static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: "failed to write process stdin", - cause: error.cause, - }); + return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: - error.stream === "exitCode" - ? "failed to read process exit code" - : `failed to read process ${error.stream}`, - cause: error.cause, - }); +export class VcsProcessOutputReadError extends Schema.TaggedErrorClass()( + "VcsProcessOutputReadError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr", "exitCode"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `VCS process failed to read ${this.stream} in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessOutputLimitError( - context: VcsProcessErrorContext, - error: VcsProcessOutputLimitFailure, - ) { - return new VcsOutputDecodeError({ - ...context, - detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, - }); +export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass()( + "VcsProcessOutputLimitError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr"]), + maxBytes: NonNegativeInt, + observedBytes: NonNegativeInt, + }, +) { + override get message(): string { + return `VCS process ${this.stream} produced ${this.observedBytes} bytes in ${this.operation}: ${this.command} (${this.cwd}), exceeding the ${this.maxBytes} byte limit`; } +} - static missingExitCode(context: VcsProcessErrorContext) { - return new VcsOutputDecodeError({ - ...context, - detail: "process completed without an exit code", - }); +export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass()( + "VcsProcessMissingExitCodeError", + VcsProcessBoundaryErrorFields, +) { + override get message(): string { + return `VCS process completed without an exit code in ${this.operation}: ${this.command} (${this.cwd})`; } } +export const VcsOutputDecodeError = Schema.Union([ + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, +]); +export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type; + export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( "VcsRepositoryDetectionError", { @@ -225,7 +270,10 @@ export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, VcsProcessTimeoutError, - VcsOutputDecodeError, + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, ]); From 69ac9d31888f3175fc63ad7095e4be2410d43209 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:04 -0700 Subject: [PATCH 10/23] [codex] Preserve VCS project config error causes (#3474) Co-authored-by: codex (cherry picked from commit 6155f5cf64e9f26d10f3c912985f8ca09f9c3fef) --- apps/server/src/vcs/VcsProjectConfig.test.ts | 155 +++++++++++++++++++ apps/server/src/vcs/VcsProjectConfig.ts | 139 ++++++++++------- 2 files changed, 241 insertions(+), 53 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index aac4beb7e32..04f7fcffcda 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; @@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { + it("keeps operation context and the original cause on config errors", () => { + const cause = new Error("permission denied"); + const error = new VcsProjectConfig.VcsProjectConfigError({ + operation: "read", + cwd: "/repo/packages/app", + configPath: "/repo/.t3code/vcs.json", + cause, + }); + + assert.equal(error.operation, "read"); + assert.equal(error.cwd, "/repo/packages/app"); + assert.equal(error.configPath, "/repo/.t3code/vcs.json"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); + }); + it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { @@ -53,6 +70,49 @@ describe("VcsProjectConfig", () => { ); }); + it.layer(TestLayer)("continues to parent configs after a candidate inspect failure", (it) => { + it.effect("logs the failed candidate and returns the parent config", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + const cwd = path.join(root, "invalid\0child"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ vcs: { kind: "jj" } }), + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd }); + + assert.equal(kind, "jj"); + const failedCandidate = path.join(cwd, ".t3code", "vcs.json"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to inspect VCS project config at " + failedCandidate + ".", + ); + assert.deepInclude(error, { + operation: "inspect", + cwd, + configPath: failedCandidate, + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + it.layer(TestLayer)("falls back to auto when no config exists", (it) => { it.effect("returns auto", () => Effect.gen(function* () { @@ -67,4 +127,99 @@ describe("VcsProjectConfig", () => { }), ); }); + + it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => { + it.effect("returns auto and logs the failed operation and path", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{not json"); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".", + ); + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + assert.deepInclude(error, { + operation: "decode", + cwd: root, + configPath: path.join(configDir, "vcs.json"), + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when the config path cannot be read", (it) => { + it.effect("retains the read failure context", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configPath = path.join(root, ".t3code", "vcs.json"); + yield* fileSystem.makeDirectory(configPath, { recursive: true }); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal(error.message, "Failed to read VCS project config at " + configPath + "."); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + assert.deepInclude(error, { + operation: "read", + cwd: root, + configPath, + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + `{"vcs":{"kind":"svn"}}`, + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 3e5ee2347ce..6abce9a3ef3 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -2,10 +2,12 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; const ProjectVcsConfig = Schema.Struct({ vcs: Schema.optional( @@ -15,46 +17,54 @@ const ProjectVcsConfig = Schema.Struct({ ), vcsKind: Schema.optional(VcsDriverKind), }); -const isProjectVcsConfig = Schema.is(ProjectVcsConfig); +const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig); +const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson); -interface ProjectVcsConfigFile { - readonly vcs?: - | { - readonly kind?: VcsDriverKindType | undefined; - } - | undefined; - readonly vcsKind?: VcsDriverKindType | undefined; -} +type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; export interface VcsProjectConfigResolveInput { readonly cwd: string; readonly requestedKind?: VcsDriverKindType | "auto"; } -export interface VcsProjectConfigShape { - readonly resolveKind: ( - input: VcsProjectConfigResolveInput, - ) => Effect.Effect; +export class VcsProjectConfigError extends Schema.TaggedErrorClass()( + "VcsProjectConfigError", + { + operation: Schema.Literals(["inspect", "read", "decode"]), + cwd: Schema.String, + configPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} VCS project config at ${this.configPath}.`; + } } -export class VcsProjectConfig extends Context.Service()( - "t3/vcs/VcsProjectConfig", -) {} +export class VcsProjectConfig extends Context.Service< + VcsProjectConfig, + { + readonly resolveKind: ( + input: VcsProjectConfigResolveInput, + ) => Effect.Effect; + } +>()("t3/vcs/VcsProjectConfig") {} function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" { return config.vcs?.kind ?? config.vcsKind ?? "auto"; } -function parseConfig(raw: string): ProjectVcsConfigFile | null { - try { - const parsed = JSON.parse(raw) as unknown; - return isProjectVcsConfig(parsed) ? parsed : null; - } catch { - return null; - } -} +const logVcsProjectConfigError = (error: VcsProjectConfigError) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + cwd: error.cwd, + configPath: error.configPath, + errorTag: error._tag, + }), + ); -export const make = Effect.fn("makeVcsProjectConfig")(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -62,57 +72,80 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { let current = cwd; while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); - if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - return candidate; + const exists = yield* fileSystem.exists(candidate).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "inspect", + cwd, + configPath: candidate, + cause, + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => logVcsProjectConfigError(error).pipe(Effect.as(false)), + }), + ); + if (exists) { + return Option.some(candidate); } const parent = path.dirname(current); if (parent === current) { - return null; + return Option.none(); } current = parent; } }); const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( + cwd: string, configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( - Effect.catch((error) => - Effect.logWarning("failed to read VCS project config", { - configPath, - error, - }).pipe(Effect.as(null)), + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "read", + cwd, + configPath, + cause, + }), + ), + ); + const parsed = yield* decodeProjectVcsConfigJson(raw).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "decode", + cwd, + configPath, + cause, + }), ), ); - if (raw === null) { - return "auto" as const; - } - - const parsed = parseConfig(raw); - if (parsed === null) { - yield* Effect.logWarning("invalid VCS project config", { - configPath, - }); - return "auto" as const; - } - return configuredKind(parsed); }); - const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( + const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn( "VcsProjectConfig.resolveKind", )(function* (input) { if (input.requestedKind !== undefined && input.requestedKind !== "auto") { return input.requestedKind; } - const configPath = yield* findConfigPath(input.cwd); - if (configPath === null) { - return "auto"; - } - - return yield* readConfiguredKind(configPath); + return yield* findConfigPath(input.cwd).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed("auto" as const), + onSome: (configPath) => readConfiguredKind(input.cwd, configPath), + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => + logVcsProjectConfigError(error).pipe(Effect.as("auto" as const)), + }), + ); }); return VcsProjectConfig.of({ @@ -120,4 +153,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { }); }); -export const layer = Layer.effect(VcsProjectConfig, make()); +export const layer = Layer.effect(VcsProjectConfig, make); From d2853c8a3c8217d6877751b90a2a1f55a783b145 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:09 -0700 Subject: [PATCH 11/23] [codex] Structure OpenCode text generation failures (#3472) Co-authored-by: codex (cherry picked from commit 8c1605b38327176bae94c33bbc6de86e8b09fe02) --- .../OpenCodeTextGeneration.test.ts | 171 ++++-- .../textGeneration/OpenCodeTextGeneration.ts | 504 ++++++++++++------ 2 files changed, 463 insertions(+), 212 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index ba1f3a0435c..558a8663b64 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,4 +1,4 @@ -import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -9,14 +9,10 @@ import * as TestClock from "effect/testing/TestClock"; import * as NetService from "@t3tools/shared/Net"; import { beforeEach, expect } from "vite-plus/test"; -import { ServerConfig } from "../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../provider/opencodeRuntime.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; const runtimeMock = { state: { @@ -24,8 +20,11 @@ const runtimeMock = { promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], + sessionCreateError: undefined as unknown, + sessionResult: undefined as { data?: { id: string } } | undefined, + promptRequestError: undefined as unknown, promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | { data?: { info?: { error?: unknown }; parts?: Array } } | undefined, }, reset() { @@ -33,11 +32,14 @@ const runtimeMock = { this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.sessionCreateError = undefined; + this.state.sessionResult = undefined; + this.state.promptRequestError = undefined; this.state.promptResult = undefined; }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ binaryPath }) => Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; @@ -65,12 +67,20 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => ({ data: { id: `${baseUrl}/session` } }), + create: async () => { + if (runtimeMock.state.sessionCreateError !== undefined) { + throw runtimeMock.state.sessionCreateError; + } + return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; + }, prompt: async () => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + if (runtimeMock.state.promptRequestError !== undefined) { + throw runtimeMock.state.promptRequestError; + } return ( runtimeMock.state.promptResult ?? { data: { @@ -88,10 +98,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); }, }, - }) as unknown as ReturnType, + }) as unknown as ReturnType, loadOpenCodeInventory: () => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "loadOpenCodeInventory", detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", cause: null, @@ -103,15 +113,22 @@ const DEFAULT_TEST_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("opencode"), model: "openai/gpt-5", }; +const DEFAULT_COMMIT_MESSAGE_INPUT = { + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, +}; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; const OpenCodeTextGenerationTestLayer = Layer.succeed( - OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-test-", }), ), @@ -120,11 +137,11 @@ const OpenCodeTextGenerationTestLayer = Layer.succeed( ); const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( - OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-existing-server-test-", }), ), @@ -143,10 +160,10 @@ const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ function withOpenCodeTextGeneration( settings: OpenCodeSettings, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const textGeneration = yield* makeOpenCodeTextGeneration(settings); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -225,22 +242,99 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); - it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + it.effect("preserves the SDK cause when session creation fails", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - runtimeMock.state.promptResult = { data: {} }; + const sdkCause = new Error("session endpoint unavailable"); + runtimeMock.state.sessionCreateError = sdkCause; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode session.create request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("reports a missing session payload without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = {}; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("preserves the SDK cause and request context when prompting fails", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("prompt endpoint unavailable"); + runtimeMock.state.promptRequestError = sdkCause; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.prompt request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("returns a typed empty-output error for malformed and blank response parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [null, { type: "tool" }, { type: "text", text: " " }], + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("OpenCode returned empty output."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationEmptyOutputError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + responsePartCount: 3, + textPartCount: 1, + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); @@ -293,16 +387,21 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { }; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("Model did not produce structured output"); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptResponseError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + providerErrorName: "StructuredOutputError", + providerMessage: "Model did not produce structured output", + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 815508ccc26..1f94f970692 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { + NonNegativeInt, TextGenerationError, type ChatAttachment, type ModelSelection, @@ -15,7 +16,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { buildBranchNamePrompt, @@ -23,28 +24,116 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; -import { - OpenCodeRuntime, - type OpenCodeServerConnection, - type OpenCodeServerProcess, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - toOpenCodeFileParts, -} from "../provider/opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; -function getOpenCodePromptErrorMessage(error: unknown): string | null { +const OpenCodeTextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); + +type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; + +const openCodeTextGenerationErrorContext = { + operation: OpenCodeTextGenerationOperation, + cwd: Schema.String, +}; + +export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionRequestError", + { + ...openCodeTextGenerationErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; + } +} + +export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionPayloadError", + openCodeTextGenerationErrorContext, +) { + override get message(): string { + return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; + } +} + +const openCodePromptErrorContext = { + ...openCodeTextGenerationErrorContext, + sessionId: Schema.String, + providerId: Schema.String, + modelId: Schema.String, +}; + +export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptRequestError", + { + ...openCodePromptErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; + } +} + +export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptResponseError", + { + ...openCodePromptErrorContext, + providerErrorName: Schema.optional(Schema.String), + providerMessage: Schema.String, + }, +) { + override get message(): string { + const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; + return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; + } +} + +export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationEmptyOutputError", + { + ...openCodePromptErrorContext, + responsePartCount: NonNegativeInt, + textPartCount: NonNegativeInt, + }, +) { + override get message(): string { + return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; + } +} + +interface OpenCodePromptFailure { + readonly name?: string; + readonly message: string; +} + +interface OpenCodeTextPart { + readonly type: "text"; + readonly text: string; +} + +function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { if (!error || typeof error !== "object") { return null; } + const name = + "name" in error && typeof error.name === "string" && error.name.trim().length > 0 + ? error.name.trim() + : undefined; const message = "data" in error && error.data && @@ -54,37 +143,40 @@ function getOpenCodePromptErrorMessage(error: unknown): string | null { ? error.data.message.trim() : ""; if (message.length > 0) { - return message; + return { + ...(name ? { name } : {}), + message, + }; } - if ("name" in error && typeof error.name === "string") { - const name = error.name.trim(); - return name.length > 0 ? name : null; + if (name) { + return { name, message: name }; } return null; } +function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { + return ( + part !== null && + typeof part === "object" && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ); +} + function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { return (parts ?? []) - .flatMap((part) => { - if (!part || typeof part !== "object") { - return []; - } - if (!("type" in part) || part.type !== "text") { - return []; - } - if (!("text" in part) || typeof part.text !== "string") { - return []; - } - return [part.text]; - }) + .filter(isOpenCodeTextPart) + .map((part) => part.text) .join("") .trim(); } interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeServerProcess | null; + server: OpenCodeRuntime.OpenCodeServerProcess | null; /** * The scope that owns the shared server's lifetime. Closing this scope * terminates the OpenCode child process and interrupts any fibers the @@ -101,8 +193,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" openCodeSettings: OpenCodeSettings, environment?: NodeJS.ProcessEnv, ) { - const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; + const serverConfig = yield* ServerConfig.ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), @@ -135,7 +227,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeServerProcess, + server: OpenCodeRuntime.OpenCodeServerProcess, ) { yield* cancelIdleCloseFiber(); const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( @@ -217,7 +309,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" (cause) => new TextGenerationError({ operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), ), @@ -240,7 +332,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }), ); - const releaseSharedServer = (server: OpenCodeServerProcess) => + const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => sharedServerMutex.withPermit( Effect.gen(function* () { if (sharedServerState.server !== server) { @@ -267,18 +359,14 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ); const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; + readonly operation: OpenCodeTextGenerationOperation; readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; readonly modelSelection: ModelSelection; readonly attachments?: ReadonlyArray | undefined; }) { - const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); if (!parsedModel) { return yield* new TextGenerationError({ operation: input.operation, @@ -286,60 +374,127 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); } - const fileParts = toOpenCodeFileParts({ + const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); - const runAgainstServer = (server: Pick) => - Effect.tryPromise({ - try: async () => { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( + function* (server: Pick) { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const session = yield* Effect.tryPromise({ + try: () => + client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }), + catch: (cause) => + new OpenCodeTextGenerationSessionRequestError({ + operation: input.operation, + cwd: input.cwd, + cause, + }), + }); + if (!session.data) { + return yield* new OpenCodeTextGenerationSessionPayloadError({ + operation: input.operation, + cwd: input.cwd, }); - const session = await client.session.create({ - title: `more Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const promptContext = { + operation: input.operation, + cwd: input.cwd, + sessionId: session.data.id, + providerId: parsedModel.providerID, + modelId: parsedModel.modelID, + }; + + const result = yield* Effect.tryPromise({ + try: () => + client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + if (promptFailure) { + return yield* new OpenCodeTextGenerationPromptResponseError({ + ...promptContext, + ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), + providerMessage: promptFailure.message, }); - if (!session.data) { - throw new Error("OpenCode session.create returned no session payload."); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue( - input.modelSelection, - "variant", - ); - - const result = await client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], + } + const responseParts = result.data?.parts ?? []; + const rawText = getOpenCodeTextResponse(responseParts); + if (rawText.length === 0) { + return yield* new OpenCodeTextGenerationEmptyOutputError({ + ...promptContext, + responsePartCount: responseParts.length, + textPartCount: responseParts.filter(isOpenCodeTextPart).length, }); - const info = result.data?.info; - const errorMessage = getOpenCodePromptErrorMessage(info?.error); - if (errorMessage) { - throw new Error(errorMessage); - } - const rawText = getOpenCodeTextResponse(result.data?.parts); - if (rawText.length === 0) { - throw new Error("OpenCode returned empty output."); - } - return rawText; - }, - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - }); + } + return rawText; + }, + Effect.catchTags({ + OpenCodeTextGenerationSessionRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create request failed.", + cause, + }), + ), + OpenCodeTextGenerationSessionPayloadError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create returned no session payload.", + cause, + }), + ), + OpenCodeTextGenerationPromptRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.prompt request failed.", + cause, + }), + ), + OpenCodeTextGenerationPromptResponseError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: cause.providerMessage, + cause, + }), + ), + OpenCodeTextGenerationEmptyOutputError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode returned empty output.", + cause, + }), + ), + }), + ); const rawOutput = openCodeSettings.serverUrl.length > 0 @@ -355,114 +510,111 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + }), ); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "OpenCodeTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OpenCodeTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OpenCodeTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "OpenCodeTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - const generated = yield* runOpenCodeJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OpenCodeTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "OpenCodeTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "OpenCodeTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, + return { + title: sanitizeThreadTitle(generated.title), + }; }); - const generated = yield* runOpenCodeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, - }); - - return { - title: sanitizeThreadTitle(generated.title), - }; - }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); From 982fa174ebf65573a404e259ad618c9e736babc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:12 -0700 Subject: [PATCH 12/23] [codex] Structure primary auth validation failures (#3471) Co-authored-by: codex (cherry picked from commit 2a29de7502cdd02686bda7d12fb2671060fd1979) --- apps/web/src/authBootstrap.test.ts | 163 ++++++++---- apps/web/src/environments/primary/auth.ts | 246 +++++++++++-------- apps/web/src/environments/primary/context.ts | 41 +--- apps/web/src/environments/primary/index.ts | 14 +- 4 files changed, 285 insertions(+), 179 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 2e30cd59dd9..ced16c15f4e 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,5 +1,4 @@ import { - AuthSessionState as AuthSessionStateSchema, EnvironmentAuthInvalidError, type AuthBrowserSessionResult, type AuthCreatePairingCredentialInput, @@ -8,10 +7,11 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; +import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; +import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./lib/runtime"; type TestWindow = { location: URL; @@ -36,8 +36,6 @@ const DESKTOP_AUTH = { } as const; const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z"); -const encodeAuthSessionState = Schema.encodeSync(AuthSessionStateSchema); - const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({ authenticated: false, auth, @@ -68,11 +66,23 @@ function installTestBrowser(url: string) { }; vi.stubGlobal("window", testWindow); - vi.stubGlobal("document", { title: "more Code" }); + vi.stubGlobal("document", { title: "T3 Code" }); return testWindow; } +function installDesktopBootstrap() { + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; +} + function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -117,6 +127,7 @@ describe("resolveInitialServerAuthGateState", () => { disposeHttpTest = undefined; const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); __resetServerAuthBootstrapForTests(); + __setPrimaryHttpRunnerForTests(); vi.unstubAllEnvs(); vi.useRealTimers(); vi.restoreAllMocks(); @@ -132,15 +143,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -220,18 +223,22 @@ describe("resolveInitialServerAuthGateState", () => { it("retries transient auth session bootstrap failures after restart", async () => { vi.useFakeTimers(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify(encodeAuthSessionState(unauthenticatedSession(LOOPBACK_AUTH))), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - vi.stubGlobal("fetch", fetchMock); + let attempts = 0; + const request = HttpClientRequest.get("http://localhost/api/auth/session"); + const response = HttpClientResponse.fromWeb( + request, + new Response("Bad Gateway", { status: 502 }), + ); + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + if (attempts < 4) { + throw new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); + } + return unauthenticatedSession(LOOPBACK_AUTH) as A; + }; + __setPrimaryHttpRunnerForTests(runner); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -242,7 +249,7 @@ describe("resolveInitialServerAuthGateState", () => { status: "requires-auth", auth: LOOPBACK_AUTH, }); - expect(fetchMock).toHaveBeenCalledTimes(4); + expect(attempts).toBe(4); }); it("takes a pairing token from the location hash and strips it immediately", async () => { @@ -286,26 +293,74 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("rejects a blank pairing token with a structured validation error", async () => { + const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const error = await submitServerAuthCredential(" ").then( + () => null, + (failure: unknown) => failure, + ); + + expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRequiredError", + providedLength: 3, + message: "Enter a pairing token to continue.", + }); + }); + it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); const testApi = await installAuthApi({ - browserSession: () => - Effect.fail( - new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }), - ), + browserSession: () => Effect.fail(cause), }); - const { submitServerAuthCredential } = await import("./environments/primary"); + const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = + await import("./environments/primary"); - await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( - "Invalid pairing token. Check the token and try again.", + const error = await submitServerAuthCredential("bad-token").then( + () => null, + (failure: unknown) => failure, ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + providedLength: 9, + message: "Invalid pairing token. Check the token and try again.", + }); + expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); + if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { + throw new Error("Expected a structured rejected pairing credential error."); + } + expect(error.cause).toMatchObject({ + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); + it("derives primary request messages from structural request context", async () => { + const cause = new Error("private transport detail"); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-pairing-links", + cause, + }); + + expect(error.status).toBe(500); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Primary environment request failed during list-pairing-links (HTTP 500).", + ); + expect(error.message).not.toContain(cause.message); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const nextSession = sequence( @@ -318,15 +373,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -337,6 +384,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(3); }); + it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { + vi.useFakeTimers(); + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Timed out waiting for authenticated session after bootstrap.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("memoizes the authenticated gate state after the first successful read", async () => { const testApi = await installAuthApi({ session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f6f07dbb303..96814b92b79 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,15 +21,100 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; -import * as Data from "effect/Data"; -import * as Predicate from "effect/Predicate"; - -export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ - readonly message: string; - readonly status: number; -}> {} -const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => - Predicate.isTagged(u, "BootstrapHttpError"); + +const PrimaryEnvironmentRequestOperation = Schema.Literals([ + "fetch-session-state", + "exchange-bootstrap-credential", + "fetch-environment-descriptor", + "create-pairing-credential", + "list-pairing-links", + "revoke-pairing-link", + "list-client-sessions", + "revoke-client-session", + "revoke-other-client-sessions", +]); +type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; + +export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentRequestError", + { + operation: PrimaryEnvironmentRequestOperation, + status: Schema.Number, + pairingLinkId: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly operation: PrimaryEnvironmentRequestOperation; + readonly cause: unknown; + readonly pairingLinkId?: string; + readonly sessionId?: string; + }): PrimaryEnvironmentRequestError { + const status = readHttpApiStatus(input.cause) ?? 500; + return new PrimaryEnvironmentRequestError({ + operation: input.operation, + status, + ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), + ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), + cause: input.cause, + }); + } + + override get message(): string { + return `Primary environment request failed during ${this.operation} (HTTP ${this.status}).`; + } +} + +export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); + +export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRejectedError", + { + providedLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid pairing token. Check the token and try again."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( + PrimaryEnvironmentPairingCredentialRejectedError, +); + +export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentAuthSessionTimeoutError", + { + timeoutMs: Schema.Number, + elapsedMs: Schema.Number, + }, +) { + override get message(): string { + return "Timed out waiting for authenticated session after bootstrap."; + } +} + +export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( + PrimaryEnvironmentAuthSessionTimeoutError, +); + +export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRequiredError", + { + providedLength: Schema.Number, + }, +) { + override get message(): string { + return "Enter a pairing token to continue."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( + PrimaryEnvironmentPairingCredentialRequiredError, +); + const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -106,10 +191,9 @@ export async function fetchSessionState(): Promise { ), ); } catch (error) { - const status = readHttpApiStatus(error); - throw new BootstrapHttpError({ - message: `Failed to load server auth session state (${status ?? "unknown"}).`, - status: status ?? 500, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, }); } }); @@ -138,42 +222,6 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): } } -function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string { - if (!isEnvironmentHttpCommonError(error)) { - return fallbackMessage; - } - switch (error._tag) { - case "EnvironmentAuthInvalidError": - return error.reason === "missing_credential" - ? "Authentication required." - : "Invalid bootstrap credential."; - case "EnvironmentRequestInvalidError": - return error.reason === "invalid_scope" - ? "Requested token scope is invalid." - : "Requested scope exceeds the bootstrap credential grant."; - case "EnvironmentScopeRequiredError": - return `The authenticated token is missing required scope: ${error.requiredScope}.`; - case "EnvironmentOperationForbiddenError": - return "This operation is not allowed for the current session."; - case "EnvironmentInternalError": - return fallbackMessage; - } -} - -const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ - "Invalid bootstrap credential.", - "Unknown bootstrap credential.", -]); - -function toFriendlyBootstrapErrorMessage(status: number, message: string): string { - const trimmedMessage = message.trim(); - if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { - return "Invalid pairing token. Check the token and try again."; - } - - return trimmedMessage; -} - async function exchangeBootstrapCredential(credential: string): Promise { return retryTransientBootstrap(async () => { try { @@ -183,11 +231,19 @@ async function exchangeBootstrapCredential(credential: string): Promise= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { - throw new Error("Timed out waiting for authenticated session after bootstrap."); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { + throw new PrimaryEnvironmentAuthSessionTimeoutError({ + timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS, + elapsedMs, + }); } await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); @@ -240,7 +300,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (isBootstrapHttpError(error)) { + if (isPrimaryEnvironmentRequestError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } @@ -281,7 +341,9 @@ async function bootstrapServerAuth(): Promise { export async function submitServerAuthCredential(credential: string): Promise { const trimmedCredential = credential.trim(); if (!trimmedCredential) { - throw new Error("Enter a pairing token to continue."); + throw new PrimaryEnvironmentPairingCredentialRequiredError({ + providedLength: credential.length, + }); } resolvedAuthenticatedGateState = null; @@ -310,13 +372,10 @@ export async function createServerPairingCredential(input?: { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "create-pairing-credential", + cause: error, + }); } } @@ -353,13 +412,10 @@ export async function listServerPairingLinks(): Promise { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-pairing-link", + pairingLinkId: id, + cause: error, + }); } } @@ -406,13 +460,10 @@ export async function listServerClientSessions(): Promise< current: clientSession.current, })); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause: error, + }); } } @@ -426,13 +477,11 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-client-session", + sessionId, + cause: error, + }); } } @@ -445,13 +494,10 @@ export async function revokeOtherServerClientSessions(): Promise { ); return result.revokedCount; } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-other-client-sessions", + cause: error, + }); } } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index db4406ecee0..e1021a7feb4 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -2,30 +2,17 @@ import { attachEnvironmentDescriptor, createKnownEnvironment, type KnownEnvironment, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +} from "@t3tools/client-runtime/environment"; +import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { HttpClientError } from "effect/unstable/http"; -import { create } from "zustand"; -import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; +import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; import { readPrimaryEnvironmentTarget } from "./target"; -interface PrimaryEnvironmentBootstrapState { - readonly descriptor: ExecutionEnvironmentDescriptor | null; - readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void; - readonly reset: () => void; -} - -const usePrimaryEnvironmentBootstrapStore = create()((set) => ({ - descriptor: null, - setDescriptor: (descriptor) => set({ descriptor }), - reset: () => set({ descriptor: null }), -})); - +let primaryEnvironmentDescriptor: ExecutionEnvironmentDescriptor | null = null; let primaryEnvironmentDescriptorPromise: Promise | null = null; function createPrimaryKnownEnvironment(input: { @@ -56,13 +43,9 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), ); } catch (error) { - const status = - HttpClientError.isHttpClientError(error) && error.response !== undefined - ? error.response.status - : 500; - throw new BootstrapHttpError({ - message: `Failed to load server environment descriptor (${status}).`, - status, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-environment-descriptor", + cause: error, }); } @@ -72,17 +55,13 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise state.descriptor?.environmentId ?? null); + return primaryEnvironmentDescriptor; } export function writePrimaryEnvironmentDescriptor( descriptor: ExecutionEnvironmentDescriptor | null, ): void { - usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor); + primaryEnvironmentDescriptor = descriptor; } export function getPrimaryKnownEnvironment(): KnownEnvironment | null { @@ -118,7 +97,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise Date: Sat, 20 Jun 2026 20:41:58 -0700 Subject: [PATCH 13/23] [codex] Structure thread archive blocked error (#3451) (cherry picked from commit 0debebafbb7d98a3c77d3e636fa54616a98d26da) --- apps/web/src/hooks/useThreadActions.test.ts | 19 ++ apps/web/src/hooks/useThreadActions.ts | 335 +++++++++++++------- 2 files changed, 233 insertions(+), 121 deletions(-) create mode 100644 apps/web/src/hooks/useThreadActions.test.ts diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts new file mode 100644 index 00000000000..c5385211591 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -0,0 +1,19 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadArchiveBlockedError } from "./useThreadActions"; + +describe("ThreadArchiveBlockedError", () => { + it("keeps the blocked thread context with the fixed message", () => { + const error = new ThreadArchiveBlockedError({ + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }); + + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + }); + expect(error.message).toBe("Cannot archive a running thread."); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 4b9cff0eba2..07655ad30d7 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,39 +1,71 @@ -import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; +import { terminalEnvironment } from "../state/terminal"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; -import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; -import { invalidateSourceControlState } from "../lib/sourceControlActions"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; -import { newCommandId } from "../lib/utils"; import { readLocalApi } from "../localApi"; -import { - selectProjectByRef, - selectThreadByRef, - selectThreadsForEnvironment, - useStore, -} from "../store"; +import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useSettings } from "./useSettings"; -import { isManagedSectionWorkspace } from "../sectionWorkspacePolicy"; +import { useClientSettings } from "./useSettings"; +import { useAtomCommand } from "../state/use-atom-command"; + +export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadArchiveBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Cannot archive a running thread."; + } +} export function useThreadActions() { - const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); - const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); + const closeTerminal = useAtomCommand(terminalEnvironment.close); + const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { + reportFailure: false, + }); + const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { + reportFailure: false, + }); + const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { + reportFailure: false, + }); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); + const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { + reportFailure: false, + }); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { + reportFailure: false, + }); + const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); const router = useRouter(); - const { handleNewThread } = useNewThreadHandler(); + const handleNewThread = useNewThreadHandler(); // Keep a ref so archiveThread can call handleNewThread without appearing in // its dependency array — handleNewThread is inherently unstable (depends on // the projects list) and would otherwise cascade new references into every @@ -42,8 +74,7 @@ export function useThreadActions() { handleNewThreadRef.current = handleNewThread; const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { - const state = useStore.getState(); - const thread = selectThreadByRef(state, target); + const thread = readThreadShell(target); if (!thread) { return null; } @@ -59,65 +90,83 @@ export function useThreadActions() { const archiveThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); - if (!resolved) return; + if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { - throw new Error("Cannot archive a running thread."); + return AsyncResult.failure( + Cause.fail( + new ThreadArchiveBlockedError({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }), + ), + ); } const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveCommand = api.orchestration.dispatchCommand({ - type: "thread.archive", - commandId: newCommandId(), - threadId: threadRef.threadId, + const archiveResult = await archiveThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); + if (archiveResult._tag === "Failure") { + return archiveResult; + } if (shouldNavigateToDraft) { - await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); + const navigationResult = await settlePromise(() => + handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + return archiveResult; } - await archiveCommand; refreshArchivedThreadsForEnvironment(threadRef.environmentId); + return archiveResult; }, - [getCurrentRouteThreadRef, resolveThreadTarget], + [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], ); - const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; - await api.orchestration.dispatchCommand({ - type: "thread.unarchive", - commandId: newCommandId(), - threadId: target.threadId, - }); - refreshArchivedThreadsForEnvironment(target.environmentId); - }, []); + const unarchiveThread = useCallback( + async (target: ScopedThreadRef) => { + const result = await unarchiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Success") { + refreshArchivedThreadsForEnvironment(target.environmentId); + } + return result; + }, + [unarchiveThreadMutation], + ); const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: target.threadId, + const result = await deleteThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, }); - refreshArchivedThreadsForEnvironment(target.environmentId); - return; + if (result._tag === "Success") { + refreshArchivedThreadsForEnvironment(target.environmentId); + } + return result; } const { thread, threadRef } = resolved; - const state = useStore.getState(); - const threads = selectThreadsForEnvironment(state, threadRef.environmentId); - const threadProject = selectProjectByRef(state, { + const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + const threadProject = readProject({ environmentId: threadRef.environmentId, projectId: thread.projectId, }); @@ -134,43 +183,45 @@ export function useThreadActions() { deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; - const orphanedWorktreePath = isManagedSectionWorkspace(threadProject?.kind) - ? null - : getOrphanedWorktreePathForThread(survivingThreads, threadRef.threadId); + const orphanedWorktreePath = getOrphanedWorktreePathForThread( + survivingThreads, + threadRef.threadId, + ); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); - const shouldDeleteWorktree = - canDeleteWorktree && - localApi && - (await localApi.dialogs.confirm( - [ - "This thread is the only one linked to this worktree:", - displayWorktreePath ?? orphanedWorktreePath, - "", - "Delete the worktree too?", - ].join("\n"), - )); - - if (thread.session && thread.session.status !== "closed") { - await api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: threadRef.threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); + let shouldDeleteWorktree = false; + if (canDeleteWorktree && localApi) { + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + "This thread is the only one linked to this worktree:", + displayWorktreePath ?? orphanedWorktreePath, + "", + "Delete the worktree too?", + ].join("\n"), + ), + ); + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + shouldDeleteWorktree = confirmationResult.value; } - try { - await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); - } catch { - // Terminal may already be closed. + if (thread.session && thread.session.status !== "stopped") { + await stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); } + await closeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, deleteHistory: true }, + }); + const deletedThreadIds = deletedIds ?? new Set(); const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToFallback = @@ -182,11 +233,13 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, }); - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadRef.threadId, + const deleteResult = await deleteThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); + if (deleteResult._tag === "Failure") { + return deleteResult; + } refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( @@ -197,44 +250,71 @@ export function useThreadActions() { if (shouldNavigateToFallback) { if (fallbackThreadId) { - const fallbackThread = selectThreadByRef( - useStore.getState(), + const fallbackThread = readThreadShell( scopeThreadRef(threadRef.environmentId, fallbackThreadId), ); if (fallbackThread) { - await router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams( - scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), - ), - replace: true, - }); + const navigationResult = await settlePromise(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), + ), + replace: true, + }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } else { - await router.navigate({ to: "/", replace: true }); + const navigationResult = await settlePromise(() => + router.navigate({ to: "/", replace: true }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } } else { - await router.navigate({ to: "/", replace: true }); + const navigationResult = await settlePromise(() => + router.navigate({ to: "/", replace: true }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { - return; + return deleteResult; } - try { - await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ - cwd: threadProject.cwd, + const removeResult = await removeWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: threadProject.workspaceRoot, path: orphanedWorktreePath, force: true, - }); - await invalidateSourceControlState({ - environmentId: threadRef.environmentId, - }); - } catch (error) { + }, + }); + const refreshResult = + removeResult._tag === "Success" + ? await refreshVcsStatus({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, + }) + : null; + const cleanupFailure = + removeResult._tag === "Failure" + ? removeResult + : refreshResult?._tag === "Failure" + ? refreshResult + : null; + if (cleanupFailure) { + const error = squashAtomCommandFailure(cleanupFailure); const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { threadId: threadRef.threadId, - projectCwd: threadProject.cwd, + projectCwd: threadProject.workspaceRoot, worktreePath: orphanedWorktreePath, error, }); @@ -245,48 +325,61 @@ export function useThreadActions() { description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }), ); + return cleanupFailure; } + return deleteResult; }, [ clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, + closeTerminal, + deleteThreadMutation, getCurrentRouteThreadRef, + refreshVcsStatus, + removeWorktree, router, resolveThreadTarget, sidebarThreadSortOrder, + stopThreadSession, ], ); const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); if (confirmThreadDelete && localApi) { const title = resolved?.thread.title ?? "this thread"; - const confirmed = await localApi.dialogs.confirm( - [ - `Delete thread "${title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + `Delete thread "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), ); - if (!confirmed) { - return; + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + if (!confirmationResult.value) { + return AsyncResult.success(undefined); } } - await deleteThread(target); + return deleteThread(target); }, [confirmThreadDelete, deleteThread, resolveThreadTarget], ); - return { - archiveThread, - unarchiveThread, - deleteThread, - confirmAndDeleteThread, - }; + return useMemo( + () => ({ + archiveThread, + unarchiveThread, + deleteThread, + confirmAndDeleteThread, + }), + [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + ); } From 42a15031c38908dc5ed18ab462c2979f021e78b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:09:05 -0700 Subject: [PATCH 14/23] [codex] Structure theme synchronization failures (#3466) Co-authored-by: codex (cherry picked from commit 1b2f39d018410a9e09c67e2629f6ca1562860e6b) --- apps/web/src/hooks/useTheme.test.ts | 193 +++++++++++++++++++++++ apps/web/src/hooks/useTheme.ts | 227 ++++++++++++++++++++-------- 2 files changed, 356 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/hooks/useTheme.test.ts diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 00000000000..6c814e30165 --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +afterEach(() => { + vi.doUnmock("react"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("theme failure handling", () => { + it("preserves exact storage causes and operation context", async () => { + const readCause = new Error("storage read blocked"); + const writeCause = new Error("storage quota exceeded"); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw readCause; + }, + setItem: () => { + throw writeCause; + }, + }), + }); + + const { readThemePreference, ThemeStorageError, writeThemePreference } = + await import("./useTheme"); + + try { + readThemePreference(); + expect.unreachable("expected the theme read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "t3code:theme", + cause: readCause, + }); + } + + try { + writeThemePreference("dark"); + expect.unreachable("expected the theme write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "t3code:theme", + theme: "dark", + cause: writeCause, + }); + } + }); + + it("falls back during initial theme application and logs only safe attributes", async () => { + const cause = new Error("private browsing storage failure"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw cause; + }, + }), + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + }, + }); + + await expect(import("./useTheme")).resolves.toBeDefined(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to read theme preference for t3code:theme.", + expect.objectContaining({ + operation: "read", + storageKey: "t3code:theme", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + }); + + it("retries a failed storage read only after a relevant storage event", async () => { + const cause = new Error("persistent storage failure"); + const getItem = vi.fn(() => { + throw cause; + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + let readSnapshot: (() => unknown) | undefined; + let subscribeToTheme: ((listener: () => void) => () => void) | undefined; + let storageHandler: ((event: StorageEvent) => void) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribeToTheme = subscribe; + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: createStorage({ getItem }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + + const { useTheme } = await import("./useTheme"); + useTheme(); + readSnapshot?.(); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(1); + expect(errorLog).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToTheme?.(() => undefined); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { + const cause = new Error("desktop IPC unavailable"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const setTheme = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", { desktopBridge: { setTheme } }); + + const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } = + await import("./useTheme"); + + const error = await syncDesktopThemePreference({ setTheme }, "dark").then( + () => undefined, + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(DesktopThemeSyncError); + expect(error).toMatchObject({ theme: "dark", cause }); + + setTheme.mockClear(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + + expect(setTheme).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledWith( + "Failed to sync the dark theme to the desktop shell.", + expect.objectContaining({ + theme: "dark", + errorTag: "DesktopThemeSyncError", + }), + ); + for (const [, attributes] of errorLog.mock.calls) { + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + } + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index df028407a6d..bdaf37f099d 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,51 +1,127 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; -import { DEFAULT_THEME_PALETTE, isThemePalette, type ThemePalette } from "../themePalettes"; -type Theme = "light" | "dark" | "system"; +const ThemePreference = Schema.Literals(["light", "dark", "system"]); +type Theme = typeof ThemePreference.Type; type ThemeSnapshot = { - palette: ThemePalette; theme: Theme; systemDark: boolean; }; +type DesktopThemeBridge = Pick; + const STORAGE_KEY = "t3code:theme"; -const PALETTE_STORAGE_KEY = "t3code:theme-palette"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { - palette: DEFAULT_THEME_PALETTE, theme: "system", systemDark: false, }; const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; +export class ThemeStorageError extends Schema.TaggedErrorClass()( + "ThemeStorageError", + { + operation: Schema.Literals(["read", "write"]), + storageKey: Schema.String, + theme: Schema.optional(ThemePreference), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; + } +} + +export const isThemeStorageError = Schema.is(ThemeStorageError); + +export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( + "DesktopThemeSyncError", + { + theme: ThemePreference, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sync the ${this.theme} theme to the desktop shell.`; + } +} + +export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); + let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; +let lastAppliedTheme: ThemeSnapshot | null = null; +let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { for (const listener of listeners) listener(); } -function hasThemeStorage() { - return typeof window !== "undefined" && typeof localStorage !== "undefined"; -} - function getSystemDark() { - return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(MEDIA_QUERY).matches + ); } -function getStored(): Theme { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; - const raw = localStorage.getItem(STORAGE_KEY); +export function readThemePreference(): Theme { + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; + let raw: string | null; + try { + raw = window.localStorage.getItem(STORAGE_KEY); + } catch (cause) { + throw new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + } if (raw === "light" || raw === "dark" || raw === "system") return raw; return DEFAULT_THEME_SNAPSHOT.theme; } -function getStoredPalette(): ThemePalette { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.palette; - const raw = localStorage.getItem(PALETTE_STORAGE_KEY); - return isThemePalette(raw) ? raw : DEFAULT_THEME_SNAPSHOT.palette; +export function writeThemePreference(theme: Theme): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, theme); + themeStorageReadFailure = null; + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme, + cause, + }); + } +} + +function getStored(): Theme { + if (themeStorageReadFailure !== null) { + return DEFAULT_THEME_SNAPSHOT.theme; + } + try { + return readThemePreference(); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + themeStorageReadFailure = error; + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + ...safeErrorLogAttributes(error), + }); + return DEFAULT_THEME_SNAPSHOT.theme; + } } function ensureThemeColorMetaTag(): HTMLMetaElement { @@ -77,8 +153,7 @@ function normalizeThemeColor(value: string | null | undefined): string | null { function resolveBrowserChromeSurface(): HTMLElement { return ( - document.querySelector("[data-theme-surface]") ?? - document.querySelector("[data-slot='sidebar-inset']") ?? + document.querySelector("main[data-slot='sidebar-inset']") ?? document.querySelector("[data-slot='sidebar-inner']") ?? document.body ); @@ -98,29 +173,44 @@ export function syncBrowserChromeTheme() { ensureThemeColorMetaTag().setAttribute("content", backgroundColor); } -function applyTheme(theme: Theme, palette: ThemePalette, suppressTransitions = false) { +function applyTheme(theme: Theme, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; - const root = document.documentElement; - if (!root || !root.dataset) return; + const systemDark = theme === "system" ? getSystemDark() : false; + if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { + syncDesktopTheme(theme); + return; + } + if (suppressTransitions) { - root.classList.add("no-transitions"); + document.documentElement.classList.add("no-transitions"); } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); - root.classList.toggle("dark", isDark); - root.dataset.themePalette = palette; + const isDark = theme === "dark" || (theme === "system" && systemDark); + document.documentElement.classList.toggle("dark", isDark); + lastAppliedTheme = { theme, systemDark }; syncBrowserChromeTheme(); syncDesktopTheme(theme); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal // oxlint-disable-next-line no-unused-expressions - root.offsetHeight; + document.documentElement.offsetHeight; requestAnimationFrame(() => { - root.classList.remove("no-transitions"); + document.documentElement.classList.remove("no-transitions"); }); } } -function syncDesktopTheme(theme: Theme) { +export async function syncDesktopThemePreference( + bridge: DesktopThemeBridge, + theme: Theme, +): Promise { + try { + await bridge.setTheme(theme); + } catch (cause) { + throw new DesktopThemeSyncError({ theme, cause }); + } +} + +export function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { @@ -128,7 +218,14 @@ function syncDesktopTheme(theme: Theme) { } lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { + void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { + const error = isDesktopThemeSyncError(cause) + ? cause + : new DesktopThemeSyncError({ theme, cause }); + console.error(error.message, { + theme: error.theme, + ...safeErrorLogAttributes(error), + }); if (lastDesktopTheme === theme) { lastDesktopTheme = null; } @@ -136,26 +233,20 @@ function syncDesktopTheme(theme: Theme) { } // Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && hasThemeStorage()) { - applyTheme(getStored(), getStoredPalette()); +if (typeof document !== "undefined" && typeof window !== "undefined") { + applyTheme(getStored()); } function getSnapshot(): ThemeSnapshot { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); - const palette = getStoredPalette(); const systemDark = theme === "system" ? getSystemDark() : false; - if ( - lastSnapshot && - lastSnapshot.theme === theme && - lastSnapshot.palette === palette && - lastSnapshot.systemDark === systemDark - ) { + if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { return lastSnapshot; } - lastSnapshot = { palette, theme, systemDark }; + lastSnapshot = { theme, systemDark }; return lastSnapshot; } @@ -168,17 +259,18 @@ function subscribe(listener: () => void): () => void { listeners.push(listener); // Listen for system preference changes - const mq = window.matchMedia(MEDIA_QUERY); + const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; const handleChange = () => { - if (getStored() === "system") applyTheme("system", getStoredPalette(), true); + if (getStored() === "system") applyTheme("system", true); emitChange(); }; - mq.addEventListener("change", handleChange); + mq?.addEventListener("change", handleChange); // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY || e.key === PALETTE_STORAGE_KEY) { - applyTheme(getStored(), getStoredPalette(), true); + if (e.key === STORAGE_KEY) { + themeStorageReadFailure = null; + applyTheme(getStored(), true); emitChange(); } }; @@ -186,40 +278,47 @@ function subscribe(listener: () => void): () => void { return () => { listeners = listeners.filter((l) => l !== listener); - mq.removeEventListener("change", handleChange); + mq?.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; } export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - const palette = snapshot.palette; const theme = snapshot.theme; const resolvedTheme: "light" | "dark" = theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { - if (!hasThemeStorage()) return; - localStorage.setItem(STORAGE_KEY, next); - applyTheme(next, getStoredPalette(), true); + if (typeof window === "undefined") return; + try { + writeThemePreference(next); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme: next, + cause, + }); + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + theme: next, + ...safeErrorLogAttributes(error), + }); + return; + } + applyTheme(next, true); emitChange(); }, []); - const setPalette = useCallback( - (next: ThemePalette) => { - if (!hasThemeStorage()) return; - localStorage.setItem(PALETTE_STORAGE_KEY, next); - applyTheme(theme, next, true); - emitChange(); - }, - [theme], - ); - // Keep DOM in sync on mount/change useEffect(() => { - applyTheme(theme, palette); - }, [palette, theme]); + applyTheme(theme); + }, [theme]); - return { palette, resolvedTheme, setPalette, setTheme, theme } as const; + return { theme, setTheme, resolvedTheme } as const; } From 79b0d91560a85482b9c983c0491e083de4e3bf8b Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Fri, 26 Jun 2026 01:26:55 -0400 Subject: [PATCH 15/23] Adapt Phase 3C web hardening to fork runtime --- apps/web/src/environments/primary/context.ts | 28 +- apps/web/src/environments/primary/index.ts | 14 +- apps/web/src/hooks/useTheme.ts | 149 +++++---- apps/web/src/hooks/useThreadActions.ts | 323 +++++++------------ 4 files changed, 244 insertions(+), 270 deletions(-) diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index e1021a7feb4..3e62bc876e2 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -2,9 +2,10 @@ import { attachEnvironmentDescriptor, createKnownEnvironment, type KnownEnvironment, -} from "@t3tools/client-runtime/environment"; -import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +} from "@t3tools/client-runtime"; +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { create } from "zustand"; import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; @@ -12,7 +13,18 @@ import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; import { readPrimaryEnvironmentTarget } from "./target"; -let primaryEnvironmentDescriptor: ExecutionEnvironmentDescriptor | null = null; +interface PrimaryEnvironmentBootstrapState { + readonly descriptor: ExecutionEnvironmentDescriptor | null; + readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void; + readonly reset: () => void; +} + +const usePrimaryEnvironmentBootstrapStore = create()((set) => ({ + descriptor: null, + setDescriptor: (descriptor) => set({ descriptor }), + reset: () => set({ descriptor: null }), +})); + let primaryEnvironmentDescriptorPromise: Promise | null = null; function createPrimaryKnownEnvironment(input: { @@ -55,13 +67,17 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise state.descriptor?.environmentId ?? null); } export function writePrimaryEnvironmentDescriptor( descriptor: ExecutionEnvironmentDescriptor | null, ): void { - primaryEnvironmentDescriptor = descriptor; + usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor); } export function getPrimaryKnownEnvironment(): KnownEnvironment | null { @@ -97,7 +113,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise; const STORAGE_KEY = "t3code:theme"; +const PALETTE_STORAGE_KEY = "t3code:theme-palette"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { + palette: DEFAULT_THEME_PALETTE, theme: "system", systemDark: false, }; @@ -61,6 +64,10 @@ function emitChange() { for (const listener of listeners) listener(); } +function hasThemeStorage() { + return typeof window !== "undefined" && typeof localStorage !== "undefined"; +} + function getSystemDark() { return ( typeof window !== "undefined" && @@ -69,6 +76,20 @@ function getSystemDark() { ); } +function themeErrorLogAttributes(error: ThemeStorageError | DesktopThemeSyncError) { + if (isThemeStorageError(error)) { + return { + errorTag: error._tag, + operation: error.operation, + storageKey: error.storageKey, + }; + } + return { + errorTag: error._tag, + theme: error.theme, + }; +} + export function readThemePreference(): Theme { if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; let raw: string | null; @@ -101,6 +122,7 @@ export function writeThemePreference(theme: Theme): void { } function getStored(): Theme { + if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; if (themeStorageReadFailure !== null) { return DEFAULT_THEME_SNAPSHOT.theme; } @@ -115,15 +137,17 @@ function getStored(): Theme { cause, }); themeStorageReadFailure = error; - console.error(error.message, { - operation: error.operation, - storageKey: error.storageKey, - ...safeErrorLogAttributes(error), - }); + console.error(error.message, themeErrorLogAttributes(error)); return DEFAULT_THEME_SNAPSHOT.theme; } } +function getStoredPalette(): ThemePalette { + if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.palette; + const raw = localStorage.getItem(PALETTE_STORAGE_KEY); + return isThemePalette(raw) ? raw : DEFAULT_THEME_SNAPSHOT.palette; +} + function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) { @@ -153,7 +177,8 @@ function normalizeThemeColor(value: string | null | undefined): string | null { function resolveBrowserChromeSurface(): HTMLElement { return ( - document.querySelector("main[data-slot='sidebar-inset']") ?? + document.querySelector("[data-theme-surface]") ?? + document.querySelector("[data-slot='sidebar-inset']") ?? document.querySelector("[data-slot='sidebar-inner']") ?? document.body ); @@ -173,28 +198,35 @@ export function syncBrowserChromeTheme() { ensureThemeColorMetaTag().setAttribute("content", backgroundColor); } -function applyTheme(theme: Theme, suppressTransitions = false) { +function applyTheme(theme: Theme, palette: ThemePalette, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; + const root = document.documentElement; + if (!root || !root.dataset) return; const systemDark = theme === "system" ? getSystemDark() : false; - if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { + if ( + lastAppliedTheme?.theme === theme && + lastAppliedTheme.palette === palette && + lastAppliedTheme.systemDark === systemDark + ) { syncDesktopTheme(theme); return; } if (suppressTransitions) { - document.documentElement.classList.add("no-transitions"); + root.classList.add("no-transitions"); } const isDark = theme === "dark" || (theme === "system" && systemDark); - document.documentElement.classList.toggle("dark", isDark); - lastAppliedTheme = { theme, systemDark }; + root.classList.toggle("dark", isDark); + root.dataset.themePalette = palette; + lastAppliedTheme = { palette, theme, systemDark }; syncBrowserChromeTheme(); syncDesktopTheme(theme); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal // oxlint-disable-next-line no-unused-expressions - document.documentElement.offsetHeight; + root.offsetHeight; requestAnimationFrame(() => { - document.documentElement.classList.remove("no-transitions"); + root.classList.remove("no-transitions"); }); } } @@ -205,7 +237,9 @@ export async function syncDesktopThemePreference( ): Promise { try { await bridge.setTheme(theme); + lastDesktopTheme = theme; } catch (cause) { + lastDesktopTheme = null; throw new DesktopThemeSyncError({ theme, cause }); } } @@ -218,35 +252,38 @@ export function syncDesktopTheme(theme: Theme) { } lastDesktopTheme = theme; - void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { - const error = isDesktopThemeSyncError(cause) - ? cause - : new DesktopThemeSyncError({ theme, cause }); - console.error(error.message, { - theme: error.theme, - ...safeErrorLogAttributes(error), - }); + void syncDesktopThemePreference(bridge, theme).catch((error) => { if (lastDesktopTheme === theme) { lastDesktopTheme = null; } + const structuredError = isDesktopThemeSyncError(error) + ? error + : new DesktopThemeSyncError({ theme, cause: error }); + console.error(structuredError.message, themeErrorLogAttributes(structuredError)); }); } // Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && typeof window !== "undefined") { - applyTheme(getStored()); +if (typeof document !== "undefined" && hasThemeStorage()) { + applyTheme(getStored(), getStoredPalette()); } function getSnapshot(): ThemeSnapshot { - if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; + if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); + const palette = getStoredPalette(); const systemDark = theme === "system" ? getSystemDark() : false; - if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { + if ( + lastSnapshot && + lastSnapshot.theme === theme && + lastSnapshot.palette === palette && + lastSnapshot.systemDark === systemDark + ) { return lastSnapshot; } - lastSnapshot = { theme, systemDark }; + lastSnapshot = { palette, theme, systemDark }; return lastSnapshot; } @@ -259,18 +296,20 @@ function subscribe(listener: () => void): () => void { listeners.push(listener); // Listen for system preference changes - const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; + const mq = window.matchMedia(MEDIA_QUERY); const handleChange = () => { - if (getStored() === "system") applyTheme("system", true); + if (getStored() === "system") applyTheme("system", getStoredPalette(), true); emitChange(); }; - mq?.addEventListener("change", handleChange); + mq.addEventListener("change", handleChange); // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) { - themeStorageReadFailure = null; - applyTheme(getStored(), true); + if (e.key === STORAGE_KEY || e.key === PALETTE_STORAGE_KEY) { + if (e.key === STORAGE_KEY) { + themeStorageReadFailure = null; + } + applyTheme(getStored(), getStoredPalette(), true); emitChange(); } }; @@ -278,47 +317,47 @@ function subscribe(listener: () => void): () => void { return () => { listeners = listeners.filter((l) => l !== listener); - mq?.removeEventListener("change", handleChange); + mq.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; } export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const palette = snapshot.palette; const theme = snapshot.theme; const resolvedTheme: "light" | "dark" = theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { - if (typeof window === "undefined") return; + if (!hasThemeStorage()) return; try { writeThemePreference(next); - } catch (cause) { - const error = isThemeStorageError(cause) - ? cause - : new ThemeStorageError({ - operation: "write", - storageKey: STORAGE_KEY, - theme: next, - cause, - }); - console.error(error.message, { - operation: error.operation, - storageKey: error.storageKey, - theme: next, - ...safeErrorLogAttributes(error), - }); + } catch (error) { + if (isThemeStorageError(error)) { + console.error(error.message, themeErrorLogAttributes(error)); + } return; } - applyTheme(next, true); + applyTheme(next, getStoredPalette(), true); emitChange(); }, []); + const setPalette = useCallback( + (next: ThemePalette) => { + if (!hasThemeStorage()) return; + localStorage.setItem(PALETTE_STORAGE_KEY, next); + applyTheme(theme, next, true); + emitChange(); + }, + [theme], + ); + // Keep DOM in sync on mount/change useEffect(() => { - applyTheme(theme); - }, [theme]); + applyTheme(theme, palette); + }, [palette, theme]); - return { theme, setTheme, resolvedTheme } as const; -} + return { palette, resolvedTheme, setPalette, setTheme, theme } as const; +} \ No newline at end of file diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..0fac96882aa 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,31 +1,29 @@ -import { - parseScopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime/environment"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; -import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; -import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; -import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; +import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; +import { invalidateSourceControlState } from "../lib/sourceControlActions"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; +import { newCommandId } from "../lib/utils"; import { readLocalApi } from "../localApi"; -import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; +import { + selectProjectByRef, + selectThreadByRef, + selectThreadsForEnvironment, + useStore, +} from "../store"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useClientSettings } from "./useSettings"; -import { useAtomCommand } from "../state/use-atom-command"; +import { useSettings } from "./useSettings"; +import { isManagedSectionWorkspace } from "../sectionWorkspacePolicy"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -40,32 +38,15 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass settings.sidebarThreadSortOrder); - const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); + const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); const router = useRouter(); - const handleNewThread = useNewThreadHandler(); + const { handleNewThread } = useNewThreadHandler(); // Keep a ref so archiveThread can call handleNewThread without appearing in // its dependency array — handleNewThread is inherently unstable (depends on // the projects list) and would otherwise cascade new references into every @@ -74,7 +55,8 @@ export function useThreadActions() { handleNewThreadRef.current = handleNewThread; const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { - const thread = readThreadShell(target); + const state = useStore.getState(); + const thread = selectThreadByRef(state, target); if (!thread) { return null; } @@ -90,83 +72,68 @@ export function useThreadActions() { const archiveThread = useCallback( async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); + if (!api) return; const resolved = resolveThreadTarget(target); - if (!resolved) return AsyncResult.success(undefined); + if (!resolved) return; const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { - return AsyncResult.failure( - Cause.fail( - new ThreadArchiveBlockedError({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }), - ), - ); + throw new ThreadArchiveBlockedError({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); } const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveResult = await archiveThreadMutation({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, + const archiveCommand = api.orchestration.dispatchCommand({ + type: "thread.archive", + commandId: newCommandId(), + threadId: threadRef.threadId, }); - if (archiveResult._tag === "Failure") { - return archiveResult; - } if (shouldNavigateToDraft) { - const navigationResult = await settlePromise(() => - handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - refreshArchivedThreadsForEnvironment(threadRef.environmentId); - return archiveResult; + await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); } + await archiveCommand; refreshArchivedThreadsForEnvironment(threadRef.environmentId); - return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], + [getCurrentRouteThreadRef, resolveThreadTarget], ); - const unarchiveThread = useCallback( - async (target: ScopedThreadRef) => { - const result = await unarchiveThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, - }); - if (result._tag === "Success") { - refreshArchivedThreadsForEnvironment(target.environmentId); - } - return result; - }, - [unarchiveThreadMutation], - ); + const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); + if (!api) return; + await api.orchestration.dispatchCommand({ + type: "thread.unarchive", + commandId: newCommandId(), + threadId: target.threadId, + }); + refreshArchivedThreadsForEnvironment(target.environmentId); + }, []); const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + const api = readEnvironmentApi(target.environmentId); + if (!api) return; const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. - const result = await deleteThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: target.threadId, }); - if (result._tag === "Success") { - refreshArchivedThreadsForEnvironment(target.environmentId); - } - return result; + refreshArchivedThreadsForEnvironment(target.environmentId); + return; } const { thread, threadRef } = resolved; - const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { - const shell = readThreadShell(ref); - return shell === null ? [] : [shell]; - }); - const threadProject = readProject({ + const state = useStore.getState(); + const threads = selectThreadsForEnvironment(state, threadRef.environmentId); + const threadProject = selectProjectByRef(state, { environmentId: threadRef.environmentId, projectId: thread.projectId, }); @@ -183,44 +150,42 @@ export function useThreadActions() { deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; - const orphanedWorktreePath = getOrphanedWorktreePathForThread( - survivingThreads, - threadRef.threadId, - ); + const orphanedWorktreePath = isManagedSectionWorkspace(threadProject?.kind) + ? null + : getOrphanedWorktreePathForThread(survivingThreads, threadRef.threadId); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; + const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; const localApi = readLocalApi(); - let shouldDeleteWorktree = false; - if (canDeleteWorktree && localApi) { - const confirmationResult = await settlePromise(() => - localApi.dialogs.confirm( - [ - "This thread is the only one linked to this worktree:", - displayWorktreePath ?? orphanedWorktreePath, - "", - "Delete the worktree too?", - ].join("\n"), - ), - ); - if (confirmationResult._tag === "Failure") { - return confirmationResult; - } - shouldDeleteWorktree = confirmationResult.value; - } + const shouldDeleteWorktree = + canDeleteWorktree && + localApi && + (await localApi.dialogs.confirm( + [ + "This thread is the only one linked to this worktree:", + displayWorktreePath ?? orphanedWorktreePath, + "", + "Delete the worktree too?", + ].join("\n"), + )); - if (thread.session && thread.session.status !== "stopped") { - await stopThreadSession({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, - }); + if (thread.session && thread.session.status !== "closed") { + await api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: threadRef.threadId, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); } - await closeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, deleteHistory: true }, - }); + try { + await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); + } catch { + // Terminal may already be closed. + } const deletedThreadIds = deletedIds ?? new Set(); const currentRouteThreadRef = getCurrentRouteThreadRef(); @@ -233,13 +198,11 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, }); - const deleteResult = await deleteThreadMutation({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: threadRef.threadId, }); - if (deleteResult._tag === "Failure") { - return deleteResult; - } refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( @@ -250,71 +213,44 @@ export function useThreadActions() { if (shouldNavigateToFallback) { if (fallbackThreadId) { - const fallbackThread = readThreadShell( + const fallbackThread = selectThreadByRef( + useStore.getState(), scopeThreadRef(threadRef.environmentId, fallbackThreadId), ); if (fallbackThread) { - const navigationResult = await settlePromise(() => - router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams( - scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), - ), - replace: true, - }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } + await router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), + ), + replace: true, + }); } else { - const navigationResult = await settlePromise(() => - router.navigate({ to: "/", replace: true }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } + await router.navigate({ to: "/", replace: true }); } } else { - const navigationResult = await settlePromise(() => - router.navigate({ to: "/", replace: true }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } + await router.navigate({ to: "/", replace: true }); } } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { - return deleteResult; + return; } - const removeResult = await removeWorktree({ - environmentId: threadRef.environmentId, - input: { - cwd: threadProject.workspaceRoot, + try { + await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ + cwd: threadProject.cwd, path: orphanedWorktreePath, force: true, - }, - }); - const refreshResult = - removeResult._tag === "Success" - ? await refreshVcsStatus({ - environmentId: threadRef.environmentId, - input: { cwd: threadProject.workspaceRoot }, - }) - : null; - const cleanupFailure = - removeResult._tag === "Failure" - ? removeResult - : refreshResult?._tag === "Failure" - ? refreshResult - : null; - if (cleanupFailure) { - const error = squashAtomCommandFailure(cleanupFailure); + }); + await invalidateSourceControlState({ + environmentId: threadRef.environmentId, + }); + } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { threadId: threadRef.threadId, - projectCwd: threadProject.workspaceRoot, + projectCwd: threadProject.cwd, worktreePath: orphanedWorktreePath, error, }); @@ -325,61 +261,48 @@ export function useThreadActions() { description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }), ); - return cleanupFailure; } - return deleteResult; }, [ clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, - closeTerminal, - deleteThreadMutation, getCurrentRouteThreadRef, - refreshVcsStatus, - removeWorktree, router, resolveThreadTarget, sidebarThreadSortOrder, - stopThreadSession, ], ); const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); + if (!api) return; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); if (confirmThreadDelete && localApi) { const title = resolved?.thread.title ?? "this thread"; - const confirmationResult = await settlePromise(() => - localApi.dialogs.confirm( - [ - `Delete thread "${title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ), + const confirmed = await localApi.dialogs.confirm( + [ + `Delete thread "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), ); - if (confirmationResult._tag === "Failure") { - return confirmationResult; - } - if (!confirmationResult.value) { - return AsyncResult.success(undefined); + if (!confirmed) { + return; } } - return deleteThread(target); + await deleteThread(target); }, [confirmThreadDelete, deleteThread, resolveThreadTarget], ); - return useMemo( - () => ({ - archiveThread, - unarchiveThread, - deleteThread, - confirmAndDeleteThread, - }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], - ); + return { + archiveThread, + unarchiveThread, + deleteThread, + confirmAndDeleteThread, + }; } From 84ca569120fa0c57ecd2195e14b760957028e3ac Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Fri, 26 Jun 2026 01:30:13 -0400 Subject: [PATCH 16/23] Fix Phase 3C provider error adaptations --- .../AzureDevOpsSourceControlProvider.test.ts | 2 +- .../AzureDevOpsSourceControlProvider.ts | 60 ++++++++++++++-- .../BitbucketSourceControlProvider.test.ts | 2 +- .../BitbucketSourceControlProvider.ts | 69 ++++++++++++++++--- .../GitHubSourceControlProvider.test.ts | 2 +- .../GitHubSourceControlProvider.ts | 2 +- .../GitLabSourceControlProvider.test.ts | 2 +- .../GitLabSourceControlProvider.ts | 60 ++++++++++++++-- .../sourceControl/SourceControlProvider.ts | 18 +++++ apps/web/src/hooks/useTheme.ts | 14 ++-- packages/contracts/src/sourceControl.ts | 4 ++ 11 files changed, 204 insertions(+), 31 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e7991..21a3183a88f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -7,7 +7,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; function makeProvider(azure: Partial) { - return AzureDevOpsSourceControlProvider.make.pipe( + return AzureDevOpsSourceControlProvider.make().pipe( Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), ); } diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8d8e081cb89..74cda2fd6ba 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -9,11 +9,22 @@ import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscover function providerError( operation: string, cause: AzureDevOpsCli.AzureDevOpsCliError, + context: { + readonly cwd?: string; + readonly reference?: string; + readonly repository?: string; + } = {}, ): SourceControlProviderError { return new SourceControlProviderError({ provider: "azure-devops", operation, detail: cause.detail, + command: cause.command, + cwd: SourceControlProvider.transportSafeSourceControlErrorValue(context.cwd ?? cause.cwd), + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + context.reference ?? ("reference" in cause ? cause.reference : undefined), + ), + repository: SourceControlProvider.transportSafeSourceControlErrorValue(context.repository), cause, }); } @@ -97,13 +108,20 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError((error) => + providerError("listChangeRequests", error, { + cwd: input.cwd, + reference: input.headSelector, + }), + ), ); }, getChangeRequest: (input) => azure.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError((error) => + providerError("getChangeRequest", error, { cwd: input.cwd, reference: input.reference }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -117,20 +135,41 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError((error) => + providerError("createChangeRequest", error, { + cwd: input.cwd, + reference: input.headSelector, + }), + ), + ); }, getRepositoryCloneUrls: (input) => azure .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + .pipe( + Effect.mapError((error) => + providerError("getRepositoryCloneUrls", error, { + cwd: input.cwd, + repository: input.repository, + }), + ), + ), createRepository: (input) => azure .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + .pipe( + Effect.mapError((error) => + providerError("createRepository", error, { + cwd: input.cwd, + repository: input.repository, + }), + ), + ), getDefaultBranch: (input) => azure .getDefaultBranch({ cwd: input.cwd }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error, input))), checkoutChangeRequest: (input) => azure .checkoutPullRequest({ @@ -138,7 +177,14 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* reference: input.reference, ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError((error) => + providerError("checkoutChangeRequest", error, { + cwd: input.cwd, + reference: input.reference, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index eeb4c8fbdd2..3e557af7642 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -7,7 +7,7 @@ import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; function makeProvider(bitbucket: Partial) { - return BitbucketSourceControlProvider.make.pipe( + return BitbucketSourceControlProvider.make().pipe( Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), ); } diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 86e17245a6d..79736c576f0 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -11,11 +11,29 @@ import type * as SourceControlProviderDiscovery from "./SourceControlProviderDis function providerError( operation: string, cause: BitbucketApi.BitbucketApiError, + context: { + readonly cwd?: string; + readonly reference?: string; + readonly repository?: string; + } = {}, ): SourceControlProviderError { + const detail = + operation === "getRepositoryCloneUrls" + ? "Failed to get repository clone URLs." + : "detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0 + ? cause.detail + : cause.message; return new SourceControlProviderError({ provider: "bitbucket", operation, - detail: cause.detail, + detail, + cwd: SourceControlProvider.transportSafeSourceControlErrorValue(context.cwd ?? cause.cwd), + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + context.reference ?? ("reference" in cause ? cause.reference : undefined), + ), + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + context.repository ?? ("repository" in cause ? cause.repository : undefined), + ), cause, }); } @@ -62,13 +80,20 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError((error) => + providerError("listChangeRequests", error, { + cwd: input.cwd, + reference: input.headSelector, + }), + ), ); }, getChangeRequest: (input) => bitbucket.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError((error) => + providerError("getChangeRequest", error, { cwd: input.cwd, reference: input.reference }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -83,23 +108,44 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError((error) => + providerError("createChangeRequest", error, { + cwd: input.cwd, + reference: input.headSelector, + }), + ), + ); }, getRepositoryCloneUrls: (input) => bitbucket .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + .pipe( + Effect.mapError((error) => + providerError("getRepositoryCloneUrls", error, { + cwd: input.cwd, + repository: input.repository, + }), + ), + ), createRepository: (input) => bitbucket .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + .pipe( + Effect.mapError((error) => + providerError("createRepository", error, { + cwd: input.cwd, + repository: input.repository, + }), + ), + ), getDefaultBranch: (input) => bitbucket .getDefaultBranch({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error, input))), checkoutChangeRequest: (input) => bitbucket .checkoutPullRequest({ @@ -108,7 +154,14 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () reference: input.reference, ...(input.force !== undefined ? { force: input.force } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError((error) => + providerError("checkoutChangeRequest", error, { + cwd: input.cwd, + reference: input.reference, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..dfea312a62c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -25,7 +25,7 @@ const processResult = ( }); function makeProvider(github: Partial) { - return GitHubSourceControlProvider.make.pipe( + return GitHubSourceControlProvider.make().pipe( Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), ); } diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..73d9066d5fb 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -94,7 +94,7 @@ export const discovery = { "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; -export const make = Effect.gen(function* () { +export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { const github = yield* GitHubCli.GitHubCli; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e066521..9667c017592 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -9,7 +9,7 @@ import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; function makeProvider(gitlab: Partial) { - return GitLabSourceControlProvider.make.pipe( + return GitLabSourceControlProvider.make().pipe( Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), ); } diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 77f41600e0f..61d91526863 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -11,11 +11,22 @@ import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLa function providerError( operation: string, cause: GitLabCli.GitLabCliError, + context: { + readonly cwd?: string; + readonly reference?: string; + readonly repository?: string; + } = {}, ): SourceControlProviderError { return new SourceControlProviderError({ provider: "gitlab", operation, detail: cause.detail, + command: cause.command, + cwd: SourceControlProvider.transportSafeSourceControlErrorValue(context.cwd ?? cause.cwd), + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + context.reference ?? ("reference" in cause ? cause.reference : undefined), + ), + repository: SourceControlProvider.transportSafeSourceControlErrorValue(context.repository), cause, }); } @@ -126,13 +137,20 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError((error) => + providerError("listChangeRequests", error, { + cwd: input.cwd, + reference: input.headSelector, + }), + ), ); }, getChangeRequest: (input) => gitlab.getMergeRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError((error) => + providerError("getChangeRequest", error, { cwd: input.cwd, reference: input.reference }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -146,24 +164,52 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError((error) => + providerError("createChangeRequest", error, { + cwd: input.cwd, + reference: input.headSelector, + }), + ), + ); }, getRepositoryCloneUrls: (input) => gitlab .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + .pipe( + Effect.mapError((error) => + providerError("getRepositoryCloneUrls", error, { + cwd: input.cwd, + repository: input.repository, + }), + ), + ), createRepository: (input) => gitlab .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + .pipe( + Effect.mapError((error) => + providerError("createRepository", error, { + cwd: input.cwd, + repository: input.repository, + }), + ), + ), getDefaultBranch: (input) => gitlab .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error, input))), checkoutChangeRequest: (input) => gitlab .checkoutMergeRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError((error) => + providerError("checkoutChangeRequest", error, { + cwd: input.cwd, + reference: input.reference, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index f0602f03d14..70c777e7730 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -49,6 +49,24 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } +export function transportSafeSourceControlErrorValue( + value: string | null | undefined, +): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + try { + const url = new URL(trimmed); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + const safeUrl = url.toString(); + return safeUrl.endsWith("/") && !trimmed.endsWith("/") ? safeUrl.slice(0, -1) : safeUrl; + } catch { + return trimmed.length > 256 ? `${trimmed.slice(0, 253)}...` : trimmed; + } +} + export interface SourceControlProviderShape { readonly kind: SourceControlProviderKind; readonly listChangeRequests: (input: { diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 209854bca7b..4bea6a875f9 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -65,7 +65,7 @@ function emitChange() { } function hasThemeStorage() { - return typeof window !== "undefined" && typeof localStorage !== "undefined"; + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } function getSystemDark() { @@ -144,7 +144,13 @@ function getStored(): Theme { function getStoredPalette(): ThemePalette { if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.palette; - const raw = localStorage.getItem(PALETTE_STORAGE_KEY); + if (themeStorageReadFailure !== null) return DEFAULT_THEME_SNAPSHOT.palette; + let raw: string | null; + try { + raw = window.localStorage.getItem(PALETTE_STORAGE_KEY); + } catch { + return DEFAULT_THEME_SNAPSHOT.palette; + } return isThemePalette(raw) ? raw : DEFAULT_THEME_SNAPSHOT.palette; } @@ -347,7 +353,7 @@ export function useTheme() { const setPalette = useCallback( (next: ThemePalette) => { if (!hasThemeStorage()) return; - localStorage.setItem(PALETTE_STORAGE_KEY, next); + window.localStorage.setItem(PALETTE_STORAGE_KEY, next); applyTheme(theme, next, true); emitChange(); }, @@ -360,4 +366,4 @@ export function useTheme() { }, [palette, theme]); return { palette, resolvedTheme, setPalette, setTheme, theme } as const; -} \ No newline at end of file +} diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 0ecf13c67e6..7f1594871bd 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -156,6 +156,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass Date: Fri, 26 Jun 2026 01:30:29 -0400 Subject: [PATCH 17/23] Format Phase 3C changes --- apps/server/src/persistence/Errors.ts | 2 +- .../persistence/Layers/AuthPairingLinks.ts | 2 +- .../src/persistence/Layers/AuthSessions.ts | 2 +- .../Layers/ProviderSessionRuntime.ts | 2 +- .../RepositoryErrorCorrelation.test.ts | 4 +- .../Layers/ProjectFaviconResolver.test.ts | 7 ++- .../project/Layers/ProjectFaviconResolver.ts | 2 +- .../Services/ProjectFaviconResolver.ts | 2 +- .../AzureDevOpsSourceControlProvider.ts | 32 ++++++------- .../BitbucketSourceControlProvider.ts | 36 +++++++------- .../GitLabSourceControlProvider.ts | 48 ++++++++----------- 11 files changed, 65 insertions(+), 74 deletions(-) diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index acb097c2c52..c22e956268c 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -142,4 +142,4 @@ export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | Persis export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; -export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; \ No newline at end of file +export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts index 6818e360d57..3419ad09a78 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -290,4 +290,4 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { export const AuthPairingLinkRepositoryLive = Layer.effect( AuthPairingLinkRepository, makeAuthPairingLinkRepository, -); \ No newline at end of file +); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts index f0e85c983d7..78b589ef1fe 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -335,4 +335,4 @@ const makeAuthSessionRepository = Effect.gen(function* () { export const AuthSessionRepositoryLive = Layer.effect( AuthSessionRepository, makeAuthSessionRepository, -); \ No newline at end of file +); diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index a3624cd76d5..aa791e03d42 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -236,4 +236,4 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { export const ProviderSessionRuntimeRepositoryLive = Layer.effect( ProviderSessionRuntimeRepository, makeProviderSessionRuntimeRepository, -); \ No newline at end of file +); diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts index ed5afa1efaf..d465be8a371 100644 --- a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -19,7 +19,9 @@ const expiresAt = DateTime.makeUnsafe("2027-06-20T00:00:00.000Z"); const now = DateTime.makeUnsafe("2026-06-21T00:00:00.000Z"); const scopes: ReadonlyArray = ["access:read"]; -const authSessionLayer = AuthSessionRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const authSessionLayer = AuthSessionRepositoryLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); const authPairingLinkLayer = AuthPairingLinkRepositoryLive.pipe( Layer.provideMerge(SqlitePersistenceMemory), ); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index a539b43bff4..5a2766d0288 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -9,7 +9,10 @@ import * as PlatformError from "effect/PlatformError"; import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; import { WorkspaceRootNotExistsError } from "../../workspace/Services/WorkspacePaths.ts"; -import { makeProjectFaviconResolver, ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; +import { + makeProjectFaviconResolver, + ProjectFaviconResolverLive, +} from "./ProjectFaviconResolver.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectFaviconResolverLive), @@ -197,4 +200,4 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); }); -}); \ No newline at end of file +}); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index 2f1b671f4ea..0aa5db7b668 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -202,4 +202,4 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { export const ProjectFaviconResolverLive = Layer.effect( ProjectFaviconResolver, makeProjectFaviconResolver, -); \ No newline at end of file +); diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts index 118b75ed461..c8ec27f2ed6 100644 --- a/apps/server/src/project/Services/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Services/ProjectFaviconResolver.ts @@ -50,4 +50,4 @@ export interface ProjectFaviconResolverShape { export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, ProjectFaviconResolverShape ->()("t3/project/Services/ProjectFaviconResolver") {} \ No newline at end of file +>()("t3/project/Services/ProjectFaviconResolver") {} diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 74cda2fd6ba..d9ad089113f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -145,27 +145,23 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* ); }, getRepositoryCloneUrls: (input) => - azure - .getRepositoryCloneUrls(input) - .pipe( - Effect.mapError((error) => - providerError("getRepositoryCloneUrls", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), + azure.getRepositoryCloneUrls(input).pipe( + Effect.mapError((error) => + providerError("getRepositoryCloneUrls", error, { + cwd: input.cwd, + repository: input.repository, + }), ), + ), createRepository: (input) => - azure - .createRepository(input) - .pipe( - Effect.mapError((error) => - providerError("createRepository", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), + azure.createRepository(input).pipe( + Effect.mapError((error) => + providerError("createRepository", error, { + cwd: input.cwd, + repository: input.repository, + }), ), + ), getDefaultBranch: (input) => azure .getDefaultBranch({ cwd: input.cwd }) diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 79736c576f0..bd61c6762f5 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -21,8 +21,8 @@ function providerError( operation === "getRepositoryCloneUrls" ? "Failed to get repository clone URLs." : "detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0 - ? cause.detail - : cause.message; + ? cause.detail + : cause.message; return new SourceControlProviderError({ provider: "bitbucket", operation, @@ -118,27 +118,23 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () ); }, getRepositoryCloneUrls: (input) => - bitbucket - .getRepositoryCloneUrls(input) - .pipe( - Effect.mapError((error) => - providerError("getRepositoryCloneUrls", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), + bitbucket.getRepositoryCloneUrls(input).pipe( + Effect.mapError((error) => + providerError("getRepositoryCloneUrls", error, { + cwd: input.cwd, + repository: input.repository, + }), ), + ), createRepository: (input) => - bitbucket - .createRepository(input) - .pipe( - Effect.mapError((error) => - providerError("createRepository", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), + bitbucket.createRepository(input).pipe( + Effect.mapError((error) => + providerError("createRepository", error, { + cwd: input.cwd, + repository: input.repository, + }), ), + ), getDefaultBranch: (input) => bitbucket .getDefaultBranch({ diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 61d91526863..06d88b6e55d 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -174,42 +174,36 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { ); }, getRepositoryCloneUrls: (input) => - gitlab - .getRepositoryCloneUrls(input) - .pipe( - Effect.mapError((error) => - providerError("getRepositoryCloneUrls", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), + gitlab.getRepositoryCloneUrls(input).pipe( + Effect.mapError((error) => + providerError("getRepositoryCloneUrls", error, { + cwd: input.cwd, + repository: input.repository, + }), ), + ), createRepository: (input) => - gitlab - .createRepository(input) - .pipe( - Effect.mapError((error) => - providerError("createRepository", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), + gitlab.createRepository(input).pipe( + Effect.mapError((error) => + providerError("createRepository", error, { + cwd: input.cwd, + repository: input.repository, + }), ), + ), getDefaultBranch: (input) => gitlab .getDefaultBranch(input) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error, input))), checkoutChangeRequest: (input) => - gitlab - .checkoutMergeRequest(input) - .pipe( - Effect.mapError((error) => - providerError("checkoutChangeRequest", error, { - cwd: input.cwd, - reference: input.reference, - }), - ), + gitlab.checkoutMergeRequest(input).pipe( + Effect.mapError((error) => + providerError("checkoutChangeRequest", error, { + cwd: input.cwd, + reference: input.reference, + }), ), + ), }); }); From 08a525d02c7b3930ae21fd8b60b4056e19359eab Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Fri, 26 Jun 2026 01:31:39 -0400 Subject: [PATCH 18/23] Keep Phase 3C compatible with fork services --- .../Layers/ProjectFaviconResolver.test.ts | 128 +--------------- .../project/Layers/ProjectFaviconResolver.ts | 122 +++------------- .../Services/ProjectFaviconResolver.ts | 25 +--- apps/server/src/vcs/VcsProcess.test.ts | 138 +----------------- apps/server/src/vcs/VcsProcess.ts | 108 ++++---------- 5 files changed, 57 insertions(+), 464 deletions(-) diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index 5a2766d0288..c983aca4ba7 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -4,19 +4,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; -import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; -import { WorkspaceRootNotExistsError } from "../../workspace/Services/WorkspacePaths.ts"; -import { - makeProjectFaviconResolver, - ProjectFaviconResolverLive, -} from "./ProjectFaviconResolver.ts"; +import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectFaviconResolverLive), - Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(NodeServices.layer), ); @@ -41,12 +34,6 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); -const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => - makeProjectFaviconResolver.pipe( - Effect.provide(WorkspacePathsLive), - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => @@ -86,118 +73,5 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); - - it.effect("preserves workspace normalization context", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - const missingCwd = `${cwd}/missing`; - - const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "normalize-workspace", - workspaceRoot: missingCwd, - }); - expect(error.cause).toBeInstanceOf(WorkspaceRootNotExistsError); - }), - ); - - it.effect("preserves non-missing candidate stat failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const faviconPath = path.join(cwd, "favicon.svg"); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - pathOrDescriptor: faviconPath, - }); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => - filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), - }), - ); - - const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "stat-candidate", - workspaceRoot: cwd, - relativePath: "favicon.svg", - absolutePath: faviconPath, - }); - expect(error.cause).toBe(cause); - }), - ); - - it.effect("preserves icon source read failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const sourcePath = path.join(cwd, "index.html"); - yield* writeTextFile(cwd, "index.html", ''); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: sourcePath, - }); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - readFileString: (filePath, options) => - filePath === sourcePath - ? Effect.fail(cause) - : fileSystem.readFileString(filePath, options), - }), - ); - - const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "read-source", - workspaceRoot: cwd, - relativePath: "index.html", - absolutePath: sourcePath, - }); - expect(error.cause).toBe(cause); - }), - ); - - it.effect("skips icon metadata paths outside the workspace", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "index.html", ''); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).toBeNull(); - }), - ); - - it.effect("continues to later sources after an outside-root icon href", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "index.html", ''); - yield* writeTextFile(cwd, "public/index.html", ''); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); - }), - ); }); }); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index 0aa5db7b668..cdfddd5438a 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -1,19 +1,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { - ProjectFaviconResolutionError, ProjectFaviconResolver, type ProjectFaviconResolverShape, } from "../Services/ProjectFaviconResolver.ts"; -import { - WorkspacePaths, - WorkspacePathOutsideRootError, -} from "../../workspace/Services/WorkspacePaths.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -65,63 +58,31 @@ function extractIconHref(source: string): string | null { return null; } -const optionOnNotFound = ( - effect: Effect.Effect, -): Effect.Effect, PlatformError.PlatformError, R> => - effect.pipe( - Effect.map(Option.some), - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), - }), - ); - export const makeProjectFaviconResolver = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; - const resolveIconHref = (href: string): ReadonlyArray => { + const resolveIconHref = (projectCwd: string, href: string): string[] => { const clean = href.replace(/^\//, ""); - return [path.join("public", clean), clean]; + return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; + }; + + const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { + const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); }; const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, - relativeCandidates: ReadonlyArray, - ): Effect.fn.Return { - for (const relativePath of relativeCandidates) { - const candidate = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath, - }) - .pipe( - Effect.map(Option.some), - Effect.catchTags({ - WorkspacePathOutsideRootError: () => - Effect.succeed( - Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), - ), - }), - ); - if (Option.isNone(candidate)) { + candidates: ReadonlyArray, + ): Effect.fn.Return { + for (const candidate of candidates) { + if (!isPathWithinProject(projectCwd, candidate)) { continue; } - const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "stat-candidate", - workspaceRoot: projectCwd, - relativePath, - absolutePath: candidate.value.absolutePath, - cause, - }), - ), - ); - if (Option.isSome(stats) && stats.value.type === "File") { - return candidate.value.absolutePath; + const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null)); + if (stats?.type === "File") { + return candidate; } } return null; @@ -129,63 +90,28 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd: string) { - const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "normalize-workspace", - workspaceRoot: cwd, - cause, - }), - ), - ); + )(function* (cwd: string): Effect.fn.Return { for (const candidate of FAVICON_CANDIDATES) { - const existing = yield* findExistingFile(projectCwd, [candidate]); + const resolved = path.join(cwd, candidate); + const existing = yield* findExistingFile(cwd, [resolved]); if (existing) { return existing; } } for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath: sourceFile, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "resolve-path", - workspaceRoot: projectCwd, - relativePath: sourceFile, - cause, - }), - ), - ); - const source = yield* optionOnNotFound( - fileSystem.readFileString(sourcePath.absolutePath), - ).pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "read-source", - workspaceRoot: projectCwd, - relativePath: sourceFile, - absolutePath: sourcePath.absolutePath, - cause, - }), - ), - ); - if (Option.isNone(source)) { + const sourcePath = path.join(cwd, sourceFile); + const source = yield* fileSystem + .readFileString(sourcePath) + .pipe(Effect.orElseSucceed(() => null)); + if (!source) { continue; } - const href = extractIconHref(source.value); + const href = extractIconHref(source); if (!href) { continue; } - const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); + const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); if (existing) { return existing; } diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts index c8ec27f2ed6..ad1b466e2c7 100644 --- a/apps/server/src/project/Services/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Services/ProjectFaviconResolver.ts @@ -8,27 +8,6 @@ */ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( - "ProjectFaviconResolutionError", - { - operation: Schema.Literals([ - "normalize-workspace", - "resolve-path", - "stat-candidate", - "read-source", - ]), - workspaceRoot: Schema.String, - relativePath: Schema.optional(Schema.String), - absolutePath: Schema.optional(Schema.String), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; - } -} /** * ProjectFaviconResolverShape - Service API for project favicon lookup. @@ -39,9 +18,7 @@ export interface ProjectFaviconResolverShape { * * Returns `null` when no candidate icon file can be found. */ - readonly resolvePath: ( - cwd: string, - ) => Effect.Effect; + readonly resolvePath: (cwd: string) => Effect.Effect; } /** diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 675d20cb82c..b58d64e435a 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -6,12 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { TestClock } from "effect/testing"; -import { - VcsProcessExitError, - VcsProcessSpawnError, - VcsProcessTimeoutError, -} from "@t3tools/contracts"; -import * as ProcessRunner from "../processRunner.ts"; +import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; import * as VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -25,25 +20,6 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const provideLive = (effect: Effect.Effect) => effect.pipe(Effect.provide(liveLayer)); -const baseInput = { - operation: "test.process-boundary", - command: "git", - args: ["status", "--short"], - cwd: "/workspace", -} satisfies VcsProcess.VcsProcessInput; - -const captureProcessResult = ( - result: Effect.Effect, -) => - VcsProcess.make.pipe( - Effect.provideService( - ProcessRunner.ProcessRunner, - ProcessRunner.ProcessRunner.of({ run: () => result }), - ), - Effect.flatMap((service) => service.run(baseInput)), - Effect.flip, - ); - describe("VcsProcess.run", () => { it.effect("collects stdout", () => Effect.gen(function* () { @@ -85,127 +61,17 @@ describe("VcsProcess.run", () => { it.effect("fails with VcsProcessExitError for non-zero exits by default", () => Effect.gen(function* () { - const secretArgument = "--token=super-secret-token"; - const secretStderr = "remote rejected super-secret-token"; const error = yield* run({ operation: "test.exit", command: "node", - args: [ - "-e", - "process.stderr.write(process.argv[1]); process.exit(2)", - secretStderr, - secretArgument, - ], + args: ["-e", "process.stderr.write('boom'); process.exit(2)"], cwd: process.cwd(), }).pipe(Effect.flip); expect(error).toBeInstanceOf(VcsProcessExitError); - expect(error).toMatchObject({ - operation: "test.exit", - command: "node", - argumentCount: 4, - exitCode: 2, - detail: "Process exited with a non-zero status.", - failureKind: "command-failed", - stderrLength: secretStderr.length, - stderrTruncated: false, - }); - expect(error.message).not.toContain(secretArgument); - expect(error.message).not.toContain(secretStderr); }).pipe(provideLive), ); - it.effect("classifies authentication failures without retaining stderr", () => - Effect.gen(function* () { - const secretStderr = "authentication failed for token super-secret-token"; - const error = yield* run({ - operation: "test.authentication", - command: "node", - args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], - cwd: process.cwd(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(VcsProcessExitError); - expect(error).toMatchObject({ - operation: "test.authentication", - command: "node", - exitCode: 1, - detail: "Authentication failed.", - failureKind: "authentication", - stderrLength: secretStderr.length, - stderrTruncated: false, - }); - expect(error.message).not.toContain(secretStderr); - expect(error.message).not.toContain("super-secret-token"); - }).pipe(provideLive), - ); - - it.effect("retains spawn causes without exposing process arguments in the error message", () => - Effect.gen(function* () { - const secretArgument = "--token=super-secret-token"; - const error = yield* run({ - operation: "test.spawn", - command: "definitely-not-a-t3code-executable", - args: [secretArgument], - cwd: process.cwd(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(VcsProcessSpawnError); - expect(error).toMatchObject({ - operation: "test.spawn", - command: "definitely-not-a-t3code-executable", - argumentCount: 1, - }); - expect(error).toHaveProperty("cause"); - expect(error.message).not.toContain(secretArgument); - }).pipe(provideLive), - ); - - it.effect("preserves real boundary causes without manufacturing structural ones", () => - Effect.gen(function* () { - const cause = new Error("secret stdin failure"); - const error = yield* captureProcessResult( - Effect.fail( - new ProcessRunner.ProcessStdinError({ - command: baseInput.command, - argumentCount: baseInput.args.length, - cwd: baseInput.cwd, - stdinBytes: 47, - cause, - }), - ), - ); - - expect(error).toMatchObject({ - _tag: "VcsProcessStdinWriteError", - operation: baseInput.operation, - stdinBytes: 47, - cause, - }); - expect(error.message).not.toContain(cause.message); - - const missingExitCodeError = yield* captureProcessResult( - Effect.succeed({ - stdout: "", - stderr: "", - code: null, - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }), - ); - - expect(missingExitCodeError).toMatchObject({ - _tag: "VcsProcessMissingExitCodeError", - operation: baseInput.operation, - command: baseInput.command, - cwd: baseInput.cwd, - argumentCount: baseInput.args.length, - }); - expect(missingExitCodeError).not.toHaveProperty("cause"); - }), - ); - it.effect("returns output when non-zero exits are allowed", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 52db6f9b1fb..a4caf7d3230 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -1,21 +1,17 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + VcsOutputDecodeError, type VcsError, VcsProcessExitError, - type VcsProcessExitFailureKind, - VcsProcessMissingExitCodeError, - VcsProcessOutputLimitError, - VcsProcessOutputReadError, VcsProcessSpawnError, - VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; -import * as ProcessRunner from "../processRunner.ts"; +import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts"; +import * as Match from "effect/Match"; export interface VcsProcessInput { readonly operation: string; @@ -39,62 +35,31 @@ export interface VcsProcessOutput { readonly stderrTruncated: boolean; } -export class VcsProcess extends Context.Service< - VcsProcess, - { - readonly run: (input: VcsProcessInput) => Effect.Effect; - } ->()("t3/vcs/VcsProcess") {} +export interface VcsProcessShape { + readonly run: (input: VcsProcessInput) => Effect.Effect; +} + +export class VcsProcess extends Context.Service()( + "t3/vcs/VcsProcess", +) {} const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { - const normalized = stderr.toLowerCase(); - - if ( - normalized.includes("authentication failed") || - normalized.includes("not logged in") || - normalized.includes("gh auth login") || - normalized.includes("glab auth login") || - normalized.includes("az devops login") || - normalized.includes("please run az login") || - normalized.includes("no oauth token") || - normalized.includes("unauthorized") - ) { - return "authentication"; - } - - if ( - (command === "gh" && - (normalized.includes("could not resolve to a pullrequest") || - normalized.includes("repository.pullrequest") || - normalized.includes("no pull requests found for branch") || - normalized.includes("pull request not found"))) || - (command === "glab" && - (normalized.includes("merge request not found") || - normalized.includes("not found") || - normalized.includes("404"))) || - (command === "az" && - normalized.includes("pull request") && - (normalized.includes("not found") || normalized.includes("does not exist"))) - ) { - return "not-found"; - } - - return "command-failed"; -}; +function commandLabel(command: string, args: ReadonlyArray): string { + return [command, ...args].join(" "); +} -export const make = Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; +export const make = Effect.fn("makeVcsProcess")(function* () { + const processRunner = yield* ProcessRunner; const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const label = commandLabel(input.command, input.args); const baseError = { operation: input.operation, - command: input.command, + command: label, cwd: input.cwd, - argumentCount: input.args.length, }; const result = yield* processRunner @@ -117,44 +82,29 @@ export const make = Effect.gen(function* () { ProcessSpawnError: (error) => VcsProcessSpawnError.fromProcessSpawnError(baseError, error), ProcessOutputLimitError: (error) => - new VcsProcessOutputLimitError({ - ...baseError, - stream: error.stream, - maxBytes: error.maxBytes, - observedBytes: error.observedBytes, - }), + VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), ProcessTimeoutError: (error) => VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), ProcessStdinError: (error) => - new VcsProcessStdinWriteError({ - ...baseError, - stdinBytes: error.stdinBytes, - cause: error.cause, - }), + VcsOutputDecodeError.fromProcessStdinError(baseError, error), ProcessReadError: (error) => - new VcsProcessOutputReadError({ - ...baseError, - stream: error.stream, - cause: error.cause, - }), + VcsOutputDecodeError.fromProcessReadError(baseError, error), }), ), ); if (result.code === null) { - return yield* new VcsProcessMissingExitCodeError(baseError); + return yield* VcsOutputDecodeError.missingExitCode(baseError); } if (!input.allowNonZeroExit && result.code !== 0) { - return yield* VcsProcessExitError.fromProcessExit( - baseError, - { - exitCode: result.code, - stderr: result.stderr, - stderrTruncated: result.stderrTruncated, - }, - classifyNonZeroExit(input.command, result.stderr), - ); + return yield* new VcsProcessExitError({ + operation: input.operation, + command: label, + cwd: input.cwd, + exitCode: result.code, + detail: result.stderr.trim() || `${label} exited with code ${result.code}.`, + }); } return { @@ -169,4 +119,4 @@ export const make = Effect.gen(function* () { return VcsProcess.of({ run }); }); -export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive)); From f4fb0153cefed2bd70238b896ddc5241d2084eb1 Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Fri, 26 Jun 2026 01:32:29 -0400 Subject: [PATCH 19/23] Retain fork VCS contracts for Phase 3C --- apps/server/src/git/GitManager.test.ts | 405 +++++++++---------------- packages/contracts/src/vcs.ts | 154 ++++------ 2 files changed, 192 insertions(+), 367 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..bee861677a5 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeChildProcess from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -20,16 +20,27 @@ import type { } from "@t3tools/contracts"; import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { type GitManagerShape } from "./GitManager.ts"; +import { + GitHubCliError, + type GitHubCliShape, + type GitHubPullRequestSummary, + GitHubCli, +} from "../sourceControl/GitHubCli.ts"; +import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as ServerConfig from "../config.ts"; -import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as GitManager from "./GitManager.ts"; +import { makeGitManager } from "./GitManager.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { + ProjectSetupScriptRunner, + ProjectSetupScriptRunnerError, + type ProjectSetupScriptRunnerInput, + type ProjectSetupScriptRunnerShape, +} from "../project/Services/ProjectSetupScriptRunner.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -49,7 +60,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCli.GitHubCliError; + failWith?: GitHubCliError; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -97,7 +108,7 @@ interface FakeGitTextGeneration { type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { +function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -164,15 +175,25 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ } function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { - const result = NodeChildProcess.spawnSync("git", args, { + const result = spawnSync("git", args, { cwd, encoding: "utf8", }); if (result.status === 0) { return; } - throw new Error( - `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, + throw new GitHubCliError({ + operation: "execute", + detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, + }); +} + +function isGitHubCliError(error: unknown): error is GitHubCliError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + (error as { _tag?: unknown })._tag === "GitHubCliError" ); } @@ -244,7 +265,7 @@ function initRepo( yield* runGit(cwd, ["init", "--initial-branch=main"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); - yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); + yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); }); @@ -291,9 +312,7 @@ function configureVisibleRemoteUrlWithLocalRewrite( }); } -function createTextGeneration( - overrides: Partial = {}, -): TextGeneration.TextGeneration["Service"] { +function createTextGeneration(overrides: Partial = {}): TextGenerationShape { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => Effect.succeed({ @@ -366,7 +385,7 @@ function createTextGeneration( } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCli.GitHubCli["Service"]; + service: GitHubCliShape; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -378,7 +397,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { + const execute: GitHubCliShape["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -449,7 +468,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { try: () => { const headBranch = scenario.pullRequest?.headRefName; if (headBranch) { - const existingBranch = NodeChildProcess.spawnSync( + const existingBranch = spawnSync( "git", ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], { @@ -466,12 +485,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - GitHubCli.isGitHubCliError(error) + isGitHubCliError(error) ? error - : new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: input.cwd, - cause: error, + : new GitHubCliError({ + operation: "execute", + detail: + error instanceof Error + ? `Failed to simulate gh checkout: ${error.message}` + : "Failed to simulate gh checkout.", }), }); } @@ -482,10 +503,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: input.cwd, - cause: new Error(`Unexpected repository lookup: ${repository}`), + new GitHubCliError({ + operation: "execute", + detail: `Unexpected repository lookup: ${repository}`, }), ); } @@ -503,10 +523,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: input.cwd, - cause: new Error(`Unexpected gh command: ${args.join(" ")}`), + new GitHubCliError({ + operation: "execute", + detail: `Unexpected gh command: ${args.join(" ")}`, }), ); }; @@ -534,7 +553,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), + .filter((entry): entry is GitHubPullRequestSummary => entry !== null), ), ), createPullRequest: (input) => @@ -573,9 +592,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], - }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), - ), + }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, @@ -583,10 +600,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: input.cwd, - cause: new Error(`Unexpected repository create: ${input.repository}`), + new GitHubCliError({ + operation: "createRepository", + detail: `Unexpected repository create: ${input.repository}`, }), ), checkoutPullRequest: (input) => @@ -600,7 +616,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } function runStackedAction( - manager: GitManager.GitManager["Service"], + manager: GitManagerShape, input: { cwd: string; action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; @@ -609,7 +625,7 @@ function runStackedAction( featureBranch?: boolean; filePaths?: readonly string[]; }, - options?: Parameters[1], + options?: Parameters[1], ) { return manager.runStackedAction( { @@ -620,15 +636,12 @@ function runStackedAction( ); } -function resolvePullRequest( - manager: GitManager.GitManager["Service"], - input: { cwd: string; reference: string }, -) { +function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { return manager.resolvePullRequest(input); } function preparePullRequestThread( - manager: GitManager.GitManager["Service"], + manager: GitManagerShape, input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); @@ -637,24 +650,24 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; - setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + setupScriptRunner?: ProjectSetupScriptRunnerShape; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettingsService.layerTest(); const vcsDriverLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerConfigLayer), ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make.pipe( + GitHubSourceControlProvider.make().pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -663,14 +676,14 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)), + Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), ), ); const managerLayer = Layer.mergeAll( - Layer.succeed(TextGeneration.TextGeneration, textGeneration), + Layer.succeed(TextGeneration, textGeneration), Layer.succeed( - ProjectSetupScriptRunner.ProjectSetupScriptRunner, + ProjectSetupScriptRunner, input?.setupScriptRunner ?? { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, @@ -679,7 +692,7 @@ function makeManager(input?: { serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); - return GitManager.make.pipe( + return makeGitManager().pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); @@ -907,7 +920,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); - const cwd = NodePath.join(rootDir, "deleted-repo"); + const cwd = path.join(rootDir, "deleted-repo"); yield* makeDirectory(cwd); yield* removePath(cwd); const { manager } = yield* makeManager(); @@ -1017,7 +1030,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "fork-pr.txt"), "fork pr\n"); + fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); yield* runGit(repoDir, ["add", "fork-pr.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); @@ -1322,10 +1335,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", - cwd: repoDir, - cause: new Error("gh is not available on PATH"), + failWith: new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, }); @@ -1340,7 +1352,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1375,7 +1387,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom\n"); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1418,8 +1430,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "a.txt"), "file a\n"); - NodeFS.writeFileSync(NodePath.join(repoDir, "b.txt"), "file b\n"); + fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); + fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1446,7 +1458,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nfeature-branch\n"); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1506,7 +1518,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom-feature\n"); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1569,18 +1581,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* initRepo(repoDir); const { manager } = yield* makeManager(); - const error = yield* runStackedAction(manager, { + const errorMessage = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, - }).pipe(Effect.flip); + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); - expect(error).toMatchObject({ - _tag: "GitManagerError", - operation: "runFeatureBranchStep", - cwd: repoDir, - }); - expect(error.message).toContain("no changes to commit"); + expect(errorMessage).toContain("no changes to commit"); }), ); @@ -1591,7 +1601,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1620,7 +1630,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -1691,7 +1701,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "push-only.txt"), "push only\n"); + fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); yield* runGit(repoDir, ["add", "push-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); @@ -1719,11 +1729,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "push-dirty.txt"), "push dirty\n"); + fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n"); yield* runGit(repoDir, ["add", "push-dirty.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); - NodeFS.mkdirSync(NodePath.join(repoDir, ".vercel")); - NodeFS.writeFileSync(NodePath.join(repoDir, ".vercel", "project.json"), "{}\n"); + fs.mkdirSync(path.join(repoDir, ".vercel")); + fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1754,7 +1764,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); + fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); @@ -1799,7 +1809,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "provider-fallback.txt"), "fallback\n"); + fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); yield* runGit(repoDir, ["add", "provider-fallback.txt"]); yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); const remoteDir = yield* createBareRemote(); @@ -1976,7 +1986,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); @@ -2194,7 +2204,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); @@ -2233,62 +2243,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]); - - const peerDir = yield* makeTempDir("t3code-git-peer-"); - yield* runGit(peerDir, ["clone", remoteDir, "."]); - yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]); - yield* runGit(peerDir, ["config", "user.name", "Peer User"]); - NodeFS.writeFileSync(NodePath.join(peerDir, "remote.txt"), "remote\n"); - yield* runGit(peerDir, ["add", "remote.txt"]); - yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]); - yield* runGit(peerDir, ["push", "origin", "main"]); - - yield* runGit(repoDir, ["fetch", "origin"]); - yield* runGit(repoDir, [ - "checkout", - "--no-track", - "-b", - "feature/remote-base", - "origin/main", - ]); - NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); - yield* runGit(repoDir, ["add", "feature.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); - yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); - - let generatedCommitSummary = ""; - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: ["[]", "[]"], - }, - textGeneration: { - generatePrContent: (input) => { - generatedCommitSummary = input.commitSummary; - return Effect.succeed({ title: "Feature PR", body: "Feature body" }); - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "create_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(generatedCommitSummary).toContain("Feature commit"); - expect(generatedCommitSummary).not.toContain("Remote base commit"); - }), - ); - it.effect( "creates a new PR instead of reusing an unrelated fork PR with the same head branch", () => @@ -2298,7 +2252,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); @@ -2370,7 +2324,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); @@ -2463,10 +2417,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", - cwd: repoDir, - cause: new Error("gh is not available on PATH"), + failWith: new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, }); @@ -2493,10 +2446,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliAuthenticationError({ - command: "gh", - cwd: repoDir, - cause: new Error("gh is not authenticated"), + failWith: new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", }), }, }); @@ -2552,7 +2504,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "local.txt"), "local\n"); + fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); yield* runGit(repoDir, ["add", "local.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); @@ -2593,7 +2545,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "upstream.txt"), "upstream\n"); + fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "upstream.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); @@ -2651,7 +2603,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "no-head-repo.txt"), "upstream\n"); + fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "no-head-repo.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); @@ -2698,7 +2650,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "worktree.txt"), "worktree\n"); + fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); yield* runGit(repoDir, ["add", "worktree.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); @@ -2726,7 +2678,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch).toBe("feature/pr-worktree"); expect(result.worktreePath).not.toBeNull(); - expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); const worktreeBranch = (yield* runGit(result.worktreePath as string, [ "branch", "--show-current", @@ -2735,65 +2687,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("preserves both branch materialization failures when the fallback also fails", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - - const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 93, - title: "Missing fork branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/93", - baseRefName: "main", - headRefName: "feature/missing-fork-branch", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: missingForkDir, - sshUrl: missingForkDir, - }, - }, - }, - }); - - const error = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "93", - mode: "worktree", - }).pipe(Effect.flip); - - if (error._tag !== "GitPullRequestMaterializationError") { - return yield* Effect.die(error); - } - expect(error).toMatchObject({ - cwd: repoDir, - pullRequestNumber: 93, - headRepository: "octocat/codething-mvp", - headBranch: "feature/missing-fork-branch", - localBranch: "t3code/pr-93/feature/missing-fork-branch", - }); - if (!(error.cause instanceof AggregateError)) { - return yield* Effect.die(error.cause); - } - expect(error.cause.errors).toHaveLength(2); - expect(error.cause.errors).toEqual([ - expect.objectContaining({ _tag: "GitCommandError" }), - expect.objectContaining({ _tag: "GitCommandError" }), - ]); - expect(error.cause.cause).toBe(error.cause.errors[0]); - }), - ); - it.effect("launches setup only when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2802,14 +2695,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "setup.txt"), "setup\n"); + fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); yield* runGit(repoDir, ["add", "setup.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); yield* runGit(repoDir, ["checkout", "main"]); - const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const setupCalls: ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -2857,7 +2750,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "fork.txt"), "fork\n"); + fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); yield* runGit(repoDir, ["add", "fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); @@ -2919,7 +2812,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "local-fork.txt"), "local fork\n"); + fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); yield* runGit(repoDir, ["add", "local-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); @@ -2972,7 +2865,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "derived-fork.txt"), "derived fork\n"); + fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); yield* runGit(repoDir, ["add", "derived-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); yield* runGit(repoDir, [ @@ -3024,18 +2917,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "existing.txt"), "existing\n"); + fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); yield* runGit(repoDir, ["add", "existing.txt"]); yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = NodePath.join( - repoDir, - "..", - `pr-existing-${NodePath.basename(repoDir)}`, - ); + const worktreePath = path.join(repoDir, "..", `pr-existing-${path.basename(repoDir)}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); - const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const setupCalls: ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -3063,8 +2952,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { threadId: asThreadId("thread-pr-existing-worktree"), }); - expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( - NodeFS.realpathSync.native(worktreePath), + expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( + fs.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); expect(setupCalls).toHaveLength(0); @@ -3083,7 +2972,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main.txt"), "fork main\n"); + fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); yield* runGit(repoDir, ["add", "fork-main.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); @@ -3143,7 +3032,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main-second.txt"), "fork main second\n"); + fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); yield* runGit(repoDir, ["add", "fork-main-second.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); @@ -3201,16 +3090,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "reused-fork.txt"), "reused fork\n"); + fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); yield* runGit(repoDir, ["add", "reused-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = NodePath.join( - repoDir, - "..", - `pr-reused-fork-${NodePath.basename(repoDir)}`, - ); + const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${path.basename(repoDir)}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); @@ -3242,8 +3127,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { mode: "worktree", }); - expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( - NodeFS.realpathSync.native(worktreePath), + expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( + fs.realpathSync.native(worktreePath), ); expect( (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), @@ -3259,7 +3144,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "setup-failure.txt"), "setup failure\n"); + fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); yield* runGit(repoDir, ["add", "setup-failure.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); @@ -3278,15 +3163,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, setupScriptRunner: { - runForThread: (input) => - Effect.fail( - new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ - threadId: input.threadId, - worktreePath: input.worktreePath, - operation: "openTerminal", - cause: new Error("terminal start failed"), - }), - ), + runForThread: () => + Effect.fail(new ProjectSetupScriptRunnerError({ message: "terminal start failed" })), }, }); @@ -3299,7 +3177,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch).toBe("feature/pr-setup-failure"); expect(result.worktreePath).not.toBeNull(); - expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); }), ); @@ -3339,9 +3217,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "hooked.txt"), "hooked\n"); - NodeFS.writeFileSync( - NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); + fs.writeFileSync( + path.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', { mode: 0o755 }, ); @@ -3402,9 +3280,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "hook-failure.txt"), "broken\n"); - NodeFS.writeFileSync( - NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); + fs.writeFileSync( + path.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', { mode: 0o755 }, ); @@ -3432,18 +3310,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.map((error) => error.message), ); - expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); - expect(errorMessage).not.toContain("hook: fail"); + expect(errorMessage).toContain("hook: fail"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "hook_started", hookName: "pre-commit", }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: fail", - }), expect.objectContaining({ kind: "action_failed", phase: "commit", @@ -3460,7 +3333,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "pr only\n"); + fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index c1090f4f39a..40deeb77da6 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); export type VcsDriverKind = typeof VcsDriverKind.Type; @@ -62,28 +62,28 @@ export interface VcsProcessErrorContext { readonly operation: string; readonly command: string; readonly cwd: string; - readonly argumentCount?: number; } export interface VcsProcessSpawnFailure { readonly cause: unknown; } -export interface VcsProcessTimeoutFailure { - readonly timeoutMs: number; +export interface VcsProcessStdinFailure { + readonly cause: unknown; } -export const VcsProcessExitFailureKind = Schema.Literals([ - "authentication", - "not-found", - "command-failed", -]); -export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type; +export interface VcsProcessReadFailure { + readonly stream: "stdout" | "stderr" | "exitCode"; + readonly cause: unknown; +} + +export interface VcsProcessOutputLimitFailure { + readonly stream: "stdout" | "stderr"; + readonly maxBytes: number; +} -export interface VcsProcessExitFailure { - readonly exitCode: number; - readonly stderr: string; - readonly stderrTruncated: boolean; +export interface VcsProcessTimeoutFailure { + readonly timeoutMs: number; } export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( @@ -92,7 +92,6 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( @@ -159,7 +128,6 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( - "VcsProcessStdinWriteError", +export class VcsOutputDecodeError extends Schema.TaggedErrorClass()( + "VcsOutputDecodeError", { - ...VcsProcessBoundaryErrorFields, - stdinBytes: NonNegativeInt, - cause: Schema.Defect(), + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; + return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; } -} -export class VcsProcessOutputReadError extends Schema.TaggedErrorClass()( - "VcsProcessOutputReadError", - { - ...VcsProcessBoundaryErrorFields, - stream: Schema.Literals(["stdout", "stderr", "exitCode"]), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `VCS process failed to read ${this.stream} in ${this.operation}: ${this.command} (${this.cwd})`; + static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { + return new VcsOutputDecodeError({ + ...context, + detail: "failed to write process stdin", + cause: error.cause, + }); } -} -export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass()( - "VcsProcessOutputLimitError", - { - ...VcsProcessBoundaryErrorFields, - stream: Schema.Literals(["stdout", "stderr"]), - maxBytes: NonNegativeInt, - observedBytes: NonNegativeInt, - }, -) { - override get message(): string { - return `VCS process ${this.stream} produced ${this.observedBytes} bytes in ${this.operation}: ${this.command} (${this.cwd}), exceeding the ${this.maxBytes} byte limit`; + static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { + return new VcsOutputDecodeError({ + ...context, + detail: + error.stream === "exitCode" + ? "failed to read process exit code" + : `failed to read process ${error.stream}`, + cause: error.cause, + }); } -} -export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass()( - "VcsProcessMissingExitCodeError", - VcsProcessBoundaryErrorFields, -) { - override get message(): string { - return `VCS process completed without an exit code in ${this.operation}: ${this.command} (${this.cwd})`; + static fromProcessOutputLimitError( + context: VcsProcessErrorContext, + error: VcsProcessOutputLimitFailure, + ) { + return new VcsOutputDecodeError({ + ...context, + detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, + }); } -} -export const VcsOutputDecodeError = Schema.Union([ - VcsProcessStdinWriteError, - VcsProcessOutputReadError, - VcsProcessOutputLimitError, - VcsProcessMissingExitCodeError, -]); -export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type; + static missingExitCode(context: VcsProcessErrorContext) { + return new VcsOutputDecodeError({ + ...context, + detail: "process completed without an exit code", + }); + } +} export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( "VcsRepositoryDetectionError", @@ -270,10 +225,7 @@ export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, VcsProcessTimeoutError, - VcsProcessStdinWriteError, - VcsProcessOutputReadError, - VcsProcessOutputLimitError, - VcsProcessMissingExitCodeError, + VcsOutputDecodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, ]); From aaea4e185af9bc0519e2f6c64d7aa27b763d2951 Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Fri, 26 Jun 2026 01:32:48 -0400 Subject: [PATCH 20/23] Type-safe Phase 3C provider context --- .../src/sourceControl/AzureDevOpsSourceControlProvider.ts | 7 +++++-- .../src/sourceControl/BitbucketSourceControlProvider.ts | 4 +++- .../src/sourceControl/GitHubSourceControlProvider.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index d9ad089113f..836ca3d3823 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -19,10 +19,13 @@ function providerError( provider: "azure-devops", operation, detail: cause.detail, - command: cause.command, + command: typeof cause.command === "string" ? cause.command : undefined, cwd: SourceControlProvider.transportSafeSourceControlErrorValue(context.cwd ?? cause.cwd), reference: SourceControlProvider.transportSafeSourceControlErrorValue( - context.reference ?? ("reference" in cause ? cause.reference : undefined), + context.reference ?? + ("reference" in cause && typeof cause.reference === "string" + ? cause.reference + : undefined), ), repository: SourceControlProvider.transportSafeSourceControlErrorValue(context.repository), cause, diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index bd61c6762f5..c770a0b1499 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -27,7 +27,9 @@ function providerError( provider: "bitbucket", operation, detail, - cwd: SourceControlProvider.transportSafeSourceControlErrorValue(context.cwd ?? cause.cwd), + cwd: SourceControlProvider.transportSafeSourceControlErrorValue( + context.cwd ?? ("cwd" in cause && typeof cause.cwd === "string" ? cause.cwd : undefined), + ), reference: SourceControlProvider.transportSafeSourceControlErrorValue( context.reference ?? ("reference" in cause ? cause.reference : undefined), ), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 73d9066d5fb..bf18d899edb 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -298,4 +298,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); From 1b36e3e1daa41d935cc73f1cc7c669190bbe8bc7 Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Fri, 26 Jun 2026 01:33:41 -0400 Subject: [PATCH 21/23] Defer incompatible provider diagnostics --- .../src/sourceControl/AzureDevOpsCli.test.ts | 78 +-- .../src/sourceControl/AzureDevOpsCli.ts | 403 +++++--------- .../AzureDevOpsSourceControlProvider.test.ts | 47 +- .../AzureDevOpsSourceControlProvider.ts | 67 +-- .../src/sourceControl/BitbucketApi.test.ts | 191 +------ apps/server/src/sourceControl/BitbucketApi.ts | 378 +++++-------- .../BitbucketSourceControlProvider.test.ts | 48 +- .../BitbucketSourceControlProvider.ts | 75 +-- .../src/sourceControl/GitHubCli.test.ts | 53 +- apps/server/src/sourceControl/GitHubCli.ts | 373 +++++-------- .../GitHubSourceControlProvider.test.ts | 46 +- .../GitHubSourceControlProvider.ts | 194 ++----- .../src/sourceControl/GitLabCli.test.ts | 54 +- apps/server/src/sourceControl/GitLabCli.ts | 526 ++++++------------ .../GitLabSourceControlProvider.test.ts | 50 +- .../GitLabSourceControlProvider.ts | 66 +-- .../sourceControl/SourceControlProvider.ts | 18 - packages/contracts/src/sourceControl.ts | 4 - 18 files changed, 749 insertions(+), 1922 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 1cd4b388552..f3078fcd06c 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -4,9 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -19,7 +17,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const supportLayer = Layer.mergeAll( Layer.mock(VcsProcess.VcsProcess)({ @@ -331,78 +329,4 @@ describe("AzureDevOpsCli.layer", () => { }); }).pipe(Effect.provide(layer)), ); - - it.effect("preserves VCS causes without copying upstream details into messages", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "AzureDevOpsCli.execute", - command: "az repos list --organization sensitive-upstream-detail", - cwd: "/repo", - exitCode: 1, - detail: "sensitive-upstream-detail", - }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); - - assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); - assert.strictEqual(error.operation, "execute"); - assert.strictEqual(error.command, "az"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.argumentCount, 2); - assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("sensitive-upstream-detail"), false); - }).pipe(Effect.provide(layer)), - ); - - it.effect("does not report a missing working directory as a missing Azure CLI", () => - Effect.gen(function* () { - const cwd = "/missing/repo"; - const platformCause = PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - syscall: "chdir", - pathOrDescriptor: cwd, - }); - const cause = new VcsProcessSpawnError({ - operation: "AzureDevOpsCli.execute", - command: "az", - cwd, - argumentCount: 2, - cause: platformCause, - }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); - - assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); - assert.strictEqual(error.cwd, cwd); - assert.strictEqual(error.cause, cause); - }).pipe(Effect.provide(layer)), - ); - - it.effect("keeps invalid pull request output diagnostics structured", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); - - assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.command, "az"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.outputLength, 8); - assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); - assert.exists(error.cause); - assert.strictEqual( - error.message, - "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", - ); - }).pipe(Effect.provide(layer)), - ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 609efe4df4c..e39ce9f0100 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,254 +1,161 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; import { - NonNegativeInt, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { - decodeAzureDevOpsPullRequestJson, - decodeAzureDevOpsPullRequestListJson, - type NormalizedAzureDevOpsPullRequestRecord, -} from "./azureDevOpsPullRequests.ts"; +import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -const azureDevOpsCommandErrorFields = { - operation: Schema.Literal("execute"), - command: Schema.Literal("az"), - cwd: Schema.String, - argumentCount: NonNegativeInt, - cause: Schema.Defect(), -}; - -export class AzureDevOpsCliUnavailableError extends Schema.TaggedErrorClass()( - "AzureDevOpsCliUnavailableError", - azureDevOpsCommandErrorFields, +export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, ) { - get detail(): string { - return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; - } - override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } -export class AzureDevOpsCliAuthenticationError extends Schema.TaggedErrorClass()( - "AzureDevOpsCliAuthenticationError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } +export interface AzureDevOpsRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; } -export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass()( - "AzureDevOpsPullRequestNotFoundError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } +export interface AzureDevOpsCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect< + ReadonlyArray, + AzureDevOpsCliError + >; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect< + AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, + AzureDevOpsCliError + >; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; } -export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( - "AzureDevOpsCommandFailedError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Azure DevOps CLI command failed."; - } +export class AzureDevOpsCli extends Context.Service()( + "t3/sourceControl/AzureDevOpsCli", +) {} - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); } - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "az"; - readonly cwd: string; - readonly argumentCount: number; - }, - cause: VcsError, - ): AzureDevOpsCliError { - const fields = { ...context, cause }; - - if ( - cause._tag === "VcsProcessSpawnError" && - cause.cause instanceof PlatformError.PlatformError && - cause.cause.reason._tag === "NotFound" && - cause.cause.reason.pathOrDescriptor !== context.cwd && - cause.cause.reason.syscall !== "chdir" - ) { - return new AzureDevOpsCliUnavailableError(fields); - } - - if (cause._tag === "VcsProcessExitError") { - if (cause.failureKind === "authentication") { - return new AzureDevOpsCliAuthenticationError(fields); - } - if (cause.failureKind === "not-found") { - return new AzureDevOpsPullRequestNotFoundError(fields); - } - } - - return new AzureDevOpsCommandFailedError(fields); - } + return String(error); } -const azureDevOpsDecodeErrorFields = { - command: Schema.Literal("az"), - cwd: Schema.String, - outputLength: NonNegativeInt, - cause: Schema.Defect(), -}; - -export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedErrorClass()( - "AzureDevOpsPullRequestListDecodeError", - { - operation: Schema.Literal("listPullRequests"), - ...azureDevOpsDecodeErrorFields, - }, -) { - get detail(): string { - return "Azure DevOps CLI returned invalid PR list JSON."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedErrorClass()( - "AzureDevOpsPullRequestDecodeError", - { - operation: Schema.Literal("getPullRequest"), - ...azureDevOpsDecodeErrorFields, - }, -) { - get detail(): string { - return "Azure DevOps CLI returned invalid pull request JSON."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; +function normalizeAzureDevOpsCliError( + operation: "execute", + error: VcsError | unknown, +): AzureDevOpsCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: az") || lower.includes("enoent")) { + return new AzureDevOpsCliError({ + operation, + detail: + "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", + cause: error, + }); } -} -const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ - "getRepositoryCloneUrls", - "getDefaultBranch", - "createRepository", -]); - -export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass()( - "AzureDevOpsRepositoryDecodeError", - { - operation: AzureDevOpsRepositoryDecodeOperation, - ...azureDevOpsDecodeErrorFields, - }, -) { - get detail(): string { - return "Azure DevOps CLI returned invalid repository JSON."; + if ( + lower.includes("az devops login") || + lower.includes("please run az login") || + lower.includes("not logged in") || + lower.includes("authentication failed") || + lower.includes("unauthorized") + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", + cause: error, + }); } - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + if ( + lower.includes("pull request") && + (lower.includes("not found") || lower.includes("does not exist")) + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); } -} - -export const AzureDevOpsCliError = Schema.Union([ - AzureDevOpsCliUnavailableError, - AzureDevOpsCliAuthenticationError, - AzureDevOpsPullRequestNotFoundError, - AzureDevOpsCommandFailedError, - AzureDevOpsPullRequestListDecodeError, - AzureDevOpsPullRequestDecodeError, - AzureDevOpsRepositoryDecodeError, -]); -export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; -export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); - -export interface AzureDevOpsRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; + return new AzureDevOpsCliError({ + operation, + detail: text, + cause: error, + }); } -export class AzureDevOpsCli extends Context.Service< - AzureDevOpsCli, - { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, AzureDevOpsCliError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly remoteName?: string; - }) => Effect.Effect; - } ->()("t3/sourceControl/AzureDevOpsCli") {} - function normalizeChangeRequestId(reference: string): string { const trimmed = reference.trim().replace(/^#/, ""); const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); @@ -317,27 +224,25 @@ function parseRepositorySpecifier(repository: string): { function decodeAzureDevOpsJson( raw: string, schema: S, - operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, - cwd: string, -): Effect.Effect { + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", + invalidDetail: string, +): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( - (cause) => - new AzureDevOpsRepositoryDecodeError({ + (error) => + new AzureDevOpsCliError({ operation, - command: "az", - cwd, - outputLength: raw.length, - cause, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, }), ), ); } -export const make = Effect.gen(function* () { +export const make = Effect.fn("makeAzureDevOpsCli")(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: AzureDevOpsCli["Service"]["execute"] = (input) => + const execute: AzureDevOpsCliShape["execute"] = (input) => process .run({ operation: "AzureDevOpsCli.execute", @@ -346,21 +251,9 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe( - Effect.mapError((error) => - AzureDevOpsCommandFailedError.fromVcsError( - { - operation: "execute", - command: "az", - cwd: input.cwd, - argumentCount: input.args.length, - }, - error, - ), - ), - ); + .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); - const executeJson = (input: Parameters[0]) => + const executeJson = (input: Parameters[0]) => execute({ ...input, args: [...input.args, "--only-show-errors", "--output", "json"], @@ -389,15 +282,15 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( + : Effect.sync(() => + AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), + ).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsPullRequestListDecodeError({ + new AzureDevOpsCliError({ operation: "listPullRequests", - command: "az", - cwd: input.cwd, - outputLength: raw.length, + detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -423,15 +316,13 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsPullRequestDecodeError({ + new AzureDevOpsCliError({ operation: "getPullRequest", - command: "az", - cwd: input.cwd, - outputLength: raw.length, + detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -453,7 +344,7 @@ export const make = Effect.gen(function* () { raw, RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", - input.cwd, + "Azure DevOps CLI returned invalid repository JSON.", ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -478,7 +369,12 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "createRepository", + "Azure DevOps CLI returned invalid repository JSON.", + ), ), Effect.map(normalizeRepositoryCloneUrls), ); @@ -510,7 +406,12 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getDefaultBranch", + "Azure DevOps CLI returned invalid repository JSON.", + ), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ), @@ -533,4 +434,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(AzureDevOpsCli, make); +export const layer = Layer.effect(AzureDevOpsCli, make()); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21a3183a88f..4ba3777159b 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -function makeProvider(azure: Partial) { +function makeProvider(azure: Partial) { return AzureDevOpsSourceControlProvider.make().pipe( Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), ); @@ -46,51 +46,10 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); -it.effect("adds change-request context while retaining Azure CLI causes", () => - Effect.gen(function* () { - const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ - operation: "execute", - command: "az", - cwd: "/repo", - argumentCount: 2, - cause: new Error("raw upstream detail that should remain in the cause"), - }); - const provider = yield* makeProvider({ - checkoutPullRequest: () => Effect.fail(cause), - }); - - const error = yield* provider - .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - command: error.command, - cwd: error.cwd, - reference: error.reference, - detail: error.detail, - }, - { - provider: "azure-devops", - operation: "checkoutChangeRequest", - command: "az", - cwd: "/repo", - reference: "#42", - detail: "Azure DevOps CLI command failed.", - }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); - it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 836ca3d3823..8d8e081cb89 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -9,25 +9,11 @@ import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscover function providerError( operation: string, cause: AzureDevOpsCli.AzureDevOpsCliError, - context: { - readonly cwd?: string; - readonly reference?: string; - readonly repository?: string; - } = {}, ): SourceControlProviderError { return new SourceControlProviderError({ provider: "azure-devops", operation, detail: cause.detail, - command: typeof cause.command === "string" ? cause.command : undefined, - cwd: SourceControlProvider.transportSafeSourceControlErrorValue(context.cwd ?? cause.cwd), - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - context.reference ?? - ("reference" in cause && typeof cause.reference === "string" - ? cause.reference - : undefined), - ), - repository: SourceControlProvider.transportSafeSourceControlErrorValue(context.repository), cause, }); } @@ -111,20 +97,13 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => - providerError("listChangeRequests", error, { - cwd: input.cwd, - reference: input.headSelector, - }), - ), + Effect.mapError((error) => providerError("listChangeRequests", error)), ); }, getChangeRequest: (input) => azure.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => - providerError("getChangeRequest", error, { cwd: input.cwd, reference: input.reference }), - ), + Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -138,37 +117,20 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* title: input.title, bodyFile: input.bodyFile, }) - .pipe( - Effect.mapError((error) => - providerError("createChangeRequest", error, { - cwd: input.cwd, - reference: input.headSelector, - }), - ), - ); + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, getRepositoryCloneUrls: (input) => - azure.getRepositoryCloneUrls(input).pipe( - Effect.mapError((error) => - providerError("getRepositoryCloneUrls", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), - ), + azure + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), createRepository: (input) => - azure.createRepository(input).pipe( - Effect.mapError((error) => - providerError("createRepository", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), - ), + azure + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => azure .getDefaultBranch({ cwd: input.cwd }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error, input))), + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => azure .checkoutPullRequest({ @@ -176,14 +138,7 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* reference: input.reference, ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) - .pipe( - Effect.mapError((error) => - providerError("checkoutChangeRequest", error, { - cwd: input.cwd, - reference: input.reference, - }), - ), - ), + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), }); }); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a9759ace0b..eb3001b3d06 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -6,14 +6,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { - HttpClient, - HttpClientError, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; - -import { GitCommandError } from "@t3tools/contracts"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -59,46 +53,41 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; - readonly requestFailure?: ( - request: HttpClientRequest.HttpClientRequest, - ) => HttpClientError.HttpClientError; - readonly git?: Partial; + readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => - input.requestFailure - ? Effect.fail(input.requestFailure(request)) - : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { - readConfigValue: vi.fn(() => + readConfigValue: vi.fn(() => Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), ), - resolvePrimaryRemoteName: vi.fn< - GitVcsDriver.GitVcsDriver["Service"]["resolvePrimaryRemoteName"] - >(() => Effect.succeed("origin")), - ensureRemote: vi.fn(() => + resolvePrimaryRemoteName: vi.fn( + () => Effect.succeed("origin"), + ), + ensureRemote: vi.fn(() => Effect.succeed("octocat"), ), - fetchRemoteBranch: vi.fn( + fetchRemoteBranch: vi.fn( () => Effect.void, ), - fetchRemoteTrackingBranch: vi.fn< - GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] - >(() => Effect.void), - setBranchUpstream: vi.fn( + fetchRemoteTrackingBranch: vi.fn( () => Effect.void, ), - switchRef: vi.fn((request) => + setBranchUpstream: vi.fn( + () => Effect.void, + ), + switchRef: vi.fn((request) => Effect.succeed({ refName: request.refName }), ), - listLocalBranchNames: vi.fn(() => + listLocalBranchNames: vi.fn(() => Effect.succeed([]), ), }; const git = { ...gitMock, ...input.git, - } satisfies Partial; + } satisfies Partial; const driver = { listRemotes: () => @@ -117,7 +106,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const layer = BitbucketApi.layer.pipe( Layer.provide( @@ -141,7 +130,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriver.VcsDriver["Service"], + driver: driver as unknown as VcsDriver.VcsDriverShape, }), }), ), @@ -150,9 +139,9 @@ function makeLayer(input: { ConfigProvider.layer( ConfigProvider.fromEnv({ env: { - T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", - T3CODE_BITBUCKET_EMAIL: "user@example.com", - T3CODE_BITBUCKET_API_TOKEN: "token", + MORECODE_T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", + MORECODE_T3CODE_BITBUCKET_EMAIL: "user@example.com", + MORECODE_T3CODE_BITBUCKET_API_TOKEN: "token", }, }), ), @@ -508,97 +497,6 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); -it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { - const transportCause = new Error("socket reset by peer"); - let requestFailure: HttpClientError.HttpClientError | undefined; - const { layer } = makeLayer({ - response: () => Response.json({}), - requestFailure: (request) => { - requestFailure = new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - cause: transportCause, - }), - }); - return requestFailure; - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.getPullRequest({ - cwd: "/repo", - reference: "42", - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", - ); - assert.strictEqual(error.cause, requestFailure); - assert.strictEqual(requestFailure?.cause, transportCause); - }).pipe(Effect.provide(layer)); -}); - -it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { - const responseBody = '{"error":{"message":"credential=secret-value"}}'; - const { layer } = makeLayer({ - response: () => new Response(responseBody, { status: 403 }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .checkoutPullRequest({ cwd: "/repo", reference: "42" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.status, 403); - assert.strictEqual(error.responseBodyLength, responseBody.length); - assert.notProperty(error, "responseBody"); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.", - ); - assert.notInclude(error.message, "secret-value"); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { - const cause = new Error("response stream failed"); - const { layer } = makeLayer({ - response: () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(cause), - }), - { status: 502 }, - ), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .getPullRequest({ cwd: "/repo", reference: "42" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.status, 502); - assert.instanceOf(error.cause, HttpClientError.HttpClientError); - assert.strictEqual(error.cause.cause, cause); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", - ); - }).pipe(Effect.provide(layer)); -}); - it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -651,51 +549,6 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket }).pipe(Effect.provide(layer)); }); -it.effect("preserves Git checkout failures without deriving the domain message from them", () => { - const gitCause = new GitCommandError({ - operation: "fetchRemoteBranch", - command: "git fetch origin feature/source-control", - cwd: "/repo", - detail: "remote rejected the request", - }); - const { layer } = makeLayer({ - response: () => - Response.json({ - ...bitbucketPullRequest, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - git: { - fetchRemoteBranch: () => Effect.fail(gitCause), - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.checkoutPullRequest({ - cwd: "/repo", - reference: "42", - force: true, - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.reference, "42"); - assert.strictEqual( - error.message, - "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", - ); - assert.strictEqual(error.cause, gitCause); - }).pipe(Effect.provide(layer)); -}); - it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index f7d7f6671a4..d287c9c5527 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,7 +6,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { - NonNegativeInt, TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, @@ -16,12 +15,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { sanitizeBranchFragment } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { - BitbucketPullRequestListSchema, - BitbucketPullRequestSchema, - normalizeBitbucketPullRequestRecord, - type NormalizedBitbucketPullRequestRecord, -} from "./bitbucketPullRequests.ts"; +import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -29,164 +23,28 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; const BitbucketApiEnvConfig = Config.all({ - baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( + baseUrl: Config.string("MORECODE_T3CODE_BITBUCKET_API_BASE_URL").pipe( Config.withDefault(DEFAULT_API_BASE_URL), ), - accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), - email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), - apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), + accessToken: Config.string("MORECODE_T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), + email: Config.string("MORECODE_T3CODE_BITBUCKET_EMAIL").pipe(Config.option), + apiToken: Config.string("MORECODE_T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), }); -const BitbucketApiOperation = Schema.Literals([ - "resolveRepository", - "getRepository", - "getBranchingModel", - "getPullRequest", - "listPullRequests", - "createRepository", - "createPullRequest", - "probeAuth", - "checkoutPullRequest", -]); -type BitbucketApiOperation = typeof BitbucketApiOperation.Type; - -export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass()( - "BitbucketRepositoryLocatorError", - { - repository: Schema.String, - }, -) { - override get message(): string { - return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository."; - } -} - -export class BitbucketRequestError extends Schema.TaggedErrorClass()( - "BitbucketRequestError", - { - operation: BitbucketApiOperation, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Bitbucket API failed in ${this.operation}: Failed to send the Bitbucket request.`; - } -} - -export class BitbucketResponseError extends Schema.TaggedErrorClass()( - "BitbucketResponseError", - { - operation: BitbucketApiOperation, - status: Schema.Int, - responseBodyLength: NonNegativeInt, - }, -) { - override get message(): string { - return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; - } -} - -export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass()( - "BitbucketResponseBodyReadError", - { - operation: BitbucketApiOperation, - status: Schema.Int, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; - } -} - -export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass()( - "BitbucketResponseDecodeError", - { - operation: BitbucketApiOperation, - status: Schema.Int, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Bitbucket API failed in ${this.operation}: Bitbucket returned invalid JSON for the requested resource.`; - } -} - -export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass()( - "BitbucketRepositoryVcsResolveError", - { - cwd: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Bitbucket API failed in resolveRepository: Failed to resolve VCS repository for ${this.cwd}.`; - } -} - -export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass()( - "BitbucketRepositoryRemotesListError", - { - cwd: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Bitbucket API failed in resolveRepository: Failed to list remotes for ${this.cwd}.`; - } -} - -export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass()( - "BitbucketRepositoryRemoteNotFoundError", - { - cwd: Schema.String, - }, -) { - override get message(): string { - return `Bitbucket API failed in resolveRepository: No Bitbucket repository remote was detected for ${this.cwd}.`; - } -} - -export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass()( - "BitbucketPullRequestBodyReadError", +export class BitbucketApiError extends Schema.TaggedErrorClass()( + "BitbucketApiError", { - cwd: Schema.String, - bodyFile: Schema.String, - cause: Schema.Defect(), + operation: Schema.String, + detail: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Bitbucket API failed in createPullRequest: Failed to read pull request body file ${this.bodyFile}.`; + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; } } - -export class BitbucketCheckoutError extends Schema.TaggedErrorClass()( - "BitbucketCheckoutError", - { - cwd: Schema.String, - reference: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request."; - } -} - -export const BitbucketApiError = Schema.Union([ - BitbucketRepositoryLocatorError, - BitbucketRequestError, - BitbucketResponseError, - BitbucketResponseBodyReadError, - BitbucketResponseDecodeError, - BitbucketRepositoryVcsResolveError, - BitbucketRepositoryRemotesListError, - BitbucketRepositoryRemoteNotFoundError, - BitbucketPullRequestBodyReadError, - BitbucketCheckoutError, -]); -export type BitbucketApiError = typeof BitbucketApiError.Type; -export const isBitbucketApiError = Schema.is(BitbucketApiError); +const isBitbucketApiErrorValue = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({ full_name: TrimmedNonEmptyString, @@ -242,55 +100,62 @@ export interface BitbucketRepositoryLocator { readonly repoSlug: string; } -export class BitbucketApi extends Context.Service< - BitbucketApi, - { - readonly probeAuth: Effect.Effect; - readonly listPullRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, BitbucketApiError>; - readonly getPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly createPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/BitbucketApi") {} +export interface BitbucketApiShape { + readonly probeAuth: Effect.Effect; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect< + ReadonlyArray, + BitbucketApiError + >; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect< + BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, + BitbucketApiError + >; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class BitbucketApi extends Context.Service()( + "t3/sourceControl/BitbucketApi", +) {} function nonEmpty(value: string | undefined): Option.Option { const trimmed = value?.trim(); @@ -346,14 +211,16 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator } function requireRepositoryLocator( + operation: string, repository: string, ): Effect.Effect { const locator = parseBitbucketRepositorySlug(repository); return locator ? Effect.succeed(locator) : Effect.fail( - new BitbucketRepositoryLocatorError({ - repository, + new BitbucketApiError({ + operation, + detail: "Bitbucket repositories must be specified as workspace/repository.", }), ); } @@ -432,7 +299,9 @@ function checkoutBranchName(input: { } function repositoryNameWithOwner( - repository: Schema.Schema.Type["source"]["repository"], + repository: Schema.Schema.Type< + typeof BitbucketPullRequests.BitbucketPullRequestSchema + >["source"]["repository"], ): string | null { const fullName = repository?.full_name?.trim() ?? ""; return fullName.length > 0 ? fullName : null; @@ -468,37 +337,45 @@ function authFromConfig( account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Set MORECODE_T3CODE_BITBUCKET_EMAIL and MORECODE_T3CODE_BITBUCKET_API_TOKEN, or MORECODE_T3CODE_BITBUCKET_ACCESS_TOKEN.", ), }; } +function requestError(operation: string, cause: unknown): BitbucketApiError { + return new BitbucketApiError({ + operation, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); +} + +function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { + return isBitbucketApiErrorValue(cause); +} + function responseError( - operation: BitbucketApiOperation, + operation: string, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { return response.text.pipe( - Effect.mapError( - (cause) => - new BitbucketResponseBodyReadError({ - operation, - status: response.status, - cause, - }), - ), + Effect.orElseSucceed(() => ""), Effect.flatMap((body) => Effect.fail( - new BitbucketResponseError({ + new BitbucketApiError({ operation, status: response.status, - responseBodyLength: body.length, + detail: + body.trim().length > 0 + ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` + : `Bitbucket returned HTTP ${response.status}.`, }), ), ), ); } -export const make = Effect.gen(function* () { +export const make = Effect.fn("makeBitbucketApi")(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; @@ -518,7 +395,7 @@ export const make = Effect.gen(function* () { }; const decodeResponse = ( - operation: BitbucketApiOperation, + operation: string, schema: S, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect => @@ -527,9 +404,9 @@ export const make = Effect.gen(function* () { HttpClientResponse.schemaBodyJson(schema)(success).pipe( Effect.mapError( (cause) => - new BitbucketResponseDecodeError({ + new BitbucketApiError({ operation, - status: success.status, + detail: "Bitbucket returned invalid JSON for the requested resource.", cause, }), ), @@ -538,18 +415,12 @@ export const make = Effect.gen(function* () { })(response); const executeJson = ( - operation: BitbucketApiOperation, + operation: string, request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( - Effect.mapError( - (cause) => - new BitbucketRequestError({ - operation, - cause, - }), - ), + Effect.mapError((cause) => requestError(operation, cause)), Effect.flatMap((response) => decodeResponse(operation, schema, response)), ); @@ -571,8 +442,9 @@ export const make = Effect.gen(function* () { const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( Effect.mapError( (cause) => - new BitbucketRepositoryVcsResolveError({ - cwd: input.cwd, + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to resolve VCS repository for ${input.cwd}.`, cause, }), ), @@ -580,8 +452,9 @@ export const make = Effect.gen(function* () { const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( Effect.mapError( (cause) => - new BitbucketRepositoryRemotesListError({ - cwd: input.cwd, + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to list remotes for ${input.cwd}.`, cause, }), ), @@ -593,8 +466,9 @@ export const make = Effect.gen(function* () { if (parsed) return parsed; } - return yield* new BitbucketRepositoryRemoteNotFoundError({ - cwd: input.cwd, + return yield* new BitbucketApiError({ + operation: "resolveRepository", + detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, }); }); @@ -637,7 +511,7 @@ export const make = Effect.gen(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, ), ), - BitbucketPullRequestSchema, + BitbucketPullRequests.BitbucketPullRequestSchema, ); const getRawPullRequest = (input: { @@ -725,17 +599,21 @@ export const make = Effect.gen(function* () { ), { urlParams: query }, ), - BitbucketPullRequestListSchema, + BitbucketPullRequests.BitbucketPullRequestListSchema, ); }), - Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), + Effect.map((list) => + list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), ), getPullRequest: (input) => - getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), + getRawPullRequest(input).pipe( + Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => - requireRepositoryLocator(input.repository).pipe( + requireRepositoryLocator("createRepository", input.repository).pipe( Effect.flatMap((repository) => executeJson( "createRepository", @@ -760,9 +638,9 @@ export const make = Effect.gen(function* () { const description = yield* fileSystem.readFileString(input.bodyFile).pipe( Effect.mapError( (cause) => - new BitbucketPullRequestBodyReadError({ - cwd: input.cwd, - bodyFile: input.bodyFile, + new BitbucketApiError({ + operation: "createPullRequest", + detail: `Failed to read pull request body file ${input.bodyFile}.`, cause, }), ), @@ -797,7 +675,7 @@ export const make = Effect.gen(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, ), ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), - BitbucketPullRequestSchema, + BitbucketPullRequests.BitbucketPullRequestSchema, ); }), getDefaultBranch: (input) => @@ -878,9 +756,9 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => isBitbucketApiError(cause) ? cause - : new BitbucketCheckoutError({ - cwd: input.cwd, - reference: input.reference, + : new BitbucketApiError({ + operation: "checkoutPullRequest", + detail: cause instanceof Error ? cause.message : String(cause), cause, }), ), @@ -888,4 +766,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(BitbucketApi, make); +export const layer = Layer.effect(BitbucketApi, make()); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 3e557af7642..07a3d386a35 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -function makeProvider(bitbucket: Partial) { +function makeProvider(bitbucket: Partial) { return BitbucketSourceControlProvider.make().pipe( Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), ); @@ -51,48 +51,9 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( }), ); -it.effect("adds repository context while retaining Bitbucket API causes", () => - Effect.gen(function* () { - const upstreamCause = new Error("raw upstream failure"); - const cause = new BitbucketApi.BitbucketRequestError({ - operation: "getRepository", - cause: upstreamCause, - }); - const provider = yield* makeProvider({ - getRepositoryCloneUrls: () => Effect.fail(cause), - }); - - const error = yield* provider - .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - command: error.command, - cwd: error.cwd, - repository: error.repository, - detail: error.detail, - }, - { - provider: "bitbucket", - operation: "getRepositoryCloneUrls", - command: undefined, - cwd: "/repo", - repository: "owner/repo", - detail: "Failed to get repository clone URLs.", - }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(upstreamCause.message), false); - }), -); - it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = - null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listPullRequests: (input) => { listInput = input; @@ -118,9 +79,8 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () => it.effect("creates Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index c770a0b1499..86e17245a6d 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -11,31 +11,11 @@ import type * as SourceControlProviderDiscovery from "./SourceControlProviderDis function providerError( operation: string, cause: BitbucketApi.BitbucketApiError, - context: { - readonly cwd?: string; - readonly reference?: string; - readonly repository?: string; - } = {}, ): SourceControlProviderError { - const detail = - operation === "getRepositoryCloneUrls" - ? "Failed to get repository clone URLs." - : "detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0 - ? cause.detail - : cause.message; return new SourceControlProviderError({ provider: "bitbucket", operation, - detail, - cwd: SourceControlProvider.transportSafeSourceControlErrorValue( - context.cwd ?? ("cwd" in cause && typeof cause.cwd === "string" ? cause.cwd : undefined), - ), - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - context.reference ?? ("reference" in cause ? cause.reference : undefined), - ), - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - context.repository ?? ("repository" in cause ? cause.repository : undefined), - ), + detail: cause.detail, cause, }); } @@ -82,20 +62,13 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => - providerError("listChangeRequests", error, { - cwd: input.cwd, - reference: input.headSelector, - }), - ), + Effect.mapError((error) => providerError("listChangeRequests", error)), ); }, getChangeRequest: (input) => bitbucket.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => - providerError("getChangeRequest", error, { cwd: input.cwd, reference: input.reference }), - ), + Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -110,40 +83,23 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () title: input.title, bodyFile: input.bodyFile, }) - .pipe( - Effect.mapError((error) => - providerError("createChangeRequest", error, { - cwd: input.cwd, - reference: input.headSelector, - }), - ), - ); + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, getRepositoryCloneUrls: (input) => - bitbucket.getRepositoryCloneUrls(input).pipe( - Effect.mapError((error) => - providerError("getRepositoryCloneUrls", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), - ), + bitbucket + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), createRepository: (input) => - bitbucket.createRepository(input).pipe( - Effect.mapError((error) => - providerError("createRepository", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), - ), + bitbucket + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => bitbucket .getDefaultBranch({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error, input))), + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => bitbucket .checkoutPullRequest({ @@ -152,14 +108,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () reference: input.reference, ...(input.force !== undefined ? { force: input.force } : {}), }) - .pipe( - Effect.mapError((error) => - providerError("checkoutChangeRequest", error, { - cwd: input.cwd, - reference: input.reference, - }), - ), - ), + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), }); }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..fb765b352c2 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,9 +1,8 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import { VcsProcessExitError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -16,7 +15,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( @@ -31,27 +30,6 @@ afterEach(() => { }); describe("GitHubCli.layer", () => { - it("does not classify a missing cwd as an unavailable gh executable", () => { - const context = { command: "gh", cwd: "/repo" } as const; - const missingCwd = new VcsProcessSpawnError({ - operation: "GitHubCli.execute", - command: "gh", - cwd: context.cwd, - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "access", - pathOrDescriptor: context.cwd, - }), - }); - - const commandFailure = GitHubCli.fromVcsError(context, missingCwd); - - assert.equal(commandFailure._tag, "GitHubCliCommandError"); - assert.strictEqual(commandFailure.cause, missingCwd); - assert.notProperty(commandFailure, "operation"); - }); - it.effect("parses pull request view output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -291,16 +269,18 @@ describe("GitHubCli.layer", () => { it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh pr view", - cwd: "/repo", - exitCode: 1, - failureKind: "not-found", - detail: - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }), + ), + ); const gh = yield* GitHubCli.GitHubCli; const error = yield* gh @@ -311,11 +291,6 @@ describe("GitHubCli.layer", () => { .pipe(Effect.flip); assert.equal(error.message.includes("Pull request not found"), true); - assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); - assert.strictEqual(error.command, "gh"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..d6c858c28bd 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,9 +1,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; import { TrimmedNonEmptyString, @@ -12,249 +12,154 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { - decodeGitHubPullRequestJson, - decodeGitHubPullRequestListJson, -} from "./gitHubPullRequests.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -const gitHubCliFailureFields = { - command: Schema.Literal("gh"), - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubCliUnavailableError extends Schema.TaggedErrorClass()( - "GitHubCliUnavailableError", - gitHubCliFailureFields, -) { - get detail(): string { - return "GitHub CLI (`gh`) is required but not available on PATH."; - } - +export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { override get message(): string { - return `GitHub CLI failed in execute: ${this.detail}`; + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; } } -export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass()( - "GitHubCliAuthenticationError", - gitHubCliFailureFields, -) { - get detail(): string { - return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; - } - - override get message(): string { - return `GitHub CLI failed in execute: ${this.detail}`; - } +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; } -export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( - "GitHubPullRequestNotFoundError", - gitHubCliFailureFields, -) { - get detail(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } - - override get message(): string { - return `GitHub CLI failed in execute: ${this.detail}`; - } +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; } -export class GitHubCliCommandError extends Schema.TaggedErrorClass()( - "GitHubCliCommandError", - gitHubCliFailureFields, -) { - get detail(): string { - return "GitHub CLI command failed."; - } +export interface GitHubCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; - override get message(): string { - return `GitHub CLI failed in execute: ${this.detail}`; - } -} + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; -const gitHubCliDecodeFields = { - command: Schema.Literal("gh"), - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubPullRequestListDecodeError extends Schema.TaggedErrorClass()( - "GitHubPullRequestListDecodeError", - gitHubCliDecodeFields, -) { - get detail(): string { - return "GitHub CLI returned invalid PR list JSON."; - } + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; - override get message(): string { - return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; - } -} + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; -export class GitHubChangeRequestListDecodeError extends Schema.TaggedErrorClass()( - "GitHubChangeRequestListDecodeError", - gitHubCliDecodeFields, -) { - get detail(): string { - return "GitHub CLI returned invalid change request JSON."; - } + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; - override get message(): string { - return `GitHub CLI failed in listChangeRequests: ${this.detail}`; - } -} + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; -export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass()( - "GitHubPullRequestDecodeError", - gitHubCliDecodeFields, -) { - get detail(): string { - return "GitHub CLI returned invalid pull request JSON."; - } + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; - override get message(): string { - return `GitHub CLI failed in getPullRequest: ${this.detail}`; - } + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; } -export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( - "GitHubRepositoryDecodeError", - gitHubCliDecodeFields, -) { - get detail(): string { - return "GitHub CLI returned invalid repository JSON."; - } +export class GitHubCli extends Context.Service()( + "t3/sourceControl/GitHubCli", +) {} - override get message(): string { - return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); } + + return String(error); } -export const GitHubCliError = Schema.Union([ - GitHubCliUnavailableError, - GitHubCliAuthenticationError, - GitHubPullRequestNotFoundError, - GitHubCliCommandError, - GitHubPullRequestListDecodeError, - GitHubChangeRequestListDecodeError, - GitHubPullRequestDecodeError, - GitHubRepositoryDecodeError, -]); -export type GitHubCliError = typeof GitHubCliError.Type; - -export const isGitHubCliError = Schema.is(GitHubCliError); - -export function fromVcsError( - context: { - readonly command: "gh"; - readonly cwd: string; - }, - error: VcsError, +function normalizeGitHubCliError( + operation: "execute" | "stdout", + error: VcsError | unknown, ): GitHubCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: gh") || lower.includes("enoent")) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + if ( - error._tag === "VcsProcessSpawnError" && - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" && - error.cause.reason.module === "ChildProcess" && - error.cause.reason.method === "spawn" + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") ) { - return new GitHubCliUnavailableError({ ...context, cause: error }); + return new GitHubCliError({ + operation, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); } - if (error._tag === "VcsProcessExitError") { - if (error.failureKind === "authentication") { - return new GitHubCliAuthenticationError({ ...context, cause: error }); - } - if (error.failureKind === "not-found") { - return new GitHubPullRequestNotFoundError({ ...context, cause: error }); - } + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); } - return new GitHubCliCommandError({ ...context, cause: error }); -} - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state?: "open" | "closed" | "merged"; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; + return new GitHubCliError({ + operation, + detail: text, + cause: error, + }); } -export class GitHubCli extends Context.Service< - GitHubCli, - { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/GitHubCli") {} - const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); -const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( - Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), -); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, @@ -303,10 +208,28 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } -export const make = Effect.gen(function* () { +function decodeGitHubJson( + raw: string, + schema: S, + operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitHubCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +export const make = Effect.fn("makeGitHubCli")(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitHubCli["Service"]["execute"] = (input) => + const execute: GitHubCliShape["execute"] = (input) => process .run({ operation: "GitHubCli.execute", @@ -315,7 +238,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); return GitHubCli.of({ execute, @@ -339,13 +262,13 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubPullRequestListDecodeError({ - command: "gh", - cwd: input.cwd, + new GitHubCliError({ + operation: "listOpenPullRequests", + detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -371,13 +294,13 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubPullRequestDecodeError({ - command: "gh", - cwd: input.cwd, + new GitHubCliError({ + operation: "getPullRequest", + detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -397,15 +320,11 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeRawGitHubRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitHubRepositoryDecodeError({ - command: "gh", - cwd: input.cwd, - cause, - }), - ), + decodeGitHubJson( + raw, + RawGitHubRepositoryCloneUrlsSchema, + "getRepositoryCloneUrls", + "GitHub CLI returned invalid repository JSON.", ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -453,4 +372,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make); +export const layer = Layer.effect(GitHubCli, make()); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index dfea312a62c..32fd1a91ce3 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -24,7 +24,7 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider(github: Partial) { +function makeProvider(github: Partial) { return GitHubSourceControlProvider.make().pipe( Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), ); @@ -68,47 +68,6 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); -it.effect("adds safe request context while retaining GitHub CLI causes", () => - Effect.gen(function* () { - const cause = new GitHubCli.GitHubPullRequestNotFoundError({ - command: "gh", - cwd: "/repo", - cause: new Error("raw upstream detail that should remain in the cause"), - }); - const provider = yield* makeProvider({ - getPullRequest: () => Effect.fail(cause), - }); - - const error = yield* provider - .getChangeRequest({ - cwd: "/repo", - reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", - }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - command: error.command, - cwd: error.cwd, - reference: error.reference, - detail: error.detail, - }, - { - provider: "github", - operation: "getChangeRequest", - command: "gh", - cwd: "/repo", - reference: "https://github.com/pingdotgg/t3code/pull/42", - detail: "Pull request not found. Check the PR number or URL and try again.", - }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); - it.effect("uses gh json listing for non-open change request state queries", () => Effect.gen(function* () { let executeArgs: ReadonlyArray = []; @@ -180,8 +139,7 @@ it.effect("treats empty non-open change request listing output as no results", ( it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: Parameters[0] | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index bf18d899edb..41329b97f75 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { SourceControlProviderError, type ChangeRequest, @@ -10,15 +11,22 @@ import { import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; -import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import { - combinedAuthOutput, - firstSafeAuthLine, - providerAuth, - type SourceControlAuthProbeInput, - type SourceControlCliDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +const isSourceControlProviderError = Schema.is(SourceControlProviderError); + +function providerError( + operation: string, + cause: GitHubCli.GitHubCliError, +): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "github", + operation, + detail: cause.detail, + cause, + }); +} function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { @@ -42,14 +50,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } -function parseGitHubAuth(input: SourceControlAuthProbeInput) { - const output = combinedAuthOutput(input); +function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; if (authenticatedAccount) { - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account: authenticatedAccount.account, host, @@ -58,7 +66,7 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unauthenticated", host: failedAccount?.host, detail: @@ -68,17 +76,21 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { } if (input.exitCode !== 0) { - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unauthenticated", host, - detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "Run `gh auth login` to authenticate GitHub CLI.", }); } - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host, - detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "GitHub CLI auth status could not be parsed.", }); } @@ -92,12 +104,12 @@ export const discovery = { parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", -} satisfies SourceControlCliDiscoverySpec; +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { const github = yield* GitHubCli.GitHubCli; - const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = + const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = (input) => { if (input.state === "open") { return github @@ -108,20 +120,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), - ), + Effect.mapError((error) => providerError("listChangeRequests", error)), ); } @@ -148,7 +147,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { if (raw.length === 0) { return Effect.succeed([]); } - return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( @@ -158,28 +157,20 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { })), ) : Effect.fail( - new GitHubCli.GitHubChangeRequestListDecodeError({ - command: "gh", - cwd: input.cwd, + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + detail: "GitHub CLI returned invalid change request JSON.", cause: decoded.failure, }), ), ), ); }), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + isSourceControlProviderError(error) + ? error + : providerError("listChangeRequests", error), ), ); }; @@ -190,20 +181,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), + Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => github @@ -214,87 +192,23 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), - ), - ), + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), getRepositoryCloneUrls: (input) => - github.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), + github + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), createRepository: (input) => - github.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createRepository", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), + github + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => - github.getDefaultBranch(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), - ), - ), + github + .getDefaultBranch(input) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => - github.checkoutPullRequest(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), - ), + github + .checkoutPullRequest(input) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), }); }); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 87621e5c8bc..c075027151a 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -8,7 +8,7 @@ import { VcsProcessExitError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; -const mockedRun = vi.fn(); +const mockedRun = vi.fn(); const layer = it.layer( GitLabCli.layer.pipe( Layer.provide( @@ -313,15 +313,17 @@ layer("GitLabCli.layer", (it) => { it.effect("surfaces a friendly error when the merge request is not found", () => Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 merge request not found", - failureKind: "not-found", - }); - mockedRun.mockReturnValueOnce(Effect.fail(cause)); + mockedRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab mr view 4888", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + }), + ), + ); const error = yield* Effect.gen(function* () { const glab = yield* GitLabCli.GitLabCli; @@ -331,37 +333,7 @@ layer("GitLabCli.layer", (it) => { }); }).pipe(Effect.flip); - assert.equal(error.message.includes("Merge request 4888 was not found"), true); - assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); - assert.strictEqual(error.command, "glab"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.detail), false); - }), - ); - - it.effect("keeps non-merge-request not-found failures generic", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 project not found", - failureKind: "not-found", - }); - mockedRun.mockReturnValueOnce(Effect.fail(cause)); - - const error = yield* Effect.gen(function* () { - const glab = yield* GitLabCli.GitLabCli; - return yield* glab.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "missing/project", - }); - }).pipe(Effect.flip); - - assert.strictEqual(error._tag, "GitLabCliCommandError"); - assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("Merge request not found"), true); }), ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index a2926afd0ef..bd430d9d01a 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,228 +1,30 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; import type * as DateTime from "effect/DateTime"; -import { - TrimmedNonEmptyString, - type SourceControlRepositoryVisibility, - type VcsError, -} from "@t3tools/contracts"; +import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { - decodeGitLabMergeRequestJson, - decodeGitLabMergeRequestListJson, -} from "./gitLabMergeRequests.ts"; +import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -const gitLabCliExecutionErrorContext = { - operation: Schema.Literal("execute"), - command: Schema.Literal("glab"), - cwd: Schema.String, - cause: Schema.Defect(), -}; - -const gitLabCliDecodeErrorContext = { - command: Schema.Literal("glab"), - cwd: Schema.String, - cause: Schema.Defect(), -}; - -export class GitLabCliUnavailableError extends Schema.TaggedErrorClass()( - "GitLabCliUnavailableError", - gitLabCliExecutionErrorContext, -) { - get detail(): string { - return "GitLab CLI (`glab`) is required but not available on PATH."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabCliAuthenticationError extends Schema.TaggedErrorClass()( - "GitLabCliAuthenticationError", - gitLabCliExecutionErrorContext, -) { - get detail(): string { - return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass()( - "GitLabMergeRequestNotFoundError", - { - ...gitLabCliExecutionErrorContext, - reference: Schema.String, - }, -) { - get detail(): string { - return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } - - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "glab"; - readonly cwd: string; - readonly reference: string; - }, - error: VcsError, - ): GitLabCliError { - if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { - return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); - } - - return GitLabCliCommandError.fromVcsError( - { - operation: context.operation, - command: context.command, - cwd: context.cwd, - }, - error, - ); - } -} - -export class GitLabCliCommandError extends Schema.TaggedErrorClass()( - "GitLabCliCommandError", - gitLabCliExecutionErrorContext, -) { - get detail(): string { - return "GitLab CLI command failed."; - } - +export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { override get message(): string { return `GitLab CLI failed in ${this.operation}: ${this.detail}`; } - - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "glab"; - readonly cwd: string; - }, - error: VcsError, - ): GitLabCliError { - return Match.valueTags(error, { - VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), - VcsProcessExitError: (cause) => { - switch (cause.failureKind) { - case "authentication": - return new GitLabCliAuthenticationError({ ...context, cause }); - case "not-found": - case "command-failed": - case undefined: - return new GitLabCliCommandError({ ...context, cause }); - } - }, - VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), - }); - } } -export class GitLabMergeRequestListDecodeError extends Schema.TaggedErrorClass()( - "GitLabMergeRequestListDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literal("listMergeRequests"), - }, -) { - get detail(): string { - return "GitLab CLI returned invalid MR list JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabMergeRequestDecodeError extends Schema.TaggedErrorClass()( - "GitLabMergeRequestDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literal("getMergeRequest"), - reference: Schema.String, - }, -) { - get detail(): string { - return "GitLab CLI returned invalid merge request JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabRepositoryDecodeError extends Schema.TaggedErrorClass()( - "GitLabRepositoryDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), - repository: Schema.optional(Schema.String), - }, -) { - get detail(): string { - return "GitLab CLI returned invalid repository JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass()( - "GitLabNamespaceDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literal("createRepository"), - namespacePath: Schema.String, - }, -) { - get detail(): string { - return "GitLab CLI returned invalid namespace JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export const GitLabCliError = Schema.Union([ - GitLabCliUnavailableError, - GitLabCliAuthenticationError, - GitLabMergeRequestNotFoundError, - GitLabCliCommandError, - GitLabMergeRequestListDecodeError, - GitLabMergeRequestDecodeError, - GitLabRepositoryDecodeError, - GitLabNamespaceDecodeError, -]); -export type GitLabCliError = typeof GitLabCliError.Type; -export const isGitLabCliError = Schema.is(GitLabCliError); - export interface GitLabMergeRequestSummary { readonly number: number; readonly title: string; @@ -242,60 +44,120 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } -export class GitLabCli extends Context.Service< - GitLabCli, - { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listMergeRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, GitLabCliError>; - - readonly getMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createMergeRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; +export interface GitLabCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitLabCliError>; + + readonly getMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createMergeRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class GitLabCli extends Context.Service()( + "t3/sourceControl/GitLabCli", +) {} + +function isVcsProcessSpawnError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "VcsProcessSpawnError" + ); +} + +function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { + return new GitLabCliError({ + operation, + detail: "GitLab CLI (`glab`) is required but not available on PATH.", + cause: error, + }); + } + + const lower = error.message.toLowerCase(); + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("glab auth login") || + lower.includes("token") + ) { + return new GitLabCliError({ + operation, + detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("merge request not found") || + lower.includes("not found") || + lower.includes("404") + ) { + return new GitLabCliError({ + operation, + detail: "Merge request not found. Check the MR number or URL and try again.", + cause: error, + }); + } + + return new GitLabCliError({ + operation, + detail: `GitLab CLI command failed: ${error.message}`, + cause: error, + }); } ->()("t3/sourceControl/GitLabCli") {} + + return new GitLabCliError({ + operation, + detail: "GitLab CLI command failed.", + cause: error, + }); +} const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, @@ -312,14 +174,6 @@ const RawGitLabNamespaceSchema = Schema.Struct({ id: Schema.Number, }); -const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( - Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), -); -const decodeGitLabDefaultBranch = Schema.decodeEffect( - Schema.fromJsonString(RawGitLabDefaultBranchSchema), -); -const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); - function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitLabRepositoryCloneUrls { @@ -330,6 +184,24 @@ function normalizeRepositoryCloneUrls( }; } +function decodeGitLabJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitLabCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { switch (state) { case "open": @@ -387,13 +259,10 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } -export const make = Effect.gen(function* () { +export const make = Effect.fn("makeGitLabCli")(function* () { const process = yield* VcsProcess.VcsProcess; - const run = ( - input: Parameters[0], - mapError: (error: VcsError) => GitLabCliError, - ) => + const execute: GitLabCliShape["execute"] = (input) => process .run({ operation: "GitLabCli.execute", @@ -402,32 +271,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError(mapError)); - - const execute: GitLabCli["Service"]["execute"] = (input) => - run(input, (error) => - GitLabCliCommandError.fromVcsError( - { operation: "execute", command: "glab", cwd: input.cwd }, - error, - ), - ); - - const executeMergeRequest = (input: { - readonly cwd: string; - readonly reference: string; - readonly args: ReadonlyArray; - }) => - run(input, (error) => - GitLabMergeRequestNotFoundError.fromVcsError( - { - operation: "execute", - command: "glab", - cwd: input.cwd, - reference: input.reference, - }, - error, - ), - ); + .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); return GitLabCli.of({ execute, @@ -450,14 +294,13 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( + : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabMergeRequestListDecodeError({ + new GitLabCliError({ operation: "listMergeRequests", - command: "glab", - cwd: input.cwd, + detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -469,22 +312,19 @@ export const make = Effect.gen(function* () { ), ), getMergeRequest: (input) => - executeMergeRequest({ + execute({ cwd: input.cwd, - reference: input.reference, args: ["mr", "view", input.reference, "--output", "json"], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( + Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabMergeRequestDecodeError({ + new GitLabCliError({ operation: "getMergeRequest", - command: "glab", - cwd: input.cwd, - reference: input.reference, + detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -502,17 +342,11 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitLabRepositoryDecodeError({ - operation: "getRepositoryCloneUrls", - command: "glab", - cwd: input.cwd, - repository: input.repository, - cause, - }), - ), + decodeGitLabJson( + raw, + RawGitLabRepositoryCloneUrlsSchema, + "getRepositoryCloneUrls", + "GitLab CLI returned invalid repository JSON.", ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -526,17 +360,11 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabNamespace(raw).pipe( - Effect.mapError( - (cause) => - new GitLabNamespaceDecodeError({ - operation: "createRepository", - command: "glab", - cwd: input.cwd, - namespacePath, - cause, - }), - ), + decodeGitLabJson( + raw, + RawGitLabNamespaceSchema, + "createRepository", + "GitLab CLI returned invalid namespace JSON.", ), ), Effect.map((namespace) => namespace.id), @@ -566,17 +394,11 @@ export const make = Effect.gen(function* () { ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitLabRepositoryDecodeError({ - operation: "createRepository", - command: "glab", - cwd: input.cwd, - repository: input.repository, - cause, - }), - ), + decodeGitLabJson( + raw, + RawGitLabRepositoryCloneUrlsSchema, + "createRepository", + "GitLab CLI returned invalid repository JSON.", ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -610,27 +432,21 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabDefaultBranch(raw).pipe( - Effect.mapError( - (cause) => - new GitLabRepositoryDecodeError({ - operation: "getDefaultBranch", - command: "glab", - cwd: input.cwd, - cause, - }), - ), + decodeGitLabJson( + raw, + RawGitLabDefaultBranchSchema, + "getDefaultBranch", + "GitLab CLI returned invalid repository JSON.", ), ), Effect.map((value) => value.default_branch ?? null), ), checkoutMergeRequest: (input) => - executeMergeRequest({ + execute({ cwd: input.cwd, - reference: input.reference, args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), }); }); -export const layer = Layer.effect(GitLabCli, make); +export const layer = Layer.effect(GitLabCli, make()); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 9667c017592..842cf4a17cf 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -8,7 +8,7 @@ import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -function makeProvider(gitlab: Partial) { +function makeProvider(gitlab: Partial) { return GitLabSourceControlProvider.make().pipe( Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), ); @@ -52,52 +52,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = }), ); -it.effect("adds repository context while retaining GitLab CLI causes", () => - Effect.gen(function* () { - const cause = new GitLabCli.GitLabCliCommandError({ - operation: "execute", - command: "glab", - cwd: "/repo", - cause: new Error("raw upstream detail that should remain in the cause"), - }); - const provider = yield* makeProvider({ - createRepository: () => Effect.fail(cause), - }); - - const error = yield* provider - .createRepository({ - cwd: "/repo", - repository: "owner/repo", - visibility: "private", - }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - command: error.command, - cwd: error.cwd, - repository: error.repository, - detail: error.detail, - }, - { - provider: "gitlab", - operation: "createRepository", - command: "glab", - cwd: "/repo", - repository: "owner/repo", - detail: "GitLab CLI command failed.", - }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); - it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listMergeRequests: (input) => { listInput = input; @@ -123,8 +80,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: Parameters[0] | null = null; const provider = yield* makeProvider({ createMergeRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 06d88b6e55d..77f41600e0f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -11,22 +11,11 @@ import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLa function providerError( operation: string, cause: GitLabCli.GitLabCliError, - context: { - readonly cwd?: string; - readonly reference?: string; - readonly repository?: string; - } = {}, ): SourceControlProviderError { return new SourceControlProviderError({ provider: "gitlab", operation, detail: cause.detail, - command: cause.command, - cwd: SourceControlProvider.transportSafeSourceControlErrorValue(context.cwd ?? cause.cwd), - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - context.reference ?? ("reference" in cause ? cause.reference : undefined), - ), - repository: SourceControlProvider.transportSafeSourceControlErrorValue(context.repository), cause, }); } @@ -137,20 +126,13 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => - providerError("listChangeRequests", error, { - cwd: input.cwd, - reference: input.headSelector, - }), - ), + Effect.mapError((error) => providerError("listChangeRequests", error)), ); }, getChangeRequest: (input) => gitlab.getMergeRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => - providerError("getChangeRequest", error, { cwd: input.cwd, reference: input.reference }), - ), + Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -164,46 +146,24 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe( - Effect.mapError((error) => - providerError("createChangeRequest", error, { - cwd: input.cwd, - reference: input.headSelector, - }), - ), - ); + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, getRepositoryCloneUrls: (input) => - gitlab.getRepositoryCloneUrls(input).pipe( - Effect.mapError((error) => - providerError("getRepositoryCloneUrls", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), - ), + gitlab + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), createRepository: (input) => - gitlab.createRepository(input).pipe( - Effect.mapError((error) => - providerError("createRepository", error, { - cwd: input.cwd, - repository: input.repository, - }), - ), - ), + gitlab + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => gitlab .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error, input))), + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => - gitlab.checkoutMergeRequest(input).pipe( - Effect.mapError((error) => - providerError("checkoutChangeRequest", error, { - cwd: input.cwd, - reference: input.reference, - }), - ), - ), + gitlab + .checkoutMergeRequest(input) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), }); }); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 70c777e7730..f0602f03d14 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -49,24 +49,6 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } -export function transportSafeSourceControlErrorValue( - value: string | null | undefined, -): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - try { - const url = new URL(trimmed); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - const safeUrl = url.toString(); - return safeUrl.endsWith("/") && !trimmed.endsWith("/") ? safeUrl.slice(0, -1) : safeUrl; - } catch { - return trimmed.length > 256 ? `${trimmed.slice(0, 253)}...` : trimmed; - } -} - export interface SourceControlProviderShape { readonly kind: SourceControlProviderKind; readonly listChangeRequests: (input: { diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 7f1594871bd..0ecf13c67e6 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -156,10 +156,6 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass Date: Fri, 26 Jun 2026 05:47:24 +0000 Subject: [PATCH 22/23] Fix Phase 3C review findings for user-facing errors Restore friendly HTTP error messages on PrimaryEnvironmentRequestError while keeping structured diagnostics for logging. Harden OpenCode session payload validation, palette storage writes, schema issue summaries, and provider runtime list decoding concurrency. --- apps/server/src/persistence/Errors.ts | 12 +++- .../Layers/ProviderSessionRuntime.ts | 19 +++--- .../OpenCodeTextGeneration.test.ts | 20 ++++++ .../textGeneration/OpenCodeTextGeneration.ts | 6 +- apps/web/src/authBootstrap.test.ts | 43 +++++++++++++ apps/web/src/environments/primary/auth.ts | 27 +++++++- apps/web/src/hooks/useTheme.test.ts | 64 +++++++++++++++++++ apps/web/src/hooks/useTheme.ts | 23 ++++++- 8 files changed, 202 insertions(+), 12 deletions(-) diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index c22e956268c..1667f168508 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,12 +1,22 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; +function formatSchemaIssuePath(path: ReadonlyArray): string { + return path + .map((segment) => (typeof segment === "number" ? `[${segment}]` : String(segment))) + .join("."); +} + function summarizeSchemaIssue(issue: SchemaIssue.Issue): string { switch (issue._tag) { case "Filter": case "Encoding": - case "Pointer": return `${issue._tag}(${summarizeSchemaIssue(issue.issue)})`; + case "Pointer": { + const inner = summarizeSchemaIssue(issue.issue); + const path = formatSchemaIssuePath(issue.path); + return path.length > 0 ? `${path}:${inner}` : inner; + } case "Composite": case "AnyOf": return `${issue._tag}(${issue.issues.map(summarizeSchemaIssue).join(",")})`; diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index aa791e03d42..c46496ab7e2 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -199,16 +199,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ), ), Effect.flatMap((rows) => - Effect.forEach(rows, (row) => - decodeRuntimeRow(row).pipe( - Effect.mapError((cause) => - PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.list:decodeRows", - cause, - { threadId: row.threadId }, + Effect.forEach( + rows, + (row) => + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, + ), ), ), - ), + { concurrency: "unbounded" }, ), ), ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 558a8663b64..347a7678d09 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -285,6 +285,26 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ), ); + it.effect("reports a missing session id without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = { data: {} }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + it.effect("preserves the SDK cause and request context when prompting fails", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1f94f970692..a0234b664c9 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -402,7 +402,11 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" cause, }), }); - if (!session.data) { + if ( + !session.data || + typeof session.data.id !== "string" || + session.data.id.length === 0 + ) { return yield* new OpenCodeTextGenerationSessionPayloadError({ operation: input.operation, cwd: input.cwd, diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index ced16c15f4e..da840cbe017 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,5 +1,6 @@ import { EnvironmentAuthInvalidError, + EnvironmentOperationForbiddenError, type AuthBrowserSessionResult, type AuthCreatePairingCredentialInput, type AuthSessionState, @@ -361,6 +362,48 @@ describe("resolveInitialServerAuthGateState", () => { expect(error.message).not.toContain(cause.message); }); + it("maps environment HTTP errors to user-facing request messages", async () => { + const cause = new EnvironmentOperationForbiddenError({ + code: "operation_forbidden", + reason: "current_session_revoke_not_allowed", + traceId: "trace-forbidden", + }); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause, + }); + + expect(error.status).toBe(403); + expect(error.message).toBe("This operation is not allowed for the current session."); + expect(error.message).not.toContain(cause.traceId); + }); + + it("surfaces a friendly bootstrap error when desktop credentials are missing", async () => { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + browserSession: () => + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "missing_credential", + traceId: "trace-missing-credential", + }), + ), + }); + + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Authentication required.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const nextSession = sequence( diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 96814b92b79..1406997aec2 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -62,7 +62,10 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass { return retryTransientBootstrap(async () => { try { diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index 6c814e30165..a028970aae0 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -153,6 +153,70 @@ describe("theme failure handling", () => { unsubscribe?.(); }); + it("logs palette write failures without throwing from setPalette", async () => { + const writeCause = new Error("palette storage quota exceeded"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const store = new Map(); + let readSnapshot: (() => unknown) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + _subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: () => undefined, + localStorage: createStorage({ + setItem: (key, value) => { + if (key === "t3code:theme-palette") { + throw writeCause; + } + store.set(key, value); + }, + getItem: (key) => store.get(key) ?? null, + }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + dataset: {}, + offsetHeight: 0, + }, + body: { style: {} }, + head: { append: vi.fn() }, + querySelector: () => null, + }); + vi.stubGlobal("getComputedStyle", () => ({ backgroundColor: "rgb(0, 0, 0)" })); + + const { useTheme } = await import("./useTheme"); + const { setPalette } = useTheme(); + setPalette("catppuccin"); + readSnapshot?.(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to write theme preference for t3code:theme-palette.", + expect.objectContaining({ + operation: "write", + storageKey: "t3code:theme-palette", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(writeCause.message); + }); + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { const cause = new Error("desktop IPC unavailable"); const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 4bea6a875f9..46e250d7aaa 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -121,6 +121,20 @@ export function writeThemePreference(theme: Theme): void { } } +function writeThemePalette(palette: ThemePalette): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(PALETTE_STORAGE_KEY, palette); + themeStorageReadFailure = null; + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: PALETTE_STORAGE_KEY, + cause, + }); + } +} + function getStored(): Theme { if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; if (themeStorageReadFailure !== null) { @@ -353,7 +367,14 @@ export function useTheme() { const setPalette = useCallback( (next: ThemePalette) => { if (!hasThemeStorage()) return; - window.localStorage.setItem(PALETTE_STORAGE_KEY, next); + try { + writeThemePalette(next); + } catch (error) { + if (isThemeStorageError(error)) { + console.error(error.message, themeErrorLogAttributes(error)); + } + return; + } applyTheme(theme, next, true); emitChange(); }, From cfc7e9361daa6065320796923fb486acd708c89b Mon Sep 17 00:00:00 2001 From: sull Date: Fri, 26 Jun 2026 05:55:15 +0000 Subject: [PATCH 23/23] Print local and LAN pairing URLs on web server startup Stop auto-opening the browser by default and emit localhost and LAN pairing URLs (plus token and QR code) when the web server starts. --- apps/server/src/cli/config.ts | 6 +- apps/server/src/cli/server.ts | 2 +- apps/server/src/serverRuntimeStartup.ts | 21 +++---- apps/server/src/startupAccess.test.ts | 42 +++++++++++++- apps/server/src/startupAccess.ts | 75 +++++++++++++++++++------ 5 files changed, 111 insertions(+), 35 deletions(-) diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index a761de588c3..680390387fb 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -48,7 +48,9 @@ export const devUrlFlag = Flag.string("dev-url").pipe( Flag.optional, ); export const noBrowserFlag = Flag.boolean("no-browser").pipe( - Flag.withDescription("Disable automatic browser opening."), + Flag.withDescription( + "Disable automatic browser opening (defaults to true; pass --no-browser false to enable).", + ), Flag.optional, ); export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( @@ -310,7 +312,7 @@ export const resolveServerConfig = ( Option.fromUndefinedOr(env.noBrowser), Option.fromUndefinedOr(bootstrap?.noBrowser), ), - () => mode === "desktop", + () => true, ); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; const autoBootstrapProjectFromCwd = Option.getOrElse( diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index dba00a5236b..317a6a66749 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -25,7 +25,7 @@ export const startCommand = Command.make("start", { ...sharedServerCommandFlags export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( Command.withDescription( - "Run the more Code server without opening a browser and print headless pairing details.", + "Run the more Code server in headless mode and print local and LAN pairing URLs.", ), Command.withHandler((flags) => runServerCommand(flags, { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c07be0be6a8..54df8151562 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -35,10 +35,10 @@ import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; import { - formatHeadlessServeOutput, formatHostForUrl, + formatStartupAccessOutput, isWildcardHost, - issueHeadlessServeAccessInfo, + issueStartupAccessInfo, } from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ @@ -436,21 +436,16 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; - if (serverConfig.startupPresentation === "headless") { - yield* Effect.logDebug("startup phase: headless access info"); - const accessInfo = yield* issueHeadlessServeAccessInfo(); + if (serverConfig.startupPresentation === "headless" || serverConfig.mode === "web") { + yield* Effect.logDebug("startup phase: pairing access info"); + const accessInfo = yield* issueStartupAccessInfo(); yield* runStartupPhase( - "headless.output", - Console.log(formatHeadlessServeOutput(accessInfo)), + "startup.output", + Console.log(formatStartupAccessOutput(accessInfo)), ); - } else { + } else if (!serverConfig.noBrowser) { yield* Effect.logDebug("startup phase: browser open check"); const startupBrowserTarget = yield* resolveStartupBrowserTarget; - if (serverConfig.mode !== "desktop") { - yield* Effect.logInfo( - "Authentication required. Open more Code using the pairing URL.", - ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); - } yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); } yield* Effect.logDebug("startup phase: complete"); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f15..a2089624993 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -3,10 +3,14 @@ import { assert, expect, it } from "@effect/vitest"; import { buildPairingUrl, formatHeadlessServeOutput, + formatStartupAccessOutput, renderTerminalQrCode, resolveHeadlessConnectionHost, resolveHeadlessConnectionString, + resolveLanConnectionHost, + resolveLanConnectionString, resolveListeningPort, + resolveLocalConnectionString, } from "./startupAccess.ts"; it("prefers localhost when no explicit host is configured", () => { @@ -65,15 +69,47 @@ it("renders terminal QR codes as a multi-line unicode block grid", () => { assert.isTrue(qrCode.split("\n").length > 10); }); -it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { +it("resolves a LAN connection string from the first external IPv4 interface", () => { + const interfaces = { + en0: [ + { + address: "192.168.1.42", + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.168.1.42/24", + }, + ], + }; + + expect(resolveLanConnectionHost(interfaces)).toBe("192.168.1.42"); + expect(resolveLanConnectionString(3773, interfaces)).toBe("http://192.168.1.42:3773"); + expect(resolveLocalConnectionString(3773)).toBe("http://localhost:3773"); +}); + +it("formats startup output with local and LAN pairing URLs", () => { + const output = formatStartupAccessOutput({ + token: "PAIRCODE", + localPairingUrl: "http://localhost:3773/pair#token=PAIRCODE", + lanPairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", + }); + + expect(output).toContain("Token: PAIRCODE"); + expect(output).toContain("Local pairing URL: http://localhost:3773/pair#token=PAIRCODE"); + expect(output).toContain("LAN pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); +}); + +it("formats legacy headless serve output through the startup formatter", () => { const output = formatHeadlessServeOutput({ connectionString: "http://192.168.1.42:3773", token: "PAIRCODE", pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", }); - expect(output).toContain("Connection string: http://192.168.1.42:3773"); expect(output).toContain("Token: PAIRCODE"); - expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + expect(output).toContain("Local pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + expect(output).not.toContain("LAN pairing URL:"); assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); }); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 610d15a4b93..952a6bac738 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -7,6 +7,13 @@ import { HttpServer } from "effect/unstable/http"; import { ServerConfig } from "./config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +export interface StartupAccessInfo { + readonly token: string; + readonly localPairingUrl: string; + readonly lanPairingUrl: string | undefined; +} + +/** @deprecated Use {@link StartupAccessInfo} */ export interface HeadlessServeAccessInfo { readonly connectionString: string; readonly token: string; @@ -77,6 +84,27 @@ export const resolveHeadlessConnectionString = ( return `http://${formatHostForUrl(connectionHost)}:${port}`; }; +export const resolveLocalConnectionString = (port: number): string => + `http://localhost:${port}`; + +export const resolveLanConnectionHost = ( + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), +): string | undefined => { + const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []); + const externalIpv4 = interfaceEntries.find( + (entry) => !entry.internal && isIpv4Family(entry.family), + ); + return externalIpv4?.address; +}; + +export const resolveLanConnectionString = ( + port: number, + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), +): string | undefined => { + const lanHost = resolveLanConnectionHost(interfaces); + return lanHost === undefined ? undefined : `http://${formatHostForUrl(lanHost)}:${port}`; +}; + export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { if ( typeof address === "object" && @@ -119,30 +147,45 @@ export const renderTerminalQrCode = (value: string, margin = 2): string => { return rows.join("\n"); }; -export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string => - [ +export const formatStartupAccessOutput = (accessInfo: StartupAccessInfo): string => { + const lines = [ "more Code server is ready.", - `Connection string: ${accessInfo.connectionString}`, `Token: ${accessInfo.token}`, - `Pairing URL: ${accessInfo.pairingUrl}`, - "", - renderTerminalQrCode(accessInfo.pairingUrl), - "", - ].join("\n"); + `Local pairing URL: ${accessInfo.localPairingUrl}`, + ]; + if (accessInfo.lanPairingUrl !== undefined) { + lines.push(`LAN pairing URL: ${accessInfo.lanPairingUrl}`); + } + lines.push("", renderTerminalQrCode(accessInfo.localPairingUrl), ""); + return lines.join("\n"); +}; + +export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string => + formatStartupAccessOutput({ + token: accessInfo.token, + localPairingUrl: accessInfo.pairingUrl, + lanPairingUrl: undefined, + }); -export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () { +export const issueStartupAccessInfo = Effect.fn("issueStartupAccessInfo")(function* () { const serverConfig = yield* ServerConfig; const httpServer = yield* HttpServer.HttpServer; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const connectionString = resolveHeadlessConnectionString( - serverConfig.host, - resolveListeningPort(httpServer.address, serverConfig.port), - ); + const port = resolveListeningPort(httpServer.address, serverConfig.port); + const localConnectionString = resolveLocalConnectionString(port); + const lanConnectionString = resolveLanConnectionString(port); const issued = yield* serverAuth.issueStartupPairingCredential(); + const localPairingUrl = buildPairingUrl(localConnectionString, issued.credential); + const lanPairingUrl = + lanConnectionString === undefined + ? undefined + : buildPairingUrl(lanConnectionString, issued.credential); return { - connectionString, token: issued.credential, - pairingUrl: buildPairingUrl(connectionString, issued.credential), - } satisfies HeadlessServeAccessInfo; + localPairingUrl, + lanPairingUrl: lanPairingUrl === localPairingUrl ? undefined : lanPairingUrl, + } satisfies StartupAccessInfo; }); + +export const issueHeadlessServeAccessInfo = issueStartupAccessInfo;