From ffda76aeb11f6e36bb4ac512f21a5ab3fc151c94 Mon Sep 17 00:00:00 2001 From: Srujan Kaleru Date: Tue, 27 Oct 2026 21:43:25 +0530 Subject: [PATCH] feat(templates): add GitHub security advisory card Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/templates/github/index.ts | 1 + src/templates/github/security-advisory.ts | 173 ++++++++++++++++++++++ src/templates/registry.ts | 2 + tests/templates/security-advisory.test.ts | 96 ++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 src/templates/github/security-advisory.ts create mode 100644 tests/templates/security-advisory.test.ts diff --git a/src/templates/github/index.ts b/src/templates/github/index.ts index eb21553..a88a197 100644 --- a/src/templates/github/index.ts +++ b/src/templates/github/index.ts @@ -14,3 +14,4 @@ export { default as githubPullRequest } from './pull-request'; export { default as githubGoodFirstIssues } from './good-first-issues'; export { default as githubMilestone } from './milestone'; export { default as githubSponsorsGoal } from './sponsors-goal'; +export { default as githubSecurityAdvisory } from './security-advisory'; diff --git a/src/templates/github/security-advisory.ts b/src/templates/github/security-advisory.ts new file mode 100644 index 0000000..96e111b --- /dev/null +++ b/src/templates/github/security-advisory.ts @@ -0,0 +1,173 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils'; + +const SEVERITIES = { + critical: { label: 'Critical', color: '#8250df' }, + high: { label: 'High', color: '#d1242f' }, + medium: { label: 'Medium', color: '#bf8700' }, + low: { label: 'Low', color: '#1a7f37' }, +}; + +const DEFAULT_TITLE = 'Path traversal in @acme/static-files'; + +const template: TemplateDefinition = { + id: 'github-security-advisory', + name: 'Security Advisory', + category: 'github', + description: 'Security fix announcement with severity, advisory ID, affected package versions and site name', + tags: ['github', 'security', 'advisory', 'vulnerability', 'package'], + fields: [ + { ...commonFields.title, defaultValue: DEFAULT_TITLE, placeholder: 'Security advisory title' }, + { + key: 'severity', + label: 'Severity', + type: 'select', + defaultValue: 'high', + options: [ + { label: 'Critical', value: 'critical' }, + { label: 'High', value: 'high' }, + { label: 'Medium', value: 'medium' }, + { label: 'Low', value: 'low' }, + ], + group: 'Content', + }, + { key: 'advisoryId', label: 'Advisory ID', type: 'text', defaultValue: 'GHSA-3x9q-7m2v-p4hc', placeholder: 'GHSA-xxxx-xxxx-xxxx', required: false, group: 'Content' }, + { key: 'packageName', label: 'Package Name', type: 'text', defaultValue: '@acme/static-files', placeholder: '@scope/package', required: false, group: 'Content' }, + { key: 'affected', label: 'Affected Versions', type: 'text', defaultValue: '< 2.4.1', placeholder: '< 2.4.1', required: false, group: 'Content' }, + { key: 'patched', label: 'Fixed Version', type: 'text', defaultValue: '2.4.1', placeholder: '2.4.1', required: false, group: 'Content' }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: DEFAULT_TITLE, + severity: 'high', + advisoryId: 'GHSA-3x9q-7m2v-p4hc', + packageName: '@acme/static-files', + affected: '< 2.4.1', + patched: '2.4.1', + siteName: 'Acme Security', + bgColor: '#0d1117', + accentColor: '#58a6ff', + textColor: '#e6edf3', + }, + render: (params) => { + const rawTitle = String(params.title ?? '').trim() || DEFAULT_TITLE; + const title = truncate(rawTitle, 90); + const rawSeverity = String(params.severity ?? '').trim().toLowerCase(); + const severity = Object.hasOwn(SEVERITIES, rawSeverity) + ? SEVERITIES[rawSeverity as keyof typeof SEVERITIES] + : SEVERITIES.high; + const advisoryId = truncate(String(params.advisoryId ?? '').trim(), 32); + const packageName = truncate(String(params.packageName ?? '').trim(), 48); + const affected = truncate(String(params.affected ?? '').trim(), 32); + const patched = truncate(String(params.patched ?? '').trim(), 32); + const siteName = truncate(String(params.siteName ?? '').trim(), 40); + const bgColor = String(params.bgColor || '#0d1117'); + const accentColor = String(params.accentColor || '#58a6ff'); + const textColor = String(params.textColor || '#e6edf3'); + const accentTextColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor; + const details = [ + packageName, + affected ? `affected ${affected}` : '', + patched ? `fixed in ${patched}` : '', + ].filter(Boolean).join(' · '); + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 28, size: 68 }, + { maxLen: 48, size: 58 }, + { maxLen: 68, size: 48 }, + { maxLen: 90, size: 40 }, + ]); + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + width: '1200px', + height: '630px', + padding: '64px 72px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + }, + children: [ + { + type: 'div', + props: { + style: { display: 'flex', alignItems: 'center', gap: '18px' }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '10px 20px', + borderRadius: '999px', + backgroundColor: severity.color, + }, + children: { + type: 'span', + props: { + style: { fontSize: '20px', fontWeight: 700, color: contrastText(severity.color) }, + children: severity.label, + }, + }, + }, + }, + ...(advisoryId + ? [{ + type: 'span', + props: { + style: { fontSize: '20px', fontWeight: 600, color: accentTextColor, fontFamily: 'JetBrains Mono, monospace' }, + children: advisoryId, + }, + }] + : []), + ], + }, + }, + { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, gap: '24px' }, + children: [ + { + type: 'h1', + props: { + style: { fontSize: `${titleSize}px`, lineHeight: 1.1, fontWeight: 700, color: textColor, margin: 0 }, + children: title, + }, + }, + ...(details + ? [{ + type: 'span', + props: { + style: { fontSize: '26px', fontWeight: 500, color: textColor }, + children: details, + }, + }] + : []), + ], + }, + }, + ...(siteName + ? [{ + type: 'span', + props: { + style: { fontSize: '22px', fontWeight: 600, color: textColor }, + children: siteName, + }, + }] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index e0dd5cb..094095b 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -146,6 +146,7 @@ import githubPullRequest from './github/pull-request'; import githubGoodFirstIssues from './github/good-first-issues'; import githubMilestone from './github/milestone'; import githubSponsorsGoal from './github/sponsors-goal'; +import githubSecurityAdvisory from './github/security-advisory'; [ githubRepoCard, githubContributionGraph, githubReadmeHero, githubReleaseNotes, @@ -156,6 +157,7 @@ import githubSponsorsGoal from './github/sponsors-goal'; githubGoodFirstIssues, githubMilestone, githubSponsorsGoal, + githubSecurityAdvisory, ].forEach(registerTemplate); // Event diff --git a/tests/templates/security-advisory.test.ts b/tests/templates/security-advisory.test.ts new file mode 100644 index 0000000..22c2ba3 --- /dev/null +++ b/tests/templates/security-advisory.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { getTemplate } from '@/templates/registry'; +import type { SatoriElement } from '@/templates/types'; +import { contrastText, isLightColor, mixColors } from '@/templates/utils'; +import securityAdvisory from '@/templates/github/security-advisory'; + +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (node && typeof node === 'object' && 'props' in node) { + const element = node as SatoriElement; + return [element, ...elements(element.props.children)]; + } + return []; +} + +function texts(node: unknown): string[] { + if (typeof node === 'string') return [node]; + if (Array.isArray(node)) return node.flatMap(texts); + if (node && typeof node === 'object' && 'props' in node) { + return texts((node as SatoriElement).props.children); + } + return []; +} + +const tree = (overrides: Record = {}) => + securityAdvisory.render({ ...securityAdvisory.defaults, ...overrides }); +const render = (overrides: Record = {}) => texts(tree(overrides)); + +describe('github-security-advisory', () => { + it('is registered in the github category', () => { + expect(getTemplate('github-security-advisory')?.category).toBe('github'); + }); + + it('renders the severity, advisory, title, package details and site name', () => { + expect(render()).toEqual([ + 'High', + 'GHSA-3x9q-7m2v-p4hc', + 'Path traversal in @acme/static-files', + '@acme/static-files · affected < 2.4.1 · fixed in 2.4.1', + 'Acme Security', + ]); + }); + + it('is a 1200x630 card', () => { + const { style } = tree().props; + expect(style.width).toBe('1200px'); + expect(style.height).toBe('630px'); + }); + + it('uses a distinct readable pill color for each severity and falls back to high', () => { + const pill = (severity: string) => elements(tree({ severity })).find((element) => element.props.style?.borderRadius === '999px')!; + const colors = ['critical', 'high', 'medium', 'low'].map((severity) => pill(severity).props.style.backgroundColor); + + expect(new Set(colors).size).toBe(4); + for (const severity of ['critical', 'high', 'medium', 'low']) { + const badge = pill(severity); + const color = String(badge.props.style.backgroundColor); + const label = elements(badge).find((element) => element.type === 'span'); + + expect(texts(badge)).toEqual([severity[0].toUpperCase() + severity.slice(1)]); + expect(label?.props.style.color).toBe(contrastText(color)); + } + expect(pill('unknown').props.style.backgroundColor).toBe(pill('high').props.style.backgroundColor); + expect(texts(pill('unknown'))).toEqual(['High']); + }); + + it('truncates long titles after sizing them and shrinks their font', () => { + const title = 'A'.repeat(120); + const heading = elements(tree({ title })).find((element) => element.type === 'h1'); + + expect(heading?.props.children).toHaveLength(90); + expect(String(heading?.props.children).endsWith('…')).toBe(true); + expect(heading?.props.style.fontSize).toBe('40px'); + }); + + it('hides blank optional fields and only separates available package details', () => { + expect(render({ + advisoryId: ' ', + packageName: ' \t', + affected: '', + patched: '\n ', + siteName: ' ', + })).toEqual(['High', 'Path traversal in @acme/static-files']); + expect(render({ packageName: '', affected: '< 3.0', patched: '' })).toContain('affected < 3.0'); + expect(render({ packageName: '', affected: '< 3.0', patched: '' }).join('')).not.toContain('·'); + }); + + it('keeps accent text readable on a light background', () => { + const element = tree({ bgColor: '#ffffff', accentColor: '#fde047' }); + const advisoryId = elements(element).find((item) => item.props.children === 'GHSA-3x9q-7m2v-p4hc'); + const accentText = String(advisoryId?.props.style?.color); + + expect(accentText).toBe(mixColors('#fde047', '#000000', 0.45)); + expect(isLightColor(accentText)).toBe(false); + }); +});