feat(review): show compliance declaration diffs (#703)

This commit is contained in:
XiaoSeS 2026-08-11 10:44:11 +08:00 committed by GitHub
parent 0690e3f256
commit 2fe7514a4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 464 additions and 0 deletions

View file

@ -0,0 +1,104 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { SkillVersion } from '@/api/types'
import { ReviewComplianceDiffPanel } from './review-compliance-diff-panel'
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string, values?: Record<string, number | string>) => {
if (key === 'review.complianceDiffAddedLabel' || key === 'review.complianceDiffRemovedLabel' || key === 'review.complianceDiffModifiedLabel') {
return `${key}:${values?.count}`
}
if (key === 'review.complianceDiffDescription') {
return `${key}:${values?.baseVersion}->${values?.pendingVersion}`
}
return key
},
i18n: { language: 'zh' },
}),
}
})
function createVersion(overrides: Partial<SkillVersion> = {}): SkillVersion {
return {
id: 1,
version: '1.0.0',
status: 'PUBLISHED',
changelog: '',
fileCount: 1,
totalSize: 100,
publishedAt: '2026-03-19T00:00:00Z',
downloadAvailable: true,
...overrides,
}
}
describe('ReviewComplianceDiffPanel', () => {
it('renders a clickable diff summary and item details', () => {
const html = renderToStaticMarkup(
<ReviewComplianceDiffPanel
baseVersion={createVersion({
version: '1.0.0',
complianceSnapshot: {
schemaVersion: '1.0',
digest: 'sha256:base-digest-value',
items: [
{
standard: 'soc2',
version: '2026',
controlId: 'CC7.2',
title: 'Monitoring activities',
evidence: [{ type: 'packaged-file', path: 'evidence/soc2.md', sha256: 'sha-base' }],
},
],
},
})}
pendingVersion={createVersion({
version: '1.1.0',
status: 'PENDING_REVIEW',
complianceSnapshot: {
schemaVersion: '1.0',
digest: 'sha256:pending-digest-value',
items: [],
},
})}
/>,
)
expect(html).toContain('review.complianceDiffTitle')
expect(html).toContain('review.complianceDiffRemovedLabel:1')
expect(html).toContain('review.complianceDiffBaseDigest')
expect(html).toContain('review.complianceDiffPendingDigest')
expect(html).toContain('soc2')
expect(html).toContain('CC7.2')
expect(html).toContain('review.complianceDiffViewDetails')
})
it('renders nothing when there is no diff', () => {
const html = renderToStaticMarkup(
<ReviewComplianceDiffPanel
baseVersion={createVersion({
complianceSnapshot: {
schemaVersion: '1.0',
digest: 'sha256:same',
items: [],
},
})}
pendingVersion={createVersion({
version: '1.1.0',
status: 'PENDING_REVIEW',
complianceSnapshot: {
schemaVersion: '1.0',
digest: 'sha256:same-2',
items: [],
},
})}
/>,
)
expect(html).toBe('')
})
})

View file

