| ... | @@ -3,7 +3,6 @@ | ... | @@ -3,7 +3,6 @@ |
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import * as Config from "effect/Config"; | 5 | import * as Config from "effect/Config"; |
| 6 | import * as Data from "effect/Data"; | | |
| 7 | import * as DateTime from "effect/DateTime"; | 6 | import * as DateTime from "effect/DateTime"; |
| 8 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| ... | @@ -19,7 +18,7 @@ import { | ... | @@ -19,7 +18,7 @@ import { |
| 19 | | 18 | |
| 20 | export type DiscordReleaseTarget = "prerelease" | "latest"; | 19 | export type DiscordReleaseTarget = "prerelease" | "latest"; |
| 21 | | 20 | |
| 22 | interface DiscordReleaseAnnouncementOptions { | 21 | export interface DiscordReleaseAnnouncementOptions { |
| 23 | readonly target: DiscordReleaseTarget; | 22 | readonly target: DiscordReleaseTarget; |
| 24 | readonly roleId: string; | 23 | readonly roleId: string; |
| 25 | readonly releaseName: string; | 24 | readonly releaseName: string; |
| ... | @@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; | ... | @@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; |
| 52 | const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); | 51 | const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); |
| 53 | const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); | 52 | const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); |
| 54 | | 53 | |
| 55 | class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ | 54 | const discordReleaseErrorContext = { |
| 56 | readonly message: string; | 55 | target: Schema.Literals(["prerelease", "latest"]), |
| 57 | readonly cause?: unknown; | 56 | releaseName: Schema.String, |
| 58 | }> {} | 57 | version: Schema.String, |
| | 58 | tag: Schema.String, |
| | 59 | releaseUrl: Schema.String, |
| | 60 | webhookOrigin: Schema.String, |
| | 61 | webhookPathnameSegmentCount: Schema.Number, |
| | 62 | contentLength: Schema.Number, |
| | 63 | embedCount: Schema.Number, |
| | 64 | allowedRoleMentionCount: Schema.Number, |
| | 65 | hasRoleMentionSyntax: Schema.Boolean, |
| | 66 | }; |
| | 67 | |
| | 68 | export class DiscordReleaseWebhookRequestError extends Schema.TaggedErrorClass<DiscordReleaseWebhookRequestError>()( |
| | 69 | "DiscordReleaseWebhookRequestError", |
| | 70 | { |
| | 71 | ...discordReleaseErrorContext, |
| | 72 | cause: Schema.Defect(), |
| | 73 | }, |
| | 74 | ) { |
| | 75 | override get message(): string { |
| | 76 | return `Failed to post Discord ${this.target} release announcement for "${this.tag}" to ${this.webhookOrigin}.`; |
| | 77 | } |
| | 78 | } |
| | 79 | |
| | 80 | export class DiscordReleaseWebhookResponseError extends Schema.TaggedErrorClass<DiscordReleaseWebhookResponseError>()( |
| | 81 | "DiscordReleaseWebhookResponseError", |
| | 82 | { |
| | 83 | ...discordReleaseErrorContext, |
| | 84 | status: Schema.Number, |
| | 85 | cause: Schema.Defect(), |
| | 86 | }, |
| | 87 | ) { |
| | 88 | override get message(): string { |
| | 89 | return `Discord ${this.target} release webhook for "${this.tag}" returned status ${this.status}.`; |
| | 90 | } |
| | 91 | } |
| | 92 | |
| | 93 | export const DiscordReleaseAnnouncementError = Schema.Union([ |
| | 94 | DiscordReleaseWebhookRequestError, |
| | 95 | DiscordReleaseWebhookResponseError, |
| | 96 | ]); |
| | 97 | export type DiscordReleaseAnnouncementError = typeof DiscordReleaseAnnouncementError.Type; |
| | 98 | export const isDiscordReleaseAnnouncementError = Schema.is(DiscordReleaseAnnouncementError); |
| 59 | | 99 | |
| 60 | const targetLabels = { | 100 | const targetLabels = { |
| 61 | prerelease: "Prerelease", | 101 | prerelease: "Prerelease", |
| ... | @@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = ( | ... | @@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = ( |
| 117 | ], | 157 | ], |
| 118 | }); | 158 | }); |
| 119 | | 159 | |
| 120 | const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( | 160 | export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 121 | webhookUrl: URL, | 161 | webhookUrl: URL, |
| 122 | payload: DiscordWebhookPayload, | 162 | payload: DiscordWebhookPayload, |
| | 163 | announcement: DiscordReleaseAnnouncementOptions, |
| 123 | ) { | 164 | ) { |
| 124 | const httpClient = (yield* HttpClient.HttpClient).pipe( | 165 | const httpClient = (yield* HttpClient.HttpClient).pipe( |
| 125 | HttpClient.retryTransient({ | 166 | HttpClient.retryTransient({ |
| ... | @@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( | ... | @@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 135 | }), | 176 | }), |
| 136 | ); | 177 | ); |
| 137 | | 178 | |
| | 179 | const errorContext = { |
| | 180 | target: announcement.target, |
| | 181 | releaseName: announcement.releaseName, |
| | 182 | version: announcement.version, |
| | 183 | tag: announcement.tag, |
| | 184 | releaseUrl: announcement.releaseUrl.href, |
| | 185 | webhookOrigin: webhookUrl.origin, |
| | 186 | webhookPathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length, |
| | 187 | ...summarizePayload(payload), |
| | 188 | } as const; |
| | 189 | |
| 138 | const response = yield* HttpClientRequest.post(webhookUrl).pipe( | 190 | const response = yield* HttpClientRequest.post(webhookUrl).pipe( |
| 139 | HttpClientRequest.bodyJson(payload), | 191 | HttpClientRequest.bodyJson(payload), |
| 140 | Effect.flatMap(httpClient.execute), | 192 | Effect.flatMap(httpClient.execute), |
| 141 | Effect.mapError( | 193 | Effect.mapError( |
| 142 | (cause) => | 194 | (cause) => |
| 143 | new DiscordReleaseAnnouncementError({ | 195 | new DiscordReleaseWebhookRequestError({ |
| 144 | message: "Failed to post Discord release announcement.", | 196 | ...errorContext, |
| 145 | cause, | 197 | cause, |
| 146 | }), | 198 | }), |
| 147 | ), | 199 | ), |
| ... | @@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( | ... | @@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 157 | yield* HttpClientResponse.filterStatusOk(response).pipe( | 209 | yield* HttpClientResponse.filterStatusOk(response).pipe( |
| 158 | Effect.mapError( | 210 | Effect.mapError( |
| 159 | (cause) => | 211 | (cause) => |
| 160 | new DiscordReleaseAnnouncementError({ | 212 | new DiscordReleaseWebhookResponseError({ |
| 161 | message: `Discord webhook returned status ${response.status}.`, | 213 | ...errorContext, |
| | 214 | status: response.status, |
| 162 | cause, | 215 | cause, |
| 163 | }), | 216 | }), |
| 164 | ), | 217 | ), |
| ... | @@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make( | ... | @@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make( |
| 208 | | 261 | |
| 209 | const webhookUrl = yield* DiscordWebhookUrl; | 262 | const webhookUrl = yield* DiscordWebhookUrl; |
| 210 | const timestamp = DateTime.formatIso(yield* DateTime.now); | 263 | const timestamp = DateTime.formatIso(yield* DateTime.now); |
| 211 | const payload = buildDiscordReleaseAnnouncement({ | 264 | const announcement = { |
| 212 | target, | 265 | target, |
| 213 | roleId, | 266 | roleId, |
| 214 | releaseName, | 267 | releaseName, |
| ... | @@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make( | ... | @@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make( |
| 216 | tag, | 269 | tag, |
| 217 | releaseUrl, | 270 | releaseUrl, |
| 218 | timestamp, | 271 | timestamp, |
| 219 | }); | 272 | } satisfies DiscordReleaseAnnouncementOptions; |
| | 273 | const payload = buildDiscordReleaseAnnouncement(announcement); |
| 220 | | 274 | |
| 221 | yield* Effect.logInfo("discord release announcement payload built").pipe( | 275 | yield* Effect.logInfo("discord release announcement payload built").pipe( |
| 222 | Effect.annotateLogs(summarizePayload(payload)), | 276 | Effect.annotateLogs(summarizePayload(payload)), |
| 223 | ); | 277 | ); |
| 224 | yield* postDiscordWebhook(webhookUrl, payload); | 278 | yield* postDiscordWebhook(webhookUrl, payload, announcement); |
| 225 | yield* Effect.logInfo("discord release announcement completed"); | 279 | yield* Effect.logInfo("discord release announcement completed"); |
| 226 | }), | 280 | }), |
| 227 | ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); | 281 | ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); |