mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* test: reproduce Actions receipt completion mismatch * fix: complete Discord receipts with Actions identity
217 lines
9.4 KiB
JavaScript
217 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
import {
|
|
announcementKey,
|
|
buildDiscordPayload,
|
|
discussionReceiptMarker,
|
|
discussionReceiptStatus,
|
|
findDiscussionReceipt,
|
|
findDiscordReceipt,
|
|
findReleaseDiscussion,
|
|
normalizeDiscordWebhookUrl,
|
|
releaseMarker,
|
|
} from './announcement-core.mjs';
|
|
|
|
const env = process.env;
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
async function request(url, options = {}, attempts = 3) {
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
const response = await fetch(url, options);
|
|
if (response.status !== 429 || attempt === attempts) return response;
|
|
const data = await response.json().catch(() => ({}));
|
|
await sleep(Math.min(Number(data.retry_after || 1) * 1000 + 250, 10_000));
|
|
}
|
|
throw new Error('request retry budget exhausted');
|
|
}
|
|
|
|
async function githubGraphql(query, variables) {
|
|
const response = await request('https://api.github.com/graphql', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, variables }),
|
|
});
|
|
if (!response.ok) throw new Error(`GitHub GraphQL request failed (${response.status})`);
|
|
const payload = await response.json();
|
|
if (payload.errors) throw new Error('GitHub GraphQL returned errors');
|
|
return payload.data;
|
|
}
|
|
|
|
async function releaseFromGitHub() {
|
|
const [owner, repo] = env.GITHUB_REPOSITORY.split('/');
|
|
const tag = env.RELEASE_TAG || env.GITHUB_REF_NAME;
|
|
const response = await request(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, {
|
|
headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' },
|
|
});
|
|
if (!response.ok) throw new Error(`release lookup failed (${response.status})`);
|
|
return response.json();
|
|
}
|
|
|
|
async function createOrFindReleaseDiscussion() {
|
|
const release = await releaseFromGitHub();
|
|
const [owner, name] = env.GITHUB_REPOSITORY.split('/');
|
|
const marker = releaseMarker(release.tag_name);
|
|
const data = await githubGraphql(
|
|
`query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id discussionCategories(first:25){nodes{id name}}}}`,
|
|
{ owner, name },
|
|
);
|
|
const repository = data.repository;
|
|
let cursor = null;
|
|
let existing = null;
|
|
for (let page = 0; page < 50 && !existing; page += 1) {
|
|
const pageData = await githubGraphql(
|
|
`query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){discussions(first:100,after:$after,orderBy:{field:CREATED_AT,direction:DESC}){nodes{id title body url category{name}} pageInfo{hasNextPage endCursor}}}}`,
|
|
{ owner, name, after: cursor },
|
|
);
|
|
const discussions = pageData.repository.discussions;
|
|
existing = findReleaseDiscussion(discussions.nodes, marker);
|
|
if (!discussions.pageInfo.hasNextPage) break;
|
|
cursor = discussions.pageInfo.endCursor;
|
|
}
|
|
if (existing) return existing;
|
|
const category = repository.discussionCategories.nodes.find(item => item.name === 'Announcements');
|
|
if (!category) throw new Error('Announcements discussion category is required');
|
|
const title = `${release.name || release.tag_name} release`;
|
|
const body = [marker, release.body || '', `Release: ${release.html_url}`].filter(Boolean).join('\n\n');
|
|
const created = await githubGraphql(
|
|
`mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{id title body url category{name}}}}`,
|
|
{ repo: repository.id, cat: category.id, title, body },
|
|
);
|
|
return created.createDiscussion.discussion;
|
|
}
|
|
|
|
function discussionFromEnvironment() {
|
|
if (env.DISCUSSION_CATEGORY !== 'Announcements') throw new Error('discussion is not an Announcement');
|
|
return {
|
|
id: env.DISCUSSION_ID,
|
|
title: env.DISCUSSION_TITLE,
|
|
body: env.DISCUSSION_BODY,
|
|
url: env.DISCUSSION_URL,
|
|
};
|
|
}
|
|
|
|
async function discussionFromGitHub() {
|
|
if (!/^\d+$/.test(env.DISCUSSION_NUMBER || '')) throw new Error('discussion number is invalid');
|
|
const response = await request(`https://api.github.com/repos/${env.GITHUB_REPOSITORY}/discussions/${env.DISCUSSION_NUMBER}`, {
|
|
headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' },
|
|
});
|
|
if (!response.ok) throw new Error(`discussion lookup failed (${response.status})`);
|
|
const discussion = await response.json();
|
|
if (discussion.category?.name !== 'Announcements') throw new Error('discussion is not an Announcement');
|
|
return { id: discussion.node_id, title: discussion.title, body: discussion.body, url: discussion.html_url };
|
|
}
|
|
|
|
async function findReceiptComment(discussionId, marker) {
|
|
let cursor = null;
|
|
for (let page = 0; page < 50; page += 1) {
|
|
const data = await githubGraphql(
|
|
`query($id:ID!,$after:String){node(id:$id){... on Discussion{comments(first:100,after:$after){nodes{id body author{login}} pageInfo{hasNextPage endCursor}}}}}`,
|
|
{ id: discussionId, after: cursor },
|
|
);
|
|
const comments = data.node?.comments;
|
|
if (!comments) throw new Error('discussion receipt lookup failed');
|
|
const receipt = findDiscussionReceipt(comments.nodes, marker);
|
|
if (receipt) return receipt;
|
|
if (!comments.pageInfo.hasNextPage) return null;
|
|
cursor = comments.pageInfo.endCursor;
|
|
}
|
|
throw new Error('discussion receipt lookup exceeded page budget');
|
|
}
|
|
|
|
async function addReceiptComment(discussionId, body) {
|
|
const data = await githubGraphql(
|
|
`mutation($id:ID!,$body:String!){addDiscussionComment(input:{discussionId:$id,body:$body}){comment{id}}}`,
|
|
{ id: discussionId, body },
|
|
);
|
|
return data.addDiscussionComment.comment.id;
|
|
}
|
|
|
|
async function deleteReceiptComment(commentId) {
|
|
await githubGraphql(
|
|
`mutation($id:ID!){deleteDiscussionComment(input:{id:$id}){clientMutationId}}`,
|
|
{ id: commentId },
|
|
);
|
|
}
|
|
|
|
async function discord(method, path, body) {
|
|
const response = await request(`https://discord.com/api/v10${path}`, {
|
|
method,
|
|
headers: { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) throw new Error(`Discord request failed (${response.status})`);
|
|
return response.status === 204 ? null : response.json();
|
|
}
|
|
|
|
async function deliver(discussion) {
|
|
const key = announcementKey({ repository: env.GITHUB_REPOSITORY, discussionId: discussion.id });
|
|
if (env.DISCORD_ANNOUNCE_WEBHOOK_URL) {
|
|
if (!env.GITHUB_TOKEN) throw new Error('GitHub receipt configuration is missing');
|
|
const webhookUrl = normalizeDiscordWebhookUrl(env.DISCORD_ANNOUNCE_WEBHOOK_URL);
|
|
const marker = discussionReceiptMarker(key);
|
|
const existingReceipt = await findReceiptComment(discussion.id, marker);
|
|
if (existingReceipt) {
|
|
if (discussionReceiptStatus(existingReceipt) === 'complete') {
|
|
console.log('announcement already delivered');
|
|
return;
|
|
}
|
|
throw new Error('announcement has a pending receipt; inspect Discord before clearing it');
|
|
}
|
|
const claimId = await addReceiptComment(discussion.id, `${marker}\n\nDiscord delivery: pending.`);
|
|
const payload = buildDiscordPayload({ title: discussion.title, body: discussion.body, url: discussion.url, key });
|
|
delete payload.nonce;
|
|
delete payload.enforce_nonce;
|
|
const response = await request(webhookUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!response.ok) {
|
|
await deleteReceiptComment(claimId);
|
|
throw new Error(`Discord webhook request failed (${response.status})`);
|
|
}
|
|
const message = await response.json();
|
|
await addReceiptComment(discussion.id, `${marker}\n\nDiscord delivery: complete (message ${message.id}).`);
|
|
await deleteReceiptComment(claimId).catch(() => {
|
|
console.warn('announcement delivered; pending receipt cleanup requires attention');
|
|
});
|
|
console.log('announcement delivered by channel webhook');
|
|
return;
|
|
}
|
|
if (!env.DISCORD_BOT_TOKEN || !/^\d{10,25}$/.test(env.DISCORD_ANNOUNCE_CHANNEL_ID || '')) {
|
|
throw new Error('Discord announcement credentials are missing or invalid');
|
|
}
|
|
const recent = await discord('GET', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/messages?limit=100`);
|
|
const receipt = findDiscordReceipt(recent, key);
|
|
if (receipt) {
|
|
await discord('PUT', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/pins/${receipt.id}`);
|
|
console.log('announcement already delivered; pin verified');
|
|
return;
|
|
}
|
|
const message = await discord('POST', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/messages`, buildDiscordPayload({
|
|
title: discussion.title,
|
|
body: discussion.body,
|
|
url: discussion.url,
|
|
key,
|
|
}));
|
|
await discord('PUT', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/pins/${message.id}`);
|
|
console.log('announcement delivered and pinned');
|
|
}
|
|
|
|
async function main() {
|
|
if (!env.GITHUB_REPOSITORY) throw new Error('GitHub repository configuration is missing');
|
|
if ((env.ANNOUNCEMENT_KIND === 'release' || env.ANNOUNCEMENT_KIND === 'manual') && !env.GITHUB_TOKEN) throw new Error('GitHub configuration is missing');
|
|
const discussion = env.ANNOUNCEMENT_KIND === 'release'
|
|
? await createOrFindReleaseDiscussion()
|
|
: env.ANNOUNCEMENT_KIND === 'manual'
|
|
? await discussionFromGitHub()
|
|
: discussionFromEnvironment();
|
|
await deliver(discussion);
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(`release-announce failed: ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|