Skip to content
Closed
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
202 changes: 97 additions & 105 deletions .github/scripts/discord-pr-sync.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import { info, warning } from "@actions/core";
import { context, getOctokit } from "@actions/github";
import { createForumThread, patchChannel, postChannelMessage } from "./discord-bot-api.mjs";
import { validateThreadChannel } from "./discord-thread-validator.mjs";

const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();

const THREAD_MARKER_REGEX = /<!--\s*discord-thread-id:(\d+)\s*-->/i;
const webhookUrl = (
process.env.DISCORD_WEBHOOK_URL ||
process.env.DISCORD_PR_FORUM_WEBHOOK ||
""
).trim();
const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim();
const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim();
const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim();
const forumChannelId = (process.env.DISCORD_PR_FORUM_CHANNEL_ID || "").trim();
const alertChannelId = (process.env.DISCORD_ALERT_CHANNEL_ID || "").trim();

const THREAD_MARKER_REGEX = /<!--\s*discord-thread-id:(\d+)\s*-->/i;

const TAGS = {
open: "1493976692967080096",
Expand Down Expand Up @@ -57,51 +51,15 @@ function upsertThreadMarker(body, threadId) {
return `${cleaned}\n\n<!-- discord-thread-id:${threadId} -->`.trim();
}

async function discordPost(payload, options = {}) {
const endpoint = new URL(webhookUrl);
endpoint.searchParams.set("wait", "true");
if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId));

const response = await fetch(endpoint.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: WEBHOOK_USERNAME,
avatar_url: WEBHOOK_AVATAR,
allowed_mentions: { parse: [] },
...payload,
}),
});

const contentType = (response.headers.get("content-type") || "").toLowerCase();
const text = await response.text();

if (!response.ok) {
throw new Error(`Discord API error ${response.status}: ${text}`);
}

if (!text) return {};
if (contentType.includes("application/json")) return JSON.parse(text);

warning(
`Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`,
);
return {};
}
const NO_MENTIONS = { allowed_mentions: { parse: [] } };

async function patchDiscordThread(threadId, patchBody) {
if (!botToken || !threadId) return;
const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, {
method: "PATCH",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(patchBody),
});
if (!response.ok) {
const text = await response.text();
warning(`Discord thread patch failed (${response.status}): ${text}`);
async function safePatchChannel(args, contextLabel) {
try {
await patchChannel(args);
} catch (err) {
warning(
`Discord thread patch failed for ${contextLabel} (continuing): ${err && err.message ? err.message : err}`,
);
}
}

Expand Down Expand Up @@ -170,10 +128,10 @@ async function main() {
return;
}

if (!webhookUrl) {
if (!botToken || !forumChannelId) {
warning(
`Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` +
"Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets.",
`Discord sync skipped: bot token or forum channel id unavailable for event '${context.eventName}'. ` +
"Set DISCORD_BOT_TOKEN (secret) and DISCORD_PR_FORUM_CHANNEL_ID (variable).",
);
return;
}
Expand Down Expand Up @@ -228,38 +186,51 @@ async function main() {
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))].slice(0, 5);

const createPayload = {
content:
action === "ready_for_review"
? "🔔 PR is now ready for review"
: "🔔 New pull request opened",
thread_name: trimThreadName(`PR #${number} - ${title}`),
name: trimThreadName(`PR #${number} - ${title}`),
auto_archive_duration: 4320,
applied_tags: appliedTags,
embeds: [
{
title: `PR #${number}: ${title}`,
url,
description: cleanDescription(body),
color: pr.draft ? 15105570 : 1998671,
author: {
name: author,
url: authorUrl || undefined,
icon_url: authorAvatar || undefined,
message: {
content:
action === "ready_for_review"
? "🔔 PR is now ready for review"
: "🔔 New pull request opened",
embeds: [
{
title: `PR #${number}: ${title}`,
url,
description: cleanDescription(body),
color: pr.draft ? 15105570 : 1998671,
author: {
name: author,
url: authorUrl || undefined,
icon_url: authorAvatar || undefined,
},
fields,
footer: { text: repoFullName },
timestamp: new Date().toISOString(),
},
fields,
footer: { text: repoFullName },
timestamp: new Date().toISOString(),
},
],
],
allowed_mentions: { parse: [] },
},
};

const result = await discordPost(createPayload);
const createdThreadId = result.channel_id || null;
const thread = await createForumThread({
botToken,
forumChannelId,
payload: createPayload,
});
const createdThreadId = thread?.id || null;
if (createdThreadId) {
const updatedBody = upsertThreadMarker(body, createdThreadId);
await octokit.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody });
await octokit.rest.pulls.update({
owner,
repo,
pull_number: number,
body: updatedBody,
});
info(`Created Discord thread ${createdThreadId} and stored mapping.`);
} else {
warning("Discord thread created but channel_id missing in response.");
warning("Discord thread created but id missing in response.");
}
return;
}
Expand All @@ -286,10 +257,17 @@ async function main() {
});
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))].slice(0, 5);
await patchDiscordThread(threadId, {
name: trimThreadName(`PR #${number} - ${title}`),
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
});
await safePatchChannel(
{
botToken,
channelId: threadId,
payload: {
name: trimThreadName(`PR #${number} - ${title}`),
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
},
},
`tag refresh on ${action}`,
);
}

