Compare commits
4 Commits
main
...
19018772d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 19018772d7 | |||
| 13883fa6e1 | |||
| 9a902d2d37 | |||
| 605c46f73a |
@@ -13,9 +13,25 @@ Field selection: `--fields <a,b,c>` exposing the extra fields `body` (raw), `clo
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `--label`, `--assignee`, `--author`, and `--milestone` map to their Gitea API query params and filter server-side
|
||||
- [ ] `--sort <created|updated|comments>` reorders descending client-side after full pagination; the count line still reports `T` from `X-Total-Count`
|
||||
- [ ] `--fields` selects among the documented extra fields, each rendered via its FieldDef extractor (relative times, joined label names, milestone title)
|
||||
- [ ] Output contains no `type` field
|
||||
- [ ] `--search` fails with `VALIDATION_ERROR` (exit 2) and a help line pointing at `gitea-axi search issues "<query>"`
|
||||
- [ ] Fixture-server tests cover each filter, client-side sort with pagination, `--fields` extraction, and the forbidden `--search`
|
||||
- [x] `--label`, `--assignee`, `--author`, and `--milestone` map to their Gitea API query params and filter server-side
|
||||
- [x] `--sort <created|updated|comments>` reorders descending client-side after full pagination; the count line still reports `T` from `X-Total-Count`
|
||||
- [x] `--fields` selects among the documented extra fields, each rendered via its FieldDef extractor (relative times, joined label names, milestone title)
|
||||
- [x] Output contains no `type` field
|
||||
- [x] `--search` fails with `VALIDATION_ERROR` (exit 2) and a help line pointing at `gitea-axi search issues "<query>"`
|
||||
- [x] Fixture-server tests cover each filter, client-side sort with pagination, `--fields` extraction, and the forbidden `--search`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Exhaustive pagination landed as a shared `src/paginate.ts` (`fetchAllPages`, `readTotalCount`), since ADR 0005 makes it a policy that later slices (`pr list`, the dashboard) reuse rather than a detail of this command.
|
||||
`lookup.ts`'s `listAllLabels` hand-rolled the same loop and now calls the shared helper, which removed its local `LABEL_PAGE_SIZE`/`LABEL_PAGE_LIMIT` constants.
|
||||
The helper carries the 20-page cap those constants encoded, matching the 1000-item ceiling Principle 8 sets on exhaustive pagination — without it a server that ignores paging would loop forever.
|
||||
|
||||
Two count-line details the acceptance criteria did not spell out.
|
||||
Under `--sort`, `--limit` caps the *sorted* result rather than the fetch, so pages are always read at the full page size of 50 and the top `N` by the sort key is what the limit selects.
|
||||
Also under `--sort`, when an instance omits `X-Total-Count`, the total falls back to the size of the fully paginated set instead of degrading to `count: N (showing first N)` — everything was fetched to sort it, so the total is known, and Principle 4 says a total is always reported.
|
||||
|
||||
`--sort` and `--state` shared an enum-parsing shape, now extracted as `parseEnumFlag` in `flags.ts`.
|
||||
|
||||
**Open question for the spec, deliberately not resolved here:** `--fields body` renders the body raw and untruncated, exactly as this task and the spec's command surface specify ("`body` (raw)"), matching the already-merged `issue create --fields body`.
|
||||
This contradicts Principle 3 ("Body text is truncated at **500 characters** in all contexts (list and detail alike)"): a 30-row list with `--fields body` can now emit 30 full bodies, which is the cost Principle 3 exists to prevent.
|
||||
Truncating here alone would make `issue list` disagree with `issue create`, so the conflict wants one ruling applied to both commands rather than a silent divergence in this slice.
|
||||
|
||||
@@ -7,7 +7,12 @@ Any commit message you write must follow the Conventional Commits specification
|
||||
## Gotchas
|
||||
|
||||
The `origin` remote is a self-hosted **Gitea** instance (`git.alexion.dev`), not GitHub.
|
||||
The `gh` CLI does not work here — open pull requests with `tea pr create --login axi --base main --head <branch>`, and list them with `tea pr`.
|
||||
The `gh` CLI does not work here.
|
||||
|
||||
Prefer this project's own CLI for pull requests — it is the tool being built, so opening its PRs with it is the dogfood path:
|
||||
`npm run build && node dist/main.js pr create --login axi --base main --head <branch> --title <text> --body-file <path>`.
|
||||
It reuses the `tea` login profiles, so it needs no separate credentials.
|
||||
Fall back to `tea pr create --login axi --base main --head <branch>` only for what gitea-axi cannot do yet; `tea pr` still lists PRs until `pr list` lands (task 0008).
|
||||
|
||||
Task branches are merged into `main` on the remote, so the local `main` goes stale.
|
||||
Always `git fetch origin` and cut a task branch from `origin/main`, not from whatever local `main` happens to point at.
|
||||
|
||||
@@ -15,8 +15,15 @@ import {
|
||||
selectExtraFields,
|
||||
type FieldDef,
|
||||
} from "../fields.js";
|
||||
import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js";
|
||||
import {
|
||||
flagValue,
|
||||
parseEnumFlag,
|
||||
parseFlags,
|
||||
parsePositionalNumber,
|
||||
splitFlag,
|
||||
} from "../flags.js";
|
||||
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
||||
import { fetchAllPages, readTotalCount } from "../paginate.js";
|
||||
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
|
||||
import { relativeTime } from "../time.js";
|
||||
import { suggestCommand } from "../suggestions.js";
|
||||
@@ -87,7 +94,13 @@ List issues in the current repository. Pull requests are never included.
|
||||
|
||||
flags:
|
||||
--state <open|closed|all> Filter by state (default: open)
|
||||
--label <a,b> Filter by label name (comma-separated)
|
||||
--assignee <login> Filter by assignee
|
||||
--author <login> Filter by author
|
||||
--milestone <name> Filter by milestone name
|
||||
--sort <created|updated|comments> Sort descending (client-side)
|
||||
--limit <n> Maximum number of issues to return (default: 30)
|
||||
--fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url
|
||||
--help Show this help
|
||||
|
||||
global flags:
|
||||
@@ -103,27 +116,97 @@ const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
|
||||
relativeTimeField("created", "created_at"),
|
||||
];
|
||||
|
||||
// Appended to the defaults on request via `--fields`, never replacing them.
|
||||
const ISSUE_LIST_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = {
|
||||
body: pluck("body"),
|
||||
closedAt: relativeTimeField("closedAt", "closed_at"),
|
||||
labels: joined("labels", "labels", "name"),
|
||||
milestone: pluck("milestone", "milestone.title"),
|
||||
updatedAt: relativeTimeField("updatedAt", "updated_at"),
|
||||
url: pluck("url", "html_url"),
|
||||
};
|
||||
|
||||
const ISSUE_STATES = ["open", "closed", "all"] as const;
|
||||
type IssueState = (typeof ISSUE_STATES)[number];
|
||||
|
||||
const ISSUE_SORTS = ["created", "updated", "comments"] as const;
|
||||
type IssueSort = (typeof ISSUE_SORTS)[number];
|
||||
|
||||
/** Sort keys, read descending. A missing or unparseable value sorts last. */
|
||||
const ISSUE_SORT_KEYS: Record<IssueSort, (issue: Issue) => number> = {
|
||||
created: (issue) => timestamp(issue.created_at),
|
||||
updated: (issue) => timestamp(issue.updated_at),
|
||||
comments: (issue) => issue.comments ?? 0,
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
|
||||
const ISSUE_LIST_HELP_SUGGESTION = [
|
||||
"Run `gitea-axi issue list --help` to see available flags",
|
||||
];
|
||||
|
||||
function timestamp(iso: string | undefined): number {
|
||||
const value = Date.parse(iso ?? "");
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
function parseState(value: string | true | undefined): IssueState {
|
||||
if (value === undefined) {
|
||||
return "open";
|
||||
return parseEnumFlag(value, "--state", ISSUE_STATES, ISSUE_LIST_HELP_SUGGESTION) ?? "open";
|
||||
}
|
||||
|
||||
function parseSort(value: string | true | undefined): IssueSort | undefined {
|
||||
return parseEnumFlag(value, "--sort", ISSUE_SORTS, ISSUE_LIST_HELP_SUGGESTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea's issue list has no sort parameter, so ordering happens here, over the
|
||||
* fully paginated set (see ADR 0005). `sort` is stable, so equal keys keep the
|
||||
* order the API returned them in.
|
||||
*/
|
||||
function sortIssues(issues: Issue[], sort: IssueSort): Issue[] {
|
||||
const key = ISSUE_SORT_KEYS[sort];
|
||||
return [...issues].sort((a, b) => key(b) - key(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters Gitea's issue list accepts as query params, under its own names.
|
||||
* All four filter server-side; none of them needs the client-side policy.
|
||||
*/
|
||||
function issueListFilters(flags: Record<string, string | true>): Record<string, string> {
|
||||
const filters: Record<string, string> = {};
|
||||
const label = flagValue(flags, "--label");
|
||||
if (label !== undefined) {
|
||||
filters.labels = label;
|
||||
}
|
||||
if (value === true || !ISSUE_STATES.includes(value as IssueState)) {
|
||||
throw axiError(
|
||||
`Invalid --state value: ${String(value)} (expected open, closed, or all)`,
|
||||
"VALIDATION_ERROR",
|
||||
ISSUE_LIST_HELP_SUGGESTION,
|
||||
);
|
||||
const assignee = flagValue(flags, "--assignee");
|
||||
if (assignee !== undefined) {
|
||||
filters.assigned_by = assignee;
|
||||
}
|
||||
return value as IssueState;
|
||||
const author = flagValue(flags, "--author");
|
||||
if (author !== undefined) {
|
||||
filters.created_by = author;
|
||||
}
|
||||
const milestone = flagValue(flags, "--milestone");
|
||||
if (milestone !== undefined) {
|
||||
filters.milestones = milestone;
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* `--search` is refused rather than quietly forwarded to the API's `q` param:
|
||||
* full-text search is `search issues`, and a flag that half-worked here would be
|
||||
* the wrong thing to learn. Checked ahead of `parseFlags` so every form of the
|
||||
* flag — valued, inline, bare — lands on the redirect instead of a generic
|
||||
* unknown-flag or missing-value error.
|
||||
*/
|
||||
function refuseSearchFlag(args: string[]): void {
|
||||
if (!args.some((arg) => splitFlag(arg).name === "--search")) {
|
||||
return;
|
||||
}
|
||||
throw axiError("issue list does not support --search", "VALIDATION_ERROR", [
|
||||
'Use `gitea-axi search issues "<query>"` for full-text search',
|
||||
]);
|
||||
}
|
||||
|
||||
function parseLimit(value: string | true | undefined): number {
|
||||
@@ -168,9 +251,19 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
|
||||
if (args.includes("--help")) {
|
||||
return ISSUE_LIST_HELP;
|
||||
}
|
||||
refuseSearchFlag(args);
|
||||
const { flags, positionals } = parseFlags(
|
||||
args,
|
||||
{ "--state": { takesValue: true }, "--limit": { takesValue: true } },
|
||||
{
|
||||
"--state": { takesValue: true },
|
||||
"--label": { takesValue: true },
|
||||
"--assignee": { takesValue: true },
|
||||
"--author": { takesValue: true },
|
||||
"--milestone": { takesValue: true },
|
||||
"--sort": { takesValue: true },
|
||||
"--limit": { takesValue: true },
|
||||
"--fields": { takesValue: true },
|
||||
},
|
||||
"issue list",
|
||||
);
|
||||
if (positionals.length > 0) {
|
||||
@@ -181,33 +274,58 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
|
||||
);
|
||||
}
|
||||
const state = parseState(flags["--state"]);
|
||||
const sort = parseSort(flags["--sort"]);
|
||||
const limit = parseLimit(flags["--limit"]);
|
||||
const extraFields = selectExtraFields(
|
||||
flagValue(flags, "--fields"),
|
||||
ISSUE_LIST_EXTRA_FIELDS,
|
||||
"issue list",
|
||||
);
|
||||
const query = { state, type: "issues" as const, ...issueListFilters(flags) };
|
||||
|
||||
const context = await resolveRepoContext(deps);
|
||||
const api = createClient(context);
|
||||
let response;
|
||||
let issues: Issue[];
|
||||
let total: number | undefined;
|
||||
try {
|
||||
response = await api.repos.issueListIssues(context.owner, context.name, {
|
||||
state,
|
||||
type: "issues",
|
||||
if (sort === undefined) {
|
||||
const response = await api.repos.issueListIssues(context.owner, context.name, {
|
||||
...query,
|
||||
limit,
|
||||
page: 1,
|
||||
});
|
||||
issues = response.data ?? [];
|
||||
total = readTotalCount(response.headers);
|
||||
} else {
|
||||
// Sorting client-side means holding the whole set first: the top `limit`
|
||||
// by the sort key is only knowable once every page is in (see ADR 0005).
|
||||
const result = await fetchAllPages<Issue>((page, pageLimit) =>
|
||||
api.repos.issueListIssues(context.owner, context.name, {
|
||||
...query,
|
||||
page,
|
||||
limit: pageLimit,
|
||||
}),
|
||||
);
|
||||
issues = sortIssues(result.items, sort).slice(0, limit);
|
||||
// Sorting reorders without changing membership, so the API's own total
|
||||
// still describes this result set and the count line keeps reporting it.
|
||||
// Having paginated everything, the set's own size is the fallback when an
|
||||
// instance omits the header — a total is always reported (Principle 4).
|
||||
total = result.total ?? result.items.length;
|
||||
}
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
const issues = response.data ?? [];
|
||||
const totalHeader = response.headers.get("x-total-count");
|
||||
const total = totalHeader !== null ? Number(totalHeader) : undefined;
|
||||
const resolvedTotal = total !== undefined && Number.isFinite(total) ? total : undefined;
|
||||
|
||||
const now = new Date();
|
||||
const rows = issues.map((issue) => extractRow(issue, ISSUE_LIST_FIELDS, { now }));
|
||||
const rows = issues.map((issue) =>
|
||||
extractRow(issue, [...ISSUE_LIST_FIELDS, ...extraFields], { now }),
|
||||
);
|
||||
return renderList({
|
||||
noun: "issues",
|
||||
rows,
|
||||
countLine: formatCountLine(rows.length, resolvedTotal, rows.length >= limit),
|
||||
help: issueListSuggestions(context, state, rows.length, resolvedTotal),
|
||||
countLine: formatCountLine(rows.length, total, rows.length >= limit),
|
||||
help: issueListSuggestions(context, state, rows.length, total),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
33
src/flags.ts
33
src/flags.ts
@@ -38,6 +38,39 @@ export function flagValue(
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** ["open", "closed", "all"] → "open, closed, or all". */
|
||||
function orList(values: readonly string[]): string {
|
||||
if (values.length < 2) {
|
||||
return values[0] ?? "";
|
||||
}
|
||||
return `${values.slice(0, -1).join(", ")}, or ${values[values.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a flag whose value must be one of a fixed set. Returns undefined when the
|
||||
* flag was absent, leaving the default to the caller — a flag with no default
|
||||
* (`--sort`) and one with a default (`--state`) then differ only in what they do
|
||||
* with that undefined.
|
||||
*/
|
||||
export function parseEnumFlag<T extends string>(
|
||||
value: string | true | undefined,
|
||||
name: string,
|
||||
allowed: readonly T[],
|
||||
suggestions: string[],
|
||||
): T | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === true || !allowed.includes(value as T)) {
|
||||
throw axiError(
|
||||
`Invalid ${name} value: ${String(value)} (expected ${orList(allowed)})`,
|
||||
"VALIDATION_ERROR",
|
||||
suggestions,
|
||||
);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/** Split "--flag=value" into name and inline value; "--flag" has none. */
|
||||
export function splitFlag(arg: string): SplitFlag {
|
||||
const equals = arg.indexOf("=");
|
||||
|
||||
@@ -2,36 +2,23 @@ import type { Label } from "gitea-js";
|
||||
import type { GiteaClient } from "./client.js";
|
||||
import type { RepoContext } from "./context.js";
|
||||
import { axiError, classifyHttpError } from "./errors.js";
|
||||
import { fetchAllPages } from "./paginate.js";
|
||||
|
||||
/**
|
||||
* Name→ID resolution for the Gitea endpoints that only accept numeric ids.
|
||||
* Shared by every command that takes a `--label` or `--milestone` name.
|
||||
*/
|
||||
|
||||
const LABEL_PAGE_SIZE = 50;
|
||||
/** Guard against an unbounded loop if a server ignores paging and always returns a full page. */
|
||||
const LABEL_PAGE_LIMIT = 20;
|
||||
|
||||
/** Fetch every label in the repository, paging until the API runs out. */
|
||||
async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
|
||||
const labels: Label[] = [];
|
||||
for (let page = 1; page <= LABEL_PAGE_LIMIT; page++) {
|
||||
let batch: Label[];
|
||||
try {
|
||||
const response = await api.repos.issueListLabels(context.owner, context.name, {
|
||||
page,
|
||||
limit: LABEL_PAGE_SIZE,
|
||||
});
|
||||
batch = response.data ?? [];
|
||||
const { items } = await fetchAllPages<Label>((page, limit) =>
|
||||
api.repos.issueListLabels(context.owner, context.name, { page, limit }),
|
||||
);
|
||||
return items;
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
labels.push(...batch);
|
||||
if (batch.length < LABEL_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
56
src/paginate.ts
Normal file
56
src/paginate.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Guard against an unbounded loop if a server ignores paging and always returns
|
||||
* a full page. 20 pages of 50 is the 1000-item cap the spec sets on exhaustive
|
||||
* pagination (Principle 8).
|
||||
*/
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
interface PageResponse<T> {
|
||||
data?: T[];
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
/** `X-Total-Count`, absent when the header is missing or not a number. */
|
||||
total: number | undefined;
|
||||
}
|
||||
|
||||
export function readTotalCount(headers: Headers): number | undefined {
|
||||
const raw = headers.get("x-total-count");
|
||||
if (raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const total = Number(raw);
|
||||
return Number.isFinite(total) ? total : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every page, stopping at the first short one or at the page cap. Needed by
|
||||
* the client-side policies (see ADR 0005): a command sorting or filtering
|
||||
* in-process cannot do either correctly until it holds the whole set.
|
||||
*
|
||||
* The total comes from the first page and describes the set the API returned, so
|
||||
* it stays accurate under sorting (which only reorders) but not under
|
||||
* client-side filtering, whose caller counts the filtered set itself.
|
||||
*/
|
||||
export async function fetchAllPages<T>(
|
||||
fetchPage: (page: number, limit: number) => Promise<PageResponse<T>>,
|
||||
): Promise<PaginatedResult<T>> {
|
||||
const items: T[] = [];
|
||||
let total: number | undefined;
|
||||
for (let page = 1; page <= PAGE_LIMIT; page++) {
|
||||
const response = await fetchPage(page, PAGE_SIZE);
|
||||
if (page === 1) {
|
||||
total = readTotalCount(response.headers);
|
||||
}
|
||||
const batch = response.data ?? [];
|
||||
items.push(...batch);
|
||||
if (batch.length < PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { items, total };
|
||||
}
|
||||
34
test/fixtures/issues-fields.json
vendored
Normal file
34
test/fixtures/issues-fields.json
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"id": 310,
|
||||
"number": 50,
|
||||
"title": "Ship the field extractors",
|
||||
"body": "Raw body text, kept whole by the body field.",
|
||||
"state": "closed",
|
||||
"is_locked": false,
|
||||
"comments": 1,
|
||||
"created_at": "2026-06-01T10:00:00Z",
|
||||
"updated_at": "2026-07-05T10:00:00Z",
|
||||
"closed_at": "2026-07-06T10:00:00Z",
|
||||
"html_url": "http://gitea.example/testowner/testrepo/issues/50",
|
||||
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/50",
|
||||
"user": {
|
||||
"id": 9,
|
||||
"login": "contributor",
|
||||
"full_name": "A Contributor",
|
||||
"email": "contributor@example.com"
|
||||
},
|
||||
"labels": [
|
||||
{ "id": 1, "name": "bug", "color": "ee0701" },
|
||||
{ "id": 3, "name": "priority: high", "color": "b60205" }
|
||||
],
|
||||
"milestone": {
|
||||
"id": 4,
|
||||
"title": "v1.0",
|
||||
"state": "open"
|
||||
},
|
||||
"assignee": null,
|
||||
"assignees": null,
|
||||
"pull_request": null
|
||||
}
|
||||
]
|
||||
@@ -10,6 +10,41 @@ afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* A synthetic issue, for the sort and pagination cases where what matters is the
|
||||
* ordering keys rather than realistic content. `number` doubles as the identity
|
||||
* asserted on in the rendered output.
|
||||
*/
|
||||
function issueOf(
|
||||
number: number,
|
||||
keys: { created?: string; updated?: string; comments?: number } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: 1000 + number,
|
||||
number,
|
||||
title: `Issue ${number}`,
|
||||
body: "",
|
||||
state: "open",
|
||||
comments: keys.comments ?? 0,
|
||||
created_at: keys.created ?? "2026-01-01T00:00:00Z",
|
||||
updated_at: keys.updated ?? "2026-01-01T00:00:00Z",
|
||||
html_url: `http://gitea.example/testowner/testrepo/issues/${number}`,
|
||||
user: { id: 7, login: "alexion" },
|
||||
labels: [],
|
||||
milestone: null,
|
||||
assignees: null,
|
||||
pull_request: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The `number` column of every rendered row, in output order. */
|
||||
function renderedNumbers(stdout: string): number[] {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter((line) => /^ {2}\d+,/.test(line))
|
||||
.map((line) => Number(line.trim().split(",")[0]));
|
||||
}
|
||||
|
||||
describe("issue list", () => {
|
||||
it("lists open issues with default fields and a count line", async () => {
|
||||
server = await startFixtureServer([
|
||||
@@ -164,4 +199,319 @@ describe("issue list", () => {
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("emits no type field, since Gitea has no issue types", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, fixture: "issues-open.json" },
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(stdout).toContain("issues[3]{number,title,state,author,created}:");
|
||||
expect(stdout).not.toContain("type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue list filters", () => {
|
||||
it("maps --label to the labels query param, passing names through", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--label", "bug,documentation"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.labels).toBe("bug,documentation");
|
||||
});
|
||||
|
||||
it("maps --assignee to assigned_by", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--assignee", "alexion"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.assigned_by).toBe("alexion");
|
||||
});
|
||||
|
||||
it("maps --author to created_by", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--author", "contributor"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.created_by).toBe("contributor");
|
||||
});
|
||||
|
||||
it("maps --milestone to milestones", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--milestone", "v1.0"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.milestones).toBe("v1.0");
|
||||
});
|
||||
|
||||
it("filters server-side: every filter travels in one request alongside type=issues", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: {
|
||||
labels: "bug",
|
||||
assigned_by: "alexion",
|
||||
created_by: "contributor",
|
||||
milestones: "v1.0",
|
||||
type: "issues",
|
||||
},
|
||||
headers: { "X-Total-Count": "1" },
|
||||
body: [],
|
||||
},
|
||||
]);
|
||||
const { exitCode } = await runCliTest(
|
||||
[
|
||||
"issue", "list",
|
||||
"--label", "bug",
|
||||
"--assignee", "alexion",
|
||||
"--author", "contributor",
|
||||
"--milestone", "v1.0",
|
||||
],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue list --sort", () => {
|
||||
it("reorders by updated descending, client-side", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "--sort", "updated"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// Fixture order is 42, 41, 38; by updated_at it is 42 (Jul 8), 38 (Jul 1), 41 (Jun 20).
|
||||
expect(renderedNumbers(stdout)).toEqual([42, 38, 41]);
|
||||
});
|
||||
|
||||
it("reorders by comments descending, client-side", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list", "--sort", "comments"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
// Comment counts: 42 has 2, 41 has 0, 38 has 5.
|
||||
expect(renderedNumbers(stdout)).toEqual([38, 42, 41]);
|
||||
});
|
||||
|
||||
it("reorders by created descending, client-side", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list", "--sort", "created"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([42, 41, 38]);
|
||||
});
|
||||
|
||||
it("paginates fully before sorting, so a later page can outrank the first", async () => {
|
||||
// Page 1 is a full page of stale issues; the freshest issue of all sits on
|
||||
// page 2, so it can only lead the output if pagination completed first.
|
||||
const page1 = Array.from({ length: 50 }, (_, i) =>
|
||||
issueOf(100 + i, { updated: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
const page2 = [
|
||||
issueOf(7, { updated: "2026-07-09T00:00:00Z" }),
|
||||
issueOf(8, { updated: "2026-03-01T00:00:00Z" }),
|
||||
];
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: { page: "1", limit: "50" },
|
||||
headers: { "X-Total-Count": "52" },
|
||||
body: page1,
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: { page: "2", limit: "50" },
|
||||
headers: { "X-Total-Count": "52" },
|
||||
body: page2,
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "--sort", "updated"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests).toHaveLength(2);
|
||||
expect(renderedNumbers(stdout)[0]).toBe(7);
|
||||
// The count line keeps T from X-Total-Count: sorting reorders without
|
||||
// changing membership, so the unfiltered total stays accurate (ADR 0005).
|
||||
expect(stdout).toContain("count: 30 of 52 total");
|
||||
});
|
||||
|
||||
it("applies --limit to the sorted order, not to the fetched pages", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "17" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout } = await runCliTest(
|
||||
["issue", "list", "--sort", "comments", "--limit", "2"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([38, 42]);
|
||||
expect(stdout).toContain("count: 2 of 17 total");
|
||||
// Pagination reads full pages regardless of --limit; the cap is applied after sorting.
|
||||
expect(server.requests[0]!.query.limit).toBe("50");
|
||||
});
|
||||
|
||||
it("reports the paginated set's own size when the instance omits X-Total-Count", async () => {
|
||||
// Everything was fetched to sort it, so the total is known even with no
|
||||
// header — the bare `count: N` form must never appear (Principle 4).
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, fixture: "issues-open.json" },
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list", "--sort", "updated", "--limit", "2"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(stdout).toContain("count: 2 of 3 total");
|
||||
});
|
||||
|
||||
it("stops at the page cap when a server keeps returning full pages", async () => {
|
||||
// A server that ignores paging would otherwise loop forever.
|
||||
const fullPage = Array.from({ length: 50 }, (_, i) => issueOf(100 + i));
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, headers: { "X-Total-Count": "9999" }, body: fullPage },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["issue", "list", "--sort", "created"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("rejects an invalid --sort value with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "--sort", "banana"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue list --fields", () => {
|
||||
it("appends the selected extra fields, each via its extractor", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "1" },
|
||||
fixture: "issues-fields.json",
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
[
|
||||
"issue", "list",
|
||||
"--state", "closed",
|
||||
"--fields", "body,closedAt,labels,milestone,updatedAt,url",
|
||||
],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(
|
||||
"issues[1]{number,title,state,author,created,body,closedAt,labels,milestone,updatedAt,url}:",
|
||||
);
|
||||
const row = stdout.split("\n").find((line) => line.startsWith(" 50,"))!;
|
||||
expect(row).toContain("Raw body text, kept whole by the body field.");
|
||||
expect(row).toContain('"bug, priority: high"');
|
||||
expect(row).toContain("v1.0");
|
||||
expect(row).toContain("http://gitea.example/testowner/testrepo/issues/50");
|
||||
// closedAt and updatedAt render as relative times, like `created`.
|
||||
expect(row).toMatch(/\d+(mo|[smhdy]) ago/);
|
||||
});
|
||||
|
||||
it("rejects an unknown --fields name with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--fields", "bogus"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("bogus");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue list --search", () => {
|
||||
it("forbids --search, redirecting to search issues", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--search", "login bug"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
// TOON escapes the quotes around <query> inside the help string.
|
||||
expect(stdout).toContain("gitea-axi search issues");
|
||||
expect(stdout).toContain("<query>");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forbids --search in its inline and bare forms too", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const inline = await runCliTest(["issue", "list", "--search=login"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
const bare = await runCliTest(["issue", "list", "--search"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
for (const result of [inline, bare]) {
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stdout).toContain("gitea-axi search issues");
|
||||
}
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user