Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 51 additions & 32 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,49 +39,68 @@ type NotificationEvent = {
| submitterName | string |
| isAnonymous | boolean |

Routes to **`#contributors`**.

## proposal.reviewed payload

Posted from room-tba admin approve/reject/request-changes routes.

| Field | Type | Notes |
| ------------- | -------------- | --------------------------------------- |
| proposalId | number | |
| outcome | string | `approved`, `rejected`, `needs_changes` |
| entityType | string | |
| entityId | number | `0` for create proposals |
| entityLabel | string | |
| submitterName | string | |
| reviewedBy | string | editor display name |
| adminNote | string \| null | reject / needs-changes note |

Routes to **`#contributors`** (green approve, red reject, amber needs-changes).

## CI E2E payloads (`ci.e2e.*`, `ci.staging-*`)

Posted from room-tba `discord-notify-e2e.yml`. Routes to `#development` except `ci.staging-smoke.failed` → `#deploys`.

| Field | Type | Notes |
| ----- | ---- | ----- |
| workflow | string | e.g. `E2E`, `E2E staging` |
| workflowUrl | string | GitHub Actions run URL |
| branch | string | head branch |
| commitSha | string | full SHA |
| prNumber | number \| null | PR workflows only |
| prUrl | string \| null | |
| suite | string | `blocking`, `advisory`, `staging-smoke` |
| failedStep | string \| null | first failed job step name |
| integrationFailed | boolean | true when integration step failed |
| failedTests | string[] | optional; v2 |
| artifactName | string \| null | Playwright report artifact |
| durationSeconds | number | |
| trigger | string | `pull_request`, `push`, `schedule`, … |
| Field | Type | Notes |
| ----------------- | -------------- | --------------------------------------- |
| workflow | string | e.g. `E2E`, `E2E staging` |
| workflowUrl | string | GitHub Actions run URL |
| branch | string | head branch |
| commitSha | string | full SHA |
| prNumber | number \| null | PR workflows only |
| prUrl | string \| null | |
| suite | string | `blocking`, `advisory`, `staging-smoke` |
| failedStep | string \| null | first failed job step name |
| integrationFailed | boolean | true when integration step failed |
| failedTests | string[] | optional; v2 |
| artifactName | string \| null | Playwright report artifact |
| durationSeconds | number | |
| trigger | string | `pull_request`, `push`, `schedule`, … |

## Test inventory (`ci.test_inventory.updated`)

Posted from room-tba [discord-test-inventory.yml](https://github.com/uplbtools/room-tba/blob/staging/.github/workflows/discord-test-inventory.yml) when spec files change on `staging`/`main`, daily at 04:00 UTC, or via workflow dispatch. Routes to **`#test-suite`** (`CHANNEL_TEST_SUITE_ID`).

The bot **edits in place** the pinned inventory message: one summary embed plus **separate tier embeds** (unit, Vitest, integration, E2E smoke/browse/admin/advisory/staging) listing every spec file inline — no download required.

| Field | Type | Notes |
| ----- | ---- | ----- |
| repo | string | e.g. `uplbtools/room-tba` |
| branch | string | ref name |
| commitSha | string | full SHA |
| commitUrl | string | GitHub commit link |
| docUrl | string | `docs/test-inventory.md` on GitHub |
| workflowUrl | string \| null | Actions run |
| generated | string | `YYYY-MM-DD` |
| total | number | all `*.test.ts` / `*.spec.ts` files |
| unit | number | Bun unit count |
| vitest | number | store + component |
| integration | number | |
| e2eBlocking | number | |
| e2eAdvisory | number | |
| e2eStaging | number | |
| tiers | object | File paths per tier (`unit`, `store`, `component`, `integration`, `e2eBlockingSmoke`, `e2eBlockingBrowse`, `e2eBlockingAdmin`, `e2eAdvisory`, `e2eStaging`) |
| Field | Type | Notes |
| ----------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| repo | string | e.g. `uplbtools/room-tba` |
| branch | string | ref name |
| commitSha | string | full SHA |
| commitUrl | string | GitHub commit link |
| docUrl | string | `docs/test-inventory.md` on GitHub |
| workflowUrl | string \| null | Actions run |
| generated | string | `YYYY-MM-DD` |
| total | number | all `*.test.ts` / `*.spec.ts` files |
| unit | number | Bun unit count |
| vitest | number | store + component |
| integration | number | |
| e2eBlocking | number | |
| e2eAdvisory | number | |
| e2eStaging | number | |
| tiers | object | File paths per tier (`unit`, `store`, `component`, `integration`, `e2eBlockingSmoke`, `e2eBlockingBrowse`, `e2eBlockingAdmin`, `e2eAdvisory`, `e2eStaging`) |

## Leaderboard API (room-tba, future)

Expand Down
8 changes: 3 additions & 5 deletions scripts/post-channel-routing-pins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ const PINS: ChannelPin[] = [
{
id: process.env.CHANNEL_PRS_REVIEWS_ID ?? "1519351243229630637",
name: "#prs-and-reviews",
content: "**PR reviews**\n\n• `pull_request_review` — approve, request changes, comment",
content:
"**PR reviews**\n\n• `pull_request_review` — approve, request changes, comment",
},
{
id: process.env.CHANNEL_DEVELOPMENT_ID ?? "1522313215927783587",
Expand Down Expand Up @@ -67,10 +68,7 @@ if (process.env.CHANNEL_TEST_SUITE_ID?.trim()) {

const API = "https://discord.com/api/v10";

async function discord(
path: string,
init?: RequestInit,
): Promise<Response> {
async function discord(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${API}${path}`, {
...init,
headers: {
Expand Down
5 changes: 1 addition & 4 deletions src/auth/maintainer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ describe("memberRoleIds", () => {
},
} as Parameters<typeof memberRoleIds>[0];

expect(memberRoleIds(interaction)).toEqual([
"maintainer-role-id",
"@everyone",
]);
expect(memberRoleIds(interaction)).toEqual(["maintainer-role-id", "@everyone"]);
});
});
2 changes: 1 addition & 1 deletion src/http/verify-vercel-signature.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHmac } from "node:crypto";
import { describe, expect, test } from "bun:test";
import { createHmac } from "node:crypto";
import { verifyVercelSignature } from "./verify-vercel-signature.js";

describe("verifyVercelSignature", () => {
Expand Down
4 changes: 3 additions & 1 deletion src/notifications/ci-embeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ export function ciE2eEmbed(
return embed;
}

export function channelIdForCiEvent(type: NotificationEvent["type"]): "development" | "deploys" {
export function channelIdForCiEvent(
type: NotificationEvent["type"],
): "development" | "deploys" {
if (type === "ci.staging-smoke.failed") return "deploys";
return "development";
}
60 changes: 53 additions & 7 deletions src/notifications/delivery/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { BOT_FOOTER, ROOM_TBA_BASE } from "../../constants.js";
import { log } from "../../log.js";
import { channelIdForCiEvent, ciE2eEmbed } from "../ci-embeds.js";
import { githubActivityEmbed } from "../github-embeds.js";
import {
channelIdForGithubRoute,
githubDiscordRoute,
} from "../github-routing.js";
import { deliverTestInventory } from "../test-inventory.js";
import { channelIdForGithubRoute, githubDiscordRoute } from "../github-routing.js";
import type { TestInventoryPayload } from "../test-inventory.js";
import type { NotificationEvent, ProposalSubmittedPayload } from "../types.js";
import { deliverTestInventory } from "../test-inventory.js";
import type {
NotificationEvent,
ProposalReviewedPayload,
ProposalReviewOutcome,
ProposalSubmittedPayload,
} from "../types.js";

const seenKeys = new Map<string, number>();
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -66,6 +68,41 @@ function proposalSubmittedEmbed(payload: ProposalSubmittedPayload): EmbedBuilder
.setFooter({ text: BOT_FOOTER });
}

const REVIEW_OUTCOME_META: Record<
ProposalReviewOutcome,
{ title: string; color: number }
> = {
approved: { title: "Proposal approved", color: 0x16a34a },
rejected: { title: "Proposal rejected", color: 0xdc2626 },
needs_changes: { title: "Changes requested", color: 0xd97706 },
};

function proposalReviewedEmbed(payload: ProposalReviewedPayload): EmbedBuilder {
const meta = REVIEW_OUTCOME_META[payload.outcome];
const embed = new EmbedBuilder()
.setColor(meta.color)
.setTitle(meta.title)
.setDescription(
`**${payload.entityLabel}** (${payload.entityType} #${payload.entityId})`,
)
.addFields(
{ name: "Submitted by", value: payload.submitterName, inline: true },
{ name: "Reviewed by", value: payload.reviewedBy, inline: true },
{ name: "Proposal ID", value: String(payload.proposalId), inline: true },
)
.setURL(ROOM_TBA_BASE)
.setFooter({ text: BOT_FOOTER });

if (payload.adminNote?.trim()) {
embed.addFields({
name: "Editor note",
value: payload.adminNote.trim().slice(0, 1024),
});
}

return embed;
}

export async function deliverToDiscord(
client: Client,
event: NotificationEvent,
Expand All @@ -83,6 +120,13 @@ export async function deliverToDiscord(
});
break;
}
case "proposal.reviewed": {
const payload = event.payload as ProposalReviewedPayload;
await sendToChannel(client, config.channelContributorsId, {
embeds: [proposalReviewedEmbed(payload)],
});
break;
}
case "deploy.succeeded":
case "deploy.failed": {
const isProd =
Expand Down Expand Up @@ -127,7 +171,9 @@ export async function deliverToDiscord(
}
case "release.published": {
const route = githubDiscordRoute("release.published");
const channelId = route ? channelIdForGithubRoute(config, route) : config.channelAnnouncementsId;
const channelId = route
? channelIdForGithubRoute(config, route)
: config.channelAnnouncementsId;
await sendToChannel(client, channelId, {
embeds: [githubActivityEmbed(event.type, event.payload, event.occurredAt)],
});
Expand Down
27 changes: 22 additions & 5 deletions src/notifications/github-embeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ function actionLabel(action: string, merged?: boolean): string {
return action.replace(/_/g, " ");
}

function linkField(name: string, url: unknown): { name: string; value: string; inline: boolean } | null {
function linkField(
name: string,
url: unknown,
): { name: string; value: string; inline: boolean } | null {
const u = String(url ?? "");
if (!u.startsWith("http")) return null;
return { name, value: u, inline: false };
Expand Down Expand Up @@ -50,7 +53,11 @@ export function githubActivityEmbed(
case "github.pull_request": {
const action = actionLabel(String(payload.action ?? ""), payload.merged === true);
const color =
payload.merged === true ? 0x16a34a : payload.action === "closed" ? 0x6b7280 : 0x1d4ed8;
payload.merged === true
? 0x16a34a
: payload.action === "closed"
? 0x6b7280
: 0x1d4ed8;
embed
.setColor(color)
.setTitle(`PR ${action}: #${payload.number}`)
Expand All @@ -69,7 +76,11 @@ export function githubActivityEmbed(
case "github.pull_request_review": {
const state = String(payload.reviewState ?? "review");
const color =
state === "approved" ? 0x16a34a : state === "changes_requested" ? 0xdc2626 : 0x1d4ed8;
state === "approved"
? 0x16a34a
: state === "changes_requested"
? 0xdc2626
: 0x1d4ed8;
embed
.setColor(color)
.setTitle(`PR review ${state.replace(/_/g, " ")}: #${payload.prNumber}`)
Expand Down Expand Up @@ -104,7 +115,9 @@ export function githubActivityEmbed(
embed
.setColor(0xdc2626)
.setTitle(`Workflow failed: ${payload.workflow}`)
.setDescription(`\`${payload.branch ?? "?"}\` @ \`${payload.commitSha ?? "?"}\``)
.setDescription(
`\`${payload.branch ?? "?"}\` @ \`${payload.commitSha ?? "?"}\``,
)
.addFields(
{ name: "Repo", value: repo, inline: true },
{ name: "Trigger", value: String(payload.trigger ?? "—"), inline: true },
Expand Down Expand Up @@ -190,7 +203,11 @@ export function githubActivityEmbed(
case "github.deployment_status": {
const state = String(payload.state ?? "");
const color =
state === "success" ? 0x16a34a : state === "failure" || state === "error" ? 0xdc2626 : 0x6b7280;
state === "success"
? 0x16a34a
: state === "failure" || state === "error"
? 0xdc2626
: 0x6b7280;
embed
.setColor(color)
.setTitle(`Deployment ${state}: ${payload.environment ?? "environment"}`)
Expand Down
2 changes: 1 addition & 1 deletion src/notifications/github-routing.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { channelIdForGithubRoute, githubDiscordRoute } from "./github-routing.js";
import type { BotConfig } from "../config.js";
import { channelIdForGithubRoute, githubDiscordRoute } from "./github-routing.js";

const cfg = {
channelGithubId: "github-ch",
Expand Down
26 changes: 25 additions & 1 deletion src/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ describe("notificationEventSchema", () => {
expect(result.success).toBe(true);
});

test("accepts proposal.reviewed envelope", () => {
const result = notificationEventSchema.safeParse({
schemaVersion: 1,
type: "proposal.reviewed",
source: "room-tba",
occurredAt: new Date().toISOString(),
idempotencyKey: "proposal:1:reviewed:approved",
payload: {
proposalId: 1,
outcome: "approved",
entityType: "room",
entityId: 2,
entityLabel: "ICS-255",
submitterName: "Test",
reviewedBy: "Editor",
adminNote: null,
},
});
expect(result.success).toBe(true);
});

test("rejects wrong schema version", () => {
const result = notificationEventSchema.safeParse({
schemaVersion: 2,
Expand Down Expand Up @@ -96,7 +117,10 @@ describe("translateGitHubRelease", () => {

test("ignores non-published actions", () => {
expect(
translateGitHubRelease({ action: "created", release: { tag_name: "v1" } }),
translateGitHubRelease({
action: "created",
release: { tag_name: "v1" },
}),
).toBeNull();
});
});
20 changes: 6 additions & 14 deletions src/notifications/test-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,7 @@ const samplePayload = {

describe("testInventorySummaryEmbed", () => {
test("includes tier counts and doc link", () => {
const embed = testInventorySummaryEmbed(
samplePayload,
"2026-07-03T00:00:00.000Z",
);
const embed = testInventorySummaryEmbed(samplePayload, "2026-07-03T00:00:00.000Z");

expect(embed.data.title).toBe("Room TBA test inventory");
const fields = embed.data.fields ?? [];
Expand All @@ -51,22 +48,17 @@ describe("testInventorySummaryEmbed", () => {

describe("buildTestInventoryEmbeds", () => {
test("builds summary plus tier embeds without attachment", () => {
const embeds = buildTestInventoryEmbeds(
samplePayload,
"2026-07-03T00:00:00.000Z",
);
const embeds = buildTestInventoryEmbeds(samplePayload, "2026-07-03T00:00:00.000Z");

expect(embeds.length).toBeGreaterThan(1);
expect(embeds[0]?.data.title).toBe("Room TBA test inventory");
expect(
embeds.some((e) => e.data.title?.startsWith("Unit tests (Bun)")),
).toBe(true);
expect(embeds.some((e) => e.data.title?.startsWith("Unit tests (Bun)"))).toBe(true);
expect(
embeds.some((e) => e.data.description?.includes("src/lib/foo.test.ts")),
).toBe(true);
expect(
embeds.some((e) => e.data.title?.startsWith("E2E blocking — smoke")),
).toBe(true);
expect(embeds.some((e) => e.data.title?.startsWith("E2E blocking — smoke"))).toBe(
true,
);
});
});

Expand Down
Loading
Loading