174 lines
7.5 KiB
JavaScript
174 lines
7.5 KiB
JavaScript
"use strict";
|
|
|
|
const crypto = require("node:crypto");
|
|
const { enqueueDeploymentEvent } = require("./deployment-event");
|
|
|
|
const DEFAULT_REPOSITORY = "bingshuo/guanghu-ice-heart";
|
|
const DEFAULT_BRANCH = "main";
|
|
const DEFAULT_TARGET = "JD-FD-PRIMARY";
|
|
const MANIFEST_PATTERN = /^deployment\/requests\/([A-Z0-9][A-Z0-9._-]{5,119})\.json$/;
|
|
|
|
function safeEqualHex(expected, supplied) {
|
|
if (!/^[a-f0-9]{64}$/i.test(String(supplied || ""))) return false;
|
|
const left = Buffer.from(expected, "hex");
|
|
const right = Buffer.from(String(supplied), "hex");
|
|
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
}
|
|
|
|
function verifyForgejoSignature(secret, rawBody, supplied) {
|
|
if (String(secret || "").length < 32) return false;
|
|
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
return safeEqualHex(expected, String(supplied || "").replace(/^sha256=/i, ""));
|
|
}
|
|
|
|
function deterministicEventId(value) {
|
|
const bytes = crypto.createHash("sha256").update(String(value)).digest().subarray(0, 16);
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
const hex = bytes.toString("hex");
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
}
|
|
|
|
function parseMergedPullRequest(payload, eventName, policy = {}) {
|
|
if (eventName !== "pull_request") return { state: "ignored", diagnostic_code: "not_pull_request_event" };
|
|
if (!payload || payload.action !== "closed" || payload.pull_request?.merged !== true) {
|
|
return { state: "ignored", diagnostic_code: "pull_request_not_merged" };
|
|
}
|
|
const pull = payload.pull_request;
|
|
const repository = String(payload.repository?.full_name || pull.base?.repo?.full_name || "").toLowerCase();
|
|
const branch = String(pull.base?.ref || "");
|
|
const commit = String(pull.merge_commit_sha || "").toLowerCase();
|
|
const merger = String(pull.merged_by?.login || pull.merged_by?.username || "");
|
|
const number = Number(pull.number || pull.index || payload.number);
|
|
const expectedRepository = String(policy.repository || DEFAULT_REPOSITORY).toLowerCase();
|
|
const expectedBranch = String(policy.branch || DEFAULT_BRANCH);
|
|
const allowedMergers = policy.allowedMergers || [];
|
|
if (repository !== expectedRepository || branch !== expectedBranch) {
|
|
return { state: "rejected", diagnostic_code: "merge_target_not_allowed" };
|
|
}
|
|
if (!/^[0-9a-f]{40}$/.test(commit) || !Number.isSafeInteger(number) || number < 1) {
|
|
return { state: "rejected", diagnostic_code: "merge_identity_invalid" };
|
|
}
|
|
if (!merger || !allowedMergers.includes(merger)) {
|
|
return { state: "rejected", diagnostic_code: "human_merger_not_allowed" };
|
|
}
|
|
return { state: "accepted", repository, branch, commit, merger, number };
|
|
}
|
|
|
|
async function listPullRequestFiles(merge, options = {}) {
|
|
const fetchImpl = options.fetchImpl || fetch;
|
|
const base = String(options.forgejoApiBase || "http://127.0.0.1:3340/api/v1").replace(/\/$/, "");
|
|
const files = [];
|
|
for (let page = 1; page <= 5; page += 1) {
|
|
const url = `${base}/repos/${merge.repository}/pulls/${merge.number}/files?limit=100&page=${page}`;
|
|
const response = await fetchImpl(url, {
|
|
headers: { accept: "application/json" },
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (!response.ok) throw new Error(`forgejo_pull_files_unavailable:${response.status}`);
|
|
const batch = await response.json();
|
|
if (!Array.isArray(batch)) throw new Error("forgejo_pull_files_invalid");
|
|
files.push(...batch);
|
|
if (batch.length < 100) return files;
|
|
}
|
|
throw new Error("forgejo_pull_files_limit_exceeded");
|
|
}
|
|
|
|
function deploymentManifests(files) {
|
|
const selected = [];
|
|
for (const file of files || []) {
|
|
const name = String(file?.filename || "");
|
|
const match = name.match(MANIFEST_PATTERN);
|
|
if (!match || file.status === "deleted") continue;
|
|
selected.push({ path: name, requestId: match[1] });
|
|
}
|
|
const unique = [...new Map(selected.map(item => [item.path, item])).values()];
|
|
if (unique.length > 5) throw new Error("too_many_deployment_manifests");
|
|
return unique;
|
|
}
|
|
|
|
async function processForgejoMergeWebhook(input, options = {}) {
|
|
const rawBody = Buffer.isBuffer(input.rawBody) ? input.rawBody : Buffer.from(String(input.rawBody || ""));
|
|
if (!verifyForgejoSignature(options.secret, rawBody, input.signature)) {
|
|
return { ok: false, status: 401, state: "rejected", diagnostic_code: "forgejo_signature_invalid" };
|
|
}
|
|
let payload;
|
|
try { payload = JSON.parse(rawBody.toString("utf8")); }
|
|
catch { return { ok: false, status: 400, state: "rejected", diagnostic_code: "forgejo_payload_invalid" }; }
|
|
const merge = parseMergedPullRequest(payload, String(input.eventName || ""), {
|
|
repository: options.repository,
|
|
branch: options.branch,
|
|
allowedMergers: options.allowedMergers || [],
|
|
});
|
|
if (merge.state === "ignored") return { ok: true, status: 202, ...merge, queued: 0 };
|
|
if (merge.state !== "accepted") return { ok: false, status: 409, ...merge };
|
|
|
|
let manifests;
|
|
try {
|
|
const files = await (options.listFiles || (value => listPullRequestFiles(value, options)))(merge);
|
|
manifests = deploymentManifests(files);
|
|
} catch (error) {
|
|
return { ok: false, status: 502, state: "rejected", diagnostic_code: String(error.message || "forgejo_pull_files_failed") };
|
|
}
|
|
if (manifests.length === 0) {
|
|
return { ok: true, status: 202, state: "accepted_no_deployment_manifest", queued: 0, commit_sha: merge.commit };
|
|
}
|
|
|
|
const delivery = String(input.delivery || "");
|
|
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(delivery)) {
|
|
return { ok: false, status: 400, state: "rejected", diagnostic_code: "forgejo_delivery_invalid" };
|
|
}
|
|
const deploymentRepositories = options.deploymentRepositories || {};
|
|
const queue = options.enqueue || enqueueDeploymentEvent;
|
|
const events = [];
|
|
for (const manifest of manifests) {
|
|
const dedupeKey = `${delivery}:${merge.repository}:${merge.commit}:${manifest.requestId}`;
|
|
const eventId = deterministicEventId(dedupeKey);
|
|
const queued = queue({
|
|
schema: "guanghu.deployment-intent/v1",
|
|
repo: merge.repository,
|
|
branch: merge.branch,
|
|
commit_sha: merge.commit,
|
|
resource: `${manifest.requestId}@${merge.commit}`,
|
|
manifest: manifest.path,
|
|
workorder_id: `forgejo-pr:${merge.number}:${delivery}`,
|
|
}, {
|
|
repo: merge.repository,
|
|
branch: merge.branch,
|
|
commit_sha: merge.commit,
|
|
}, options.queueDir, {
|
|
authorizer_id: "ICE-GL∞",
|
|
persona_id: "SYS-GLW-ZY-EXEC-0001",
|
|
execution_runtime_id: "HLCC-MERGE-DEPLOY-GATE-001",
|
|
target: options.target || DEFAULT_TARGET,
|
|
registry: deploymentRepositories,
|
|
event_id: eventId,
|
|
dedupe_key: dedupeKey,
|
|
});
|
|
if (!["queued_for_resident_agent", "duplicate"].includes(queued.state)) {
|
|
return { ok: false, status: 409, state: "rejected", diagnostic_code: queued.diagnostic_code || "deployment_event_rejected" };
|
|
}
|
|
events.push({ request_id: manifest.requestId, event_id: queued.event_id, state: queued.state });
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: 202,
|
|
state: events.every(item => item.state === "duplicate") ? "duplicate" : "queued_for_resident_agent",
|
|
repository: merge.repository,
|
|
branch: merge.branch,
|
|
commit_sha: merge.commit,
|
|
pull_request: merge.number,
|
|
merger: merge.merger,
|
|
queued: events.filter(item => item.state === "queued_for_resident_agent").length,
|
|
events,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
deploymentManifests,
|
|
deterministicEventId,
|
|
listPullRequestFiles,
|
|
parseMergedPullRequest,
|
|
processForgejoMergeWebhook,
|
|
verifyForgejoSignature,
|
|
};
|