| ... | ... | @@ -2,9 +2,14 @@ |
| 2 | 2 | |
| 3 | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | 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 | 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 | 14 | export type DiscordReleaseTarget = "prerelease" | "latest"; |
| 10 | 15 | |
| ... | ... | @@ -14,7 +19,7 @@ interface DiscordReleaseAnnouncementOptions { |
| 14 | 19 | readonly releaseName: string; |
| 15 | 20 | readonly version: string; |
| 16 | 21 | readonly tag: string; |
| 17 | | readonly releaseUrl: string; |
| 22 | readonly releaseUrl: URL; |
| 18 | 23 | readonly timestamp: string; |
| 19 | 24 | } |
| 20 | 25 | |
| ... | ... | @@ -39,8 +44,7 @@ interface DiscordWebhookPayload { |
| 39 | 44 | |
| 40 | 45 | const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; |
| 41 | 46 | const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); |
| 42 | | const WebUrlSchema = Schema.String.check(Schema.isPattern(/^https?:\/\/\S+$/)); |
| 43 | | const DiscordWebhookUrl = Config.nonEmptyString("DISCORD_WEBHOOK_URL"); |
| 47 | const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); |
| 44 | 48 | |
| 45 | 49 | class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ |
| 46 | 50 | readonly message: string; |
| ... | ... | @@ -57,6 +61,23 @@ const targetColors = { |
| 57 | 61 | latest: 0x2ecc71, |
| 58 | 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 | 81 | export const buildDiscordReleaseAnnouncement = ( |
| 61 | 82 | options: DiscordReleaseAnnouncementOptions, |
| 62 | 83 | ): DiscordWebhookPayload => ({ |
| ... | ... | @@ -67,7 +88,7 @@ export const buildDiscordReleaseAnnouncement = ( |
| 67 | 88 | embeds: [ |
| 68 | 89 | { |
| 69 | 90 | title: options.releaseName, |
| 70 | | url: options.releaseUrl, |
| 91 | url: options.releaseUrl.href, |
| 71 | 92 | description: |
| 72 | 93 | options.target === "prerelease" |
| 73 | 94 | ? "A new T3 Code prerelease is available for nightly testers." |
| ... | ... | @@ -91,7 +112,7 @@ export const buildDiscordReleaseAnnouncement = ( |
| 91 | 112 | }); |
| 92 | 113 | |
| 93 | 114 | const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 94 | | webhookUrl: string, |
| 115 | webhookUrl: URL, |
| 95 | 116 | payload: DiscordWebhookPayload, |
| 96 | 117 | ) { |
| 97 | 118 | const httpClient = (yield* HttpClient.HttpClient).pipe( |
| ... | ... | @@ -99,10 +120,16 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( |
| 99 | 120 | retryOn: "errors-and-responses", |
| 100 | 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 | 133 | HttpClientRequest.bodyJson(payload), |
| 107 | 134 | Effect.flatMap(httpClient.execute), |
| 108 | 135 | Effect.mapError( |
| ... | ... | @@ -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 | 162 | export const notifyDiscordReleaseCommand = Command.make( |
| 121 | 163 | "notify-discord-release", |
| ... | ... | @@ -140,31 +182,52 @@ export const notifyDiscordReleaseCommand = Command.make( |
| 140 | 182 | Flag.withDescription("Git tag for the release."), |
| 141 | 183 | ), |
| 142 | 184 | releaseUrl: Flag.string("release-url").pipe( |
| 143 | | Flag.withSchema(WebUrlSchema), |
| 185 | Flag.withSchema(Schema.URLFromString), |
| 144 | 186 | Flag.withDescription("Public GitHub release URL."), |
| 145 | 187 | ), |
| 146 | 188 | }, |
| 147 | 189 | ({ target, roleId, releaseName, version, tag, releaseUrl }) => |
| 148 | 190 | Effect.gen(function* () { |
| 149 | | const webhookUrl = yield* DiscordWebhookUrl; |
| 150 | | yield* postDiscordWebhook( |
| 151 | | webhookUrl, |
| 152 | | buildDiscordReleaseAnnouncement({ |
| 191 | yield* Effect.logInfo("discord release announcement starting").pipe( |
| 192 | Effect.annotateLogs({ |
| 153 | 193 | target, |
| 154 | | roleId, |
| 194 | roleIdProvided: roleId.length > 0, |
| 195 | roleIdLength: roleId.length, |
| 155 | 196 | releaseName, |
| 156 | 197 | version, |
| 157 | 198 | tag, |
| 158 | 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 | 220 | ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); |
| 164 | 221 | |
| 165 | 222 | if (import.meta.main) { |
| 166 | 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 | 231 | NodeRuntime.runMain, |
| 169 | 232 | ); |
| 170 | 233 | } |