diff --git a/eslint.config.mjs b/eslint.config.mjs index 22647924d8e..e3534d09a44 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -421,14 +421,6 @@ 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 deleted file mode 100644 index 521405f8dea..00000000000 --- a/packages/react/src/Timeline/taxonomy/actorType.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 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, 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#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". - * 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 deleted file mode 100644 index c3acfdc0900..00000000000 --- a/packages/react/src/Timeline/taxonomy/eventCategories.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * 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, taxonomy, and data-* tagging), parent epic - * github/primer#6654, 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 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 github/primer docs/timeline-audit/ for the full model. - */ - -import 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 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' - -/** - * 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' - -/** - * 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 in the renderer, 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', -] - -/** Canonical display order of surfaces. */ -const SURFACE_ORDER: readonly TimelineSurface[] = [ - 'pull', - 'issue', - 'dependabot', - 'code-scanning', - 'secret-scanning', - '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)) -} - -/** 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 deleted file mode 100644 index d442a1aa502..00000000000 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * 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, - 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[] - -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 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', () => { - // 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') - }) -}) - -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) - } - }) - - 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', () => { - 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) - }) -}) - -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 deleted file mode 100644 index 348910b4c6c..00000000000 --- a/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts +++ /dev/null @@ -1,507 +0,0 @@ -/** - * 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, taxonomy, and data-* tagging), parent epic - * github/primer#6654, 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 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 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 - * (`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; this module mirrors - * the values the surfaces emit 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`). - * - * 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' - | '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 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) - 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). - * - * DELTA — 5 canonical cases → 7 catalog leaves. This catalog fans the switch - * out to finer wire types: `Resolution` splits into `closed` + `reopened` - * (the case branches on `resolution.type === 'reopened'`), and it 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: - * 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 catalog 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 classic issue/PR timeline - * combines both event sets in one stream; 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 a 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 a - * flat store's per-event keys. This is the 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 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, '_')}_` - 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. */ -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#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` - * 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 deleted file mode 100644 index 560bdd7f50c..00000000000 --- a/packages/react/src/Timeline/taxonomy/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * 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, 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 - * 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 deleted file mode 100644 index 9634de12341..00000000000 --- a/packages/react/src/Timeline/taxonomy/surfaces.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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 and the `data-*` event contract (both - * github/primer#6664). - */ - -/** - * 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) -}