name: PR to Discord Forum on: pull_request_target: types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed] pull_request_review: types: [submitted] issue_comment: types: [created] schedule: - cron: "0 12 * * 1" workflow_dispatch: permissions: contents: read pull-requests: write issues: read jobs: notify: if: github.event_name != 'schedule' && github.actor != 'github-actions[bot]' concurrency: group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} cancel-in-progress: false runs-on: ubuntu-latest steps: - name: Sync PR activity to Discord forum thread id: sync uses: actions/github-script@v7 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 }} with: script: | 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 TAGS = { open: "1493976692967080096", draft: "1493976782028935279", ready: "1493976833626996756", changes: "1493976909875515564", approved: "1493976951038152764", merged: "1493977049709281320", closed: "1493977108102516786", }; const labelTagMap = { bug: "1493977562773458975", enhancement: "1493977619216207993", documentation: "1493978565153394830", }; function cleanDescription(text, maxLen = 3500) { if (!text) return "No description provided."; const normalized = text .replace(/\r\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); if (normalized.length <= maxLen) return normalized; return `${normalized.slice(0, maxLen - 1)}โ€ฆ`; } function trimThreadName(name) { return name.length > 95 ? name.slice(0, 95) : name; } function extractThreadId(body) { if (!body) return null; const match = body.match(THREAD_MARKER_REGEX); return match ? match[1] : null; } function upsertThreadMarker(body, threadId) { const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim(); 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); // Some proxy/CDN edge responses may return HTML with 2xx; avoid crashing on JSON parse. core.warning(`Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`); return {}; } 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(); core.warning(`Discord thread patch failed (${response.status}): ${text}`); } } function desiredStatusTag(prState) { if (prState.merged && TAGS.merged) return TAGS.merged; if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed; if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes; if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved; if (prState.draft && TAGS.draft) return TAGS.draft; if (!prState.draft && TAGS.ready) return TAGS.ready; return TAGS.open || null; } function tagIdsFromLabels(labels) { const out = []; for (const label of labels) { const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label]; if (mapped) out.push(String(mapped)); } return out; } async function getPullRequest() { if (context.eventName === "pull_request_target" || context.eventName === "pull_request_review") { return context.payload.pull_request || null; } if (context.eventName === "issue_comment") { const issue = context.payload.issue; if (!issue?.pull_request) return null; const { data } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: issue.number, }); return data; } return null; } async function getReviewState(owner, repo, pullNumber) { const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 }); let hasChanges = false; let hasApproved = false; for (const r of data) { const s = (r.state || "").toUpperCase(); if (s === "CHANGES_REQUESTED") hasChanges = true; if (s === "APPROVED") hasApproved = true; } if (hasChanges) return "CHANGES_REQUESTED"; if (hasApproved) return "APPROVED"; return "NONE"; } async function sendFailureAlert(message) { if (!alertWebhookUrl) return; try { await fetch(alertWebhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "OpenScreen", avatar_url: WEBHOOK_AVATAR, content: `โš ๏ธ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, allowed_mentions: { parse: [] } }) }); } catch { core.warning("Failed to send failure alert webhook."); } } try { const pr = await getPullRequest(); if (!pr) { core.info("No PR context found. Skipping."); return; } if (!webhookUrl) { const strictEvents = new Set(["pull_request_target", "workflow_dispatch"]); const msg = `Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` + "Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets."; if (strictEvents.has(context.eventName)) { core.setFailed(msg); } else { core.warning(msg); } return; } const action = context.payload.action || ""; const owner = context.repo.owner; const repo = context.repo.repo; const number = pr.number; const title = pr.title; const author = pr.user?.login || "unknown"; const url = pr.html_url; const authorUrl = pr.user?.html_url || ""; const authorAvatar = pr.user?.avatar_url || ""; const base = pr.base?.ref || ""; const head = pr.head?.ref || ""; const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`; const labels = (pr.labels || []).map((l) => l.name); const body = (pr.body || "").trim(); const reviewState = await getReviewState(owner, repo, number); let threadId = extractThreadId(body); const shouldCreateThread = context.eventName === "pull_request_target" && ["opened", "reopened", "ready_for_review"].includes(action) && !threadId; if (shouldCreateThread) { const fields = [ { name: "PR", value: `[#${number}](${url})`, inline: true }, { name: "Author", value: `[${author}](${authorUrl || url})`, inline: true }, { name: "Status", value: pr.draft ? "Draft" : "Open", inline: true }, { name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true }, { name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true }, { name: "Files Changed", value: String(pr.changed_files), inline: true } ]; if (labels.length) { fields.push({ name: "Labels", value: labels.map((l) => `\`${l}\``).join(" "), inline: false, }); } const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false }); const mappedLabelTags = tagIdsFromLabels(labels); const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; const createPayload = { content: action === "ready_for_review" ? "๐Ÿ”” PR is now ready for review" : "๐Ÿ”” New pull request opened", thread_name: trimThreadName(`PR #${number} - ${title}`), 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, }, fields, footer: { text: repoFullName }, timestamp: new Date().toISOString(), }, ], }; const result = await discordPost(createPayload); const createdThreadId = result.channel_id || null; if (createdThreadId) { const updatedBody = upsertThreadMarker(body, createdThreadId); await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody }); core.info(`Created Discord thread ${createdThreadId} and stored mapping.`); } else { core.warning("Discord thread created but channel_id missing in response."); } return; } if (!threadId) { core.info("No mapped Discord thread ID found; skipping update event."); return; } if (context.eventName === "pull_request_target" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) { const statusTag = desiredStatusTag({ draft: action === "converted_to_draft" ? true : pr.draft, reviewState, merged: false, closed: false, }); const mappedLabelTags = tagIdsFromLabels(labels); const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; await patchDiscordThread(threadId, { name: trimThreadName(`PR #${number} - ${title}`), ...(appliedTags.length ? { applied_tags: appliedTags } : {}), }); } let updateMessage = null; let updateEmbed = null; if (context.eventName === "pull_request_target") { if (action === "synchronize") { const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 }); const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details"; updateMessage = `๐Ÿงฉ New commits pushed to PR #${number}`; updateEmbed = { title: `Commit Update โ€ข PR #${number}`, url: `${url}/files`, description: `${list}`, color: 1998671, footer: { text: repoFullName }, timestamp: new Date().toISOString(), }; } else if (action === "edited") { updateMessage = `โœ๏ธ PR #${number} details were edited`; updateEmbed = { title: `PR Updated โ€ข #${number}`, url, description: cleanDescription(body, 1200), color: 1998671, timestamp: new Date().toISOString(), }; } else if (action === "closed") { const isMerged = !!pr.merged; const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true }); const mappedLabelTags = tagIdsFromLabels(labels); const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; await patchDiscordThread(threadId, { ...(appliedTags.length ? { applied_tags: appliedTags } : {}), ...(isMerged ? { archived: true, locked: true } : {}), }); updateMessage = isMerged ? `โœ… PR #${number} was merged` : `๐Ÿ›‘ PR #${number} was closed without merge`; updateEmbed = { title: isMerged ? `Merged โ€ข PR #${number}` : `Closed โ€ข PR #${number}`, url, description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.", color: isMerged ? 5763719 : 15158332, timestamp: new Date().toISOString(), }; } else if (action === "ready_for_review") { updateMessage = `๐Ÿš€ PR #${number} moved from draft to ready for review`; if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; } else if (action === "converted_to_draft") { updateMessage = `๐Ÿ“ PR #${number} converted to draft`; } } else if (context.eventName === "pull_request_review") { const review = context.payload.review; if (review) { const state = (review.state || "commented").toUpperCase(); const reviewer = review.user?.login || "reviewer"; updateMessage = `๐Ÿงช Review ${state} by **${reviewer}** on PR #${number}`; if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; updateEmbed = { title: `Review ${state} โ€ข PR #${number}`, url: review.html_url || url, description: cleanDescription(review.body || "No review note.", 1000), color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671, timestamp: new Date().toISOString(), }; if (state === "CHANGES_REQUESTED" || state === "APPROVED") { const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false }); const mappedLabelTags = tagIdsFromLabels(labels); const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; await patchDiscordThread(threadId, { ...(appliedTags.length ? { applied_tags: appliedTags } : {}), }); } } } else if (context.eventName === "issue_comment") { const comment = context.payload.comment; if (comment) { const commenter = comment.user?.login || "user"; updateMessage = `๐Ÿ’ฌ New comment by **${commenter}** on PR #${number}`; updateEmbed = { title: `New PR Comment โ€ข #${number}`, url: comment.html_url || url, description: cleanDescription(comment.body || "No comment body.", 1000), color: 1998671, timestamp: new Date().toISOString(), }; } } if (!updateMessage && !updateEmbed) { core.info("No Discord update message for this event/action. Skipping."); return; } const payload = { content: updateMessage || "" }; if (updateEmbed) payload.embeds = [updateEmbed]; await discordPost(payload, { threadId }); core.info(`Posted update to Discord thread ${threadId}.`); } catch (err) { const msg = err && err.message ? err.message : String(err); core.setFailed(msg); const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL; if (alertWebhook) { try { await fetch(alertWebhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "OpenScreen", avatar_url: WEBHOOK_AVATAR, content: `โš ๏ธ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, allowed_mentions: { parse: [] } }) }); } catch { core.warning("Failed to send alert webhook."); } } } weekly-contributor-leaderboard: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - name: Post weekly contributor leaderboard uses: actions/github-script@v7 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 }} with: script: | 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(); if (!spotlightWebhook) { core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post."); return; } const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const owner = context.repo.owner; const repo = context.repo.repo; const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`; const search = await github.rest.search.issuesAndPullRequests({ q, per_page: 100, }); const counter = new Map(); for (const item of search.data.items) { const login = item.user?.login; if (!login) continue; counter.set(login, (counter.get(login) || 0) + 1); } const ranked = [...counter.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 10); const totalMerged = search.data.items.length; const lines = ranked.length ? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n") : "No merged PRs this week."; const payload = { username: webhookUsername, ...(webhookAvatar ? { avatar_url: webhookAvatar } : {}), embeds: [ { title: "๐ŸŒŸ Weekly Contributor Leaderboard", description: lines, color: 1998671, fields: [ { name: "Merged PRs (7d)", value: String(totalMerged), inline: true }, { name: "Repository", value: `${owner}/${repo}`, inline: true }, { name: "Period", value: "Last 7 days", inline: true } ], timestamp: new Date().toISOString() } ], 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(); core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`); }