@ -0,0 +1,321 @@
import { ChevronDown, ShieldAlert, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ComplianceMapping, ComplianceSnapshot, SkillVersion } from '@/api/types'
import { cn } from '@/shared/lib/utils'
interface ReviewComplianceDiffPanelProps {
baseVersion?: SkillVersion | null
pendingVersion?: SkillVersion | null
className?: string
}
type DiffKind = 'added' | 'removed' | 'modified'
interface DiffEntry {
kind: DiffKind
key: string
base?: ComplianceMapping
pending?: ComplianceMapping
}
function shortDigest(digest?: string) {
if (!digest) {
return '—'
}
if (digest.length <= 20) {
return digest
}
return `${digest.slice(0, 17)}`
}
function mappingKey(mapping: ComplianceMapping) {
return [
mapping.standard?.trim().toLowerCase() ?? '',
mapping.version?.trim() ?? '',
mapping.controlId?.trim() ?? '',
].join('\u0000')
}
function mappingSignature(mapping: ComplianceMapping) {
return JSON.stringify({
standard: mapping.standard?.trim().toLowerCase() ?? '',
version: mapping.version?.trim() ?? '',
controlId: mapping.controlId?.trim() ?? '',
title: mapping.title?.trim() ?? '',
evidence: (mapping.evidence ?? []).map((item) => ({
type: item.type?.trim() ?? '',
path: item.path?.trim() ?? '',
url: item.url?.trim() ?? '',
sha256: item.sha256?.trim() ?? '',
})),
})
}
function compareComplianceSnapshots(baseSnapshot?: ComplianceSnapshot | null, pendingSnapshot?: ComplianceSnapshot | null) {
const baseItems = new Map((baseSnapshot?.items ?? []).map((item) => [mappingKey(item), item]))
const pendingItems = new Map((pendingSnapshot?.items ?? []).map((item) => [mappingKey(item), item]))
const keys = new Set<string>([...baseItems.keys(), ...pendingItems.keys()])
const diffs: DiffEntry[] = []
for (const key of keys) {
const base = baseItems.get(key)
const pending = pendingItems.get(key)
if (base && pending) {
if (mappingSignature(base) !== mappingSignature(pending)) {
diffs.push({ kind: 'modified', key, base, pending })
}
continue
}
if (base) {
diffs.push({ kind: 'removed', key, base })
continue
}
if (pending) {
diffs.push({ kind: 'added', key, pending })
}
}
const sorted = diffs.sort((left, right) => {
const rank: Record<DiffKind, number> = {
removed: 0,
modified: 1,
added: 2,
}
return rank[left.kind] - rank[right.kind]
|| (left.base?.standard ?? left.pending?.standard ?? '').localeCompare(right.base?.standard ?? right.pending?.standard ?? '', 'zh-Hans-CN')
|| (left.base?.controlId ?? left.pending?.controlId ?? '').localeCompare(right.base?.controlId ?? right.pending?.controlId ?? '', 'zh-Hans-CN')
})
return {
diffs: sorted,
added: sorted.filter((item) => item.kind === 'added').length,
removed: sorted.filter((item) => item.kind === 'removed').length,
modified: sorted.filter((item) => item.kind === 'modified').length,
}
}
function formatMappingLabel(mapping?: ComplianceMapping) {
if (!mapping) {
return '—'
}
const parts = [mapping.standard, mapping.controlId].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : '—'
}
function renderEvidenceLabel(path?: string, url?: string, type?: string) {
return path ?? url ?? type ?? '—'
}
function MappingDetails({
mapping,
emptyMessage,
}: {
mapping?: ComplianceMapping
emptyMessage: string
}) {
const { t } = useTranslation()
if (!mapping) {
return (
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 p-4 text-sm text-muted-foreground">
{emptyMessage}
</div>
)
}
return (
<div className="rounded-xl border border-border/70 bg-background/80 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-secondary px-2.5 py-0.5 font-mono text-xs text-secondary-foreground">
{mapping.standard ?? t('compliance.unknownStandard')}
</span>
{mapping.version ? (
<span className="font-mono text-xs text-muted-foreground">{mapping.version}</span>
) : null}
<span className="font-mono text-sm font-semibold text-foreground">{mapping.controlId ?? '—'}</span>
</div>
{mapping.title ? (
<p className="mt-2 break-words text-sm leading-6 text-foreground">{mapping.title}</p>
) : null}
<div className="mt-3 space-y-2">
{(mapping.evidence ?? []).length > 0 ? (
(mapping.evidence ?? []).map((evidence, index) => (
<div
key={`${evidence.type ?? 'evidence'}-${evidence.path ?? evidence.url ?? index}`}
className="flex min-w-0 flex-wrap items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
title={evidence.sha256}
>
<span className="min-w-0 break-words rounded-full bg-background px-2 py-0.5 font-medium text-foreground">
{renderEvidenceLabel(evidence.path, evidence.url, evidence.type)}
</span>
{evidence.sha256 ? (
<span className="font-mono break-all leading-5">{evidence.sha256}</span>
) : null}
</div>
))
) : (
<div className="text-sm text-muted-foreground">{t('compliance.evidence')}</div>
)}
</div>
</div>
)
}
function DiffItem({ entry }: { entry: DiffEntry }) {
const { t } = useTranslation()
const labelKey = entry.kind === 'added'
? 'review.complianceDiffAdded'
: entry.kind === 'removed'
? 'review.complianceDiffRemoved'
: 'review.complianceDiffModified'
const label = t(labelKey)
const title = entry.pending?.title ?? entry.base?.title
return (
<details className="group rounded-2xl border border-border/70 bg-card/90 p-4 shadow-sm">
<summary className="flex list-none cursor-pointer items-start gap-3 [&::-webkit-details-marker]:hidden">
<span
className={cn(
'mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full',
entry.kind === 'added' && 'bg-emerald-500/10 text-emerald-600',
entry.kind === 'removed' && 'bg-rose-500/10 text-rose-600',
entry.kind === 'modified' && 'bg-amber-500/10 text-amber-600',
)}
>
{entry.kind === 'removed' ? <ShieldAlert className="h-4 w-4" /> : <ShieldCheck className="h-4 w-4" />}
</span>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
entry.kind === 'added' && 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
entry.kind === 'removed' && 'bg-rose-500/10 text-rose-700 dark:text-rose-300',
entry.kind === 'modified' && 'bg-amber-500/10 text-amber-700 dark:text-amber-300',
)}
>
{label}
</span>
<span className="font-mono text-sm font-semibold text-foreground">{formatMappingLabel(entry.base ?? entry.pending)}</span>
</div>
{title ? <p className="text-sm text-muted-foreground">{title}</p> : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<span>{t('review.complianceDiffViewDetails')}</span>
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-open:rotate-180" />
</div>
</summary>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('review.complianceDiffBaseVersion')}
</div>
<MappingDetails
mapping={entry.base}
emptyMessage={t('review.complianceDiffBaseRemoved')}
/>
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('review.complianceDiffPendingVersion')}
</div>
<MappingDetails
mapping={entry.pending}
emptyMessage={t('review.complianceDiffPendingAdded')}
/>
</div>
</div>
</details>
)
}
function pickBaseVersion(versions: SkillVersion[], activeVersion: string) {
const publishedVersions = versions.filter((version) => version.status === 'PUBLISHED' && version.version !== activeVersion)
if (publishedVersions.length > 0) {
return publishedVersions.sort((left, right) => {
const leftTime = new Date(left.publishedAt).getTime()
const rightTime = new Date(right.publishedAt).getTime()
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return rightTime - leftTime
}
return right.id - left.id
})[0]
}
return versions.find((version) => version.version !== activeVersion) ?? null
}
export function ReviewComplianceDiffPanel({ baseVersion, pendingVersion, className }: ReviewComplianceDiffPanelProps) {
const { t } = useTranslation()
if (!baseVersion || !pendingVersion) {
return null
}
const diff = compareComplianceSnapshots(baseVersion.complianceSnapshot, pendingVersion.complianceSnapshot)
if (diff.diffs.length === 0) {
return null
}
return (
<div className={cn('rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4', className)}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
<ShieldAlert className="h-4 w-4 text-amber-500" />
{t('review.complianceDiffTitle')}
</div>
<p className="break-words text-sm leading-6 text-muted-foreground">
{t('review.complianceDiffDescription', {
baseVersion: baseVersion.version,
pendingVersion: pendingVersion.version,
})}
</p>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
{t('review.complianceDiffAddedLabel', { count: diff.added })}
</span>
<span className="rounded-full bg-rose-500/10 px-2.5 py-1 text-xs font-medium text-rose-700 dark:text-rose-300">
{t('review.complianceDiffRemovedLabel', { count: diff.removed })}
</span>
<span className="rounded-full bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
{t('review.complianceDiffModifiedLabel', { count: diff.modified })}
</span>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-border/60 bg-background/70 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('review.complianceDiffBaseDigest')}
</div>
<div className="mt-1 min-w-0 break-all font-mono text-xs leading-5 text-foreground" title={baseVersion.complianceSnapshot?.digest}>
{shortDigest(baseVersion.complianceSnapshot?.digest)}
</div>
</div>
<div className="rounded-xl border border-border/60 bg-background/70 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('review.complianceDiffPendingDigest')}
</div>
<div className="mt-1 min-w-0 break-all font-mono text-xs leading-5 text-foreground" title={pendingVersion.complianceSnapshot?.digest}>
{shortDigest(pendingVersion.complianceSnapshot?.digest)}
</div>
</div>
</div>
<div className="mt-4 grid gap-3">
{diff.diffs.map((entry) => (
<DiffItem key={`${entry.kind}-${entry.key}`} entry={entry} />
))}
</div>
</div>
)
}
export { compareComplianceSnapshots, pickBaseVersion }

