This commit is contained in:
@@ -37,6 +37,7 @@ export default async function gitCommitPOST(app: Express) {
|
|||||||
const body = req.body;
|
const body = req.body;
|
||||||
const repo = body.repository;
|
const repo = body.repository;
|
||||||
const pusher = body.pusher;
|
const pusher = body.pusher;
|
||||||
|
const head_sha = body.after;
|
||||||
const commits: any[] = body.commits ?? [];
|
const commits: any[] = body.commits ?? [];
|
||||||
const headCommit = body.head_commit;
|
const headCommit = body.head_commit;
|
||||||
const ref: string = body.ref ?? "";
|
const ref: string = body.ref ?? "";
|
||||||
@@ -173,9 +174,16 @@ export default async function gitCommitPOST(app: Express) {
|
|||||||
pusher: pusher.name,
|
pusher: pusher.name,
|
||||||
branch,
|
branch,
|
||||||
commitCount: commits.length,
|
commitCount: commits.length,
|
||||||
|
commitLines,
|
||||||
compareUrl,
|
compareUrl,
|
||||||
forced,
|
forced,
|
||||||
|
deliveryId: req.headers["x-github-delivery"] ?? "unknown",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
messageId: message.id,
|
||||||
|
head_sha: head_sha,
|
||||||
|
jobs: {},
|
||||||
|
lastEditedAt: new Date(),
|
||||||
|
pendingUpdate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json({ success: true });
|
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 IgnoredPaths = ["/favicon.ico", "/robots.txt", "/", "/hello"];
|
||||||
const KnownPaths = ["/git-commit", "/", "/health"];
|
const KnownPaths = ["/git-commit", "/", "/health", "/git-job"];
|
||||||
|
|
||||||
// log all incoming requests
|
// log all incoming requests
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user