1073b0c214
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Bump Nix package on release / bump (release) Has been cancelled
Update Homebrew Cask / update-cask (release) Has been cancelled
520 lines
23 KiB
YAML
520 lines
23 KiB
YAML
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 = /<!--\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 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,
|
|
})
|
|
});
|
|
|
|
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}`);
|
|
}
|