From 9ac46b9ffed17f85d62df901f69206835a60c1b4 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:28:57 -0700 Subject: [PATCH 1/5] Add Timeline event taxonomy source module Port the event categorization model from the Timeline redesign prototype (github/prototyping) into the Timeline component package. Adds the surface, actor-type, category, and event catalogs plus the projections that derive the data-* event contract (github/primer#6654), the flattened registry keys, and by-category grouping. Includes the catalog-fit test sweep (22 cases). The module is internal-only for now: nothing joins the public @primer/react export surface until the model is ratified. It gives the Phase 3 Timeline Playground (github/primer#6664) and the per-surface Timeline stories one source of truth to consume instead of hand-maintained per-surface lists. --- eslint.config.mjs | 8 + .../react/src/Timeline/taxonomy/actorType.ts | 36 ++ .../src/Timeline/taxonomy/eventCategories.ts | 304 +++++++++++ .../Timeline/taxonomy/eventTaxonomy.test.ts | 235 +++++++++ .../src/Timeline/taxonomy/eventTaxonomy.ts | 496 ++++++++++++++++++ packages/react/src/Timeline/taxonomy/index.ts | 20 + .../react/src/Timeline/taxonomy/surfaces.ts | 48 ++ 7 files changed, 1147 insertions(+) create mode 100644 packages/react/src/Timeline/taxonomy/actorType.ts create mode 100644 packages/react/src/Timeline/taxonomy/eventCategories.ts create mode 100644 packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts create mode 100644 packages/react/src/Timeline/taxonomy/eventTaxonomy.ts create mode 100644 packages/react/src/Timeline/taxonomy/index.ts create mode 100644 packages/react/src/Timeline/taxonomy/surfaces.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index e3534d09a44..22647924d8e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -421,6 +421,14 @@ const config = defineConfig([ 'primer-react/direct-slot-children': 'off', }, }, + // Timeline event taxonomy: catalog keys are REST `event.type` wire values + // (snake_case), not code identifiers. + { + files: ['packages/react/src/Timeline/taxonomy/eventTaxonomy.ts'], + rules: { + camelcase: 'off', + }, + }, ]) export default tseslint.config(config) diff --git a/packages/react/src/Timeline/taxonomy/actorType.ts b/packages/react/src/Timeline/taxonomy/actorType.ts new file mode 100644 index 00000000000..954b6529fcb --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/actorType.ts @@ -0,0 +1,36 @@ +/** + * Ported from the Timeline redesign prototype (github/prototyping, + * src/packages/conversation/timeline). Backs the taxonomy model documented in + * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3 + * Timeline Playground), github/primer#6654 (data-* event contract), + * primer/react#8075 (License Compliance stories). + */ + +/** + * Coarse actor classification, surfaced as the `data-actor-type` attribute on + * event rows (mirrors the Primer Timeline `data-*` convention from + * github/primer#6654, alongside `data-event-type` / `data-event-scope`). + * + * "bot" covers GitHub apps and first-party automation (Dependabot, Actions, + * Copilot, Hubot) plus any `…[bot]` login; everything else is a human "user". + * This lets a filtering/grouping pass target automated vs. human activity + * declaratively — e.g. collapsing the system lifecycle on a security alert. + */ + +export type ActorType = 'user' | 'bot' + +const BOT_LOGINS: ReadonlySet = new Set([ + 'dependabot', + 'dependabot-preview', + 'github-actions', + 'github-license-compliance', + 'copilot', + 'hubot', +]) + +export function actorTypeForLogin(login: string | undefined): ActorType { + if (!login) return 'user' + const normalized = login.toLowerCase() + if (normalized.endsWith('[bot]')) return 'bot' + return BOT_LOGINS.has(normalized) ? 'bot' : 'user' +} diff --git a/packages/react/src/Timeline/taxonomy/eventCategories.ts b/packages/react/src/Timeline/taxonomy/eventCategories.ts new file mode 100644 index 00000000000..b84f107aed4 --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/eventCategories.ts @@ -0,0 +1,304 @@ +/** + * Ported from the Timeline redesign prototype (github/prototyping, + * src/packages/conversation/timeline). Backs the taxonomy model documented in + * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3 + * Timeline Playground), github/primer#6654 (data-* event contract), + * primer/react#8075 (License Compliance stories). + */ + +/** + * Timeline event categorization model (canonical v1). + * + * Single source of truth for the redesigned Primer Timeline category system. + * Replaces the earlier internal vocabulary (`review | commit | metadata | + * automation | discussion` + `primary | secondary` + density presets). + * + * Two axes describe every event: + * + * 1. **Category** — which family the event belongs to. Seven categories exist + * in the taxonomy; the Viewing menu offers each as a toggle on the surfaces + * where it applies (scoped per surface). Two categories are internal and + * never appear in the menu: + * - `conversation` — comments and reviews. Always shown; uncheckable. + * - `metadata` — labels, assignments, projects, milestones, fields, + * types. Always audit-only; never in the main timeline. + * + * The never-empty guarantee is *not* carried by any category. It is a + * property of the lifecycle: the opening event and the terminal closing + * event are pinned in the renderer (see `computeLifecycleBookendIds` in + * `timelineVisibility.ts`), so filtering can never collapse a record to + * nothing — regardless of which categories are toggled off. + * + * 2. **Visibility** — `primary` events render in the main timeline when their + * category is toggled on; `auditOnly` events render exclusively in the + * audit ("full activity") view. Conversation items render regardless. + * + * See the project context doc §4 (categorization model) and §5 (events catalog). + */ + +import { + CheckCircleIcon, + CommentIcon, + CrossReferenceIcon, + EyeIcon, + FoldIcon, + GitBranchIcon, + GitMergeIcon, + ReportIcon, + ShieldIcon, + type Icon, +} from '@primer/octicons-react' +import {SECURITY_ALERT_SURFACES, type TimelineSurface} from './surfaces' + +/** + * Categories the user can toggle on/off in the Viewing menu. + * + * This is the proposed canonical set (primer#6665): seven families. `findings` + * is the security set (alerts detected / remediated / license changes) and is a + * real, toggleable category on alert surfaces — its detection and remediation + * events (`dependabot_opened`, `dependabot_fixed`) + * can be hidden like any other. The record still never empties, because the + * opening + terminal-close events are pinned as lifecycle bookends (see + * `computeLifecycleBookendIds`), independent of the Findings toggle. + * See {@link SURFACE_CATEGORIES} and {@link EventCategory}. + */ +export type ToggleableCategory = 'reviews' | 'merging' | 'status' | 'findings' | 'commits' | 'references' | 'moderation' + +/** + * Full category union. `conversation` and `metadata` are internal (not in the + * Viewing menu): + * - `conversation` — comments/reviews. Always shown; uncheckable. + * - `metadata` — labels/assignments/etc. Always audit-only. + * + * The security alert lifecycle now maps across the toggleable categories: + * detection + remediation (opened, fixed) are `findings`; the remaining state + * changes (dismissed, reopened) are `status`; dismissal governance (requested / + * reviewed / cancelled) is `reviews`. Dependabot's Reintroduced folds into + * reopened and Auto-dismissed folds into dismissed, so both stay in `status`. + * The opening and terminal-close events are additionally pinned as lifecycle + * bookends so the record never empties regardless of toggles. + */ +export type EventCategory = ToggleableCategory | 'conversation' | 'metadata' + +/** + * How prominently an event surfaces by default. + * + * - `primary` — renders in the main timeline when its category is toggled on + * - `auditOnly` — never in the main timeline; only in the audit view + * + * (Conversation items are implicitly "always" — shown regardless of toggles.) + */ +export type EventVisibility = 'primary' | 'auditOnly' + +export interface CategoryMeta { + /** Title-cased label shown in the Viewing menu. */ + label: string + /** One-line description shown under the label in the Viewing menu. */ + description: string + /** Leading visual for the menu item. */ + icon: Icon +} + +/** Display metadata for each toggleable category (Viewing menu). */ +export const CATEGORY_META: Record = { + reviews: { + label: 'Reviews', + description: 'Review requests, approvals, dismissals', + icon: EyeIcon, + }, + merging: { + label: 'Merging', + description: 'Merge events, queue, auto-merge', + icon: GitMergeIcon, + }, + status: { + label: 'Status updates', + description: 'Opened, closed, reopened, dismissed', + icon: CheckCircleIcon, + }, + findings: { + label: 'Findings', + description: 'Alerts detected, remediated, licenses', + icon: ShieldIcon, + }, + commits: { + label: 'Commits and branches', + description: 'Pushes, branch changes', + icon: GitBranchIcon, + }, + references: { + label: 'References', + description: 'Cross-references, mentions, relationships', + icon: CrossReferenceIcon, + }, + moderation: { + label: 'Moderation', + description: 'Locks, deleted comments, blocks', + icon: ReportIcon, + }, +} + +/** + * Per-surface description overrides for the Viewing menu. + * + * {@link CATEGORY_META} descriptions are the generic, cross-surface captions + * (used by the preferences page, which lists every category without scoping to a + * surface). The Viewing menu is always scoped to one surface, so it should + * describe a category in terms that are true *there* — e.g. a Dependabot alert + * never has "licenses" (that's a separate License-compliance surface), and its + * `reviews` + * are dismissal governance, not PR approvals. Only categories whose meaning + * differs by surface need an entry; anything unlisted falls back to the generic + * {@link CATEGORY_META} description via {@link categoryDescription}. + */ +const SURFACE_CATEGORY_DESCRIPTIONS: Partial>>> = { + pull: { + status: 'Opened, closed, reopened', + }, + issue: { + status: 'Opened, closed, reopened', + }, + dependabot: { + findings: 'Vulnerabilities detected, reintroduced, remediated', + status: 'Dismissed, reopened, auto-dismissed', + reviews: 'Dismissal requests and reviews', + }, + 'code-scanning': { + findings: 'Alerts detected, appeared in branches, and fixed', + status: 'Alerts closed and reopened', + reviews: 'Dismissal requests and reviews', + }, + 'secret-scanning': { + findings: 'Secrets detected and validity changes', + status: 'Closed, reopened, and push-protection bypasses', + reviews: 'Dismissal requests and reviews', + }, + 'license-compliance': { + findings: 'Violations opened, appeared in branches, and closed', + status: 'Policy exceptions and approved-license changes', + reviews: 'Close requests and reviews', + }, +} + +/** + * Description shown under a category in the Viewing menu for a given surface. + * Prefers the surface-specific override; falls back to the generic + * {@link CATEGORY_META} caption. + */ +export function categoryDescription(category: ToggleableCategory, surface: TimelineSurface): string { + return SURFACE_CATEGORY_DESCRIPTIONS[surface]?.[category] ?? CATEGORY_META[category].description +} + +/** + * Which toggleable categories apply to each surface, in menu display order. + * The Viewing menu only renders categories applicable to the current surface + * (6 for PRs, 3 for issues, 3 for Dependabot). Every listed category is a real + * toggle; the never-empty guarantee is handled separately by lifecycle-bookend + * pinning (see `computeLifecycleBookendIds`), not by forcing any category on. + */ +export const SURFACE_CATEGORIES: Record = { + pull: ['reviews', 'merging', 'status', 'commits', 'references', 'moderation'], + issue: ['status', 'references', 'moderation'], + dependabot: ['findings', 'status', 'reviews'], + 'code-scanning': ['findings', 'status', 'reviews'], + 'secret-scanning': ['findings', 'status', 'reviews'], + 'license-compliance': ['findings', 'status', 'reviews'], +} + +/** Every toggleable category (surface-agnostic), in canonical order. */ +export const ALL_TOGGLEABLE_CATEGORIES: readonly ToggleableCategory[] = [ + 'reviews', + 'merging', + 'status', + 'findings', + 'commits', + 'references', + 'moderation', +] + +/** + * Display order of surfaces (used when listing which surfaces a setting hits). + */ +const SURFACE_ORDER: readonly TimelineSurface[] = [ + 'pull', + 'issue', + 'dependabot', + 'code-scanning', + 'secret-scanning', + 'license-compliance', +] + +/** Human label for a surface, used in settings copy. */ +export const SURFACE_LABEL: Record = { + pull: 'Pull requests', + issue: 'Issues', + dependabot: 'Alerts', + 'code-scanning': 'Code scanning', + 'secret-scanning': 'Secret scanning', + 'license-compliance': 'License compliance', +} + +/** Which surfaces a category applies to (inverse of {@link SURFACE_CATEGORIES}). */ +export function surfacesForCategory(category: ToggleableCategory): TimelineSurface[] { + return SURFACE_ORDER.filter(surface => SURFACE_CATEGORIES[surface].includes(category)) +} + +/** + * Short "applies to" label for a category, e.g. "Pull requests", "Issues", or + * "All surfaces" when it spans every surface. Used by the preferences page to + * delimit which surface each setting affects. + */ +export function appliesToLabel(category: ToggleableCategory): string { + const surfaces = surfacesForCategory(category) + if (surfaces.length === SURFACE_ORDER.length) return 'All surfaces' + // Collapse the security-alert surfaces (Dependabot, code/secret scanning, + // license compliance) into one label — they share the security taxonomy. + const securitySet = new Set(SECURITY_ALERT_SURFACES) + const security = surfaces.filter(s => securitySet.has(s)) + const nonSecurity = surfaces.filter(s => !securitySet.has(s)) + const parts = nonSecurity.map(s => SURFACE_LABEL[s]) + if (security.length === securitySet.size) { + parts.push('Security alerts') + } else { + parts.push(...security.map(s => SURFACE_LABEL[s])) + } + return parts.join(', ') +} + +/** + * Refinements are PR-only toggles, separate from categories. They narrow the + * curated main timeline without removing a whole category. Audit view ignores + * them (it shows everything). + */ +export type Refinement = 'hideResolved' | 'hideOutdated' + +export interface RefinementMeta { + label: string + /** Leading visual for the menu item. */ + icon: Icon + /** Surfaces where this refinement is offered. */ + surfaces: readonly TimelineSurface[] +} + +export const REFINEMENT_META: Record = { + hideResolved: { + label: 'Hide resolved review comments', + icon: CommentIcon, + surfaces: ['pull'], + }, + hideOutdated: { + label: 'Hide outdated comments', + icon: FoldIcon, + surfaces: ['pull'], + }, +} + +/** Refinements offered on a given surface, in display order. */ +export function refinementsForSurface(surface: TimelineSurface): Refinement[] { + return (Object.keys(REFINEMENT_META) as Refinement[]).filter(r => REFINEMENT_META[r].surfaces.includes(surface)) +} + +/** Whether a category is user-toggleable (appears in the Viewing menu). */ +export function isToggleableCategory(category: EventCategory): category is ToggleableCategory { + return category !== 'conversation' && category !== 'metadata' +} diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts new file mode 100644 index 00000000000..e100dc80e35 --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts @@ -0,0 +1,235 @@ +/** + * Tests for the event taxonomy source of truth — the License Compliance catalog + * and the three projections derived from it (flattened keys, `data-*` + * attributes, by-category grouping). + */ + +import {describe, it, expect} from 'vitest' +import { + LICENSE_COMPLIANCE_SCOPE, + LICENSE_COMPLIANCE_TAXONOMY, + SURFACE_TAXONOMIES, + ISSUE_TAXONOMY, + taxonomyCategoriesMatchSurface, + qualifyEventType, + unqualifyEventType, + toEventDataAttributes, + eventTypesByCategory, + type LicenseComplianceEventType, + type CodeScanningEventType, + type CatalogedScope, +} from './eventTaxonomy' +import {SURFACE_CATEGORIES, isToggleableCategory} from './eventCategories' + +const LC_TYPES = Object.keys(LICENSE_COMPLIANCE_TAXONOMY) as LicenseComplianceEventType[] + +describe('LICENSE_COMPLIANCE_TAXONOMY', () => { + it('is the authoritative nine unscoped leaf types (primer/react#8075)', () => { + expect(LC_TYPES).toEqual([ + 'opened', + 'appeared_in_branch', + 'review_requested', + 'review_approved', + 'review_denied', + 'review_expired', + 'exception_added', + 'licenses_added', + 'closed', + ]) + }) + + it('uses unscoped leaves (no redundant license_compliance_ prefix)', () => { + for (const type of LC_TYPES) { + expect(type.startsWith('license_compliance')).toBe(false) + } + }) + + it('only uses categories the License Compliance surface offers, and covers all three', () => { + const surfaceCategories = SURFACE_CATEGORIES[LICENSE_COMPLIANCE_SCOPE] + const used = new Set(LC_TYPES.map(type => LICENSE_COMPLIANCE_TAXONOMY[type].category)) + for (const category of used) { + expect(surfaceCategories).toContain(category) + expect(isToggleableCategory(category)).toBe(true) + } + expect([...used].sort()).toEqual([...surfaceCategories].sort()) + }) + + it('marks only the structurally actor-less synthetic event as actor-less', () => { + const withoutActor = LC_TYPES.filter(type => !LICENSE_COMPLIANCE_TAXONOMY[type].hasActor) + expect(withoutActor).toEqual(['appeared_in_branch']) + }) +}) + +describe('qualifyEventType', () => { + it('snake-cases the scope and matches the existing prototype key convention', () => { + expect(qualifyEventType('license-compliance', 'opened')).toBe('license_compliance_opened') + expect(qualifyEventType('license-compliance', 'review_requested')).toBe('license_compliance_review_requested') + }) + + it('produces the corrected key for the drifted branch event', () => { + // Prototype currently ships `license_compliance_appeared`; the real leaf is + // `appeared_in_branch`, so the reconciled key gains the full suffix. + expect(qualifyEventType('license-compliance', 'appeared_in_branch')).toBe('license_compliance_appeared_in_branch') + }) +}) + +describe('unqualifyEventType', () => { + it('recovers the unscoped leaf from a flattened security-surface key', () => { + expect(unqualifyEventType('license-compliance', 'license_compliance_appeared_in_branch')).toBe('appeared_in_branch') + expect(unqualifyEventType('license-compliance', 'license_compliance_opened')).toBe('opened') + }) + + it('leaves an already-unscoped key untouched (pull/issue carry no prefix)', () => { + expect(unqualifyEventType('pull', 'labeled')).toBe('labeled') + expect(unqualifyEventType('pull', 'review')).toBe('review') + }) + + it('round-trips with qualifyEventType for every License Compliance leaf', () => { + for (const type of LC_TYPES) { + const flattened = qualifyEventType(LICENSE_COMPLIANCE_SCOPE, type) + expect(unqualifyEventType(LICENSE_COMPLIANCE_SCOPE, flattened)).toBe(type) + } + }) +}) + +describe('toEventDataAttributes', () => { + it('emits the unscoped type with the surface carried separately in scope', () => { + const attrs = toEventDataAttributes({ + scope: 'license-compliance', + type: 'opened', + category: 'findings', + actorType: 'user', + }) + expect(attrs).toEqual({ + 'data-event-scope': 'license-compliance', + 'data-event-type': 'opened', + 'data-event-category': 'findings', + 'data-event-visibility': 'primary', + 'data-actor-type': 'user', + }) + }) + + it('defaults visibility to primary', () => { + const attrs = toEventDataAttributes({ + scope: 'license-compliance', + type: 'closed', + category: 'findings', + }) + expect(attrs['data-event-visibility']).toBe('primary') + }) + + it('respects an explicit visibility', () => { + const attrs = toEventDataAttributes({ + scope: 'pull', + type: 'labeled', + category: 'references', + visibility: 'auditOnly', + }) + expect(attrs['data-event-visibility']).toBe('auditOnly') + }) + + it('omits data-actor-type for actor-less events rather than emitting empty', () => { + const attrs = toEventDataAttributes({ + scope: 'license-compliance', + type: 'appeared_in_branch', + category: 'findings', + }) + expect('data-actor-type' in attrs).toBe(false) + }) +}) + +describe('eventTypesByCategory', () => { + it('groups leaves by category, folding the two policy events into one status group', () => { + const groups = eventTypesByCategory(LICENSE_COMPLIANCE_TAXONOMY) + expect(groups.findings).toEqual(['opened', 'appeared_in_branch', 'closed']) + expect(groups.reviews).toEqual(['review_requested', 'review_approved', 'review_denied', 'review_expired']) + expect(groups.status).toEqual(['exception_added', 'licenses_added']) + }) + + it('preserves catalog declaration order within each group', () => { + const groups = eventTypesByCategory(LICENSE_COMPLIANCE_TAXONOMY) + // `exception_added` is declared before `licenses_added`. + expect(groups.status?.indexOf('exception_added')).toBeLessThan(groups.status?.indexOf('licenses_added') ?? -1) + }) +}) + +describe('SURFACE_TAXONOMIES (cross-surface model)', () => { + const scopes = Object.keys(SURFACE_TAXONOMIES) as CatalogedScope[] + + it('formalizes the five in-scope surfaces (PR out of scope this pass)', () => { + expect(scopes.sort()).toEqual( + ['code-scanning', 'dependabot', 'issue', 'license-compliance', 'secret-scanning'].sort(), + ) + }) + + it('every catalog only uses categories its surface offers (metadata always allowed)', () => { + for (const scope of scopes) { + const mismatches = taxonomyCategoriesMatchSurface(scope, SURFACE_TAXONOMIES[scope]) + expect({scope, mismatches}).toEqual({scope, mismatches: []}) + } + }) + + it('uses unscoped leaves for multi-word scopes (no redundant surface prefix)', () => { + // Only kebab (multi-word) scopes can carry an unambiguous redundant prefix; + // single-token scopes legitimately own leaves like `issue_type_added`. + for (const scope of scopes.filter(s => s.includes('-'))) { + const snakeScope = scope.replace(/-/g, '_') + for (const type of Object.keys(SURFACE_TAXONOMIES[scope])) { + expect(type.startsWith(`${snakeScope}_`)).toBe(false) + } + } + }) + + it('marks every issue metadata leaf auditOnly and leaves toggleable leaves unset', () => { + // Raw `visibility` field: metadata leaves must explicitly carry 'auditOnly'; + // every other leaf omits the facet and defaults to primary at projection time. + const actual = Object.fromEntries(Object.entries(ISSUE_TAXONOMY).map(([type, entry]) => [type, entry.visibility])) + const expected = Object.fromEntries( + Object.entries(ISSUE_TAXONOMY).map(([type, entry]) => [ + type, + entry.category === 'metadata' ? 'auditOnly' : undefined, + ]), + ) + expect(actual).toEqual(expected) + }) + + it('excludes PR-only families from the issue catalog', () => { + const issueCategories = new Set(Object.values(ISSUE_TAXONOMY).map(entry => entry.category)) + // Issues never offer commits, merging, or reviews (see SURFACE_CATEGORIES.issue). + expect(issueCategories.has('commits')).toBe(false) + expect(issueCategories.has('merging')).toBe(false) + expect(issueCategories.has('reviews')).toBe(false) + }) + + it('models the whole security-alert detection group as no-actor on code scanning', () => { + const codeScanning = SURFACE_TAXONOMIES['code-scanning'] + const detectionGroup: CodeScanningEventType[] = ['detected', 'appeared', 'reappeared', 'fixed'] + const actorFlags = Object.fromEntries(detectionGroup.map(type => [type, codeScanning[type].hasActor])) + const expected = Object.fromEntries(detectionGroup.map(type => [type, false])) + expect(actorFlags).toEqual(expected) + // The folded closure leaf stays actor-capable (BECAME_OUTDATED is system, + // CLOSED_BY_USER carries an actor), so presence is data-driven. + expect(codeScanning.closed.hasActor).toBe(true) + }) + + it('models every dependabot leaf as actor-ful (Dependabot renders itself as a bot actor)', () => { + const dependabot = SURFACE_TAXONOMIES['dependabot'] + // Verified against the primer/react Dependabot Storybook: seven leaves, and + // unlike code scanning there are NO structurally actor-less events — the + // detection/auto paths render the Dependabot bot actor. + expect(Object.keys(dependabot).sort()).toEqual( + [ + 'opened', + 'fixed', + 'dismissed', + 'reopened', + 'dismissal_requested', + 'dismissal_reviewed', + 'dismissal_cancelled', + ].sort(), + ) + const actorFlags = Object.fromEntries(Object.entries(dependabot).map(([type, entry]) => [type, entry.hasActor])) + const everyLeafHasActor = Object.fromEntries(Object.keys(dependabot).map(type => [type, true])) + expect(actorFlags).toEqual(everyLeafHasActor) + }) +}) diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts new file mode 100644 index 00000000000..1d79f4b69a6 --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts @@ -0,0 +1,496 @@ +/** + * Ported from the Timeline redesign prototype (github/prototyping, + * src/packages/conversation/timeline). Backs the taxonomy model documented in + * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3 + * Timeline Playground), github/primer#6654 (data-* event contract), + * primer/react#8075 (License Compliance stories). + */ + +/** + * Timeline event taxonomy — the single source of truth for the redesigned + * Primer Timeline event categorization, keyed by the `(scope, type)` composite. + * + * The taxonomy has **five axes**. Three are hierarchical (the Figma + * surface → category → event nesting, and the `data-*` spine): + * + * - **scope** ({@link EventScope}, axis L1) — the owning surface. + * - **category** ({@link ToggleableCategory}, axis L2) — the event family that + * the Viewing menu toggles. + * - **type** (the leaf, axis L3) — the **unscoped** REST `event.type`. + * + * Two are orthogonal facets: **visibility** (`primary | auditOnly`) and + * **actorType** (`user | bot`, resolved at runtime from the actor login). + * + * Identity is the `(scope, type)` pair, so the leaf `type` is unscoped + * (`opened`, not `license_compliance_opened`): `data-event-scope` already + * carries the surface, and keeping `type` unscoped lets a selector target a + * lifecycle verb across every surface (`[data-event-type="closed"]`) or one + * surface (`[data-event-scope="license-compliance"][data-event-type="closed"]`). + * The prototype's flattened `PullConversationEventType` union is a downstream + * storage artifact derived via {@link qualifyEventType}, not the canonical form. + * + * Every consumer (the `data-*` attributes, the Storybook story grouping, and the + * prototype registry/union keys) is a **projection** of this one catalog, not a + * separately hand-maintained list. + * + * NOTE (value casing): axis values are currently mixed — scope is kebab + * (`license-compliance`, from {@link TimelineSurface}), type is snake + * (`review_requested`), category is a bare word (`reviews`). Normalizing all + * axis values to snake_case is an open ratification item (see the rollout doc); + * this module intentionally mirrors the values the running prototype emits today + * rather than forking casing unilaterally. + */ + +import type {EventCategory, EventVisibility, ToggleableCategory} from './eventCategories' +import {SURFACE_CATEGORIES, isToggleableCategory} from './eventCategories' +import type {ActorType} from './actorType' +import type {TimelineSurface} from './surfaces' + +/** + * Owning surface of an event — axis L1. Identical to {@link TimelineSurface}: + * scope IS the surface. Aliased so taxonomy consumers read intent (`EventScope`) + * without coupling to the rendering-context type name. + */ +export type EventScope = TimelineSurface + +/** + * License Compliance leaf event types — axis L3, **unscoped** (the real + * `event.type`). This is the authoritative nine, verified in primer/react#8075 + * against the live github-ui `switch (event.type)` in + * `packages/license-compliance-alerts/components/timeline/TimelineEventItem.tsx` + * plus the Rails synthetic-event builder (`opened`, `appeared_in_branch`). + * + * The prototype union (`repo-pull-conversation-types.ts`) drifts from this: it + * shortens `appeared_in_branch` to `appeared`, omits `licenses_added`, and + * flattens each leaf with a `license_compliance_` prefix. Reconcile to this set. + */ +export type LicenseComplianceEventType = + | 'opened' + | 'appeared_in_branch' + | 'review_requested' + | 'review_approved' + | 'review_denied' + | 'review_expired' + | 'exception_added' + | 'licenses_added' + | 'closed' + +/** Placement of one event on the non-identity axes (category + facets). */ +export interface EventTaxonomyEntry { + /** + * Category family — axis L2. Drives `data-event-category` and the Viewing-menu + * grouping. A {@link ToggleableCategory} the surface offers (see + * `SURFACE_CATEGORIES`), OR the always-audit `metadata` family (never + * toggleable, always `auditOnly` — labels, assignees, project fields). Use + * {@link taxonomyCategoriesMatchSurface} to assert a catalog only uses + * categories its surface actually offers. + */ + category: EventCategory + /** + * Default density facet — `data-event-visibility`. Omit for `primary` (the + * common case; every License Compliance event is primary because the audit-log + * surface renders one flat list with no curated/audit split). + */ + visibility?: EventVisibility + /** + * Whether the event renders through the **actor-capable** path. `false` only + * for structurally actor-less events (the synthetic `appeared_in_branch`, + * which upstream renders through `TimelineEventWithoutActor`) — these emit no + * `data-actor-type`. `true` means the row can carry an actor, but PRESENCE is + * data-driven: upstream `TimelineEventWithActor` renders the avatar only when + * `event.actor` exists, so a time-triggered event like `review_expired` is + * `true` yet renders actor-less when the payload has no actor. The concrete + * `user | bot` value is resolved at runtime from the actor login (see + * `actorTypeForLogin`), never fixed by event type; `data-actor-type` is omitted + * whenever no actor is present. + */ + hasActor: boolean +} + +/** Convenience handle for the pilot surface. */ +export const LICENSE_COMPLIANCE_SCOPE: EventScope = 'license-compliance' + +/** + * The License Compliance catalog — declared ONCE. Categories mirror the running + * registry (`TimelineEventRegistry.tsx`): lifecycle + branch presence are + * `findings`, all review governance is `reviews`, and the two policy-change + * events are `status` (folding `exception_added` + `licenses_added` into one + * "Status updates" story instead of two singletons). The lifecycle bookends + * (`opened`, `closed`) are additionally pinned by `computeLifecycleBookendIds` + * so filtering never empties the record — a guarantee that lives in the renderer, + * not in this catalog. + */ +export const LICENSE_COMPLIANCE_TAXONOMY: Record = { + opened: {category: 'findings', hasActor: true}, // synthetic; system-identity actor, rendered linked (no "bot" Label) + appeared_in_branch: {category: 'findings', hasActor: false}, // synthetic; system, no actor + review_requested: {category: 'reviews', hasActor: true}, + review_approved: {category: 'reviews', hasActor: true}, + review_denied: {category: 'reviews', hasActor: true}, + review_expired: {category: 'reviews', hasActor: true}, // actor-capable; renders actor-less when payload has no actor (automatic expiry) + exception_added: {category: 'status', hasActor: true}, + licenses_added: {category: 'status', hasActor: true}, + closed: {category: 'findings', hasActor: true}, +} + +/* ------------------------------------------------------------------------- * + * Secret Scanning — axis L3 leaves. + * + * SOURCE OF TRUTH: live github-ui React `packages/secret-scanning-alerts` + * `components/show/AlertTimeline.tsx` `switch (event.type)` (five cases: + * Creation, Resolution, Bypass, Report, DelegatedClosureRequestOpened). + * See `files/story-secret-scanning.diff` (translated 1:1 from the live switch). + * + * DELTA — 5 canonical cases → 7 prototype leaves. The prototype fans the switch + * out to finer wire types: `Resolution` splits into `closed` + `reopened` + * (the case branches on `resolution.type === 'reopened'`), and the prototype + * additionally carries `validity_changed` (the Report path) and a distinct + * `dismissal_reviewed`. Tracked in the cross-surface delta report. + * + * ACTOR — `Creation` uses `isGitHubActor` unconditionally: the detection event + * ALWAYS renders the system "GitHub" actor (like LC `opened`), so `hasActor` is + * true with a system identity. Every other case carries a user actor. + * ------------------------------------------------------------------------- */ +export type SecretScanningEventType = + | 'detected' + | 'validity_changed' + | 'bypassed' + | 'dismissal_requested' + | 'dismissal_reviewed' + | 'reopened' + | 'closed' + +export const SECRET_SCANNING_TAXONOMY: Record = { + detected: {category: 'findings', hasActor: true}, // `Creation`; system GitHub actor (isGitHubActor), rendered + validity_changed: {category: 'findings', hasActor: true}, // `Report` path; user actor + bypassed: {category: 'status', hasActor: true}, + dismissal_requested: {category: 'reviews', hasActor: true}, // `DelegatedClosureRequestOpened` + dismissal_reviewed: {category: 'reviews', hasActor: true}, + reopened: {category: 'status', hasActor: true}, // `Resolution` (resolution.type === 'reopened') + closed: {category: 'status', hasActor: true}, // `Resolution` (any other resolution.type) +} + +/* ------------------------------------------------------------------------- * + * Code Scanning — axis L3 leaves. + * + * SOURCE OF TRUTH: live dotcom ERB `timeline_component.html.erb` dispatch (Code + * Scanning is NOT migrated to React — server-rendered ViewComponent). The + * authoritative nine cases are enumerated in `files/story-code-scanning.diff`: + * ALERT_CREATED, ALERT_APPEARED_IN_BRANCH, ALERT_REAPPEARED, + * ALERT_CLOSED_BECAME_FIXED, ALERT_CLOSED_BECAME_OUTDATED, ALERT_CLOSED_BY_USER, + * ALERT_REOPENED_BY_USER, ALERT_DISMISSAL_REQUESTED, ALERT_DISMISSAL_REVIEWED. + * + * DELTA — 9 ERB cases → 8 prototype leaves. The two non-fixed closure paths + * (`CLOSED_BECAME_OUTDATED`, system/config-deleted, no actor; and + * `CLOSED_BY_USER`, user actor) are folded into one `closed` leaf, so `closed` + * is actor-CAPABLE with presence data-driven (like LC `review_expired`). + * `CLOSED_BECAME_FIXED` maps to `fixed`. The delegated-dismissal pair is + * feature-gated (`delegated_dismissal_enabled?`) — dormant on most repos. + * + * ACTOR — the whole detection group (`detected`, `appeared`, `reappeared`) and + * `fixed` are SYSTEM events with no actor. `closed`/`reopened`/`dismissal_*` + * carry user actors. + * ------------------------------------------------------------------------- */ +export type CodeScanningEventType = + | 'detected' + | 'appeared' + | 'reappeared' + | 'fixed' + | 'closed' + | 'reopened' + | 'dismissal_requested' + | 'dismissal_reviewed' + +export const CODE_SCANNING_TAXONOMY: Record = { + detected: {category: 'findings', hasActor: false}, // ALERT_CREATED; system, no actor + appeared: {category: 'findings', hasActor: false}, // ALERT_APPEARED_IN_BRANCH; system, no actor + reappeared: {category: 'findings', hasActor: false}, // ALERT_REAPPEARED; system, no actor + fixed: {category: 'findings', hasActor: false}, // ALERT_CLOSED_BECAME_FIXED; system, no actor + closed: {category: 'status', hasActor: true}, // folds BECAME_OUTDATED (system) + CLOSED_BY_USER (user) + reopened: {category: 'status', hasActor: true}, // ALERT_REOPENED_BY_USER; user actor + dismissal_requested: {category: 'reviews', hasActor: true}, // feature-gated (delegated_dismissal_enabled?) + dismissal_reviewed: {category: 'reviews', hasActor: true}, // feature-gated +} + +/* ------------------------------------------------------------------------- * + * Dependabot — axis L3 leaves. + * + * SOURCE OF TRUTH: primer/react Storybook `Components/Timeline/Events/Dependabot` + * (`Timeline.dependabot.features.stories.tsx`), cross-checked against the + * github/primer audit `docs/timeline-audit/dependabot-timeline-events-for-figma.md` + * and the dotcom ERB it documents. The Storybook exports five Dependabot-specific + * event groups (EventOpened, EventFixed, EventDismissed, EventReopened, + * EventDismissalRequest) plus two SHARED groups (EventAssignment, EventCopilotWork) + * that are out of per-surface catalog scope (same treatment as the other surfaces). + * + * LEAF GRANULARITY: the Storybook groups consolidate rendering variants — `opened` + * covers Opened / OpenedFromPR / OpenedFromPush; `dismissed` folds manual (user) + * and auto/rule-based (Dependabot) dismissals; `reopened` folds manual reopen, + * Reintroduced, and Auto-Reopened. The delegated-dismissal group splits into three + * leaves for cross-surface parity with secret/code scanning (`dismissal_requested`, + * `dismissal_reviewed` [approved|denied], `dismissal_cancelled`). + * + * ACTOR (VERIFIED): every Dependabot leaf renders an actor — there are NO + * structurally actor-less events here. Dependabot renders ITSELF as a bot actor + * (square avatar + linked `dependabot` + `bot` Label) for opened/fixed/auto paths; + * user-initiated paths render a user actor. This is the key delta from code + * scanning, whose detection group is truly actor-less — the security surfaces do + * NOT share a "system detection has no actor" trait. (Corrects a prior inference + * that marked opened/fixed/reintroduced/auto_dismissed as `hasActor: false` and + * invented `severity_changed`/`advisory_updated` leaves that no source carries.) + * ------------------------------------------------------------------------- */ +export type DependabotEventType = + | 'opened' + | 'fixed' + | 'dismissed' + | 'reopened' + | 'dismissal_requested' + | 'dismissal_reviewed' + | 'dismissal_cancelled' + +export const DEPENDABOT_TAXONOMY: Record = { + opened: {category: 'findings', hasActor: true}, // Dependabot bot actor (source/PR/push variants) + fixed: {category: 'findings', hasActor: true}, // Dependabot bot actor + dismissed: {category: 'status', hasActor: true}, // folds manual (user) + auto/rule-based (Dependabot bot) + reopened: {category: 'status', hasActor: true}, // folds manual reopen (user) + Reintroduced + Auto-Reopened (Dependabot bot) + dismissal_requested: {category: 'reviews', hasActor: true}, // delegated closures; user actor + dismissal_reviewed: {category: 'reviews', hasActor: true}, // Approved | Denied; user actor + dismissal_cancelled: {category: 'reviews', hasActor: true}, // user actor +} + +/* ------------------------------------------------------------------------- * + * Issues — axis L3 leaves (the ISSUE-SCOPED subset of the classic + * issue/PR timeline). + * + * SOURCE OF TRUTH: dotcom (Rails) issue timeline. The running prototype stores + * issue and PR events in one flattened 56-entry registry map with no scope + * prefix; this catalog is the issue-applicable slice, excluding PR-only families + * (commits, merging, reviews) and PR-only lifecycle verbs (`convert_to_draft`, + * `ready_for_review`, `converted_from_draft`, `deployed`). + * + * CATEGORY FIT: `SURFACE_CATEGORIES.issue` offers `status`, `references`, + * `moderation` (toggleable) plus the always-audit `metadata` family. Every leaf + * here uses one of those — verified by `taxonomyCategoriesMatchSurface`. + * + * VISIBILITY: `metadata` leaves are `auditOnly` (labels, assignees, projects, + * type, rename, milestone — the audit-log detail that the curated timeline hides + * by default). The rest default to `primary`. + * + * OUTLIER: Issues just GA'd INLINE avatars (moving off the large left-gutter + * avatar), so its actor rendering diverges from the security surfaces — a + * per-surface rendering delta, not a taxonomy delta (see `isInlineAvatarSurface`). + * ------------------------------------------------------------------------- */ +export type IssueEventType = + | 'closed' + | 'reopened' + | 'pinned' + | 'unpinned' + | 'transferred' + | 'converted_to_discussion' + | 'marked_as_duplicate' + | 'referenced' + | 'cross_referenced' + | 'connected' + | 'disconnected' + | 'sub_issue_added' + | 'sub_issue_removed' + | 'parent_added' + | 'parent_removed' + | 'blocked_by_added' + | 'blocked_by_removed' + | 'blocking_added' + | 'blocking_removed' + | 'locked' + | 'unlocked' + | 'comment_deleted' + | 'comment_pinned' + | 'comment_unpinned' + | 'user_blocked' + | 'labeled' + | 'unlabeled' + | 'assigned' + | 'unassigned' + | 'renamed' + | 'milestoned' + | 'demilestoned' + | 'issue_type_added' + | 'issue_type_removed' + | 'issue_type_changed' + | 'added_to_project' + | 'removed_from_project' + | 'project_field_changed' + +export const ISSUE_TAXONOMY: Record = { + closed: {category: 'status', hasActor: true}, + reopened: {category: 'status', hasActor: true}, + pinned: {category: 'status', hasActor: true}, + unpinned: {category: 'status', hasActor: true}, + transferred: {category: 'status', hasActor: true}, + converted_to_discussion: {category: 'status', hasActor: true}, + marked_as_duplicate: {category: 'status', hasActor: true}, + referenced: {category: 'references', hasActor: true}, + cross_referenced: {category: 'references', hasActor: true}, + connected: {category: 'references', hasActor: true}, + disconnected: {category: 'references', hasActor: true}, + sub_issue_added: {category: 'references', hasActor: true}, + sub_issue_removed: {category: 'references', hasActor: true}, + parent_added: {category: 'references', hasActor: true}, + parent_removed: {category: 'references', hasActor: true}, + blocked_by_added: {category: 'references', hasActor: true}, + blocked_by_removed: {category: 'references', hasActor: true}, + blocking_added: {category: 'references', hasActor: true}, + blocking_removed: {category: 'references', hasActor: true}, + locked: {category: 'moderation', hasActor: true}, + unlocked: {category: 'moderation', hasActor: true}, + comment_deleted: {category: 'moderation', hasActor: true}, + comment_pinned: {category: 'moderation', hasActor: true}, + comment_unpinned: {category: 'moderation', hasActor: true}, + user_blocked: {category: 'moderation', hasActor: true}, + labeled: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + unlabeled: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + assigned: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + unassigned: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + renamed: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + milestoned: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + demilestoned: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + issue_type_added: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + issue_type_removed: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + issue_type_changed: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + added_to_project: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + removed_from_project: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, + project_field_changed: {category: 'metadata', visibility: 'auditOnly', hasActor: true}, +} + +/** + * Every surface catalog in one place — the combined taxonomy. Keyed by + * {@link EventScope}, so `SURFACE_TAXONOMIES['secret-scanning']['detected']` + * resolves a leaf's category + facets. `pull` is intentionally absent: PR events + * are out of scope for this pass and still live in the flattened registry only. + * + * This is the "all surfaces, one model" view the extrapolation targets — the + * validation test asserts every leaf's category is one the surface actually + * offers (`SURFACE_CATEGORIES`), so the model provably works across surfaces. + */ +export const SURFACE_TAXONOMIES = { + 'license-compliance': LICENSE_COMPLIANCE_TAXONOMY, + 'secret-scanning': SECRET_SCANNING_TAXONOMY, + 'code-scanning': CODE_SCANNING_TAXONOMY, + dependabot: DEPENDABOT_TAXONOMY, + issue: ISSUE_TAXONOMY, +} satisfies Partial>> + +/** Surfaces that have a formalized catalog in {@link SURFACE_TAXONOMIES}. */ +export type CatalogedScope = keyof typeof SURFACE_TAXONOMIES + +/** + * Derive the prototype's flattened union/registry key from the canonical + * `(scope, type)` pair. Snake-cases the (kebab) scope and joins with `_`, so + * `('license-compliance', 'opened')` → `license_compliance_opened`, matching the + * existing `PullConversationEventType` keys. This is the prototype storage + * projection — the emitted `data-event-type` stays the unscoped leaf. + */ +export function qualifyEventType(scope: EventScope, type: string): string { + return `${scope.replace(/-/g, '_')}_${type}` +} + +/** + * Inverse of {@link qualifyEventType}: recover the unscoped leaf `type` from a + * flattened union/registry key. Strips the snake-cased scope prefix when present + * (`('license-compliance', 'license_compliance_appeared_in_branch')` → + * `appeared_in_branch`); leaves an already-unscoped key untouched + * (`('pull', 'labeled')` → `labeled`, since pull/issue events carry no prefix). + * This is the projection `EventRow` uses to emit the unscoped `data-event-type` + * while `data-event-scope` carries the surface. + */ +export function unqualifyEventType(scope: EventScope, flattenedType: string): string { + const prefix = `${scope.replace(/-/g, '_')}_` + return flattenedType.startsWith(prefix) ? flattenedType.slice(prefix.length) : flattenedType +} + +/** Input for the `data-*` projection. */ +export interface EventDataAttributeInput { + scope: EventScope + /** Unscoped leaf type (e.g. `opened`). */ + type: string + category: EventCategory + visibility?: EventVisibility + /** Omit for actor-less events. */ + actorType?: ActorType +} + +/** The `data-*` attribute set emitted on a timeline event row. */ +export interface EventDataAttributes { + 'data-event-scope': string + 'data-event-type': string + 'data-event-category': string + 'data-event-visibility': EventVisibility + 'data-actor-type'?: ActorType +} + +/** + * Canonical serializer for the event `data-*` contract (primer#6654). The single + * place that turns the five axes into attribute strings — `EventRow` can delegate + * here so the contract has exactly one implementation. `data-event-type` is the + * **unscoped** leaf; the surface travels in `data-event-scope`. `data-actor-type` + * is omitted entirely for actor-less events rather than emitted empty. + */ +export function toEventDataAttributes({ + scope, + type, + category, + visibility, + actorType, +}: EventDataAttributeInput): EventDataAttributes { + const attributes: EventDataAttributes = { + 'data-event-scope': scope, + 'data-event-type': type, + 'data-event-category': category, + 'data-event-visibility': visibility ?? 'primary', + } + if (actorType) { + attributes['data-actor-type'] = actorType + } + return attributes +} + +/** + * Group a catalog's leaf types by category, preserving catalog order. This is + * the projection that regroups the surface-level Storybook stories by category + * (so `exception_added` + `licenses_added` share one "Status updates" story + * instead of shipping singletons) and can order the Viewing menu. Generated from + * the catalog, never hand-maintained. + */ +export function eventTypesByCategory( + taxonomy: Record, +): Partial> { + const groups: Partial> = {} + for (const type of Object.keys(taxonomy) as T[]) { + const {category} = taxonomy[type] + ;(groups[category] ??= []).push(type) + } + return groups +} + +/** + * Cross-surface guarantee: every leaf's category is one the surface actually + * offers. A category is valid for a surface when it is a {@link ToggleableCategory} + * listed in `SURFACE_CATEGORIES[scope]`, or the always-audit `metadata` / + * `conversation` families (which are never toggleable and apply everywhere). + * Returns the offending `type`s (empty ⇒ the catalog fits the surface). This is + * the check that proves the one categorization model works cleanly across every + * surface in {@link SURFACE_TAXONOMIES}. + */ +export function taxonomyCategoriesMatchSurface( + scope: CatalogedScope, + taxonomy: Record, +): string[] { + const offered = new Set(SURFACE_CATEGORIES[scope]) + const mismatches: string[] = [] + for (const [type, entry] of Object.entries(taxonomy)) { + const {category} = entry + const alwaysAudit = !isToggleableCategory(category) // metadata | conversation + if (!alwaysAudit && !offered.has(category)) { + mismatches.push(type) + } + } + return mismatches +} diff --git a/packages/react/src/Timeline/taxonomy/index.ts b/packages/react/src/Timeline/taxonomy/index.ts new file mode 100644 index 00000000000..a9653e10105 --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/index.ts @@ -0,0 +1,20 @@ +/** + * Timeline event taxonomy — public entry point. + * + * The single categorization model for the redesigned Primer Timeline, ported + * from the Timeline redesign prototype (github/prototyping, + * `src/packages/conversation/timeline`). Every consumer — the `data-*` event + * contract (github/primer#6654), the Storybook per-surface stories, and the + * planned Timeline Playground (github/primer#6664) — is a projection of this one + * catalog, not a separately maintained list. + * + * Not yet part of the public `@primer/react` export surface: this lands the + * source beside the Timeline component so stories and the playground can consume + * it. Promoting the projections (`toEventDataAttributes`, the catalogs) to the + * package's public API is deferred until the model is ratified. + */ + +export * from './surfaces' +export * from './actorType' +export * from './eventCategories' +export * from './eventTaxonomy' diff --git a/packages/react/src/Timeline/taxonomy/surfaces.ts b/packages/react/src/Timeline/taxonomy/surfaces.ts new file mode 100644 index 00000000000..e1dec5f3fb7 --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/surfaces.ts @@ -0,0 +1,48 @@ +/** + * Timeline surfaces — the owning-surface axis of the event taxonomy. + * + * This is the pure, render-free subset of the prototype's + * `TimelineSurfaceContext`: just the {@link TimelineSurface} union, the + * security-alert membership set, and its predicate. The prototype's React + * context and the surface-specific rendering predicates (avatar placement, + * gutterless layout, audit-log affordances) are intentionally left behind — the + * taxonomy only needs to name surfaces and know which ones are security alerts. + * + * Provenance: ported from the Timeline redesign prototype + * (github/prototyping, `src/packages/conversation/timeline`). See the Phase 3 + * Timeline Playground issue (github/primer#6664) and the `data-*` event contract + * (github/primer#6654). + */ + +/** + * Which conversation surface a timeline is rendered inside. This is axis L1 of + * the event taxonomy ({@link EventScope} aliases it). + */ +export type TimelineSurface = + | 'pull' + | 'issue' + | 'dependabot' + | 'code-scanning' + | 'secret-scanning' + | 'license-compliance' + +/** + * The security-alert surfaces — Dependabot alerts and the three scanning + * surfaces (code scanning, secret scanning, license compliance). They share a + * family of behaviours (no audit split, flat single-list record) that + * distinguish them from conversational PR/Issue timelines. Centralized so the + * predicate below and every taxonomy consumer agree on the same membership set. + */ +export const SECURITY_ALERT_SURFACES = [ + 'dependabot', + 'code-scanning', + 'secret-scanning', + 'license-compliance', +] as const satisfies readonly TimelineSurface[] + +const SECURITY_ALERT_SURFACE_SET: ReadonlySet = new Set(SECURITY_ALERT_SURFACES) + +/** Whether a surface is one of the security-alert surfaces. */ +export function isSecurityAlertSurface(surface: TimelineSurface): boolean { + return SECURITY_ALERT_SURFACE_SET.has(surface) +} From 49e9bb83d67e95e57dc21ff8f0acf0c13271ec90 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:46:53 -0700 Subject: [PATCH 2/5] Trim prototype-specific presentation from taxonomy module Keep the module to structural classification only. Remove the prototype Viewing-menu and preferences presentation that no taxonomy logic or test uses: refinements (hide resolved/outdated), category display metadata (CATEGORY_META labels/descriptions/icons), per-surface menu microcopy, and surface labels. This also drops the @primer/octicons-react import, so the taxonomy no longer pulls in icons. Generalize doc comments that pointed at prototype-internal files (the renderer bookend helper, the flattened event-type union, session-local diffs), so the module reads as standalone and canonical while keeping every citation to real github-ui and dotcom source. --- .../src/Timeline/taxonomy/eventCategories.ts | 196 +----------------- .../src/Timeline/taxonomy/eventTaxonomy.ts | 51 +++-- 2 files changed, 34 insertions(+), 213 deletions(-) diff --git a/packages/react/src/Timeline/taxonomy/eventCategories.ts b/packages/react/src/Timeline/taxonomy/eventCategories.ts index b84f107aed4..56958b751c7 100644 --- a/packages/react/src/Timeline/taxonomy/eventCategories.ts +++ b/packages/react/src/Timeline/taxonomy/eventCategories.ts @@ -25,30 +25,18 @@ * * The never-empty guarantee is *not* carried by any category. It is a * property of the lifecycle: the opening event and the terminal closing - * event are pinned in the renderer (see `computeLifecycleBookendIds` in - * `timelineVisibility.ts`), so filtering can never collapse a record to - * nothing — regardless of which categories are toggled off. + * event are pinned by the renderer as lifecycle bookends, so filtering can + * never collapse a record to nothing — regardless of which categories are + * toggled off. * * 2. **Visibility** — `primary` events render in the main timeline when their * category is toggled on; `auditOnly` events render exclusively in the * audit ("full activity") view. Conversation items render regardless. * - * See the project context doc §4 (categorization model) and §5 (events catalog). + * See github/primer docs/timeline-audit/ for the full model. */ -import { - CheckCircleIcon, - CommentIcon, - CrossReferenceIcon, - EyeIcon, - FoldIcon, - GitBranchIcon, - GitMergeIcon, - ReportIcon, - ShieldIcon, - type Icon, -} from '@primer/octicons-react' -import {SECURITY_ALERT_SURFACES, type TimelineSurface} from './surfaces' +import type {TimelineSurface} from './surfaces' /** * Categories the user can toggle on/off in the Viewing menu. @@ -58,8 +46,8 @@ import {SECURITY_ALERT_SURFACES, type TimelineSurface} from './surfaces' * real, toggleable category on alert surfaces — its detection and remediation * events (`dependabot_opened`, `dependabot_fixed`) * can be hidden like any other. The record still never empties, because the - * opening + terminal-close events are pinned as lifecycle bookends (see - * `computeLifecycleBookendIds`), independent of the Findings toggle. + * opening + terminal-close events are pinned by the renderer as lifecycle + * bookends, independent of the Findings toggle. * See {@link SURFACE_CATEGORIES} and {@link EventCategory}. */ export type ToggleableCategory = 'reviews' | 'merging' | 'status' | 'findings' | 'commits' | 'references' | 'moderation' @@ -90,111 +78,12 @@ export type EventCategory = ToggleableCategory | 'conversation' | 'metadata' */ export type EventVisibility = 'primary' | 'auditOnly' -export interface CategoryMeta { - /** Title-cased label shown in the Viewing menu. */ - label: string - /** One-line description shown under the label in the Viewing menu. */ - description: string - /** Leading visual for the menu item. */ - icon: Icon -} - -/** Display metadata for each toggleable category (Viewing menu). */ -export const CATEGORY_META: Record = { - reviews: { - label: 'Reviews', - description: 'Review requests, approvals, dismissals', - icon: EyeIcon, - }, - merging: { - label: 'Merging', - description: 'Merge events, queue, auto-merge', - icon: GitMergeIcon, - }, - status: { - label: 'Status updates', - description: 'Opened, closed, reopened, dismissed', - icon: CheckCircleIcon, - }, - findings: { - label: 'Findings', - description: 'Alerts detected, remediated, licenses', - icon: ShieldIcon, - }, - commits: { - label: 'Commits and branches', - description: 'Pushes, branch changes', - icon: GitBranchIcon, - }, - references: { - label: 'References', - description: 'Cross-references, mentions, relationships', - icon: CrossReferenceIcon, - }, - moderation: { - label: 'Moderation', - description: 'Locks, deleted comments, blocks', - icon: ReportIcon, - }, -} - -/** - * Per-surface description overrides for the Viewing menu. - * - * {@link CATEGORY_META} descriptions are the generic, cross-surface captions - * (used by the preferences page, which lists every category without scoping to a - * surface). The Viewing menu is always scoped to one surface, so it should - * describe a category in terms that are true *there* — e.g. a Dependabot alert - * never has "licenses" (that's a separate License-compliance surface), and its - * `reviews` - * are dismissal governance, not PR approvals. Only categories whose meaning - * differs by surface need an entry; anything unlisted falls back to the generic - * {@link CATEGORY_META} description via {@link categoryDescription}. - */ -const SURFACE_CATEGORY_DESCRIPTIONS: Partial>>> = { - pull: { - status: 'Opened, closed, reopened', - }, - issue: { - status: 'Opened, closed, reopened', - }, - dependabot: { - findings: 'Vulnerabilities detected, reintroduced, remediated', - status: 'Dismissed, reopened, auto-dismissed', - reviews: 'Dismissal requests and reviews', - }, - 'code-scanning': { - findings: 'Alerts detected, appeared in branches, and fixed', - status: 'Alerts closed and reopened', - reviews: 'Dismissal requests and reviews', - }, - 'secret-scanning': { - findings: 'Secrets detected and validity changes', - status: 'Closed, reopened, and push-protection bypasses', - reviews: 'Dismissal requests and reviews', - }, - 'license-compliance': { - findings: 'Violations opened, appeared in branches, and closed', - status: 'Policy exceptions and approved-license changes', - reviews: 'Close requests and reviews', - }, -} - -/** - * Description shown under a category in the Viewing menu for a given surface. - * Prefers the surface-specific override; falls back to the generic - * {@link CATEGORY_META} caption. - */ -export function categoryDescription(category: ToggleableCategory, surface: TimelineSurface): string { - return SURFACE_CATEGORY_DESCRIPTIONS[surface]?.[category] ?? CATEGORY_META[category].description -} - /** * Which toggleable categories apply to each surface, in menu display order. * The Viewing menu only renders categories applicable to the current surface * (6 for PRs, 3 for issues, 3 for Dependabot). Every listed category is a real * toggle; the never-empty guarantee is handled separately by lifecycle-bookend - * pinning (see `computeLifecycleBookendIds`), not by forcing any category on. + * pinning in the renderer, not by forcing any category on. */ export const SURFACE_CATEGORIES: Record = { pull: ['reviews', 'merging', 'status', 'commits', 'references', 'moderation'], @@ -216,9 +105,7 @@ export const ALL_TOGGLEABLE_CATEGORIES: readonly ToggleableCategory[] = [ 'moderation', ] -/** - * Display order of surfaces (used when listing which surfaces a setting hits). - */ +/** Canonical display order of surfaces. */ const SURFACE_ORDER: readonly TimelineSurface[] = [ 'pull', 'issue', @@ -228,76 +115,11 @@ const SURFACE_ORDER: readonly TimelineSurface[] = [ 'license-compliance', ] -/** Human label for a surface, used in settings copy. */ -export const SURFACE_LABEL: Record = { - pull: 'Pull requests', - issue: 'Issues', - dependabot: 'Alerts', - 'code-scanning': 'Code scanning', - 'secret-scanning': 'Secret scanning', - 'license-compliance': 'License compliance', -} - /** Which surfaces a category applies to (inverse of {@link SURFACE_CATEGORIES}). */ export function surfacesForCategory(category: ToggleableCategory): TimelineSurface[] { return SURFACE_ORDER.filter(surface => SURFACE_CATEGORIES[surface].includes(category)) } -/** - * Short "applies to" label for a category, e.g. "Pull requests", "Issues", or - * "All surfaces" when it spans every surface. Used by the preferences page to - * delimit which surface each setting affects. - */ -export function appliesToLabel(category: ToggleableCategory): string { - const surfaces = surfacesForCategory(category) - if (surfaces.length === SURFACE_ORDER.length) return 'All surfaces' - // Collapse the security-alert surfaces (Dependabot, code/secret scanning, - // license compliance) into one label — they share the security taxonomy. - const securitySet = new Set(SECURITY_ALERT_SURFACES) - const security = surfaces.filter(s => securitySet.has(s)) - const nonSecurity = surfaces.filter(s => !securitySet.has(s)) - const parts = nonSecurity.map(s => SURFACE_LABEL[s]) - if (security.length === securitySet.size) { - parts.push('Security alerts') - } else { - parts.push(...security.map(s => SURFACE_LABEL[s])) - } - return parts.join(', ') -} - -/** - * Refinements are PR-only toggles, separate from categories. They narrow the - * curated main timeline without removing a whole category. Audit view ignores - * them (it shows everything). - */ -export type Refinement = 'hideResolved' | 'hideOutdated' - -export interface RefinementMeta { - label: string - /** Leading visual for the menu item. */ - icon: Icon - /** Surfaces where this refinement is offered. */ - surfaces: readonly TimelineSurface[] -} - -export const REFINEMENT_META: Record = { - hideResolved: { - label: 'Hide resolved review comments', - icon: CommentIcon, - surfaces: ['pull'], - }, - hideOutdated: { - label: 'Hide outdated comments', - icon: FoldIcon, - surfaces: ['pull'], - }, -} - -/** Refinements offered on a given surface, in display order. */ -export function refinementsForSurface(surface: TimelineSurface): Refinement[] { - return (Object.keys(REFINEMENT_META) as Refinement[]).filter(r => REFINEMENT_META[r].surfaces.includes(surface)) -} - /** Whether a category is user-toggleable (appears in the Viewing menu). */ export function isToggleableCategory(category: EventCategory): category is ToggleableCategory { return category !== 'conversation' && category !== 'metadata' diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts index 1d79f4b69a6..a9445299d14 100644 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts @@ -26,11 +26,12 @@ * carries the surface, and keeping `type` unscoped lets a selector target a * lifecycle verb across every surface (`[data-event-type="closed"]`) or one * surface (`[data-event-scope="license-compliance"][data-event-type="closed"]`). - * The prototype's flattened `PullConversationEventType` union is a downstream - * storage artifact derived via {@link qualifyEventType}, not the canonical form. + * The flattened, surface-prefixed event-type union some stores use (e.g. + * `license_compliance_opened`) is a downstream storage artifact derived via + * {@link qualifyEventType}, not the canonical form. * - * Every consumer (the `data-*` attributes, the Storybook story grouping, and the - * prototype registry/union keys) is a **projection** of this one catalog, not a + * Every consumer (the `data-*` attributes, the Storybook story grouping, and any + * flattened registry/union keys) is a **projection** of this one catalog, not a * separately hand-maintained list. * * NOTE (value casing): axis values are currently mixed — scope is kebab @@ -60,9 +61,9 @@ export type EventScope = TimelineSurface * `packages/license-compliance-alerts/components/timeline/TimelineEventItem.tsx` * plus the Rails synthetic-event builder (`opened`, `appeared_in_branch`). * - * The prototype union (`repo-pull-conversation-types.ts`) drifts from this: it - * shortens `appeared_in_branch` to `appeared`, omits `licenses_added`, and - * flattens each leaf with a `license_compliance_` prefix. Reconcile to this set. + * A downstream flattened union can drift from this: e.g. shortening + * `appeared_in_branch` to `appeared`, omitting `licenses_added`, or prefixing + * each leaf with `license_compliance_`. Reconcile to this set. */ export type LicenseComplianceEventType = | 'opened' @@ -111,14 +112,13 @@ export interface EventTaxonomyEntry { export const LICENSE_COMPLIANCE_SCOPE: EventScope = 'license-compliance' /** - * The License Compliance catalog — declared ONCE. Categories mirror the running - * registry (`TimelineEventRegistry.tsx`): lifecycle + branch presence are - * `findings`, all review governance is `reviews`, and the two policy-change - * events are `status` (folding `exception_added` + `licenses_added` into one - * "Status updates" story instead of two singletons). The lifecycle bookends - * (`opened`, `closed`) are additionally pinned by `computeLifecycleBookendIds` - * so filtering never empties the record — a guarantee that lives in the renderer, - * not in this catalog. + * The License Compliance catalog — declared ONCE. Categories mirror the canonical + * registry: lifecycle + branch presence are `findings`, all review governance is + * `reviews`, and the two policy-change events are `status` (folding + * `exception_added` + `licenses_added` into one "Status updates" story instead of + * two singletons). The lifecycle bookends (`opened`, `closed`) are additionally + * pinned by the renderer as lifecycle bookends so filtering never empties the + * record — a guarantee that lives in the renderer, not in this catalog. */ export const LICENSE_COMPLIANCE_TAXONOMY: Record = { opened: {category: 'findings', hasActor: true}, // synthetic; system-identity actor, rendered linked (no "bot" Label) @@ -138,7 +138,6 @@ export const LICENSE_COMPLIANCE_TAXONOMY: Record Date: Thu, 16 Jul 2026 20:51:04 -0700 Subject: [PATCH 3/5] Reword catalog comments to canonical, drop prototype attribution The catalog leaves, DELTA notes, source-of-truth notes, and one test description framed the taxonomy as "the prototype's." Reword to canonical phrasing so the module reads as a standalone Timeline source. Preserve all delta counts, actor facts, and real source citations. Provenance banners and the surfaces.ts extraction note stay as-is. --- .../Timeline/taxonomy/eventTaxonomy.test.ts | 6 ++--- .../src/Timeline/taxonomy/eventTaxonomy.ts | 23 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts index e100dc80e35..56f49eb4461 100644 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts @@ -61,14 +61,14 @@ describe('LICENSE_COMPLIANCE_TAXONOMY', () => { }) describe('qualifyEventType', () => { - it('snake-cases the scope and matches the existing prototype key convention', () => { + it('snake-cases the scope and matches the flattened snake_case key convention', () => { expect(qualifyEventType('license-compliance', 'opened')).toBe('license_compliance_opened') expect(qualifyEventType('license-compliance', 'review_requested')).toBe('license_compliance_review_requested') }) it('produces the corrected key for the drifted branch event', () => { - // Prototype currently ships `license_compliance_appeared`; the real leaf is - // `appeared_in_branch`, so the reconciled key gains the full suffix. + // The real leaf is `appeared_in_branch` (not the shortened `appeared`), so + // the qualified key carries the full suffix. expect(qualifyEventType('license-compliance', 'appeared_in_branch')).toBe('license_compliance_appeared_in_branch') }) }) diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts index a9445299d14..a02ab401f8b 100644 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts @@ -37,9 +37,8 @@ * NOTE (value casing): axis values are currently mixed — scope is kebab * (`license-compliance`, from {@link TimelineSurface}), type is snake * (`review_requested`), category is a bare word (`reviews`). Normalizing all - * axis values to snake_case is an open ratification item (see the rollout doc); - * this module intentionally mirrors the values the running prototype emits today - * rather than forking casing unilaterally. + * axis values to snake_case is an open ratification item; this module mirrors + * the values the surfaces emit today rather than forking casing unilaterally. */ import type {EventCategory, EventVisibility, ToggleableCategory} from './eventCategories' @@ -139,10 +138,10 @@ export const LICENSE_COMPLIANCE_TAXONOMY: Record Date: Thu, 16 Jul 2026 21:00:02 -0700 Subject: [PATCH 4/5] Fix unqualifyEventType collision on issue_type_* leaves; cover untested helpers Review pass finding: unqualifyEventType stripped the scope prefix whenever the flattened key started with it, which corrupted the three raw issue leaves whose names begin with the `issue_` scope token (issue_type_added / issue_type_removed / issue_type_changed became type_added etc.). This broke the documented "already-unscoped key untouched" guarantee. Strip the prefix only when the remainder is a real leaf of the scope's catalog, so raw issue leaves pass through unchanged while a genuinely qualified key still reverses. Add a regression test plus an Issue-leaf round-trip test. Also add coverage for three exported helpers that had branching logic but no direct tests: actorTypeForLogin, surfacesForCategory, isSecurityAlertSurface. --- .../Timeline/taxonomy/eventTaxonomy.test.ts | 86 ++++++++++++++++++- .../src/Timeline/taxonomy/eventTaxonomy.ts | 17 +++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts index 56f49eb4461..d442a1aa502 100644 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts @@ -19,7 +19,14 @@ import { type CodeScanningEventType, type CatalogedScope, } from './eventTaxonomy' -import {SURFACE_CATEGORIES, isToggleableCategory} from './eventCategories' +import { + SURFACE_CATEGORIES, + ALL_TOGGLEABLE_CATEGORIES, + isToggleableCategory, + surfacesForCategory, +} from './eventCategories' +import {actorTypeForLogin} from './actorType' +import {SECURITY_ALERT_SURFACES, isSecurityAlertSurface} from './surfaces' const LC_TYPES = Object.keys(LICENSE_COMPLIANCE_TAXONOMY) as LicenseComplianceEventType[] @@ -90,6 +97,23 @@ describe('unqualifyEventType', () => { expect(unqualifyEventType(LICENSE_COMPLIANCE_SCOPE, flattened)).toBe(type) } }) + + it('leaves raw issue leaves that start with the scope token untouched', () => { + // Regression: the `issue` scope prefix (`issue_`) collides with three real + // unscoped leaves. A naive prefix strip corrupts them to `type_added` etc.; + // they must pass through unchanged because they are already unscoped. + for (const leaf of ['issue_type_added', 'issue_type_removed', 'issue_type_changed'] as const) { + expect(unqualifyEventType('issue', leaf)).toBe(leaf) + // …while a genuinely qualified key still reverses cleanly. + expect(unqualifyEventType('issue', qualifyEventType('issue', leaf))).toBe(leaf) + } + }) + + it('round-trips with qualifyEventType for every Issue leaf', () => { + for (const type of Object.keys(ISSUE_TAXONOMY)) { + expect(unqualifyEventType('issue', qualifyEventType('issue', type))).toBe(type) + } + }) }) describe('toEventDataAttributes', () => { @@ -233,3 +257,63 @@ describe('SURFACE_TAXONOMIES (cross-surface model)', () => { expect(actorFlags).toEqual(everyLeafHasActor) }) }) + +describe('actorTypeForLogin', () => { + it('treats a missing login as a human user', () => { + expect(actorTypeForLogin(undefined)).toBe('user') + expect(actorTypeForLogin('')).toBe('user') + }) + + it('classifies any `[bot]`-suffixed login as a bot, case-insensitively', () => { + expect(actorTypeForLogin('renovate[bot]')).toBe('bot') + expect(actorTypeForLogin('Some-App[BOT]')).toBe('bot') + }) + + it('classifies known first-party automation logins as bots', () => { + for (const login of ['dependabot', 'github-actions', 'github-license-compliance', 'copilot', 'hubot']) { + expect(actorTypeForLogin(login)).toBe('bot') + expect(actorTypeForLogin(login.toUpperCase())).toBe('bot') + } + }) + + it('classifies an ordinary login as a human user', () => { + expect(actorTypeForLogin('octocat')).toBe('user') + // A human whose name merely contains "bot" is not a bot. + expect(actorTypeForLogin('robotta')).toBe('user') + }) +}) + +describe('surfacesForCategory', () => { + it('is the inverse of SURFACE_CATEGORIES and returns surfaces in canonical order', () => { + // `merging` is a pull-only category. + expect(surfacesForCategory('merging')).toEqual(['pull']) + // `findings` is shared by exactly the four security-alert surfaces. + expect(surfacesForCategory('findings')).toEqual([ + 'dependabot', + 'code-scanning', + 'secret-scanning', + 'license-compliance', + ]) + }) + + it('agrees with SURFACE_CATEGORIES for every surface/category pair', () => { + for (const category of ALL_TOGGLEABLE_CATEGORIES) { + for (const surface of surfacesForCategory(category)) { + expect(SURFACE_CATEGORIES[surface]).toContain(category) + } + } + }) +}) + +describe('isSecurityAlertSurface', () => { + it('is true for exactly the four security-alert surfaces', () => { + for (const surface of SECURITY_ALERT_SURFACES) { + expect(isSecurityAlertSurface(surface)).toBe(true) + } + }) + + it('is false for the conversational surfaces', () => { + expect(isSecurityAlertSurface('pull')).toBe(false) + expect(isSecurityAlertSurface('issue')).toBe(false) + }) +}) diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts index a02ab401f8b..4c1e736792a 100644 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts @@ -392,16 +392,29 @@ export function qualifyEventType(scope: EventScope, type: string): string { /** * Inverse of {@link qualifyEventType}: recover the unscoped leaf `type` from a - * flattened union/registry key. Strips the snake-cased scope prefix when present + * flattened union/registry key. Strips the snake-cased scope prefix when the + * remainder is a real leaf of that scope's catalog * (`('license-compliance', 'license_compliance_appeared_in_branch')` → * `appeared_in_branch`); leaves an already-unscoped key untouched * (`('pull', 'labeled')` → `labeled`, since pull/issue events carry no prefix). + * + * The catalog check guards the scope-name collision: some `issue` leaves begin + * with the scope token itself (`issue_type_added`, `issue_type_removed`, + * `issue_type_changed`). A naive prefix strip would corrupt those raw leaves to + * `type_added` etc.; requiring the remainder to be a cataloged leaf keeps them + * untouched while still reversing a genuinely qualified key + * (`issue_issue_type_added` → `issue_type_added`). + * * This is the projection a row renderer uses to emit the unscoped `data-event-type` * while `data-event-scope` carries the surface. */ export function unqualifyEventType(scope: EventScope, flattenedType: string): string { const prefix = `${scope.replace(/-/g, '_')}_` - return flattenedType.startsWith(prefix) ? flattenedType.slice(prefix.length) : flattenedType + if (!flattenedType.startsWith(prefix)) return flattenedType + const remainder = flattenedType.slice(prefix.length) + const catalog = SURFACE_TAXONOMIES[scope as CatalogedScope] as Record | undefined + if (catalog && !(remainder in catalog)) return flattenedType + return remainder } /** Input for the `data-*` projection. */ From f580b33e19cc18081915329c0022dcff70614306 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:13:41 -0700 Subject: [PATCH 5/5] Point data-* contract references at github/primer#6664, not the parity epic The data-* event contract (data-event-type / data-event-scope / data-actor-type) is owned by github/primer#6664 (Phase 3), whose "category taxonomy and data-* tagging" section is the actual deliverable. github/primer#6654 is the broad parity epic, not the contract. Fix the citations in all six banners plus the toEventDataAttributes serializer note, and relabel #6654 as the parent epic. --- packages/react/src/Timeline/taxonomy/actorType.ts | 8 ++++---- packages/react/src/Timeline/taxonomy/eventCategories.ts | 6 +++--- packages/react/src/Timeline/taxonomy/eventTaxonomy.ts | 8 ++++---- packages/react/src/Timeline/taxonomy/index.ts | 4 ++-- packages/react/src/Timeline/taxonomy/surfaces.ts | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/react/src/Timeline/taxonomy/actorType.ts b/packages/react/src/Timeline/taxonomy/actorType.ts index 954b6529fcb..521405f8dea 100644 --- a/packages/react/src/Timeline/taxonomy/actorType.ts +++ b/packages/react/src/Timeline/taxonomy/actorType.ts @@ -1,15 +1,15 @@ /** * Ported from the Timeline redesign prototype (github/prototyping, * src/packages/conversation/timeline). Backs the taxonomy model documented in - * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3 - * Timeline Playground), github/primer#6654 (data-* event contract), - * primer/react#8075 (License Compliance stories). + * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3: + * Timeline Playground, taxonomy, and data-* tagging), parent epic + * github/primer#6654, primer/react#8075 (License Compliance stories). */ /** * Coarse actor classification, surfaced as the `data-actor-type` attribute on * event rows (mirrors the Primer Timeline `data-*` convention from - * github/primer#6654, alongside `data-event-type` / `data-event-scope`). + * github/primer#6664, alongside `data-event-type` / `data-event-scope`). * * "bot" covers GitHub apps and first-party automation (Dependabot, Actions, * Copilot, Hubot) plus any `…[bot]` login; everything else is a human "user". diff --git a/packages/react/src/Timeline/taxonomy/eventCategories.ts b/packages/react/src/Timeline/taxonomy/eventCategories.ts index 56958b751c7..c3acfdc0900 100644 --- a/packages/react/src/Timeline/taxonomy/eventCategories.ts +++ b/packages/react/src/Timeline/taxonomy/eventCategories.ts @@ -1,9 +1,9 @@ /** * Ported from the Timeline redesign prototype (github/prototyping, * src/packages/conversation/timeline). Backs the taxonomy model documented in - * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3 - * Timeline Playground), github/primer#6654 (data-* event contract), - * primer/react#8075 (License Compliance stories). + * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3: + * Timeline Playground, taxonomy, and data-* tagging), parent epic + * github/primer#6654, primer/react#8075 (License Compliance stories). */ /** diff --git a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts index 4c1e736792a..348910b4c6c 100644 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts @@ -1,9 +1,9 @@ /** * Ported from the Timeline redesign prototype (github/prototyping, * src/packages/conversation/timeline). Backs the taxonomy model documented in - * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3 - * Timeline Playground), github/primer#6654 (data-* event contract), - * primer/react#8075 (License Compliance stories). + * github/primer docs/timeline-audit/. Related: github/primer#6664 (Phase 3: + * Timeline Playground, taxonomy, and data-* tagging), parent epic + * github/primer#6654, primer/react#8075 (License Compliance stories). */ /** @@ -438,7 +438,7 @@ export interface EventDataAttributes { } /** - * Canonical serializer for the event `data-*` contract (primer#6654). The single + * Canonical serializer for the event `data-*` contract (primer#6664). The single * place that turns the five axes into attribute strings; a row renderer can * delegate here so the contract has exactly one implementation. `data-event-type` * is the **unscoped** leaf; the surface travels in `data-event-scope`. `data-actor-type` diff --git a/packages/react/src/Timeline/taxonomy/index.ts b/packages/react/src/Timeline/taxonomy/index.ts index a9653e10105..560bdd7f50c 100644 --- a/packages/react/src/Timeline/taxonomy/index.ts +++ b/packages/react/src/Timeline/taxonomy/index.ts @@ -4,8 +4,8 @@ * The single categorization model for the redesigned Primer Timeline, ported * from the Timeline redesign prototype (github/prototyping, * `src/packages/conversation/timeline`). Every consumer — the `data-*` event - * contract (github/primer#6654), the Storybook per-surface stories, and the - * planned Timeline Playground (github/primer#6664) — is a projection of this one + * contract, the Storybook per-surface stories, and the + * planned Timeline Playground (all github/primer#6664) — is a projection of this one * catalog, not a separately maintained list. * * Not yet part of the public `@primer/react` export surface: this lands the diff --git a/packages/react/src/Timeline/taxonomy/surfaces.ts b/packages/react/src/Timeline/taxonomy/surfaces.ts index e1dec5f3fb7..9634de12341 100644 --- a/packages/react/src/Timeline/taxonomy/surfaces.ts +++ b/packages/react/src/Timeline/taxonomy/surfaces.ts @@ -10,8 +10,8 @@ * * Provenance: ported from the Timeline redesign prototype * (github/prototyping, `src/packages/conversation/timeline`). See the Phase 3 - * Timeline Playground issue (github/primer#6664) and the `data-*` event contract - * (github/primer#6654). + * Timeline Playground issue and the `data-*` event contract (both + * github/primer#6664). */ /**