diff --git a/CLAUDE.md b/CLAUDE.md index 31e468e..4bf7ea8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,3 +133,9 @@ Rules: - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). + +## Gitlab API + +You are familiar with Gitlab API: + +OpenAPI: diff --git a/README.md b/README.md index 097a6cb..b5e3b3b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Embed **GitLab** resources — project info, README, releases, issues, and any file or code snippet — directly in your **Docusaurus 3** documentation using MDX components. +![Screenshot](./docs/screenshot1.png) + All data is fetched **at build time** and baked into your static site. No API tokens or network calls ever reach the browser, and pages stay fast. @@ -89,17 +91,45 @@ namespace path (`project="group/subgroup/repo"`). ### `` -A card with name, description, topics, stars/forks, and last activity. +A card with name, description, topics, stars/forks, and last activity. It can +also embed compact **releases**, **commits**, and **issues** sections — each is +opt-in and only fetched when its count prop is a positive number. ```mdx + + + + ``` | Prop | Type | Default | Description | |---|---|---|---| | `project` | string \| number | — | **Required.** Project path or ID | -| `showStats` | boolean | `true` | Show stars/forks/last-activity row | +| `showStats` | boolean | `true` | Show the stars / forks / created / last-activity row | +| `showLinks` | boolean | `true` | Link the release / commit / issue items. Set `false` to render them as plain text (the card title stays a link) | +| `link` | string | project's `web_url` | Override the card title's link target | +| `releases` | number | — | Embed the latest N releases. Absent or `≤ 0` — not fetched, not rendered | +| `commits` | number | — | Embed the latest N commits. Absent or `≤ 0` — not fetched, not rendered | +| `issues` | number | — | Embed the latest N issues. Absent or `≤ 0` — not fetched, not rendered | +| `releasesLayout` | `"list"` \| `"cards"` | `"list"` | Layout for the releases section. An invalid value fails the build | +| `commitsLayout` | `"list"` \| `"cards"` | `"list"` | Layout for the commits section. An invalid value fails the build | +| `issuesLayout` | `"list"` \| `"cards"` | `"list"` | Layout for the issues section. An invalid value fails the build | + +> Each section's `list` layout renders one compact line per item — release: +> tag and name; commit: linked short SHA, title, and author; issue: linked +> `#iid` and title. Every item shows its date (absolute, e.g. `May 1, 2020`) +> pinned to the right. `cards` renders a richer variant of the same data. +> +> The `showStats` row can also show extra pills — total commit count, +> contributor count, open issue count, repository size — automatically, +> whenever that data is available; there's no attribute to request them. Set +> `showStats={false}` to hide the whole row (pills included). Commit count and +> repository size come from the project's `statistics`, which GitLab only +> returns to tokens with **Reporter role or higher**; on anonymous/public +> builds those two pills are simply omitted. Contributor count also depends +> on the API returning a total count header, and is omitted if it isn't. ### `` diff --git a/docs/screenshot1.png b/docs/screenshot1.png new file mode 100644 index 0000000..34805ac Binary files /dev/null and b/docs/screenshot1.png differ diff --git a/docs/superpowers/plans/2026-07-08-projectinfo-sections-and-stats.md b/docs/superpowers/plans/2026-07-08-projectinfo-sections-and-stats.md new file mode 100644 index 0000000..8b9ec14 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-projectinfo-sections-and-stats.md @@ -0,0 +1,1062 @@ +# GitlabProjectInfo Sections + Extended Stats 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 opt-in embedded releases/commits/issues sections (with a title-link override) and extra stat pills (commits, contributors, open issues, repo size) to ``. + +**Architecture:** `fetchProjectInfo` stays the single fetcher for the element. It composes the existing `fetchReleases`/`fetchIssues` plus a new `fetchCommits` for the sections, and reads project `statistics` + a contributors count for the stats. All extra data is attached to `ProjectInfoData` as optional fields. The pure `GitlabProjectInfo` component renders sections (compact `list` default, opt-in `cards`) right after the description, and appends stat pills inside the existing `showStats` row. Section attributes are count-gated (no fetch when unset/≤0); stats are best-effort (never abort the build). + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), `@gitbeaker/rest`, React (SSR), Vitest + React Testing Library. + +**Spec:** `docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md` + +**Conventions (read before starting):** +- ESM-only: every intra-package import uses an explicit `.js` extension. +- gitbeaker responses are snake_case; normalize to camelCase in fetchers. +- Component attribute values are static literals only; the remark plugin injects a `data` prop and leaves all other MDX attributes (e.g. `link`, `releasesLayout`, `showStats`) as props on the element — they flow straight to the React component. +- Component styling uses plain global class names (e.g. `gitlab-badge`, `gitlab-muted`, `gitlab-title`) — this file does NOT use CSS modules. Reuse existing classes; add new semantic class names freely (no new CSS file required). +- After each task run `npx vitest run ` for the touched tests; run `npm run typecheck` before the final commit of each feature. +- Commits are GPG-signed automatically (`commit.gpgsign=true`). Verify with `git log -1 --format='%G?'` (expect `G`). + +--- + +# FEATURE 1 — Embedded releases / commits / issues sections + +## File structure (Feature 1) + +- Modify `src/gitlab/client.ts` — add `getCommits`. +- Modify `src/gitlab/types.ts` — add `CommitData`; add `releases`/`commits`/`issues` to `ProjectInfoData`. +- Modify `src/gitlab/fetchers.ts` — add `fetchCommits`; compose sections + validate layouts in `fetchProjectInfo`. +- Modify `src/components/GitlabProjectInfo.tsx` — render sections + `link` override. +- Modify `src/components/types.ts`, `src/components/index.ts`, `src/index.ts` — re-export `CommitData`. +- Tests: `src/gitlab/client.test.ts`, `src/gitlab/fetchers.test.ts`, `src/components/GitlabProjectInfo.test.tsx`. +- Docs: `README.md`, `examples/site/docs/components/*ProjectInfo*`. + +--- + +## Task 1: `getCommits` client method + +**Files:** +- Modify: `src/gitlab/client.ts` (add method after `getIssues`) +- Test: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Write the failing test** + +In `src/gitlab/client.test.ts`, add a `commitsAllMock` alongside the other mocks. Add it to the mocked `Gitlab` return object as `Commits: { all: commitsAllMock }`, and reset it in `beforeEach` (`commitsAllMock.mockReset()`). Then add this test inside `describe("GitLabClient", ...)`: + +```ts +it("getCommits fetches one page and slices to the limit", async () => { + commitsAllMock.mockResolvedValue([ + { short_id: "a1", title: "one" }, + { short_id: "b2", title: "two" }, + { short_id: "c3", title: "three" }, + ]); + const client = new GitLabClient({ host: "https://gitlab.com" }); + const commits = await client.getCommits("g/r", 2); + expect(commitsAllMock).toHaveBeenCalledWith("g/r", { perPage: 2, maxPages: 1 }); + expect(commits).toHaveLength(2); + expect(commits[0].short_id).toBe("a1"); +}); +``` + +Declare the mock at the top with the others: `const commitsAllMock = vi.fn();`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/gitlab/client.test.ts -t "getCommits"` +Expected: FAIL — `client.getCommits is not a function`. + +- [ ] **Step 3: Implement `getCommits`** + +In `src/gitlab/client.ts`, add after `getIssues`: + +```ts + async getCommits(project: ProjectRef, limit: number): Promise { + const commits = await this.api.Commits.all(project, { perPage: limit, maxPages: 1 }); + return commits.slice(0, limit); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/gitlab/client.test.ts -t "getCommits"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: add getCommits to GitLabClient" +``` + +--- + +## Task 2: `CommitData` type + `fetchCommits` fetcher + +**Files:** +- Modify: `src/gitlab/types.ts` (add `CommitData`) +- Modify: `src/gitlab/fetchers.ts` (add `fetchCommits`) +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Add the `CommitData` type** + +In `src/gitlab/types.ts`, add after `IssueData`: + +```ts +export interface CommitData { + shortId: string; + title: string; + webUrl: string; + authorName: string; + createdAt: string; +} +``` + +- [ ] **Step 2: Write the failing test** + +In `src/gitlab/fetchers.test.ts`, import `fetchCommits` in the existing import from `./fetchers`. Add: + +```ts +describe("fetchCommits", () => { + it("normalizes commits and respects the limit", async () => { + const client = { + getCommits: vi.fn(async () => [ + { short_id: "a1b2c3d", title: "fix: thing", web_url: "https://gitlab.com/g/r/-/commit/a1b2c3d", + author_name: "Ada", created_at: "2026-01-02T00:00:00Z" }, + ]), + }; + const c = ctx(client); + const data = await fetchCommits(c, { project: "g/r", limit: 5 }); + expect(client.getCommits).toHaveBeenCalledWith("g/r", 5); + expect(data).toEqual([ + { shortId: "a1b2c3d", title: "fix: thing", webUrl: "https://gitlab.com/g/r/-/commit/a1b2c3d", + authorName: "Ada", createdAt: "2026-01-02T00:00:00Z" }, + ]); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchCommits"` +Expected: FAIL — `fetchCommits is not exported` / not a function. + +- [ ] **Step 4: Implement `fetchCommits`** + +In `src/gitlab/fetchers.ts`, add `CommitData` to the type import from `./types`, and add this fetcher after `fetchIssues`: + +```ts +export async function fetchCommits(ctx: GitLabContext, attrs: Attrs): Promise { + const project = String(attrs.project); + const limit = typeof attrs.limit === "number" ? attrs.limit : 10; + return memo(ctx, `commits:${project}:${limit}`, async () => { + const raw = await ctx.client.getCommits(attrs.project as string | number, limit); + return raw.map((c: any) => ({ + shortId: c.short_id, + title: c.title, + webUrl: c.web_url, + authorName: c.author_name ?? "", + createdAt: c.created_at, + } satisfies CommitData)); + }); +} +``` + +Also add `getCommits` to the `GitLabClient` type surface used by fetchers if a local interface exists — it does not; fetchers call `ctx.client` (typed `GitLabClient`), so no extra change beyond Task 1. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchCommits"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/types.ts src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add CommitData type and fetchCommits fetcher" +``` + +--- + +## Task 3: Compose sections into `fetchProjectInfo` + +**Files:** +- Modify: `src/gitlab/types.ts` (extend `ProjectInfoData`) +- Modify: `src/gitlab/fetchers.ts` (`fetchProjectInfo` + layout validator) +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Extend `ProjectInfoData`** + +In `src/gitlab/types.ts`, add optional fields to `ProjectInfoData` (after `avatarUrl`): + +```ts + releases?: ReleaseData[]; + commits?: CommitData[]; + issues?: IssueData[]; +``` + +- [ ] **Step 2: Write the failing tests** + +In `src/gitlab/fetchers.test.ts`, add inside `describe("fetchProjectInfo", ...)`: + +```ts +it("attaches sections only when their count is > 0", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => [ + { name: "v1", tag_name: "v1", released_at: "2026-01-01T00:00:00Z", description: "", upcoming_release: false, assets: { links: [] } }, + ]), + getCommits: vi.fn(async () => [ + { short_id: "a1", title: "t", web_url: "u", author_name: "Ada", created_at: "2026-01-02T00:00:00Z" }, + ]), + getIssues: vi.fn(async () => []), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r", releases: 2, commits: 3 }); + expect(client.getReleases).toHaveBeenCalledWith("g/r", 2); + expect(client.getCommits).toHaveBeenCalledWith("g/r", 3); + expect(client.getIssues).not.toHaveBeenCalled(); + expect(data.releases).toHaveLength(1); + expect(data.commits).toHaveLength(1); + expect(data.issues).toBeUndefined(); +}); + +it("does not fetch a section when its count is 0 or absent", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => []), + getCommits: vi.fn(async () => []), + getIssues: vi.fn(async () => []), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r", commits: 0 }); + expect(client.getReleases).not.toHaveBeenCalled(); + expect(client.getCommits).not.toHaveBeenCalled(); + expect(client.getIssues).not.toHaveBeenCalled(); + expect(data.releases).toBeUndefined(); +}); + +it("omits a failing section in non-strict mode instead of throwing", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => { throw new Error("boom"); }), + }; + const c = ctx(client); + c.options.strict = false; + const data = await fetchProjectInfo(c, { project: "g/r", releases: 2 }); + expect(data.releases).toBeUndefined(); + expect(data.name).toBe("r"); +}); + +it("rethrows a failing section in strict mode", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => { throw new Error("boom"); }), + }; + const c = ctx(client); + c.options.strict = true; + await expect(fetchProjectInfo(c, { project: "g/r", releases: 2 })).rejects.toThrow("boom"); +}); + +it("rejects an invalid section layout", async () => { + const client = { getProject: vi.fn(async () => ({ id: 1, path_with_namespace: "g/r", name: "r", description: "", web_url: "u", star_count: 0, forks_count: 0, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null })) }; + await expect(fetchProjectInfo(ctx(client), { project: "g/r", releasesLayout: "grid" })).rejects.toThrow(/releasesLayout/); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchProjectInfo"` +Expected: FAIL — new assertions fail (sections not attached, layout not validated). + +- [ ] **Step 4: Implement the composition** + +In `src/gitlab/fetchers.ts`, add a section-layout validator near `readLayout`: + +```ts +function readSectionLayout(value: unknown, attr: string): "list" | "cards" { + if (value === undefined || value === "list" || value === "cards") { + return value === undefined ? "list" : value; + } + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "${attr}" must be "list" or "cards"; ` + + `got ${JSON.stringify(value)}.`, + ); +} +``` + +Then rewrite `fetchProjectInfo` so it validates layouts up front, builds the base object inside `memo`, and attaches the count-gated sections (respecting `strict`): + +```ts +export async function fetchProjectInfo(ctx: GitLabContext, attrs: Attrs): Promise { + const project = String(attrs.project); + // Validate presentational layout literals early (values are read by the component). + readSectionLayout(attrs.releasesLayout, "releasesLayout"); + readSectionLayout(attrs.commitsLayout, "commitsLayout"); + readSectionLayout(attrs.issuesLayout, "issuesLayout"); + + const rN = typeof attrs.releases === "number" ? attrs.releases : 0; + const cN = typeof attrs.commits === "number" ? attrs.commits : 0; + const iN = typeof attrs.issues === "number" ? attrs.issues : 0; + const strict = ctx.options.strict ?? true; + + async function section(count: number, fn: () => Promise): Promise { + if (!(count > 0)) return undefined; + try { + return await fn(); + } catch (err) { + if (strict) throw err; + return undefined; + } + } + + return memo(ctx, `projectInfo:${project}:r${rN}:c${cN}:i${iN}`, async () => { + const p = await ctx.client.getProject(attrs.project as string | number); + const avatarUrl = p.avatar_url ? await ctx.assets.localize(p.avatar_url, "", project) : null; + const [releases, commits, issues] = await Promise.all([ + section(rN, () => fetchReleases(ctx, { project, limit: rN })), + section(cN, () => fetchCommits(ctx, { project, limit: cN })), + section(iN, () => fetchIssues(ctx, { project, limit: iN })), + ]); + const base: ProjectInfoData = { + id: p.id, + path: p.path_with_namespace, + name: p.name, + descriptionHtml: await renderMarkdown(p.description ?? "", { renderChain: ctx.options.markdownRenderChain }), + webUrl: p.web_url, + starCount: p.star_count, + forksCount: p.forks_count, + topics: p.topics ?? [], + lastActivityAt: p.last_activity_at, + avatarUrl, + }; + if (releases) base.releases = releases; + if (commits) base.commits = commits; + if (issues) base.issues = issues; + return base; + }).then((v) => ({ ...v, path: v.path || project })); +} +``` + +Note: `fetchReleases`/`fetchIssues`/`fetchCommits` are hoisted function declarations in the same module, so the forward references are fine. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchProjectInfo"` +Expected: PASS. Then run the full fetchers file to catch regressions: `npx vitest run src/gitlab/fetchers.test.ts` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/types.ts src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: compose count-gated sections into fetchProjectInfo" +``` + +--- + +## Task 4: Render compact sections + `link` override in the component + +**Files:** +- Modify: `src/components/GitlabProjectInfo.tsx` +- Test: `src/components/GitlabProjectInfo.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +In `src/components/GitlabProjectInfo.test.tsx`, add: + +```ts +it("renders compact release, commit, and issue lines after the description", () => { + render(); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("v1.0")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "a1b2c3d" })).toHaveAttribute("href", "https://gitlab.com/c/a1b2c3d"); + expect(screen.getByText("fix: bug")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Broken thing/ })).toHaveAttribute("href", "https://gitlab.com/i/42"); +}); + +it("renders no section blocks when arrays are absent", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-section")).toBeNull(); +}); + +it("overrides the title link when link is provided", () => { + render(); + expect(screen.getByRole("link", { name: "My Repo" })).toHaveAttribute("href", "https://example.com/app"); +}); + +it("defaults the title link to the project webUrl", () => { + render(); + expect(screen.getByRole("link", { name: "My Repo" })).toHaveAttribute("href", "https://gitlab.com/g/r"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx` +Expected: FAIL — sections not rendered, `link` not applied. + +- [ ] **Step 3: Implement sections + link override** + +Rewrite `src/components/GitlabProjectInfo.tsx`. Update the imports and props, add a `SectionLayout` type + three local render helpers, use `link` for the title href, and render the sections right after the description block: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import { formatCount } from "./format.js"; +import type { ComponentPayload, ProjectInfoData, ReleaseData, CommitData, IssueData } from "./types.js"; + +type SectionLayout = "list" | "cards"; + +interface ProjectInfoProps extends ComponentPayload { + showStats?: boolean; + link?: string; + releasesLayout?: SectionLayout; + commitsLayout?: SectionLayout; + issuesLayout?: SectionLayout; +} + +function shortDate(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + +function Releases({ items, layout }: { items: ReleaseData[]; layout: SectionLayout }) { + return ( +
+
Releases
+
    + {items.map((r) => ( +
  • + {r.tagName} + {r.name || r.tagName} + {layout === "cards" && ( + · {shortDate(r.releasedAt)} + )} +
  • + ))} +
