From 5420e707a72f65377041574d938195695e32905f Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 20:09:28 +0200 Subject: [PATCH 01/24] docs: design spec for GitlabRoadmap component --- .gitignore | 4 +- ...6-07-10-gitlab-roadmap-component-design.md | 220 ++++++++++++++++++ 2 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-10-gitlab-roadmap-component-design.md diff --git a/.gitignore b/.gitignore index b21c707..15ead89 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ examples/*/static/gitlab-assets/ node_modules/.cache/ .env -external/ \ No newline at end of file +external/ +# Visual brainstorming companion +.superpowers/ diff --git a/docs/superpowers/specs/2026-07-10-gitlab-roadmap-component-design.md b/docs/superpowers/specs/2026-07-10-gitlab-roadmap-component-design.md new file mode 100644 index 0000000..2675bb2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-gitlab-roadmap-component-design.md @@ -0,0 +1,220 @@ +# Design: `` component + +**Status:** Approved (design phase) +**Date:** 2026-07-10 +**Pipeline:** 3 — `` JSX components (remark). See [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md). + +## Summary + +A new pipeline-3 component that embeds a GitLab **roadmap** — a timeline of +group **epics** or **milestones** — into a Docusaurus page. Like every other +component in this package, all data is fetched at build time and baked into +static HTML; the browser holds no token and makes no GitLab calls. + +The component renders in one of two layouts: + +- **`gantt`** (default) — horizontal bars on a time axis; scrolls sideways. +- **`timeline`** — a vertical spine of stacked cards; scrolls down only. Suited + to narrow docs columns and mobile. + +Because a static site is built once, there is **no "today" marker** — it would +be frozen at build time and go stale. Rendered content is **title + dates + +labels only**; no markdown descriptions are rendered, so this component never +touches `dangerouslySetInnerHTML` and stays clear of the XSS surface. + +## Data source + +GitLab's real roadmap renders **group epics** (Premium/Ultimate, group-level; +the Epics REST API is deprecated in 17.0 in favor of Work Items but still +functions via gitbeaker). **Milestones** carry the same start/due dates, are +available on the free tier, and exist at both project and group level. + +The component therefore takes a **`source` prop** with two fetch paths: + +- `source="epics"` → `GET /groups/:id/epics` (requires `group`). +- `source="milestones"` → group or project milestones (exactly one of + `group`/`project`). + +## Props API + +All attributes are **static literals** (rejected otherwise in +`src/remark/attributes.ts`), so data is fetched deterministically at build time. + +| Prop | Values | Default | Notes | +|---|---|---|---| +| `source` | `"epics"` \| `"milestones"` | `"epics"` | Chooses the fetch path | +| `group` | group path/id | — | Required for `epics`; one of `group`/`project` for `milestones` | +| `project` | project path/id | — | `milestones` only | +| `layout` | `"gantt"` \| `"timeline"` | `"gantt"` | Horizontal bars vs. vertical spine | +| `scale` | `"quarters"` \| `"months"` \| `"weeks"` | *auto* | Auto-selected from date span; prop overrides | +| `state` | `"opened"` \| `"closed"` \| `"all"` | `"opened"` | Normalized across epics/milestones | +| `labels` | comma-separated | — | Label filter (epics: API-side; milestones: client-side) | +| `from` / `to` | `YYYY-MM-DD` | *derived* | Explicit window; else min/max of item dates | +| `limit` | number | `50` | Capped at the 500 ceiling used by topics/labels | +| `order` | `"start"` \| `"due"` \| `"title"` | `"start"` | Sort key | +| `groupBy` | `"none"` \| `"label"` \| `"parent"` | `"none"` | `"parent"` = epic parent; renders section headings | +| `colorBy` | `"source"` \| `"label"` \| `"state"` | `"source"` | `source` = epic's own `color`; milestones fall back to `state` | +| `showProgress` | boolean | `true` | Progress fill overlay; epics only (milestones ignore) | +| `showLabels` | boolean | `false` | Inline label chips, reusing `src/components/scopedLabel.ts` | + +### Validation rules + +- `epics` without `group` → error. +- `milestones` with neither or both of `group`/`project` → error. +- Non-literal / unknown enum values → error with the same message style as the + existing `readOrder` / `readLayout` helpers in `fetchers.ts`. + +## Visual treatment (confirmed via mockups) + +- **Progress** renders as a darker fill inside a lighter bar (Gantt) / a small + meter on the card (timeline). Not a "60%" text. +- **Labels** render as inline colored chips next to the title when `showLabels`. +- **Grouped sections** render a heading per group; ungrouped output is a single + unnamed group. +- **No "today" marker** (static build; see Summary). + +## Architecture + +Standard pipeline-3 loop (`remark/index.ts` → `registry.ts` → fetcher → +`data` prop → pure component). Three new source areas: + +### 1. Domain types — `src/gitlab/types.ts` + +```ts +interface LabelRef { name: string; color: string; textColor: string; } + +interface RoadmapItemData { + id: number; + iid: number; + title: string; + state: "opened" | "closed"; + startDate: string | null; // YYYY-MM-DD + dueDate: string | null; + webUrl: string; + color?: string; // epic color; absent for milestones + progress?: number | null; // 0..100; epics only + parentId?: number | null; + parentTitle?: string | null; + labels: LabelRef[]; +} + +// Fully positioned model the component renders — no geometry math in React. +interface ScaleTick { label: string; offsetPct: number; } +interface RoadmapPositionedItem extends RoadmapItemData { + offsetPct: number; // bar left edge, 0..100 within [rangeStart,rangeEnd] + widthPct: number; // bar width, >0 +} +interface RoadmapGroup { key: string; title: string | null; items: RoadmapPositionedItem[]; } +interface RoadmapData { + source: "epics" | "milestones"; + scale: "quarters" | "months" | "weeks"; + rangeStart: string; // YYYY-MM-DD + rangeEnd: string; + ticks: ScaleTick[]; + groups: RoadmapGroup[]; +} +``` + +### 2. Geometry — `src/gitlab/roadmap.ts` (new, pure, no network) + +Owns all timeline math so the React components are dumb renderers and the logic +is unit-testable in isolation: + +- **Scale selection:** span ≤ ~3 months → `weeks`; ≤ ~1 year → `months`; else + `quarters`. Overridden by the `scale` prop. +- **Window:** `[from, to]` if given, else `[min(startDate), max(dueDate)]` across + items, snapped outward to the scale boundary. +- **Positioning:** each item → `offsetPct` / `widthPct` within the window. + Items with only one date are clamped to a half-open bar within range; items + with neither date are dropped upstream. +- **Ticks:** boundary ticks + labels for the chosen scale. +- **Grouping:** partition into `RoadmapGroup[]` by `groupBy` (`none` → one + unnamed group; `label` → one group per label; `parent` → by `parentTitle`). + +### 3. Client — `src/gitlab/client.ts` + +New thin gitbeaker wrappers, snake_case passthrough (normalized in the fetcher): + +- `getGroupEpics(group, { state, labels, orderBy, sort, perPage, maxPages })` → `Epics.all` +- `getGroupMilestones(group, opts)` → `GroupMilestones.all` +- `getProjectMilestones(project, opts)` → `ProjectMilestones.all` + +Pagination is bounded by the existing 500-item ceiling (`perPage` 100 × +`maxPages` 5) — do not raise it. + +### 4. Fetcher — `src/gitlab/fetchers.ts` + +`fetchRoadmap(ctx, attrs)`: + +1. Validate scope + enum literals (reuse the `readOrder`/`readLayout` error + style). +2. Fetch raw via the source path; normalize snake_case → `RoadmapItemData`: + - milestone `state` `active`/`closed` → `opened`/`closed`; + - epic `color`, `parent_id` → `parentId`, resolve `parentTitle` from the + fetched set when available; + - label names → `LabelRef` by resolving colors against the group/project + labels (reuse `getGroupLabels`/`getProjectLabels`); unknown labels get a + neutral default color. +3. Drop items with neither `startDate` nor `dueDate`. +4. Call `roadmap.ts` to select scale, compute window, position items, and group. +5. Wrap in `memo(...)`. **Cache key** includes `source`, scope (`group`/ + `project`), `state`, `labels`, `from`, `to`, `scale`, `order`, `groupBy`, + `limit`. `colorBy`, `showLabels`, and `layout` are presentational (read by the + component) and are **not** in the key. + +### 5. Components — `src/components/` + +- `GitlabRoadmap.tsx` — dispatches on `layout` → `RoadmapGantt` / `RoadmapTimeline`. + Both consume the same `RoadmapData`. Shared shape: `error → Fallback; + no data → null; else render`. Pure — no fetching, no hooks, no side effects. +- `RoadmapGantt.tsx` — scale header from `ticks`; per group a heading + rows of + positioned bars; progress fill; inline label chips. +- `RoadmapTimeline.tsx` — vertical spine; per group a heading + stacked cards + (title, date range, progress meter, labels). +- Styles in `src/components/styles.module.css` (typed via `src/css.d.ts`). +- `colorBy` resolves the bar/card tint: `source` → `item.color` (fallback to a + state color when absent), `label` → first label color, `state` → open/closed + palette. + +### 6. Registry & exports + +- `src/remark/registry.ts`: `GitlabRoadmap: fetchRoadmap`. +- `src/components/index.ts`: export `GitlabRoadmap`. +- `src/index.ts`: export the `RoadmapData` type. + +## Error handling + +Honors the `strict` option like every pipeline: a tier `403` (epics on free +tier), a bad scope, or an empty result **throws and aborts the build** in strict +mode, and **degrades to ``** otherwise. No new error channel. + +## Testing (TDD) + +- **`roadmap.test.ts`** (geometry, pure): scale-threshold selection, window + derivation + boundary snapping, positioning math, single-date clamping, + grouping partitions. The bulk of the logic lives here. +- **`fetchers` roadmap tests** (fake client): epics + milestones normalization, + state mapping, label-color resolution, scope validation, `strict` degrade path, + cache-key stability. +- **Component tests** (React Testing Library) for both layouts: queries by + role/text — group headings, item titles/links, progress fill present when + `showProgress`, label chips when `showLabels`, `error → Fallback`, + `no data → null`. +- No new e2e wiring beyond an example page; run `test/e2e/build.test.ts` + explicitly if the pipeline is touched. + +## Docs + +- README: a `` section with prop table + examples. +- `examples/site/docs/components/roadmap.md`: live example pages (both layouts, + both sources). + +## Non-goals (YAGNI) + +- No "today" marker (static build). +- No markdown/description rendering (title-only roadmap). +- No Work Items API migration (Epics API still works via gitbeaker; revisit if + it is removed). +- No interactive expand/collapse of child epics (static HTML; grouping headings + cover the hierarchy need). +- No raising the 500-item fetch ceiling. From e9942eb34d63c1b26fbbee70ae1ced10f60ddab6 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 20:27:30 +0200 Subject: [PATCH 02/24] docs: implementation plan for GitlabRoadmap component --- .../2026-07-10-gitlab-roadmap-component.md | 1495 +++++++++++++++++ 1 file changed, 1495 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-gitlab-roadmap-component.md diff --git a/docs/superpowers/plans/2026-07-10-gitlab-roadmap-component.md b/docs/superpowers/plans/2026-07-10-gitlab-roadmap-component.md new file mode 100644 index 0000000..b57998d --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-gitlab-roadmap-component.md @@ -0,0 +1,1495 @@ +# GitlabRoadmap Component Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `` pipeline-3 component that renders GitLab group **epics** or **milestones** on a build-time timeline, in either a horizontal Gantt or a vertical timeline layout. + +**Architecture:** Follows the existing pipeline-3 loop (`remark/index.ts` → `registry.ts` → fetcher → `data` prop → pure component). All timeline geometry (scale selection, positioning, ticks, grouping) lives in a new pure module `src/gitlab/roadmap.ts` computed at build time, so the React components are dumb renderers. Two thin gitbeaker client methods add the epics/milestones fetch paths. + +**Tech Stack:** TypeScript (ESM, `.js` import extensions), `@gitbeaker/rest`, React (SSR), Vitest + React Testing Library, CSS modules. + +**Design spec:** [`../specs/2026-07-10-gitlab-roadmap-component-design.md`](../specs/2026-07-10-gitlab-roadmap-component-design.md) + +--- + +## Conventions for every task + +- **ESM imports** inside `src/` carry explicit `.js` extensions (e.g. `import { buildRoadmap } from "./roadmap.js"`). +- **TDD:** write the failing test first, watch it fail, implement, watch it pass, commit. +- **Commits are GPG-signed automatically** (`commit.gpgsign=true`). If a commit is unsigned, re-run with `git commit -S`. Verify with `git log -1 --format="%G?"` → expect `G`. +- Run one test file with `npx vitest run `. Run the whole suite with `npm run test`. Typecheck with `npm run typecheck`. +- Do **not** run the slow e2e (`test/e2e/build.test.ts`) per task; it runs once at the end (Task 14). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/gitlab/types.ts` (modify) | Add roadmap domain types | 1 | +| `src/gitlab/roadmap.ts` (create) | Pure geometry: scale, window, positioning, ticks, grouping, `buildRoadmap` | 2 | +| `src/gitlab/roadmap.test.ts` (create) | Unit tests for the geometry module | 2 | +| `src/gitlab/client.ts` (modify) | `getGroupEpics`, `getGroupMilestones`, `getProjectMilestones` | 3 | +| `src/gitlab/client.test.ts` (modify) | Tests for the three new client methods | 3 | +| `src/gitlab/fetchers.ts` (modify) | `fetchRoadmap` — normalize + validate + `buildRoadmap` + memo | 4, 5 | +| `src/gitlab/fetchers.test.ts` (modify) | Fetcher tests (epics, milestones, degrade, cache key) | 4, 5 | +| `src/remark/registry.ts` (modify) | `GitlabRoadmap: fetchRoadmap` | 6 | +| `src/components/GitlabRoadmap.tsx` (create) | Layout dispatcher | 7 | +| `src/components/RoadmapGantt.tsx` (create) | Horizontal bars | 8 | +| `src/components/RoadmapTimeline.tsx` (create) | Vertical spine | 9 | +| `src/components/roadmapColor.ts` (create) | `colorBy` tint resolver (shared by both layouts) | 7 | +| `src/components/styles.module.css` (modify) | Roadmap styles | 8, 9 | +| `src/components/index.ts` + `src/index.ts` (modify) | Exports | 10 | +| `README.md` + `examples/site/docs/components/roadmap.md` | Docs + live examples | 11 | + +--- + +## Task 1: Domain types + +**Files:** +- Modify: `src/gitlab/types.ts` (append) + +- [ ] **Step 1: Add the roadmap types** + +Append to `src/gitlab/types.ts`: + +```ts +/** A GitLab label reduced to what the roadmap renders. */ +export interface LabelRef { + name: string; + color: string; + textColor: string; +} + +export type RoadmapSource = "epics" | "milestones"; +export type RoadmapState = "opened" | "closed"; +export type RoadmapScale = "quarters" | "months" | "weeks"; + +/** One epic/milestone normalized from the GitLab API. */ +export interface RoadmapItemData { + id: number; + iid: number; + title: string; + state: RoadmapState; + /** ISO `YYYY-MM-DD`, or null when the source has no such date. */ + startDate: string | null; + dueDate: string | null; + webUrl: string; + /** Epic color (e.g. `#1f75cb`); absent for milestones. */ + color?: string; + /** Completion 0..100; epics only, null when not derivable. */ + progress?: number | null; + parentId?: number | null; + parentTitle?: string | null; + labels: LabelRef[]; +} + +/** An item after geometry: same fields plus its bar placement. */ +export interface RoadmapPositionedItem extends RoadmapItemData { + /** Bar left edge as a percentage of the timeline window (0..100). */ + offsetPct: number; + /** Bar width as a percentage of the window (>0). */ + widthPct: number; +} + +export interface ScaleTick { + label: string; + /** Tick position as a percentage of the window (0..100). */ + offsetPct: number; +} + +export interface RoadmapGroup { + key: string; + /** Section heading; null for the single ungrouped bucket. */ + title: string | null; + items: RoadmapPositionedItem[]; +} + +/** The fully positioned model the component renders — no math in React. */ +export interface RoadmapData { + source: RoadmapSource; + scale: RoadmapScale; + rangeStart: string; // ISO YYYY-MM-DD + rangeEnd: string; + ticks: ScaleTick[]; + groups: RoadmapGroup[]; +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: PASS (types are additive, nothing consumes them yet). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/types.ts +git commit -m "feat: add roadmap domain types" +``` + +--- + +## Task 2: Geometry module (`roadmap.ts`) + +This is the bulk of the logic and is pure (no network). Build it test-first as one cohesive module. + +**Files:** +- Create: `src/gitlab/roadmap.ts` +- Test: `src/gitlab/roadmap.test.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `src/gitlab/roadmap.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + selectScale, + positionItem, + buildTicks, + groupItems, + buildRoadmap, +} from "./roadmap"; +import type { RoadmapItemData, RoadmapPositionedItem } from "./types"; + +function item(partial: Partial): RoadmapItemData { + return { + id: 1, iid: 1, title: "X", state: "opened", + startDate: null, dueDate: null, webUrl: "https://x", labels: [], + ...partial, + }; +} + +describe("selectScale", () => { + it("picks weeks for a short span (<= 92 days)", () => { + expect(selectScale("2026-01-01", "2026-02-01")).toBe("weeks"); + }); + it("picks months for a mid span (<= 366 days)", () => { + expect(selectScale("2026-01-01", "2026-07-01")).toBe("months"); + }); + it("picks quarters for a long span (> 366 days)", () => { + expect(selectScale("2026-01-01", "2028-06-01")).toBe("quarters"); + }); +}); + +describe("positionItem", () => { + it("positions a bar as a percentage of the window", () => { + const p = positionItem( + item({ startDate: "2026-01-01", dueDate: "2026-01-06" }), + "2026-01-01", + "2026-01-11", + ); + expect(p.offsetPct).toBe(0); + expect(p.widthPct).toBe(50); + }); + it("clamps a bar that starts before the window and keeps a minimum width", () => { + const p = positionItem( + item({ dueDate: "2026-01-02" }), // start falls back to due → zero-length + "2026-01-01", + "2026-01-11", + ); + expect(p.offsetPct).toBeGreaterThanOrEqual(0); + expect(p.widthPct).toBeGreaterThan(0); + expect(p.offsetPct + p.widthPct).toBeLessThanOrEqual(100); + }); +}); + +describe("buildTicks", () => { + it("emits one tick per month across the window", () => { + const ticks = buildTicks("2026-01-01", "2026-04-01", "months"); + expect(ticks.map((t) => t.label)).toEqual(["Jan", "Feb", "Mar"]); + expect(ticks[0].offsetPct).toBe(0); + }); +}); + +describe("groupItems", () => { + const positioned = (name: string, parent: string | null): RoadmapPositionedItem => ({ + ...item({ title: name, parentTitle: parent, labels: [] }), + offsetPct: 0, widthPct: 10, + }); + it("returns a single unnamed group when groupBy is none", () => { + const groups = groupItems([positioned("a", null)], "none"); + expect(groups).toHaveLength(1); + expect(groups[0].title).toBeNull(); + }); + it("splits into one section per parent title when groupBy is parent", () => { + const groups = groupItems( + [positioned("a", "Platform"), positioned("b", "Growth"), positioned("c", null)], + "parent", + ); + expect(groups.map((g) => g.title).sort()).toEqual(["(no parent)", "Growth", "Platform"]); + }); +}); + +describe("buildRoadmap", () => { + it("drops undated items, sorts, positions, and wraps in RoadmapData", () => { + const data = buildRoadmap( + [ + item({ id: 1, title: "late", startDate: "2026-06-01", dueDate: "2026-08-01" }), + item({ id: 2, title: "early", startDate: "2026-01-01", dueDate: "2026-03-01" }), + item({ id: 3, title: "undated" }), + ], + { source: "epics", order: "start", groupBy: "none" }, + ); + expect(data.source).toBe("epics"); + expect(data.groups[0].items.map((i) => i.title)).toEqual(["early", "late"]); + expect(data.groups[0].items).toHaveLength(2); // undated dropped + expect(data.rangeStart <= "2026-01-01").toBe(true); + expect(data.ticks.length).toBeGreaterThan(0); + }); + + it("honors an explicit scale override and window", () => { + const data = buildRoadmap( + [item({ id: 1, title: "a", startDate: "2026-02-01", dueDate: "2026-03-01" })], + { source: "epics", order: "start", groupBy: "none", scale: "weeks", from: "2026-01-01", to: "2026-04-01" }, + ); + expect(data.scale).toBe("weeks"); + expect(data.rangeStart).toBe("2026-01-01"); + expect(data.rangeEnd).toBe("2026-04-01"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/roadmap.test.ts` +Expected: FAIL — `Failed to resolve import "./roadmap"`. + +- [ ] **Step 3: Implement the module** + +Create `src/gitlab/roadmap.ts`: + +```ts +import type { + RoadmapData, + RoadmapGroup, + RoadmapItemData, + RoadmapPositionedItem, + RoadmapScale, + RoadmapSource, + ScaleTick, +} from "./types.js"; + +const MS_PER_DAY = 86_400_000; +const MIN_WIDTH_PCT = 1; // keep zero-length/point items visible +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function parseDay(iso: string): number { + const [y, m, d] = iso.split("-").map(Number); + return Date.UTC(y, m - 1, d); +} +function toISODate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} +function spanDays(startISO: string, endISO: string): number { + return (parseDay(endISO) - parseDay(startISO)) / MS_PER_DAY; +} + +/** Round a timestamp down to the start of its scale unit (Monday / 1st / quarter). */ +function snapDown(ms: number, scale: RoadmapScale): number { + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + if (scale === "weeks") { + const day = d.getUTCDay(); // 0=Sun..6=Sat + const backToMonday = day === 0 ? 6 : day - 1; + return ms - backToMonday * MS_PER_DAY; + } + if (scale === "months") return Date.UTC(y, m, 1); + return Date.UTC(y, Math.floor(m / 3) * 3, 1); // quarters +} + +/** Advance a boundary timestamp by exactly one scale unit. */ +function advance(ms: number, scale: RoadmapScale): number { + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + if (scale === "weeks") return ms + 7 * MS_PER_DAY; + if (scale === "months") return Date.UTC(y, m + 1, 1); + return Date.UTC(y, m + 3, 1); // quarters +} + +/** Round a timestamp up to the next scale boundary (unchanged if already on one). */ +function snapUp(ms: number, scale: RoadmapScale): number { + const down = snapDown(ms, scale); + return down === ms ? ms : advance(down, scale); +} + +export function selectScale(startISO: string, endISO: string): RoadmapScale { + const days = spanDays(startISO, endISO); + if (days <= 92) return "weeks"; + if (days <= 366) return "months"; + return "quarters"; +} + +/** Raw min-start / max-due across items (falling back to the other date). */ +function rawBounds(items: RoadmapItemData[]): { startISO: string; endISO: string } { + const starts = items.map((i) => i.startDate ?? i.dueDate!).filter(Boolean); + const ends = items.map((i) => i.dueDate ?? i.startDate!).filter(Boolean); + const startMs = Math.min(...starts.map(parseDay)); + const endMs = Math.max(...ends.map(parseDay)); + return { startISO: toISODate(startMs), endISO: toISODate(endMs) }; +} + +function deriveWindow( + items: RoadmapItemData[], + scale: RoadmapScale, + from: string | undefined, + to: string | undefined, +): { rangeStart: string; rangeEnd: string } { + const raw = rawBounds(items); + const startMs = from ? parseDay(from) : snapDown(parseDay(raw.startISO), scale); + let endMs = to ? parseDay(to) : snapUp(parseDay(raw.endISO), scale); + if (endMs <= startMs) endMs = advance(startMs, scale); // guarantee a positive window + return { rangeStart: toISODate(startMs), rangeEnd: toISODate(endMs) }; +} + +export function positionItem( + it: RoadmapItemData, + rangeStart: string, + rangeEnd: string, +): { offsetPct: number; widthPct: number } { + const s = parseDay(it.startDate ?? it.dueDate!); + const e = parseDay(it.dueDate ?? it.startDate!); + const total = parseDay(rangeEnd) - parseDay(rangeStart); + const offsetPct = Math.min(Math.max(((s - parseDay(rangeStart)) / total) * 100, 0), 100); + const rawWidth = ((e - s) / total) * 100; + const widthPct = Math.max(Math.min(rawWidth, 100 - offsetPct), MIN_WIDTH_PCT); + return { offsetPct, widthPct }; +} + +function tickLabel(ms: number, scale: RoadmapScale): string { + const d = new Date(ms); + if (scale === "quarters") return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`; + if (scale === "weeks") return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; + return MONTHS[d.getUTCMonth()]; +} + +export function buildTicks(rangeStart: string, rangeEnd: string, scale: RoadmapScale): ScaleTick[] { + const startMs = parseDay(rangeStart); + const endMs = parseDay(rangeEnd); + const total = endMs - startMs; + const ticks: ScaleTick[] = []; + for (let cur = startMs; cur < endMs; cur = advance(cur, scale)) { + ticks.push({ label: tickLabel(cur, scale), offsetPct: ((cur - startMs) / total) * 100 }); + } + return ticks; +} + +export function groupItems( + items: RoadmapPositionedItem[], + groupBy: "none" | "label" | "parent", +): RoadmapGroup[] { + if (groupBy === "none") return [{ key: "all", title: null, items }]; + const map = new Map(); + for (const it of items) { + const keys = + groupBy === "label" + ? it.labels.length + ? it.labels.map((l) => l.name) + : ["(no label)"] + : [it.parentTitle ?? "(no parent)"]; + for (const k of keys) { + const g = map.get(k) ?? { key: k, title: k, items: [] }; + g.items.push(it); + map.set(k, g); + } + } + return [...map.values()]; +} + +function sortItems(items: RoadmapItemData[], order: "start" | "due" | "title"): RoadmapItemData[] { + const key = (i: RoadmapItemData): string => + order === "title" ? i.title : order === "due" ? i.dueDate ?? i.startDate ?? "" : i.startDate ?? i.dueDate ?? ""; + return [...items].sort((a, b) => key(a).localeCompare(key(b))); +} + +export interface BuildRoadmapOptions { + source: RoadmapSource; + order: "start" | "due" | "title"; + groupBy: "none" | "label" | "parent"; + scale?: RoadmapScale; + from?: string; + to?: string; +} + +export function buildRoadmap(items: RoadmapItemData[], opts: BuildRoadmapOptions): RoadmapData { + const dated = items.filter((i) => i.startDate || i.dueDate); + const sorted = sortItems(dated, opts.order); + const raw = rawBounds(sorted); + const scale = opts.scale ?? selectScale(raw.startISO, raw.endISO); + const { rangeStart, rangeEnd } = deriveWindow(sorted, scale, opts.from, opts.to); + const positioned: RoadmapPositionedItem[] = sorted.map((i) => ({ + ...i, + ...positionItem(i, rangeStart, rangeEnd), + })); + return { + source: opts.source, + scale, + rangeStart, + rangeEnd, + ticks: buildTicks(rangeStart, rangeEnd, scale), + groups: groupItems(positioned, opts.groupBy), + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/roadmap.test.ts` +Expected: PASS (all cases green). + +- [ ] **Step 5: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/roadmap.ts src/gitlab/roadmap.test.ts +git commit -m "feat: add roadmap timeline geometry module" +``` + +--- + +## Task 3: Client methods + +**Files:** +- Modify: `src/gitlab/client.ts` +- Test: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Add mocks + a failing test** + +In `src/gitlab/client.test.ts`, add these mock fns near the other `const ...Mock = vi.fn();` declarations (top of file): + +```ts +const epicsAllMock = vi.fn(); +const groupMilestonesAllMock = vi.fn(); +const projectMilestonesAllMock = vi.fn(); +``` + +Add them to the object returned by the `Gitlab` mock (inside `vi.mock("@gitbeaker/rest", ...)`): + +```ts + Epics: { all: epicsAllMock }, + GroupMilestones: { all: groupMilestonesAllMock }, + ProjectMilestones: { all: projectMilestonesAllMock }, +``` + +Add resets in `beforeEach` alongside the others: + +```ts + epicsAllMock.mockReset(); + groupMilestonesAllMock.mockReset(); + projectMilestonesAllMock.mockReset(); +``` + +Add this test block at the end of the file (before the final closing brace of the top-level `describe`, or as a new `describe`): + +```ts +describe("roadmap sources", () => { + it("getGroupEpics passes filters and bounded pagination", async () => { + epicsAllMock.mockResolvedValue([{ id: 1 }]); + const client = new GitLabClient({ host: "https://gitlab.com", token: "t" }); + const res = await client.getGroupEpics("g", { state: "opened", labels: "a", orderBy: "start_date", sort: "asc" }); + expect(res).toEqual([{ id: 1 }]); + expect(epicsAllMock).toHaveBeenCalledWith("g", { + state: "opened", labels: "a", orderBy: "start_date", sort: "asc", perPage: 100, maxPages: 5, + }); + }); + + it("getGroupMilestones and getProjectMilestones fetch with bounded pagination", async () => { + groupMilestonesAllMock.mockResolvedValue([{ id: 2 }]); + projectMilestonesAllMock.mockResolvedValue([{ id: 3 }]); + const client = new GitLabClient({ host: "https://gitlab.com", token: "t" }); + expect(await client.getGroupMilestones("g")).toEqual([{ id: 2 }]); + expect(await client.getProjectMilestones("p/x")).toEqual([{ id: 3 }]); + expect(groupMilestonesAllMock).toHaveBeenCalledWith("g", { perPage: 100, maxPages: 5 }); + expect(projectMilestonesAllMock).toHaveBeenCalledWith("p/x", { perPage: 100, maxPages: 5 }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: FAIL — `client.getGroupEpics is not a function`. + +- [ ] **Step 3: Implement the client methods** + +In `src/gitlab/client.ts`, add an epics-query interface next to `IssuesQuery`: + +```ts +export interface EpicsQuery { + state?: string; + labels?: string; + orderBy?: string; + sort?: string; +} +``` + +Add these methods to the `GitLabClient` class (e.g. after `getGroupProjects`): + +```ts + async getGroupEpics(group: ProjectRef, opts: EpicsQuery = {}): Promise { + return this.api.Epics.all(group, { + ...(opts.state ? { state: opts.state } : {}), + ...(opts.labels ? { labels: opts.labels } : {}), + ...(opts.orderBy ? { orderBy: opts.orderBy } : {}), + ...(opts.sort ? { sort: opts.sort } : {}), + perPage: DEFAULT_PER_PAGE, + maxPages: DEFAULT_MAX_PAGES, + }); + } + + async getGroupMilestones(group: ProjectRef): Promise { + return this.api.GroupMilestones.all(group, { perPage: DEFAULT_PER_PAGE, maxPages: DEFAULT_MAX_PAGES }); + } + + async getProjectMilestones(project: ProjectRef): Promise { + return this.api.ProjectMilestones.all(project, { perPage: DEFAULT_PER_PAGE, maxPages: DEFAULT_MAX_PAGES }); + } +``` + +> Note: the first test expects `getGroupEpics` to forward all four filter keys. Because the spread only includes keys when truthy, the test passes all four so every key is present. This keeps real calls from sending empty filters. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: add epics and milestones client methods" +``` + +--- + +## Task 4: Fetcher — epics path + +**Files:** +- Modify: `src/gitlab/fetchers.ts` +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Write the failing test** + +In `src/gitlab/fetchers.test.ts`, add `fetchRoadmap` to the import from `./fetchers`: + +```ts +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects, fetchRoadmap } from "./fetchers"; +``` + +Add this `describe` block at the end of the file: + +```ts +describe("fetchRoadmap (epics)", () => { + const epics = [ + { id: 10, iid: 1, title: "Auth", state: "opened", start_date: "2026-01-01", due_date: "2026-03-01", + web_url: "https://gitlab.com/groups/g/-/epics/1", color: "#1f75cb", parent_id: null, labels: ["backend"] }, + { id: 11, iid: 2, title: "Billing", state: "closed", start_date: "2026-02-01", due_date: "2026-05-01", + web_url: "https://gitlab.com/groups/g/-/epics/2", color: "#6666c4", parent_id: 10, labels: [] }, + ]; + + it("normalizes epics into positioned RoadmapData", async () => { + const client = { + getGroupEpics: vi.fn(async () => epics), + getGroupLabels: vi.fn(async () => [{ name: "backend", color: "#dbeafe", text_color: "#1e40af" }]), + }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "epics", group: "g" }); + expect(data.source).toBe("epics"); + const items = data.groups.flatMap((g) => g.items); + expect(items.map((i) => i.title).sort()).toEqual(["Auth", "Billing"]); + const auth = items.find((i) => i.title === "Auth")!; + expect(auth.startDate).toBe("2026-01-01"); + expect(auth.color).toBe("#1f75cb"); + expect(auth.labels).toEqual([{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }]); + expect(auth.widthPct).toBeGreaterThan(0); + expect(client.getGroupEpics).toHaveBeenCalled(); + }); + + it("throws when source is epics but group is missing", async () => { + const c = ctx({}); + await expect(fetchRoadmap(c, { source: "epics" })).rejects.toThrow(/group/); + }); + + it("degrades: rethrows in strict mode", async () => { + const client = { getGroupEpics: vi.fn(async () => { throw new Error("403 tier"); }) }; + const c = ctx(client); + c.options.strict = true; + await expect(fetchRoadmap(c, { source: "epics", group: "g" })).rejects.toThrow("403 tier"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: FAIL — `fetchRoadmap` is not exported. + +- [ ] **Step 3: Implement the epics path** + +In `src/gitlab/fetchers.ts`, add imports at the top (extend the existing type import and the roadmap import): + +```ts +import { buildRoadmap, type BuildRoadmapOptions } from "./roadmap.js"; +``` + +Add to the existing `import type { ... } from "./types"` block: `LabelRef`, `RoadmapData`, `RoadmapItemData`, `RoadmapScale`. + +Add these helpers and the fetcher (near the other fetchers): + +```ts +function readRoadmapSource(value: unknown): "epics" | "milestones" { + if (value === undefined || value === "epics") return "epics"; + if (value === "milestones") return "milestones"; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "source" must be "epics" or "milestones"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapScale(value: unknown): RoadmapScale | undefined { + if (value === undefined) return undefined; + if (value === "quarters" || value === "months" || value === "weeks") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "scale" must be "quarters", "months", or "weeks"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapOrder(value: unknown): "start" | "due" | "title" { + if (value === undefined || value === "start") return "start"; + if (value === "due" || value === "title") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "order" must be "start", "due", or "title"; got ${JSON.stringify(value)}.`, + ); +} + +function readGroupBy(value: unknown): "none" | "label" | "parent" { + if (value === undefined || value === "none") return "none"; + if (value === "label" || value === "parent") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "groupBy" must be "none", "label", or "parent"; got ${JSON.stringify(value)}.`, + ); +} + +/** Build a name→LabelRef lookup from group/project labels for color resolution. */ +async function labelIndex( + ctx: GitLabContext, + scope: { group?: string | number; project?: string | number }, +): Promise> { + const raw = + scope.project !== undefined + ? await ctx.client.getProjectLabels(scope.project).catch(() => []) + : scope.group !== undefined + ? await ctx.client.getGroupLabels(scope.group).catch(() => []) + : []; + const idx = new Map(); + for (const l of raw as any[]) { + idx.set(l.name, { name: l.name, color: l.color, textColor: l.text_color }); + } + return idx; +} + +function resolveLabels(names: string[], idx: Map): LabelRef[] { + return names.map( + (name) => idx.get(name) ?? { name, color: "#e5e7eb", textColor: "#1f2937" }, + ); +} + +export async function fetchRoadmap(ctx: GitLabContext, attrs: Attrs): Promise { + const source = readRoadmapSource(attrs.source); + const group = attrs.group as string | number | undefined; + const project = attrs.project as string | number | undefined; + const scale = readRoadmapScale(attrs.scale); + const order = readRoadmapOrder(attrs.order); + const groupBy = readGroupBy(attrs.groupBy); + const state = (attrs.state as string) ?? "opened"; + const labels = attrs.labels as string | undefined; + const from = attrs.from as string | undefined; + const to = attrs.to as string | undefined; + const limit = typeof attrs.limit === "number" ? attrs.limit : 50; + + if (source === "epics" && group === undefined) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: requires a "group".`); + } + if (source === "milestones" && (group === undefined) === (project === undefined)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: requires exactly one of "project" or "group".`, + ); + } + + const scopeKey = project !== undefined ? `p:${String(project)}` : `g:${String(group)}`; + const key = `roadmap:${source}:${scopeKey}:${state}:${labels ?? ""}:${from ?? ""}:${to ?? ""}:${scale ?? "auto"}:${order}:${groupBy}:${limit}`; + + // On failure this simply throws; the remark layer (src/remark/index.ts) turns a + // thrown fetch into an `error` prop (Fallback) when `strict` is false, and + // aborts the build when true — identical to every other fetcher. + return memo(ctx, key, async () => { + const buildOpts: BuildRoadmapOptions = { source, order, groupBy }; + if (scale) buildOpts.scale = scale; + if (from) buildOpts.from = from; + if (to) buildOpts.to = to; + + const items = + source === "epics" + ? await fetchEpicItems(ctx, group!, { state, labels, order }, limit) + : await fetchMilestoneItems(ctx, { group, project }, state, limit); + + return buildRoadmap(items, buildOpts); + }); +} + +async function fetchEpicItems( + ctx: GitLabContext, + group: string | number, + q: { state: string; labels?: string; order: "start" | "due" | "title" }, + limit: number, +): Promise { + const orderBy = q.order === "title" ? "title" : q.order === "due" ? "due_date" : "start_date"; + const raw = await ctx.client.getGroupEpics(group, { + state: q.state, + ...(q.labels ? { labels: q.labels } : {}), + orderBy, + sort: "asc", + }); + const idx = await labelIndex(ctx, { group }); + const byId = new Map(raw.map((e: any) => [e.id, e])); + return raw.slice(0, limit).map((e: any) => ({ + id: e.id, + iid: e.iid, + title: e.title, + state: e.state === "closed" ? "closed" : "opened", + startDate: e.start_date ?? null, + dueDate: e.due_date ?? null, + webUrl: e.web_url, + color: e.color, + progress: computeProgress(e.descendant_counts), + parentId: e.parent_id ?? null, + parentTitle: e.parent_id != null ? byId.get(e.parent_id)?.title ?? null : null, + labels: resolveLabels(Array.isArray(e.labels) ? e.labels : [], idx), + } satisfies RoadmapItemData)); +} + +/** GitLab epic list payloads may include descendant issue counts; derive % from them. */ +function computeProgress(counts: any): number | null { + if (!counts) return null; + const opened = Number(counts.opened_issues ?? 0); + const closed = Number(counts.closed_issues ?? 0); + const total = opened + closed; + return total > 0 ? Math.round((closed / total) * 100) : null; +} +``` + +> `fetchMilestoneItems` is defined in Task 5. This task's tests exercise only the epics path; the milestones import will be added next. + +- [ ] **Step 4: Temporarily stub the milestone path so the file compiles** + +Add a placeholder just below `fetchEpicItems` so `fetchRoadmap` type-checks until Task 5 replaces it: + +```ts +async function fetchMilestoneItems( + _ctx: GitLabContext, + _scope: { group?: string | number; project?: string | number }, + _state: string, + _limit: number, +): Promise { + throw new Error("milestones source not yet implemented"); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: PASS (epics tests green; milestone path untested here). + +- [ ] **Step 6: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add fetchRoadmap epics path" +``` + +--- + +## Task 5: Fetcher — milestones path + +**Files:** +- Modify: `src/gitlab/fetchers.ts` +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `src/gitlab/fetchers.test.ts`: + +```ts +describe("fetchRoadmap (milestones)", () => { + const milestones = [ + { id: 1, iid: 5, title: "v1.0", state: "active", start_date: "2026-01-01", due_date: "2026-02-01", + web_url: "https://gitlab.com/g/r/-/milestones/5" }, + { id: 2, iid: 6, title: "v1.1", state: "closed", start_date: "2026-02-01", due_date: "2026-03-01", + web_url: "https://gitlab.com/g/r/-/milestones/6" }, + ]; + + it("normalizes project milestones and maps active→opened", async () => { + const client = { + getProjectMilestones: vi.fn(async () => milestones), + getProjectLabels: vi.fn(async () => []), + }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "milestones", project: "g/r" }); + expect(data.source).toBe("milestones"); + const items = data.groups.flatMap((g) => g.items); + expect(items.find((i) => i.title === "v1.0")!.state).toBe("opened"); + expect(items.find((i) => i.title === "v1.1")!.state).toBe("closed"); + expect(items.every((i) => i.color === undefined)).toBe(true); + expect(client.getProjectMilestones).toHaveBeenCalledWith("g/r"); + }); + + it("fetches group milestones when group is given", async () => { + const client = { getGroupMilestones: vi.fn(async () => milestones), getGroupLabels: vi.fn(async () => []) }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "milestones", group: "g" }); + expect(data.groups.flatMap((g) => g.items)).toHaveLength(2); + expect(client.getGroupMilestones).toHaveBeenCalledWith("g"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: FAIL — throws `milestones source not yet implemented`. + +- [ ] **Step 3: Replace the placeholder with the real implementation** + +Replace the `fetchMilestoneItems` stub added in Task 4 with: + +```ts +async function fetchMilestoneItems( + ctx: GitLabContext, + scope: { group?: string | number; project?: string | number }, + state: string, + limit: number, +): Promise { + // Milestone API state vocabulary is active/closed; map our opened→active. + const apiState = state === "opened" ? "active" : state; // "closed" and "all" pass through + const raw = + scope.project !== undefined + ? await ctx.client.getProjectMilestones(scope.project) + : await ctx.client.getGroupMilestones(scope.group!); + const filtered = + apiState === "all" ? raw : raw.filter((m: any) => m.state === apiState); + return filtered.slice(0, limit).map((m: any) => ({ + id: m.id, + iid: m.iid, + title: m.title, + state: m.state === "closed" ? "closed" : "opened", + startDate: m.start_date ?? null, + dueDate: m.due_date ?? null, + webUrl: m.web_url, + progress: null, + parentId: null, + parentTitle: null, + labels: [], + } satisfies RoadmapItemData)); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: PASS (epics + milestones green). + +- [ ] **Step 5: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add fetchRoadmap milestones path" +``` + +--- + +## Task 6: Register the component + +**Files:** +- Modify: `src/remark/registry.ts` + +- [ ] **Step 1: Add the import and registry entry** + +In `src/remark/registry.ts`, add `fetchRoadmap` to the import list from `../gitlab/fetchers.js` and add to `COMPONENT_REGISTRY`: + +```ts + GitlabRoadmap: fetchRoadmap, +``` + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/remark/registry.ts +git commit -m "feat: register GitlabRoadmap in the component registry" +``` + +--- + +## Task 7: `GitlabRoadmap` dispatcher + color resolver + +**Files:** +- Create: `src/components/roadmapColor.ts` +- Create: `src/components/GitlabRoadmap.tsx` +- Test: `src/components/GitlabRoadmap.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/GitlabRoadmap.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabRoadmap } from "./GitlabRoadmap"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", + scale: "months", + rangeStart: "2026-01-01", + rangeEnd: "2026-04-01", + ticks: [{ label: "Jan", offsetPct: 0 }, { label: "Feb", offsetPct: 33 }, { label: "Mar", offsetPct: 66 }], + groups: [ + { + key: "all", title: null, + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/epics/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: null, + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 0, widthPct: 33, + }], + }, + ], +}; + +describe("GitlabRoadmap", () => { + it("renders the gantt layout by default", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-gantt")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Auth" })).toHaveAttribute("href", "https://x/epics/1"); + }); + + it("renders the timeline layout when layout='timeline'", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-timeline")).toBeInTheDocument(); + }); + + it("renders the fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); + + it("renders nothing when there is no data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/GitlabRoadmap.test.tsx` +Expected: FAIL — cannot resolve `./GitlabRoadmap`. + +- [ ] **Step 3: Implement the color resolver** + +Create `src/components/roadmapColor.ts`: + +```ts +import type { RoadmapPositionedItem } from "./types.js"; + +export type ColorBy = "source" | "label" | "state"; + +const STATE_COLORS: Record = { opened: "#1f75cb", closed: "#6b7280" }; + +/** Resolve the bar/card tint for an item under the chosen colorBy strategy. */ +export function resolveColor(item: RoadmapPositionedItem, colorBy: ColorBy): string { + if (colorBy === "state") return STATE_COLORS[item.state] ?? STATE_COLORS.opened; + if (colorBy === "label") return item.labels[0]?.color ?? STATE_COLORS[item.state]; + return item.color ?? STATE_COLORS[item.state]; // "source" +} +``` + +- [ ] **Step 4: Implement the dispatcher** + +Create `src/components/GitlabRoadmap.tsx`: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import { RoadmapGantt } from "./RoadmapGantt.js"; +import { RoadmapTimeline } from "./RoadmapTimeline.js"; +import type { ColorBy } from "./roadmapColor.js"; +import type { ComponentPayload, RoadmapData } from "./types.js"; + +export interface GitlabRoadmapProps extends ComponentPayload { + layout?: "gantt" | "timeline"; + colorBy?: ColorBy; + showProgress?: boolean; + showLabels?: boolean; +} + +export function GitlabRoadmap({ + data, + error, + layout = "gantt", + colorBy = "source", + showProgress = true, + showLabels = false, +}: GitlabRoadmapProps) { + if (error) return ; + if (!data) return null; + const view = { data, colorBy, showProgress, showLabels }; + return layout === "timeline" ? : ; +} +``` + +> `RoadmapGantt` and `RoadmapTimeline` come in Tasks 8 and 9. This step will not compile until those files exist; you will run the test at the end of Task 9. To keep this task self-contained, proceed to Task 8 before running. + +- [ ] **Step 5: Commit (partial — layout children follow)** + +```bash +git add src/components/roadmapColor.ts src/components/GitlabRoadmap.tsx src/components/GitlabRoadmap.test.tsx +git commit -m "feat: add GitlabRoadmap dispatcher and color resolver" +``` + +--- + +## Task 8: `RoadmapGantt` layout + +**Files:** +- Create: `src/components/RoadmapGantt.tsx` +- Modify: `src/components/styles.module.css` +- Test: `src/components/RoadmapGantt.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/RoadmapGantt.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RoadmapGantt } from "./RoadmapGantt"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", scale: "months", rangeStart: "2026-01-01", rangeEnd: "2026-04-01", + ticks: [{ label: "Jan", offsetPct: 0 }, { label: "Feb", offsetPct: 33 }], + groups: [{ + key: "Platform", title: "Platform", + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: "Platform", + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 10, widthPct: 40, + }], + }], +}; + +describe("RoadmapGantt", () => { + it("renders the scale header, group heading, and a positioned bar", () => { + const { container } = render(); + expect(screen.getByText("Platform")).toBeInTheDocument(); + expect(screen.getByText("Jan")).toBeInTheDocument(); + const bar = container.querySelector(".gitlab-roadmap-bar") as HTMLElement; + expect(bar).toHaveStyle({ left: "10%", width: "40%", backgroundColor: "#1f75cb" }); + expect(screen.getByRole("link", { name: /Auth/ })).toHaveAttribute("href", "https://x/1"); + }); + + it("renders the progress fill only when showProgress is true", () => { + const { container, rerender } = render(); + expect(container.querySelector(".gitlab-roadmap-progress")).toBeNull(); + rerender(); + expect(container.querySelector(".gitlab-roadmap-progress")).toHaveStyle({ width: "60%" }); + }); + + it("renders label chips only when showLabels is true", () => { + const { queryByText, rerender } = render(); + expect(queryByText("backend")).toBeNull(); + rerender(); + expect(queryByText("backend")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/RoadmapGantt.test.tsx` +Expected: FAIL — cannot resolve `./RoadmapGantt`. + +- [ ] **Step 3: Implement the component** + +Create `src/components/RoadmapGantt.tsx`: + +```tsx +import React from "react"; +import { resolveColor, type ColorBy } from "./roadmapColor.js"; +import type { RoadmapData, RoadmapPositionedItem } from "./types.js"; + +export interface RoadmapViewProps { + data: RoadmapData; + colorBy: ColorBy; + showProgress: boolean; + showLabels: boolean; +} + +function LabelChips({ item }: { item: RoadmapPositionedItem }) { + return ( + <> + {item.labels.map((l) => ( + + {l.name} + + ))} + + ); +} + +export function RoadmapGantt({ data, colorBy, showProgress, showLabels }: RoadmapViewProps) { + return ( +
+
+ {data.ticks.map((t) => ( + + {t.label} + + ))} +
+ {data.groups.map((group) => ( +
+ {group.title &&
{group.title}
} + {group.items.map((item) => { + const color = resolveColor(item, colorBy); + return ( +
+
+ {item.title} + {showLabels && } +
+
+
+ {showProgress && item.progress != null && ( +
+ )} +
+
+
+ ); + })} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Add styles** + +Append to `src/components/styles.module.css` (plain class selectors, matching how other components use `gitlab-*` global classes): + +```css +.gitlab-roadmap { font-size: 0.85rem; } +.gitlab-roadmap-gantt { overflow-x: auto; } +.gitlab-roadmap-scale { + position: relative; height: 1.4rem; margin-left: 12rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); +} +.gitlab-roadmap-tick { position: absolute; transform: translateX(-50%); color: var(--ifm-color-emphasis-600); } +.gitlab-roadmap-group-title { font-weight: 600; margin: 0.6rem 0 0.3rem; } +.gitlab-roadmap-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; align-items: center; margin: 0.25rem 0; } +.gitlab-roadmap-label-col { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.gitlab-roadmap-track { position: relative; height: 1.25rem; background: var(--ifm-color-emphasis-100); border-radius: 4px; } +.gitlab-roadmap-bar { position: absolute; top: 2px; height: calc(100% - 4px); border-radius: 4px; overflow: hidden; opacity: 0.85; } +.gitlab-roadmap-progress { height: 100%; background: rgba(0, 0, 0, 0.35); } +.gitlab-roadmap-label { + display: inline-block; margin-left: 0.3rem; padding: 0 0.3rem; + border-radius: 3px; font-size: 0.7rem; +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx vitest run src/components/RoadmapGantt.test.tsx` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/RoadmapGantt.tsx src/components/RoadmapGantt.test.tsx src/components/styles.module.css +git commit -m "feat: add RoadmapGantt horizontal layout" +``` + +--- + +## Task 9: `RoadmapTimeline` layout + +**Files:** +- Create: `src/components/RoadmapTimeline.tsx` +- Modify: `src/components/styles.module.css` +- Test: `src/components/RoadmapTimeline.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/RoadmapTimeline.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RoadmapTimeline } from "./RoadmapTimeline"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", scale: "months", rangeStart: "2026-01-01", rangeEnd: "2026-04-01", ticks: [], + groups: [{ + key: "Platform", title: "Platform", + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: "Platform", + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 0, widthPct: 33, + }], + }], +}; + +describe("RoadmapTimeline", () => { + it("renders a vertical spine with group heading, card, and date range", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-timeline")).toBeInTheDocument(); + expect(screen.getByText("Platform")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Auth/ })).toHaveAttribute("href", "https://x/1"); + expect(screen.getByText(/2026-01-01/)).toBeInTheDocument(); + expect(container.querySelector(".gitlab-roadmap-meter")).toHaveStyle({ width: "60%" }); + expect(screen.getByText("backend")).toBeInTheDocument(); + }); + + it("omits meter and labels when toggles are off", () => { + const { container, queryByText } = render( + , + ); + expect(container.querySelector(".gitlab-roadmap-meter")).toBeNull(); + expect(queryByText("backend")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/RoadmapTimeline.test.tsx` +Expected: FAIL — cannot resolve `./RoadmapTimeline`. + +- [ ] **Step 3: Implement the component** + +Create `src/components/RoadmapTimeline.tsx`: + +```tsx +import React from "react"; +import { resolveColor } from "./roadmapColor.js"; +import type { RoadmapViewProps } from "./RoadmapGantt.js"; + +function dateRange(start: string | null, due: string | null): string { + if (start && due) return `${start} → ${due}`; + return start ?? due ?? ""; +} + +export function RoadmapTimeline({ data, colorBy, showProgress, showLabels }: RoadmapViewProps) { + return ( +
+ {data.groups.map((group) => ( +
+ {group.title &&
{group.title}
} +
+ {group.items.map((item) => { + const color = resolveColor(item, colorBy); + return ( +
+ +
+ {item.title} +
{dateRange(item.startDate, item.dueDate)}
+ {showProgress && item.progress != null && ( +
+
+
+ )} + {showLabels && + item.labels.map((l) => ( + + {l.name} + + ))} +
+
+ ); + })} +
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Add styles** + +Append to `src/components/styles.module.css`: + +```css +.gitlab-roadmap-spine { border-left: 2px solid var(--ifm-color-emphasis-300); margin-left: 0.5rem; padding-left: 1rem; } +.gitlab-roadmap-node { position: relative; margin: 0.75rem 0; } +.gitlab-roadmap-dot { position: absolute; left: -1.4rem; top: 0.25rem; width: 0.6rem; height: 0.6rem; border-radius: 50%; } +.gitlab-roadmap-card { display: flex; flex-direction: column; gap: 0.25rem; align-items: flex-start; } +.gitlab-roadmap-dates { color: var(--ifm-color-emphasis-600); font-size: 0.75rem; } +.gitlab-roadmap-meter-track { width: 100%; max-width: 12rem; height: 0.4rem; background: var(--ifm-color-emphasis-200); border-radius: 3px; } +.gitlab-roadmap-meter { height: 100%; border-radius: 3px; } +``` + +- [ ] **Step 5: Run the timeline, dispatcher, and gantt tests** + +Run: `npx vitest run src/components/RoadmapTimeline.test.tsx src/components/GitlabRoadmap.test.tsx src/components/RoadmapGantt.test.tsx` +Expected: PASS (all three, including the Task 7 dispatcher test which now resolves its children). + +- [ ] **Step 6: Commit** + +```bash +git add src/components/RoadmapTimeline.tsx src/components/RoadmapTimeline.test.tsx src/components/styles.module.css +git commit -m "feat: add RoadmapTimeline vertical layout" +``` + +--- + +## Task 10: Exports + +**Files:** +- Modify: `src/components/index.ts` +- Modify: `src/index.ts` + +- [ ] **Step 1: Export the component** + +In `src/components/index.ts`, add: + +```ts +export { GitlabRoadmap } from "./GitlabRoadmap.js"; +``` + +And add to the `export type { ... } from "./types.js"` block: `RoadmapData`, `RoadmapItemData`, `LabelRef`. + +- [ ] **Step 2: Export the public types** + +In `src/index.ts`, add to the `export type { ... } from "./gitlab/types.js"` block: `RoadmapData`, `RoadmapItemData`, `LabelRef`. + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 4: Run the full suite** + +Run: `npm run test` +Expected: PASS (all existing + new tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/index.ts src/index.ts +git commit -m "feat: export GitlabRoadmap and roadmap types" +``` + +--- + +## Task 11: Documentation + example page + +**Files:** +- Modify: `README.md` +- Create: `examples/site/docs/components/roadmap.md` + +- [ ] **Step 1: Add a README section** + +Add a `### ` section to `README.md` (place it alongside the other component sections). Include the full prop table from the design spec and two examples: + +```md +### `` + +Renders a timeline of GitLab **epics** (Premium/Ultimate, group-level) or +**milestones** (free; project or group). All data is fetched at build time. + + + + + +| Prop | Values | Default | Notes | +|---|---|---|---| +| `source` | `epics` \| `milestones` | `epics` | Fetch path | +| `group` | group path/id | — | Required for epics; one of group/project for milestones | +| `project` | project path/id | — | Milestones only | +| `layout` | `gantt` \| `timeline` | `gantt` | Horizontal bars vs. vertical spine | +| `scale` | `quarters` \| `months` \| `weeks` | auto | Auto from span; prop overrides | +| `state` | `opened` \| `closed` \| `all` | `opened` | | +| `labels` | comma-separated | — | Label filter | +| `from` / `to` | `YYYY-MM-DD` | derived | Explicit window | +| `limit` | number | `50` | Max items (≤ 500) | +| `order` | `start` \| `due` \| `title` | `start` | Sort key | +| `groupBy` | `none` \| `label` \| `parent` | `none` | Section headings | +| `colorBy` | `source` \| `label` \| `state` | `source` | Bar/card tint | +| `showProgress` | boolean | `true` | Epics only | +| `showLabels` | boolean | `false` | Inline label chips | +``` + +- [ ] **Step 2: Create the example page** + +Create `examples/site/docs/components/roadmap.md` following the structure of the sibling pages in that folder (front-matter + a short intro + a couple of live `` embeds pointing at a public GitLab group/project used by the other example pages). + +- [ ] **Step 3: Commit** + +```bash +git add README.md examples/site/docs/components/roadmap.md +git commit -m "docs: document GitlabRoadmap component" +``` + +--- + +## Task 12: Full verification + +- [ ] **Step 1: Typecheck + full test suite** + +Run: `npm run typecheck && npm run test` +Expected: PASS. + +- [ ] **Step 2: Build the package** + +Run: `npm run build` +Expected: PASS — emits `dist/` with `.js` + `.d.ts` for the new files. + +- [ ] **Step 3: e2e build (pipeline touched — run once)** + +Run: `npx vitest run test/e2e/build.test.ts` +Expected: PASS — the example Docusaurus site (including `roadmap.md`) builds. If the example embeds hit a private/unavailable resource, point them at a public group/project or mark the page so the e2e's `strict:false` dev config degrades to `Fallback` rather than aborting. + +- [ ] **Step 4: Verify commits are signed** + +Run: `git log --format="%G? %s" -12` +Expected: every roadmap commit shows `G`. + +--- + +## Self-review notes + +- **Spec coverage:** source prop (Tasks 4–5), both layouts (8–9), scale auto+override (2), state/labels/from/to/limit/order/groupBy/colorBy/showProgress/showLabels (4, 7–9), epics+milestones normalization + label colors (4–5), no "today" marker / title-only / no markdown (components render text + `href` only — no `dangerouslySetInnerHTML`), strict degrade (4), 500-item ceiling (3), caching key excludes presentational props (4), registry + exports + docs (6, 10, 11). +- **Non-strict degrade:** `fetchRoadmap` rethrows on error; the remark layer (`src/remark/index.ts`) already converts a thrown fetch into an `error` prop when `strict` is false, which renders `` — consistent with every other fetcher. No separate empty-roadmap path is introduced. +- **Type consistency:** `RoadmapViewProps` is defined once in `RoadmapGantt.tsx` and reused by `RoadmapTimeline.tsx` and the dispatcher; `resolveColor(item, colorBy)` signature is identical across call sites; `BuildRoadmapOptions` matches the fetcher's construction (optional `scale`/`from`/`to` set conditionally to satisfy `exactOptionalPropertyTypes` if enabled). +``` From 067ec4ed996d7d40cdaa1b19ec6dbda22f166135 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 17:38:20 +0200 Subject: [PATCH 03/24] docs: design spec for GitlabUser/GitlabUsers components --- .../specs/2026-07-16-gitlab-users-design.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-gitlab-users-design.md diff --git a/docs/superpowers/specs/2026-07-16-gitlab-users-design.md b/docs/superpowers/specs/2026-07-16-gitlab-users-design.md new file mode 100644 index 0000000..28c0d90 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-gitlab-users-design.md @@ -0,0 +1,261 @@ +# Design: `GitlabUser` & `GitlabUsers` components + +**Date:** 2026-07-16 +**Status:** Approved (brainstorming) + +## Summary + +Two new build-time MDX components that render GitLab **user profiles** as small +cards. Both follow the existing pipeline: `registry → fetcher → inject → pure +component`, with all data fetched at build time and baked into static HTML. + +- `` — one user card looked up by **username**: photo, display name, + linked @username, and a configurable set of profile sections (job title / + organization, location, bio, follower counts, member-since). +- `` — a cards grid of the members of a **group** or a **project**, + optionally filtered by **role** (exact match), reusing the shared + `ComponentLayout` grid knobs (`cardColumns`, `cardMinWidth`, `gap`, `maxWidth`, + `align`) and `cardsGridStyle()` from `src/components/layout.ts`. + +Card content is driven by a `show` attribute on both components. For +`GitlabUsers`, `show` also drives fetching cost (**enrich on demand**): the +members API alone covers identity + role; any profile section beyond that +triggers one extra (memoized) user lookup per member at build time. + +Only fields returned by the GitLab **user API** are used. Activity/contribution +metrics (events, contributed projects) come from other endpoints and are out of +scope. + +## Attribute surface + +### `` + +| attr | type | default | notes | +|---|---|---|---| +| `name` | string | **required** | GitLab username (login), e.g. `"jdoe"` | +| `show` | string | `org,location,bio,counts,since` | comma-separated section tokens, see below | + +### `` + +Exactly one of `project` / `group`, plus the shared layout knobs. + +| attr | type | default | notes | +|---|---|---|---| +| `project` | string \| number | — | e.g. `"group/proj"` or numeric id | +| `group` | string \| number | — | e.g. `"my-group"` or numeric id | +| `role` | string | — | exact-match filter, case-insensitive: `minimal` \| `guest` \| `planner` \| `reporter` \| `developer` \| `maintainer` \| `owner` | +| `show` | string | `role` | same tokens as `GitlabUser`, plus `role` | +| `limit` | number | all | cap applied after role filter + sort | +| `cardColumns` | number | — | `ComponentLayout` (presentational, never reaches the fetcher) | +| `cardMinWidth` | string | `"260px"` | `ComponentLayout` | +| `gap` | string | — | `ComponentLayout` | +| `maxWidth` | string | — | `ComponentLayout` | +| `align` | string | — | `ComponentLayout`: `start` \| `center` | + +### `show` tokens + +Identity (avatar, display name, @username linked to the GitLab profile) is +**always** rendered and needs no token. + +| token | card section | needs enrichment (`GitlabUsers`) | +|---|---|---| +| `org` | job title · organization | yes | +| `location` | location line | yes | +| `bio` | bio paragraph (plain text) | yes | +| `counts` | followers · following | yes | +| `since` | "Member since " | yes | +| `role` | role badge (`GitlabUsers` only) | no — comes from the members API | + +**Validation (build-time errors, standard remark error path):** + +- `` without `name` throws. +- `` with both or neither of `project` / `group` throws. +- Unknown `show` token throws; `role` token on `` throws. +- Invalid `role` value throws. +- `limit` must be a positive number. + +All attribute values remain **static literals** (enforced by `parseAttributes`). + +## Domain types (`src/gitlab/types.ts`) + +```ts +export interface UserData { + id: number; + username: string; + name: string; + webUrl: string; + /** Localized via AssetManager; null when the user has no avatar. */ + avatarUrl: string | null; + /** Role name (e.g. "developer"); set only for members lists. */ + role?: string; + // Profile fields — null when not enriched or absent on the profile. + jobTitle: string | null; + organization: string | null; + location: string | null; + bio: string | null; + followers: number | null; + following: number | null; + createdAt: string | null; +} +``` + +`fetchUsers` returns `UserData[]` (same convention as `TopicData[]` / +`LabelData[]`). + +## Data flow + +### Single user (`fetchUser`) + +1. `client.getUserByUsername(name)` → `api.Users.all({ username })` — exact + username lookup; empty result → clear "user not found" error. +2. `client.getUser(id)` → `api.Users.show(id)` — the single-user GET is what + carries the full public profile (`bio`, `job_title`, `organization`, + `location`, `followers`, `following`, `created_at`). + **Verification during implementation:** confirm the exact field names against + the live user API (notably `followers` / `following` and `work_information` + vs `job_title`/`organization`); adjust normalization if they differ. +3. Avatar localized via `ctx.assets.localize(avatar_url, ...)`, same as project + avatars in `fetchProjectInfo`. +4. Normalized `UserData` memoized under `user:${username}` — the **full** profile + is always fetched and cached; `show` is presentational and excluded from the + cache key. + +### Members list (`fetchUsers`) + +1. `client.getGroupMembers(group)` / `client.getProjectMembers(project)` → + `api.GroupMembers.all` / `api.ProjectMembers.all` with + `{ includeInherited: true }` (the `/members/all` endpoint — matches what the + GitLab members page shows), paginated with the same safety ceiling as + topics/labels: `perPage: 100`, max 5 pages → **500 members max** (do not + raise; see the existing cap rationale). +2. `access_level` mapped to a role name via one shared map + (`5:minimal, 10:guest, 15:planner, 20:reporter, 30:developer, 40:maintainer, + 50:owner`); unknown levels render as the numeric value and never match a + `role` filter. +3. **Filter** by `role` (exact, case-insensitive) → **sort** by display name + (`localeCompare`, ascending — deterministic builds; no `order` attribute for + now) → **limit**. +4. **Enrich on demand:** if the resolved `show` set contains any token marked + "needs enrichment" above, each remaining member is resolved through the same + per-user path as `fetchUser` (individually memoized under `user:${username}`, + so members shared across pages/components cost one lookup per build). If not, + the members payload alone (id, username, name, avatar_url, web_url, + access_level) fills `UserData` with profile fields `null`. +5. Result memoized under a key containing scope, role, limit, and an + `enrich:0|1` flag — `show` beyond that flag does not change the data. + +### How `show` and layout props reach the components + +As with `layout` on `GitlabLabels`: `injectProp` only pushes the `data`/`error` +attribute, so `show`, `cardColumns`, `gap`, etc. survive remark and arrive as +ordinary React props. The fetcher validates `show`/`role`; the components read +them directly. + +### Shared `show`/role helpers + +A small pure module `src/gitlab/users.ts` (no Node/gitbeaker imports, safe for +the browser bundle) holds the `show` token list, per-component defaults, the +parse/validate helper, and the access-level→role map. Both the fetchers and the +components import it, so defaults and enrichment triggers cannot drift apart. + +## Client additions (`src/gitlab/client.ts`) + +```ts +async getUserByUsername(username: string): Promise { + return this.api.Users.all({ username }); +} +async getUser(id: number): Promise { + return this.api.Users.show(id); +} +async getGroupMembers(group: ProjectRef, pagination): Promise { + return this.api.GroupMembers.all(group, { includeInherited: true, ...pagination }); +} +async getProjectMembers(project: ProjectRef, pagination): Promise { + return this.api.ProjectMembers.all(project, { includeInherited: true, ...pagination }); +} +``` + +**Verification during implementation:** confirm the gitbeaker resource names +(`Users`, `GroupMembers`, `ProjectMembers`) and the `includeInherited` option in +the installed gitbeaker version; fall back to the explicit `/members/all` +endpoint call if the option differs. + +## Registry (`src/remark/registry.ts`) + +```ts +GitlabUser: fetchUser, +GitlabUsers: fetchUsers, +``` + +## Components (`src/components/`) + +Pure, `error → Fallback; !data → null; else render`. Plain global class names +(no CSS module import), following the existing convention: `gitlab-user-card`, +`gitlab-user-cards`, `gitlab-avatar`, `gitlab-user-role`, etc. + +- **`UserCard.tsx`** — internal shared partial rendering one card from a + `UserData` + a resolved `show` set. Header: avatar (`` with the localized + URL, `alt` = display name), display name, `@username` linked to `webUrl`. + Sections render only when their token is in `show` **and** the field is + non-null. Bio is plain text from the API — rendered as text, **no** + `dangerouslySetInnerHTML`. +- **`GitlabUser.tsx`** — `ComponentPayload & { show?: string }`; + renders one `UserCard`. +- **`GitlabUsers.tsx`** — `ComponentPayload & ComponentLayout & + { show?: string }`; renders a grid container styled with + `cardsGridStyle({ cardColumns, cardMinWidth: cardMinWidth ?? "260px", gap, + maxWidth, align })`, one `UserCard` per member (plus the role badge when + `show` includes `role`). An empty array renders the empty container + (consistent with `GitlabIssues` / `GitlabTopics`). + +No `Map`/`Set` iterator spreads in these files (Babel `iterableIsArray` +gotcha) — use `Array.from` if a `Set` of show tokens is materialized. + +## Exports + +- `src/components/index.ts` — export `GitlabUser`, `GitlabUsers`. +- `src/index.ts` — export `UserData` type. + +## Error handling + +Unchanged: fetchers throw; the remark transformer centralizes `strict` +handling (throw → abort build in production, or inject an `error` prop → +`Fallback` in dev). "User not found", "group/project not found", and all +attribute-validation failures surface through this path. + +## Testing (TDD) + +**Fetcher tests** (fake/mocked client): + +- `fetchUser`: username resolution (found / not found); normalization of + snake_case profile fields; avatar localization; memo key excludes `show`. +- `fetchUsers`: group vs project source; both/neither scope → error; role + filter (exact, case-insensitive, unknown value → error); access-level→role + mapping; sort + limit; 500-member ceiling; **enrich-on-demand: identity-only + `show` performs zero per-user calls, a profile token triggers exactly one + lookup per member**; invalid `show` token → error. + +**Component tests** (React Testing Library): + +- `UserCard` via `GitlabUser`: avatar `alt`, profile link `href`, each `show` + token toggles its section, null fields render nothing, `error → Fallback`. +- `GitlabUsers`: renders one card per user; role badge with `show="role"`; + layout props produce the expected `cardsGridStyle` inline styles (same + assertions as the `GitlabLabels` cards tests); empty array → empty container; + `error → Fallback`. + +## Docs + +- README section for both components (including the `show` table and the + enrichment cost note). +- `examples/site/docs/components/` page for each (exercised by the slow e2e + build in `test/e2e/build.test.ts`). + +## Out of scope + +- Activity/contribution metrics (events API, contributed-projects) — not part + of the user API. +- `publicEmail` / website / social links on the card. +- An `order` attribute (fixed name-ascending sort). +- Filtering by minimum access level (`role` is exact-match only). +- Direct-members-only mode (inherited members are always included). From 1d45781132274ba0de5f9dbf2b192575d9ec685b Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 17:59:28 +0200 Subject: [PATCH 04/24] docs: implementation plan for GitlabUser/GitlabUsers components --- .../plans/2026-07-16-gitlab-users.md | 1376 +++++++++++++++++ 1 file changed, 1376 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-gitlab-users.md diff --git a/docs/superpowers/plans/2026-07-16-gitlab-users.md b/docs/superpowers/plans/2026-07-16-gitlab-users.md new file mode 100644 index 0000000..4118b09 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-gitlab-users.md @@ -0,0 +1,1376 @@ +# GitlabUser & GitlabUsers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two build-time MDX components — `` (one user profile card) and `` (a cards grid of group/project members) — per the approved spec `docs/superpowers/specs/2026-07-16-gitlab-users-design.md`. + +**Architecture:** Follows the existing pipeline exactly: `registry → fetcher → inject → pure component`. A `show` attribute picks card sections on both components; on `GitlabUsers` it also drives **enrich-on-demand** (profile sections trigger one memoized `GET /users/:id` per member; identity + role need only the members call). Layout reuses `ComponentLayout` + `cardsGridStyle` from `src/components/layout.ts`. + +**Tech Stack:** TypeScript ESM (`.js` import extensions mandatory), gitbeaker (`Users`, `GroupMembers`, `ProjectMembers` — verified present with `includeInherited`), Vitest + React Testing Library, e2e via the local GitLab stub in `test/e2e/fixtures.ts`. + +**Conventions that apply to every task:** + +- All commits are GPG-signed automatically (`commit.gpgsign=true`). Verify with `git log -1 --format="%G?"` → `G`. +- ESM: intra-package imports use explicit `.js` extensions. +- gitbeaker responses are snake_case; normalize to camelCase in fetchers. +- Components are pure (`error → Fallback; !data → null; else render`), no hooks/fetching, plain global class names, no CSS module imports, **no `Map`/`Set` iterator spreads**. +- After each task: run the named tests. After the last code task also run `pnpm run typecheck`. + +--- + +## Task 1: Shared user helpers (`src/gitlab/users.ts`) + `UserData` type + +The `show`-token and role helpers are shared by the fetchers (Node) and the components (browser bundle), so defaults and enrichment triggers cannot drift apart. The module must stay **pure** — no Node, gitbeaker, or fetcher imports. + +**Files:** + +- Create: `src/gitlab/users.ts` +- Create: `src/gitlab/users.test.ts` +- Modify: `src/gitlab/types.ts` (append `UserData`) + +- [ ] **Step 1: Write the failing test** + +Create `src/gitlab/users.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { parseShow, needsProfile, roleName, parseRole } from "./users"; + +describe("parseShow", () => { + it("applies per-component defaults", () => { + expect(parseShow(undefined, "GitlabUser")).toEqual(["org", "location", "bio", "counts", "since"]); + expect(parseShow(undefined, "GitlabUsers")).toEqual(["role"]); + }); + + it("parses a comma-separated list, trimming whitespace", () => { + expect(parseShow(" bio , counts ", "GitlabUser")).toEqual(["bio", "counts"]); + }); + + it("allows an empty string (identity-only card)", () => { + expect(parseShow("", "GitlabUser")).toEqual([]); + }); + + it("rejects unknown tokens", () => { + expect(() => parseShow("bogus", "GitlabUser")).toThrow(/"show" token "bogus"/); + }); + + it("rejects non-string values", () => { + expect(() => parseShow(5, "GitlabUsers")).toThrow(/"show" must be a comma-separated string/); + }); + + it("rejects the role token on GitlabUser but allows it on GitlabUsers", () => { + expect(() => parseShow("role", "GitlabUser")).toThrow(/does not support "role"/); + expect(parseShow("role", "GitlabUsers")).toEqual(["role"]); + }); +}); + +describe("needsProfile", () => { + it("is false for identity/role-only tokens", () => { + expect(needsProfile([])).toBe(false); + expect(needsProfile(["role"])).toBe(false); + }); + + it("is true when any profile token is present", () => { + expect(needsProfile(["role", "bio"])).toBe(true); + expect(needsProfile(["counts"])).toBe(true); + expect(needsProfile(["org"])).toBe(true); + expect(needsProfile(["since"])).toBe(true); + expect(needsProfile(["location"])).toBe(true); + }); +}); + +describe("roles", () => { + it("maps GitLab access levels to role names", () => { + expect(roleName(5)).toBe("minimal"); + expect(roleName(10)).toBe("guest"); + expect(roleName(15)).toBe("planner"); + expect(roleName(20)).toBe("reporter"); + expect(roleName(30)).toBe("developer"); + expect(roleName(40)).toBe("maintainer"); + expect(roleName(50)).toBe("owner"); + }); + + it("falls back to the numeric value for unknown levels", () => { + expect(roleName(99)).toBe("99"); + }); + + it("parseRole validates case-insensitively; undefined means no filter", () => { + expect(parseRole(undefined)).toBeUndefined(); + expect(parseRole("Developer")).toBe("developer"); + expect(() => parseRole("boss")).toThrow(/"role" must be one of/); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm exec vitest run src/gitlab/users.test.ts` +Expected: FAIL — `Cannot find module './users'` (or similar resolution error). + +- [ ] **Step 3: Write the implementation** + +Create `src/gitlab/users.ts`: + +```ts +/** + * `show`-token and role helpers shared by the user fetchers (Node) and the + * user components (browser bundle). Keep this module pure — no Node, gitbeaker, + * or fetcher imports — so Docusaurus can bundle it for the browser. + */ + +export const USER_SHOW_TOKENS = ["org", "location", "bio", "counts", "since", "role"] as const; +export type UserShowToken = (typeof USER_SHOW_TOKENS)[number]; + +/** Tokens whose card section needs the full user profile (GET /users/:id). */ +const PROFILE_TOKENS: readonly UserShowToken[] = ["org", "location", "bio", "counts", "since"]; + +export const DEFAULT_USER_SHOW = "org,location,bio,counts,since"; +export const DEFAULT_USERS_SHOW = "role"; + +type UserComponent = "GitlabUser" | "GitlabUsers"; + +/** Parse + validate a `show` attribute into tokens; applies the per-component default. */ +export function parseShow(value: unknown, component: UserComponent): UserShowToken[] { + const raw = + value === undefined ? (component === "GitlabUser" ? DEFAULT_USER_SHOW : DEFAULT_USERS_SHOW) : value; + if (typeof raw !== "string") { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: <${component}> "show" must be a comma-separated string; ` + + `got ${JSON.stringify(value)}.`, + ); + } + const tokens = raw.split(",").map((s) => s.trim()).filter(Boolean); + for (const token of tokens) { + if (!(USER_SHOW_TOKENS as readonly string[]).includes(token)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: <${component}> "show" token ${JSON.stringify(token)} ` + + `is not one of ${USER_SHOW_TOKENS.join(", ")}.`, + ); + } + if (token === "role" && component === "GitlabUser") { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "show" does not support "role" (members lists only).`, + ); + } + } + return tokens as UserShowToken[]; +} + +/** True when any requested section needs the full user profile (drives enrichment). */ +export function needsProfile(tokens: readonly UserShowToken[]): boolean { + return tokens.some((t) => PROFILE_TOKENS.includes(t)); +} + +/** GitLab numeric access levels → role names (see the members API docs). */ +const ROLE_BY_ACCESS_LEVEL: Record = { + 5: "minimal", + 10: "guest", + 15: "planner", + 20: "reporter", + 30: "developer", + 40: "maintainer", + 50: "owner", +}; + +const ROLE_NAMES = Object.values(ROLE_BY_ACCESS_LEVEL); + +export function roleName(accessLevel: number): string { + return ROLE_BY_ACCESS_LEVEL[accessLevel] ?? String(accessLevel); +} + +/** Validate the `role` attribute (case-insensitive); undefined = no filter. */ +export function parseRole(value: unknown): string | undefined { + if (value === undefined) return undefined; + const role = String(value).toLowerCase(); + if (!ROLE_NAMES.includes(role)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "role" must be one of ${ROLE_NAMES.join(", ")}; ` + + `got ${JSON.stringify(value)}.`, + ); + } + return role; +} +``` + +- [ ] **Step 4: Add the `UserData` domain type** + +In `src/gitlab/types.ts`, append after the `GroupProjectData` interface (after its closing `}` around line 125): + +```ts +export interface UserData { + id: number; + username: string; + name: string; + webUrl: string; + /** Localized via AssetManager; null when the user has no avatar. */ + avatarUrl: string | null; + /** Role name (e.g. "developer"); set only for members lists. */ + role?: string; + // Profile fields — null when not enriched or absent on the profile. + jobTitle: string | null; + organization: string | null; + location: string | null; + bio: string | null; + followers: number | null; + following: number | null; + createdAt: string | null; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pnpm exec vitest run src/gitlab/users.test.ts` +Expected: PASS (all tests green). + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/users.ts src/gitlab/users.test.ts src/gitlab/types.ts +git commit -m "feat: shared show/role helpers and UserData type for user components" +``` + +--- + +## Task 2: GitLabClient user/member methods + +**Files:** + +- Modify: `src/gitlab/client.ts` (add 4 methods) +- Modify: `src/gitlab/client.test.ts` (extend the gitbeaker mock + add tests) + +- [ ] **Step 1: Write the failing tests** + +In `src/gitlab/client.test.ts`: + +a) Add four mock fns next to the existing ones (after `const projectMilestonesAllMock = vi.fn();`): + +```ts +const usersAllMock = vi.fn(); +const usersShowMock = vi.fn(); +const groupMembersAllMock = vi.fn(); +const projectMembersAllMock = vi.fn(); +``` + +b) Extend the object returned by the mocked `Gitlab` constructor (inside the `vi.mock("@gitbeaker/rest", ...)` factory, after `ProjectMilestones: { all: projectMilestonesAllMock },`): + +```ts + Users: { all: usersAllMock, show: usersShowMock }, + GroupMembers: { all: groupMembersAllMock }, + ProjectMembers: { all: projectMembersAllMock }, +``` + +c) Add a describe block at the end of the file: + +```ts +describe("users and members", () => { + it("getUserByUsername queries the users endpoint with an exact username", async () => { + usersAllMock.mockResolvedValue([{ id: 101, username: "jdoe" }]); + const c = new GitLabClient({ host: "https://gitlab.example.com" }); + await expect(c.getUserByUsername("jdoe")).resolves.toEqual([{ id: 101, username: "jdoe" }]); + expect(usersAllMock).toHaveBeenCalledWith({ username: "jdoe", maxPages: 1 }); + }); + + it("getUser fetches the full single-user profile", async () => { + usersShowMock.mockResolvedValue({ id: 101, bio: "hi" }); + const c = new GitLabClient({ host: "https://gitlab.example.com" }); + await expect(c.getUser(101)).resolves.toEqual({ id: 101, bio: "hi" }); + expect(usersShowMock).toHaveBeenCalledWith(101); + }); + + it("member fetches include inherited members with the 500-item ceiling", async () => { + groupMembersAllMock.mockResolvedValue([]); + projectMembersAllMock.mockResolvedValue([]); + const c = new GitLabClient({ host: "https://gitlab.example.com" }); + await c.getGroupMembers("my-group"); + expect(groupMembersAllMock).toHaveBeenCalledWith("my-group", { + includeInherited: true, + perPage: 100, + maxPages: 5, + }); + await c.getProjectMembers("group/repo"); + expect(projectMembersAllMock).toHaveBeenCalledWith("group/repo", { + includeInherited: true, + perPage: 100, + maxPages: 5, + }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm exec vitest run src/gitlab/client.test.ts` +Expected: FAIL — `c.getUserByUsername is not a function`. + +- [ ] **Step 3: Implement the client methods** + +In `src/gitlab/client.ts`, add after `getProjectMilestones` (before the private `headers()` method): + +```ts + /** Exact-username lookup (GET /users?username=...); returns 0 or 1 matches. */ + async getUserByUsername(username: string): Promise { + return this.api.Users.all({ username, maxPages: 1 }); + } + + /** Single-user GET — the only endpoint that carries the full public profile. */ + async getUser(id: number): Promise { + return this.api.Users.show(id); + } + + async getGroupMembers(group: ProjectRef, opts: PageOptions = {}): Promise { + return this.api.GroupMembers.all(group, { + includeInherited: true, + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + }); + } + + async getProjectMembers(project: ProjectRef, opts: PageOptions = {}): Promise { + return this.api.ProjectMembers.all(project, { + includeInherited: true, + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + }); + } +``` + +If TypeScript rejects an option object (gitbeaker's typings are strict about pagination generics), cast the options object `as any` — the codebase already does this for `Repositories.allContributors` and `Epics.all`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm exec vitest run src/gitlab/client.test.ts` +Expected: PASS (existing + 3 new tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: client methods for users and group/project members" +``` + +--- + +## Task 3: `fetchUser` fetcher + +**Files:** + +- Modify: `src/gitlab/fetchers.ts` +- Modify: `src/gitlab/fetchers.test.ts` + +The test file already has a `ctx(client)` helper (top of file) building a real temp-dir `FileCache` and a `vi.fn()` assets localizer that returns `/gitlab-assets/.png`. + +- [ ] **Step 1: Write the failing tests** + +In `src/gitlab/fetchers.test.ts`: add `fetchUser` to the existing import from `"./fetchers"`, then append: + +```ts +describe("fetchUser", () => { + const profile = { + id: 101, username: "jdoe", name: "Jane Doe", web_url: "https://x/jdoe", avatar_url: "https://x/a.png", + job_title: "Senior Developer", organization: "ACME", location: "Paris", bio: "Docs enthusiast", + followers: 12, following: 34, created_at: "2020-01-15T00:00:00Z", + }; + + it("resolves the username and normalizes the full profile", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 101, username: "jdoe" }]), + getUser: vi.fn(async () => profile), + }; + const c = ctx(client); + const data = await fetchUser(c, { name: "jdoe" }); + expect(client.getUserByUsername).toHaveBeenCalledWith("jdoe"); + expect(client.getUser).toHaveBeenCalledWith(101); + expect(data).toMatchObject({ + id: 101, username: "jdoe", name: "Jane Doe", webUrl: "https://x/jdoe", + jobTitle: "Senior Developer", organization: "ACME", location: "Paris", + bio: "Docs enthusiast", followers: 12, following: 34, createdAt: "2020-01-15T00:00:00Z", + }); + expect(c.assets.localize).toHaveBeenCalledWith("https://x/a.png", "", "user/jdoe"); + expect(data.avatarUrl).toMatch(/^\/gitlab-assets\//); + }); + + it("throws a clear error when the username does not exist", async () => { + const client = { getUserByUsername: vi.fn(async () => []), getUser: vi.fn() }; + await expect(fetchUser(ctx(client), { name: "nope" })).rejects.toThrow(/user "nope" not found/); + expect(client.getUser).not.toHaveBeenCalled(); + }); + + it("requires a name and rejects invalid show tokens", async () => { + await expect(fetchUser(ctx({}), {})).rejects.toThrow(/requires a "name"/); + await expect(fetchUser(ctx({}), { name: "jdoe", show: "role" })).rejects.toThrow(/does not support "role"/); + }); + + it("nulls absent profile fields and skips avatar localization when absent", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 7, username: "bob" }]), + getUser: vi.fn(async () => ({ id: 7, username: "bob", name: "Bob", web_url: "https://x/bob", avatar_url: null })), + }; + const c = ctx(client); + const data = await fetchUser(c, { name: "bob" }); + expect(data).toMatchObject({ + jobTitle: null, organization: null, location: null, bio: null, + followers: null, following: null, createdAt: null, + }); + expect(data.avatarUrl).toBeNull(); + expect(c.assets.localize).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm exec vitest run src/gitlab/fetchers.test.ts` +Expected: FAIL — `fetchUser` is not exported. + +- [ ] **Step 3: Implement `fetchUser`** + +In `src/gitlab/fetchers.ts`: + +a) Add `UserData` to the type-only import from `"./types"`, and add below the existing imports: + +```ts +import { needsProfile, parseRole, parseShow, roleName } from "./users.js"; +``` + +b) Add after the `fetchLabels` function (after its closing `}`, around line 412): + +```ts +/** + * Normalize a snake_case user/member payload to `UserData`. Fields the payload + * lacks (members payloads have no profile fields) become null. + */ +async function normalizeUser(ctx: GitLabContext, u: any, role?: string): Promise { + // Avatar URLs are absolute; the "user/" scope only namespaces the local asset. + const avatarUrl = u.avatar_url ? await ctx.assets.localize(u.avatar_url, "", `user/${u.username}`) : null; + return { + id: u.id, + username: u.username, + name: u.name ?? u.username, + webUrl: u.web_url, + avatarUrl, + ...(role === undefined ? {} : { role }), + jobTitle: u.job_title ?? null, + organization: u.organization ?? null, + location: u.location ?? null, + bio: u.bio ?? null, + followers: typeof u.followers === "number" ? u.followers : null, + following: typeof u.following === "number" ? u.following : null, + createdAt: u.created_at ?? null, + }; +} + +/** + * Resolve a username to its full profile. Memoized per username so members + * shared across pages/components cost one lookup per build; `show` never + * affects this data. + */ +async function fetchUserProfile(ctx: GitLabContext, username: string): Promise { + return memo(ctx, `user:${username}`, async () => { + const matches = await ctx.client.getUserByUsername(username); + const found = matches.find((u: any) => u.username === username); + if (!found) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: GitLab user "${username}" not found.`); + } + return normalizeUser(ctx, await ctx.client.getUser(found.id)); + }); +} + +export async function fetchUser(ctx: GitLabContext, attrs: Attrs): Promise { + const name = typeof attrs.name === "string" ? attrs.name.trim() : ""; + if (!name) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: requires a "name" (GitLab username).`); + } + parseShow(attrs.show, "GitlabUser"); // validate only; `show` is presentational (read by the component) + return fetchUserProfile(ctx, name); +} +``` + +(`needsProfile`, `parseRole`, and `roleName` are used by Task 4 — TypeScript's unused-import check passes because they're all in one import statement that `fetchUser` partially uses; if `noUnusedLocals` still complains, import only `parseShow` here and add the rest in Task 4.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm exec vitest run src/gitlab/fetchers.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: fetchUser — resolve a username to a normalized, cached profile" +``` + +--- + +## Task 4: `fetchUsers` fetcher (members + enrich-on-demand) + +**Files:** + +- Modify: `src/gitlab/fetchers.ts` +- Modify: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `src/gitlab/fetchers.test.ts`: add `fetchUsers` to the import from `"./fetchers"`, then append: + +```ts +describe("fetchUsers", () => { + // Unsorted + one duplicate (a user can be direct AND inherited member). + const members = [ + { id: 1, username: "jdoe", name: "Jane Doe", web_url: "https://x/jdoe", avatar_url: null, access_level: 50 }, + { id: 2, username: "bob", name: "Bob Martin", web_url: "https://x/bob", avatar_url: null, access_level: 30 }, + { id: 2, username: "bob", name: "Bob Martin", web_url: "https://x/bob", avatar_url: null, access_level: 30 }, + ]; + + function membersClient() { + return { + getGroupMembers: vi.fn(async () => members), + getProjectMembers: vi.fn(async () => members), + getUserByUsername: vi.fn(async (u: string) => [{ id: u === "jdoe" ? 1 : 2, username: u }]), + getUser: vi.fn(async (id: number) => ({ + id, + username: id === 1 ? "jdoe" : "bob", + name: id === 1 ? "Jane Doe" : "Bob Martin", + web_url: "https://x/u", + avatar_url: null, + bio: "hi", + followers: 1, + following: 2, + })), + }; + } + + it("requires exactly one of project/group", async () => { + await expect(fetchUsers(ctx({}), {})).rejects.toThrow(/exactly one of "project" or "group"/); + await expect(fetchUsers(ctx({}), { project: "g/r", group: "g" })).rejects.toThrow(/exactly one/); + }); + + it("lists members with role names, deduped and sorted by display name", async () => { + const client = membersClient(); + const data = await fetchUsers(ctx(client), { group: "my-group" }); + expect(client.getGroupMembers).toHaveBeenCalledWith("my-group"); + expect(data.map((u) => u.username)).toEqual(["bob", "jdoe"]); + expect(data.map((u) => u.role)).toEqual(["developer", "owner"]); + // default show="role" is identity-only → zero per-user profile calls + expect(client.getUserByUsername).not.toHaveBeenCalled(); + expect(client.getUser).not.toHaveBeenCalled(); + expect(data[0]).toMatchObject({ bio: null, followers: null, createdAt: null }); + }); + + it("filters by role (exact, case-insensitive) and applies limit", async () => { + const devs = await fetchUsers(ctx(membersClient()), { group: "my-group", role: "Developer" }); + expect(devs.map((u) => u.username)).toEqual(["bob"]); + const limited = await fetchUsers(ctx(membersClient()), { group: "my-group", limit: 1 }); + expect(limited.map((u) => u.username)).toEqual(["bob"]); + }); + + it("rejects an unknown role and a non-positive limit", async () => { + await expect(fetchUsers(ctx({}), { group: "g", role: "boss" })).rejects.toThrow(/"role" must be one of/); + await expect(fetchUsers(ctx({}), { group: "g", limit: 0 })).rejects.toThrow(/"limit" must be a positive number/); + }); + + it("enriches each member exactly once when show needs profile fields", async () => { + const client = membersClient(); + const data = await fetchUsers(ctx(client), { project: "g/r", show: "role,bio,counts" }); + expect(client.getProjectMembers).toHaveBeenCalledWith("g/r"); + expect(client.getUser).toHaveBeenCalledTimes(2); + expect(data.map((u) => u.bio)).toEqual(["hi", "hi"]); + // role comes from the members payload, not the profile + expect(data.map((u) => u.role)).toEqual(["developer", "owner"]); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm exec vitest run src/gitlab/fetchers.test.ts` +Expected: FAIL — `fetchUsers` is not exported. + +- [ ] **Step 3: Implement `fetchUsers`** + +In `src/gitlab/fetchers.ts`, add after `fetchUser`: + +```ts +export async function fetchUsers(ctx: GitLabContext, attrs: Attrs): Promise { + const project = attrs.project as string | number | undefined; + const group = attrs.group as string | number | undefined; + if ((project === undefined) === (group === undefined)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: requires exactly one of "project" or "group".`, + ); + } + const show = parseShow(attrs.show, "GitlabUsers"); + const enrich = needsProfile(show); + const role = parseRole(attrs.role); + const limit = attrs.limit === undefined ? undefined : Number(attrs.limit); + if (limit !== undefined && !(Number.isFinite(limit) && limit > 0)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "limit" must be a positive number; ` + + `got ${JSON.stringify(attrs.limit)}.`, + ); + } + const scopeKey = project !== undefined ? `p:${String(project)}` : `g:${String(group)}`; + const key = `users:${scopeKey}:role=${role ?? "all"}:limit=${limit ?? "all"}:enrich=${enrich ? 1 : 0}`; + return memo(ctx, key, async () => { + const raw = + project !== undefined + ? await ctx.client.getProjectMembers(project) + : await ctx.client.getGroupMembers(group as string | number); + // /members/all can list a user under several ancestors — keep the first occurrence. + const seen = new Set(); + let members = raw.filter((m: any) => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); + if (role !== undefined) members = members.filter((m: any) => roleName(m.access_level) === role); + members = sortByName(members, (m: any) => String(m.name ?? m.username), "asc"); + if (limit !== undefined) members = members.slice(0, limit); + if (!enrich) { + return Promise.all(members.map((m: any) => normalizeUser(ctx, m, roleName(m.access_level)))); + } + // Sequential on purpose: keeps the request pattern gentle; each profile is + // individually memoized so repeat builds and shared members are free. + const users: UserData[] = []; + for (const m of members) { + const full = await fetchUserProfile(ctx, m.username); + users.push({ ...full, role: roleName(m.access_level) }); + } + return users; + }); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm exec vitest run src/gitlab/fetchers.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: fetchUsers — group/project members with role filter and enrich-on-demand" +``` + +--- + +## Task 5: Registry entries + +**Files:** + +- Modify: `src/remark/registry.ts` + +- [ ] **Step 1: Register both components** + +Replace the full content of `src/remark/registry.ts` with: + +```ts +import { + fetchProjectInfo, + fetchReadme, + fetchReleases, + fetchIssues, + fetchFile, + fetchTopics, + fetchLabels, + fetchGroupProjects, + fetchRoadmap, + fetchUser, + fetchUsers, + type GitLabContext, +} from "../gitlab/fetchers.js"; + +export type Fetcher = (ctx: GitLabContext, attrs: Record) => Promise; + +export const COMPONENT_REGISTRY: Record = { + GitlabProjectInfo: fetchProjectInfo, + GitlabReadme: fetchReadme, + GitlabReleases: fetchReleases, + GitlabIssues: fetchIssues, + GitlabFile: fetchFile, + GitlabTopics: fetchTopics, + GitlabLabels: fetchLabels, + GitlabProjectGrid: fetchGroupProjects, + GitlabRoadmap: fetchRoadmap, + GitlabUser: fetchUser, + GitlabUsers: fetchUsers, +}; +``` + +- [ ] **Step 2: Run the remark tests (registry has no dedicated test; this catches import errors)** + +Run: `pnpm exec vitest run src/remark` +Expected: PASS (unchanged). + +- [ ] **Step 3: Commit** + +```bash +git add src/remark/registry.ts +git commit -m "feat: register GitlabUser and GitlabUsers in the remark component registry" +``` + +--- + +## Task 6: `UserCard` partial + `GitlabUser` component + +**Files:** + +- Modify: `src/components/types.ts` (re-export `UserData`) +- Create: `src/components/UserCard.tsx` +- Create: `src/components/GitlabUser.tsx` +- Create: `src/components/GitlabUser.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Create `src/components/GitlabUser.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabUser } from "./GitlabUser"; + +const user = { + id: 101, + username: "jdoe", + name: "Jane Doe", + webUrl: "https://x/jdoe", + avatarUrl: "/gitlab-assets/jdoe.png", + jobTitle: "Senior Developer", + organization: "ACME", + location: "Paris", + bio: "Docs enthusiast", + followers: 12, + following: 34, + createdAt: "2020-01-15T00:00:00Z", +}; + +describe("GitlabUser", () => { + it("renders identity and the default profile sections", () => { + render(); + expect(screen.getByRole("img", { name: "Jane Doe" })).toHaveAttribute("src", "/gitlab-assets/jdoe.png"); + expect(screen.getByRole("link", { name: "@jdoe" })).toHaveAttribute("href", "https://x/jdoe"); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("Senior Developer · ACME")).toBeInTheDocument(); + expect(screen.getByText("Paris")).toBeInTheDocument(); + expect(screen.getByText("Docs enthusiast")).toBeInTheDocument(); + expect(screen.getByText("12 followers · 34 following")).toBeInTheDocument(); + expect(screen.getByText(/Member since /)).toBeInTheDocument(); + }); + + it("show narrows the sections", () => { + render(); + expect(screen.getByText("Docs enthusiast")).toBeInTheDocument(); + expect(screen.queryByText("Paris")).not.toBeInTheDocument(); + expect(screen.queryByText(/followers/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Member since/)).not.toBeInTheDocument(); + }); + + it("skips sections whose profile field is empty and the avatar when null", () => { + render(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.queryByText("Docs enthusiast")).not.toBeInTheDocument(); + expect(screen.queryByText(/followers/)).not.toBeInTheDocument(); + expect(screen.getByText("Paris")).toBeInTheDocument(); + }); + + it("renders partial follower counts when only one side is known", () => { + render(); + expect(screen.getByText("12 followers")).toBeInTheDocument(); + }); + + it("renders nothing without data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the Fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("not found"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm exec vitest run src/components/GitlabUser.test.tsx` +Expected: FAIL — cannot resolve `./GitlabUser`. + +- [ ] **Step 3: Re-export the type** + +In `src/components/types.ts`, add `UserData,` to the export list (after `GroupProjectData,`): + +```ts +export type { + ProjectInfoData, + ReleaseData, + CommitData, + IssueData, + ReadmeData, + FileData, + TopicData, + LabelData, + GroupProjectData, + UserData, + FetchError, + ComponentPayload, + LabelRef, + RoadmapSource, + RoadmapState, + RoadmapScale, + RoadmapItemData, + RoadmapPositionedItem, + ScaleTick, + RoadmapGroup, + RoadmapData, +} from "../gitlab/types.js"; +``` + +- [ ] **Step 4: Implement `UserCard`** + +Create `src/components/UserCard.tsx` (internal partial — not exported from `index.ts`): + +```tsx +import React from "react"; +import { formatDate } from "./formatDate.js"; +import type { UserShowToken } from "../gitlab/users.js"; +import type { UserData } from "./types.js"; + +/** One user card. `show` is the parsed token list; identity always renders. */ +export function UserCard({ user, show }: { user: UserData; show: readonly UserShowToken[] }) { + const has = (t: UserShowToken) => show.includes(t); + const orgLine = [user.jobTitle, user.organization].filter(Boolean).join(" · "); + const counts = [ + user.followers !== null ? `${user.followers} followers` : null, + user.following !== null ? `${user.following} following` : null, + ] + .filter(Boolean) + .join(" · "); + return ( +
+
+ {user.avatarUrl && ( + {user.name} + )} +
+ {user.name} + + @{user.username} + + {has("role") && user.role && {user.role}} +
+
+ {has("org") && orgLine &&

{orgLine}

} + {has("location") && user.location &&

{user.location}

} + {has("bio") && user.bio &&

{user.bio}

} + {has("counts") && counts &&

{counts}

} + {has("since") && user.createdAt && ( +

Member since {formatDate(user.createdAt)}

+ )} +
+ ); +} +``` + +Note: `user.bio` is plain text from the API and renders as text — **never** via `dangerouslySetInnerHTML`. + +- [ ] **Step 5: Implement `GitlabUser`** + +Create `src/components/GitlabUser.tsx`: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import { UserCard } from "./UserCard.js"; +import { parseShow } from "../gitlab/users.js"; +import type { ComponentPayload, UserData } from "./types.js"; + +interface GitlabUserProps extends ComponentPayload { + /** Comma-separated card sections; validated at build time by the fetcher. */ + show?: string; +} + +export function GitlabUser({ data, error, show }: GitlabUserProps) { + if (error) return ; + if (!data) return null; + return ; +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `pnpm exec vitest run src/components/GitlabUser.test.tsx` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/types.ts src/components/UserCard.tsx src/components/GitlabUser.tsx src/components/GitlabUser.test.tsx +git commit -m "feat: GitlabUser component with shared UserCard partial" +``` + +--- + +## Task 7: `GitlabUsers` component (cards grid, ComponentLayout) + +**Files:** + +- Create: `src/components/GitlabUsers.tsx` +- Create: `src/components/GitlabUsers.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Create `src/components/GitlabUsers.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabUsers } from "./GitlabUsers"; + +const users = [ + { + id: 101, username: "jdoe", name: "Jane Doe", webUrl: "https://x/jdoe", avatarUrl: null, role: "owner", + jobTitle: null, organization: null, location: null, bio: null, followers: null, following: null, createdAt: null, + }, + { + id: 102, username: "bob", name: "Bob Martin", webUrl: "https://x/bob", avatarUrl: null, role: "developer", + jobTitle: "Dev", organization: "ACME", location: null, bio: null, followers: 2, following: 3, createdAt: null, + }, +]; + +describe("GitlabUsers", () => { + it("renders one card per member with a role badge by default", () => { + render(); + expect(screen.getByRole("link", { name: "@jdoe" })).toHaveAttribute("href", "https://x/jdoe"); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("Bob Martin")).toBeInTheDocument(); + expect(screen.getByText("owner")).toBeInTheDocument(); + expect(screen.getByText("developer")).toBeInTheDocument(); + // default list card is identity + role only + expect(screen.queryByText("Dev · ACME")).not.toBeInTheDocument(); + }); + + it("adds profile sections to every card via show", () => { + render(); + expect(screen.getByText("Dev · ACME")).toBeInTheDocument(); + expect(screen.getByText("2 followers · 3 following")).toBeInTheDocument(); + expect(screen.getByText("owner")).toBeInTheDocument(); + }); + + it("uses a responsive auto-fill grid with a 260px default min width", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-user-cards")).toHaveStyle({ + gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", + }); + }); + + it("lets cardColumns and gap tune the grid (ComponentLayout)", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-user-cards")).toHaveStyle({ + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: "1.5rem", + }); + }); + + it("renders an empty grid for an empty member list", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-user-cards")).toBeEmptyDOMElement(); + }); + + it("renders the Fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm exec vitest run src/components/GitlabUsers.test.tsx` +Expected: FAIL — cannot resolve `./GitlabUsers`. + +- [ ] **Step 3: Implement `GitlabUsers`** + +Create `src/components/GitlabUsers.tsx`: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import { UserCard } from "./UserCard.js"; +import { cardsGridStyle, type ComponentLayout } from "./layout.js"; +import { parseShow } from "../gitlab/users.js"; +import type { ComponentPayload, UserData } from "./types.js"; + +interface GitlabUsersProps extends ComponentPayload, ComponentLayout { + /** Comma-separated card sections; validated at build time by the fetcher. */ + show?: string; +} + +export function GitlabUsers({ + data, + error, + show, + cardColumns, + cardMinWidth, + gap, + maxWidth, + align, +}: GitlabUsersProps) { + if (error) return ; + if (!data) return null; + const tokens = parseShow(show, "GitlabUsers"); + return ( +
+ {data.map((u) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm exec vitest run src/components/GitlabUsers.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/GitlabUsers.tsx src/components/GitlabUsers.test.tsx +git commit -m "feat: GitlabUsers component — members cards grid with ComponentLayout props" +``` + +--- + +## Task 8: Public exports + typecheck + +**Files:** + +- Modify: `src/components/index.ts` +- Modify: `src/index.ts` + +- [ ] **Step 1: Export the components** + +In `src/components/index.ts`, add after `export { GitlabRoadmap } from "./GitlabRoadmap.js";`: + +```ts +export { GitlabUser } from "./GitlabUser.js"; +export { GitlabUsers } from "./GitlabUsers.js"; +``` + +and add `UserData,` to the type re-export list in the same file (after `GroupProjectData,`). + +- [ ] **Step 2: Export the domain type from the package root** + +In `src/index.ts`, add `UserData,` to the `export type { ... } from "./gitlab/types.js";` list (after `GroupProjectData,`). + +- [ ] **Step 3: Verify** + +Run: `pnpm run typecheck` +Expected: exit 0, no errors. + +Run: `pnpm exec vitest run src test/packaging.test.ts` +Expected: PASS — all unit tests green, including the packaging guard. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/index.ts src/index.ts +git commit -m "feat: export GitlabUser, GitlabUsers and UserData" +``` + +--- + +## Task 9: e2e fixtures, example pages, README + +**Files:** + +- Modify: `test/e2e/fixtures.ts` (stub routes) +- Modify: `test/e2e/build.test.ts` (new assertion) +- Modify: `examples/site/docs/intro.mdx` (live usage — this is what the e2e asserts) +- Create: `examples/site/docs/components/user.mdx` +- Create: `examples/site/docs/components/users.mdx` +- Modify: `README.md` (component docs) + +- [ ] **Step 1: Add stub users + members routes** + +In `test/e2e/fixtures.ts`: + +a) Add above `startGitlabStub` (module scope, after `ONE_PX_PNG`): + +```ts +/** Stub users: jdoe is a group owner with a full profile, bob a developer with a sparse one. */ +const STUB_USERS: Record = { + jdoe: { + id: 101, username: "jdoe", name: "Jane Doe", avatar_url: null, web_url: "https://x/jdoe", + job_title: "Senior Developer", organization: "ACME", location: "Paris", bio: "Docs enthusiast", + followers: 12, following: 34, created_at: "2020-01-15T00:00:00Z", + }, + bob: { + id: 102, username: "bob", name: "Bob Martin", avatar_url: null, web_url: "https://x/bob", + followers: 2, following: 3, created_at: "2021-06-01T00:00:00Z", + }, +}; + +const STUB_MEMBERS = [ + { id: 101, username: "jdoe", name: "Jane Doe", avatar_url: null, web_url: "https://x/jdoe", access_level: 50 }, + { id: 102, username: "bob", name: "Bob Martin", avatar_url: null, web_url: "https://x/bob", access_level: 30 }, +]; +``` + +b) Add these routes inside the request handler, immediately **after** the `/api/v4/topics` block and **before** the `/api/v4/groups/my-group/projects` block (the members route must precede the bare `/api/v4/groups/my-group` catch-all): + +```ts + if (url.startsWith("/api/v4/groups/my-group/members/all")) { + return send(STUB_MEMBERS); + } + if (url.startsWith("/api/v4/projects/group%2Frepo/members/all")) { + return send(STUB_MEMBERS); + } + if (url.startsWith("/api/v4/users/")) { + const id = Number(url.slice("/api/v4/users/".length).split("?")[0]); + const user = Object.values(STUB_USERS).find((u) => u.id === id); + if (user) return send(user); + res.writeHead(404); + return res.end("not found"); + } + if (url.startsWith("/api/v4/users")) { + const username = new URL(url, "http://stub").searchParams.get("username") ?? ""; + return send(STUB_USERS[username] ? [STUB_USERS[username]] : []); + } +``` + +(Note: the project members route must also precede the bare `/api/v4/projects/group%2Frepo` catch-all — it does, since that catch-all is last.) + +- [ ] **Step 2: Add live usage to the example index page** + +Append to `examples/site/docs/intro.mdx`: + +```mdx + +## Team + + + + +``` + +- [ ] **Step 3: Add the e2e assertion** + +In `test/e2e/build.test.ts`, add after the `"bakes topics and labels into the static html"` test: + +```ts + it("bakes user cards into the static html", () => { + const html = readFileSync(join(siteDir, "build", "index.html"), "utf8"); + // single card: identity + default profile sections + expect(html).toContain("Jane Doe"); + expect(html).toContain("@jdoe"); + expect(html).toContain("12 followers"); + expect(html).toContain("Member since"); + // members grid: both members, role badges, enriched org line + expect(html).toContain("gitlab-user-cards"); + expect(html).toContain("Bob Martin"); + expect(html).toContain("owner"); + expect(html).toContain("Senior Developer · ACME"); + }); +``` + +- [ ] **Step 4: Run the e2e test (slow, ~1 min)** + +Run: `pnpm exec vitest run test/e2e/build.test.ts` +Expected: PASS, including the new user-cards assertion. + +- [ ] **Step 5: Write the component doc pages** + +Create `examples/site/docs/components/user.mdx`: + +````mdx +--- +title: GitlabUser +sidebar_position: 11 +--- + +# `` + +Renders a GitLab user profile as a small card: photo, display name, linked +`@username`, and a configurable set of profile sections. All data comes from the +public user API at build time — nothing is fetched in the browser. + +## Usage + +```mdx + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | `string` | **required** | GitLab username (login). | +| `show` | `string` | `org,location,bio,counts,since` | Comma-separated card sections (see below). | + +## `show` tokens + +The avatar, display name, and linked `@username` always render. Tokens add sections: + +| Token | Section | +|---|---| +| `org` | Job title · organization | +| `location` | Location line | +| `bio` | Bio paragraph (plain text) | +| `counts` | Followers · following | +| `since` | "Member since " | + +An empty `show=""` renders an identity-only card. Sections whose profile field is +empty are skipped automatically. + +## Notes + +- The username must exist; an unknown user fails the build in strict mode (renders + the fallback otherwise). +- The avatar is downloaded at build time and served from your site's static assets. +```` + +Create `examples/site/docs/components/users.mdx`: + +````mdx +--- +title: GitlabUsers +sidebar_position: 12 +--- + +# `` + +Renders the members of a **group** or a **project** (including inherited members) +as a grid of user cards, optionally filtered by role. + +## Usage + +```mdx + + + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `group` | `string \| number` | — | Group path or numeric ID. Provide **either** this or `project`. | +| `project` | `string \| number` | — | Project path or numeric ID. Provide **either** this or `group`. | +| `role` | `string` | — | Exact-match filter (case-insensitive): `minimal`, `guest`, `planner`, `reporter`, `developer`, `maintainer`, `owner`. | +| `show` | `string` | `role` | Same tokens as ``, plus `role` (a role badge on each card). | +| `limit` | `number` | all | Maximum members to show (applied after the role filter; the fetch itself is capped at 500). | + +## Grid layout + +The grid shares the standard card-grid props: + +| Prop | Type | Default | Description | +|---|---|---|---| +| `cardColumns` | `number` | — | Fixed number of columns. Takes precedence over `cardMinWidth`. | +| `cardMinWidth` | `string` | `260px` | Minimum card width for a responsive (auto-fill) grid. | +| `gap` | `string` | — | Spacing between cards, e.g. `"1.5rem"`. | +| `maxWidth` | `string` | — | Constrain the grid width, e.g. `"900px"`. | +| `align` | `string` | `start` | `start` or `center` — placement of a width-constrained grid. | + +## Build cost + +The default `show="role"` needs a **single** members API call. Any profile token +(`org`, `location`, `bio`, `counts`, `since`) triggers one extra user lookup per +member at build time (cached on disk, deduplicated across pages and components). + +## Notes + +- Exactly one of `group` / `project` is required; both or neither fails the build. +- Members are fetched **including inherited** ones, like GitLab's members page. +- Cards are sorted by display name; an unknown `role` value fails the build. +```` + +- [ ] **Step 6: Add the README sections** + +In `README.md`, insert between the end of the `### ` section (after the "Both components render scoped labels…" paragraph, ~line 262) and `### `: + +````markdown +### `` + +A user profile as a small card: photo, display name, linked `@username`, and +configurable profile sections from the public user API. + +```mdx + + + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | string | required | GitLab username | +| `show` | string | `org,location,bio,counts,since` | Card sections: `org`, `location`, `bio`, `counts` (followers/following), `since` (member since) | + +### `` + +The members of a group **or** project (inherited included) as a grid of user cards. + +```mdx + + + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `group` | string \| number | — | Provide either `group` or `project` | +| `project` | string \| number | — | Provide either `group` or `project` | +| `role` | string | — | Exact-match filter: `guest`, `reporter`, `developer`, `maintainer`, `owner`, … | +| `show` | string | `role` | `` tokens plus `role` (role badge) | +| `limit` | number | all | Max members to show (fetch capped at 500) | + +The grid accepts the shared card-grid props: `cardColumns`, `cardMinWidth` +(default `260px`), `gap`, `maxWidth`, `align`. The default `show="role"` costs a +single members call; profile tokens add one cached user lookup per member at +build time. +```` + +- [ ] **Step 7: Commit** + +```bash +git add test/e2e/fixtures.ts test/e2e/build.test.ts examples/site/docs/intro.mdx examples/site/docs/components/user.mdx examples/site/docs/components/users.mdx README.md +git commit -m "docs: GitlabUser/GitlabUsers pages, README sections, and e2e coverage" +``` + +--- + +## Task 10: Final verification + +- [ ] **Step 1: Full unit suite** + +Run: `pnpm test` +Expected: PASS, zero failures. + +- [ ] **Step 2: Typecheck + package build** + +Run: `pnpm run typecheck && pnpm run build` +Expected: both exit 0; `dist/` contains `components/GitlabUser.js`, `components/GitlabUsers.js`, `gitlab/users.js`. + +- [ ] **Step 3: e2e (if not already run in Task 9)** + +Run: `pnpm exec vitest run test/e2e/build.test.ts` +Expected: PASS. + +- [ ] **Step 4: Update the knowledge graph** + +Run: `graphify update .` +Expected: completes without error (AST-only). + +- [ ] **Step 5: Verify all commits are signed** + +Run: `git log --format="%h %G? %s" -10` +Expected: every new commit shows `G`. From 13c7624d03bab70759f3ba65d61baad0642e35db Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 21:47:21 +0200 Subject: [PATCH 05/24] feat: shared show/role helpers and UserData type for user components Signed-off-by: Thomas Decaux --- src/gitlab/types.ts | 19 ++++++++++ src/gitlab/users.test.ts | 67 ++++++++++++++++++++++++++++++++++ src/gitlab/users.ts | 78 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/gitlab/users.test.ts create mode 100644 src/gitlab/users.ts diff --git a/src/gitlab/types.ts b/src/gitlab/types.ts index 4c5e9c0..5b01379 100644 --- a/src/gitlab/types.ts +++ b/src/gitlab/types.ts @@ -124,6 +124,25 @@ export interface GroupProjectData { topics: string[]; } +export interface UserData { + id: number; + username: string; + name: string; + webUrl: string; + /** Localized via AssetManager; null when the user has no avatar. */ + avatarUrl: string | null; + /** Role name (e.g. "developer"); set only for members lists. */ + role?: string; + // Profile fields — null when not enriched or absent on the profile. + jobTitle: string | null; + organization: string | null; + location: string | null; + bio: string | null; + followers: number | null; + following: number | null; + createdAt: string | null; +} + /** A GitLab label reduced to what the roadmap renders. */ export interface LabelRef { name: string; diff --git a/src/gitlab/users.test.ts b/src/gitlab/users.test.ts new file mode 100644 index 0000000..54b23fa --- /dev/null +++ b/src/gitlab/users.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { parseShow, needsProfile, roleName, parseRole } from "./users"; + +describe("parseShow", () => { + it("applies per-component defaults", () => { + expect(parseShow(undefined, "GitlabUser")).toEqual(["org", "location", "bio", "counts", "since"]); + expect(parseShow(undefined, "GitlabUsers")).toEqual(["role"]); + }); + + it("parses a comma-separated list, trimming whitespace", () => { + expect(parseShow(" bio , counts ", "GitlabUser")).toEqual(["bio", "counts"]); + }); + + it("allows an empty string (identity-only card)", () => { + expect(parseShow("", "GitlabUser")).toEqual([]); + }); + + it("rejects unknown tokens", () => { + expect(() => parseShow("bogus", "GitlabUser")).toThrow(/"show" token "bogus"/); + }); + + it("rejects non-string values", () => { + expect(() => parseShow(5, "GitlabUsers")).toThrow(/"show" must be a comma-separated string/); + }); + + it("rejects the role token on GitlabUser but allows it on GitlabUsers", () => { + expect(() => parseShow("role", "GitlabUser")).toThrow(/does not support "role"/); + expect(parseShow("role", "GitlabUsers")).toEqual(["role"]); + }); +}); + +describe("needsProfile", () => { + it("is false for identity/role-only tokens", () => { + expect(needsProfile([])).toBe(false); + expect(needsProfile(["role"])).toBe(false); + }); + + it("is true when any profile token is present", () => { + expect(needsProfile(["role", "bio"])).toBe(true); + expect(needsProfile(["counts"])).toBe(true); + expect(needsProfile(["org"])).toBe(true); + expect(needsProfile(["since"])).toBe(true); + expect(needsProfile(["location"])).toBe(true); + }); +}); + +describe("roles", () => { + it("maps GitLab access levels to role names", () => { + expect(roleName(5)).toBe("minimal"); + expect(roleName(10)).toBe("guest"); + expect(roleName(15)).toBe("planner"); + expect(roleName(20)).toBe("reporter"); + expect(roleName(30)).toBe("developer"); + expect(roleName(40)).toBe("maintainer"); + expect(roleName(50)).toBe("owner"); + }); + + it("falls back to the numeric value for unknown levels", () => { + expect(roleName(99)).toBe("99"); + }); + + it("parseRole validates case-insensitively; undefined means no filter", () => { + expect(parseRole(undefined)).toBeUndefined(); + expect(parseRole("Developer")).toBe("developer"); + expect(() => parseRole("boss")).toThrow(/"role" must be one of/); + }); +}); diff --git a/src/gitlab/users.ts b/src/gitlab/users.ts new file mode 100644 index 0000000..13c2b7a --- /dev/null +++ b/src/gitlab/users.ts @@ -0,0 +1,78 @@ +/** + * `show`-token and role helpers shared by the user fetchers (Node) and the + * user components (browser bundle). Keep this module pure — no Node, gitbeaker, + * or fetcher imports — so Docusaurus can bundle it for the browser. + */ + +export const USER_SHOW_TOKENS = ["org", "location", "bio", "counts", "since", "role"] as const; +export type UserShowToken = (typeof USER_SHOW_TOKENS)[number]; + +/** Tokens whose card section needs the full user profile (GET /users/:id). */ +const PROFILE_TOKENS: readonly UserShowToken[] = ["org", "location", "bio", "counts", "since"]; + +export const DEFAULT_USER_SHOW = "org,location,bio,counts,since"; +export const DEFAULT_USERS_SHOW = "role"; + +type UserComponent = "GitlabUser" | "GitlabUsers"; + +/** Parse + validate a `show` attribute into tokens; applies the per-component default. */ +export function parseShow(value: unknown, component: UserComponent): UserShowToken[] { + const raw = + value === undefined ? (component === "GitlabUser" ? DEFAULT_USER_SHOW : DEFAULT_USERS_SHOW) : value; + if (typeof raw !== "string") { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: <${component}> "show" must be a comma-separated string; ` + + `got ${JSON.stringify(value)}.`, + ); + } + const tokens = raw.split(",").map((s) => s.trim()).filter(Boolean); + for (const token of tokens) { + if (!(USER_SHOW_TOKENS as readonly string[]).includes(token)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: <${component}> "show" token ${JSON.stringify(token)} ` + + `is not one of ${USER_SHOW_TOKENS.join(", ")}.`, + ); + } + if (token === "role" && component === "GitlabUser") { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "show" does not support "role" (members lists only).`, + ); + } + } + return tokens as UserShowToken[]; +} + +/** True when any requested section needs the full user profile (drives enrichment). */ +export function needsProfile(tokens: readonly UserShowToken[]): boolean { + return tokens.some((t) => PROFILE_TOKENS.includes(t)); +} + +/** GitLab numeric access levels → role names (see the members API docs). */ +const ROLE_BY_ACCESS_LEVEL: Record = { + 5: "minimal", + 10: "guest", + 15: "planner", + 20: "reporter", + 30: "developer", + 40: "maintainer", + 50: "owner", +}; + +const ROLE_NAMES = Object.values(ROLE_BY_ACCESS_LEVEL); + +export function roleName(accessLevel: number): string { + return ROLE_BY_ACCESS_LEVEL[accessLevel] ?? String(accessLevel); +} + +/** Validate the `role` attribute (case-insensitive); undefined = no filter. */ +export function parseRole(value: unknown): string | undefined { + if (value === undefined) return undefined; + const role = String(value).toLowerCase(); + if (!ROLE_NAMES.includes(role)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "role" must be one of ${ROLE_NAMES.join(", ")}; ` + + `got ${JSON.stringify(value)}.`, + ); + } + return role; +} From 48a5d1fb21d68fa4539e532b195602a53a8ddb53 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 21:52:41 +0200 Subject: [PATCH 06/24] feat: client methods for users and group/project members Signed-off-by: Thomas Decaux --- src/gitlab/client.test.ts | 45 +++++++++++++++++++++++++++++++++++++++ src/gitlab/client.ts | 26 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/gitlab/client.test.ts b/src/gitlab/client.test.ts index 8521cf3..ed07aac 100644 --- a/src/gitlab/client.test.ts +++ b/src/gitlab/client.test.ts @@ -14,6 +14,10 @@ const contributorsAllMock = vi.fn(); const epicsAllMock = vi.fn(); const groupMilestonesAllMock = vi.fn(); const projectMilestonesAllMock = vi.fn(); +const usersAllMock = vi.fn(); +const usersShowMock = vi.fn(); +const groupMembersAllMock = vi.fn(); +const projectMembersAllMock = vi.fn(); vi.mock("@gitbeaker/rest", () => ({ // Vitest 4 invokes the mock implementation as a real constructor under @@ -34,6 +38,9 @@ vi.mock("@gitbeaker/rest", () => ({ Epics: { all: epicsAllMock }, GroupMilestones: { all: groupMilestonesAllMock }, ProjectMilestones: { all: projectMilestonesAllMock }, + Users: { all: usersAllMock, show: usersShowMock }, + GroupMembers: { all: groupMembersAllMock }, + ProjectMembers: { all: projectMembersAllMock }, }; }), })); @@ -58,6 +65,10 @@ beforeEach(() => { epicsAllMock.mockReset(); groupMilestonesAllMock.mockReset(); projectMilestonesAllMock.mockReset(); + usersAllMock.mockReset(); + usersShowMock.mockReset(); + groupMembersAllMock.mockReset(); + projectMembersAllMock.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); @@ -291,3 +302,37 @@ describe("roadmap sources", () => { expect(projectMilestonesAllMock).toHaveBeenCalledWith("p/x", { perPage: 100, maxPages: 5 }); }); }); + +describe("users and members", () => { + it("getUserByUsername queries the users endpoint with an exact username", async () => { + usersAllMock.mockResolvedValue([{ id: 101, username: "jdoe" }]); + const c = new GitLabClient({ host: "https://gitlab.example.com" }); + await expect(c.getUserByUsername("jdoe")).resolves.toEqual([{ id: 101, username: "jdoe" }]); + expect(usersAllMock).toHaveBeenCalledWith({ username: "jdoe", maxPages: 1 }); + }); + + it("getUser fetches the full single-user profile", async () => { + usersShowMock.mockResolvedValue({ id: 101, bio: "hi" }); + const c = new GitLabClient({ host: "https://gitlab.example.com" }); + await expect(c.getUser(101)).resolves.toEqual({ id: 101, bio: "hi" }); + expect(usersShowMock).toHaveBeenCalledWith(101); + }); + + it("member fetches include inherited members with the 500-item ceiling", async () => { + groupMembersAllMock.mockResolvedValue([]); + projectMembersAllMock.mockResolvedValue([]); + const c = new GitLabClient({ host: "https://gitlab.example.com" }); + await c.getGroupMembers("my-group"); + expect(groupMembersAllMock).toHaveBeenCalledWith("my-group", { + includeInherited: true, + perPage: 100, + maxPages: 5, + }); + await c.getProjectMembers("group/repo"); + expect(projectMembersAllMock).toHaveBeenCalledWith("group/repo", { + includeInherited: true, + perPage: 100, + maxPages: 5, + }); + }); +}); diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts index 88be261..efe9c3a 100644 --- a/src/gitlab/client.ts +++ b/src/gitlab/client.ts @@ -142,6 +142,32 @@ export class GitLabClient { return this.api.ProjectMilestones.all(project, { perPage: DEFAULT_PER_PAGE, maxPages: DEFAULT_MAX_PAGES }); } + /** Exact-username lookup (GET /users?username=...); returns 0 or 1 matches. */ + async getUserByUsername(username: string): Promise { + return this.api.Users.all({ username, maxPages: 1 }); + } + + /** Single-user GET — the only endpoint that carries the full public profile. */ + async getUser(id: number): Promise { + return this.api.Users.show(id); + } + + async getGroupMembers(group: ProjectRef, opts: PageOptions = {}): Promise { + return this.api.GroupMembers.all(group, { + includeInherited: true, + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + }); + } + + async getProjectMembers(project: ProjectRef, opts: PageOptions = {}): Promise { + return this.api.ProjectMembers.all(project, { + includeInherited: true, + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + }); + } + private headers(): Record { const h: Record = { Accept: "application/json" }; if (this.config.token) h["PRIVATE-TOKEN"] = this.config.token; From 01ed8c51d4e5dc9200fdc470f831c70f2ff7f8d5 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 22:02:03 +0200 Subject: [PATCH 07/24] docs: broaden PageOptions comment to cover member endpoints Signed-off-by: Thomas Decaux --- src/gitlab/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts index efe9c3a..c038d7e 100644 --- a/src/gitlab/client.ts +++ b/src/gitlab/client.ts @@ -25,7 +25,7 @@ export interface EpicsQuery { sort?: string; } -/** Pagination for the topic/label list endpoints. Defaults cap the fetch at 500 items. */ +/** Pagination for the topic/label/member list endpoints. Defaults cap the fetch at 500 items. */ export interface PageOptions { perPage?: number; maxPages?: number; From fb65916584099e9811f2956035051c92ec83caaa Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 22:03:55 +0200 Subject: [PATCH 08/24] =?UTF-8?q?feat:=20fetchUser=20=E2=80=94=20resolve?= =?UTF-8?q?=20a=20username=20to=20a=20normalized,=20cached=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Decaux --- src/gitlab/fetchers.test.ts | 54 ++++++++++++++++++++++++++++++++++++- src/gitlab/fetchers.ts | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index a221a74..4e0e83e 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -6,7 +6,7 @@ import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { describe, it, expect, vi } from "vitest"; import { FileCache } from "./cache"; -import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects, fetchRoadmap } from "./fetchers"; +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects, fetchRoadmap, fetchUser } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -780,3 +780,55 @@ describe("fetchRoadmap (milestones)", () => { expect(items.map((i) => i.title)).toEqual(["v1.0"]); }); }); + +describe("fetchUser", () => { + const profile = { + id: 101, username: "jdoe", name: "Jane Doe", web_url: "https://x/jdoe", avatar_url: "https://x/a.png", + job_title: "Senior Developer", organization: "ACME", location: "Paris", bio: "Docs enthusiast", + followers: 12, following: 34, created_at: "2020-01-15T00:00:00Z", + }; + + it("resolves the username and normalizes the full profile", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 101, username: "jdoe" }]), + getUser: vi.fn(async () => profile), + }; + const c = ctx(client); + const data = await fetchUser(c, { name: "jdoe" }); + expect(client.getUserByUsername).toHaveBeenCalledWith("jdoe"); + expect(client.getUser).toHaveBeenCalledWith(101); + expect(data).toMatchObject({ + id: 101, username: "jdoe", name: "Jane Doe", webUrl: "https://x/jdoe", + jobTitle: "Senior Developer", organization: "ACME", location: "Paris", + bio: "Docs enthusiast", followers: 12, following: 34, createdAt: "2020-01-15T00:00:00Z", + }); + expect(c.assets.localize).toHaveBeenCalledWith("https://x/a.png", "", "user/jdoe"); + expect(data.avatarUrl).toMatch(/^\/gitlab-assets\//); + }); + + it("throws a clear error when the username does not exist", async () => { + const client = { getUserByUsername: vi.fn(async () => []), getUser: vi.fn() }; + await expect(fetchUser(ctx(client), { name: "nope" })).rejects.toThrow(/user "nope" not found/); + expect(client.getUser).not.toHaveBeenCalled(); + }); + + it("requires a name and rejects invalid show tokens", async () => { + await expect(fetchUser(ctx({}), {})).rejects.toThrow(/requires a "name"/); + await expect(fetchUser(ctx({}), { name: "jdoe", show: "role" })).rejects.toThrow(/does not support "role"/); + }); + + it("nulls absent profile fields and skips avatar localization when absent", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 7, username: "bob" }]), + getUser: vi.fn(async () => ({ id: 7, username: "bob", name: "Bob", web_url: "https://x/bob", avatar_url: null })), + }; + const c = ctx(client); + const data = await fetchUser(c, { name: "bob" }); + expect(data).toMatchObject({ + jobTitle: null, organization: null, location: null, bio: null, + followers: null, following: null, createdAt: null, + }); + expect(data.avatarUrl).toBeNull(); + expect(c.assets.localize).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 19266cf..635be4e 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -24,7 +24,9 @@ import type { RoadmapItemData, RoadmapScale, TopicData, + UserData, } from "./types"; +import { parseShow } from "./users.js"; export interface GitLabContext { client: GitLabClient; @@ -411,6 +413,55 @@ export async function fetchLabels(ctx: GitLabContext, attrs: Attrs): Promise { + // Avatar URLs are absolute; the "user/" scope only namespaces the local asset. + const avatarUrl = u.avatar_url ? await ctx.assets.localize(u.avatar_url, "", `user/${u.username}`) : null; + return { + id: u.id, + username: u.username, + name: u.name ?? u.username, + webUrl: u.web_url, + avatarUrl, + ...(role === undefined ? {} : { role }), + jobTitle: u.job_title ?? null, + organization: u.organization ?? null, + location: u.location ?? null, + bio: u.bio ?? null, + followers: typeof u.followers === "number" ? u.followers : null, + following: typeof u.following === "number" ? u.following : null, + createdAt: u.created_at ?? null, + }; +} + +/** + * Resolve a username to its full profile. Memoized per username so members + * shared across pages/components cost one lookup per build; `show` never + * affects this data. + */ +async function fetchUserProfile(ctx: GitLabContext, username: string): Promise { + return memo(ctx, `user:${username}`, async () => { + const matches = await ctx.client.getUserByUsername(username); + const found = matches.find((u: any) => u.username === username); + if (!found) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: GitLab user "${username}" not found.`); + } + return normalizeUser(ctx, await ctx.client.getUser(found.id)); + }); +} + +export async function fetchUser(ctx: GitLabContext, attrs: Attrs): Promise { + const name = typeof attrs.name === "string" ? attrs.name.trim() : ""; + if (!name) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: requires a "name" (GitLab username).`); + } + parseShow(attrs.show, "GitlabUser"); // validate only; `show` is presentational (read by the component) + return fetchUserProfile(ctx, name); +} + /** Parse a topic list attribute: `["a","b"]`, `"a,b"`, or undefined → string[]. */ function parseTopicList(value: unknown): string[] { if (Array.isArray(value)) return value.map(String).map((s) => s.trim()).filter(Boolean); From b0c9844c0c2315693775dd386c30b2c978302e5f Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 22:55:40 +0200 Subject: [PATCH 09/24] fix: case-insensitive username resolution and canonical memo key in fetchUser Signed-off-by: Thomas Decaux --- src/gitlab/fetchers.test.ts | 40 +++++++++++++++++++++++++++++++++++++ src/gitlab/fetchers.ts | 4 ++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index 4e0e83e..dbe6ca2 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -831,4 +831,44 @@ describe("fetchUser", () => { expect(data.avatarUrl).toBeNull(); expect(c.assets.localize).not.toHaveBeenCalled(); }); + + it("resolves the username case-insensitively", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 5, username: "tdecaux" }]), + getUser: vi.fn(async () => ({ id: 5, username: "tdecaux", name: "T Decaux", web_url: "https://x/tdecaux" })), + }; + const c = ctx(client); + await expect(fetchUser(c, { name: "TDecaux" })).resolves.toMatchObject({ id: 5, username: "tdecaux" }); + expect(client.getUser).toHaveBeenCalledWith(5); + }); + + it("memoizes so a repeated lookup hits the network once", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 101, username: "jdoe" }]), + getUser: vi.fn(async () => profile), + }; + const c = ctx(client); + await fetchUser(c, { name: "jdoe" }); + await fetchUser(c, { name: "jdoe" }); + expect(client.getUserByUsername).toHaveBeenCalledTimes(1); + expect(client.getUser).toHaveBeenCalledTimes(1); + }); + + it("falls back to the username when the profile has no name", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 9, username: "noname" }]), + getUser: vi.fn(async () => ({ id: 9, username: "noname", name: null, web_url: "https://x/noname" })), + }; + const data = await fetchUser(ctx(client), { name: "noname" }); + expect(data.name).toBe("noname"); + }); + + it("trims the name attribute before resolving", async () => { + const client = { + getUserByUsername: vi.fn(async () => [{ id: 101, username: "jdoe" }]), + getUser: vi.fn(async () => profile), + }; + await fetchUser(ctx(client), { name: " jdoe " }); + expect(client.getUserByUsername).toHaveBeenCalledWith("jdoe"); + }); }); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 635be4e..6746a98 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -443,9 +443,9 @@ async function normalizeUser(ctx: GitLabContext, u: any, role?: string): Promise * affects this data. */ async function fetchUserProfile(ctx: GitLabContext, username: string): Promise { - return memo(ctx, `user:${username}`, async () => { + return memo(ctx, `userByName:${username.toLowerCase()}`, async () => { const matches = await ctx.client.getUserByUsername(username); - const found = matches.find((u: any) => u.username === username); + const found = matches.find((u: any) => String(u.username).toLowerCase() === username.toLowerCase()); if (!found) { throw new Error(`@ebuildy/docusaurus-plugin-gitlab: GitLab user "${username}" not found.`); } From ce5b46c48c7b71292e37a85b271d36aec20f5071 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 22:58:41 +0200 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20fetchUsers=20=E2=80=94=20group/pr?= =?UTF-8?q?oject=20members=20with=20role=20filter=20and=20enrich-on-demand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Decaux --- src/gitlab/fetchers.test.ts | 68 ++++++++++++++++++++++++++++++++++++- src/gitlab/fetchers.ts | 51 +++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index dbe6ca2..ebeac42 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -6,7 +6,7 @@ import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { describe, it, expect, vi } from "vitest"; import { FileCache } from "./cache"; -import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects, fetchRoadmap, fetchUser } from "./fetchers"; +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects, fetchRoadmap, fetchUser, fetchUsers } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -872,3 +872,69 @@ describe("fetchUser", () => { expect(client.getUserByUsername).toHaveBeenCalledWith("jdoe"); }); }); + +describe("fetchUsers", () => { + // Unsorted + one duplicate (a user can be direct AND inherited member). + const members = [ + { id: 1, username: "jdoe", name: "Jane Doe", web_url: "https://x/jdoe", avatar_url: null, access_level: 50 }, + { id: 2, username: "bob", name: "Bob Martin", web_url: "https://x/bob", avatar_url: null, access_level: 30 }, + { id: 2, username: "bob", name: "Bob Martin", web_url: "https://x/bob", avatar_url: null, access_level: 30 }, + ]; + + function membersClient() { + return { + getGroupMembers: vi.fn(async () => members), + getProjectMembers: vi.fn(async () => members), + getUserByUsername: vi.fn(async (u: string) => [{ id: u === "jdoe" ? 1 : 2, username: u }]), + getUser: vi.fn(async (id: number) => ({ + id, + username: id === 1 ? "jdoe" : "bob", + name: id === 1 ? "Jane Doe" : "Bob Martin", + web_url: "https://x/u", + avatar_url: null, + bio: "hi", + followers: 1, + following: 2, + })), + }; + } + + it("requires exactly one of project/group", async () => { + await expect(fetchUsers(ctx({}), {})).rejects.toThrow(/exactly one of "project" or "group"/); + await expect(fetchUsers(ctx({}), { project: "g/r", group: "g" })).rejects.toThrow(/exactly one/); + }); + + it("lists members with role names, deduped and sorted by display name", async () => { + const client = membersClient(); + const data = await fetchUsers(ctx(client), { group: "my-group" }); + expect(client.getGroupMembers).toHaveBeenCalledWith("my-group"); + expect(data.map((u) => u.username)).toEqual(["bob", "jdoe"]); + expect(data.map((u) => u.role)).toEqual(["developer", "owner"]); + // default show="role" is identity-only → zero per-user profile calls + expect(client.getUserByUsername).not.toHaveBeenCalled(); + expect(client.getUser).not.toHaveBeenCalled(); + expect(data[0]).toMatchObject({ bio: null, followers: null, createdAt: null }); + }); + + it("filters by role (exact, case-insensitive) and applies limit", async () => { + const devs = await fetchUsers(ctx(membersClient()), { group: "my-group", role: "Developer" }); + expect(devs.map((u) => u.username)).toEqual(["bob"]); + const limited = await fetchUsers(ctx(membersClient()), { group: "my-group", limit: 1 }); + expect(limited.map((u) => u.username)).toEqual(["bob"]); + }); + + it("rejects an unknown role and a non-positive limit", async () => { + await expect(fetchUsers(ctx({}), { group: "g", role: "boss" })).rejects.toThrow(/"role" must be one of/); + await expect(fetchUsers(ctx({}), { group: "g", limit: 0 })).rejects.toThrow(/"limit" must be a positive number/); + }); + + it("enriches each member exactly once when show needs profile fields", async () => { + const client = membersClient(); + const data = await fetchUsers(ctx(client), { project: "g/r", show: "role,bio,counts" }); + expect(client.getProjectMembers).toHaveBeenCalledWith("g/r"); + expect(client.getUser).toHaveBeenCalledTimes(2); + expect(data.map((u) => u.bio)).toEqual(["hi", "hi"]); + // role comes from the members payload, not the profile + expect(data.map((u) => u.role)).toEqual(["developer", "owner"]); + }); +}); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 6746a98..4310f04 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -26,7 +26,7 @@ import type { TopicData, UserData, } from "./types"; -import { parseShow } from "./users.js"; +import { needsProfile, parseRole, parseShow, roleName } from "./users.js"; export interface GitLabContext { client: GitLabClient; @@ -462,6 +462,55 @@ export async function fetchUser(ctx: GitLabContext, attrs: Attrs): Promise { + const project = attrs.project as string | number | undefined; + const group = attrs.group as string | number | undefined; + if ((project === undefined) === (group === undefined)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: requires exactly one of "project" or "group".`, + ); + } + const show = parseShow(attrs.show, "GitlabUsers"); + const enrich = needsProfile(show); + const role = parseRole(attrs.role); + const limit = attrs.limit === undefined ? undefined : Number(attrs.limit); + if (limit !== undefined && !(Number.isFinite(limit) && limit > 0)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "limit" must be a positive number; ` + + `got ${JSON.stringify(attrs.limit)}.`, + ); + } + const scopeKey = project !== undefined ? `p:${String(project)}` : `g:${String(group)}`; + const key = `users:${scopeKey}:role=${role ?? "all"}:limit=${limit ?? "all"}:enrich=${enrich ? 1 : 0}`; + return memo(ctx, key, async () => { + const raw = + project !== undefined + ? await ctx.client.getProjectMembers(project) + : await ctx.client.getGroupMembers(group as string | number); + // /members/all can list a user under several ancestors — keep the first occurrence. + const seen = new Set(); + let members = raw.filter((m: any) => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); + if (role !== undefined) members = members.filter((m: any) => roleName(m.access_level) === role); + members = sortByName(members, (m: any) => String(m.name ?? m.username), "asc"); + if (limit !== undefined) members = members.slice(0, limit); + if (!enrich) { + return Promise.all(members.map((m: any) => normalizeUser(ctx, m, roleName(m.access_level)))); + } + // Sequential on purpose: keeps the request pattern gentle; each profile is + // individually memoized so repeat builds and shared members are free. + const users: UserData[] = []; + for (const m of members) { + const full = await fetchUserProfile(ctx, m.username); + users.push({ ...full, role: roleName(m.access_level) }); + } + return users; + }); +} + /** Parse a topic list attribute: `["a","b"]`, `"a,b"`, or undefined → string[]. */ function parseTopicList(value: unknown): string[] { if (Array.isArray(value)) return value.map(String).map((s) => s.trim()).filter(Boolean); From eeab3ce513118373a5eadccd113bd2152221bfc8 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 23:19:16 +0200 Subject: [PATCH 11/24] fix: effective-role dedupe, resilient enrichment, integer limit in fetchUsers Signed-off-by: Thomas Decaux --- src/gitlab/fetchers.test.ts | 49 ++++++++++++++++++++++++++++++++++++- src/gitlab/fetchers.ts | 33 ++++++++++++++++--------- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index ebeac42..6bc3143 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -925,7 +925,11 @@ describe("fetchUsers", () => { it("rejects an unknown role and a non-positive limit", async () => { await expect(fetchUsers(ctx({}), { group: "g", role: "boss" })).rejects.toThrow(/"role" must be one of/); - await expect(fetchUsers(ctx({}), { group: "g", limit: 0 })).rejects.toThrow(/"limit" must be a positive number/); + await expect(fetchUsers(ctx({}), { group: "g", limit: 0 })).rejects.toThrow(/"limit" must be a positive integer/); + }); + + it("rejects a fractional limit", async () => { + await expect(fetchUsers(ctx({}), { group: "g", limit: 1.5 })).rejects.toThrow(/"limit" must be a positive integer/); }); it("enriches each member exactly once when show needs profile fields", async () => { @@ -937,4 +941,47 @@ describe("fetchUsers", () => { // role comes from the members payload, not the profile expect(data.map((u) => u.role)).toEqual(["developer", "owner"]); }); + + it("dedupes to the highest access level when a member appears at multiple levels", async () => { + const client = { + getGroupMembers: vi.fn(async () => [ + // Inherited (lower) row first, direct (higher) row second — effective + // membership is the max, regardless of row order. + { id: 2, username: "bob", name: "Bob Martin", web_url: "https://x/bob", avatar_url: null, access_level: 30 }, + { id: 2, username: "bob", name: "Bob Martin", web_url: "https://x/bob", avatar_url: null, access_level: 50 }, + ]), + }; + const data = await fetchUsers(ctx(client), { group: "my-group" }); + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ username: "bob", role: "owner" }); + }); + + it("degrades to identity data when one member's profile cannot be resolved, without failing the roster", async () => { + const client = { + getGroupMembers: vi.fn(async () => members), + // "bob" is a bot/service account that doesn't resolve; "jdoe" does. + getUserByUsername: vi.fn(async (u: string) => (u === "bob" ? [] : [{ id: 1, username: "jdoe" }])), + getUser: vi.fn(async (id: number) => ({ + id, + username: "jdoe", + name: "Jane Doe", + web_url: "https://x/jdoe", + avatar_url: null, + bio: "hi", + followers: 1, + following: 2, + })), + }; + const data = await fetchUsers(ctx(client), { group: "my-group", show: "role,bio" }); + expect(data.map((u) => u.username)).toEqual(["bob", "jdoe"]); + expect(data[0]).toMatchObject({ username: "bob", role: "developer", bio: null, followers: null, createdAt: null }); + expect(data[1]).toMatchObject({ username: "jdoe", role: "owner", bio: "hi" }); + }); + + it("applies limit before enrichment so only surviving members are resolved", async () => { + const client = membersClient(); + const data = await fetchUsers(ctx(client), { group: "my-group", show: "role,bio", limit: 1 }); + expect(data.map((u) => u.username)).toEqual(["bob"]); + expect(client.getUser).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 4310f04..b98b548 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -474,9 +474,9 @@ export async function fetchUsers(ctx: GitLabContext, attrs: Attrs): Promise 0)) { + if (limit !== undefined && !(Number.isInteger(limit) && limit > 0)) { throw new Error( - `@ebuildy/docusaurus-plugin-gitlab: "limit" must be a positive number; ` + + `@ebuildy/docusaurus-plugin-gitlab: "limit" must be a positive integer; ` + `got ${JSON.stringify(attrs.limit)}.`, ); } @@ -487,13 +487,14 @@ export async function fetchUsers(ctx: GitLabContext, attrs: Attrs): Promise(); - let members = raw.filter((m: any) => { - if (seen.has(m.id)) return false; - seen.add(m.id); - return true; - }); + // /members/all can list a user under several ancestors; the effective + // membership is the highest access level, so keep the max per user. + const byId = new Map(); + for (const m of raw) { + const prev = byId.get(m.id); + if (!prev || (m.access_level ?? 0) > (prev.access_level ?? 0)) byId.set(m.id, m); + } + let members = Array.from(byId.values()); if (role !== undefined) members = members.filter((m: any) => roleName(m.access_level) === role); members = sortByName(members, (m: any) => String(m.name ?? m.username), "asc"); if (limit !== undefined) members = members.slice(0, limit); @@ -502,10 +503,20 @@ export async function fetchUsers(ctx: GitLabContext, attrs: Attrs): Promise. const users: UserData[] = []; for (const m of members) { - const full = await fetchUserProfile(ctx, m.username); - users.push({ ...full, role: roleName(m.access_level) }); + const memberRole = roleName(m.access_level); + try { + const full = await fetchUserProfile(ctx, m.username); + users.push({ ...full, role: memberRole }); + } catch { + // Bot/service accounts and deleted users don't resolve to a profile; + // degrade to the identity data from the members payload rather than + // failing the whole roster. + users.push(await normalizeUser(ctx, m, memberRole)); + } } return users; }); From 142e5642a292bf3e034a3fe3e0e6847bc29e2035 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 23:21:38 +0200 Subject: [PATCH 12/24] feat: register GitlabUser and GitlabUsers in the remark component registry Also update the fetchers.js vi.mock in src/remark/index.test.ts to declare fetchUser/fetchUsers, otherwise importing the registry throws at module-load time (vitest mock is missing named exports). Signed-off-by: Thomas Decaux --- src/remark/index.test.ts | 2 ++ src/remark/registry.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/remark/index.test.ts b/src/remark/index.test.ts index 6ce18fc..c6100a6 100644 --- a/src/remark/index.test.ts +++ b/src/remark/index.test.ts @@ -17,6 +17,8 @@ vi.mock("../gitlab/fetchers.js", () => ({ fetchLabels: vi.fn(), fetchGroupProjects: vi.fn(), fetchRoadmap: vi.fn(), + fetchUser: vi.fn(), + fetchUsers: vi.fn(), })); function processor(opts: any) { diff --git a/src/remark/registry.ts b/src/remark/registry.ts index 112a4f9..0af6033 100644 --- a/src/remark/registry.ts +++ b/src/remark/registry.ts @@ -8,6 +8,8 @@ import { fetchLabels, fetchGroupProjects, fetchRoadmap, + fetchUser, + fetchUsers, type GitLabContext, } from "../gitlab/fetchers.js"; @@ -23,4 +25,6 @@ export const COMPONENT_REGISTRY: Record = { GitlabLabels: fetchLabels, GitlabProjectGrid: fetchGroupProjects, GitlabRoadmap: fetchRoadmap, + GitlabUser: fetchUser, + GitlabUsers: fetchUsers, }; From a05ccbb6ba9b1b934036b7e2d48c941fd62da81c Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 16 Jul 2026 23:25:25 +0200 Subject: [PATCH 13/24] feat: GitlabUser component with shared UserCard partial Signed-off-by: Thomas Decaux --- src/components/GitlabUser.test.tsx | 63 ++++++++++++++++++++++++++++++ src/components/GitlabUser.tsx | 16 ++++++++ src/components/UserCard.tsx | 39 ++++++++++++++++++ src/components/types.ts | 1 + 4 files changed, 119 insertions(+) create mode 100644 src/components/GitlabUser.test.tsx create mode 100644 src/components/GitlabUser.tsx create mode 100644 src/components/UserCard.tsx diff --git a/src/components/GitlabUser.test.tsx b/src/components/GitlabUser.test.tsx new file mode 100644 index 0000000..399f3b0 --- /dev/null +++ b/src/components/GitlabUser.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabUser } from "./GitlabUser"; + +const user = { + id: 101, + username: "jdoe", + name: "Jane Doe", + webUrl: "https://x/jdoe", + avatarUrl: "/gitlab-assets/jdoe.png", + jobTitle: "Senior Developer", + organization: "ACME", + location: "Paris", + bio: "Docs enthusiast", + followers: 12, + following: 34, + createdAt: "2020-01-15T00:00:00Z", +}; + +describe("GitlabUser", () => { + it("renders identity and the default profile sections", () => { + render(); + expect(screen.getByRole("img", { name: "Jane Doe" })).toHaveAttribute("src", "/gitlab-assets/jdoe.png"); + expect(screen.getByRole("link", { name: "@jdoe" })).toHaveAttribute("href", "https://x/jdoe"); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("Senior Developer · ACME")).toBeInTheDocument(); + expect(screen.getByText("Paris")).toBeInTheDocument(); + expect(screen.getByText("Docs enthusiast")).toBeInTheDocument(); + expect(screen.getByText("12 followers · 34 following")).toBeInTheDocument(); + expect(screen.getByText(/Member since /)).toBeInTheDocument(); + }); + + it("show narrows the sections", () => { + render(); + expect(screen.getByText("Docs enthusiast")).toBeInTheDocument(); + expect(screen.queryByText("Paris")).not.toBeInTheDocument(); + expect(screen.queryByText(/followers/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Member since/)).not.toBeInTheDocument(); + }); + + it("skips sections whose profile field is empty and the avatar when null", () => { + render(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.queryByText("Docs enthusiast")).not.toBeInTheDocument(); + expect(screen.queryByText(/followers/)).not.toBeInTheDocument(); + expect(screen.getByText("Paris")).toBeInTheDocument(); + }); + + it("renders partial follower counts when only one side is known", () => { + render(); + expect(screen.getByText("12 followers")).toBeInTheDocument(); + }); + + it("renders nothing without data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the Fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("not found"); + }); +}); diff --git a/src/components/GitlabUser.tsx b/src/components/GitlabUser.tsx new file mode 100644 index 0000000..3f6a5bf --- /dev/null +++ b/src/components/GitlabUser.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { parseShow } from "../gitlab/users.js"; +import { Fallback } from "./Fallback.js"; +import { UserCard } from "./UserCard.js"; +import type { ComponentPayload, UserData } from "./types.js"; + +interface GitlabUserProps extends ComponentPayload { + /** Comma-separated card sections; validated at build time by the fetcher. */ + show?: string; +} + +export function GitlabUser({ data, error, show }: GitlabUserProps) { + if (error) return ; + if (!data) return null; + return ; +} diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx new file mode 100644 index 0000000..d860b4b --- /dev/null +++ b/src/components/UserCard.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import type { UserShowToken } from "../gitlab/users.js"; +import { formatDate } from "./formatDate.js"; +import type { UserData } from "./types.js"; + +/** One user card. `show` is the parsed token list; identity always renders. */ +export function UserCard({ user, show }: { user: UserData; show: readonly UserShowToken[] }) { + const has = (t: UserShowToken) => show.includes(t); + const orgLine = [user.jobTitle, user.organization].filter(Boolean).join(" · "); + const counts = [ + user.followers !== null ? `${user.followers} followers` : null, + user.following !== null ? `${user.following} following` : null, + ] + .filter(Boolean) + .join(" · "); + return ( +
+
+ {user.avatarUrl && ( + {user.name} + )} +
+ {user.name} + + @{user.username} + + {has("role") && user.role && {user.role}} +
+
+ {has("org") && orgLine &&

{orgLine}

} + {has("location") && user.location &&

{user.location}

} + {has("bio") && user.bio &&

{user.bio}

} + {has("counts") && counts &&

{counts}

} + {has("since") && user.createdAt && ( +

Member since {formatDate(user.createdAt)}

+ )} +
+ ); +} diff --git a/src/components/types.ts b/src/components/types.ts index 1f341fd..88ff563 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -8,6 +8,7 @@ export type { TopicData, LabelData, GroupProjectData, + UserData, FetchError, ComponentPayload, LabelRef, From 74e52bbba16e7caccbba57221ecc072c3cdc5e55 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 17 Jul 2026 07:16:22 +0200 Subject: [PATCH 14/24] =?UTF-8?q?feat:=20GitlabUsers=20component=20?= =?UTF-8?q?=E2=80=94=20members=20cards=20grid=20with=20ComponentLayout=20p?= =?UTF-8?q?rops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Decaux --- src/components/GitlabUsers.test.tsx | 59 +++++++++++++++++++++++++++++ src/components/GitlabUsers.tsx | 36 ++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/components/GitlabUsers.test.tsx create mode 100644 src/components/GitlabUsers.tsx diff --git a/src/components/GitlabUsers.test.tsx b/src/components/GitlabUsers.test.tsx new file mode 100644 index 0000000..d9e1833 --- /dev/null +++ b/src/components/GitlabUsers.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabUsers } from "./GitlabUsers"; + +const users = [ + { + id: 101, username: "jdoe", name: "Jane Doe", webUrl: "https://x/jdoe", avatarUrl: null, role: "owner", + jobTitle: null, organization: null, location: null, bio: null, followers: null, following: null, createdAt: null, + }, + { + id: 102, username: "bob", name: "Bob Martin", webUrl: "https://x/bob", avatarUrl: null, role: "developer", + jobTitle: "Dev", organization: "ACME", location: null, bio: null, followers: 2, following: 3, createdAt: null, + }, +]; + +describe("GitlabUsers", () => { + it("renders one card per member with a role badge by default", () => { + render(); + expect(screen.getByRole("link", { name: "@jdoe" })).toHaveAttribute("href", "https://x/jdoe"); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("Bob Martin")).toBeInTheDocument(); + expect(screen.getByText("owner")).toBeInTheDocument(); + expect(screen.getByText("developer")).toBeInTheDocument(); + // default list card is identity + role only + expect(screen.queryByText("Dev · ACME")).not.toBeInTheDocument(); + }); + + it("adds profile sections to every card via show", () => { + render(); + expect(screen.getByText("Dev · ACME")).toBeInTheDocument(); + expect(screen.getByText("2 followers · 3 following")).toBeInTheDocument(); + expect(screen.getByText("owner")).toBeInTheDocument(); + }); + + it("uses a responsive auto-fill grid with a 260px default min width", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-user-cards")).toHaveStyle({ + gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", + }); + }); + + it("lets cardColumns and gap tune the grid (ComponentLayout)", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-user-cards")).toHaveStyle({ + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: "1.5rem", + }); + }); + + it("renders an empty grid for an empty member list", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-user-cards")).toBeEmptyDOMElement(); + }); + + it("renders the Fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); +}); diff --git a/src/components/GitlabUsers.tsx b/src/components/GitlabUsers.tsx new file mode 100644 index 0000000..db8241a --- /dev/null +++ b/src/components/GitlabUsers.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { parseShow } from "../gitlab/users.js"; +import { Fallback } from "./Fallback.js"; +import { UserCard } from "./UserCard.js"; +import { cardsGridStyle, type ComponentLayout } from "./layout.js"; +import type { ComponentPayload, UserData } from "./types.js"; + +interface GitlabUsersProps extends ComponentPayload, ComponentLayout { + /** Comma-separated card sections; validated at build time by the fetcher. */ + show?: string; +} + +export function GitlabUsers({ + data, + error, + show, + cardColumns, + cardMinWidth, + gap, + maxWidth, + align, +}: GitlabUsersProps) { + if (error) return ; + if (!data) return null; + const tokens = parseShow(show, "GitlabUsers"); + return ( +
+ {data.map((u) => ( + + ))} +
+ ); +} From 07f3de517801f899ecfa1364b8ee0019747183c6 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 17 Jul 2026 07:16:57 +0200 Subject: [PATCH 15/24] feat: user card styles in theme.css; reuse gitlab-card-header layout Signed-off-by: Thomas Decaux --- src/components/UserCard.tsx | 2 +- theme.css | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx index d860b4b..89122ec 100644 --- a/src/components/UserCard.tsx +++ b/src/components/UserCard.tsx @@ -15,7 +15,7 @@ export function UserCard({ user, show }: { user: UserData; show: readonly UserSh .join(" · "); return (
-
+
{user.avatarUrl && ( {user.name} )} diff --git a/theme.css b/theme.css index ba6b813..1c95b81 100644 --- a/theme.css +++ b/theme.css @@ -126,6 +126,50 @@ color: var(--ifm-color-emphasis-700); } +/* Users — user profile cards, standalone or in a responsive grid. */ +.gitlab-user-cards { + display: grid; + gap: 0.75rem; + margin: 1rem 0; +} +.gitlab-user-cards .gitlab-user-card { + margin: 0; +} +.gitlab-user-card .gitlab-avatar { + width: 48px; + height: 48px; +} +.gitlab-user-identity { + display: flex; + flex-direction: column; + min-width: 0; +} +.gitlab-user-name { + line-height: 1.2; +} +.gitlab-user-username { + font-size: 0.85em; +} +.gitlab-user-role { + align-self: flex-start; + margin-top: 0.2rem; + text-transform: capitalize; +} +.gitlab-user-org, +.gitlab-user-location, +.gitlab-user-bio, +.gitlab-user-counts, +.gitlab-user-since { + margin: 0.35rem 0 0; + font-size: 0.85em; +} +.gitlab-user-org, +.gitlab-user-location, +.gitlab-user-counts, +.gitlab-user-since { + color: var(--ifm-color-emphasis-700); +} + .gitlab-description { margin-bottom: 0.1rem; } From ce0896db2d0c8d68b249d98ac74b76772a8c194d Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 17 Jul 2026 07:22:13 +0200 Subject: [PATCH 16/24] style: static grid-template-columns fallback for gitlab-user-cards Signed-off-by: Thomas Decaux --- theme.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/theme.css b/theme.css index 1c95b81..74b377b 100644 --- a/theme.css +++ b/theme.css @@ -129,6 +129,8 @@ /* Users — user profile cards, standalone or in a responsive grid. */ .gitlab-user-cards { display: grid; + /* Fallback; the component's inline style (cardMinWidth/cardColumns) overrides. */ + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; margin: 1rem 0; } From 4a1085a84e1f8c88a8e6020a660e5e119e77fad5 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 17 Jul 2026 07:23:02 +0200 Subject: [PATCH 17/24] feat: export GitlabUser, GitlabUsers and UserData Signed-off-by: Thomas Decaux --- src/components/index.ts | 3 +++ src/index.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/components/index.ts b/src/components/index.ts index a4092ef..8aeacd2 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,8 @@ export { GitlabTopics } from "./GitlabTopics.js"; export { GitlabLabels } from "./GitlabLabels.js"; export { GitlabProjectGrid } from "./GitlabProjectGrid.js"; export { GitlabRoadmap } from "./GitlabRoadmap.js"; +export { GitlabUser } from "./GitlabUser.js"; +export { GitlabUsers } from "./GitlabUsers.js"; export type { ComponentLayout } from "./layout.js"; export type { ProjectInfoData, @@ -18,6 +20,7 @@ export type { TopicData, LabelData, GroupProjectData, + UserData, RoadmapData, RoadmapItemData, LabelRef, diff --git a/src/index.ts b/src/index.ts index c76ec3c..73d2905 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ export type { TopicData, LabelData, GroupProjectData, + UserData, RoadmapData, RoadmapItemData, LabelRef, From a1793283ecafb3f2ca1000dea4f235089e1b0e50 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 17 Jul 2026 07:28:07 +0200 Subject: [PATCH 18/24] docs: GitlabUser/GitlabUsers pages, README sections, and e2e coverage Signed-off-by: Thomas Decaux --- README.md | 39 ++++++++++++++++++ examples/site/docs/components/user.mdx | 46 +++++++++++++++++++++ examples/site/docs/components/users.mdx | 53 +++++++++++++++++++++++++ examples/site/docs/intro.mdx | 6 +++ test/e2e/build.test.ts | 16 ++++++++ test/e2e/fixtures.ts | 35 ++++++++++++++++ 6 files changed, 195 insertions(+) create mode 100644 examples/site/docs/components/user.mdx create mode 100644 examples/site/docs/components/users.mdx diff --git a/README.md b/README.md index ff32ab4..1f1784d 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,45 @@ Both components render [scoped labels/topics](https://docs.gitlab.com/ee/user/pr (`scope::value`, e.g. `Abilities::Performance`) as a two-part badge — the scope keeps its color and the value gets a dark-gray background. The split is on the last `::`. +### `` + +A user profile as a small card: photo, display name, linked `@username`, and +configurable profile sections from the public user API. + +```mdx + + + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | string | required | GitLab username | +| `show` | string | `org,location,bio,counts,since` | Card sections: `org`, `location`, `bio`, `counts` (followers/following), `since` (member since) | + +### `` + +The members of a group **or** project (inherited included) as a grid of user cards. + +```mdx + + + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `group` | string \| number | — | Provide either `group` or `project` | +| `project` | string \| number | — | Provide either `group` or `project` | +| `role` | string | — | Exact-match filter: `guest`, `reporter`, `developer`, `maintainer`, `owner`, … | +| `show` | string | `role` | `` tokens plus `role` (role badge) | +| `limit` | number | all | Max members to show (fetch capped at 500) | + +The grid accepts the shared card-grid props: `cardColumns`, `cardMinWidth` +(default `260px`), `gap`, `maxWidth`, `align`. The default `show="role"` costs a +single members call; profile tokens add one cached user lookup per member at +build time. + ### `` Renders a timeline of GitLab **epics** (Premium/Ultimate, group-level) or diff --git a/examples/site/docs/components/user.mdx b/examples/site/docs/components/user.mdx new file mode 100644 index 0000000..4bc8abb --- /dev/null +++ b/examples/site/docs/components/user.mdx @@ -0,0 +1,46 @@ +--- +title: GitlabUser +sidebar_position: 11 +--- + +# `` + +Renders a GitLab user profile as a small card: photo, display name, linked +`@username`, and a configurable set of profile sections. All data comes from the +public user API at build time — nothing is fetched in the browser. + +## Usage + +```mdx + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | `string` | **required** | GitLab username (login). | +| `show` | `string` | `org,location,bio,counts,since` | Comma-separated card sections (see below). | + +## `show` tokens + +The avatar, display name, and linked `@username` always render. Tokens add sections: + +| Token | Section | +|---|---| +| `org` | Job title · organization | +| `location` | Location line | +| `bio` | Bio paragraph (plain text) | +| `counts` | Followers · following | +| `since` | "Member since ``" | + +An empty `show=""` renders an identity-only card. Sections whose profile field is +empty are skipped automatically. + +## Notes + +- The username must exist; an unknown user fails the build in strict mode (renders + the fallback otherwise). +- The avatar is downloaded at build time and served from your site's static assets. diff --git a/examples/site/docs/components/users.mdx b/examples/site/docs/components/users.mdx new file mode 100644 index 0000000..c8e3af7 --- /dev/null +++ b/examples/site/docs/components/users.mdx @@ -0,0 +1,53 @@ +--- +title: GitlabUsers +sidebar_position: 12 +--- + +# `` + +Renders the members of a **group** or a **project** (including inherited members) +as a grid of user cards, optionally filtered by role. + +## Usage + +```mdx + + + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `group` | `string \| number` | — | Group path or numeric ID. Provide **either** this or `project`. | +| `project` | `string \| number` | — | Project path or numeric ID. Provide **either** this or `group`. | +| `role` | `string` | — | Exact-match filter (case-insensitive): `minimal`, `guest`, `planner`, `reporter`, `developer`, `maintainer`, `owner`. | +| `show` | `string` | `role` | Same tokens as ``, plus `role` (a role badge on each card). | +| `limit` | `number` | all | Maximum members to show (applied after the role filter; the fetch itself is capped at 500). | + +## Grid layout + +The grid shares the standard card-grid props: + +| Prop | Type | Default | Description | +|---|---|---|---| +| `cardColumns` | `number` | — | Fixed number of columns. Takes precedence over `cardMinWidth`. | +| `cardMinWidth` | `string` | `260px` | Minimum card width for a responsive (auto-fill) grid. | +| `gap` | `string` | — | Spacing between cards, e.g. `"1.5rem"`. | +| `maxWidth` | `string` | — | Constrain the grid width, e.g. `"900px"`. | +| `align` | `string` | `start` | `start` or `center` — placement of a width-constrained grid. | + +## Build cost + +The default `show="role"` needs a **single** members API call. Any profile token +(`org`, `location`, `bio`, `counts`, `since`) triggers one extra user lookup per +member at build time (cached on disk, deduplicated across pages and components). + +## Notes + +- Exactly one of `group` / `project` is required; both or neither fails the build. +- Members are fetched **including inherited** ones, like GitLab's members page. +- Cards are sorted by display name; an unknown `role` value fails the build. diff --git a/examples/site/docs/intro.mdx b/examples/site/docs/intro.mdx index 0d2df76..cf17a39 100644 --- a/examples/site/docs/intro.mdx +++ b/examples/site/docs/intro.mdx @@ -25,3 +25,9 @@ Some intro text. + +## Team + + + + diff --git a/test/e2e/build.test.ts b/test/e2e/build.test.ts index cd471a6..dc18871 100644 --- a/test/e2e/build.test.ts +++ b/test/e2e/build.test.ts @@ -105,6 +105,22 @@ describe("e2e: docusaurus build", () => { expect(html).toContain("/groups/my-group/-/issues?label_name[]=epic"); }); + it("bakes user cards into the static html", () => { + const html = readFileSync(join(siteDir, "build", "index.html"), "utf8"); + // single card: identity + default profile sections + expect(html).toContain("Jane Doe"); + // React SSR splits adjacent text/expression children ("@" and {username}) with an + // comment marker, so assert on the link rather than the contiguous string. + expect(html).toContain('class="gitlab-user-username" href="https://x/jdoe"'); + expect(html).toContain("12 followers"); + expect(html).toContain("Member since"); + // members grid: both members, role badges, enriched org line + expect(html).toContain("gitlab-user-cards"); + expect(html).toContain("Bob Martin"); + expect(html).toContain("owner"); + expect(html).toContain("Senior Developer · ACME"); + }); + it("generates a child page nested under the declaring index page, with a card grid", () => { // The generator wrote the child page as a SIBLING of the declaring index page // (docs/generate/index.mdx), so Docusaurus nests it under that page. diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts index 9a0c1d2..69dc589 100644 --- a/test/e2e/fixtures.ts +++ b/test/e2e/fixtures.ts @@ -6,6 +6,24 @@ const ONE_PX_PNG = Buffer.from( "base64", ); +/** Stub users: jdoe is a group owner with a full profile, bob a developer with a sparse one. */ +const STUB_USERS: Record = { + jdoe: { + id: 101, username: "jdoe", name: "Jane Doe", avatar_url: null, web_url: "https://x/jdoe", + job_title: "Senior Developer", organization: "ACME", location: "Paris", bio: "Docs enthusiast", + followers: 12, following: 34, created_at: "2020-01-15T00:00:00Z", + }, + bob: { + id: 102, username: "bob", name: "Bob Martin", avatar_url: null, web_url: "https://x/bob", + followers: 2, following: 3, created_at: "2021-06-01T00:00:00Z", + }, +}; + +const STUB_MEMBERS = [ + { id: 101, username: "jdoe", name: "Jane Doe", avatar_url: null, web_url: "https://x/jdoe", access_level: 50 }, + { id: 102, username: "bob", name: "Bob Martin", avatar_url: null, web_url: "https://x/bob", access_level: 30 }, +]; + /** Minimal GitLab REST v4 stub. Returns a base URL and a stop() fn. */ export async function startGitlabStub(): Promise<{ url: string; stop: () => Promise }> { const server: Server = createServer((req, res) => { @@ -49,6 +67,23 @@ export async function startGitlabStub(): Promise<{ url: string; stop: () => Prom { name: "api", title: "API", total_projects_count: 9 }, ]); } + if (url.startsWith("/api/v4/groups/my-group/members/all")) { + return send(STUB_MEMBERS); + } + if (url.startsWith("/api/v4/projects/group%2Frepo/members/all")) { + return send(STUB_MEMBERS); + } + if (url.startsWith("/api/v4/users/")) { + const id = Number(url.slice("/api/v4/users/".length).split("?")[0]); + const user = Object.values(STUB_USERS).find((u) => u.id === id); + if (user) return send(user); + res.writeHead(404); + return res.end("not found"); + } + if (url.startsWith("/api/v4/users")) { + const username = new URL(url, "http://stub").searchParams.get("username") ?? ""; + return send(STUB_USERS[username] ? [STUB_USERS[username]] : []); + } if (url.startsWith("/api/v4/groups/my-group/projects")) { return send([ { From 76260056fad3a56a4bf2c3e093312dca52dc7a56 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 17 Jul 2026 07:52:35 +0200 Subject: [PATCH 19/24] =?UTF-8?q?docs:=20review=20polish=20=E2=80=94=20bad?= =?UTF-8?q?ge-scoped=20e2e=20assertion,=20accurate=20enrichment=20cost,=20?= =?UTF-8?q?bot-fallback=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Decaux --- README.md | 5 +++-- examples/site/docs/components/users.mdx | 7 +++++-- test/e2e/build.test.ts | 3 ++- test/e2e/fixtures.ts | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1f1784d..cb89207 100644 --- a/README.md +++ b/README.md @@ -297,8 +297,9 @@ The members of a group **or** project (inherited included) as a grid of user car The grid accepts the shared card-grid props: `cardColumns`, `cardMinWidth` (default `260px`), `gap`, `maxWidth`, `align`. The default `show="role"` costs a -single members call; profile tokens add one cached user lookup per member at -build time. +single members call; profile tokens add one cached profile lookup per member at +build time (two API requests per member on a cold build, deduplicated across +pages and builds). ### `` diff --git a/examples/site/docs/components/users.mdx b/examples/site/docs/components/users.mdx index c8e3af7..e13c74e 100644 --- a/examples/site/docs/components/users.mdx +++ b/examples/site/docs/components/users.mdx @@ -43,11 +43,14 @@ The grid shares the standard card-grid props: ## Build cost The default `show="role"` needs a **single** members API call. Any profile token -(`org`, `location`, `bio`, `counts`, `since`) triggers one extra user lookup per -member at build time (cached on disk, deduplicated across pages and components). +(`org`, `location`, `bio`, `counts`, `since`) triggers one extra profile lookup per +member at build time (two API requests per member on a cold build; cached on disk +and deduplicated across pages and components). ## Notes - Exactly one of `group` / `project` is required; both or neither fails the build. - Members are fetched **including inherited** ones, like GitLab's members page. - Cards are sorted by display name; an unknown `role` value fails the build. +- Bot/service accounts (and deleted users) that have no public profile degrade to + identity-only cards instead of failing the build. diff --git a/test/e2e/build.test.ts b/test/e2e/build.test.ts index dc18871..2b20d63 100644 --- a/test/e2e/build.test.ts +++ b/test/e2e/build.test.ts @@ -117,7 +117,8 @@ describe("e2e: docusaurus build", () => { // members grid: both members, role badges, enriched org line expect(html).toContain("gitlab-user-cards"); expect(html).toContain("Bob Martin"); - expect(html).toContain("owner"); + // role rendered as a badge, not just the word appearing anywhere on the page + expect(html).toContain('gitlab-user-role">owner'); expect(html).toContain("Senior Developer · ACME"); }); diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts index 69dc589..eb2eb72 100644 --- a/test/e2e/fixtures.ts +++ b/test/e2e/fixtures.ts @@ -73,6 +73,7 @@ export async function startGitlabStub(): Promise<{ url: string; stop: () => Prom if (url.startsWith("/api/v4/projects/group%2Frepo/members/all")) { return send(STUB_MEMBERS); } + // Bare /users/:id only — a /users/:id/ path yields NaN and 404s. if (url.startsWith("/api/v4/users/")) { const id = Number(url.slice("/api/v4/users/".length).split("?")[0]); const user = Object.values(STUB_USERS).find((u) => u.id === id); From 0478ce38c103874efc69aa6babc5cb1f630c15dd Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Sat, 18 Jul 2026 17:42:58 +0200 Subject: [PATCH 20/24] feat: right-aligned info block with emoji-prefixed lines on user cards Signed-off-by: Thomas Decaux --- src/components/GitlabUser.test.tsx | 31 +++++++++++++------ src/components/GitlabUsers.test.tsx | 6 ++-- src/components/UserCard.tsx | 47 ++++++++++++++++++++++++----- theme.css | 20 ++++++++++++ 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/src/components/GitlabUser.test.tsx b/src/components/GitlabUser.test.tsx index 399f3b0..7324776 100644 --- a/src/components/GitlabUser.test.tsx +++ b/src/components/GitlabUser.test.tsx @@ -19,21 +19,32 @@ const user = { describe("GitlabUser", () => { it("renders identity and the default profile sections", () => { - render(); + const { container } = render(); expect(screen.getByRole("img", { name: "Jane Doe" })).toHaveAttribute("src", "/gitlab-assets/jdoe.png"); expect(screen.getByRole("link", { name: "@jdoe" })).toHaveAttribute("href", "https://x/jdoe"); expect(screen.getByText("Jane Doe")).toBeInTheDocument(); - expect(screen.getByText("Senior Developer · ACME")).toBeInTheDocument(); - expect(screen.getByText("Paris")).toBeInTheDocument(); - expect(screen.getByText("Docs enthusiast")).toBeInTheDocument(); - expect(screen.getByText("12 followers · 34 following")).toBeInTheDocument(); + expect(screen.getByText(/Senior Developer · ACME/)).toBeInTheDocument(); + expect(screen.getByText(/Paris/)).toBeInTheDocument(); + expect(screen.getByText(/Docs enthusiast/)).toBeInTheDocument(); + expect(screen.getByText(/12 followers · 34 following/)).toBeInTheDocument(); expect(screen.getByText(/Member since /)).toBeInTheDocument(); + // profile sections live in a right-hand info block + expect(container.querySelector(".gitlab-user-info")).toBeInTheDocument(); + }); + + it("prefixes every info line with a decorative emoji", () => { + render(); + for (const emoji of ["💼", "📍", "📝", "👥", "📅"]) { + const marker = screen.getByText(emoji); + expect(marker).toHaveClass("gitlab-user-emoji"); + expect(marker).toHaveAttribute("aria-hidden", "true"); + } }); it("show narrows the sections", () => { render(); - expect(screen.getByText("Docs enthusiast")).toBeInTheDocument(); - expect(screen.queryByText("Paris")).not.toBeInTheDocument(); + expect(screen.getByText(/Docs enthusiast/)).toBeInTheDocument(); + expect(screen.queryByText(/Paris/)).not.toBeInTheDocument(); expect(screen.queryByText(/followers/)).not.toBeInTheDocument(); expect(screen.queryByText(/Member since/)).not.toBeInTheDocument(); }); @@ -41,14 +52,14 @@ describe("GitlabUser", () => { it("skips sections whose profile field is empty and the avatar when null", () => { render(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); - expect(screen.queryByText("Docs enthusiast")).not.toBeInTheDocument(); + expect(screen.queryByText(/Docs enthusiast/)).not.toBeInTheDocument(); expect(screen.queryByText(/followers/)).not.toBeInTheDocument(); - expect(screen.getByText("Paris")).toBeInTheDocument(); + expect(screen.getByText(/Paris/)).toBeInTheDocument(); }); it("renders partial follower counts when only one side is known", () => { render(); - expect(screen.getByText("12 followers")).toBeInTheDocument(); + expect(screen.getByText(/12 followers/)).toBeInTheDocument(); }); it("renders nothing without data", () => { diff --git a/src/components/GitlabUsers.test.tsx b/src/components/GitlabUsers.test.tsx index d9e1833..bcb4857 100644 --- a/src/components/GitlabUsers.test.tsx +++ b/src/components/GitlabUsers.test.tsx @@ -22,13 +22,13 @@ describe("GitlabUsers", () => { expect(screen.getByText("owner")).toBeInTheDocument(); expect(screen.getByText("developer")).toBeInTheDocument(); // default list card is identity + role only - expect(screen.queryByText("Dev · ACME")).not.toBeInTheDocument(); + expect(screen.queryByText(/Dev · ACME/)).not.toBeInTheDocument(); }); it("adds profile sections to every card via show", () => { render(); - expect(screen.getByText("Dev · ACME")).toBeInTheDocument(); - expect(screen.getByText("2 followers · 3 following")).toBeInTheDocument(); + expect(screen.getByText(/Dev · ACME/)).toBeInTheDocument(); + expect(screen.getByText(/2 followers · 3 following/)).toBeInTheDocument(); expect(screen.getByText("owner")).toBeInTheDocument(); }); diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx index 89122ec..cc23ed4 100644 --- a/src/components/UserCard.tsx +++ b/src/components/UserCard.tsx @@ -3,6 +3,18 @@ import type { UserShowToken } from "../gitlab/users.js"; import { formatDate } from "./formatDate.js"; import type { UserData } from "./types.js"; +/** One emoji-prefixed profile line; the emoji is decorative and hidden from AT. */ +function InfoLine({ className, emoji, children }: { className: string; emoji: string; children: React.ReactNode }) { + return ( +

+ {" "} + {children} +

+ ); +} + /** One user card. `show` is the parsed token list; identity always renders. */ export function UserCard({ user, show }: { user: UserData; show: readonly UserShowToken[] }) { const has = (t: UserShowToken) => show.includes(t); @@ -13,6 +25,33 @@ export function UserCard({ user, show }: { user: UserData; show: readonly UserSh ] .filter(Boolean) .join(" · "); + const info = [ + has("org") && orgLine && ( + + {orgLine} + + ), + has("location") && user.location && ( + + {user.location} + + ), + has("bio") && user.bio && ( + + {user.bio} + + ), + has("counts") && counts && ( + + {counts} + + ), + has("since") && user.createdAt && ( + + Member since {formatDate(user.createdAt)} + + ), + ].filter(Boolean); return (
@@ -27,13 +66,7 @@ export function UserCard({ user, show }: { user: UserData; show: readonly UserSh {has("role") && user.role && {user.role}}
- {has("org") && orgLine &&

{orgLine}

} - {has("location") && user.location &&

{user.location}

} - {has("bio") && user.bio &&

{user.bio}

} - {has("counts") && counts &&

{counts}

} - {has("since") && user.createdAt && ( -

Member since {formatDate(user.createdAt)}

- )} + {info.length > 0 &&
{info}
}
); } diff --git a/theme.css b/theme.css index 74b377b..ab26147 100644 --- a/theme.css +++ b/theme.css @@ -137,6 +137,26 @@ .gitlab-user-cards .gitlab-user-card { margin: 0; } +/* Identity (avatar + name) on the left, emoji-prefixed info lines on the right. */ +.gitlab-user-card { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} +.gitlab-user-card-header { + flex: none; +} +.gitlab-user-info { + margin-left: auto; + min-width: 0; + text-align: right; +} +.gitlab-user-info > p:first-child { + margin-top: 0; +} +.gitlab-user-emoji { + margin-right: 0.25rem; +} .gitlab-user-card .gitlab-avatar { width: 48px; height: 48px; From 07440a804fe7feb16e0966fa757bf23238ab6571 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Sat, 18 Jul 2026 22:04:04 +0200 Subject: [PATCH 21/24] feat: hide zero followers/following counts on user cards Signed-off-by: Thomas Decaux --- examples/site/docs/components/user.mdx | 3 ++- src/components/GitlabUser.test.tsx | 12 ++++++++++++ src/components/UserCard.tsx | 5 +++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/examples/site/docs/components/user.mdx b/examples/site/docs/components/user.mdx index 4bc8abb..71f49a9 100644 --- a/examples/site/docs/components/user.mdx +++ b/examples/site/docs/components/user.mdx @@ -37,7 +37,8 @@ The avatar, display name, and linked `@username` always render. Tokens add secti | `since` | "Member since ``" | An empty `show=""` renders an identity-only card. Sections whose profile field is -empty are skipped automatically. +empty are skipped automatically; a zero followers/following count is treated as +empty too. ## Notes diff --git a/src/components/GitlabUser.test.tsx b/src/components/GitlabUser.test.tsx index 7324776..35352d6 100644 --- a/src/components/GitlabUser.test.tsx +++ b/src/components/GitlabUser.test.tsx @@ -62,6 +62,18 @@ describe("GitlabUser", () => { expect(screen.getByText(/12 followers/)).toBeInTheDocument(); }); + it("hides a zero followers or following count", () => { + render(); + expect(screen.getByText(/34 following/)).toBeInTheDocument(); + expect(screen.queryByText(/followers/)).not.toBeInTheDocument(); + }); + + it("drops the counts line entirely when both counts are zero", () => { + render(); + expect(screen.queryByText(/followers|following/)).not.toBeInTheDocument(); + expect(screen.queryByText("👥")).not.toBeInTheDocument(); + }); + it("renders nothing without data", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx index cc23ed4..840f495 100644 --- a/src/components/UserCard.tsx +++ b/src/components/UserCard.tsx @@ -19,9 +19,10 @@ function InfoLine({ className, emoji, children }: { className: string; emoji: st export function UserCard({ user, show }: { user: UserData; show: readonly UserShowToken[] }) { const has = (t: UserShowToken) => show.includes(t); const orgLine = [user.jobTitle, user.organization].filter(Boolean).join(" · "); + // A zero count carries no information — hide that side (and the whole line when both are zero). const counts = [ - user.followers !== null ? `${user.followers} followers` : null, - user.following !== null ? `${user.following} following` : null, + user.followers !== null && user.followers > 0 ? `${user.followers} followers` : null, + user.following !== null && user.following > 0 ? `${user.following} following` : null, ] .filter(Boolean) .join(" · "); From b08e4af560ef9aecc65ac573acd303011bf2fa23 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Sun, 19 Jul 2026 08:19:22 +0200 Subject: [PATCH 22/24] feat: bio in identity block; info wraps below on narrow cards (container query) Signed-off-by: Thomas Decaux --- src/components/GitlabUser.test.tsx | 9 ++++++++- src/components/UserCard.tsx | 6 +----- theme.css | 15 ++++++++++++++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/GitlabUser.test.tsx b/src/components/GitlabUser.test.tsx index 35352d6..250c34d 100644 --- a/src/components/GitlabUser.test.tsx +++ b/src/components/GitlabUser.test.tsx @@ -34,13 +34,20 @@ describe("GitlabUser", () => { it("prefixes every info line with a decorative emoji", () => { render(); - for (const emoji of ["💼", "📍", "📝", "👥", "📅"]) { + for (const emoji of ["💼", "📍", "👥", "📅"]) { const marker = screen.getByText(emoji); expect(marker).toHaveClass("gitlab-user-emoji"); expect(marker).toHaveAttribute("aria-hidden", "true"); } }); + it("renders the bio inside the identity block, without an emoji marker", () => { + const { container } = render(); + const bio = container.querySelector(".gitlab-user-identity .gitlab-user-bio"); + expect(bio).toHaveTextContent("Docs enthusiast"); + expect(screen.queryByText("📝")).not.toBeInTheDocument(); + }); + it("show narrows the sections", () => { render(); expect(screen.getByText(/Docs enthusiast/)).toBeInTheDocument(); diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx index 840f495..b472405 100644 --- a/src/components/UserCard.tsx +++ b/src/components/UserCard.tsx @@ -37,11 +37,6 @@ export function UserCard({ user, show }: { user: UserData; show: readonly UserSh {user.location} ), - has("bio") && user.bio && ( - - {user.bio} - - ), has("counts") && counts && ( {counts} @@ -65,6 +60,7 @@ export function UserCard({ user, show }: { user: UserData; show: readonly UserSh @{user.username} {has("role") && user.role && {user.role}} + {has("bio") && user.bio &&

{user.bio}

}
{info.length > 0 &&
{info}
} diff --git a/theme.css b/theme.css index ab26147..6744fea 100644 --- a/theme.css +++ b/theme.css @@ -137,11 +137,16 @@ .gitlab-user-cards .gitlab-user-card { margin: 0; } -/* Identity (avatar + name) on the left, emoji-prefixed info lines on the right. */ +/* Identity (avatar + name + bio) on the left, emoji-prefixed info lines on the + right. On narrow cards (≤ 300px) the info block wraps below the identity. */ .gitlab-user-card { display: flex; + flex-wrap: wrap; align-items: flex-start; gap: 0.75rem; + /* Make the card a query container so the info block tracks the card's own + width (grid column, sidebar, …), not the viewport's. */ + container-type: inline-size; } .gitlab-user-card-header { flex: none; @@ -151,6 +156,14 @@ min-width: 0; text-align: right; } +@container (max-width: 300px) { + .gitlab-user-info { + flex-basis: 100%; + margin-left: 0; + text-align: left; + } +} + .gitlab-user-info > p:first-child { margin-top: 0; } From 27cc793a975a32e5c2702854535218e265362129 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Sun, 19 Jul 2026 15:28:51 +0200 Subject: [PATCH 23/24] chore: add mise tasks Signed-off-by: Thomas Decaux --- CLAUDE.md | 13 +++++++++-- CONTRIBUTING.md | 14 +++++++++++ mise.toml | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e205bea..2a3a8bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,17 @@ static HTML. The browser never holds a token or calls the GitLab API. `require` export condition; `test/packaging.test.ts` guards this. - `pnpm test` — run all tests (Vitest). Use `pnpm exec vitest run ` for one file. - `pnpm run typecheck` — `tsc --noEmit`. - -There is no dev server. After code edits, run +- **mise tasks** (`mise.toml`) mirror the pnpm scripts: + `mise run setup | lint | lint:fix | typecheck | test | build`, plus + `mise run release` (runs the full gate locally and shows the pending + release-please PR — never publishes) and example-site tasks that rebuild + `dist/` first: `gitlab:build`/`gitlab:start` (showcase site, live gitlab.com + data) and `site:build`/`site:start` (`examples/site` is the e2e fixture — + its stub projects 404 against real gitlab.com, so `site:build` only works + with `GITLAB_HOST` pointing at a stub; `site:start` renders Fallbacks in dev). + +There is no dev server for the package itself (`mise run gitlab:start` serves +the showcase site to see components live). After code edits, run `pnpm exec vitest run` and `pnpm run typecheck`. The e2e test (`test/e2e/build.test.ts`) builds a real Docusaurus site and is slow (~1 min); run it explicitly when touching the pipeline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09d730b..b546d20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,9 +34,20 @@ pnpm run build # bundle the package into dist/ | `pnpm run lint:fix` | Auto-fix lint issues | | `pnpm run build` | Compile with tsc (ESM-only + types) | +All of these are also exposed as **mise tasks** (`mise tasks` to list): +`mise run setup | lint | lint:fix | typecheck | test | build | release`. + The example sites have their own READMEs: [`examples/site`](./examples/site/README.md) (mocked, drives the e2e) and [`examples/gitlab`](./examples/gitlab/README.md) (live gitlab.com data). +They have mise tasks too — each rebuilds the plugin's `dist/` first: + +| Task | What it does | +|---|---| +| `mise run gitlab:build` / `gitlab:start` | Build / serve the showcase site (`examples/gitlab`, live gitlab.com data) | +| `mise run site:build` / `site:start` | Build / serve the e2e fixture site (`examples/site`) — its stub projects 404 against real gitlab.com, so `site:build` needs `GITLAB_HOST` pointing at an instance that has them (the e2e test provides a stub); `site:start` works in dev (failed fetches render the Fallback) | + +Both sites read `GITLAB_TOKEN` / `GITLAB_HOST` from the environment. ## Conventions @@ -80,6 +91,9 @@ and published to npm with provenance via OIDC trusted publishing. `publish` job runs `npm publish --provenance --access public`. No npm token is stored — publishing authenticates via GitHub OIDC. +`mise run release` runs the full gate locally (frozen install, lint, typecheck, +test, build) and then shows the pending release PR — it never publishes. + ### One-time setup - On npmjs.com, add `ebuildy/docusaurus-plugin-gitlab` + diff --git a/mise.toml b/mise.toml index e65c8b9..655a597 100644 --- a/mise.toml +++ b/mise.toml @@ -4,3 +4,65 @@ [tools] node = "22" pnpm = "11.13.1" + +[tasks.setup] +description = "Install dependencies" +run = "pnpm install" + +[tasks.lint] +description = "Lint JS/TS (eslint) and markdown (markdownlint-cli2)" +run = "pnpm run lint" + +[tasks."lint:fix"] +description = "Lint with auto-fix" +run = "pnpm run lint:fix" + +[tasks.typecheck] +description = "Type-check without emitting (tsc --noEmit)" +run = "pnpm run typecheck" + +[tasks.test] +description = "Run all tests with Vitest (includes the slow e2e Docusaurus build)" +run = "pnpm test" + +[tasks.build] +description = "Compile ESM .js + .d.ts into dist/" +run = "pnpm run build" + +# Example sites (pnpm workspace packages). They consume the plugin's dist/ via +# workspace:*, so they depend on the plugin build. GITLAB_TOKEN / GITLAB_HOST +# are read from the environment (optional for the public demo projects). +[tasks."site:build"] +description = "Build the e2e fixture site (examples/site) — references stub projects, so set GITLAB_HOST to a stub/instance that has them (the e2e test does this; against gitlab.com it 404s)" +depends = ["build"] +run = "pnpm --filter example-site run build" + +[tasks."site:start"] +description = "Run the e2e fixture site dev server (examples/site) — stub projects render the Fallback in dev unless GITLAB_HOST provides them" +depends = ["build"] +run = "pnpm --filter example-site run start" + +[tasks."gitlab:build"] +description = "Build the full GitLab showcase site (examples/gitlab)" +depends = ["build"] +run = "pnpm --filter example-gitlab run build" + +[tasks."gitlab:start"] +description = "Run the GitLab showcase site dev server (examples/gitlab)" +depends = ["build"] +run = "pnpm --filter example-gitlab run start" + +[tasks.release] +description = "Run the full release gate locally, then show the pending release-please PR (merging it triggers the automated npm publish — no local publish)" +run = """ +set -e +pnpm install --frozen-lockfile +pnpm run lint +pnpm run typecheck +pnpm test +pnpm run build +echo "" +echo "Release gate passed. Releases are automated with release-please:" +gh pr list --state open --label "autorelease: pending" || true +echo "Merge the release PR above (if any); CI tags the release and publishes to npm via OIDC." +""" From d26fea57c50f64a496cb4bb6a6093bfaf186ec96 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Sun, 19 Jul 2026 15:29:20 +0200 Subject: [PATCH 24/24] feat(user): theming Signed-off-by: Thomas Decaux --- examples/gitlab/docs/users.md | 5 +++++ theme.css | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 examples/gitlab/docs/users.md diff --git a/examples/gitlab/docs/users.md b/examples/gitlab/docs/users.md new file mode 100644 index 0000000..ff5b7fa --- /dev/null +++ b/examples/gitlab/docs/users.md @@ -0,0 +1,5 @@ +# Component user + + + + \ No newline at end of file diff --git a/theme.css b/theme.css index 6744fea..ea02ff5 100644 --- a/theme.css +++ b/theme.css @@ -154,13 +154,11 @@ .gitlab-user-info { margin-left: auto; min-width: 0; - text-align: right; } @container (max-width: 300px) { .gitlab-user-info { flex-basis: 100%; margin-left: 0; - text-align: left; } }