import { Express, Request, Response } from "express"; import { ButtonBuilder, ButtonStyle, ContainerBuilder, MessageFlags, SectionBuilder, SeparatorBuilder, SeparatorSpacingSize, TextChannel, TextDisplayBuilder, ThumbnailBuilder, } from "discord.js"; import { CHANNELS } from "../config"; import { client, db } from "../index"; const configured_channel = CHANNELS.UPDATES; const EDIT_COOLDOWN_MS = 2000; /** Job names to ignore and not display in the CI Jobs section */ const jobNameIgnore: string[] = ["changes", "Validate Docker Compose"]; /** In-memory map of messageId โ†’ pending setTimeout handle */ const pendingTimeouts = new Map(); function getStatusEmoji(status: string, conclusion: string | null): string { if (status === "completed") { if (conclusion === "success") return "โœ…"; if (conclusion === "cancelled") return "๐Ÿšซ"; return "โŒ"; } if (status === "in_progress") { return Math.random() < 0.5 ? "โณ" : "โŒ›"; } if (status === "queued") { return "๐Ÿ•ฐ๏ธ"; } return "โ“"; } /** * Rebuild the full Components V2 container from a stored commit document, * including the current jobs section and an updated footer. */ function buildContainer(doc: any): ContainerBuilder { const { repository, pusher, branch, compareUrl, forced, commitCount, commitLines, deliveryId, jobs, } = doc; const container = new ContainerBuilder().setAccentColor( forced ? 0xff4444 : 0x2ea44f, ); // Author + title section container.addSectionComponents( new SectionBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent( `## ${forced ? "โš ๏ธ Force Push" : "๐Ÿ“ฆ New Push"} to \`${repository ?? "unknown-repo"}\` on \`${branch}\``, ), ) .setThumbnailAccessory( new ThumbnailBuilder({ media: { url: `https://github.com/${pusher}.png`, }, }), ), ); container.addSeparatorComponents( new SeparatorBuilder() .setDivider(true) .setSpacing(SeparatorSpacingSize.Small), ); // Branch & commits container.addTextDisplayComponents( new TextDisplayBuilder().setContent(`**๐ŸŒฟ Branch:** \`${branch}\``), ); container.addSeparatorComponents( new SeparatorBuilder() .setDivider(false) .setSpacing(SeparatorSpacingSize.Small), ); container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `**๐Ÿ“ Commits (${commitCount})**\n${ (commitLines as string[])?.join("\n") || "_No commits_" }`, ), ); // Compare link button if (compareUrl) { container.addSeparatorComponents( new SeparatorBuilder() .setDivider(true) .setSpacing(SeparatorSpacingSize.Small), ); container.addSectionComponents( new SectionBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent("๐Ÿ”— **View Changes**"), ) .setButtonAccessory( new ButtonBuilder() .setLabel("Compare") .setURL(compareUrl) .setStyle(ButtonStyle.Link), ), ); } // CI Jobs section if (jobs && Object.keys(jobs).length > 0) { container.addSeparatorComponents( new SeparatorBuilder() .setDivider(true) .setSpacing(SeparatorSpacingSize.Small), ); const jobLines = Object.entries(jobs) .filter(([name]) => !jobNameIgnore.includes(name)) .map(([name, info]: [string, any]) => { const emoji = getStatusEmoji(info.status, info.conclusion); const url = info.html_url ? `[${name}](${info.html_url})` : name; return `${emoji} ${url}`; }); container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `**โš™๏ธ CI Jobs**\n${jobLines.join("\n")}`, ), ); } // Footer container.addSeparatorComponents( new SeparatorBuilder() .setDivider(false) .setSpacing(SeparatorSpacingSize.Small), ); container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `-# Delivery: ${deliveryId ?? "unknown"} ยท Updated: `, ), ); return container; } /** * Fetch the Discord message, edit it with the latest jobs, and persist * lastEditedAt to MongoDB. */ async function performDiscordEdit(doc: any): Promise { try { const channel = (await client.channels.fetch( configured_channel, )) as TextChannel | null; if (!channel || !channel.isTextBased()) { console.error("[WEB-gitJob] Channel unavailable for edit"); return; } const message = await channel.messages.fetch(doc.messageId); const container = buildContainer(doc); await message.edit({ flags: MessageFlags.IsComponentsV2, components: [container], }); await db .collection("git_commits") .updateOne( { _id: doc._id }, { $set: { lastEditedAt: new Date(), pendingUpdate: false } }, ); console.log( `[WEB-gitJob] Edited Discord message for ${doc.head_sha} (${Object.keys(doc.jobs ?? {}).length} jobs)`, ); } catch (err) { console.error("[WEB-gitJob] Failed to edit Discord message:", err); } } export default async function gitJobPOST(app: Express) { app.post("/git-job", async (req: Request, res: Response) => { try { const event = req.headers["x-github-event"] as string; if (event === "ping") { console.log("[WEB-gitJob] Received GitHub ping event"); return res.status(200).json({ success: true, message: "pong" }); } if (event !== "workflow_job") { return res.status(200).json({ success: true, message: `Event '${event}' ignored`, }); } const body = req.body; const job = body.workflow_job; const repo = body.repository; if (!job || !repo) { return res.status(400).json({ error: "Invalid payload" }); } const head_sha: string = job.head_sha; const jobName: string = job.name; const status: string = job.status; const conclusion: string | null = job.conclusion ?? null; const html_url: string = job.html_url; // Look up the commit by sha + repo const commitDoc = await db.collection("git_commits").findOne({ head_sha, repository: repo.full_name, }); if (!commitDoc) { console.log( `[WEB-gitJob] No commit found for sha ${head_sha} in ${repo.full_name}`, ); return res.status(200).json({ success: true, message: "No matching commit found", }); } // Merge the new job state into the existing jobs map const jobs: Record = { ...(commitDoc.jobs ?? {}) }; jobs[jobName] = { status, conclusion, html_url }; const now = new Date(); const lastEdited: Date | null = commitDoc.lastEditedAt ? new Date(commitDoc.lastEditedAt) : null; const elapsed = lastEdited ? now.getTime() - lastEdited.getTime() : Infinity; const canEdit = elapsed >= EDIT_COOLDOWN_MS; // Always persist the latest job state to MongoDB await db.collection("git_commits").updateOne( { _id: commitDoc._id }, { $set: { jobs, pendingUpdate: !canEdit, lastModified: now, }, }, ); const updatedDoc = { ...commitDoc, jobs }; if (canEdit) { // Cancel any scheduled pending edit since we're doing it now if (pendingTimeouts.has(commitDoc.messageId)) { clearTimeout(pendingTimeouts.get(commitDoc.messageId)!); pendingTimeouts.delete(commitDoc.messageId); } await performDiscordEdit(updatedDoc); } else { // Schedule an edit once the cooldown expires so the final state // always makes it onto Discord even if we're rate-limited const remaining = EDIT_COOLDOWN_MS - elapsed; console.log( `[WEB-gitJob] Rate limited, scheduling edit in ${remaining}ms for message ${commitDoc.messageId}`, ); if (pendingTimeouts.has(commitDoc.messageId)) { clearTimeout(pendingTimeouts.get(commitDoc.messageId)!); } const timeout = setTimeout(async () => { pendingTimeouts.delete(commitDoc.messageId); // Re-fetch from MongoDB so we get the very latest job states const latestDoc = await db .collection("git_commits") .findOne({ _id: commitDoc._id }); if (latestDoc) { await performDiscordEdit(latestDoc); } }, remaining + 150); // small buffer to stay clear of Discord's window pendingTimeouts.set(commitDoc.messageId, timeout); } return res.status(200).json({ success: true }); } catch (error) { console.error("[WEB-gitJob] Error handling workflow_job:", error); return res .status(500) .json({ success: false, error: "Internal Server Error" }); } }); }