+
+ ); +} + +function Commits({ items, layout }: { items: CommitData[]; layout: SectionLayout }) { + return ( +
+
Latest commits
+
    + {items.map((c) => ( +
  • + {c.shortId} + {c.title} + · {c.authorName} · {shortDate(c.createdAt)} +
  • + ))} +
+
+ ); +} + +function Issues({ items, layout }: { items: IssueData[]; layout: SectionLayout }) { + return ( +
+
Issues
+
    + {items.map((i) => ( +
  • + #{i.iid} {i.title} + {layout === "cards" && ( + · {i.state} · {i.authorName} + )} +
  • + ))} +
+
+ ); +} + +export function GitlabProjectInfo({ + data, + error, + showStats = true, + link, + releasesLayout = "list", + commitsLayout = "list", + issuesLayout = "list", +}: ProjectInfoProps) { + if (error) return ; + if (!data) return null; + return ( +
+
+ {data.avatarUrl && ( + {data.name} + )} + +
+ {data.descriptionHtml && ( +
+ )} + {data.releases && data.releases.length > 0 && ( + + )} + {data.commits && data.commits.length > 0 && ( + + )} + {data.issues && data.issues.length > 0 && ( + + )} + {data.topics.length > 0 && ( +
+ {data.topics.map((t) => ( + {t} + ))} +
+ )} + {showStats && ( +
+ ★ {formatCount(data.starCount)} + ⑂ {formatCount(data.forksCount)} + updated {new Date(data.lastActivityAt).toLocaleDateString()} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx` +Expected: PASS (all existing + new tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/GitlabProjectInfo.tsx src/components/GitlabProjectInfo.test.tsx +git commit -m "feat: render embedded sections and link override in GitlabProjectInfo" +``` + +--- + +## Task 5: `cards` layout coverage + export `CommitData` + +**Files:** +- Modify: `src/components/types.ts`, `src/components/index.ts`, `src/index.ts` +- Test: `src/components/GitlabProjectInfo.test.tsx` + +- [ ] **Step 1: Write the failing test (cards layout)** + +In `src/components/GitlabProjectInfo.test.tsx`, add: + +```ts +it("shows richer metadata in cards layout", () => { + render(); + expect(screen.getByText(/opened/)).toBeInTheDocument(); + expect(screen.getByText(/Ada/)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails, then passes** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx -t "cards layout"` +The rendering from Task 4 already implements the `cards` branch, so this test should PASS immediately. If it FAILS, fix the `layout === "cards"` branch in the `Issues` helper. (This task exists to lock in cards behavior and finish exports.) + +- [ ] **Step 3: Re-export `CommitData`** + +Add `CommitData` to the export list in `src/components/types.ts`: + +```ts +export type { + ProjectInfoData, + ReleaseData, + IssueData, + CommitData, + ReadmeData, + FileData, + TopicData, + LabelData, + FetchError, + ComponentPayload, +} from "../gitlab/types.js"; +``` + +Add `CommitData` to the `export type { … } from "./types.js";` block in `src/components/index.ts` (insert after `IssueData`). + +Add `CommitData` to the `export type { … }` block in `src/index.ts` (insert after `IssueData`). + +- [ ] **Step 4: Typecheck + run tests** + +Run: `npm run typecheck` +Expected: no errors. +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx src/gitlab/fetchers.test.ts src/gitlab/client.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/types.ts src/components/index.ts src/index.ts src/components/GitlabProjectInfo.test.tsx +git commit -m "feat: export CommitData and cover cards layout" +``` + +--- + +## Task 6: Documentation (Feature 1) + +**Files:** +- Modify: `README.md` +- Modify: `examples/site/docs/components/` (the `GitlabProjectInfo` page) + +- [ ] **Step 1: Locate the docs** + +Run: `grep -rl "GitlabProjectInfo" README.md examples/site/docs` +Open the README section and the example page for `GitlabProjectInfo`. + +- [ ] **Step 2: Document the new attributes** + +Add an attributes subsection describing: `releases={N}`, `commits={N}`, `issues={N}` (opt-in counts; no fetch when unset/≤0), `releasesLayout` / `commitsLayout` / `issuesLayout` (`"list"` default, `"cards"`), and `link` (overrides the title href, defaults to the project URL). Include a worked example: + +```mdx + + +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md examples/site/docs +git commit -m "docs: document GitlabProjectInfo embedded sections" +``` + +--- + +# FEATURE 2 — Extended project stats + +## File structure (Feature 2) + +- Create `src/components/formatBytes.ts` — new `formatBytes` helper (kept next to `format.ts`). +- Modify `src/gitlab/client.ts` — `getProject` statistics option + `getContributorsCount`. +- Modify `src/gitlab/types.ts` — add stat fields to `ProjectInfoData`. +- Modify `src/gitlab/fetchers.ts` — map stats in `fetchProjectInfo`. +- Modify `src/components/GitlabProjectInfo.tsx` — render stat pills. +- Tests: `src/components/formatBytes.test.ts`, `src/gitlab/client.test.ts`, `src/gitlab/fetchers.test.ts`, `src/components/GitlabProjectInfo.test.tsx`. +- Docs: `README.md`, example page. + +--- + +## Task 7: `formatBytes` helper + +**Files:** +- Create: `src/components/formatBytes.ts` +- Test: `src/components/formatBytes.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/formatBytes.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { formatBytes } from "./formatBytes.js"; + +describe("formatBytes", () => { + it("formats zero and bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + }); + it("formats KB, MB, GB with one decimal", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(4_404_019)).toBe("4.2 MB"); + expect(formatBytes(2_147_483_648)).toBe("2 GB"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/components/formatBytes.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `formatBytes`** + +Create `src/components/formatBytes.ts`: + +```ts +/** Humanize a byte count: 1536 -> "1.5 KB", 4.4e6 -> "4.2 MB". */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = n; + let unit = -1; + do { + value /= 1024; + unit += 1; + } while (value >= 1024 && unit < units.length - 1); + return `${parseFloat(value.toFixed(1))} ${units[unit]}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/components/formatBytes.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/formatBytes.ts src/components/formatBytes.test.ts +git commit -m "feat: add formatBytes helper" +``` + +--- + +## Task 8: `getProject` statistics option + `getContributorsCount` + +**Files:** +- Modify: `src/gitlab/client.ts` +- Test: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `src/gitlab/client.test.ts`, add a `contributorsAllMock = vi.fn();` at the top, wire it into the mocked Gitlab object as `Repositories: { allContributors: contributorsAllMock }`, and reset it in `beforeEach`. Add: + +```ts +it("getProject forwards the statistics option", async () => { + showMock.mockResolvedValue({ id: 1 }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + await client.getProject("g/r", { statistics: true }); + expect(showMock).toHaveBeenCalledWith("g/r", { statistics: true }); +}); + +it("getProject omits options by default", async () => { + showMock.mockResolvedValue({ id: 1 }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + await client.getProject("g/r"); + expect(showMock).toHaveBeenCalledWith("g/r"); +}); + +it("getContributorsCount returns the pagination total", async () => { + contributorsAllMock.mockResolvedValue({ data: [{}], paginationInfo: { total: 8 } }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + const count = await client.getContributorsCount("g/r"); + expect(contributorsAllMock).toHaveBeenCalledWith("g/r", { showExpanded: true, perPage: 1, maxPages: 1 }); + expect(count).toBe(8); +}); + +it("getContributorsCount returns undefined when total is absent", async () => { + contributorsAllMock.mockResolvedValue({ data: [], paginationInfo: {} }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + expect(await client.getContributorsCount("g/r")).toBeUndefined(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/client.test.ts -t "getProject forwards|getContributorsCount"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +In `src/gitlab/client.ts`, change `getProject` to accept options and add `getContributorsCount`: + +```ts + async getProject(project: ProjectRef, opts?: { statistics?: boolean }): Promise { + return opts ? this.api.Projects.show(project, opts) : this.api.Projects.show(project); + } + + async getContributorsCount(project: ProjectRef): Promise { + const res: any = await this.api.Repositories.allContributors(project, { + showExpanded: true, + perPage: 1, + maxPages: 1, + }); + const total = res?.paginationInfo?.total; + return typeof total === "number" ? total : undefined; + } +``` + +Note: passing `undefined` options to `Projects.show` would change the call signature in the "omits options by default" test — that is why the ternary calls `show(project)` with no second arg when `opts` is undefined. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: PASS (all client tests — the existing `getProject` callers still pass one arg). + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: add statistics option and getContributorsCount to client" +``` + +--- + +## Task 9: Map stats in `fetchProjectInfo` + +**Files:** +- Modify: `src/gitlab/types.ts` (stat fields) +- Modify: `src/gitlab/fetchers.ts` +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Extend `ProjectInfoData`** + +In `src/gitlab/types.ts`, add to `ProjectInfoData` (after the section fields from Task 3): + +```ts + openIssuesCount?: number; + commitCount?: number; + repositorySize?: number; + contributorsCount?: number; +``` + +- [ ] **Step 2: Write the failing tests** + +In `src/gitlab/fetchers.test.ts`, add inside `describe("fetchProjectInfo", ...)`: + +```ts +it("maps statistics, open issues, and contributors count", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: true, open_issues_count: 12, + statistics: { commit_count: 1200, repository_size: 4404019 }, + })), + getContributorsCount: vi.fn(async () => 8), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(client.getProject).toHaveBeenCalledWith("g/r", { statistics: true }); + expect(data.commitCount).toBe(1200); + expect(data.repositorySize).toBe(4404019); + expect(data.openIssuesCount).toBe(12); + expect(data.contributorsCount).toBe(8); +}); + +it("omits statistics-derived stats when statistics is absent", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: false, + })), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(data.commitCount).toBeUndefined(); + expect(data.repositorySize).toBeUndefined(); + expect(data.openIssuesCount).toBeUndefined(); + expect(data.contributorsCount).toBeUndefined(); +}); + +it("never throws when the contributors fetch fails, even in strict mode", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: true, open_issues_count: 5, statistics: { commit_count: 1, repository_size: 1 }, + })), + getContributorsCount: vi.fn(async () => { throw new Error("no perms"); }), + }; + const c = ctx(client); + c.options.strict = true; + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(data.contributorsCount).toBeUndefined(); + expect(data.commitCount).toBe(1); +}); +``` + +Also update the two existing `fetchProjectInfo` tests that assert `expect(client.getProject).toHaveBeenCalledWith("g/r")` — they must now expect `toHaveBeenCalledWith("g/r", { statistics: true })`. Those two fakes have no `getContributorsCount`; add `getContributorsCount: vi.fn(async () => undefined)` to their `client` objects. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchProjectInfo"` +Expected: FAIL — stats not mapped; `getProject` called without options. + +- [ ] **Step 4: Implement stat mapping** + +In `src/gitlab/fetchers.ts`, inside the `fetchProjectInfo` memo closure, change the project fetch to request statistics and add the stat mapping. Update the `getProject` call: + +```ts + const p = await ctx.client.getProject(attrs.project as string | number, { statistics: true }); +``` + +Compute the contributors count best-effort (never throws) alongside the section fetches: + +```ts + const contributorsCount = await ctx.client + .getContributorsCount(attrs.project as string | number) + .catch(() => undefined); +``` + +After building `base` (before the `if (releases)` lines), attach the stats: + +```ts + if (typeof p.statistics?.commit_count === "number") base.commitCount = p.statistics.commit_count; + if (typeof p.statistics?.repository_size === "number") base.repositorySize = p.statistics.repository_size; + if (p.issues_enabled && typeof p.open_issues_count === "number") base.openIssuesCount = p.open_issues_count; + if (typeof contributorsCount === "number") base.contributorsCount = contributorsCount; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: PASS (including the two updated existing tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/types.ts src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: map commit/contributor/issue/size stats in fetchProjectInfo" +``` + +--- + +## Task 10: Render stat pills in the component + +**Files:** +- Modify: `src/components/GitlabProjectInfo.tsx` +- Test: `src/components/GitlabProjectInfo.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +In `src/components/GitlabProjectInfo.test.tsx`, add: + +```ts +it("appends stat pills when their data is present", () => { + render(); + expect(screen.getByText(/1.2k commits/)).toBeInTheDocument(); + expect(screen.getByText(/8 contributors/)).toBeInTheDocument(); + expect(screen.getByText(/12 issues/)).toBeInTheDocument(); + expect(screen.getByText(/4.2 MB/)).toBeInTheDocument(); +}); + +it("omits stat pills whose data is absent", () => { + render(); + expect(screen.queryByText(/commits/)).not.toBeInTheDocument(); + expect(screen.queryByText(/contributors/)).not.toBeInTheDocument(); +}); + +it("hides all stats including new pills when showStats is false", () => { + render(); + expect(screen.queryByText(/commits/)).not.toBeInTheDocument(); + expect(screen.queryByText(/★/)).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx -t "stat pills|showStats is false"` +Expected: FAIL. + +- [ ] **Step 3: Implement the pills** + +In `src/components/GitlabProjectInfo.tsx`, add the `formatBytes` import: + +```tsx +import { formatBytes } from "./formatBytes.js"; +``` + +Inside the `showStats` block, add the new pills after the forks span and before the `updated` span: + +```tsx + ⑂ {formatCount(data.forksCount)} + {typeof data.commitCount === "number" && ( + ⎇ {formatCount(data.commitCount)} commits + )} + {typeof data.contributorsCount === "number" && ( + 👥 {formatCount(data.contributorsCount)} contributors + )} + {typeof data.openIssuesCount === "number" && ( + ⊙ {formatCount(data.openIssuesCount)} issues + )} + {typeof data.repositorySize === "number" && ( + ▤ {formatBytes(data.repositorySize)} + )} + updated {new Date(data.lastActivityAt).toLocaleDateString()} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/GitlabProjectInfo.tsx src/components/GitlabProjectInfo.test.tsx +git commit -m "feat: render extended stat pills in GitlabProjectInfo" +``` + +--- + +## Task 11: Documentation (Feature 2) + full verification + +**Files:** +- Modify: `README.md`, example page +- Verify: whole test suite + typecheck + +- [ ] **Step 1: Document the stats** + +In the README and the `GitlabProjectInfo` example page, describe the new stat pills (commits, contributors, open issues, repository size), note they appear automatically when data is available and are still gated by `showStats`, and add the **Reporter+ token** caveat: commits count and repository size require the build-time token to have Reporter access; otherwise those two pills are omitted. + +- [ ] **Step 2: Run the full unit suite** + +Run: `npx vitest run` +Expected: PASS (all files). + +- [ ] **Step 3: Typecheck + build** + +Run: `npm run typecheck && npm run build` +Expected: no errors; `dist/` emitted. + +- [ ] **Step 4: Commit** + +```bash +git add README.md examples/site/docs +git commit -m "docs: document GitlabProjectInfo extended stats" +``` + +- [ ] **Step 5 (optional but recommended): e2e** + +If you touched anything the e2e site exercises, add a `` usage to an example page and run: +Run: `npx vitest run test/e2e/build.test.ts` +Expected: PASS (slow, ~1 min). + +--- + +## Self-review notes (already reconciled) + +- **Spec coverage:** sections (Tasks 1–4), layouts + exports (Task 5), sections docs (Task 6); stats helper (Task 7), client (Task 8), fetcher mapping (Task 9), component pills (Task 10), stats docs + verification (Task 11). Code-lines is intentionally absent (no API). +- **Strict semantics:** section fetches respect `strict` (Task 3); contributors/statistics are best-effort and never throw (Tasks 8–9). +- **Type consistency:** `CommitData` (`shortId/title/webUrl/authorName/createdAt`) is defined in Task 2 and used identically in Tasks 3–5; `getProject(project, { statistics })`, `getContributorsCount`, and the `openIssuesCount/commitCount/repositorySize/contributorsCount` fields are named identically across Tasks 8–10. +- **Existing-test updates:** Task 9 Step 2 explicitly updates the two prior `fetchProjectInfo` tests for the new `getProject` signature. diff --git a/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md b/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md new file mode 100644 index 0000000..06300c6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md @@ -0,0 +1,306 @@ +# GitlabProjectInfo: embedded sections + extended stats + +**Date:** 2026-07-08 +**Status:** Approved design +**Component:** `` + +Two related enhancements to ``: + +1. **Embedded sections** — opt-in latest releases / commits / issues rendered + inside the card, plus a title-link override. +2. **Extended stats** — additional pills (commits, contributors, open issues, + repository size) appended to the existing stats row when available. + +--- + +# Feature 1 — Embedded releases / commits / issues sections + +## Goal + +Let authors optionally embed compact, build-time-fetched summaries of a +project's latest releases, commits, and issues directly inside the +`GitlabProjectInfo` card, and let them override the card's title link. Each +section is opt-in via a count attribute, discreet by default, and configurable +to a richer layout. + +New MDX attributes on ``: + +| Attribute | Type | Default | Effect | +|---|---|---|---| +| `releases` | number | unset | Include latest N releases. Unset/`≤0` ⇒ not fetched, not rendered. | +| `commits` | number | unset | Include latest N commits. Unset/`≤0` ⇒ not fetched, not rendered. | +| `issues` | number | unset | Include latest N issues. Unset/`≤0` ⇒ not fetched, not rendered. | +| `link` | string | `data.webUrl` | Override the card title's `href`. | +| `releasesLayout` | `"list"` \| `"cards"` | `"list"` | Compact one-line list vs richer cards. | +| `commitsLayout` | `"list"` \| `"cards"` | `"list"` | Compact one-line list vs richer rows. | +| `issuesLayout` | `"list"` \| `"cards"` | `"list"` | Compact one-line list vs richer cards. | + +Attribute values are static scalar literals only (string/number/boolean), per +the existing `src/remark/attributes.ts` parser — no objects or arrays. + +Example: + +```mdx + + +``` + +## Chosen approach + +**Compose existing fetchers.** `fetchProjectInfo` remains the single fetcher +registered for `GitlabProjectInfo`. When a section's count attribute is present +and `> 0`, it awaits the matching fetcher and attaches the result to +`ProjectInfoData`: + +- releases → reuse existing `fetchReleases(ctx, { project, limit })` +- issues → reuse existing `fetchIssues(ctx, { project, limit })` +- commits → new `fetchCommits(ctx, { project, limit })` + new client method + +Rejected alternatives: + +- **Inline everything** in `fetchProjectInfo` — duplicates the + snake_case→camelCase normalization already living in `fetchReleases` / + `fetchIssues`. +- **Separate injected props** — the remark plugin injects exactly one `data` + prop per JSX element, so the sub-data must be nested inside `ProjectInfoData`. + +## Data model & fetching + +### Types (`src/gitlab/types.ts`) + +New `CommitData`: + +```ts +export interface CommitData { + shortId: string; // e.g. "a79a7f7" + title: string; + webUrl: string; // link to the commit + authorName: string; + createdAt: string; // ISO; rendered as a short/relative date +} +``` + +`ProjectInfoData` gains three optional fields, populated only when the matching +count attribute is set and `> 0`: + +```ts +export interface ProjectInfoData { + // …existing fields unchanged… + releases?: ReleaseData[]; + commits?: CommitData[]; + issues?: IssueData[]; +} +``` + +`ReleaseData` and `IssueData` are reused as-is. + +### Client (`src/gitlab/client.ts`) + +New method mirroring `getReleases` / `getIssues`: + +```ts +async getCommits(project: ProjectRef, limit: number): Promise { + const commits = await this.api.Commits.all(project, { perPage: limit, maxPages: 1 }); + return commits.slice(0, limit); +} +``` + +### Fetcher (`src/gitlab/fetchers.ts`) + +- Add `fetchCommits(ctx, attrs)` normalizing gitbeaker commits + (`short_id`, `title`, `web_url`, `author_name`, `created_at`) → + `CommitData`, memoized on `commits:${project}:${limit}`. +- Extend `fetchProjectInfo` to read `releases` / `commits` / `issues` as + numbers. For each present and `> 0`, await the corresponding fetcher (passing + `limit: N`) and attach the array to the result. +- **No-fetch rule (explicit):** if a count is unset or `≤ 0`, that section's + fetcher is **not called** — no `getCommits` / releases / issues request is + made, and the field is left `undefined`. +- Extend the `projectInfo:` cache key to include the three counts + (e.g. `projectInfo:${project}:r${rN}:c${cN}:i${iN}`) so different configs do + not collide. `link` and the `*Layout` attributes are presentational and are + **not** part of the cache key. + +## Component rendering (`src/components/GitlabProjectInfo.tsx`) + +The three sections render **inside the card, immediately after the +`descriptionHtml` block**, in fixed order: **releases → commits → issues**. A +section renders only if its data array is present and non-empty. + +New presentational props read directly from MDX attributes (not from `data`): + +- `link?: string` — overrides the title `href`; falls back to `data.webUrl`. +- `releasesLayout?`, `commitsLayout?`, `issuesLayout?` — `"list"` (default) or + `"cards"`. Invalid literals throw at build time, matching the existing + `GitlabLabels` layout validation. + +Each section has a small heading label ("Releases", "Latest commits", +"Issues"). + +### Compact (`list`) lines — the discreet default + +- **Release:** `tagName` — `name`, linked. +- **Commit:** `shortId` (linked to commit) · `title` · `authorName` · short date. +- **Issue:** `#iid` `title`, linked to `webUrl`. + +### Rich (`cards`) + +Self-contained renderers inside `GitlabProjectInfo` (per the requirement that +the sections live inside this component): + +- Releases and issues render as small stacked cards. +- Commits render as richer rows (SHA + title + author + date). + +Shared per-item markup lives in tiny local helpers to avoid duplication between +the two layouts. + +## Error handling + +- Composed sub-fetches run through the existing `strict` path: in `strict` + mode a failed releases/commits/issues fetch aborts the build (current + behavior); in non-strict/dev mode the failing section is omitted and the rest + of the card still renders. +- A failure of the core project-info fetch still yields the `error` prop → + `Fallback`, unchanged. +- Invalid `*Layout` literals throw at build time. + +## Testing (TDD) + +- **Client:** `getCommits` calls `Commits.all` with `perPage`/`maxPages` and + slices to `limit` (mocked gitbeaker). +- **Fetcher:** + - `fetchProjectInfo` attaches `releases` / `commits` / `issues` only when the + count is `> 0`. + - Asserts **no** client call for a section when its count is unset or `0`. + - Cache key varies by counts. + - `fetchCommits` normalizes snake_case → camelCase. +- **Component:** + - Sections render after the description in order releases → commits → issues. + - Compact vs `cards` layouts (queries by role/text). + - `link` overrides the title `href`; default is `data.webUrl`. + - Empty/absent arrays render nothing. + - Invalid layout literal throws. + +## Documentation + +- Update `README` with the new attributes and examples. +- Update the `GitlabProjectInfo` page under + `examples/site/docs/components/`; the e2e Docusaurus build + (`test/e2e/build.test.ts`) exercises the render path. + +--- + +# Feature 2 — Extended project stats + +## Goal + +Enrich the existing `gitlab-stats` row in `GitlabProjectInfo` with more +project metrics, sourced cheaply from the GitLab API. The existing `showStats` +prop still gates the whole row; the extra pills are appended automatically +whenever their data is available. No new opt-in attributes. + +Stats added: **commits count**, **contributors count**, **open issues count**, +**repository size**. (Code-lines count was requested but is **not feasible** — +GitLab exposes no lines-of-code API; `repository_size` is bytes-on-disk, +`languages` is percentages, and contributor `additions`/`deletions` are churn, +not current LOC.) + +## API feasibility (verified against installed gitbeaker) + +| Stat | Source | Cost | +|---|---|---| +| Commits count | `statistics.commit_count` on `Projects.show(project, {statistics:true})` | Free (same call) | +| Repository size | `statistics.repository_size` (bytes) on the same call | Free (same call) | +| Open issues count | `open_issues_count` on the base project object | Free (already fetched) | +| Contributors count | `X-Total` pagination header from `Repositories.allContributors` | 1 cheap request | + +**Permission caveat:** the `statistics` object is only returned when the +build-time token has **Reporter+** access. For anonymous/public builds it is +omitted, so `commitCount` / `repositorySize` are `undefined` and their pills +are simply not rendered. + +## Data model (`src/gitlab/types.ts`) + +`ProjectInfoData` gains four optional, best-effort fields (omitted when +unavailable): + +```ts +export interface ProjectInfoData { + // …existing: starCount, forksCount, lastActivityAt… + openIssuesCount?: number; // project.open_issues_count (only when issues_enabled) + commitCount?: number; // statistics.commit_count — needs Reporter+ token + repositorySize?: number; // statistics.repository_size (bytes) — needs Reporter+ token + contributorsCount?: number; // from the contributors endpoint's X-Total header +} +``` + +## Client (`src/gitlab/client.ts`) + +- `getProject(project, opts?)` gains an optional `{ statistics?: boolean }`. + `fetchProjectInfo` passes `{ statistics: true }`; all other callers + (readme / file / labels) keep today's behavior — no extra cost or permission + change elsewhere. +- New `getContributorsCount(project): Promise`: + calls `Repositories.allContributors(project, { showExpanded: true, perPage: 1, maxPages: 1 })` + and returns `paginationInfo.total` (the `X-Total` header). Returns `undefined` + when the header is absent — no full-list walk. + +## Fetcher (`fetchProjectInfo`) + +After the project fetch (now with `statistics: true`), map: + +- `commitCount` ← `p.statistics?.commit_count` (undefined when statistics withheld) +- `repositorySize` ← `p.statistics?.repository_size` (same) +- `openIssuesCount` ← `p.open_issues_count` only when `p.issues_enabled` +- `contributorsCount` ← `await getContributorsCount(project)` + +**All four are best-effort:** any absence or failure leaves the field +`undefined`, renders no pill, and **never aborts the build — even in `strict` +mode** (they are supplementary, unlike the Feature 1 section fetches, which +respect `strict`). The contributors call is wrapped so a failure degrades to +`undefined`. No cache-key change — these are deterministic per project and +already covered by the existing `projectInfo:` key. + +## Component (`src/components/GitlabProjectInfo.tsx`) + +Inside the existing `showStats` block, append one pill per **defined** field: + +- Commits — `formatCount(commitCount)` (e.g. "1.2k commits") +- Contributors — `formatCount(contributorsCount)` (e.g. "8 contributors") +- Open issues — `formatCount(openIssuesCount)` (e.g. "12 issues") +- Repository size — new `formatBytes(repositorySize)` helper (e.g. "4.2 MB") + +`showStats={false}` still hides the entire row. Existing star / fork / updated +pills are unchanged. + +## Testing (TDD) + +- **Client:** `getProject` forwards `statistics: true` when requested; + `getContributorsCount` returns the pagination `total` and `undefined` when the + header is absent. +- **Fetcher:** maps all four fields; omits `commitCount`/`repositorySize` when + `statistics` is absent; omits `openIssuesCount` when issues disabled; a + contributors-fetch failure yields `undefined` and does **not** throw (even in + strict mode). +- **Component:** each pill renders only when its value is defined; `formatBytes` + formatting (bytes → KB/MB/GB); `showStats={false}` hides the row. +- **`formatBytes`:** unit-tested directly (0, bytes, KB, MB, GB boundaries). + +## Documentation + +- Update the `GitlabProjectInfo` README section and the + `examples/site/docs/components/` page to describe the new stat pills and the + Reporter+ token requirement for commits/size. + +--- + +# Out of scope (both features) + +- No standalone `GitlabCommits` component (commits live only inside + `GitlabProjectInfo`). +- No new pagination ceiling work for the sections; each fetches a single page + bounded by its `limit` (`maxPages: 1`), consistent with `getReleases` / + `getIssues`. +- No code-lines-count stat (no GitLab API for LOC). +- No new opt-in attributes for stats; visibility is governed by `showStats` + plus data availability. diff --git a/examples/gitlab/docs/gitlab-runner.md b/examples/gitlab/docs/gitlab-runner.md new file mode 100644 index 0000000..04fbf86 --- /dev/null +++ b/examples/gitlab/docs/gitlab-runner.md @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/site/docs/components/project-info.mdx b/examples/site/docs/components/project-info.mdx index 957dda5..1aa5ea6 100644 --- a/examples/site/docs/components/project-info.mdx +++ b/examples/site/docs/components/project-info.mdx @@ -8,12 +8,20 @@ sidebar_position: 1 Renders a card with a project's name, description, topics, star/fork counts, and last activity. Data is fetched at build time. +It can also embed compact **releases**, **commits**, and **issues** sections +directly in the card — each section is opt-in: it's only fetched (and only +rendered) when its count prop is a positive number. + ## Usage ```mdx + + + + ``` ## Props @@ -21,9 +29,41 @@ last activity. Data is fetched at build time. | Prop | Type | Default | Description | |---|---|---|---| | `project` | `string \| number` | — | **Required.** Project path (`group/sub/repo`) or numeric ID. | -| `showStats` | `boolean` | `true` | Show the stars / forks / last-activity row. | +| `showStats` | `boolean` | `true` | Show the stars / forks / created / last-activity row. | +| `showLinks` | `boolean` | `true` | Link the release / commit / issue items. Set `false` to render them as plain text; the card title stays a link either way. | +| `link` | `string` | project's `web_url` | Override the card title's link target. | +| `releases` | `number` | — | Embed the latest N releases. Absent or `≤ 0` ⇒ not fetched, not rendered. | +| `commits` | `number` | — | Embed the latest N commits. Absent or `≤ 0` ⇒ not fetched, not rendered. | +| `issues` | `number` | — | Embed the latest N issues. Absent or `≤ 0` ⇒ not fetched, not rendered. | +| `releasesLayout` | `"list" \| "cards"` | `"list"` | Layout for the embedded releases section. | +| `commitsLayout` | `"list" \| "cards"` | `"list"` | Layout for the embedded commits section. | +| `issuesLayout` | `"list" \| "cards"` | `"list"` | Layout for the embedded issues section. | ## Notes -- The link in the card points to the project's GitLab page (`web_url`). +- The link in the card points to the project's GitLab page (`web_url`) unless + overridden with `link`. +- Embedded sections are independent: any combination of `releases`, `commits`, + and `issues` may be set, in any order. +- `*Layout` props default to `"list"` — a compact single line per item: + release = tag + name; commit = linked short SHA + title + author; issue = + linked `#iid` + title. Every item shows its date (absolute, e.g. + `May 1, 2020` — not "x ago", so a page built once stays correct) pinned to + the right. `"cards"` renders a richer variant of the same data. An invalid + layout value (anything other than `"list"` or `"cards"`) fails the build. - On a failed fetch in non-strict mode, a fallback notice is rendered instead. +- The stats row (`showStats`) can show up to four extra pills — total commit + count, contributor count, open issue count, and repository size — but only + when that data is available. There's no prop to request them; they just + appear when GitLab returns the underlying data, and disappear along with + the rest of the row when `showStats={false}`. + - Commit count and repository size come from the project's `statistics`, + which GitLab only includes in the API response for tokens with + **Reporter role or higher**. On anonymous or public builds, those two + pills are omitted. + - Contributor count relies on the contributors API returning a total-count + header; if it's unavailable, that pill is omitted too. + - Open issue count has no such requirement (as long as the project has + issues enabled). + - There's no lines-of-code pill — GitLab has no API for it, so it's + intentionally not offered. diff --git a/src/components/GitlabProjectInfo.test.tsx b/src/components/GitlabProjectInfo.test.tsx index 4c22dab..f552a3e 100644 --- a/src/components/GitlabProjectInfo.test.tsx +++ b/src/components/GitlabProjectInfo.test.tsx @@ -4,7 +4,8 @@ import { GitlabProjectInfo } from "./GitlabProjectInfo"; const data = { id: 1, path: "g/r", name: "My Repo", descriptionHtml: "

A thing

", webUrl: "https://gitlab.com/g/r", - starCount: 12, forksCount: 3, topics: ["docs", "tooling"], lastActivityAt: "2026-01-01T00:00:00Z", avatarUrl: null, + starCount: 12, forksCount: 3, topics: ["docs", "tooling"], + createdAt: "2020-05-01T00:00:00Z", lastActivityAt: "2026-01-01T00:00:00Z", avatarUrl: null, }; describe("GitlabProjectInfo", () => { @@ -48,4 +49,112 @@ describe("GitlabProjectInfo", () => { render(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); + + it("renders compact release, commit, and issue lines after the description", () => { + render(); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("v1.0")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "a1b2c3d" })).toHaveAttribute("href", "https://gitlab.com/c/a1b2c3d"); + expect(screen.getByText("fix: bug")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Broken thing/ })).toHaveAttribute("href", "https://gitlab.com/i/42"); + }); + + it("renders the project path below the description", () => { + const { container } = render(); + const path = container.querySelector(".gitlab-path"); + expect(path).not.toBeNull(); + expect(path?.textContent).toBe("g/r"); + }); + + it("links commit, issue, and release items by default", () => { + render(); + expect(screen.getByRole("link", { name: "a1b2c3d" })).toHaveAttribute("href", "https://gitlab.com/c/a1b2c3d"); + expect(screen.getByRole("link", { name: /Bug/ })).toHaveAttribute("href", "https://gitlab.com/i/42"); + expect(screen.getByRole("link", { name: /First/ })).toHaveAttribute("href", "https://gitlab.com/r/v1.0"); + }); + + it("renders section items as plain text when showLinks is false, keeping the title link", () => { + render(); + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(1); + expect(links[0]).toHaveTextContent("My Repo"); + expect(screen.getByText("a1b2c3d")).toBeInTheDocument(); + expect(screen.getByText(/#42 Bug/)).toBeInTheDocument(); + expect(screen.getByText("First")).toBeInTheDocument(); + }); + + it("shows a created date in the stats row", () => { + render(); + expect(screen.getByText(/^created /)).toBeInTheDocument(); + }); + + it("renders no section blocks when arrays are absent", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-section")).toBeNull(); + }); + + it("overrides the title link when link is provided", () => { + render(); + expect(screen.getByRole("link", { name: "My Repo" })).toHaveAttribute("href", "https://example.com/app"); + }); + + it("defaults the title link to the project webUrl", () => { + render(); + expect(screen.getByRole("link", { name: "My Repo" })).toHaveAttribute("href", "https://gitlab.com/g/r"); + }); + + it("shows richer metadata in cards layout", () => { + render(); + expect(screen.getByText(/opened/)).toBeInTheDocument(); + expect(screen.getByText(/Ada/)).toBeInTheDocument(); + }); + + it("renders an absolute (not relative) date on each release, commit, and issue", () => { + const { container } = render(); + for (const section of ["releases", "commits", "issues"]) { + const date = container.querySelector(`.gitlab-section-${section} .gitlab-section-date`); + expect(date?.textContent).toMatch(/2020/); + expect(date?.textContent).not.toMatch(/ago/); + } + }); + + it("appends stat pills when their data is present", () => { + render(); + expect(screen.getByText(/1.2k commits/)).toBeInTheDocument(); + expect(screen.getByText(/8 contributors/)).toBeInTheDocument(); + expect(screen.getByText(/12 issues/)).toBeInTheDocument(); + expect(screen.getByText(/4.2 MB/)).toBeInTheDocument(); + }); + + it("omits stat pills whose data is absent", () => { + render(); + expect(screen.queryByText(/commits/)).not.toBeInTheDocument(); + expect(screen.queryByText(/contributors/)).not.toBeInTheDocument(); + }); + + it("hides all stats including new pills when showStats is false", () => { + render(); + expect(screen.queryByText(/commits/)).not.toBeInTheDocument(); + expect(screen.queryByText(/★/)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/GitlabProjectInfo.tsx b/src/components/GitlabProjectInfo.tsx index f741faf..58a0230 100644 --- a/src/components/GitlabProjectInfo.tsx +++ b/src/components/GitlabProjectInfo.tsx @@ -1,30 +1,116 @@ import React from "react"; import { Fallback } from "./Fallback.js"; import { formatCount } from "./format.js"; -import type { ComponentPayload, ProjectInfoData } from "./types.js"; +import { formatBytes } from "./formatBytes.js"; +import { formatDate } from "./formatDate.js"; +import type { ComponentPayload, ProjectInfoData, ReleaseData, CommitData, IssueData } from "./types.js"; -export function GitlabProjectInfo({ data, error, showStats = true }: ComponentPayload & { showStats?: boolean }) { +type SectionLayout = "list" | "cards"; + +interface ProjectInfoProps extends ComponentPayload { + showStats?: boolean; + showLinks?: boolean; + link?: string; + releasesLayout?: SectionLayout; + commitsLayout?: SectionLayout; + issuesLayout?: SectionLayout; +} + +/** Render `text` as a link to `href`, or as plain text when links are disabled. */ +function MaybeLink({ + href, + className, + showLinks, + children, +}: { + href?: string; + className: string; + showLinks: boolean; + children: React.ReactNode; +}) { + if (showLinks && href) return {children}; + return {children}; +} + +function Releases({ items, layout, showLinks }: { items: ReleaseData[]; layout: SectionLayout; showLinks: boolean }) { + return ( +
+
Releases
+
    + {items.map((r) => ( +
  • + {r.tagName} + {r.name || r.tagName} + {formatDate(r.releasedAt)} +
  • + ))} +
+
+ ); +} + +function Commits({ items, layout, showLinks }: { items: CommitData[]; layout: SectionLayout; showLinks: boolean }) { + return ( +
+
Latest commits
+
    + {items.map((c) => ( +
  • + {c.shortId} + {c.title} + · {c.authorName} + {formatDate(c.createdAt)} +
  • + ))} +
+
+ ); +} + +function Issues({ items, layout, showLinks }: { items: IssueData[]; layout: SectionLayout; showLinks: boolean }) { + return ( +
+
Issues
+
    + {items.map((i) => ( +
  • + #{i.iid} {i.title} + {layout === "cards" && ( + · {i.state} · {i.authorName} + )} + {formatDate(i.createdAt)} +
  • + ))} +
+
+ ); +} + +export function GitlabProjectInfo({ + data, + error, + showStats = true, + showLinks = true, + link, + releasesLayout = "list", + commitsLayout = "list", + issuesLayout = "list", +}: ProjectInfoProps) { if (error) return ; if (!data) return null; return (
{data.avatarUrl && ( - {data.name} + {data.name} )}
{data.descriptionHtml && (
)} @@ -35,11 +121,35 @@ export function GitlabProjectInfo({ data, error, showStats = true }: ComponentPa ))}
)} +
{data.path}
+ {data.releases && data.releases.length > 0 && ( + + )} + {data.commits && data.commits.length > 0 && ( + + )} + {data.issues && data.issues.length > 0 && ( + + )} {showStats && (
★ {formatCount(data.starCount)} ⑂ {formatCount(data.forksCount)} - updated {new Date(data.lastActivityAt).toLocaleDateString()} + {typeof data.commitCount === "number" && ( + ⎇ {formatCount(data.commitCount)} commits + )} + {typeof data.contributorsCount === "number" && ( + 👥 {formatCount(data.contributorsCount)} contributors + )} + {typeof data.openIssuesCount === "number" && ( + ⊙ {formatCount(data.openIssuesCount)} issues + )} + {typeof data.repositorySize === "number" && ( + ▤ {formatBytes(data.repositorySize)} + )} + {data.createdAt && ( + created {formatDate(data.createdAt)} - updated {formatDate(data.lastActivityAt)} + )}
)}
diff --git a/src/components/formatBytes.test.ts b/src/components/formatBytes.test.ts new file mode 100644 index 0000000..6510f41 --- /dev/null +++ b/src/components/formatBytes.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { formatBytes } from "./formatBytes.js"; + +describe("formatBytes", () => { + it("formats zero and bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + }); + it("formats KB, MB, GB with one decimal", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(4_404_019)).toBe("4.2 MB"); + expect(formatBytes(2_147_483_648)).toBe("2 GB"); + }); +}); diff --git a/src/components/formatBytes.ts b/src/components/formatBytes.ts new file mode 100644 index 0000000..e58150c --- /dev/null +++ b/src/components/formatBytes.ts @@ -0,0 +1,12 @@ +/** Humanize a byte count: 1536 -> "1.5 KB", 4.4e6 -> "4.2 MB". */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = n; + let unit = -1; + do { + value /= 1024; + unit += 1; + } while (value >= 1024 && unit < units.length - 1); + return `${parseFloat(value.toFixed(1))} ${units[unit]}`; +} diff --git a/src/components/formatDate.test.ts b/src/components/formatDate.test.ts new file mode 100644 index 0000000..9b35497 --- /dev/null +++ b/src/components/formatDate.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { formatDate } from "./formatDate.js"; + +describe("formatDate", () => { + it("formats an ISO date in a human, absolute form", () => { + expect(formatDate("2020-05-01T12:00:00Z", "en-US")).toBe("May 1, 2020"); + expect(formatDate("2026-12-25T12:00:00Z", "en-US")).toBe("Dec 25, 2026"); + }); + + it("does not echo the raw ISO string or a relative form", () => { + const out = formatDate("2020-05-01T12:00:00Z", "en-US"); + expect(out).not.toContain("T00:00:00Z"); + expect(out).not.toMatch(/ago/); + }); +}); diff --git a/src/components/formatDate.ts b/src/components/formatDate.ts new file mode 100644 index 0000000..f1004f7 --- /dev/null +++ b/src/components/formatDate.ts @@ -0,0 +1,11 @@ +/** + * Human, static-safe date: "May 1, 2020". Absolute (not "x ago") so a page + * built once stays correct for years. Uses the build host's locale by default. + */ +export function formatDate(iso: string, locale?: string | string[]): string { + return new Date(iso).toLocaleDateString(locale, { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/src/components/index.ts b/src/components/index.ts index b9ad787..a97be37 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,6 +10,7 @@ export type { ProjectInfoData, ReleaseData, IssueData, + CommitData, ReadmeData, FileData, TopicData, diff --git a/src/components/types.ts b/src/components/types.ts index d2e1810..8d488a0 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -1,6 +1,7 @@ export type { ProjectInfoData, ReleaseData, + CommitData, IssueData, ReadmeData, FileData, diff --git a/src/gitlab/client.test.ts b/src/gitlab/client.test.ts index c575a6e..0146bbb 100644 --- a/src/gitlab/client.test.ts +++ b/src/gitlab/client.test.ts @@ -3,12 +3,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const showMock = vi.fn(); const releasesAllMock = vi.fn(); const issuesAllMock = vi.fn(); +const commitsAllMock = vi.fn(); const showRawMock = vi.fn(); const gitlabCtor = vi.fn(); const topicsAllMock = vi.fn(); const projectLabelsAllMock = vi.fn(); const groupLabelsAllMock = vi.fn(); const groupShowMock = vi.fn(); +const contributorsAllMock = vi.fn(); vi.mock("@gitbeaker/rest", () => ({ // Vitest 4 invokes the mock implementation as a real constructor under @@ -19,11 +21,13 @@ vi.mock("@gitbeaker/rest", () => ({ Projects: { show: showMock }, ProjectReleases: { all: releasesAllMock }, Issues: { all: issuesAllMock }, + Commits: { all: commitsAllMock }, RepositoryFiles: { showRaw: showRawMock }, Topics: { all: topicsAllMock }, ProjectLabels: { all: projectLabelsAllMock }, GroupLabels: { all: groupLabelsAllMock }, Groups: { show: groupShowMock }, + Repositories: { allContributors: contributorsAllMock }, }; }), })); @@ -37,12 +41,14 @@ beforeEach(() => { showMock.mockReset(); releasesAllMock.mockReset(); issuesAllMock.mockReset(); + commitsAllMock.mockReset(); showRawMock.mockReset(); gitlabCtor.mockReset(); topicsAllMock.mockReset(); projectLabelsAllMock.mockReset(); groupLabelsAllMock.mockReset(); groupShowMock.mockReset(); + contributorsAllMock.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); @@ -90,6 +96,19 @@ describe("GitLabClient", () => { expect(data).toEqual([{ iid: 1 }, { iid: 2 }]); }); + it("getCommits fetches one page and slices to the limit", async () => { + commitsAllMock.mockResolvedValue([ + { short_id: "a1", title: "one" }, + { short_id: "b2", title: "two" }, + { short_id: "c3", title: "three" }, + ]); + const client = new GitLabClient({ host: "https://gitlab.com" }); + const commits = await client.getCommits("g/r", 2); + expect(commitsAllMock).toHaveBeenCalledWith("g/r", { perPage: 2, maxPages: 1 }); + expect(commits).toHaveLength(2); + expect(commits[0].short_id).toBe("a1"); + }); + it("getFileRaw delegates to RepositoryFiles.showRaw with the given path and ref", async () => { showRawMock.mockResolvedValue("# hello"); const c = new GitLabClient({ host: "https://gitlab.com" }); @@ -170,4 +189,38 @@ describe("GitLabClient", () => { expect(data).toEqual({ id: 9, web_url: "https://x/groups/my-group" }); expect(groupShowMock).toHaveBeenCalledWith("my-group"); }); + + it("getProject forwards the statistics option", async () => { + showMock.mockResolvedValue({ id: 1 }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + await client.getProject("g/r", { statistics: true }); + expect(showMock).toHaveBeenCalledWith("g/r", { statistics: true }); + }); + + it("getProject omits options by default", async () => { + showMock.mockResolvedValue({ id: 1 }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + await client.getProject("g/r"); + expect(showMock).toHaveBeenCalledWith("g/r"); + }); + + it("getContributorsCount returns the pagination total", async () => { + contributorsAllMock.mockResolvedValue({ data: [{}], paginationInfo: { total: 8 } }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + const count = await client.getContributorsCount("g/r"); + expect(contributorsAllMock).toHaveBeenCalledWith("g/r", { showExpanded: true, perPage: 1, maxPages: 1 }); + expect(count).toBe(8); + }); + + it("getContributorsCount returns undefined when total is absent", async () => { + contributorsAllMock.mockResolvedValue({ data: [], paginationInfo: {} }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + expect(await client.getContributorsCount("g/r")).toBeUndefined(); + }); + + it("getContributorsCount returns undefined when total is NaN", async () => { + contributorsAllMock.mockResolvedValue({ data: [], paginationInfo: { total: NaN } }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + expect(await client.getContributorsCount("g/r")).toBeUndefined(); + }); }); diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts index 2d72720..9be03ef 100644 --- a/src/gitlab/client.ts +++ b/src/gitlab/client.ts @@ -36,8 +36,18 @@ export class GitLabClient { : new Gitlab({ host: config.host }); } - async getProject(project: ProjectRef): Promise { - return this.api.Projects.show(project); + async getProject(project: ProjectRef, opts?: { statistics?: boolean }): Promise { + return opts ? this.api.Projects.show(project, opts) : this.api.Projects.show(project); + } + + async getContributorsCount(project: ProjectRef): Promise { + const res: any = await this.api.Repositories.allContributors(project, { + showExpanded: true, + perPage: 1, + maxPages: 1, + } as any); + const total = res?.paginationInfo?.total; + return Number.isFinite(total) ? total : undefined; } async getReleases(project: ProjectRef, limit: number): Promise { @@ -57,6 +67,11 @@ export class GitLabClient { return issues.slice(0, opts.limit); } + async getCommits(project: ProjectRef, limit: number): Promise { + const commits = await this.api.Commits.all(project, { perPage: limit, maxPages: 1 }); + return commits.slice(0, limit); + } + async getFileRaw(project: ProjectRef, path: string, ref: string): Promise { const raw = await this.api.RepositoryFiles.showRaw(project, path, ref); return typeof raw === "string" ? raw : await raw.text(); diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index 801dc15..1b2fe68 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, fetchReadme, fetchFile, fetchTopics, fetchLabels } from "./fetchers"; +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -25,6 +25,7 @@ describe("fetchProjectInfo", () => { id: 7, path_with_namespace: "g/r", name: "r", description: "ship **it** :rocket:", web_url: "https://gitlab.com/g/r", star_count: 3, forks_count: 1, topics: ["x"], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, })), + getContributorsCount: vi.fn(async () => undefined), }; const c = ctx(client); const data = await fetchProjectInfo(c, { project: "g/r" }); @@ -33,7 +34,61 @@ describe("fetchProjectInfo", () => { expect(data.descriptionHtml).toContain("🚀"); expect(data.avatarUrl).toBeNull(); expect(c.assets.localize).not.toHaveBeenCalled(); - expect(client.getProject).toHaveBeenCalledWith("g/r"); + expect(client.getProject).toHaveBeenCalledWith("g/r", { statistics: true }); + }); + + it("maps statistics, open issues, and contributors count", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], created_at: "2020-05-01T00:00:00Z", + last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: true, open_issues_count: 12, + statistics: { commit_count: 1200, repository_size: 4404019 }, + })), + getContributorsCount: vi.fn(async () => 8), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(client.getProject).toHaveBeenCalledWith("g/r", { statistics: true }); + expect(data.createdAt).toBe("2020-05-01T00:00:00Z"); + expect(data.commitCount).toBe(1200); + expect(data.repositorySize).toBe(4404019); + expect(data.openIssuesCount).toBe(12); + expect(data.contributorsCount).toBe(8); + }); + + it("omits statistics-derived stats when statistics is absent", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: false, + })), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(data.commitCount).toBeUndefined(); + expect(data.repositorySize).toBeUndefined(); + expect(data.openIssuesCount).toBeUndefined(); + expect(data.contributorsCount).toBeUndefined(); + }); + + it("never throws when the contributors fetch fails, even in strict mode", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: true, open_issues_count: 5, statistics: { commit_count: 1, repository_size: 1 }, + })), + getContributorsCount: vi.fn(async () => { throw new Error("no perms"); }), + }; + const c = ctx(client); + c.options.strict = true; + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(data.contributorsCount).toBeUndefined(); + expect(data.commitCount).toBe(1); }); it("localizes the avatar when the project has one", async () => { @@ -43,12 +98,116 @@ describe("fetchProjectInfo", () => { star_count: 3, forks_count: 1, topics: ["x"], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: "https://gitlab.com/uploads/avatar.png", })), + getContributorsCount: vi.fn(async () => undefined), }; const c = ctx(client); const data = await fetchProjectInfo(c, { project: "g/r" }); expect(c.assets.localize).toHaveBeenCalledWith("https://gitlab.com/uploads/avatar.png", "", "g/r"); expect(data.avatarUrl).toBe("/gitlab-assets/httpsgitlabcomuploadsavatarpng.png"); }); + + it("attaches sections only when their count is > 0", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => [ + { name: "v1", tag_name: "v1", released_at: "2026-01-01T00:00:00Z", description: "", upcoming_release: false, assets: { links: [] } }, + ]), + getCommits: vi.fn(async () => [ + { short_id: "a1", title: "t", web_url: "u", author_name: "Ada", created_at: "2026-01-02T00:00:00Z" }, + ]), + getIssues: vi.fn(async () => []), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r", releases: 2, commits: 3 }); + expect(client.getReleases).toHaveBeenCalledWith("g/r", 2); + expect(client.getCommits).toHaveBeenCalledWith("g/r", 3); + expect(client.getIssues).not.toHaveBeenCalled(); + expect(data.releases).toHaveLength(1); + expect(data.commits).toHaveLength(1); + expect(data.issues).toBeUndefined(); + }); + + it("does not fetch a section when its count is 0 or absent", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => []), + getCommits: vi.fn(async () => []), + getIssues: vi.fn(async () => []), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r", commits: 0 }); + expect(client.getReleases).not.toHaveBeenCalled(); + expect(client.getCommits).not.toHaveBeenCalled(); + expect(client.getIssues).not.toHaveBeenCalled(); + expect(data.releases).toBeUndefined(); + }); + + it("omits a failing section in non-strict mode instead of throwing", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => { throw new Error("boom"); }), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + c.options.strict = false; + const data = await fetchProjectInfo(c, { project: "g/r", releases: 2 }); + expect(data.releases).toBeUndefined(); + expect(data.name).toBe("r"); + }); + + it("rethrows a failing section in strict mode", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => { throw new Error("boom"); }), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + c.options.strict = true; + await expect(fetchProjectInfo(c, { project: "g/r", releases: 2 })).rejects.toThrow("boom"); + }); + + it("rejects an invalid section layout", async () => { + const client = { getProject: vi.fn(async () => ({ id: 1, path_with_namespace: "g/r", name: "r", description: "", web_url: "u", star_count: 0, forks_count: 0, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null })) }; + await expect(fetchProjectInfo(ctx(client), { project: "g/r", releasesLayout: "grid" })).rejects.toThrow(/releasesLayout/); + }); + + it("caches sections separately per count (cache key varies)", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async (_p: unknown, limit: number) => + Array.from({ length: limit }, (_, i) => ({ + name: `v${i}`, tag_name: `v${i}`, released_at: "2026-01-01T00:00:00Z", + description: "", upcoming_release: false, assets: { links: [] }, + })), + ), + getCommits: vi.fn(async () => []), + getIssues: vi.fn(async () => []), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + const first = await fetchProjectInfo(c, { project: "g/r", releases: 1 }); + const second = await fetchProjectInfo(c, { project: "g/r", releases: 2 }); + expect(first.releases).toHaveLength(1); + expect(second.releases).toHaveLength(2); + expect(client.getReleases).toHaveBeenCalledTimes(2); + }); }); describe("fetchReleases", () => { @@ -56,12 +215,14 @@ describe("fetchReleases", () => { const client = { getReleases: vi.fn(async () => [ { name: "v1", tag_name: "v1", released_at: "2026-01-01T00:00:00Z", description: "**notes**", - upcoming_release: false, assets: { links: [{ name: "bin", url: "https://x/bin" }] } }, + upcoming_release: false, assets: { links: [{ name: "bin", url: "https://x/bin" }] }, + _links: { self: "https://gitlab.com/g/r/-/releases/v1" } }, ]), }; const data = await fetchReleases(ctx(client), { project: "g/r", limit: 5, includePrereleases: true }); expect(data).toHaveLength(1); expect(data[0].tagName).toBe("v1"); + expect(data[0].webUrl).toBe("https://gitlab.com/g/r/-/releases/v1"); expect(data[0].descriptionHtml).toContain("notes"); expect(data[0].assets).toEqual([{ name: "bin", url: "https://x/bin" }]); expect(client.getReleases).toHaveBeenCalledWith("g/r", 5); @@ -109,6 +270,24 @@ describe("fetchIssues", () => { }); }); +describe("fetchCommits", () => { + it("normalizes commits and respects the limit", async () => { + const client = { + getCommits: vi.fn(async () => [ + { short_id: "a1b2c3d", title: "fix: thing", web_url: "https://gitlab.com/g/r/-/commit/a1b2c3d", + author_name: "Ada", created_at: "2026-01-02T00:00:00Z" }, + ]), + }; + const c = ctx(client); + const data = await fetchCommits(c, { project: "g/r", limit: 5 }); + expect(client.getCommits).toHaveBeenCalledWith("g/r", 5); + expect(data).toEqual([ + { shortId: "a1b2c3d", title: "fix: thing", webUrl: "https://gitlab.com/g/r/-/commit/a1b2c3d", + authorName: "Ada", createdAt: "2026-01-02T00:00:00Z" }, + ]); + }); +}); + describe("fetchReadme", () => { it("resolves default branch, renders html, and localizes images", async () => { const client: any = { diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 656ff2e..d27c5bb 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -10,6 +10,7 @@ import type { GitLabClient, PageOptions } from "./client"; import { renderMarkdown } from "./markdown.js"; import type { TocEntry, TocMode } from "./toc.js"; import type { + CommitData, FileData, IssueData, LabelData, @@ -70,14 +71,50 @@ async function memo(ctx: GitLabContext, key: string, fn: () => Promise): P return value; } +function readSectionLayout(value: unknown, attr: string): "list" | "cards" { + if (value === undefined || value === "list" || value === "cards") { + return value === undefined ? "list" : value; + } + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "${attr}" must be "list" or "cards"; ` + + `got ${JSON.stringify(value)}.`, + ); +} + export async function fetchProjectInfo(ctx: GitLabContext, attrs: Attrs): Promise { const project = String(attrs.project); - return memo(ctx, `projectInfo:${project}`, async () => { - const p = await ctx.client.getProject(attrs.project as string | number); - const avatarUrl = p.avatar_url - ? await ctx.assets.localize(p.avatar_url, "", project) - : null; - return { + // Validate presentational layout literals early (values are read by the component). + readSectionLayout(attrs.releasesLayout, "releasesLayout"); + readSectionLayout(attrs.commitsLayout, "commitsLayout"); + readSectionLayout(attrs.issuesLayout, "issuesLayout"); + + const rN = typeof attrs.releases === "number" ? attrs.releases : 0; + const cN = typeof attrs.commits === "number" ? attrs.commits : 0; + const iN = typeof attrs.issues === "number" ? attrs.issues : 0; + const strict = ctx.options.strict ?? true; + + async function section(count: number, fn: () => Promise): Promise { + if (!(count > 0)) return undefined; + try { + return await fn(); + } catch (err) { + if (strict) throw err; + return undefined; + } + } + + return memo(ctx, `projectInfo:${project}:r${rN}:c${cN}:i${iN}`, async () => { + const p = await ctx.client.getProject(attrs.project as string | number, { statistics: true }); + const avatarUrl = p.avatar_url ? await ctx.assets.localize(p.avatar_url, "", project) : null; + const contributorsCount = await ctx.client + .getContributorsCount(attrs.project as string | number) + .catch(() => undefined); + const [releases, commits, issues] = await Promise.all([ + section(rN, () => fetchReleases(ctx, { project, limit: rN })), + section(cN, () => fetchCommits(ctx, { project, limit: cN })), + section(iN, () => fetchIssues(ctx, { project, limit: iN })), + ]); + const base: ProjectInfoData = { id: p.id, path: p.path_with_namespace, name: p.name, @@ -86,9 +123,18 @@ export async function fetchProjectInfo(ctx: GitLabContext, attrs: Attrs): Promis starCount: p.star_count, forksCount: p.forks_count, topics: p.topics ?? [], + createdAt: p.created_at, lastActivityAt: p.last_activity_at, avatarUrl, - } satisfies ProjectInfoData; + }; + if (typeof p.statistics?.commit_count === "number") base.commitCount = p.statistics.commit_count; + if (typeof p.statistics?.repository_size === "number") base.repositorySize = p.statistics.repository_size; + if (p.issues_enabled && typeof p.open_issues_count === "number") base.openIssuesCount = p.open_issues_count; + if (typeof contributorsCount === "number") base.contributorsCount = contributorsCount; + if (releases) base.releases = releases; + if (commits) base.commits = commits; + if (issues) base.issues = issues; + return base; }).then((v) => ({ ...v, path: v.path || project })); } @@ -107,6 +153,7 @@ export async function fetchReleases(ctx: GitLabContext, attrs: Attrs): Promise ({ name: l.name, url: l.url })), + webUrl: r._links?.self, })), ); }); @@ -135,6 +182,21 @@ export async function fetchIssues(ctx: GitLabContext, attrs: Attrs): Promise { + const project = String(attrs.project); + const limit = typeof attrs.limit === "number" ? attrs.limit : 10; + return memo(ctx, `commits:${project}:${limit}`, async () => { + const raw = await ctx.client.getCommits(attrs.project as string | number, limit); + return raw.map((c: any) => ({ + shortId: c.short_id, + title: c.title, + webUrl: c.web_url, + authorName: c.author_name ?? "", + createdAt: c.created_at, + } satisfies CommitData)); + }); +} + interface OrderSpec { field: "name"; dir: "asc" | "desc"; diff --git a/src/gitlab/types.ts b/src/gitlab/types.ts index 088d52b..f2f8692 100644 --- a/src/gitlab/types.ts +++ b/src/gitlab/types.ts @@ -12,8 +12,16 @@ export interface ProjectInfoData { starCount: number; forksCount: number; topics: string[]; + createdAt: string; lastActivityAt: string; avatarUrl: string | null; + releases?: ReleaseData[]; + commits?: CommitData[]; + issues?: IssueData[]; + openIssuesCount?: number; + commitCount?: number; + repositorySize?: number; + contributorsCount?: number; } export interface ReleaseAsset { @@ -28,6 +36,8 @@ export interface ReleaseData { descriptionHtml: string; upcomingRelease: boolean; assets: ReleaseAsset[]; + /** Release page URL (GitLab `_links.self`); absent if the API omits it. */ + webUrl?: string; } export interface IssueData { @@ -41,6 +51,14 @@ export interface IssueData { createdAt: string; } +export interface CommitData { + shortId: string; + title: string; + webUrl: string; + authorName: string; + createdAt: string; +} + export interface ReadmeData { ref: string; html: string; diff --git a/src/index.ts b/src/index.ts index 43a0f20..b5d80e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export type { ProjectInfoData, ReleaseData, IssueData, + CommitData, ReadmeData, FileData, TopicData, diff --git a/theme.css b/theme.css index 47fd175..ef1b7dc 100644 --- a/theme.css +++ b/theme.css @@ -11,7 +11,7 @@ .gitlab-card { border: 1px solid var(--ifm-color-emphasis-200); border-radius: 10px; - padding: 1rem; + padding: 0.5rem; margin: 1rem 0; background: var(--ifm-background-surface-color); box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 2px 8px rgb(0 0 0 / 0.04); @@ -41,18 +41,26 @@ .gitlab-muted { color: var(--ifm-color-emphasis-600); } + +.gitlab-description p { + margin: 0; +} + .gitlab-stats { display: flex; gap: 1rem; - margin-top: 0.5rem; + margin-top: 1rem; + border-top: 1px solid lightgray; + padding-top: 0.5rem; + font-size: 0.8rem; } /* Badges: topics, tags, labels, release assets */ .gitlab-badge { display: inline-block; - padding: 0 0.5rem; + padding: 0 0.4rem; margin-right: 0.25rem; - border-radius: 999px; + border-radius: 5px; background: var(--ifm-color-emphasis-100); color: var(--ifm-color-primary); font-size: 0.85em; @@ -118,6 +126,10 @@ color: var(--ifm-color-emphasis-700); } +.gitlab-description { + margin-bottom: 0.1rem; +} + /* Issues list */ .gitlab-issues { list-style: none; @@ -236,6 +248,70 @@ margin-top: 0.75rem; } +/* Project path (group/name), shown just below the description */ +.gitlab-path { + margin-top: 0.15rem; + color: var(--ifm-color-success); + font-family: var(--ifm-font-family-monospace); + font-size: 0.85em; +} + +/* Embedded sections inside the project card (releases / commits / issues) */ +.gitlab-section { + margin-top: 0.85rem; +} +.gitlab-section-title { + font-size: 0.72em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.35rem; +} +.gitlab-section-list { + list-style: none; + margin: 0; + padding: 0; +} +.gitlab-section-item { + padding: 0.2rem 0; + font-size: 0.9em; + line-height: 1.45; +} +.gitlab-section-name { + font-weight: 500; +} +.gitlab-commit-sha { + font-family: var(--ifm-font-family-monospace); + font-size: 0.85em; + color: var(--ifm-color-primary); +} +/* Release / commit / issue rows pin their "x ago" date to the right edge. */ +.gitlab-section-releases .gitlab-section-item, +.gitlab-section-commits .gitlab-section-item, +.gitlab-section-issues .gitlab-section-item { + display: flex; + align-items: baseline; + gap: 0.35rem; +} +.gitlab-section-date { + margin-left: auto; + white-space: nowrap; + font-size: 0.85em; +} + +/* Cards layout: each item becomes a bordered chip-card instead of a plain line */ +.gitlab-section-list[data-layout="cards"] { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.5rem; +} +.gitlab-section-list[data-layout="cards"] .gitlab-section-item { + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 8px; + padding: 0.5rem 0.75rem; + background: var(--ifm-background-surface-color); +} + /* README / markdown file embed */ .gitlab-readme img { max-width: 100%; @@ -276,6 +352,9 @@ [data-theme='dark'] .gitlab-release { box-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 2px 10px rgb(0 0 0 / 0.25); } +[data-theme='dark'] .gitlab-section-list[data-layout="cards"] .gitlab-section-item { + box-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 2px 10px rgb(0 0 0 / 0.25); +} /* Generated [[_TOC_]] table of contents */ .gitlab-md-toc ul {