| ... | @@ -2,9 +2,14 @@ | ... | @@ -2,9 +2,14 @@ |
| 2 | | 2 | |
| 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 { Config, Data, Effect, Layer, Schema } from "effect"; | 5 | import { Config, Data, Effect, Layer, Logger, Schema } from "effect"; |
| 6 | import { Argument, Command, Flag } from "effect/unstable/cli"; | 6 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 7 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 7 | import { |
| | 8 | FetchHttpClient, |
| | 9 | HttpClient, |
| | 10 | HttpClientRequest, |
| | 11 | HttpClientResponse, |
| | 12 | } from "effect/unstable/http"; |
| 8 | | 13 | |
| 9 | export type DiscordReleaseTarget = "prerelease" | "latest"; | 14 | export type DiscordReleaseTarget = "prerelease" | "latest"; |
| 10 | | 15 | |
| ... | @@ -14,7 +19,7 @@ interface DiscordReleaseAnnouncementOptions { | ... | @@ -14,7 +19,7 @@ interface DiscordReleaseAnnouncementOptions { |
| 14 | readonly releaseName: string; | 19 | readonly releaseName: string; |
| 15 | readonly version: string; | 20 | readonly version: string; |
| 16 | readonly tag: string; | 21 | readonly tag: string; |
| 17 | readonly releaseUrl: string; | 22 | readonly releaseUrl: URL; |
| 18 | readonly timestamp: string; | 23 | readonly timestamp: string; |
| 19 | } | 24 | } |
| 20 | | 25 | |
| ... | @@ -39,8 +44,7 @@ interface DiscordWebhookPayload { | ... | @@ -39,8 +44,7 @@ interface DiscordWebhookPayload { |
| 39 | | 44 | |
| 40 | const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; | 45 | const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; |
| 41 | const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); | 46 | const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); |
| 42 | const WebUrlSchema = Schema.String.check(Schema.isPattern(/^https?:\/\/\S+$/)); | 47 | const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); |
| 43 | const DiscordWebhookUrl = Config.nonEmptyString("DISCORD_WEBHOOK_URL"); | | |
| 44 | | 48 | |
| 45 | class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ | 49 | class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ |
| 46 | readonly message: string; | 50 | readonly message: string; |
| ... | @@ -57,6 +61,23 @@ const targetColors = { | ... | @@ -57,6 +61,23 @@ const targetColors = { |
| 57 | latest: 0x2ecc71, | 61 | latest: 0x2ecc71, |
| 58 | } as const satisfies Record<DiscordReleaseTarget, number>; | 62 | } as const satisfies Record<DiscordReleaseTarget, number>; |
| 59 | | 63 | |
| | 64 | function describeWebhookUrl(webhookUrl: URL) { |
| | 65 | return { |
| | 66 | configured: true, |
| | 67 | origin: webhookUrl.origin, |
| | 68 | pathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length, |
| | 69 | } as const; |
| | 70 | } |
| | 71 | |
| | 72 | function summarizePayload(payload: DiscordWebhookPayload) { |
| | 73 | return { |
| | 74 | contentLength: payload.content.length, |
| | 75 | embedCount: payload.embeds.length, |
| | 76 | allowedRoleMentionCount: payload.allowed_mentions.roles.length, |
| | 77 | hasRoleMentionSyntax: payload.content.includes("<@&"), |
| | 78 | } as const; |
| | 79 | } |
| | 80 | |
| 60 | export const buildDiscordReleaseAnnouncement = ( | 81 | export const buildDiscordReleaseAnnouncement = ( |
| 61 | options: DiscordReleaseAnnouncementOptions, | 82 | options: DiscordReleaseAnnouncementOptions, |
| 62 | ): DiscordWebhookPayload => ({ | 83 | ): DiscordWebhookPayload => ({ |
| ... | @@ -67,7 +88,7 @@ export const buildDiscordReleaseAnnouncement = ( | ... | @@ -67,7 +88,7 @@ export const buildDiscordReleaseAnnouncement = ( |
| 67 | embeds: [ | 88 | embeds: [ |
| 68 | { | 89 | { |
| 69 | title: options.releaseName, | 90 | title: options.releaseName, |
| 70 | url: options.releaseUrl, | 91 | url: options.releaseUrl.href, |
| 71 | description: | 92 | description: |
| 72 | options.target === "prerelease" | 93 | options.target === "prerelease" |
| 73 | ? "A new T3 Code prerelease is available for nightly testers." | 94 | ? "A new T3 Code prerelease is available for nightly testers." |
| ... | @@ -91,7 +112,7 @@ export const buildDiscordReleaseAnnouncement = ( | ... | @@ -91,7 +112,7 @@ export const buildDiscordReleaseAnnouncement = ( |
| 91 | }); | 112 | }); |
| 92 | | 113 | |
| 93 | const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( | 114 | const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 94 | webhookUrl: string, | 115 | webhookUrl: URL, |
| 95 | payload: DiscordWebhookPayload, | 116 | payload: DiscordWebhookPayload, |
| 96 | ) { | 117 | ) { |
| 97 | const httpClient = (yield* HttpClient.HttpClient).pipe( | 118 | const httpClient = (yield* HttpClient.HttpClient).pipe( |
| ... | @@ -99,10 +120,16 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( | ... | @@ -99,10 +120,16 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 99 | retryOn: "errors-and-responses", | 120 | retryOn: "errors-and-responses", |
| 100 | times: 3, | 121 | times: 3, |
| 101 | }), | 122 | }), |
| 102 | HttpClient.filterStatusOk, | | |
| 103 | ); | 123 | ); |
| 104 | | 124 | |
| 105 | yield* HttpClientRequest.post(webhookUrl).pipe( | 125 | yield* Effect.logInfo("discord webhook request dispatching").pipe( |
| | 126 | Effect.annotateLogs({ |
| | 127 | ...describeWebhookUrl(webhookUrl), |
| | 128 | ...summarizePayload(payload), |
| | 129 | }), |
| | 130 | ); |
| | 131 | |
| | 132 | const response = yield* HttpClientRequest.post(webhookUrl).pipe( |
| 106 | HttpClientRequest.bodyJson(payload), | 133 | HttpClientRequest.bodyJson(payload), |
| 107 | Effect.flatMap(httpClient.execute), | 134 | Effect.flatMap(httpClient.execute), |
| 108 | Effect.mapError( | 135 | Effect.mapError( |
| ... | @@ -113,9 +140,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( | ... | @@ -113,9 +140,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 113 | }), | 140 | }), |
| 114 | ), | 141 | ), |
| 115 | ); | 142 | ); |
| 116 | }); | | |
| 117 | | 143 | |
| 118 | const runtimeLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); | 144 | yield* Effect.logInfo("discord webhook response received").pipe( |
| | 145 | Effect.annotateLogs({ |
| | 146 | status: response.status, |
| | 147 | ok: response.status >= 200 && response.status < 300, |
| | 148 | }), |
| | 149 | ); |
| | 150 | |
| | 151 | yield* HttpClientResponse.filterStatusOk(response).pipe( |
| | 152 | Effect.mapError( |
| | 153 | (cause) => |
| | 154 | new DiscordReleaseAnnouncementError({ |
| | 155 | message: `Discord webhook returned status ${response.status}.`, |
| | 156 | cause, |
| | 157 | }), |
| | 158 | ), |
| | 159 | ); |
| | 160 | }); |
| 119 | | 161 | |
| 120 | export const notifyDiscordReleaseCommand = Command.make( | 162 | export const notifyDiscordReleaseCommand = Command.make( |
| 121 | "notify-discord-release", | 163 | "notify-discord-release", |
| ... | @@ -140,31 +182,52 @@ export const notifyDiscordReleaseCommand = Command.make( | ... | @@ -140,31 +182,52 @@ export const notifyDiscordReleaseCommand = Command.make( |
| 140 | Flag.withDescription("Git tag for the release."), | 182 | Flag.withDescription("Git tag for the release."), |
| 141 | ), | 183 | ), |
| 142 | releaseUrl: Flag.string("release-url").pipe( | 184 | releaseUrl: Flag.string("release-url").pipe( |
| 143 | Flag.withSchema(WebUrlSchema), | 185 | Flag.withSchema(Schema.URLFromString), |
| 144 | Flag.withDescription("Public GitHub release URL."), | 186 | Flag.withDescription("Public GitHub release URL."), |
| 145 | ), | 187 | ), |
| 146 | }, | 188 | }, |
| 147 | ({ target, roleId, releaseName, version, tag, releaseUrl }) => | 189 | ({ target, roleId, releaseName, version, tag, releaseUrl }) => |
| 148 | Effect.gen(function* () { | 190 | Effect.gen(function* () { |
| 149 | const webhookUrl = yield* DiscordWebhookUrl; | 191 | yield* Effect.logInfo("discord release announcement starting").pipe( |
| 150 | yield* postDiscordWebhook( | 192 | Effect.annotateLogs({ |
| 151 | webhookUrl, | | |
| 152 | buildDiscordReleaseAnnouncement({ | | |
| 153 | target, | 193 | target, |
| 154 | roleId, | 194 | roleIdProvided: roleId.length > 0, |
| | 195 | roleIdLength: roleId.length, |
| 155 | releaseName, | 196 | releaseName, |
| 156 | version, | 197 | version, |
| 157 | tag, | 198 | tag, |
| 158 | releaseUrl, | 199 | releaseUrl, |
| 159 | timestamp: new Date().toISOString(), | | |
| 160 | }), | 200 | }), |
| 161 | ); | 201 | ); |
| | 202 | |
| | 203 | const webhookUrl = yield* DiscordWebhookUrl; |
| | 204 | const payload = buildDiscordReleaseAnnouncement({ |
| | 205 | target, |
| | 206 | roleId, |
| | 207 | releaseName, |
| | 208 | version, |
| | 209 | tag, |
| | 210 | releaseUrl, |
| | 211 | timestamp: new Date().toISOString(), |
| | 212 | }); |
| | 213 | |
| | 214 | yield* Effect.logInfo("discord release announcement payload built").pipe( |
| | 215 | Effect.annotateLogs(summarizePayload(payload)), |
| | 216 | ); |
| | 217 | yield* postDiscordWebhook(webhookUrl, payload); |
| | 218 | yield* Effect.logInfo("discord release announcement completed"); |
| 162 | }), | 219 | }), |
| 163 | ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); | 220 | ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); |
| 164 | | 221 | |
| 165 | if (import.meta.main) { | 222 | if (import.meta.main) { |
| 166 | Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe( | 223 | Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe( |
| 167 | Effect.provide(runtimeLayer), | 224 | Effect.provide( |
| | 225 | Layer.mergeAll( |
| | 226 | Logger.layer([Logger.consolePretty()]), |
| | 227 | NodeServices.layer, |
| | 228 | FetchHttpClient.layer, |
| | 229 | ), |
| | 230 | ), |
| 168 | NodeRuntime.runMain, | 231 | NodeRuntime.runMain, |
| 169 | ); | 232 | ); |
| 170 | } | 233 | } |