let updateMessage = null;
Expand Down Expand Up @@ -338,10 +316,17 @@ async function main() {
0,
5,
);
await patchDiscordThread(threadId, {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
...(isMerged ? { archived: true, locked: true } : {}),
});
await safePatchChannel(
{
botToken,
channelId: threadId,
payload: {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
...(isMerged ? { archived: true, locked: true } : {}),
},
},
`close (${isMerged ? "merged" : "closed without merge"})`,
);

updateMessage = isMerged
? `✅ PR #${number} was merged`
Expand Down Expand Up @@ -390,9 +375,16 @@ async function main() {
0,
5,
);
await patchDiscordThread(threadId, {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
});
await safePatchChannel(
{
botToken,
channelId: threadId,
payload: {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
},
},
`review ${state}`,
);
}
}
} else if (context.eventName === "issue_comment") {
Expand All @@ -415,30 +407,30 @@ async function main() {
return;
}

const payload = { content: updateMessage || "" };
const payload = { content: updateMessage || "", ...NO_MENTIONS };
if (updateEmbed) payload.embeds = [updateEmbed];
await discordPost(payload, { threadId });
await postChannelMessage({ botToken, channelId: threadId, payload });
info(`Posted update to Discord thread ${threadId}.`);
} catch (err) {
const msg = err && err.message ? err.message : String(err);
warning(
`Discord sync failed, but this optional automation will not block PR validation: ${msg}`,
);

if (alertWebhookUrl) {
if (alertChannelId) {
try {
await fetch(alertWebhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "OpenScreen",
avatar_url: WEBHOOK_AVATAR,
await postChannelMessage({
botToken,
channelId: alertChannelId,
payload: {
content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
allowed_mentions: { parse: [] },
}),
...NO_MENTIONS,
},
});
} catch {
warning("Failed to send alert webhook.");
} catch (alertErr) {
warning(
`Failed to send alert message: ${alertErr && alertErr.message ? alertErr.message : alertErr}`,
);
}
}
}
Expand Down
29 changes: 13 additions & 16 deletions .github/scripts/discord-weekly-leaderboard.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { info, warning } from "@actions/core";
import { context, getOctokit } from "@actions/github";
import { postChannelMessage } from "./discord-bot-api.mjs";

const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim();
const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim();
const spotlightChannelId = (process.env.DISCORD_SPOTLIGHT_CHANNEL_ID || "").trim();

