diff --git a/docs/discord.md b/docs/discord.md index a12262e..1e03c6a 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -7,10 +7,46 @@ Only ARK and arknet have `.github/workflows/notify-discord.yml`, which calls the workflow's metadata and job results. It does not execute the triggering commit or download its artifacts. A matrix produces one summary per workflow attempt. -Messages show the repository, workflow, branch, commit, outcome, job counts, -failed or cancelled job names, and a link to the run. Mentions are disabled. +## Summary cards + +Each attempt produces one Discord Embed. Its title and color identify the outcome: +✅ green for success, ❌ red for failure (including timeouts, action-required and +startup failures), and ⏹ amber for cancellation. Other conclusions use a neutral +color and their result label. + +The card shows the repository and workflow, PR number when GitHub supplies one, +branch, linked short SHA and commit subject when available, job counts, and links +to the exact run attempt. If multiple PRs are attached, the first is linked and +the remaining count is shown. Successful runs omit zero counts and problem fields. +A missing branch is marked detached; missing job data or commit metadata is not +invented. + +Failed jobs appear first, before cancelled jobs and general metadata. Each job +links to its own GitHub job page and shows up to two failed step names. If GitHub +provides no failed step, the card says so. Extra failed steps have a remaining +count. Large problem lists occupy at most two fields and explicitly report how +many jobs were omitted; the run link leads to all results. + +**Duration** runs from the earliest executed job start to the latest job +completion in the notified attempt. It includes gaps between those jobs, excludes +the initial queue, and does not add parallel matrix durations. It does not use +run creation or update timestamps, which can mislead on reruns and manual +resends. If any executed job has missing or invalid timestamps, or all jobs are +skipped, it displays `Unavailable`. + +Source titles, branch names, job names and step names are treated as text: +Markdown is escaped and mentions and automatic URL formatting are neutralized. +Only links built from the repository, validated IDs and SHA are emitted. Mentions +are also disabled with `allowed_mentions`. Text truncation preserves complete +escapes and Unicode characters; whole links are kept intact. Field budgets stay +within Discord's title, field, field-count and 6,000-character aggregate +[Embed limits](https://docs.discord.com/developers/resources/message#embed-limits). +These are standard Webhook Embeds with Markdown links; they do not require buttons. + Discord must return a message receipt for the notification job to pass. Explicit rate limits are retried twice; other delivery errors fail the notification job. +The card formatting does not change the shared workflow, Secrets, permissions, +resend behavior, or delivery/receipt rules. ## Webhook configuration diff --git a/docs/discord_CN.md b/docs/discord_CN.md index 7de9b11..0be59cb 100644 --- a/docs/discord_CN.md +++ b/docs/discord_CN.md @@ -6,9 +6,37 @@ `discord-summary.yml`。通知只读取已完成工作流的元数据和任务结果, 不执行触发通知的提交,也不下载其产物。矩阵任务按一次工作流执行汇总为一条消息。 -消息包含仓库、工作流、分支、提交、结果、任务数量、失败或取消的任务名称,以及运行链接。 -消息不会触发成员提醒。只有收到 Discord 消息回执,通知任务才算通过。 -遇到明确的限流响应最多重试两次,其他发送错误会使通知任务失败。 +## 汇总卡片 + +每次执行生成一条 Discord Embed。标题与颜色共同表示结果:成功使用 ✅ 和绿色, +失败使用 ❌ 和红色(包括超时、需要操作、启动失败),取消使用 ⏹ 和琥珀色。 +其他结果使用中性色,并显示对应状态。 + +卡片包含仓库、工作流、GitHub 提供的 PR 编号、分支、带链接的短 SHA、可用的提交标题、 +任务数量,以及本次执行的链接。关联多个 PR 时,显示第一个 PR 的链接和其余 PR 数量。 +成功时省略数量为零的状态和异常任务字段。分支缺失时标记为 detached; +任务数据或提交元数据缺失时,不补造内容。 + +失败任务优先展示,其后是取消的任务,再展示常规信息。每个任务链接到对应的 +GitHub 任务页面,并显示最多两个失败步骤名称。GitHub 未提供失败步骤时,卡片明确提示。 +其余失败步骤显示剩余数量。异常任务清单最多占用两个字段,超出时明确提示省略的任务数量; +完整结果通过运行链接查看。 + +**执行耗时(Duration)** 表示本次执行中,从最早一个已执行任务开始到最后一个任务结束的时间。 +它包含这些任务之间的间隔,不包含初始排队时间,也不累加并行矩阵任务的耗时。 +计算不使用工作流创建时间或更新时间,避免重新执行和手动重发导致结果失真。 +任一已执行任务缺少有效时间戳,或所有任务均跳过时,显示 `Unavailable`。 + +标题、分支名、任务名和步骤名均按文本处理:转义 Markdown,阻止成员提醒和自动 URL 格式化。 +仅生成由仓库地址、已校验的 ID 和 SHA 组成的链接,同时通过 `allowed_mentions` 禁用提醒。 +截断保留完整转义和 Unicode 字符,不截断链接。字段预算遵守 Discord 的标题长度、字段长度、 +字段数量和总计 6,000 字符的 +[Embed 限制](https://docs.discord.com/developers/resources/message#embed-limits)。 +卡片使用普通 Webhook Embed 和 Markdown 链接,不依赖按钮。 + +只有收到 Discord 消息回执,通知任务才算通过。遇到明确的限流响应最多重试两次, +其他发送错误会使通知任务失败。卡片格式调整不改变共享工作流、Secret、权限、 +重新发送行为,以及投递和回执规则。 ## Webhook 配置 diff --git a/scripts/discord.cjs b/scripts/discord.cjs index fcd1083..3e6c8c0 100644 --- a/scripts/discord.cjs +++ b/scripts/discord.cjs @@ -7,10 +7,148 @@ const labels = { neutral: 'Neutral', skipped: 'Skipped', stale: 'Stale', startup_failure: 'Startup failure', }; +const events = { + pull_request: 'Pull request', push: 'Push', merge_group: 'Merge queue', + workflow_dispatch: 'Manual run', schedule: 'Scheduled run', workflow_run: 'Workflow completion', +}; + +// Escape before truncating, keeping escapes and surrogate pairs intact. Source metadata +// must remain text: it cannot add Markdown links, mentions or URL previews. +function text(value, limit, markdown = true) { + const tokens = Array.from(String(value ?? '').replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim(), char => { + if (char === '@') return '@\u200b'; + if (char === '<') return '‹'; + if (char === '>') return '›'; + if (char === ':' || char === '.') return char + '\u200b'; + return markdown && /[\\`*_{}[\]()#+!|~\-]/.test(char) ? '\\' + char : char; + }); + const result = tokens.join(''); + if (result.length <= limit) return result; + let shortened = ''; + for (const token of tokens) { + if (shortened.length + token.length > limit - 1) break; + shortened += token; + } + return shortened + '…'; +} + +function positiveId(value) { + return /^\d{1,16}$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0; +} + +function repositoryUrl(context) { + try { + const server = new URL(context.serverUrl || 'https://github.com'); + const { owner, repo } = context.repo; + if (server.protocol !== 'https:' || server.username || server.password || + ![owner, repo].every(part => /^[a-zA-Z0-9_.-]{1,100}$/.test(part))) return ''; + const url = `${server.origin}/${owner}/${repo}`.replace(/[()]/g, char => char === '(' ? '%28' : '%29'); + // Bound encoded targets too; never truncate a Markdown link inside its URL. + return url.length <= 256 ? url : ''; + } catch { + return ''; + } +} -function clip(value, limit) { - const text = String(value ?? ''); - return text.length <= limit ? text : text.slice(0, limit - 3) + '...'; +function link(label, url) { + return url ? `[${label}](${url})` : label; +} + +function duration(jobs) { + // Current-attempt job timestamps avoid original-run creation times on reruns, + // summed matrix durations, and updated_at (which is not a completion time). + const executed = jobs.filter(job => job.conclusion !== 'skipped'); + if (!executed.length) return 'Unavailable'; + let first = Infinity, last = -Infinity; + for (const job of executed) { + const start = Date.parse(job.started_at), end = Date.parse(job.completed_at); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 'Unavailable'; + first = Math.min(first, start); + last = Math.max(last, end); + } + const seconds = Math.floor((last - first) / 1000); + const hours = Math.floor(seconds / 3600), minutes = Math.floor(seconds % 3600 / 60); + return `${hours ? `${hours}h ` : ''}${hours || minutes ? `${minutes}m ` : ''}${seconds % 60}s`; +} + +function problemFields(jobs, runUrl) { + const problems = [ + ...jobs.filter(job => failed.has(job.conclusion)), + ...jobs.filter(job => job.conclusion === 'cancelled'), + ]; + const fields = []; + let index = 0; + while (index < problems.length && fields.length < 2) { + const rows = []; + // Reserve space for a visible omission count in the final field. + const budget = fields.length === 1 ? 940 : 1024; + while (index < problems.length) { + const job = problems[index]; + const jobUrl = positiveId(job.id) && runUrl ? `${runUrl}/job/${job.id}` : ''; + const steps = (job.steps || []).filter(step => failed.has(step.conclusion)); + const details = steps.slice(0, 2).map(step => text(step.name || '(unnamed step)', 80)).join('; '); + const stepSummary = steps.length + ? `\n ↳ ${details}${steps.length > 2 ? ` (+${steps.length - 2} more failed steps)` : ''}` + : failed.has(job.conclusion) ? '\n ↳ Failed step unavailable' : ''; + const row = `${link(text(job.name || '(unnamed job)', 100), jobUrl)} — ${labels[job.conclusion]}${stepSummary}`; + if (rows.join('\n').length + (rows.length ? 1 : 0) + row.length > budget) break; + rows.push(row); + index++; + } + fields.push({ name: fields.length ? 'Problems (continued)' : 'Failed / cancelled jobs', value: rows.join('\n') }); + } + if (index < problems.length) { + fields[fields.length - 1].value += `\n… ${problems.length - index} more jobs; see the run for all results.`; + } + return fields; +} + +function buildPayload(context, run, jobs, runId) { + const repoUrl = repositoryUrl(context); + const runUrl = repoUrl ? `${repoUrl}/actions/runs/${runId}` : ''; + const attemptUrl = runUrl && positiveId(run.run_attempt) ? `${runUrl}/attempts/${run.run_attempt}` : runUrl; + const outcome = labels[run.conclusion] || 'Unknown result'; + const icon = run.conclusion === 'success' ? '✅' : failed.has(run.conclusion) ? '❌' : run.conclusion === 'cancelled' ? '⏹' : 'ℹ️'; + const counts = { passed: 0, failed: 0, cancelled: 0, skipped: 0, other: 0 }; + for (const job of jobs) { + if (job.conclusion === 'success') counts.passed++; + else if (failed.has(job.conclusion)) counts.failed++; + else if (job.conclusion === 'cancelled') counts.cancelled++; + else if (job.conclusion === 'skipped') counts.skipped++; + else counts.other++; + } + const summary = Object.entries(counts).filter(([, count]) => count).map(([label, count]) => `${count} ${label}`).join(' · ') || 'No jobs reported'; + const pulls = (run.pull_requests || []).filter(pr => positiveId(pr.number)); + const pull = pulls[0]; + const ref = text(run.head_branch || '(detached)', 160); + const pullSummary = pull ? `${link(`PR #${pull.number}`, repoUrl ? `${repoUrl}/pull/${pull.number}` : '')}${pulls.length > 1 ? ` (+${pulls.length - 1} more PRs)` : ''}\n` : ''; + const sha = typeof run.head_sha === 'string' && /^[a-fA-F0-9]{7,64}$/.test(run.head_sha) ? run.head_sha : ''; + const displayTitle = run.display_title || run.name || 'Workflow completed'; + const subject = (run.head_commit?.message || '').split(/\r?\n/)[0]; + const commitTitle = subject === displayTitle ? '' : text(subject, 160); + const fields = [ + ...problemFields(jobs, runUrl), + { name: 'Repository', value: link(text(`${context.repo.owner}/${context.repo.repo}`, 128), repoUrl), inline: true }, + { name: 'Workflow', value: link(text(run.name || '(unnamed workflow)', 120), attemptUrl), inline: true }, + { name: pull ? 'Pull request / branch' : 'Branch', value: pullSummary + ref }, + { name: 'Commit', value: `${link(sha ? sha.slice(0, 12) : 'Unavailable', sha && repoUrl ? `${repoUrl}/commit/${sha}` : '')}${commitTitle ? `\n${commitTitle}` : ''}` }, + { name: 'Duration', value: duration(jobs), inline: true }, + { name: 'Jobs', value: summary, inline: true }, + { name: 'Run', value: link(`Open run #${positiveId(run.run_number) ? run.run_number : '?'} · attempt ${positiveId(run.run_attempt) ? run.run_attempt : '?'}`, attemptUrl) }, + ]; + // Fixed field budgets keep this single embed below Discord's 6,000-character + // aggregate limit, including worst-case escaped text and two problem fields. + return { + allowed_mentions: { parse: [] }, + embeds: [{ + title: `${icon} ${outcome} · ${text(run.name || '(unnamed workflow)', 200)}`, + description: text(displayTitle, 512), + ...(attemptUrl ? { url: attemptUrl } : {}), + color: run.conclusion === 'success' ? 0x2da44e : failed.has(run.conclusion) ? 0xcf222e : run.conclusion === 'cancelled' ? 0xbf8700 : 0x6e7781, + fields, + footer: { text: `GitHub Actions · ${text(events[run.event] || run.event || 'Unknown event', 80, false)}` }, + }], + }; } module.exports = async function notify({ @@ -31,7 +169,7 @@ module.exports = async function notify({ } catch { throw new Error('DISCORD_WEBHOOK_URL is not a valid Discord incoming Webhook.'); } - if (!/^\d+$/.test(runId) || !Number.isSafeInteger(Number(runId)) || Number(runId) < 1) { + if (!positiveId(runId)) { throw new Error('A valid workflow run ID is required.'); } @@ -48,36 +186,7 @@ module.exports = async function notify({ throw new Error('Could not read a completed source workflow and its jobs.'); } - const counts = { passed: 0, failed: 0, cancelled: 0, skipped: 0, other: 0 }; - for (const job of jobs) { - if (job.conclusion === 'success') counts.passed++; - else if (failed.has(job.conclusion)) counts.failed++; - else if (job.conclusion === 'cancelled') counts.cancelled++; - else if (job.conclusion === 'skipped') counts.skipped++; - else counts.other++; - } - const summary = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', '); - const problemJobs = jobs.filter(job => failed.has(job.conclusion) || job.conclusion === 'cancelled'); - const fields = [ - { name: 'Repository', value: clip(`${repo.owner}/${repo.repo}`, 256), inline: true }, - { name: 'Branch', value: clip(run.head_branch || '(detached)', 256), inline: true }, - { name: 'Commit', value: clip(run.head_sha, 12), inline: true }, - { name: 'Jobs', value: summary }, - ]; - if (problemJobs.length) { - fields.push({ name: 'Failed or cancelled jobs', value: clip(problemJobs.map(job => `${job.name}: ${job.conclusion}`).join('\n'), 1024) }); - } - const payload = { - allowed_mentions: { parse: [] }, - embeds: [{ - title: clip(`${run.name}: ${labels[run.conclusion] ?? run.conclusion}`, 256), - description: clip(run.display_title || run.name, 512), - url: run.html_url, - color: run.conclusion === 'success' ? 0x2da44e : failed.has(run.conclusion) ? 0xcf222e : 0x6e7781, - fields, - footer: { text: clip(`Run #${run.run_number}, attempt ${run.run_attempt} | ${run.event}`, 256) }, - }], - }; + const payload = buildPayload(context, run, jobs, runId); // Retry only explicit rate limits; ambiguous delivery errors could duplicate a message. for (let attempt = 0; attempt < 3; attempt++) { diff --git a/tests/discord.test.cjs b/tests/discord.test.cjs index b026d4d..da069b9 100644 --- a/tests/discord.test.cjs +++ b/tests/discord.test.cjs @@ -9,10 +9,11 @@ function setup(overrides = {}) { id: 42, run_number: 9, run_attempt: 2, status: 'completed', conclusion: 'success', name: 'CI', display_title: 'Fix @everyone', head_branch: 'main', head_sha: 'a'.repeat(40), html_url: 'https://github.com/OpenArkStudio/arknet/actions/runs/42', event: 'push', - path: '.github/workflows/ci.yml', + path: '.github/workflows/ci.yml', head_commit: { message: 'Fix connection retry\n\nDetails' }, ...overrides.run, }; - const jobs = overrides.jobs ?? [{ name: 'Linux', conclusion: 'success' }]; + const jobs = overrides.jobs ?? [{ id: 101, name: 'Linux', conclusion: 'success', + started_at: '2026-10-22T01:00:00Z', completed_at: '2026-10-22T01:02:05Z' }]; const args = { webhook, runId: '42', context: { repo: { owner: 'OpenArkStudio', repo: 'arknet' } }, core: { info: message => logs.push(message), setOutput: (key, value) => { outputs[key] = value; } }, @@ -36,32 +37,200 @@ test('reports the completed attempt, suppresses mentions and records delivery', assert.equal(sent[0].url.searchParams.get('wait'), 'true'); assert.equal(sent[0].options.redirect, 'error'); assert.deepEqual(sent[0].payload.allowed_mentions, { parse: [] }); - assert.match(sent[0].payload.embeds[0].fields[3].value, /1 passed, 0 failed/); + const embed = sent[0].payload.embeds[0]; + assert.match(embed.title, /^✅ Succeeded/); + assert.equal(embed.color, 0x2da44e); + assert.equal(field(embed, 'Jobs'), '1 passed'); + assert.equal(field(embed, 'Duration'), '2m 5s'); + assert.match(field(embed, 'Commit'), /\[aaaaaaaaaaaa\]\(https:\/\/github.com\/OpenArkStudio\/arknet\/commit\/a{40}\)\nFix connection retry/); + assert.match(field(embed, 'Run'), /actions\/runs\/42\/attempts\/2/); + assert.ok(!embed.fields.some(item => item.name.includes('Failed'))); assert.deepEqual(outputs, { message_id: '456', channel_id: '789' }); assert.ok(logs.every(line => !line.includes('test-secret'))); }); test('summarizes matrix failures, timeouts, cancellations and skips', async () => { const { args, sent } = setup({ run: { conclusion: 'failure' }, jobs: [ - { name: 'Windows', conclusion: 'failure' }, { name: 'macOS', conclusion: 'timed_out' }, - { name: 'Linux', conclusion: 'cancelled' }, { name: 'Deploy', conclusion: 'skipped' }, + { id: 100, name: 'Linux', conclusion: 'cancelled' }, + { id: 101, name: 'Windows', conclusion: 'failure', steps: [ + { name: 'Checkout', conclusion: 'success' }, { name: 'Build', conclusion: 'failure' }, + ] }, + { id: 102, name: 'macOS', conclusion: 'timed_out' }, { name: 'Deploy', conclusion: 'skipped' }, ] }); await notify(args); const embed = sent[0].payload.embeds[0]; - assert.equal(embed.title, 'CI: Failed'); - assert.match(embed.fields[3].value, /2 failed, 1 cancelled, 1 skipped/); - assert.match(embed.fields[4].value, /Windows: failure/); - assert.match(embed.fields[4].value, /macOS: timed_out/); + assert.equal(embed.title, '❌ Failed · CI'); + assert.equal(embed.color, 0xcf222e); + assert.equal(field(embed, 'Jobs'), '2 failed · 1 cancelled · 1 skipped'); + assert.match(embed.fields[0].value, /^\[Windows\]\(https:\/\/github.com\/OpenArkStudio\/arknet\/actions\/runs\/42\/job\/101\) — Failed\n ↳ Build/); + assert.match(embed.fields[0].value, /macOS.*Timed out/); + assert.ok(embed.fields[0].value.indexOf('macOS') < embed.fields[0].value.indexOf('Linux')); + assert.ok(!embed.fields[0].value.includes('Checkout')); + assert.match(embed.fields[0].value, /Failed step unavailable/); }); -test('bounds large workflow titles and job lists to Discord limits', async () => { - const { args, sent } = setup({ run: { name: 'n'.repeat(1000), display_title: 'd'.repeat(2000) }, - jobs: Array.from({ length: 500 }, () => ({ name: 'j'.repeat(200), conclusion: 'failure' })) }); +function field(embed, name) { + return embed.fields.find(item => item.name === name)?.value; +} + +function assertLimits(embed) { + assert.ok(embed.title.length <= 256); + assert.ok(embed.description.length <= 4096); + assert.ok(embed.footer.text.length <= 2048); + assert.ok(embed.fields.length <= 25); + for (const item of embed.fields) { + assert.ok(item.name.length > 0 && item.name.length <= 256); + assert.ok(item.value.length > 0 && item.value.length <= 1024); + } + const size = embed.title.length + embed.description.length + embed.footer.text.length + + embed.fields.reduce((sum, item) => sum + item.name.length + item.value.length, 0); + assert.ok(size <= 6000, `Embed total: ${size}`); +} + +test('bounds escaped Unicode metadata and large failures without silently losing jobs or steps', async () => { + const noisy = '[🐧**@everyone](https://example.com)\\'.repeat(200); + const { args, sent } = setup({ run: { + name: noisy, display_title: noisy, head_branch: noisy, event: noisy, + head_commit: { message: noisy }, pull_requests: [{ number: 17 }, { number: 18 }], + }, jobs: Array.from({ length: 500 }, (_, id) => ({ id: id + 100, name: noisy, conclusion: 'failure', + steps: Array.from({ length: 5 }, () => ({ name: noisy, conclusion: 'failure' })), + })) }); await notify(args); const embed = sent[0].payload.embeds[0]; - assert.ok(embed.title.length <= 256); - assert.ok(embed.description.length <= 512); - assert.ok(embed.fields.every(field => field.value.length <= 1024)); + assertLimits(embed); + const problemText = embed.fields.filter(item => item.name.includes('jobs') || item.name === 'Problems (continued)').map(item => item.value).join('\n'); + assert.match(problemText, /\(\+3 more failed steps\)/); + const shown = (problemText.match(/ — Failed/g) || []).length; + assert.match(problemText, new RegExp(`… ${500 - shown} more jobs; see the run for all results\\.`)); + for (const value of [embed.title, embed.description, embed.footer.text, ...embed.fields.map(item => item.value)]) { + assert.ok(value.isWellFormed()); + } +}); + +test('keeps worst-case target lengths and metadata within every Discord budget', async () => { + const noisy = '*'.repeat(5000); + const { args, sent } = setup({ run: { name: noisy, display_title: noisy, head_branch: noisy, + head_sha: 'f'.repeat(64), event: noisy, head_commit: { message: noisy }, + run_attempt: 9007199254740991, run_number: 9007199254740991, + pull_requests: [{ number: 9007199254740991 }, { number: 18 }], + }, jobs: Array.from({ length: 20 }, () => ({ id: 9007199254740991, name: noisy, conclusion: 'failure', + steps: Array.from({ length: 3 }, () => ({ name: noisy, conclusion: 'failure' })), + })) }); + args.context.serverUrl = 'https://' + 'a'.repeat(46); + args.context.repo = { owner: 'o'.repeat(100), repo: 'r'.repeat(100) }; + args.runId = '9007199254740991'; + await notify(args); + const embed = sent[0].payload.embeds[0]; + assertLimits(embed); + assert.match(field(embed, 'Commit'), /commit\/f{64}\)/); + assert.match(embed.fields[0].value, /job\/9007199254740991\)/); +}); + +test('shows PR, branch, repository, workflow and exact-attempt links', async () => { + const { args, sent } = setup({ run: { pull_requests: [{ number: 17 }, { number: 18 }], head_branch: 'fix/retry', event: 'pull_request' } }); + args.context.serverUrl = 'https://github.enterprise.example'; + await notify(args); + const embed = sent[0].payload.embeds[0]; + assert.match(field(embed, 'Repository'), /https:\/\/github.enterprise.example\/OpenArkStudio\/arknet/); + assert.match(field(embed, 'Workflow'), /actions\/runs\/42\/attempts\/2/); + assert.equal(field(embed, 'Pull request / branch'), '[PR #17](https://github.enterprise.example/OpenArkStudio/arknet/pull/17) (+1 more PRs)\nfix/retry'); + assert.match(embed.url, /attempts\/2$/); + assert.equal(embed.footer.text, 'GitHub Actions · Pull request'); +}); + +test('escapes metadata Markdown, mentions, autolinks and injected URL boundaries', async () => { + const malicious = 'x](https://evil.example/) @everyone <@123> <#456> **bold** `code`\nnew'; + const { args, sent } = setup({ run: { name: malicious, head_branch: malicious, display_title: malicious, + html_url: 'javascript:alert(1)', head_commit: { message: malicious }, + pull_requests: [{ number: '17)https://evil.example' }], + }, jobs: [{ id: '101)https://evil.example', html_url: 'https://evil.example', name: malicious, conclusion: 'failure', steps: [{ name: malicious, conclusion: 'failure' }] }] }); + await notify(args); + const embed = sent[0].payload.embeds[0]; + const rendered = JSON.stringify(embed); + assert.ok(!rendered.includes('@everyone')); + assert.ok(!rendered.includes('<@123>')); + assert.ok(!rendered.includes('<#456>')); + assert.ok(!rendered.includes('](https://evil.example')); + assert.ok(!rendered.includes('**bold**')); + assert.ok(!rendered.includes('javascript:')); + assert.equal(field(embed, 'Pull request / branch'), undefined); + assert.match(embed.title, /@\u200beveryone/); + assert.match(embed.fields[0].value, /\\\]\\\(/); + assertLimits(embed); +}); + +test('rejects unsafe server targets and never clips a generated URL', async () => { + for (const serverUrl of ['javascript:alert(1)', 'https://user:secret@github.com', 'https://' + 'a'.repeat(260) + '.example', 'https://' + '('.repeat(70) + ')'.repeat(70) + '.example']) { + const { args, sent } = setup({ run: { head_sha: '[bad](https://evil.example)' } }); + args.context.serverUrl = serverUrl; + await notify(args); + const embed = sent[0].payload.embeds[0]; + assert.equal(embed.url, undefined); + assert.equal(field(embed, 'Commit'), 'Unavailable\nFix connection retry'); + assert.ok(!JSON.stringify(embed).includes('secret')); + assertLimits(embed); + } +}); + +test('encodes parentheses in trusted server origins without breaking Markdown targets', async () => { + const { args, sent } = setup(); + args.context.serverUrl = 'https://git(hub).example'; + await notify(args); + const embed = sent[0].payload.embeds[0]; + assert.equal(field(embed, 'Repository'), '[OpenArkStudio/arknet](https://git%28hub%29.example/OpenArkStudio/arknet)'); + assertLimits(embed); +}); + +test('caps numeric link IDs even when leading zeros would parse as a small number', async () => { + const oversizedId = '0'.repeat(10000) + '1'; + const { args, sent } = setup({ run: { run_attempt: oversizedId, pull_requests: [{ number: oversizedId }] }, + jobs: [{ id: oversizedId, name: 'Build', conclusion: 'failure' }], + }); + await notify(args); + const embed = sent[0].payload.embeds[0]; + assertLimits(embed); + assert.ok(!JSON.stringify(embed).includes(oversizedId)); + assert.equal(embed.url, 'https://github.com/OpenArkStudio/arknet/actions/runs/42'); +}); + +test('uses current-attempt job wall time rather than stale run metadata or summed matrix time', async () => { + const { args, sent } = setup({ run: { created_at: '2020-01-01T00:00:00Z', updated_at: '2026-11-01T00:00:00Z' }, jobs: [ + { name: 'Linux', conclusion: 'success', started_at: '2026-10-22T01:00:00Z', completed_at: '2026-10-22T02:02:05Z' }, + { name: 'Windows', conclusion: 'success', started_at: '2026-10-22T01:01:00Z', completed_at: '2026-10-22T02:00:00Z' }, + { name: 'Deploy', conclusion: 'skipped' }, + ] }); + await notify(args); + assert.equal(field(sent[0].payload.embeds[0], 'Duration'), '1h 2m 5s'); +}); + +test('does not fabricate durations from absent, partial or inverted timestamps', async () => { + for (const jobs of [[], [{ conclusion: 'skipped' }], [{ conclusion: 'success' }], + [{ conclusion: 'failure', started_at: '2026-10-22T02:00:00Z', completed_at: '2026-10-22T01:00:00Z' }], + [{ conclusion: 'success', started_at: '2026-10-22T01:00:00Z', completed_at: '2026-10-22T01:00:30Z' }, { conclusion: 'cancelled' }], + ]) { + const { args, sent } = setup({ jobs }); + await notify(args); + assert.equal(field(sent[0].payload.embeds[0], 'Duration'), 'Unavailable'); + } +}); + +test('distinguishes cancellation, special failure conclusions and missing job data', async () => { + for (const conclusion of ['cancelled', 'timed_out', 'action_required', 'startup_failure', 'neutral', 'unknown']) { + const { args, sent } = setup({ run: { conclusion, head_branch: null, head_commit: null }, jobs: [] }); + await notify(args); + const embed = sent[0].payload.embeds[0]; + assert.equal(field(embed, 'Jobs'), 'No jobs reported'); + assert.equal(field(embed, 'Branch'), '\\(detached\\)'); + assert.ok(!field(embed, 'Commit').includes('undefined')); + if (conclusion === 'cancelled') { + assert.match(embed.title, /^⏹ Cancelled/); + assert.equal(embed.color, 0xbf8700); + } else if (['timed_out', 'action_required', 'startup_failure'].includes(conclusion)) { + assert.match(embed.title, /^❌/); + assert.equal(embed.color, 0xcf222e); + } else assert.equal(embed.color, 0x6e7781); + assertLimits(embed); + } }); test('rejects unfinished runs and notification recursion', async () => { @@ -76,7 +245,7 @@ test('rejects missing secrets, non-Discord URLs and invalid run IDs without expo for (const input of [ { webhook: '' }, { webhook: 'https://example.com/test-secret' }, { webhook: 'https://discord.com/api/webhooks/123/test-secret/github' }, - { runId: '1; command' }, { runId: '9007199254740992' }, + { runId: '1; command' }, { runId: '9007199254740992' }, { runId: '0'.repeat(10000) + '1' }, ]) { const { args, sent } = setup(); await assert.rejects(notify({ ...args, ...input }), error => !error.message.includes('test-secret')); @@ -119,3 +288,19 @@ test('does not claim delivery without a message receipt or after HTTP rejection' assert.deepEqual(outputs, {}); } }); + +test('uses friendly event labels without repeating the commit subject', async () => { + const subject = 'Improve connection retry handling'; + const { args, sent } = setup({ run: { + name: 'Build & Test', display_title: subject, + head_commit: { message: subject + '\n\nDetails' }, event: 'merge_group', + } }); + await notify(args); + const embed = sent[0].payload.embeds[0]; + assert.equal(embed.title, '✅ Succeeded · Build & Test'); + assert.equal(embed.description, subject); + assert.ok(!field(embed, 'Commit').includes(subject)); + assert.equal(embed.footer.text, 'GitHub Actions · Merge queue'); + assert.equal(field(embed, 'Duration'), '2m 5s'); + assertLimits(embed); +});