#!/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; });