294 lines · 8.8 KB · TypeScript
RawDownload
#!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import * as Config from "effect/Config";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Logger from "effect/Logger";import * as Schema from "effect/Schema";import { Argument, Command, Flag } from "effect/unstable/cli";import {  FetchHttpClient,  HttpClient,  HttpClientRequest,  HttpClientResponse,} from "effect/unstable/http"; export type DiscordReleaseTarget = "prerelease" | "latest"; export interface DiscordReleaseAnnouncementOptions {  readonly target: DiscordReleaseTarget;  readonly roleId: string;  readonly releaseName: string;  readonly version: string;  readonly tag: string;  readonly releaseUrl: URL;  readonly timestamp: string;} interface DiscordWebhookPayload {  readonly content: string;  readonly allowed_mentions: {    readonly roles: ReadonlyArray<string>;  };  readonly embeds: ReadonlyArray<{    readonly title: string;    readonly url: string;    readonly description: string;    readonly color: number;    readonly fields: ReadonlyArray<{      readonly name: string;      readonly value: string;      readonly inline: boolean;    }>;    readonly timestamp: string;  }>;} const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); 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>()(  "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>()(  "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",  latest: "Latest",} as const satisfies Record<DiscordReleaseTarget, string>; const targetColors = {  prerelease: 0x5865f2,  latest: 0x2ecc71,} as const satisfies Record<DiscordReleaseTarget, number>; function describeWebhookUrl(webhookUrl: URL) {  return {    configured: true,    origin: webhookUrl.origin,    pathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length,  } as const;} function summarizePayload(payload: DiscordWebhookPayload) {  return {    contentLength: payload.content.length,    embedCount: payload.embeds.length,    allowedRoleMentionCount: payload.allowed_mentions.roles.length,    hasRoleMentionSyntax: payload.content.includes("<@&"),  } as const;} export const buildDiscordReleaseAnnouncement = (  options: DiscordReleaseAnnouncementOptions,): DiscordWebhookPayload => ({  content: `<@&${options.roleId}> ${targetLabels[options.target]} published: ${options.releaseName}`,  allowed_mentions: {    roles: [options.roleId],  },  embeds: [    {      title: options.releaseName,      url: options.releaseUrl.href,      description:        options.target === "prerelease"          ? "A new T3 Code prerelease is available for nightly testers."          : "A new T3 Code latest release is available.",      color: targetColors[options.target],      fields: [        {          name: "Version",          value: options.version,          inline: true,        },        {          name: "Tag",          value: options.tag,          inline: true,        },      ],      timestamp: options.timestamp,    },  ],}); export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (  webhookUrl: URL,  payload: DiscordWebhookPayload,  announcement: DiscordReleaseAnnouncementOptions,) {  const httpClient = (yield* HttpClient.HttpClient).pipe(    HttpClient.retryTransient({      retryOn: "errors-and-responses",      times: 3,    }),  );   yield* Effect.logInfo("discord webhook request dispatching").pipe(    Effect.annotateLogs({      ...describeWebhookUrl(webhookUrl),      ...summarizePayload(payload),    }),  );   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 DiscordReleaseWebhookRequestError({          ...errorContext,          cause,        }),    ),  );   yield* Effect.logInfo("discord webhook response received").pipe(    Effect.annotateLogs({      status: response.status,      ok: response.status >= 200 && response.status < 300,    }),  );   yield* HttpClientResponse.filterStatusOk(response).pipe(    Effect.mapError(      (cause) =>        new DiscordReleaseWebhookResponseError({          ...errorContext,          status: response.status,          cause,        }),    ),  );}); export const notifyDiscordReleaseCommand = Command.make(  "notify-discord-release",  {    target: Argument.choice("target", DISCORD_RELEASE_TARGETS).pipe(      Argument.withDescription("Discord announcement target: prerelease or latest."),    ),    roleId: Flag.string("role-id").pipe(      Flag.withSchema(DiscordRoleIdSchema),      Flag.withDescription("Discord role ID to mention in the release announcement."),    ),    releaseName: Flag.string("release-name").pipe(      Flag.withSchema(Schema.NonEmptyString),      Flag.withDescription("Human-readable release name."),    ),    releaseVersion: Flag.string("release-version").pipe(      Flag.withSchema(Schema.NonEmptyString),      Flag.withDescription("Release version."),    ),    tag: Flag.string("tag").pipe(      Flag.withSchema(Schema.NonEmptyString),      Flag.withDescription("Git tag for the release."),    ),    releaseUrl: Flag.string("release-url").pipe(      Flag.withSchema(Schema.URLFromString),      Flag.withDescription("Public GitHub release URL."),    ),  },  ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl }) =>    Effect.gen(function* () {      yield* Effect.logInfo("discord release announcement starting").pipe(        Effect.annotateLogs({          target,          roleIdProvided: roleId.length > 0,          roleIdLength: roleId.length,          releaseName,          version: releaseVersion,          tag,          releaseUrl,        }),      );       const webhookUrl = yield* DiscordWebhookUrl;      const timestamp = DateTime.formatIso(yield* DateTime.now);      const announcement = {        target,        roleId,        releaseName,        version: releaseVersion,        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, announcement);      yield* Effect.logInfo("discord release announcement completed");    }),).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); if (import.meta.main) {  Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe(    Effect.provide(      Layer.mergeAll(        Logger.layer([Logger.consolePretty()]),        NodeServices.layer,        FetchHttpClient.layer,      ),    ),    NodeRuntime.runMain,  );}