Skip to content
Open
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
40 changes: 40 additions & 0 deletions .github/scripts/discord-bot-api.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { warning } from "@actions/core";

const API_BASE = "https://discord.com/api/v10";

async function callDiscord(botToken, method, path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
Comment on lines +5 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files '.github/scripts/*' | sed -n '1,120p'

printf '\nOutline discord-bot-api.mjs:\n'
ast-grep outline .github/scripts/discord-bot-api.mjs --view expanded || true

printf '\nRelevant source:\n'
cat -n .github/scripts/discord-bot-api.mjs | sed -n '1,220p'

printf '\nSearch for AbortController/timeout usage in scripts:\n'
rg -n --hidden --glob '.github/scripts/*' 'AbortController|setTimeout|timeout|signal:' .github/scripts || true

printf '\nCallers:\n'
rg -n --hidden --glob '.github/scripts/*' 'callDiscord\(' .github/scripts || true

Repository: getopenscreen/openscreen

Length of output: 3478


Add a timeout to callDiscord()

Every Discord request can hang indefinitely here, which can stall the PR sync and leaderboard workflows until the job times out. Add an AbortController-based timeout like the other Discord helper uses.

🤖 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 @.github/scripts/discord-bot-api.mjs around lines 5 - 13, The callDiscord
helper can hang indefinitely because it makes a fetch without any request
timeout, which can block PR sync and leaderboard workflows. Update callDiscord
in discord-bot-api.mjs to use an AbortController-based timeout, matching the
pattern used by the other Discord helper, and ensure the controller is wired
into fetch and cleared/handled after the request completes or fails.


if (res.status === 429) {
const txt = await res.text();
warning(`Discord rate-limited (429) on ${method} ${path}: ${txt}`);
throw new Error(`Discord rate-limited (429) on ${method} ${path}`);
}

if (!res.ok) {
const txt = await res.text();
throw new Error(`Discord API ${method} ${path} failed ${res.status}: ${txt}`);
}

if (res.status === 204) return null;
return res.json();
}

export async function createForumThread({ botToken, forumChannelId, payload }) {
return callDiscord(botToken, "POST", `/channels/${forumChannelId}/threads`, payload);
}

export async function postChannelMessage({ botToken, channelId, payload }) {
return callDiscord(botToken, "POST", `/channels/${channelId}/messages`, payload);
}

export async function patchChannel({ botToken, channelId, payload }) {
return callDiscord(botToken, "PATCH", `/channels/${channelId}`, payload);
}
72 changes: 72 additions & 0 deletions .github/scripts/discord-bot-api.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createForumThread, patchChannel, postChannelMessage } from "./discord-bot-api.mjs";

const botToken = "test-token";

beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
});

function mockResponse({ status = 200, body = { id: "x" } } = {}) {
vi.mocked(fetch).mockResolvedValue({
ok: status >= 200 && status < 300,
status,
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
json: vi.fn().mockResolvedValue(body),
});
}

const happyCases = [
{
name: "createForumThread",
call: (args) => createForumThread(args),
args: { forumChannelId: "forum-1", payload: { name: "PR #1" } },
expectUrl: "https://discord.com/api/v10/channels/forum-1/threads",
expectMethod: "POST",
expectBody: { name: "PR #1" },
},
{
name: "postChannelMessage",
call: (args) => postChannelMessage(args),
args: { channelId: "thread-1", payload: { content: "hello" } },
expectUrl: "https://discord.com/api/v10/channels/thread-1/messages",
expectMethod: "POST",
expectBody: { content: "hello" },
},
{
name: "patchChannel",
call: (args) => patchChannel(args),
args: { channelId: "thread-1", payload: { archived: true } },
expectUrl: "https://discord.com/api/v10/channels/thread-1",
expectMethod: "PATCH",
expectBody: { archived: true },
},
];

describe.each(happyCases)("$name", ({ call, args, expectUrl, expectMethod, expectBody }) => {
it("calls Discord with the right URL, method, bot auth, and payload", async () => {
mockResponse();

await call({ ...args, botToken });

const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(url).toBe(expectUrl);
expect(init.method).toBe(expectMethod);
expect(init.headers.Authorization).toBe(`Bot ${botToken}`);
expect(JSON.parse(init.body)).toEqual(expectBody);
});

it("throws on 429 with rate-limit message", async () => {
mockResponse({ status: 429, body: { retry_after: 1 } });
await expect(call({ ...args, botToken })).rejects.toThrow(/rate-limited \(429\)/);
});

it("throws on non-ok responses with status in message", async () => {
mockResponse({ status: 403, body: { message: "Missing Permissions" } });
await expect(call({ ...args, botToken })).rejects.toThrow(/failed 403/);
});
});
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
Loading
Loading