From 928de3223ae645420136100219ca059114477ed6 Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 22 Oct 2026 18:27:48 +0800 Subject: [PATCH] feat(discord): compact workflow notification layout --- docs/discord.md | 59 ++++++++++++++++++++++---------- docs/discord_CN.md | 50 +++++++++++++++++++-------- scripts/discord.cjs | 42 ++++++++++++++--------- tests/discord.test.cjs | 78 ++++++++++++++++++++++++++++++------------ 4 files changed, 160 insertions(+), 69 deletions(-) diff --git a/docs/discord.md b/docs/discord.md index 1e03c6a..b001550 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -9,23 +9,25 @@ download its artifacts. A matrix produces one summary per workflow attempt. ## 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. +Each attempt produces one compact Discord Embed: + +- The author row identifies the repository and shows its owner's avatar. +- One linked title names the workflow and its result: ✅ passed, ❌ failed, or + ⏹ cancelled. Timeouts and startup failures keep their specific result labels. +- The description shows the change title, nonzero job counts, and **View run**. +- Failed jobs and their bold failed steps appear before the context row. + Cancelled jobs follow failures. Up to two failed steps are shown per job. +- Three inline fields show the PR or branch, short linked SHA, and **Duration**. + A different commit subject appears under the SHA; a repeated subject is omitted. +- The footer contains the friendly event name, run number, and retry attempt + when greater than one. For example: `Merge queue · Run #129 · Attempt 2`. + +If multiple PRs are attached, the first is linked and the remaining count is +shown. A missing branch is marked detached. Missing job or commit metadata is +not invented. Unavailable failed steps are labelled `Step details unavailable`. +Large problem lists occupy at most two fields, with an explicit count of omitted +jobs; the run link leads to all results. Cancelled cards use the same compact +layout and do not guess why the run was cancelled. **Duration** runs from the earliest executed job start to the latest job completion in the notified attempt. It includes gaps between those jobs, excludes @@ -41,7 +43,28 @@ 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. +The author name also counts toward the aggregate limit. + +## Layout choices and references + +The layout uses only the standard +[Embed author, title, description, inline fields, footer and color](https://docs.discord.com/developers/resources/message#embed-object). +Discord controls font sizes, spacing, column widths and mobile wrapping; inline +fields are layout hints. There are no CSS panels, custom badges or buttons in +the message payload. A local HTML preview only approximates this structure and +is not evidence of rendering or delivery in Discord. + +The compact title, result color and linked context were informed by the mature +[sarisia/actions-status-discord formatter](https://github.com/sarisia/actions-status-discord/blob/main/src/main.ts) +and its [minimal examples](https://github.com/sarisia/actions-status-discord#minimum). +The [Ilshidur/action-discord implementation](https://github.com/Ilshidur/action-discord/blob/master/entrypoint.js) +was also reviewed as an example of plain incoming Webhook delivery. No code, +third-party branding or delivery behavior was copied from these references. + +[Components V2](https://docs.discord.com/developers/components/overview) offers +more layout primitives but requires `IS_COMPONENTS_V2` and replaces traditional +content and embeds. The existing Embed covers this notification's needs without +changing its payload model, so this change does not adopt Components V2. 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. diff --git a/docs/discord_CN.md b/docs/discord_CN.md index 0be59cb..eec4875 100644 --- a/docs/discord_CN.md +++ b/docs/discord_CN.md @@ -8,19 +8,23 @@ ## 汇总卡片 -每次执行生成一条 Discord Embed。标题与颜色共同表示结果:成功使用 ✅ 和绿色, -失败使用 ❌ 和红色(包括超时、需要操作、启动失败),取消使用 ⏹ 和琥珀色。 -其他结果使用中性色,并显示对应状态。 - -卡片包含仓库、工作流、GitHub 提供的 PR 编号、分支、带链接的短 SHA、可用的提交标题、 -任务数量,以及本次执行的链接。关联多个 PR 时,显示第一个 PR 的链接和其余 PR 数量。 -成功时省略数量为零的状态和异常任务字段。分支缺失时标记为 detached; -任务数据或提交元数据缺失时,不补造内容。 - -失败任务优先展示,其后是取消的任务,再展示常规信息。每个任务链接到对应的 -GitHub 任务页面,并显示最多两个失败步骤名称。GitHub 未提供失败步骤时,卡片明确提示。 -其余失败步骤显示剩余数量。异常任务清单最多占用两个字段,超出时明确提示省略的任务数量; -完整结果通过运行链接查看。 +每次执行生成一张紧凑的 Discord Embed: + +- 顶部 author 行显示仓库名称和仓库所有者的头像。 +- 单一标题显示工作流名称和结果:✅ passed、❌ failed 或 ⏹ cancelled。 + 超时、启动失败等结果保留具体状态。标题链接到对应执行。 +- 描述显示变更标题、非零任务数量,以及 **View run** 链接。 +- 失败任务和加粗的失败步骤优先显示,其后是取消的任务,再展示常规信息。 + 每个任务最多显示两个失败步骤。 +- 三个 inline 字段集中显示 PR 或分支、带链接的短 SHA,以及 **Duration**。 + 提交标题与变更标题不同时,显示在 SHA 下方;重复时省略。 +- 页脚显示易懂的事件名称、运行编号,以及大于 1 的重试次数。 + 例如:`Merge queue · Run #129 · Attempt 2`。 + +关联多个 PR 时,显示第一个 PR 的链接和其余数量。分支缺失时标记为 detached; +任务数据或提交元数据缺失时,不补造内容。未提供失败步骤时,显示 `Step details unavailable`。 +异常任务清单最多占用两个字段,超出时明确提示省略的任务数量;完整结果通过运行链接查看。 +取消卡片沿用相同布局,不推测取消原因。 **执行耗时(Duration)** 表示本次执行中,从最早一个已执行任务开始到最后一个任务结束的时间。 它包含这些任务之间的间隔,不包含初始排队时间,也不累加并行矩阵任务的耗时。 @@ -32,7 +36,25 @@ GitHub 任务页面,并显示最多两个失败步骤名称。GitHub 未提供 截断保留完整转义和 Unicode 字符,不截断链接。字段预算遵守 Discord 的标题长度、字段长度、 字段数量和总计 6,000 字符的 [Embed 限制](https://docs.discord.com/developers/resources/message#embed-limits)。 -卡片使用普通 Webhook Embed 和 Markdown 链接,不依赖按钮。 +author 名称也计入总字符数。 + +## 布局选择与参考 + +卡片仅使用标准 +[Embed author、标题、描述、inline 字段、页脚和颜色](https://docs.discord.com/developers/resources/message#embed-object)。 +字号、间距、列宽和移动端换行由 Discord 控制;inline 只是排版提示。 +消息载荷不包含 CSS 面板、自定义徽章或按钮。本地 HTML 预览只模拟结构, +不能作为 Discord 实际渲染或投递验收的证据。 + +紧凑标题、结果颜色和上下文链接参考了成熟开源项目 +[sarisia/actions-status-discord 的格式化源码](https://github.com/sarisia/actions-status-discord/blob/main/src/main.ts) +及其[最简示例](https://github.com/sarisia/actions-status-discord#minimum)。 +同时查阅了 [Ilshidur/action-discord 的实现](https://github.com/Ilshidur/action-discord/blob/master/entrypoint.js), +用于核对普通 incoming Webhook 的投递形式。没有复制参考项目的代码、第三方品牌素材或投递行为。 + +[Components V2](https://docs.discord.com/developers/components/overview) 提供更多布局组件, +但需要启用 `IS_COMPONENTS_V2`,并替代传统 content 和 embeds。 +现有 Embed 已能满足通知需求,无需改变载荷模型,本次不引入 Components V2。 只有收到 Discord 消息回执,通知任务才算通过。遇到明确的限流响应最多重试两次, 其他发送错误会使通知任务失败。卡片格式调整不改变共享工作流、Secret、权限、 diff --git a/scripts/discord.cjs b/scripts/discord.cjs index 3e6c8c0..458e0f3 100644 --- a/scripts/discord.cjs +++ b/scripts/discord.cjs @@ -7,6 +7,11 @@ const labels = { neutral: 'Neutral', skipped: 'Skipped', stale: 'Stale', startup_failure: 'Startup failure', }; +const outcomes = { + success: 'passed', failure: 'failed', cancelled: 'cancelled', timed_out: 'timed out', + action_required: 'needs attention', startup_failure: 'could not start', + neutral: 'finished', skipped: 'skipped', stale: 'expired', +}; const events = { pull_request: 'Pull request', push: 'Push', merge_group: 'Merge queue', workflow_dispatch: 'Manual run', schedule: 'Scheduled run', workflow_run: 'Workflow completion', @@ -86,16 +91,16 @@ function problemFields(jobs, runUrl) { 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 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' : ''; + : failed.has(job.conclusion) ? '\n ↳ Step details 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') }); + fields.push({ name: fields.length ? 'More jobs' : jobs.some(job => failed.has(job.conclusion)) ? 'Needs attention' : '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.`; @@ -107,7 +112,7 @@ 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 outcome = outcomes[run.conclusion] || 'completed'; 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) { @@ -117,36 +122,41 @@ function buildPayload(context, run, jobs, runId) { 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 summary = ['failed', 'cancelled', 'passed', 'skipped', 'other'].filter(label => counts[label]).map(label => { + const value = `${counts[label]} ${label}`; + return label === 'failed' ? `**${value}**` : value; + }).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 ref = text(run.head_branch || '(detached)', 100); + const pullSummary = pull ? `${link(`PR #${pull.number}`, repoUrl ? `${repoUrl}/pull/${pull.number}` : '')}${pulls.length > 1 ? ` (+${pulls.length - 1} more)` : ''}\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: pull ? 'Pull request' : 'Branch', value: pullSummary + ref, inline: true }, + { name: 'Commit', value: `${link(sha ? sha.slice(0, 12) : 'Unavailable', sha && repoUrl ? `${repoUrl}/commit/${sha}` : '')}${commitTitle ? `\n${commitTitle}` : ''}`, inline: true }, { 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) }, ]; + const runLink = link('View run', attemptUrl); + const ownerUrl = repoUrl ? repoUrl.slice(0, repoUrl.lastIndexOf('/')) : ''; // 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), + author: { + name: text(`${context.repo.owner} / ${context.repo.repo}`, 128, false), + ...(repoUrl ? { url: repoUrl, icon_url: `${ownerUrl}.png?size=64` } : {}), + }, + title: `${icon} ${text(run.name || '(unnamed workflow)', 180)} ${outcome}`, + description: `${text(displayTitle, 240)}\n${summary} · ${runLink}`, ...(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)}` }, + footer: { text: `${text(events[run.event] || run.event || 'Unknown event', 60, false)} · Run #${positiveId(run.run_number) ? run.run_number : '?'}${positiveId(run.run_attempt) && Number(run.run_attempt) > 1 ? ` · Attempt ${run.run_attempt}` : ''}` }, }], }; } diff --git a/tests/discord.test.cjs b/tests/discord.test.cjs index da069b9..acb0713 100644 --- a/tests/discord.test.cjs +++ b/tests/discord.test.cjs @@ -38,13 +38,15 @@ test('reports the completed attempt, suppresses mentions and records delivery', assert.equal(sent[0].options.redirect, 'error'); assert.deepEqual(sent[0].payload.allowed_mentions, { parse: [] }); const embed = sent[0].payload.embeds[0]; - assert.match(embed.title, /^✅ Succeeded/); + assert.match(embed.title, /^✅ CI passed$/); assert.equal(embed.color, 0x2da44e); - assert.equal(field(embed, 'Jobs'), '1 passed'); + assert.match(embed.description, /1 passed · \[View run\]/); 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.match(embed.description, /actions\/runs\/42\/attempts\/2/); + assert.equal(embed.fields.length, 3); + assert.ok(embed.fields.every(item => item.inline)); + assert.deepEqual(embed.author, { name: 'OpenArkStudio / arknet', url: 'https://github.com/OpenArkStudio/arknet', icon_url: 'https://github.com/OpenArkStudio.png?size=64' }); assert.deepEqual(outputs, { message_id: '456', channel_id: '789' }); assert.ok(logs.every(line => !line.includes('test-secret'))); }); @@ -59,14 +61,15 @@ test('summarizes matrix failures, timeouts, cancellations and skips', async () = ] }); await notify(args); const embed = sent[0].payload.embeds[0]; - assert.equal(embed.title, '❌ Failed · CI'); + assert.equal(embed.title, '❌ CI failed'); 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.description, /\*\*2 failed\*\* · 1 cancelled · 1 skipped/); + assert.equal(embed.fields[0].name, 'Needs attention'); + 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/); + assert.match(embed.fields[0].value, /Step details unavailable/); }); function field(embed, name) { @@ -78,11 +81,12 @@ function assertLimits(embed) { assert.ok(embed.description.length <= 4096); assert.ok(embed.footer.text.length <= 2048); assert.ok(embed.fields.length <= 25); + assert.ok(embed.author.name.length > 0 && embed.author.name.length <= 256); 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 + + const size = embed.title.length + embed.description.length + embed.footer.text.length + embed.author.name.length + embed.fields.reduce((sum, item) => sum + item.name.length + item.value.length, 0); assert.ok(size <= 6000, `Embed total: ${size}`); } @@ -98,7 +102,7 @@ test('bounds escaped Unicode metadata and large failures without silently losing await notify(args); const embed = sent[0].payload.embeds[0]; assertLimits(embed); - const problemText = embed.fields.filter(item => item.name.includes('jobs') || item.name === 'Problems (continued)').map(item => item.value).join('\n'); + const problemText = embed.fields.filter(item => ['Needs attention', 'More jobs', 'Cancelled jobs'].includes(item.name)).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\\.`)); @@ -131,11 +135,11 @@ test('shows PR, branch, repository, workflow and exact-attempt links', async () 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.equal(embed.author.url, 'https://github.enterprise.example/OpenArkStudio/arknet'); + assert.match(embed.description, /actions\/runs\/42\/attempts\/2/); + assert.equal(field(embed, 'Pull request'), '[PR #17](https://github.enterprise.example/OpenArkStudio/arknet/pull/17) (+1 more)\nfix/retry'); assert.match(embed.url, /attempts\/2$/); - assert.equal(embed.footer.text, 'GitHub Actions · Pull request'); + assert.equal(embed.footer.text, 'Pull request · Run #9 · Attempt 2'); }); test('escapes metadata Markdown, mentions, autolinks and injected URL boundaries', async () => { @@ -153,7 +157,7 @@ test('escapes metadata Markdown, mentions, autolinks and injected URL boundaries 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.equal(field(embed, 'Pull request'), undefined); assert.match(embed.title, /@\u200beveryone/); assert.match(embed.fields[0].value, /\\\]\\\(/); assertLimits(embed); @@ -177,7 +181,7 @@ test('encodes parentheses in trusted server origins without breaking Markdown ta 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)'); + assert.equal(embed.author.url, 'https://git%28hub%29.example/OpenArkStudio/arknet'); assertLimits(embed); }); @@ -219,11 +223,11 @@ test('distinguishes cancellation, special failure conclusions and missing job da 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.match(embed.description, /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.match(embed.title, /^⏹ CI cancelled$/); assert.equal(embed.color, 0xbf8700); } else if (['timed_out', 'action_required', 'startup_failure'].includes(conclusion)) { assert.match(embed.title, /^❌/); @@ -297,10 +301,42 @@ test('uses friendly event labels without repeating the commit subject', async () } }); await notify(args); const embed = sent[0].payload.embeds[0]; - assert.equal(embed.title, '✅ Succeeded · Build & Test'); - assert.equal(embed.description, subject); + assert.equal(embed.title, '✅ Build & Test passed'); + assert.ok(embed.description.startsWith(subject + '\n')); + assert.equal(embed.description.split(subject).length - 1, 1); assert.ok(!field(embed, 'Commit').includes(subject)); - assert.equal(embed.footer.text, 'GitHub Actions · Merge queue'); + assert.equal(embed.footer.text, 'Merge queue · Run #9 · Attempt 2'); assert.equal(field(embed, 'Duration'), '2m 5s'); assertLimits(embed); }); + + +test('keeps compact cards within standard Embed capabilities', async () => { + const { args, sent } = setup({ run: { run_attempt: 1, event: 'workflow_dispatch' } }); + await notify(args); + const payload = sent[0].payload, embed = payload.embeds[0]; + assert.equal(payload.flags, undefined); + assert.equal(payload.components, undefined); + assert.equal(embed.components, undefined); + assert.deepEqual(embed.fields.map(item => item.name), ['Branch', 'Commit', 'Duration']); + assert.equal(embed.footer.text, 'Manual run · Run #9'); + assert.ok(!JSON.stringify(embed).includes('Job wall time')); + assertLimits(embed); +}); + +test('gives cancelled runs a compact stopped-job list without guessing a cause', async () => { + const { args, sent } = setup({ run: { conclusion: 'cancelled', event: 'merge_group' }, jobs: [ + { id: 101, name: 'Windows', conclusion: 'cancelled' }, + { id: 102, name: 'Linux', conclusion: 'success' }, + ] }); + await notify(args); + const embed = sent[0].payload.embeds[0]; + assert.equal(embed.title, '⏹ CI cancelled'); + assert.equal(embed.color, 0xbf8700); + assert.equal(embed.fields[0].name, 'Cancelled jobs'); + assert.match(embed.fields[0].value, /Windows.*Cancelled/); + assert.ok(!embed.fields[0].value.includes('Step details unavailable')); + assert.match(embed.description, /1 cancelled · 1 passed/); + assert.equal(embed.fields.length, 4); + assertLimits(embed); +});