Merge pull request #452 from imAaryash/discord-actions
added discord.yaml
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
name: PR to Discord Forum
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
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'
|
||||
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_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 = /<!--\s*discord-thread-id:(\d+)\s*-->/i;
|
||||
const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || "").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<!-- 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,
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Discord API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
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" || 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 {
|
||||
if (!webhookUrl) {
|
||||
core.setFailed("Missing webhook URL (DISCORD_PR_FORUM_WEBHOOK).");
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = await getPullRequest();
|
||||
if (!pr) {
|
||||
core.info("No PR context found. Skipping.");
|
||||
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" &&
|
||||
["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" && ["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") {
|
||||
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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user