async function main() {
if (!spotlightWebhook) {
info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post.");
if (!botToken || !spotlightChannelId) {
info("DISCORD_BOT_TOKEN or DISCORD_SPOTLIGHT_CHANNEL_ID missing. Skipping leaderboard post.");
return;
}

Expand Down Expand Up @@ -45,8 +45,6 @@ async function main() {
: "No merged PRs this week.";

const payload = {
username: webhookUsername,
...(webhookAvatar ? { avatar_url: webhookAvatar } : {}),
embeds: [
{
title: "🌟 Weekly Contributor Leaderboard",
Expand All @@ -63,15 +61,14 @@ async function main() {
allowed_mentions: { parse: [] },
};

const res = await fetch(`${spotlightWebhook}?wait=true`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});

if (!res.ok) {
const txt = await res.text();
warning(`Leaderboard post failed ${res.status}: ${txt}`);
try {
await postChannelMessage({
botToken,
channelId: spotlightChannelId,
payload,
});
} catch (err) {
warning(`Leaderboard post failed: ${err && err.message ? err.message : err}`);
}
}

Expand Down
6 changes: 1 addition & 5 deletions .github/workflows/discord-pr-notify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,9 @@ jobs:
- name: Sync PR activity to Discord forum thread
continue-on-error: true
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_PR_FORUM_WEBHOOK: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }}
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }}
DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }}
DISCORD_PR_FORUM_CHANNEL_ID: ${{ vars.DISCORD_PR_FORUM_CHANNEL_ID }}
DISCORD_ALERT_CHANNEL_ID: ${{ vars.DISCORD_ALERT_CHANNEL_ID }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/discord-pr-sync.mjs
5 changes: 2 additions & 3 deletions .github/workflows/discord-weekly-leaderboard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ jobs:

- name: Post weekly leaderboard to Discord
env:
DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }}
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
DISCORD_SPOTLIGHT_CHANNEL_ID: ${{ vars.DISCORD_SPOTLIGHT_CHANNEL_ID }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/discord-weekly-leaderboard.mjs
6 changes: 5 additions & 1 deletion docs/github-actions-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Triggered by `pull_request_target` (opened, reopened, synchronize, edited, label

Runs `node .github/scripts/discord-pr-sync.mjs`, which creates or updates a Discord forum thread for each PR. Thread state is persisted via an HTML comment (`<!-- discord-thread-id:... -->`) in the PR body. Tag updates (draft, ready, changes requested, approved, merged, closed) are applied via the Discord API. The job is marked `continue-on-error: true` so that Discord failures never block the PR workflow.

All Discord traffic goes through a single bot (`DISCORD_BOT_TOKEN`, secret). The script creates the forum thread via `POST /channels/{forumChannelId}/threads` and posts review/comment updates into the existing thread via `POST /channels/{threadId}/messages`. Required bot permissions on the PR forum channel: View Channel, Send Messages, Embed Links, Create Public Threads, Manage Threads (for tag/archive/lock). Optional failure alerts can be sent to a separate channel via `DISCORD_ALERT_CHANNEL_ID` (variable); unset to silence.

Comment on lines +195 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the forum permissions Discord checks.

Forum posts and thread replies need Send Messages in Threads; Create Public Threads is not the right permission to list here. Without that, the bot can still hit 403s when it posts PR updates.

✏️ Proposed doc fix
-All Discord traffic goes through a single bot (`DISCORD_BOT_TOKEN`, secret). The script creates the forum thread via `POST /channels/{forumChannelId}/threads` and posts review/comment updates into the existing thread via `POST /channels/{threadId}/messages`. Required bot permissions on the PR forum channel: View Channel, Send Messages, Embed Links, Create Public Threads, Manage Threads (for tag/archive/lock). Optional failure alerts can be sent to a separate channel via `DISCORD_ALERT_CHANNEL_ID` (variable); unset to silence.
+All Discord traffic goes through a single bot (`DISCORD_BOT_TOKEN`, secret). The script creates the forum thread via `POST /channels/{forumChannelId}/threads` and posts review/comment updates into the existing thread via `POST /channels/{threadId}/messages`. Required bot permissions on the PR forum channel: View Channel, Send Messages, Send Messages in Threads, Embed Links, and Manage Threads (for tag/archive/lock). Optional failure alerts can be sent to a separate channel via `DISCORD_ALERT_CHANNEL_ID` (variable); unset to silence.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
All Discord traffic goes through a single bot (`DISCORD_BOT_TOKEN`, secret). The script creates the forum thread via `POST /channels/{forumChannelId}/threads` and posts review/comment updates into the existing thread via `POST /channels/{threadId}/messages`. Required bot permissions on the PR forum channel: View Channel, Send Messages, Embed Links, Create Public Threads, Manage Threads (for tag/archive/lock). Optional failure alerts can be sent to a separate channel via `DISCORD_ALERT_CHANNEL_ID` (variable); unset to silence.
All Discord traffic goes through a single bot (`DISCORD_BOT_TOKEN`, secret). The script creates the forum thread via `POST /channels/{forumChannelId}/threads` and posts review/comment updates into the existing thread via `POST /channels/{threadId}/messages`. Required bot permissions on the PR forum channel: View Channel, Send Messages, Send Messages in Threads, Embed Links, and Manage Threads (for tag/archive/lock). Optional failure alerts can be sent to a separate channel via `DISCORD_ALERT_CHANNEL_ID` (variable); unset to silence.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/github-actions-workflows.md` around lines 133 - 134, The Discord
permissions documentation for the forum workflow is incorrect: the bot needs
thread-scoped posting permission rather than listing Create Public Threads.
Update the permissions text in the Discord workflow docs to mention Send
Messages in Threads for posting into existing forum threads, and keep the other
required permissions aligned with the forum channel behavior described by the
thread creation and message-posting flow.

### discord-roadmap-sync.yml

Triggered on push to `main` and on merged PRs targeting `main`. Runs `node .github/scripts/discord-roadmap-sync.mjs`, which:
Expand All @@ -205,7 +207,9 @@ Requires `DISCORD_BOT_TOKEN` (secret) and `DISCORD_ROADMAP_CHANNEL_ID` (variable

### discord-weekly-leaderboard.yml

Triggered by schedule (Mondays at 12:00 UTC) and `workflow_dispatch`. Runs `node .github/scripts/discord-weekly-leaderboard.mjs`, which queries the GitHub Search API for merged PRs in the last 7 days, ranks contributors by PR count, and posts a top-10 leaderboard to a Discord webhook.
Triggered by schedule (Mondays at 12:00 UTC) and `workflow_dispatch`. Runs `node .github/scripts/discord-weekly-leaderboard.mjs`, which queries the GitHub Search API for merged PRs in the last 7 days, ranks contributors by PR count, and posts a top-10 leaderboard to the `#🌟・contributor-spotlight` channel via the same bot (`DISCORD_BOT_TOKEN`).

Requires `DISCORD_SPOTLIGHT_CHANNEL_ID` (variable) and the bot to have View Channel + Send Messages + Embed Links on that channel.

### merged-pr-bookkeeping.yml

Expand Down
Loading