This commit is contained in:
@@ -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
312
src/web/gitJob.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user