feat. Display Commit Workflow Status
All checks were successful
CI / build (push) Successful in 9s

This commit is contained in:
Space-Banane
2026-02-22 18:13:04 +01:00
parent 1e4a83c0a5
commit 59dace529d
3 changed files with 321 additions and 1 deletions

View File

@@ -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 });

312
src/web/gitJob.ts Normal file
View File

@@ -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<string, NodeJS.Timeout>();
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: <t:${Math.floor(Date.now() / 1000)}:R>`,
),
);
return container;
}
/**
* Fetch the Discord message, edit it with the latest jobs, and persist
* lastEditedAt to MongoDB.
*/
async function performDiscordEdit(doc: any): Promise<void> {
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<string, any> = { ...(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" });
}
});
}

View File

@@ -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) => {