feat: add the two-tier dashboard (task 0017)
All checks were successful
CI / test (pull_request) Successful in 47s
CI / test (push) Successful in 50s

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.
This commit was merged in pull request #18.
This commit is contained in:
2026-07-14 11:01:07 -04:00
parent c2c5f1728c
commit 5b6a9b4917
7 changed files with 705 additions and 27 deletions

284
test/dashboard.test.ts Normal file
View File

@@ -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<string, unknown> {
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<string, unknown> {
return {
id: 2000 + number,
number,
title,
state: "open",
user: { id: 7, login: author },
};
}
function reviewOf(state: string): Record<string, unknown> {
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<string, unknown>[]): 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<string, unknown> => ({
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<string, unknown> => ({
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");
});
});

View File

@@ -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 });

View File

@@ -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<string, string> = {}): Record<string, string> {
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);
});
});