diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index 23f73fa0d76c73c93b9cd404942d0d5168e4985c..7ca108f3188460fc2b594a9bd8650475d5a480d2 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -1,6 +1,25 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; -import { buildDiscordReleaseAnnouncement } from "./notify-discord-release.ts"; +import { + buildDiscordReleaseAnnouncement, + isDiscordReleaseAnnouncementError, + postDiscordWebhook, +} from "./notify-discord-release.ts"; + +const latestAnnouncement = { + target: "latest", + roleId: "222222222222222222", + releaseName: "T3 Code v1.2.3", + version: "1.2.3", + tag: "v1.2.3", + releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), + timestamp: "2026-05-01T01:41:00.000Z", +} as const; + +const webhookUrl = new URL("https://discord.com/api/webhooks/123456/secret-token"); it("builds a prerelease Discord announcement for nightly subscribers", () => { assert.deepStrictEqual( @@ -47,42 +66,109 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => { }); it("builds a latest Discord announcement for stable subscribers", () => { - assert.deepStrictEqual( - buildDiscordReleaseAnnouncement({ - target: "latest", - roleId: "222222222222222222", - releaseName: "T3 Code v1.2.3", - version: "1.2.3", - tag: "v1.2.3", - releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), - timestamp: "2026-05-01T01:41:00.000Z", - }), - { - content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", - allowed_mentions: { - roles: ["222222222222222222"], - }, - embeds: [ - { - title: "T3 Code v1.2.3", - url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", - description: "A new T3 Code latest release is available.", - color: 0x2ecc71, - fields: [ - { - name: "Version", - value: "1.2.3", - inline: true, - }, - { - name: "Tag", - value: "v1.2.3", - inline: true, - }, - ], - timestamp: "2026-05-01T01:41:00.000Z", - }, - ], + assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), { + content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", + allowed_mentions: { + roles: ["222222222222222222"], }, + embeds: [ + { + title: "T3 Code v1.2.3", + url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", + description: "A new T3 Code latest release is available.", + color: 0x2ecc71, + fields: [ + { + name: "Version", + value: "1.2.3", + inline: true, + }, + { + name: "Tag", + value: "v1.2.3", + inline: true, + }, + ], + timestamp: "2026-05-01T01:41:00.000Z", + }, + ], + }); +}); + +it.effect("preserves webhook request context and the full client cause chain", () => { + const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); + const requestCause = new Error("request encoder unavailable"); + let clientError: HttpClientError.HttpClientError | undefined; + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + clientError = new HttpClientError.HttpClientError({ + reason: new HttpClientError.EncodeError({ + request, + cause: requestCause, + }), + }); + return Effect.fail(clientError); + }), + ); + + return Effect.gen(function* () { + const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( + Effect.provide(httpClientLayer), + Effect.flip, + ); + + if (error._tag !== "DiscordReleaseWebhookRequestError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.target, "latest"); + assert.equal(error.releaseName, latestAnnouncement.releaseName); + assert.equal(error.version, latestAnnouncement.version); + assert.equal(error.tag, latestAnnouncement.tag); + assert.equal(error.releaseUrl, latestAnnouncement.releaseUrl.href); + assert.equal(error.webhookOrigin, webhookUrl.origin); + assert.equal(error.webhookPathnameSegmentCount, 4); + assert.equal(error.contentLength, payload.content.length); + assert.equal(error.embedCount, 1); + assert.equal(error.allowedRoleMentionCount, 1); + assert.equal(error.hasRoleMentionSyntax, true); + assert.equal(error.cause, clientError); + assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause); + assert.ok(!error.message.includes(requestCause.message)); + assert.equal(isDiscordReleaseAnnouncementError(error), true); + }); +}); + +it.effect("preserves a non-success response error with structured status context", () => { + const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("invalid webhook", { status: 400 })), + ), + ), ); + + return Effect.gen(function* () { + const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( + Effect.provide(httpClientLayer), + Effect.flip, + ); + + if (error._tag !== "DiscordReleaseWebhookResponseError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.target, "latest"); + assert.equal(error.tag, latestAnnouncement.tag); + assert.equal(error.webhookOrigin, webhookUrl.origin); + assert.equal(error.webhookPathnameSegmentCount, 4); + assert.equal(error.status, 400); + if (!HttpClientError.isHttpClientError(error.cause)) { + assert.fail("Expected HttpClientError cause"); + } + assert.equal(error.cause.reason._tag, "StatusCodeError"); + assert.ok(!error.message.includes(error.cause.message)); + assert.equal(isDiscordReleaseAnnouncementError(error), true); + }); }); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 1e3106b05edd014d173271c87c8ad3b4dfe0dabc..d013e7c2f6578fd8120ca4ae84121a7fc84bb5cb 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -3,7 +3,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -19,7 +18,7 @@ import { export type DiscordReleaseTarget = "prerelease" | "latest"; -interface DiscordReleaseAnnouncementOptions { +export interface DiscordReleaseAnnouncementOptions { readonly target: DiscordReleaseTarget; readonly roleId: string; readonly releaseName: string; @@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); -class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const discordReleaseErrorContext = { + target: Schema.Literals(["prerelease", "latest"]), + releaseName: Schema.String, + version: Schema.String, + tag: Schema.String, + releaseUrl: Schema.String, + webhookOrigin: Schema.String, + webhookPathnameSegmentCount: Schema.Number, + contentLength: Schema.Number, + embedCount: Schema.Number, + allowedRoleMentionCount: Schema.Number, + hasRoleMentionSyntax: Schema.Boolean, +}; + +export class DiscordReleaseWebhookRequestError extends Schema.TaggedErrorClass()( + "DiscordReleaseWebhookRequestError", + { + ...discordReleaseErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to post Discord ${this.target} release announcement for "${this.tag}" to ${this.webhookOrigin}.`; + } +} + +export class DiscordReleaseWebhookResponseError extends Schema.TaggedErrorClass()( + "DiscordReleaseWebhookResponseError", + { + ...discordReleaseErrorContext, + status: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Discord ${this.target} release webhook for "${this.tag}" returned status ${this.status}.`; + } +} + +export const DiscordReleaseAnnouncementError = Schema.Union([ + DiscordReleaseWebhookRequestError, + DiscordReleaseWebhookResponseError, +]); +export type DiscordReleaseAnnouncementError = typeof DiscordReleaseAnnouncementError.Type; +export const isDiscordReleaseAnnouncementError = Schema.is(DiscordReleaseAnnouncementError); const targetLabels = { prerelease: "Prerelease", @@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = ( ], }); -const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( +export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( webhookUrl: URL, payload: DiscordWebhookPayload, + announcement: DiscordReleaseAnnouncementOptions, ) { const httpClient = (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( }), ); + const errorContext = { + target: announcement.target, + releaseName: announcement.releaseName, + version: announcement.version, + tag: announcement.tag, + releaseUrl: announcement.releaseUrl.href, + webhookOrigin: webhookUrl.origin, + webhookPathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length, + ...summarizePayload(payload), + } as const; + const response = yield* HttpClientRequest.post(webhookUrl).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => - new DiscordReleaseAnnouncementError({ - message: "Failed to post Discord release announcement.", + new DiscordReleaseWebhookRequestError({ + ...errorContext, cause, }), ), @@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.mapError( (cause) => - new DiscordReleaseAnnouncementError({ - message: `Discord webhook returned status ${response.status}.`, + new DiscordReleaseWebhookResponseError({ + ...errorContext, + status: response.status, cause, }), ), @@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make( const webhookUrl = yield* DiscordWebhookUrl; const timestamp = DateTime.formatIso(yield* DateTime.now); - const payload = buildDiscordReleaseAnnouncement({ + const announcement = { target, roleId, releaseName, @@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make( tag, releaseUrl, timestamp, - }); + } satisfies DiscordReleaseAnnouncementOptions; + const payload = buildDiscordReleaseAnnouncement(announcement); yield* Effect.logInfo("discord release announcement payload built").pipe( Effect.annotateLogs(summarizePayload(payload)), ); - yield* postDiscordWebhook(webhookUrl, payload); + yield* postDiscordWebhook(webhookUrl, payload, announcement); yield* Effect.logInfo("discord release announcement completed"); }), ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));