From 5b6a9b4917d7ef8b682916e797cf15885b5cd62a Mon Sep 17 00:00:00 2001 From: alexion Date: Tue, 14 Jul 2026 11:01:07 -0400 Subject: [PATCH] feat: add the two-tier dashboard (task 0017) Wire the bare `gitea-axi` home command to a two-tier repository dashboard (ADR 0012): the short tier shows up to 3 open issues and 3 open PRs with the client-side `review` decision, and `--full` shows the 20-row open-PR table plus open issue counts grouped by label, aggregated across every page of open issues up to the 1000-issue cap with a `+` suffix when capped. Empty states render the raw `prs: 0 open` / `issues: 0 open` strings; issue fetches pass `type=issues`; outside a Gitea repo the dashboard errors with REPO_NOT_FOUND. `paginate.ts` now reports whether pagination stopped at the cap, which drives the label-count `+` suffix. --- .claude/tasks/0017-dashboard.md | 36 +++- src/cli.ts | 30 ++-- src/commands/dashboard.ts | 276 +++++++++++++++++++++++++++++++ src/paginate.ts | 12 +- test/dashboard.test.ts | 284 ++++++++++++++++++++++++++++++++ test/detection.test.ts | 15 ++ test/e2e/dashboard.test.ts | 79 +++++++++ 7 files changed, 705 insertions(+), 27 deletions(-) create mode 100644 src/commands/dashboard.ts create mode 100644 test/dashboard.test.ts create mode 100644 test/e2e/dashboard.test.ts diff --git a/.claude/tasks/0017-dashboard.md b/.claude/tasks/0017-dashboard.md index 500f202..70d5a20 100644 --- a/.claude/tasks/0017-dashboard.md +++ b/.claude/tasks/0017-dashboard.md @@ -13,11 +13,31 @@ Issue fetching passes `type=issues`; outside a recognizable Gitea repo the dashb ## Acceptance criteria -- [ ] Bare `gitea-axi` renders the header, `repo:` line, up to 3 issues and 3 PRs with the specified fields (including the computed `review`), and a `help:` block hinting at `--full` -- [ ] `gitea-axi --full` renders the PR table capped at 20 rows with `count: 20 of T total` and issue counts grouped by label -- [ ] Label aggregation paginates to the 1000-issue cap, suffixes counts with `+` when capped, counts each issue under all its labels, and shows `unlabeled` only when nonzero -- [ ] Empty states render `prs: 0 open` / `issues: 0 open` as raw strings -- [ ] Issue fetches pass `type=issues` so PRs never appear in the issue block -- [ ] Outside a Gitea repo the dashboard exits with `REPO_NOT_FOUND` and help mentioning `-R` and `--login` -- [ ] Fixture-server tests cover both tiers, the cap-and-suffix behavior, empty states, and the no-repo error -- [ ] End-to-end tests render the bare dashboard and `--full` against a live Gitea instance, confirming the live issue/PR list response shapes and that the computed `review` and label-aggregation fields hold against real responses — behavior the fixture server cannot attest to +- [x] Bare `gitea-axi` renders the header, `repo:` line, up to 3 issues and 3 PRs with the specified fields (including the computed `review`), and a `help:` block hinting at `--full` +- [x] `gitea-axi --full` renders the PR table capped at 20 rows with `count: 20 of T total` and issue counts grouped by label +- [x] Label aggregation paginates to the 1000-issue cap, suffixes counts with `+` when capped, counts each issue under all its labels, and shows `unlabeled` only when nonzero +- [x] Empty states render `prs: 0 open` / `issues: 0 open` as raw strings +- [x] Issue fetches pass `type=issues` so PRs never appear in the issue block +- [x] Outside a Gitea repo the dashboard exits with `REPO_NOT_FOUND` and help mentioning `-R` and `--login` +- [x] Fixture-server tests cover both tiers, the cap-and-suffix behavior, empty states, and the no-repo error +- [x] End-to-end tests render the bare dashboard and `--full` against a live Gitea instance, confirming the live issue/PR list response shapes and that the computed `review` and label-aggregation fields hold against real responses — behavior the fixture server cannot attest to + +## Implementation Notes + +The two tiers live in a new `src/commands/dashboard.ts` wired as the SDK's `home` handler. +The handler returns a **string** (not an object), so the SDK prepends the `bin:`/`description:` header verbatim above the dashboard's bespoke layout — the raw `prs: 0 open` / `issues: 0 open` empty-state lines and the label→count record cannot be expressed as a plain object for the SDK to encode. + +`--full` is extracted in `runCli` before the SDK dispatches, and only when it is the sole remaining argument (after the global `-R`/`--login` flags are stripped). +This is because the SDK rejects any flag placed before a command, so `gitea-axi --full` would otherwise never reach the `home` handler; the extraction leaves an empty argv for the SDK and threads a `full` boolean into `dashboardCommand`. + +`paginate.ts`'s `PaginatedResult` gained a `capped` flag: `fetchAllPages` now reports whether it stopped at the 20-page/1000-item cap with every page full, which drives the `+` suffix on the label counts. +The addition is backward-compatible — existing callers destructure only `items`/`total`. + +Decisions made mid-implementation (all beyond the literal spec, kept deliberately): + +- **Label-count ordering.** The spec fixes *what* to count but not the order; the `issues:` record is emitted by descending count, ties broken by name ascending, with `unlabeled` always last — a stable, at-a-glance ordering rather than an arbitrary one. +- **Defensive PR cap.** `fetchOpenPulls` slices the returned page to the requested limit so a server that ignored `limit=20` cannot inflate the table (or the review-fetch fan-out) past the cap. +- **Count line always present in the full tier**, including the empty case (`count: 0 of 0 total` above `prs: 0 open`), consistent with the list commands' count-line convention. +- **Top-level `--help`** gained a short note that the bare command shows the dashboard and `--full` selects the rich view, for discoverability. + +The short and full tiers each re-slot the computed `review` after the PR fields via a local `prRowsWithReview` helper rather than sharing `pr list`'s inline loop — `pr list` also merges `--fields` extras into the same row, so the two are not the same operation despite the shared "review after the fields" shape (ADR 0006). diff --git a/src/cli.ts b/src/cli.ts index 5c5e098..ba3a162 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,19 +1,22 @@ import { readFileSync } from "node:fs"; import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js"; +import { dashboardCommand } from "./commands/dashboard.js"; import { issueCommand } from "./commands/issue.js"; import { labelCommand } from "./commands/label.js"; import { prCommand } from "./commands/pr.js"; import { searchCommand } from "./commands/search.js"; -import { resolveRepoContext } from "./context.js"; import type { CliDeps, GlobalFlags } from "./deps.js"; import { consumeFlagValue, splitFlag } from "./flags.js"; import { renderErrorOutput } from "./render.js"; -import { suggestCommand } from "./suggestions.js"; const DESCRIPTION = "Agent-ergonomic CLI for Gitea issues and pull requests"; const TOP_LEVEL_HELP = `usage: gitea-axi [flags] +Run with no command to see the repository dashboard (open issues and pull +requests); add --full for the rich view (the open-PR table and issue counts +by label). + commands: issue list List issues in the current repository issue view Show a single issue's details @@ -77,19 +80,6 @@ function readVersion(): string { return packageJson.version; } -function homeCommand(deps: CliDeps) { - return async (): Promise> => { - const context = await resolveRepoContext(deps); - return { - repo: `${context.owner}/${context.name}`, - help: [ - suggestCommand(context, "issue list", "to list open issues"), - "Run `gitea-axi --help` to see available commands", - ], - }; - }; -} - export interface RunCliOptions { argv: string[]; env: Record; @@ -113,10 +103,16 @@ export async function runCli(options: RunCliOptions): Promise { cwd: options.cwd, globals: extracted.globals, }; + // The dashboard's full tier is `gitea-axi --full`. The SDK rejects any flag + // before a command, so `--full` never reaches the home handler on its own; it + // is pulled out here when it is the sole remaining argument, leaving an empty + // argv for the SDK to dispatch to the home handler in full-tier mode. + const full = extracted.argv.length === 1 && extracted.argv[0] === "--full"; + const argv = full ? [] : extracted.argv; await runAxiCli({ description: DESCRIPTION, version: readVersion(), - argv: extracted.argv, + argv, topLevelHelp: TOP_LEVEL_HELP, commands: { issue: issueCommand(deps), @@ -124,7 +120,7 @@ export async function runCli(options: RunCliOptions): Promise { label: labelCommand(deps), search: searchCommand(deps), }, - home: homeCommand(deps), + home: dashboardCommand(deps, full), stdout: options.stdout, }); return typeof process.exitCode === "number" ? process.exitCode : 0; diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts new file mode 100644 index 0000000..a27158c --- /dev/null +++ b/src/commands/dashboard.ts @@ -0,0 +1,276 @@ +import { encode } from "@toon-format/toon"; +import type { Issue, PullRequest } from "gitea-js"; +import { createClient, type GiteaClient } from "../client.js"; +import { resolveRepoContext, type RepoContext } from "../context.js"; +import type { CliDeps } from "../deps.js"; +import { classifyHttpError } from "../errors.js"; +import { extractRow, joined, lowercased, pluck, type FieldDef } from "../fields.js"; +import { fetchAllPages, readTotalCount, type PaginatedResult } from "../paginate.js"; +import { formatCountLine } from "../render.js"; +import { fetchReviewDecision, type ReviewDecision } from "../review.js"; +import { suggestCommand } from "../suggestions.js"; + +// The short tier fetches at most this many issues and PRs — gh-axi's home shape +// (ADR 0012). At most 3 extra review fetches follow, keeping the whole tier +// inside the SessionStart hook's timeout. +const SHORT_LIMIT = 3; + +// The full tier's open-PR table is capped at this many rows (ADR 0012), each +// costing one review fetch. +const FULL_PR_LIMIT = 20; + +// Short-tier PR columns. `review` is not a FieldDef: it comes from a separate +// per-PR reviews fetch (ADR 0006) and is set on each row afterwards. +const SHORT_PR_FIELDS: FieldDef[] = [ + pluck("number"), + pluck("title"), + pluck("author", "user.login"), +]; + +const SHORT_ISSUE_FIELDS: FieldDef[] = [ + pluck("number"), + pluck("title"), + lowercased("state"), + pluck("author", "user.login"), +]; + +// Full-tier PR-table columns; `review` is appended after, as in the short tier. +const FULL_PR_FIELDS: FieldDef[] = [ + pluck("number"), + pluck("title"), + pluck("author", "user.login"), + joined("labels", "labels", "name"), +]; + +interface OpenPulls { + pulls: PullRequest[]; + /** `X-Total-Count` — the repo's open-PR count, for the full tier's count line. */ + total: number | undefined; +} + +/** Fetch the first page of open PRs, capped at `limit`. */ +async function fetchOpenPulls( + api: GiteaClient, + context: RepoContext, + limit: number, +): Promise { + try { + const response = await api.repos.repoListPullRequests(context.owner, context.name, { + state: "open", + limit, + page: 1, + }); + return { + // A server that ignores `limit` cannot inflate the table past its cap. + pulls: (response.data ?? []).slice(0, limit), + total: readTotalCount(response.headers), + }; + } catch (error) { + throw classifyHttpError(error); + } +} + +/** + * Every page of open issues up to the 1000-issue cap, always passing + * `type=issues` (the Issue/PR Type Guard). The result's `capped` flag drives the + * `+` suffix on the label counts when the cap cut the set short. + */ +async function fetchAllOpenIssues( + api: GiteaClient, + context: RepoContext, +): Promise> { + try { + return await fetchAllPages((page, limit) => + api.repos.issueListIssues(context.owner, context.name, { + state: "open", + type: "issues", + page, + limit, + }), + ); + } catch (error) { + throw classifyHttpError(error); + } +} + +/** + * Fetch the first page of open issues, capped at `limit`, always passing + * `type=issues` so pull requests never pollute the issue block (see the + * Issue/PR Type Guard). + */ +async function fetchOpenIssues( + api: GiteaClient, + context: RepoContext, + limit: number, +): Promise { + try { + const response = await api.repos.issueListIssues(context.owner, context.name, { + state: "open", + type: "issues", + limit, + page: 1, + }); + return response.data ?? []; + } catch (error) { + throw classifyHttpError(error); + } +} + +/** + * The reviewDecision for each PR, one reviews fetch per PR all in flight at once + * (ADR 0006). A PR Gitea returned without a number is reported as `required` + * rather than blocking the whole dashboard on a single malformed entry. + */ +async function pullDecisions( + api: GiteaClient, + context: RepoContext, + pulls: PullRequest[], +): Promise { + return Promise.all( + pulls.map((pull) => + pull.number === undefined + ? Promise.resolve("required") + : fetchReviewDecision(api, context, pull.number), + ), + ); +} + +/** The PR rows with the computed `review` column slotted in after the fields. */ +async function prRowsWithReview( + api: GiteaClient, + context: RepoContext, + pulls: PullRequest[], + fields: FieldDef[], + now: Date, +): Promise[]> { + const decisions = await pullDecisions(api, context, pulls); + return pulls.map((pull, index) => { + const row = extractRow(pull, fields, { now }); + row.review = decisions[index]; + return row; + }); +} + +/** Encode a `` list block, or the raw `emptyLine` when there are no rows. */ +function listBlock(noun: string, rows: Record[], emptyLine: string): string { + return rows.length > 0 ? encode({ [noun]: rows }) : emptyLine; +} + +function shortHelp(context: RepoContext): string[] { + return [ + suggestCommand(context, "issue list", "to list all open issues"), + suggestCommand(context, "pr list", "to list all open pull requests"), + suggestCommand( + context, + "--full", + "for the full dashboard: the open-PR table and issue counts by label", + ), + ]; +} + +function fullHelp(context: RepoContext): string[] { + return [ + suggestCommand(context, "issue list", "to list all open issues"), + suggestCommand(context, "pr list", "to list all open pull requests"), + ]; +} + +/** + * The short tier: up to 3 open PRs (with the computed `review`) and up to 3 open + * issues, fetched in parallel, above a help block that always hints at `--full`. + */ +async function shortDashboard(api: GiteaClient, context: RepoContext): Promise { + const [openPulls, issues] = await Promise.all([ + fetchOpenPulls(api, context, SHORT_LIMIT), + fetchOpenIssues(api, context, SHORT_LIMIT), + ]); + + const now = new Date(); + const prRows = await prRowsWithReview(api, context, openPulls.pulls, SHORT_PR_FIELDS, now); + const issueRows = issues.map((issue) => extractRow(issue, SHORT_ISSUE_FIELDS, { now })); + + return [ + `repo: ${context.owner}/${context.name}`, + listBlock("prs", prRows, "prs: 0 open"), + listBlock("issues", issueRows, "issues: 0 open"), + encode({ help: shortHelp(context) }), + ].join("\n"); +} + +/** + * Open issue counts grouped by label: each issue contributes to every label it + * carries, unlabeled issues fall into a single `unlabeled` bucket rendered only + * when non-zero, and — when the 1000-issue pagination cap was hit — every count + * is suffixed with `+` to mark it a lower bound. Labels are ordered by count + * (descending), ties broken by name, with `unlabeled` always last. + */ +function issueLabelCounts(issues: Issue[], capped: boolean): Record { + const counts = new Map(); + let unlabeled = 0; + for (const issue of issues) { + const names = (issue.labels ?? []) + .map((label) => label.name) + .filter((name): name is string => typeof name === "string" && name.length > 0); + if (names.length === 0) { + unlabeled += 1; + continue; + } + for (const name of names) { + counts.set(name, (counts.get(name) ?? 0) + 1); + } + } + + const mark = (count: number): string | number => (capped ? `${count}+` : count); + const record: Record = {}; + const ordered = [...counts.entries()].sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]), + ); + for (const [name, count] of ordered) { + record[name] = mark(count); + } + if (unlabeled > 0) { + record.unlabeled = mark(unlabeled); + } + return record; +} + +/** + * The full tier: the open-PR table (up to 20 rows, with labels and the computed + * `review`) above open issue counts grouped by label, aggregated across every + * page of open issues up to the 1000-issue cap. + */ +async function fullDashboard(api: GiteaClient, context: RepoContext): Promise { + const [openPulls, issuePages] = await Promise.all([ + fetchOpenPulls(api, context, FULL_PR_LIMIT), + fetchAllOpenIssues(api, context), + ]); + + const now = new Date(); + const prRows = await prRowsWithReview(api, context, openPulls.pulls, FULL_PR_FIELDS, now); + const labelCounts = issueLabelCounts(issuePages.items, issuePages.capped); + + const issuesBlock = + Object.keys(labelCounts).length > 0 ? encode({ issues: labelCounts }) : "issues: 0 open"; + + return [ + `repo: ${context.owner}/${context.name}`, + formatCountLine(prRows.length, openPulls.total, prRows.length >= FULL_PR_LIMIT), + listBlock("prs", prRows, "prs: 0 open"), + issuesBlock, + encode({ help: fullHelp(context) }), + ].join("\n"); +} + +/** + * The two-tier home view (ADR 0012). Returns a string so the SDK prepends the + * `bin:`/`description:` header verbatim; `full` selects the rich tier. Fetching + * begins with {@link resolveRepoContext}, so outside a Gitea repo this errors + * with `REPO_NOT_FOUND` before any request goes out. + */ +export function dashboardCommand(deps: CliDeps, full: boolean) { + return async (): Promise => { + const context = await resolveRepoContext(deps); + const api = createClient(context); + return full ? fullDashboard(api, context) : shortDashboard(api, context); + }; +} diff --git a/src/paginate.ts b/src/paginate.ts index 080ab83..a8aec1d 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -16,6 +16,12 @@ export interface PaginatedResult { items: T[]; /** `X-Total-Count`, absent when the header is missing or not a number. */ total: number | undefined; + /** + * True when pagination stopped at the page cap with every page full, so the + * set may be incomplete — the dashboard's issue aggregation suffixes its + * counts with `+` in this case (see the spec's hard 1000-issue cap). + */ + capped: boolean; } export function readTotalCount(headers: Headers): number | undefined { @@ -48,9 +54,11 @@ export async function fetchAllPages( } const batch = response.data ?? []; items.push(...batch); + // A short page is the last page: the set is complete. if (batch.length < PAGE_SIZE) { - break; + return { items, total, capped: false }; } } - return { items, total }; + // Every page up to the cap came back full, so there may be more beyond it. + return { items, total, capped: true }; } diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts new file mode 100644 index 0000000..1587853 --- /dev/null +++ b/test/dashboard.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureRoute, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues"; +const PULLS_PATH = "/api/v1/repos/testowner/testrepo/pulls"; + +let server: FixtureServer; + +afterEach(async () => { + await server.close(); +}); + +/** A pull request shaped like Gitea's, with only the fields the dashboard reads. */ +function pullOf(number: number, title: string, author: string): Record { + return { + id: 1000 + number, + number, + title, + state: "open", + user: { id: 7, login: author }, + }; +} + +/** An issue shaped like Gitea's, with only the fields the dashboard reads. */ +function issueOf(number: number, title: string, author: string): Record { + return { + id: 2000 + number, + number, + title, + state: "open", + user: { id: 7, login: author }, + }; +} + +function reviewOf(state: string): Record { + return { + id: 1, + state, + official: false, + stale: false, + dismissed: false, + user: { login: "reviewer" }, + }; +} + +/** The reviews-list route a rendered PR triggers (one fetch per PR). */ +function reviewsRoute(number: number, reviews: Record[]): FixtureRoute { + return { method: "GET", path: `${PULLS_PATH}/${number}/reviews`, body: reviews }; +} + +describe("bare dashboard", () => { + it("renders the header, repo line, PRs with computed review, issues, and a --full hint", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: PULLS_PATH, + body: [ + pullOf(5, "Add search", "alexion"), + pullOf(6, "Fix crash", "contributor"), + ], + }, + { + method: "GET", + path: ISSUES_PATH, + body: [ + issueOf(3, "Login loops", "alexion"), + issueOf(4, "Dark mode", "contributor"), + ], + }, + reviewsRoute(5, [reviewOf("APPROVED")]), + reviewsRoute(6, []), + ]); + + const { stdout, exitCode } = await runCliTest([], { env: testModeEnv(server.url) }); + + expect(exitCode).toBe(0); + + // Header and repo line. + expect(stdout).toMatch(/^bin:/m); + expect(stdout).toContain("repo: testowner/testrepo"); + + // PR block: computed review renders approved for PR 5, required for PR 6. + const lines = stdout.split("\n"); + const prHeaderIndex = lines.indexOf("prs[2]{number,title,author,review}:"); + expect(prHeaderIndex).toBeGreaterThanOrEqual(0); + const prRows = lines.slice(prHeaderIndex + 1, prHeaderIndex + 3); + expect(prRows).toEqual([ + " 5,Add search,alexion,approved", + " 6,Fix crash,contributor,required", + ]); + + // Issue block: both rows appear under the specified header. + const issueHeaderIndex = lines.indexOf("issues[2]{number,title,state,author}:"); + expect(issueHeaderIndex).toBeGreaterThanOrEqual(0); + const issueRows = lines.slice(issueHeaderIndex + 1, issueHeaderIndex + 3); + expect(issueRows).toEqual([ + " 3,Login loops,open,alexion", + " 4,Dark mode,open,contributor", + ]); + + // Help block hints at the full dashboard. + expect(stdout).toContain("--full"); + }); + + it("renders raw empty-state lines when there are no open PRs or issues", async () => { + server = await startFixtureServer([ + { method: "GET", path: PULLS_PATH, body: [] }, + { method: "GET", path: ISSUES_PATH, body: [] }, + ]); + + const { stdout, exitCode } = await runCliTest([], { env: testModeEnv(server.url) }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("prs: 0 open"); + expect(stdout).toContain("issues: 0 open"); + // The dashboard uses the raw form, not the list commands' (none) convention. + expect(stdout).not.toContain("prs[0]: (none)"); + expect(stdout).not.toContain("issues[0]: (none)"); + }); + + it("fetches issues with type=issues so PRs never appear in the issue block", async () => { + server = await startFixtureServer([ + { method: "GET", path: PULLS_PATH, body: [] }, + { method: "GET", path: ISSUES_PATH, body: [issueOf(3, "Login loops", "alexion")] }, + ]); + + const { exitCode } = await runCliTest([], { env: testModeEnv(server.url) }); + + expect(exitCode).toBe(0); + const issuesRequest = server.requests.find( + (request) => request.method === "GET" && request.path === ISSUES_PATH, + ); + expect(issuesRequest, "expected a GET to the issues path").toBeDefined(); + expect(issuesRequest!.query.type).toBe("issues"); + }); + + it("renders the --full PR table capped at 20 rows with count: 20 of T total", async () => { + // 45 open PRs exist (X-Total-Count), but the full tier returns and caps at 20. + const pulls = Array.from({ length: 20 }, (_, i) => { + const number = i + 1; + return { + id: 1000 + number, + number, + title: `PR ${number}`, + state: "open", + user: { id: 7, login: "alexion" }, + labels: [{ id: number, name: `label-${number}` }], + }; + }); + server = await startFixtureServer([ + { + method: "GET", + path: PULLS_PATH, + headers: { "X-Total-Count": "45" }, + body: pulls, + }, + { method: "GET", path: ISSUES_PATH, body: [] }, + reviewsRoute(1, [reviewOf("APPROVED")]), + ...Array.from({ length: 19 }, (_, i) => reviewsRoute(i + 2, [])), + ]); + + const { stdout, exitCode } = await runCliTest(["--full"], { env: testModeEnv(server.url) }); + + expect(exitCode).toBe(0); + + const lines = stdout.split("\n"); + const headerIndex = lines.indexOf("prs[20]{number,title,author,labels,review}:"); + expect(headerIndex, "expected the full-tier PR table header").toBeGreaterThanOrEqual(0); + + // Exactly 20 PR rows render, even though 45 open PRs exist. + const prRows = lines.filter((line) => /^ {2}\d+,/.test(line)); + expect(prRows).toHaveLength(20); + + // The standard count line sits above the PR block. + const countIndex = lines.indexOf("count: 20 of 45 total"); + expect(countIndex, "expected the count line").toBeGreaterThanOrEqual(0); + expect(countIndex).toBeLessThan(headerIndex); + + // PR 1 carries its joined label and its computed review reads approved. + const firstRow = lines[headerIndex + 1]!; + expect(firstRow.startsWith(" 1,")).toBe(true); + expect(firstRow).toContain("label-1"); + expect(firstRow.endsWith(",approved")).toBe(true); + }); + + it("groups --full issue counts by label, counting each issue under all its labels", async () => { + const labeledIssue = ( + number: number, + labels: { id: number; name: string }[], + ): Record => ({ + id: 2000 + number, + number, + title: `Issue ${number}`, + state: "open", + user: { id: 7, login: "alexion" }, + labels, + }); + server = await startFixtureServer([ + { method: "GET", path: PULLS_PATH, body: [] }, + { + method: "GET", + path: ISSUES_PATH, + body: [ + labeledIssue(1, [{ id: 10, name: "bug" }]), + labeledIssue(2, [ + { id: 10, name: "bug" }, + { id: 11, name: "feature" }, + ]), + labeledIssue(3, [{ id: 11, name: "feature" }]), + labeledIssue(4, []), + ], + }, + ]); + + const { stdout, exitCode } = await runCliTest(["--full"], { env: testModeEnv(server.url) }); + + expect(exitCode).toBe(0); + // Each issue is counted under all its labels; the lone unlabeled issue buckets alone. + expect(stdout).toContain("bug: 2"); + expect(stdout).toContain("feature: 2"); + expect(stdout).toContain("unlabeled: 1"); + // The issues block is a label->count record, not a table. + expect(stdout).toContain("issues:"); + expect(stdout).not.toContain("issues["); + }); + + it("suffixes --full label counts with + when aggregation hits the 1000-issue cap", async () => { + // A single full page of 50 bug-labelled issues, served for every page. The + // CLI keeps paging while pages stay full, stopping at the 20-page cap: + // 20 * 50 = 1000 aggregated issues, so `bug` reads a capped lower bound. + const fullPage = Array.from({ length: 50 }, (_, i) => ({ + id: 2000 + i, + number: i + 1, + title: `Issue ${i + 1}`, + state: "open", + user: { id: 7, login: "alexion" }, + labels: [{ id: 1, name: "bug" }], + })); + server = await startFixtureServer([ + { method: "GET", path: PULLS_PATH, body: [] }, + { method: "GET", path: ISSUES_PATH, body: fullPage }, + ]); + + const { stdout, exitCode } = await runCliTest(["--full"], { env: testModeEnv(server.url) }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("bug: 1000+"); + }); + + it("omits the unlabeled bucket from --full counts when every issue is labelled", async () => { + const labelledIssue = ( + number: number, + label: string, + ): Record => ({ + id: 2000 + number, + number, + title: `Issue ${number}`, + state: "open", + user: { id: 7, login: "alexion" }, + labels: [{ id: number, name: label }], + }); + server = await startFixtureServer([ + { method: "GET", path: PULLS_PATH, body: [] }, + { + method: "GET", + path: ISSUES_PATH, + body: [ + labelledIssue(1, "bug"), + labelledIssue(2, "feature"), + labelledIssue(3, "bug"), + ], + }, + ]); + + const { stdout, exitCode } = await runCliTest(["--full"], { env: testModeEnv(server.url) }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("bug:"); + expect(stdout).toContain("feature:"); + // No unlabeled issues, so no zero-count bucket is emitted. + expect(stdout).not.toContain("unlabeled"); + }); +}); diff --git a/test/detection.test.ts b/test/detection.test.ts index e11db4f..05018ff 100644 --- a/test/detection.test.ts +++ b/test/detection.test.ts @@ -159,6 +159,21 @@ describe("repository context detection", () => { expect(stdout).toContain("code: REPO_NOT_FOUND"); }); + it("shows REPO_NOT_FOUND with -R/--login help for the bare dashboard outside a Gitea repo", async () => { + const cwd = makeRepo(undefined); + const bin = makeSandbox({ logins: [] }); + + const { stdout, exitCode } = await runCliTest([], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: REPO_NOT_FOUND"); + expect(stdout).toContain("-R"); + expect(stdout).toContain("--login"); + }); + it("fails with TEA_NOT_INSTALLED when the tea binary is missing", async () => { const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git"); const bin = makeSandbox({ tea: false }); diff --git a/test/e2e/dashboard.test.ts b/test/e2e/dashboard.test.ts new file mode 100644 index 0000000..345ca8f --- /dev/null +++ b/test/e2e/dashboard.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { runCliTest } from "../harness.js"; +import { provisionInstance, seedBranch, type E2EInstance } from "./provision.js"; + +/** + * The end-to-end tier for the dashboard. The short and full tiers read the live + * issue-list and PR-list responses, then compute two fields the fixture server + * can only stub: the per-PR `review` (folded from a separate reviews fetch) and + * the full-tier label aggregation (issue counts grouped by label). Those live + * response shapes and derived fields are exactly what fixtures cannot attest to, + * so here both tiers run against a live, disposable Gitea instance. Unlike the + * search tier, the dashboard hits immediately-consistent list endpoints, so no + * indexer polling is needed — assertions run directly after provisioning. + */ +const E2E_URL = process.env.GITEA_AXI_E2E_URL; + +describe.skipIf(!E2E_URL)("end-to-end: dashboard", () => { + let instance: E2EInstance; + const branch = "e2e-dashboard-branch"; + + function env(overrides: Record = {}): Record { + return { + GITEA_AXI_API_URL: instance.baseUrl, + GITEA_AXI_TOKEN: instance.token, + GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`, + ...overrides, + }; + } + + beforeAll(async () => { + instance = await provisionInstance(E2E_URL!); + // A fresh repo has no pull requests; seed a branch with a diff and open one + // through the CLI dogfood path so the dashboard's PR block has a real row. + await seedBranch(instance, branch); + const created = await runCliTest( + [ + "pr", "create", + "--title", "E2E dashboard pull request", + "--head", branch, + "--base", "main", + ], + { env: env() }, + ); + expect(created.exitCode).toBe(0); + }, 150_000); + + it("renders the bare dashboard with live issue and PR list shapes and a computed review", async () => { + const { stdout, exitCode } = await runCliTest([], { env: env() }); + + expect(exitCode).toBe(0); + expect(stdout).toContain(`repo: ${instance.owner}/${instance.repo}`); + + // The issue block is the short-tier list shape, populated from the live + // issue-list response — one of the seeded open titles must appear. + expect(stdout).toMatch(/^issues\[\d+\]\{number,title,state,author\}:$/m); + expect(stdout).toContain(instance.openTitles[0]!); + + // The PR block carries the client-side `review` field. The seeded PR has no + // reviews yet, so it computes `required`; the union guards against the value + // legitimately being an approval/change-request in some run. + expect(stdout).toMatch(/^prs\[\d+\]\{number,title,author,review\}:$/m); + expect(stdout).toMatch(/,(approved|changes_requested|required)$/m); + }); + + it("renders the --full PR table and label-aggregation against live responses", async () => { + const { stdout, exitCode } = await runCliTest(["--full"], { env: env() }); + + expect(exitCode).toBe(0); + + // The full tier renders the open-PR table with a labels column and a count + // line, both derived from the live list response. + expect(stdout).toMatch(/^prs\[\d+\]\{number,title,author,labels,review\}:$/m); + expect(stdout).toMatch(/^count: \d+ of \d+ total$/m); + + // The full-tier issues block is a label->count record. The seeded open + // issues are unlabeled, so an `unlabeled` bucket must be present. + expect(stdout).toMatch(/^ {2}unlabeled: \d+$/m); + }); +}); -- 2.47.3