diff --git a/.github/scripts/discord-pr-sync.mjs b/.github/scripts/discord-pr-sync.mjs index 3a62dc819..a40e34f4b 100644 --- a/.github/scripts/discord-pr-sync.mjs +++ b/.github/scripts/discord-pr-sync.mjs @@ -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 = //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 = //i; const TAGS = { open: "1493976692967080096", @@ -57,51 +51,15 @@ function upsertThreadMarker(body, threadId) { return `${cleaned}\n\n`.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}`, + ); } } @@ -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; } @@ -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; } @@ -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; @@ -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` @@ -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") { @@ -415,9 +407,9 @@ 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); @@ -425,20 +417,20 @@ async function main() { `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}`, + ); } } } diff --git a/.github/scripts/discord-weekly-leaderboard.mjs b/.github/scripts/discord-weekly-leaderboard.mjs index bcf3192cf..0bfe180a4 100644 --- a/.github/scripts/discord-weekly-leaderboard.mjs +++ b/.github/scripts/discord-weekly-leaderboard.mjs @@ -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; } @@ -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", @@ -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}`); } } diff --git a/.github/workflows/discord-pr-notify.yml b/.github/workflows/discord-pr-notify.yml index 8c97f78ec..20ed1c47c 100644 --- a/.github/workflows/discord-pr-notify.yml +++ b/.github/workflows/discord-pr-notify.yml @@ -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 diff --git a/.github/workflows/discord-weekly-leaderboard.yml b/.github/workflows/discord-weekly-leaderboard.yml index f2c93e453..9724e36de 100644 --- a/.github/workflows/discord-weekly-leaderboard.yml +++ b/.github/workflows/discord-weekly-leaderboard.yml @@ -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 diff --git a/docs/github-actions-workflows.md b/docs/github-actions-workflows.md index cfc810f1d..415445031 100644 --- a/docs/github-actions-workflows.md +++ b/docs/github-actions-workflows.md @@ -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 (``) 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. + ### discord-roadmap-sync.yml Triggered on push to `main` and on merged PRs targeting `main`. Runs `node .github/scripts/discord-roadmap-sync.mjs`, which: @@ -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