feat: add the two-tier dashboard (task 0017) #18
@@ -13,11 +13,31 @@ Issue fetching passes `type=issues`; outside a recognizable Gitea repo the dashb
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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`
|
- [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`
|
||||||
- [ ] `gitea-axi --full` renders the PR table capped at 20 rows with `count: 20 of T total` and issue counts grouped by label
|
- [x] `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
|
- [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
|
||||||
- [ ] Empty states render `prs: 0 open` / `issues: 0 open` as raw strings
|
- [x] 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
|
- [x] 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`
|
- [x] 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
|
- [x] 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] 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).
|
||||||
|
|||||||
30
src/cli.ts
30
src/cli.ts
@@ -1,19 +1,22 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
|
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
|
||||||
|
import { dashboardCommand } from "./commands/dashboard.js";
|
||||||
import { issueCommand } from "./commands/issue.js";
|
import { issueCommand } from "./commands/issue.js";
|
||||||
import { labelCommand } from "./commands/label.js";
|
import { labelCommand } from "./commands/label.js";
|
||||||
import { prCommand } from "./commands/pr.js";
|
import { prCommand } from "./commands/pr.js";
|
||||||
import { searchCommand } from "./commands/search.js";
|
import { searchCommand } from "./commands/search.js";
|
||||||
import { resolveRepoContext } from "./context.js";
|
|
||||||
import type { CliDeps, GlobalFlags } from "./deps.js";
|
import type { CliDeps, GlobalFlags } from "./deps.js";
|
||||||
import { consumeFlagValue, splitFlag } from "./flags.js";
|
import { consumeFlagValue, splitFlag } from "./flags.js";
|
||||||
import { renderErrorOutput } from "./render.js";
|
import { renderErrorOutput } from "./render.js";
|
||||||
import { suggestCommand } from "./suggestions.js";
|
|
||||||
|
|
||||||
const DESCRIPTION = "Agent-ergonomic CLI for Gitea issues and pull requests";
|
const DESCRIPTION = "Agent-ergonomic CLI for Gitea issues and pull requests";
|
||||||
|
|
||||||
const TOP_LEVEL_HELP = `usage: gitea-axi <command> [flags]
|
const TOP_LEVEL_HELP = `usage: gitea-axi <command> [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:
|
commands:
|
||||||
issue list List issues in the current repository
|
issue list List issues in the current repository
|
||||||
issue view Show a single issue's details
|
issue view Show a single issue's details
|
||||||
@@ -77,19 +80,6 @@ function readVersion(): string {
|
|||||||
return packageJson.version;
|
return packageJson.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
function homeCommand(deps: CliDeps) {
|
|
||||||
return async (): Promise<Record<string, unknown>> => {
|
|
||||||
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 {
|
export interface RunCliOptions {
|
||||||
argv: string[];
|
argv: string[];
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
@@ -113,10 +103,16 @@ export async function runCli(options: RunCliOptions): Promise<number> {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
globals: extracted.globals,
|
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({
|
await runAxiCli({
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
version: readVersion(),
|
version: readVersion(),
|
||||||
argv: extracted.argv,
|
argv,
|
||||||
topLevelHelp: TOP_LEVEL_HELP,
|
topLevelHelp: TOP_LEVEL_HELP,
|
||||||
commands: {
|
commands: {
|
||||||
issue: issueCommand(deps),
|
issue: issueCommand(deps),
|
||||||
@@ -124,7 +120,7 @@ export async function runCli(options: RunCliOptions): Promise<number> {
|
|||||||
label: labelCommand(deps),
|
label: labelCommand(deps),
|
||||||
search: searchCommand(deps),
|
search: searchCommand(deps),
|
||||||
},
|
},
|
||||||
home: homeCommand(deps),
|
home: dashboardCommand(deps, full),
|
||||||
stdout: options.stdout,
|
stdout: options.stdout,
|
||||||
});
|
});
|
||||||
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
||||||
|
|||||||
276
src/commands/dashboard.ts
Normal file
276
src/commands/dashboard.ts
Normal file
@@ -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<PullRequest>[] = [
|
||||||
|
pluck("number"),
|
||||||
|
pluck("title"),
|
||||||
|
pluck("author", "user.login"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHORT_ISSUE_FIELDS: FieldDef<Issue>[] = [
|
||||||
|
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<PullRequest>[] = [
|
||||||
|
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<OpenPulls> {
|
||||||
|
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<PaginatedResult<Issue>> {
|
||||||
|
try {
|
||||||
|
return await fetchAllPages<Issue>((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<Issue[]> {
|
||||||
|
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<ReviewDecision[]> {
|
||||||
|
return Promise.all(
|
||||||
|
pulls.map((pull) =>
|
||||||
|
pull.number === undefined
|
||||||
|
? Promise.resolve<ReviewDecision>("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<PullRequest>[],
|
||||||
|
now: Date,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
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 `<noun>` list block, or the raw `emptyLine` when there are no rows. */
|
||||||
|
function listBlock(noun: string, rows: Record<string, unknown>[], 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<string> {
|
||||||
|
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<string, string | number> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
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<string, string | number> = {};
|
||||||
|
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<string> {
|
||||||
|
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<string> => {
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
return full ? fullDashboard(api, context) : shortDashboard(api, context);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ export interface PaginatedResult<T> {
|
|||||||
items: T[];
|
items: T[];
|
||||||
/** `X-Total-Count`, absent when the header is missing or not a number. */
|
/** `X-Total-Count`, absent when the header is missing or not a number. */
|
||||||
total: number | undefined;
|
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 {
|
export function readTotalCount(headers: Headers): number | undefined {
|
||||||
@@ -48,9 +54,11 @@ export async function fetchAllPages<T>(
|
|||||||
}
|
}
|
||||||
const batch = response.data ?? [];
|
const batch = response.data ?? [];
|
||||||
items.push(...batch);
|
items.push(...batch);
|
||||||
|
// A short page is the last page: the set is complete.
|
||||||
if (batch.length < PAGE_SIZE) {
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
284
test/dashboard.test.ts
Normal file
284
test/dashboard.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -159,6 +159,21 @@ describe("repository context detection", () => {
|
|||||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
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 () => {
|
it("fails with TEA_NOT_INSTALLED when the tea binary is missing", async () => {
|
||||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||||
const bin = makeSandbox({ tea: false });
|
const bin = makeSandbox({ tea: false });
|
||||||
|
|||||||
79
test/e2e/dashboard.test.ts
Normal file
79
test/e2e/dashboard.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user