diff --git a/src/web/gitCommit.ts b/src/web/gitCommit.ts index a3c6f4a..caa19f6 100644 --- a/src/web/gitCommit.ts +++ b/src/web/gitCommit.ts @@ -37,6 +37,7 @@ export default async function gitCommitPOST(app: Express) { const body = req.body; const repo = body.repository; const pusher = body.pusher; + const head_sha = body.after; const commits: any[] = body.commits ?? []; const headCommit = body.head_commit; const ref: string = body.ref ?? ""; @@ -173,9 +174,16 @@ export default async function gitCommitPOST(app: Express) { pusher: pusher.name, branch, commitCount: commits.length, + commitLines, compareUrl, forced, + deliveryId: req.headers["x-github-delivery"] ?? "unknown", timestamp: new Date(), + messageId: message.id, + head_sha: head_sha, + jobs: {}, + lastEditedAt: new Date(), + pendingUpdate: false, }); return res.status(200).json({ success: true }); diff --git a/src/web/gitJob.ts b/src/web/gitJob.ts new file mode 100644 index 0000000..18b9bc3 --- /dev/null +++ b/src/web/gitJob.ts @@ -0,0 +1,312 @@ +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"]; + +/** 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 { + 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 \`${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" }); + } + }); +} diff --git a/src/webserver.ts b/src/webserver.ts index 17f3595..2496fd4 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -28,7 +28,7 @@ export default async function webserver() { }); const IgnoredPaths = ["/favicon.ico", "/robots.txt", "/", "/hello"]; - const KnownPaths = ["/git-commit", "/", "/health"]; + const KnownPaths = ["/git-commit", "/", "/health", "/git-job"]; // log all incoming requests app.use((req, res, next) => {