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..521405f8dea --- /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, 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 new file mode 100644 index 00000000000..c3acfdc0900 --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/eventCategories.ts @@ -0,0 +1,126 @@ +/** + * 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 new file mode 100644 index 00000000000..d442a1aa502 --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.test.ts @@ -0,0 +1,319 @@ +/** + * 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 new file mode 100644 index 00000000000..348910b4c6c --- /dev/null +++ b/packages/react/src/Timeline/taxonomy/eventTaxonomy.ts @@ -0,0 +1,507 @@ +/** + * 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 new file mode 100644 index 00000000000..560bdd7f50c --- /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, 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 new file mode 100644 index 00000000000..9634de12341 --- /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 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) +}