diff --git a/README.md b/README.md index ff32ab4..cb89207 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,46 @@ 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 profile lookup per member at +build time (two API requests per member on a cold build, deduplicated across +pages and builds). + ### `` Renders a timeline of GitLab **epics** (Premium/Ultimate, group-level) or 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`. 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). diff --git a/examples/site/docs/components/user.mdx b/examples/site/docs/components/user.mdx new file mode 100644 index 0000000..71f49a9 --- /dev/null +++ b/examples/site/docs/components/user.mdx @@ -0,0 +1,47 @@ +--- +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; a zero followers/following count is treated as +empty too. + +## 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..e13c74e --- /dev/null +++ b/examples/site/docs/components/users.mdx @@ -0,0 +1,56 @@ +--- +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 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/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/src/components/GitlabUser.test.tsx b/src/components/GitlabUser.test.tsx new file mode 100644 index 0000000..35352d6 --- /dev/null +++ b/src/components/GitlabUser.test.tsx @@ -0,0 +1,86 @@ +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", () => { + 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(/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.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("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(); + }); + + 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/GitlabUsers.test.tsx b/src/components/GitlabUsers.test.tsx new file mode 100644 index 0000000..bcb4857 --- /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) => ( + + ))} +
+ ); +} diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx new file mode 100644 index 0000000..840f495 --- /dev/null +++ b/src/components/UserCard.tsx @@ -0,0 +1,73 @@ +import React from "react"; +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); + 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 > 0 ? `${user.followers} followers` : null, + user.following !== null && user.following > 0 ? `${user.following} following` : null, + ] + .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 ( +
+
+ {user.avatarUrl && ( + {user.name} + )} +
+ {user.name} + + @{user.username} + + {has("role") && user.role && {user.role}} +
+
+ {info.length > 0 &&
{info}
} +
+ ); +} 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/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, 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..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; @@ -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; diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index a221a74..6bc3143 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, fetchUsers } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -780,3 +780,208 @@ 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(); + }); + + 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"); + }); +}); + +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 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 () => { + 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"]); + }); + + 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 19266cf..b98b548 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -24,7 +24,9 @@ import type { RoadmapItemData, RoadmapScale, TopicData, + UserData, } from "./types"; +import { needsProfile, parseRole, parseShow, roleName } from "./users.js"; export interface GitLabContext { client: GitLabClient; @@ -411,6 +413,115 @@ 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, `userByName:${username.toLowerCase()}`, async () => { + const matches = await ctx.client.getUserByUsername(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.`); + } + 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); +} + +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.isInteger(limit) && limit > 0)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "limit" must be a positive integer; ` + + `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; 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); + 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. + // Resolving via username (rather than calling getUser(m.id) directly) costs + // one extra lookup here, but shares the per-username memo with . + const users: UserData[] = []; + for (const m of members) { + 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; + }); +} + /** 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); 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; +} 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, 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, }; diff --git a/test/e2e/build.test.ts b/test/e2e/build.test.ts index cd471a6..2b20d63 100644 --- a/test/e2e/build.test.ts +++ b/test/e2e/build.test.ts @@ -105,6 +105,23 @@ 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"); + // 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"); + }); + 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..eb2eb72 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,24 @@ 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); + } + // 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); + 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([ { diff --git a/theme.css b/theme.css index ba6b813..ab26147 100644 --- a/theme.css +++ b/theme.css @@ -126,6 +126,72 @@ color: var(--ifm-color-emphasis-700); } +/* 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; +} +.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; +} +.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; }