diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css new file mode 100644 index 00000000000..956c480a53c --- /dev/null +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css @@ -0,0 +1,107 @@ +.RealisticTimeline { + /* GitHub renders the alert timeline at most 1012px wide in product surfaces. */ + max-width: 1012px; +} + +/* Story-only scaffolding: each variant is wrapped in its own
with a + small caption heading ABOVE the event, so the event row itself (badge + + inline actor + body) renders exactly as it would in product — the caption + never sits inside Timeline.Body. Variants stay scannable like a Figma + component set. Not part of the faithful event. */ +.Variant { + margin-bottom: var(--base-size-24); +} + +.VariantLabel { + margin: 0 0 var(--base-size-4); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Inline user-actor avatar in the body. Live `TimelineEventWithActor` + (`events/shared.tsx`) renders a 20px CIRCLE `GitHubAvatar` immediately before + the login — note this surface uses 20px, NOT the 16px of the secret-scanning + timeline. */ +.InlineAvatar { + vertical-align: middle; + margin-right: var(--base-size-4); +} + +/* Bold actor name for the non-link actor shapes — live `shared.tsx` renders the + login as a bold `` when there is no `actor.url` + (and for the bot shape, before the secondary "bot" Label). */ +/* Actor name — shared by the linked actor (``), the bold non-link actor, + and the bot login. Rendered PLAIN INLINE (no flex wrapper) so it sits on the + same vertically-centered line as the avatar, badge and summary, matching the + Issues / Dependabot rows. Semibold + `fgColor-default`: the bold weight is the + non-color differentiator that satisfies the axe `link-in-text-block` rule, so + the linked variant stays a11y-safe without an underline. */ +.ActorName { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); + margin-right: var(--base-size-4); +} + +/* Accent on hover for the linked actor (no-op on the plain-span shapes), + mirroring the Dependabot `LinkWithBoldStyle` hover. */ +.ActorName:hover { + color: var(--fgColor-accent); +} + +/* Secondary "bot" Label spacing — live `shared.tsx` renders the bot Label with + a leading `ml-1`. */ +.BotLabel { + margin-left: var(--base-size-4); +} + +/* Muted action verb (e.g. "opened this alert"). Live `shared.tsx` renders it as + ``. */ +.ActionText { + color: var(--fgColor-muted); +} + +/* Muted relative timestamp — live `shared.tsx` renders a plain `RelativeTime` + with no link wrapper (muted text only), matching the secret-scanning and + Dependabot timelines. */ +.Timestamp { + color: var(--fgColor-muted); +} + +/* Comment sub-row — live `EventComment` (`events/shared.tsx`) renders a + `Stack` (mt-1) with a 16px muted `NoteIcon` + an `f6` muted comment span. */ +.CommentRow { + display: flex; + align-items: center; + margin-top: var(--base-size-4); + font-size: var(--text-body-size-small); + color: var(--fgColor-muted); +} + +.CommentRowIcon { + margin-right: var(--base-size-8); + color: var(--fgColor-muted); +} + +/* PR-link sub-row — live `PullRequestLink` (`events/shared.tsx`) renders a 16px + success-green `GitPullRequestIcon` + an `f6` ``. */ +.PrRow { + display: inline-flex; + align-items: center; + margin-top: var(--base-size-4); + font-size: var(--text-body-size-small); +} + +.PrIcon { + margin-right: var(--base-size-8); + color: var(--fgColor-success); +} + +/* Sub-row link forced to the default foreground color, matching live + `defaultColorLink`. Rendered with the `inline` prop (underline) at the call + site so it stays distinguishable for the axe `link-in-text-block` rule. */ +.SubRowLink { + color: var(--fgColor-default) !important; +} diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx new file mode 100644 index 00000000000..9c62c87ffa3 --- /dev/null +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx @@ -0,0 +1,762 @@ +import type {Meta} from '@storybook/react-vite' +import type {ComponentProps} from '../utils/types' +import {FeatureFlags} from '../FeatureFlags' +import Timeline from './Timeline' +import { + CheckIcon, + CircleSlashIcon, + CommentIcon, + GitBranchIcon, + GitPullRequestIcon, + LawIcon, + NoteIcon, + ShieldCheckIcon, + ShieldIcon, + XIcon, +} from '@primer/octicons-react' +import type React from 'react' +import Avatar from '../Avatar' +import BranchName from '../BranchName' +import {Button} from '../Button' +import Label from '../Label' +import Link from '../Link' +import Octicon from '../Octicon' +import RelativeTime from '../RelativeTime' +import classes from './Timeline.license-compliance.features.stories.module.css' + +/** + * License Compliance alert Timeline event examples (Phase 2 of github/primer#6663). + * + * These stories recreate GitHub's live license-compliance-alert timeline events + * using the Primer `Timeline` compositional slots. They mirror the established + * Issues (`Timeline.issues.features.stories.tsx`), Dependabot + * (`Timeline.dependabot.features.stories.tsx`) and Secret Scanning + * (`Timeline.secret-scanning.features.stories.tsx`) stories and are sourced from + * the `timeline-audit` inventory + * (`license-compliance-timeline-events-for-figma.md`), verified against the live + * React implementation. + * + * SOURCE OF TRUTH — License Compliance is FULLY React (NOT ERB), like Secret + * Scanning. The alert show page is a React SPA in `github/github-ui`, package + * `packages/license-compliance-alerts/`. The timeline is rendered by + * `components/timeline/AlertTimeline.tsx`, which maps each event through + * `components/timeline/TimelineEventItem.tsx`, whose `switch (event.type)` is + * the authoritative dispatch. The actual badge variant + octicon + copy for each + * event live in the per-event components under `components/timeline/events/`, + * which compose Primer React `Timeline` + `Timeline.Badge variant=` via two + * shared wrappers in `events/shared.tsx`: + * - `TimelineEventWithActor` — renders the event `actor` INLINE (a 20px circle + * `GitHubAvatar` + login), then the muted action verb + `RelativeTime`. + * - `TimelineEventWithoutActor` — no actor; muted action verb + `RelativeTime` + * only (used by the synthetic `appeared_in_branch` event). + * + * The authoritative `switch (event.type)` dispatches NINE event types + * (`types/alerts.ts` `AlertEventType`): `opened`, `appeared_in_branch`, + * `review_requested`, `review_denied`, `review_approved`, `review_expired`, + * `exception_added`, `licenses_added`, `closed`. `opened` and + * `appeared_in_branch` are SYNTHETIC events created by Rails. The `default` case + * renders `null` (unknown types render nothing). + * + * NO SYSTEM "GitHub" ACTOR ON THIS SURFACE: unlike the secret-scanning timeline + * (which has a `MarkGithubIcon` + "GitHub" system actor for its Created event), + * EVERY license-compliance actor event flows through `TimelineEventWithActor`, + * which renders a user/bot actor — never a `MarkGithubIcon` system label. So + * this file intentionally does NOT define a `GitHubActor` helper. + * + * SCOPE: These are Storybook-only examples by design. They are intentionally + * NOT wired into components-json / the primer.style docs page (do NOT add this + * file to `Timeline.docs.json` or `build.ts`). Individual timeline events are + * not consumer-facing components — the primer.style Timeline page reflects the + * base `Timeline` component's own stories, and any docs-site representation is a + * Phase 3 consideration via base-component story changes, out of scope here. + * + * FUTURE FILTERING (taxonomy still open — github/primer#6663): category + * `data-*` attributes (e.g. `data-event-category="created"`) will attach to each + * `Timeline.Item` below so stories can be filtered/grouped by event family. We + * intentionally do NOT add them yet to avoid baking in a taxonomy. + * + * PROOF-OF-PATTERN HISTORY: this file landed first as an `opened`-only + * scaffold to validate the template, then grew to the full nine-group set. All + * groups are verified against the live per-event components in + * `events/` and, for the two synthetic events, against the Rails controller + * `app/controllers/repos/license_compliance_alerts_controller.rb` (the synthetic + * `opened` / `appeared_in_branch` builder). + * + * DORMANT CODE (verified, intentionally NOT built): `AppearedInBranchEvent` + * renders a `PullRequestLink` sub-row whenever its event body carries + * `pull_request_number` / `pull_request_title`, but the Rails + * `create_synthetic_branch_event` only ever writes `{branch_name}` — it never + * populates those PR fields (PR enrichment is applied ONLY to + * `review_requested`). So that sub-row is dead in production; we build only the + * branch-name variant. Likewise `review_expired` uses `TimelineEventWithActor` + * but expiry is automatic and Rails attaches no actor, so it renders actor-less + * (its copy "Request to close expired" is a standalone capitalized sentence). + * + * BADGE COLORS (live per-event components): success (green) `ShieldIcon` — + * `opened`; done (purple) `ShieldCheckIcon` — `closed`. Every other group + * renders a gray DEFAULT badge (`badgeVariant` undefined → `` + * with a muted icon, no solid fill). We render those bare, matching the + * secret-scanning / Dependabot convention (NOT the Issues + * `--timelineBadge-bgColor` hook). + * + * ACCESSIBILITY NOTE: the only in-text `` on this surface is the actor + * link (rendered when `actor.url` is present). Live `shared.tsx` styles it + * SEMIBOLD + default foreground color (`actorLink`); the bold weight keeps it + * distinguishable from the surrounding muted text without relying on color, so + * it satisfies the axe `link-in-text-block` high-contrast rule. Any future + * in-text link added here must likewise be `inline` (underline) or bold. + */ + +const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' +const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/hubot' +// The license-compliance system bot — Rails attributes every system event +// (Opened, Review-expired) to `github-license-compliance[bot]` when user_id<=0. +const LICENSE_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/9919?s=40&v=4' + +/** + * User actor — live `TimelineEventWithActor` (`events/shared.tsx`). Renders a + * 20px CIRCLE `GitHubAvatar` followed by the login. Rendered as PLAIN INLINE + * elements (avatar with `vertical-align: middle` immediately followed by an + * inline ``/span) — NO `inline-flex` wrapper — so the avatar, login, badge + * and trailing summary all sit on one cleanly vertically-centered line, exactly + * like the working Issues (#8070) / Dependabot (#8071) rows. The shape is derived + * from the login, matching live `shared.tsx`: + * - login ends with `[bot]` → the suffix is stripped and the (UNLINKED) display + * login renders bold + a secondary "bot" `Label` (live ignores `url` for bots). + * - otherwise `url` present → inline `` login, semibold + `fgColor-default` + * (the bold weight is the non-color differentiator that satisfies + * `link-in-text-block`). + * - otherwise → bold login text. + */ +const UserActor = ({login = 'monalisa', src = MONALISA_AVATAR, url}: {login?: string; src?: string; url?: string}) => { + // Derive the bot shape from the login itself, exactly like live `shared.tsx` + // (`isBot = login.endsWith('[bot]')`, with the suffix stripped for display). + const isBot = login.endsWith('[bot]') + const displayLogin = isBot ? login.replace(/\[bot\]$/i, '') : login + const avatar = + if (isBot) { + // Live renders bots UNLINKED (the bot branch ignores `actor.url`): avatar + + // bold display login + a secondary "bot" Label. + return ( + <> + {avatar} + {displayLogin} + + + ) + } + if (url) { + return ( + <> + {avatar} + + {displayLogin} + + + ) + } + return ( + <> + {avatar} + {displayLogin} + + ) +} + +// Muted relative timestamp. Live `shared.tsx` renders a plain `RelativeTime` +// with no link wrapper — muted text only (matching the secret-scanning and +// Dependabot timelines, unlike the Issues `Ago` deep-link). +const Time = ({date}: {date: string}) => ( + + + +) + +/** + * Optional comment sub-row — live `EventComment` (`events/shared.tsx`) renders a + * `` containing a + * 16px muted `NoteIcon` + an `f6` muted comment span. Shared by the review + * request / approve / deny events and the closed event. (Note this surface uses + * `NoteIcon`, NOT the secret-scanning `CommentIcon`.) + */ +const EventComment = ({children}: {children: React.ReactNode}) => ( +
+ + {children} +
+) + +/** + * PR link sub-row — live `PullRequestLink` (`events/shared.tsx`): a 16px + * success-green `GitPullRequestIcon` + an `f6` `` reading `{title} + * #{number}`. Only the `review_requested` event renders this (the Rails + * controller enriches that event with `pull_request_number` / `_title` when the + * alert has an associated PR). The link is `inline` (underlined) here to satisfy + * the axe `link-in-text-block` rule — live uses a color-only `defaultColorLink` + * (an a11y debt we correct in the story). + */ +const PullRequestLink = ({number, title}: {number: number; title: string}) => ( + + + + {title} #{number} + + +) + +/** + * Inline "license policy" link — live `ExceptionAddedEvent` / `LicensesAddedEvent` + * embed a `` reading "license policy" inside the action sentence. Rendered + * `inline` (underlined) here to satisfy the axe `link-in-text-block` rule (live + * uses a color-only `defaultColorLink`). + */ +const PolicyLink = ({href = '../../settings/security_analysis'}: {href?: string}) => ( + + license policy + +) + +/** + * Story-only wrapper around each group's examples. Besides constraining the + * width (`.RealisticTimeline`), it swallows clicks on the demo links (actor + * profile, PR, license-policy) so navigating one of these placeholder `href`s + * doesn't kick the viewer out of the Storybook UI — the same guard the base + * `Timeline.features.stories.tsx` `WithActions` story uses. + */ +const Examples = ({children}: {children: React.ReactNode}) => ( +
{ + if ((e.target as HTMLElement).closest('a')) e.preventDefault() + }} + > + {children} +
+) + +export default { + title: 'Components/Timeline/Events/License Compliance', + component: Timeline, + subcomponents: { + 'Timeline.Item': Timeline.Item, + 'Timeline.Avatar': Timeline.Avatar, + 'Timeline.Badge': Timeline.Badge, + 'Timeline.Body': Timeline.Body, + 'Timeline.Break': Timeline.Break, + 'Timeline.Actions': Timeline.Actions, + }, + decorators: [ + // File-scoped: render every story in the future-state list semantics + // (`
    `/`
  1. `). The `primer_react_timeline_list_semantics` flag is merged + // on main; this opts these stories into the DOM the timeline will ship. + Story => ( + + + + ), + ], +} as Meta> + +/** + * The Opened event group — `AlertEventType.Opened` (audit § 1). + * + * Source: `case AlertEventType.Opened` in `TimelineEventItem.tsx`, rendered by + * `events/OpenedEvent.tsx`: + * `} + * actionText="opened this alert" badgeVariant="success" />` + * Badge: `ShieldIcon` on `success` (green). Copy is a fixed `"opened this + * alert"` + the relative time. This is a SYNTHETIC event created by Rails, but + * it still carries an `actor` (enriched by the Rails controller). + * + * ACTOR (security UNCERTAIN deep-dive resolved): the synthetic `opened` event is + * ALWAYS attributed to the license-compliance system bot, + * `github-license-compliance[bot]` (Rails `create_synthetic_opened_event`). Per + * live `shared.tsx`, a login ending in `[bot]` renders avatar + bold display + * login ("github-license-compliance") + a secondary "bot" Label — NOT a Link. + * There is no human "opened" actor, so a single bot variant is shown. + */ +export const EventOpened = () => ( + + {/* Opened — license-compliance system bot, ShieldIcon on success (green) */} +
    +

    Opened

    + + + + + + + {' '} + opened this alert + + +
    +
    +) + +/** + * The Appeared-in-branch event group — `AlertEventType.AppearedInBranch` + * (audit § 2). + * + * Source: `case AlertEventType.AppearedInBranch` → `events/AppearedInBranchEvent.tsx`, + * which uses `TimelineEventWithoutActor` (NO actor): `GitBranchIcon size={16}` + * on the default (gray) badge, copy "Appeared in branch", followed by a + * `BranchName` pill linking to the branch tree. This is a SYNTHETIC event. + * + * The component ALSO renders a `PullRequestLink` sub-row when its body has + * `pull_request_number` / `pull_request_title` — but per the Rails + * `create_synthetic_branch_event`, those fields are NEVER written for this event + * (only `{branch_name}`), so that sub-row is DORMANT and intentionally not built + * here. Single variant: the branch-name pill. + */ +export const EventAppearedInBranch = () => ( + + {/* Appeared in branch — actor-less, GitBranchIcon on the default (gray) + badge, with a BranchName pill. PR sub-row is dormant (see group doc). */} +
    +

    Appeared in branch

    + + + + + + + Appeared in branch{' '} + feature-branch{' '} + + + +
    +
    +) + +/** + * The Review-requested event group — `AlertEventType.ReviewRequested` + * (audit § 3). + * + * Source: `events/ReviewRequestedEvent.tsx` (`TimelineEventWithActor`): + * `CommentIcon size={16}` on the default (gray) badge. Copy is "requested to + * close" or, when the event body carries a `closure_reason`, "requested to close + * as {reason}". An optional `EventComment` sub-row renders the requester's + * comment. When the alert has an associated PR, the Rails controller enriches + * this event with `pull_request_number` / `_title`, so a `PullRequestLink` + * sub-row appears AND — for the latest review_requested event only — a primary + * "Review request" button. Live nests that button beside the PR link in a + * space-between Stack; we surface it via the `Timeline.Actions` right-controls + * slot (the established template convention). + * + * NOTE: the `closure_reason` text shown after "requested to close as …" is + * produced by the Go OLC service, not the local Rails/React code — its exact + * format (e.g. "amendment"-style label vs raw value) is unverified here. Confirm + * via a prod smoke-test; the example label below is illustrative. + */ +export const EventReviewRequested = () => ( + + {/* Requested to close — no reason, no PR, no comment */} +
    +

    Requested to close

    + + + + + + + {' '} + requested to close + + +
    + + {/* Requested to close as {reason}, with a requester comment sub-row */} +
    +

    Requested to close as a specific reason (with comment)

    + + + + + + + {' '} + requested to close as used in tests{' '} + + + +
    + + {/* Requested to close with an associated PR — PR link sub-row + the primary + "Review request" button (latest request only) in Timeline.Actions. */} +
    +

    Requested to close with a pull request (latest — shows Review request)

    + + + + + + + {' '} + requested to close + + + + + +
    +
    +) + +/** + * The Review-approved event group — `AlertEventType.ReviewApproved` + * (audit § 4). + * + * Source: `events/ReviewApprovedEvent.tsx` (`TimelineEventWithActor`): + * `CheckIcon size={16}` on the default (gray) badge, copy "approved closure + * request", with an optional `EventComment` sub-row. + */ +export const EventReviewApproved = () => ( + + {/* Approved closure request */} +
    +

    Approved closure request

    + + + + + + + {' '} + approved closure request + + +
    + + {/* Approved closure request — with reviewer comment */} +
    +

    Approved closure request (with comment)

    + + + + + + + {' '} + approved closure request + + +
    +
    +) + +/** + * The Review-denied event group — `AlertEventType.ReviewDenied` (audit § 5). + * + * Source: `events/ReviewDeniedEvent.tsx` (`TimelineEventWithActor`): + * `XIcon size={16}` on the default (gray) badge, copy "denied closure request", + * with an optional `EventComment` sub-row. + */ +export const EventReviewDenied = () => ( + + {/* Denied closure request */} +
    +

    Denied closure request

    + + + + + + + {' '} + denied closure request + + +
    + + {/* Denied closure request — with reviewer comment */} +
    +

    Denied closure request (with comment)

    + + + + + + + {' '} + denied closure request + + +
    +
    +) + +/** + * The Review-expired event group — `AlertEventType.ReviewExpired` (audit § 6). + * + * Source: `events/ReviewExpiredEvent.tsx` (`TimelineEventWithActor`): + * `CircleSlashIcon size={16}` on the default (gray) badge, copy "Request to close + * expired". Although a misleading test mock omits the actor, Rails sets the + * license-compliance system bot for these system events (`user_id<=0`), so it + * renders WITH the bot actor. No comment sub-row. Single variant. + */ +export const EventReviewExpired = () => ( + + {/* Request to close expired — license-compliance system bot, automatic expiry */} +
    +

    Request to close expired

    + + + + + + + {' '} + Request to close expired + + +
    +
    +) + +/** + * The Exception-added event group — `AlertEventType.ExceptionAdded` + * (audit § 7). + * + * Source: `events/ExceptionAddedEvent.tsx` (`TimelineEventWithActor`): + * `LawIcon size={16}` on the default (gray) badge. When the body has + * `package_manager` + `package_name`, the copy is "added {pm}/{pkg}" + (when a + * policy path is known) " to {license policy link}" + (when a repo name is + * known) " for {repo}". Otherwise it falls back to "created exception". + */ +export const EventExceptionAdded = () => ( + + {/* Full shape — package + policy link + repo name */} +
    +

    Added a package exception to the license policy

    + + + + + + + {' '} + + added npm/left-pad to for monalisa/octo-app + {' '} + + + +
    + + {/* Fallback shape — body missing package info */} +
    +

    Created exception (fallback)

    + + + + + + + {' '} + created exception + + +
    +
    +) + +/** + * The Licenses-added event group — `AlertEventType.LicensesAdded` (audit § 8). + * + * Source: `events/LicensesAddedEvent.tsx` (`TimelineEventWithActor`): + * `LawIcon size={16}` on the default (gray) badge. When the body has a + * `licenses` array, the copy is "added {licenses joined by ', '}" + (when a + * policy path is known) " to {license policy link}" + (when a repo name is + * known) " for {repo}". Otherwise it falls back to "added to approved licenses". + */ +export const EventLicensesAdded = () => ( + + {/* Full shape — licenses list + policy link + repo name */} +
    +

    Added licenses to the license policy

    + + + + + + + {' '} + + added MIT, Apache-2.0 to for monalisa/octo-app + {' '} + + + +
    + + {/* Fallback shape — body missing licenses array */} +
    +

    Added to approved licenses (fallback)

    + + + + + + + {' '} + added to approved licenses + + +
    +
    +) + +/** + * The Closed event group — `AlertEventType.Closed` (audit § 9). + * + * Source: `events/ClosedEvent.tsx` (`TimelineEventWithActor`): `ShieldCheckIcon + * size={16}` on the `done` (PURPLE) badge — the only colored gray-exempt event + * besides Opened. The copy comes from `getClosedActionText(closureReason, + * resolution)`. When a free-text `closureReason` is present it renders "closed as + * {reason}"; the live label set is `CLOSURE_REASON_TO_LABEL` + * (`license_compliance_dismissal_options.rb`): amendment, private package, + * inaccurate license, policy edited, fixed. The two resolution-driven fallbacks + * are "closed as outdated" (resolution `Outdated`) and "closed this alert" + * (default). An optional `EventComment` sub-row renders a closing comment. + */ +export const EventClosed = () => ( + + {/* Closed as amendment — with a closing comment */} +
    +

    Closed as amendment (with comment)

    + + + + + + + {' '} + closed as amendment + + +
    + + {/* Closed as private package */} +
    +

    Closed as private package

    + + + + + + + {' '} + closed as private package + + +
    + + {/* Closed as inaccurate license */} +
    +

    Closed as inaccurate license

    + + + + + + + {' '} + closed as inaccurate license{' '} + + + +
    + + {/* Closed as policy edited */} +
    +

    Closed as policy edited

    + + + + + + + {' '} + closed as policy edited + + +
    + + {/* Closed as fixed */} +
    +

    Closed as fixed

    + + + + + + + {' '} + closed as fixed + + +
    + + {/* Closed as outdated — resolution Outdated */} +
    +

    Closed as outdated

    + + + + + + + {' '} + closed as outdated + + +
    + + {/* Closed this alert — default (no reason / resolution) */} +
    +

    Closed this alert (default)

    + + + + + + + {' '} + closed this alert + + +
    +
    +)