View file

@ -8,6 +8,7 @@ import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
import type { FileTreeNode } from '@/features/skill/file-tree-builder'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { ComplianceSnapshotPanel } from '@/features/skill/compliance-snapshot-panel'
import { ReviewComplianceDiffPanel, pickBaseVersion } from './review-compliance-diff-panel'
import { Button, buttonVariants } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
@ -77,6 +78,8 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
}
const documentation = getReviewSkillDocumentation(detail)
const pendingVersion = detail.versions.find((version) => version.version === detail.activeVersion) ?? null
const baseVersion = pickBaseVersion(detail.versions, detail.activeVersion)
return (
<Card className="p-6 space-y-4">
@ -115,6 +118,12 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
</a>
</div>
<ReviewComplianceDiffPanel
baseVersion={baseVersion}
pendingVersion={pendingVersion}
className="shadow-sm"
/>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">{t('skillDetail.tabOverview')}</TabsTrigger>

View file

@ -1442,6 +1442,21 @@
"skillDetailError": "Failed to load the skill detail for this review.",
"activeReviewVersion": "Review Version",
"downloadSkillZip": "Download Skill ZIP",
"complianceDiffTitle": "Compliance declaration diff",
"complianceDiffDescription": "Compare compliance changes between published version {{baseVersion}} and pending version {{pendingVersion}}.",
"complianceDiffBaseVersion": "Base version",
"complianceDiffPendingVersion": "Pending version",
"complianceDiffBaseDigest": "Base digest",
"complianceDiffPendingDigest": "Pending digest",
"complianceDiffAdded": "Added",
"complianceDiffRemoved": "Removed",
"complianceDiffModified": "Modified",
"complianceDiffAddedLabel": "Added {{count}}",
"complianceDiffRemovedLabel": "Removed {{count}}",
"complianceDiffModifiedLabel": "Modified {{count}}",
"complianceDiffViewDetails": "View details",
"complianceDiffBaseRemoved": "This declaration exists in the base version and was removed from the pending version.",
"complianceDiffPendingAdded": "This declaration was added in the pending version.",
"noDocumentation": "This review version does not include a readable documentation file."
},
"token": {

View file

@ -1443,6 +1443,21 @@
"skillDetailError": "技能详情加载失败,请稍后重试。",
"activeReviewVersion": "当前审核版本",
"downloadSkillZip": "下载技能 ZIP",
"complianceDiffTitle": "合规声明差异",
"complianceDiffDescription": "对比已发布版本 {{baseVersion}} 与待审核版本 {{pendingVersion}} 的合规声明变化。",
"complianceDiffBaseVersion": "基线版本",
"complianceDiffPendingVersion": "待审版本",
"complianceDiffBaseDigest": "基线摘要",
"complianceDiffPendingDigest": "待审摘要",
"complianceDiffAdded": "新增",
"complianceDiffRemoved": "删除",
"complianceDiffModified": "修改",
"complianceDiffAddedLabel": "新增 {{count}} 项",
"complianceDiffRemovedLabel": "删除 {{count}} 项",
"complianceDiffModifiedLabel": "修改 {{count}} 项",
"complianceDiffViewDetails": "查看详情",
"complianceDiffBaseRemoved": "基线版本包含该声明,待审版本已移除。",
"complianceDiffPendingAdded": "待审版本新增了这条声明。",
"noDocumentation": "当前待审核版本未提供可读的说明文档。"
},
"token": {