Compare commits
2 Commits
d034fce3ea
...
03937a8f6e
| Author | SHA1 | Date | |
|---|---|---|---|
| 03937a8f6e | |||
| 0bf914cbdd |
@@ -13,10 +13,40 @@ The label command group: `label list`, `label create`, `label edit`, `label dele
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `label list` renders the count line and `labels:` block; empty repos get the explicit empty state
|
- [x] `label list` renders the count line and `labels:` block; empty repos get the explicit empty state
|
||||||
- [ ] `label create --name --color` creates the label, prepending `#` to the color, and outputs `created: ok` + `label: <name>`
|
- [x] `label create --name --color` creates the label, prepending `#` to the color, and outputs `created: ok` + `label: <name>`
|
||||||
- [ ] Creating an existing label (case-insensitive) outputs `create: already_exists` + the existing name, exit 0
|
- [x] Creating an existing label (case-insensitive) outputs `create: already_exists` + the existing name, exit 0
|
||||||
- [ ] `label edit <name>` applies `--name`/`--color`/`--description` and outputs `edit: ok` + the resulting name
|
- [x] `label edit <name>` applies `--name`/`--color`/`--description` and outputs `edit: ok` + the resulting name
|
||||||
- [ ] `label edit`/`label delete` on an unknown name yield `VALIDATION_ERROR` (exit 2)
|
- [x] `label edit`/`label delete` on an unknown name yield `VALIDATION_ERROR` (exit 2)
|
||||||
- [ ] `label delete <name>` outputs `delete: ok` + `label: <name>`
|
- [x] `label delete <name>` outputs `delete: ok` + `label: <name>`
|
||||||
- [ ] Fixture-server tests cover create, idempotent re-create, edit, delete, and the unknown-name refusals
|
- [x] Fixture-server tests cover create, idempotent re-create, edit, delete, and the unknown-name refusals
|
||||||
|
- [x] End-to-end tests exercise the live-Gitea semantics the fixture server cannot attest to: the `#`-prefixed color round-trip on create, idempotency against the live listing, and name→id resolution behind edit/delete (plus the not-idempotent delete refusal)
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Built the `label` group in `src/commands/label.ts`, wired into `src/cli.ts` (dispatcher plus the two most-common entries in the top-level help), following the sibling `issue`/`pr` command patterns.
|
||||||
|
|
||||||
|
Deviations and decisions:
|
||||||
|
|
||||||
|
- **`label edit` requires at least one change.**
|
||||||
|
The spec/gh-axi reference leaves all edit flags optional, but sending an empty `PATCH` is a pointless call, so an edit with none of `--name`/`--color`/`--description` is refused with `VALIDATION_ERROR` — mirroring `issue edit`'s "requires at least one change" guard for consistency within gitea-axi.
|
||||||
|
- **Resulting name for `edit`/`create`/`delete` comes from the API response** (`edited.name`, `label.name`), not the input, so the reported name is the server's canonical echo (correct casing, and the unchanged original when `--name` was omitted).
|
||||||
|
- **`create` vs `created` output keys.**
|
||||||
|
The success key is `created: ok` and the idempotent-hit key is `create: already_exists` — two different top-level keys.
|
||||||
|
This is spec-mandated (spec lines 342–343) rather than the `already: true` shape the dependency no-ops use; kept verbatim to match the fixed contract.
|
||||||
|
- **Flat `renderObject` output shape.**
|
||||||
|
Added `renderObject(item, help)` to `src/render.ts` because the label create/edit/delete outputs are flat top-level fields (`created: ok` / `label: <name>`), which the sibling `renderDetail` (nests under a `noun:` block) cannot produce. This is the spec's output shape, not a new convention chosen freely.
|
||||||
|
- **Shared lookup + positional helpers (cleanups from review).**
|
||||||
|
Extracted `findLabel`/`resolveLabel` and a shared `labelNotFound` message into `src/lookup.ts`, reused by the existing `resolveLabelIds`; and extracted `parseSinglePositional` in `src/flags.ts`, now shared by `parsePositionalNumber` and the label name positionals, removing the duplicated count-check/error scaffolding.
|
||||||
|
- **`label list` uses a single-page fetch with `--limit` (default 500)**, reading `X-Total-Count` for the count line, rather than the exhaustive pagination `resolveLabel`/`resolveLabelIds` use; the spec asks only for `--limit`, and a repo with >500 labels is signalled by the `count: N of T total` line.
|
||||||
|
|
||||||
|
### Follow-ups added after review
|
||||||
|
|
||||||
|
- **End-to-end tier extended.**
|
||||||
|
The task originally scoped its tests to the fixture-server tier only, matching the precedent of the preceding PR-command tasks (0011–0014), none of which added e2e cases.
|
||||||
|
On reflection the label commands sit squarely inside the e2e tier's charter — "behavior the fixture server cannot attest to" — because a fixture server never enforces that Gitea's `CreateLabelOption.color` requires the leading `#`, nor that edit/delete really key on the numeric label id.
|
||||||
|
Added a `test/e2e/mutations.test.ts` label-lifecycle case (create → idempotent re-create → edit → delete, verified against live state via a new `fetchLabels` provisioner helper) plus the not-idempotent delete refusal.
|
||||||
|
These run only in CI (gated on `GITEA_AXI_E2E_URL`).
|
||||||
|
- **Unit-tier coverage backfill.**
|
||||||
|
The initial fixture tests covered only the happy paths and unknown-name refusals, leaving the help output, validation errors, and API-error propagation untested — enough to drop the repo below its global branch-coverage gate.
|
||||||
|
Added confirming fixture tests for those behaviors, bringing `src/commands/label.ts` to ~96% line / ~86% branch and the suite back over its thresholds.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { issueCommand } from "./commands/issue.js";
|
import { issueCommand } from "./commands/issue.js";
|
||||||
|
import { labelCommand } from "./commands/label.js";
|
||||||
import { prCommand } from "./commands/pr.js";
|
import { prCommand } from "./commands/pr.js";
|
||||||
import { resolveRepoContext } from "./context.js";
|
import { resolveRepoContext } from "./context.js";
|
||||||
import type { CliDeps, GlobalFlags } from "./deps.js";
|
import type { CliDeps, GlobalFlags } from "./deps.js";
|
||||||
@@ -19,6 +20,8 @@ commands:
|
|||||||
issue comment Post a comment on an issue or pull request
|
issue comment Post a comment on an issue or pull request
|
||||||
pr create Create a pull request
|
pr create Create a pull request
|
||||||
pr comment Post a comment on a pull request
|
pr comment Post a comment on a pull request
|
||||||
|
label list List labels in the current repository
|
||||||
|
label create Create a label
|
||||||
|
|
||||||
global flags:
|
global flags:
|
||||||
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||||
@@ -115,6 +118,7 @@ export async function runCli(options: RunCliOptions): Promise<number> {
|
|||||||
commands: {
|
commands: {
|
||||||
issue: issueCommand(deps),
|
issue: issueCommand(deps),
|
||||||
pr: prCommand(deps),
|
pr: prCommand(deps),
|
||||||
|
label: labelCommand(deps),
|
||||||
},
|
},
|
||||||
home: homeCommand(deps),
|
home: homeCommand(deps),
|
||||||
stdout: options.stdout,
|
stdout: options.stdout,
|
||||||
|
|||||||
316
src/commands/label.ts
Normal file
316
src/commands/label.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import type { CreateLabelOption, EditLabelOption, Label } from "gitea-js";
|
||||||
|
import { createClient } from "../client.js";
|
||||||
|
import { resolveRepoContext } from "../context.js";
|
||||||
|
import type { CliDeps } from "../deps.js";
|
||||||
|
import { axiError, classifyHttpError } from "../errors.js";
|
||||||
|
import { extractRow, pluck, type FieldDef } from "../fields.js";
|
||||||
|
import { flagValue, parseFlags, parsePositiveInt, parseSinglePositional } from "../flags.js";
|
||||||
|
import { findLabel, listAllLabels, resolveLabel } from "../lookup.js";
|
||||||
|
import { readTotalCount } from "../paginate.js";
|
||||||
|
import { formatCountLine, renderList, renderObject } from "../render.js";
|
||||||
|
import { suggestCommand } from "../suggestions.js";
|
||||||
|
|
||||||
|
export const LABEL_HELP = `usage: gitea-axi label <command> [flags]
|
||||||
|
|
||||||
|
commands:
|
||||||
|
list List labels in the current repository
|
||||||
|
create Create a label
|
||||||
|
edit Edit a label's name, color, or description
|
||||||
|
delete Delete a label
|
||||||
|
|
||||||
|
Run \`gitea-axi label <command> --help\` for the flags of a command.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LABEL_LIST_HELP = `usage: gitea-axi label list [flags]
|
||||||
|
|
||||||
|
List labels in the current repository.
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--limit <n> Maximum number of labels to return (default: 500)
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
global flags:
|
||||||
|
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||||
|
--login <name> Select a tea login profile by name
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LABEL_CREATE_HELP = `usage: gitea-axi label create --name <text> --color <hex> [flags]
|
||||||
|
|
||||||
|
Create a label in the current repository. Idempotent: a label whose name already
|
||||||
|
exists (case-insensitive) is reported rather than duplicated.
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--name <text> Label name (required)
|
||||||
|
--color <hex> Label color as a hex code without \`#\` (required)
|
||||||
|
--description <text> Label description
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
global flags:
|
||||||
|
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||||
|
--login <name> Select a tea login profile by name
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LABEL_EDIT_HELP = `usage: gitea-axi label edit <name> [flags]
|
||||||
|
|
||||||
|
Edit a label in the current repository. The positional name is resolved
|
||||||
|
case-insensitively. At least one change is required.
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--name <text> New label name
|
||||||
|
--color <hex> New color as a hex code without \`#\`
|
||||||
|
--description <text> New description
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
global flags:
|
||||||
|
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||||
|
--login <name> Select a tea login profile by name
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LABEL_DELETE_HELP = `usage: gitea-axi label delete <name>
|
||||||
|
|
||||||
|
Delete a label in the current repository. The positional name is resolved
|
||||||
|
case-insensitively. Deleting a nonexistent label is an error, not a silent
|
||||||
|
success (see ADR 0010).
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
global flags:
|
||||||
|
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||||
|
--login <name> Select a tea login profile by name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 500;
|
||||||
|
|
||||||
|
const LABEL_LIST_HELP_SUGGESTION = ["Run `gitea-axi label list --help` to see available flags"];
|
||||||
|
|
||||||
|
// The list block carries only each label's name, per the spec.
|
||||||
|
const LABEL_LIST_FIELDS: FieldDef<Label>[] = [pluck("name")];
|
||||||
|
|
||||||
|
function parseLimit(value: string | true | undefined): number {
|
||||||
|
if (value === undefined) {
|
||||||
|
return DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
return parsePositiveInt(value, "--limit", LABEL_LIST_HELP_SUGGESTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function labelList(deps: CliDeps, args: string[]): Promise<string> {
|
||||||
|
if (args.includes("--help")) {
|
||||||
|
return LABEL_LIST_HELP;
|
||||||
|
}
|
||||||
|
const { flags, positionals } = parseFlags(args, { "--limit": { takesValue: true } }, "label list");
|
||||||
|
if (positionals.length > 0) {
|
||||||
|
throw axiError(
|
||||||
|
`Unexpected argument: ${positionals[0]}`,
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
LABEL_LIST_HELP_SUGGESTION,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const limit = parseLimit(flags["--limit"]);
|
||||||
|
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
let labels: Label[];
|
||||||
|
let total: number | undefined;
|
||||||
|
try {
|
||||||
|
const response = await api.repos.issueListLabels(context.owner, context.name, {
|
||||||
|
page: 1,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
labels = response.data ?? [];
|
||||||
|
total = readTotalCount(response.headers);
|
||||||
|
} catch (error) {
|
||||||
|
throw classifyHttpError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const rows = labels.map((label) => extractRow(label, LABEL_LIST_FIELDS, { now }));
|
||||||
|
return renderList({
|
||||||
|
noun: "labels",
|
||||||
|
rows,
|
||||||
|
countLine: formatCountLine(rows.length, total, rows.length >= limit),
|
||||||
|
help: [
|
||||||
|
suggestCommand(
|
||||||
|
context,
|
||||||
|
'label create --name "<name>" --color <hex>',
|
||||||
|
"to create a label",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_CREATE_HELP_SUGGESTION = ["Run `gitea-axi label create --help` to see available flags"];
|
||||||
|
|
||||||
|
/** Gitea's `CreateLabelOption.color` requires the leading `#`; the CLI takes it without. */
|
||||||
|
function withHash(color: string): string {
|
||||||
|
return color.startsWith("#") ? color : `#${color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function labelCreate(deps: CliDeps, args: string[]): Promise<string> {
|
||||||
|
if (args.includes("--help")) {
|
||||||
|
return LABEL_CREATE_HELP;
|
||||||
|
}
|
||||||
|
const { flags, positionals } = parseFlags(
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
"--name": { takesValue: true },
|
||||||
|
"--color": { takesValue: true },
|
||||||
|
"--description": { takesValue: true },
|
||||||
|
},
|
||||||
|
"label create",
|
||||||
|
);
|
||||||
|
if (positionals.length > 0) {
|
||||||
|
throw axiError(
|
||||||
|
`Unexpected argument: ${positionals[0]}`,
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
LABEL_CREATE_HELP_SUGGESTION,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything that can fail on the caller's own input is settled before any
|
||||||
|
// request goes out, so a rejected invocation never half-creates a label.
|
||||||
|
const name = flagValue(flags, "--name");
|
||||||
|
if (name === undefined) {
|
||||||
|
throw axiError("label create requires --name <text>", "VALIDATION_ERROR", [
|
||||||
|
'Run `gitea-axi label create --name "<name>" --color <hex>`',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const color = flagValue(flags, "--color");
|
||||||
|
if (color === undefined) {
|
||||||
|
throw axiError("label create requires --color <hex>", "VALIDATION_ERROR", [
|
||||||
|
'Run `gitea-axi label create --name "<name>" --color <hex>`',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const description = flagValue(flags, "--description");
|
||||||
|
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
|
||||||
|
const help = [suggestCommand(context, "label list", "to see all labels")];
|
||||||
|
|
||||||
|
// Fetch-first idempotency check (case-insensitive): an existing label is
|
||||||
|
// reported as a no-op rather than re-POSTed, mirroring the create idempotency
|
||||||
|
// the spec fixes. Note the output key is `create`, not `created`.
|
||||||
|
const existing = findLabel(await listAllLabels(api, context), name);
|
||||||
|
if (existing) {
|
||||||
|
return renderObject({ create: "already_exists", label: existing.name ?? name }, help);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: CreateLabelOption = { name, color: withHash(color) };
|
||||||
|
if (description !== undefined) {
|
||||||
|
payload.description = description;
|
||||||
|
}
|
||||||
|
let label: Label;
|
||||||
|
try {
|
||||||
|
const response = await api.repos.issueCreateLabel(context.owner, context.name, payload);
|
||||||
|
label = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw classifyHttpError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderObject({ created: "ok", label: label.name ?? name }, help);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function labelEdit(deps: CliDeps, args: string[]): Promise<string> {
|
||||||
|
if (args.includes("--help")) {
|
||||||
|
return LABEL_EDIT_HELP;
|
||||||
|
}
|
||||||
|
const { flags, positionals } = parseFlags(
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
"--name": { takesValue: true },
|
||||||
|
"--color": { takesValue: true },
|
||||||
|
"--description": { takesValue: true },
|
||||||
|
},
|
||||||
|
"label edit",
|
||||||
|
);
|
||||||
|
const name = parseSinglePositional(positionals, "label edit", "a label name", "<name>");
|
||||||
|
const newName = flagValue(flags, "--name");
|
||||||
|
const color = flagValue(flags, "--color");
|
||||||
|
const description = flagValue(flags, "--description");
|
||||||
|
if (newName === undefined && color === undefined && description === undefined) {
|
||||||
|
throw axiError("label edit requires at least one change", "VALIDATION_ERROR", [
|
||||||
|
"Run `gitea-axi label edit --help` to see the fields you can change",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
|
||||||
|
// The positional resolves case-insensitively before any mutation; an unknown
|
||||||
|
// name surfaces here as a VALIDATION_ERROR rather than a half-applied edit.
|
||||||
|
const label = await resolveLabel(api, context, name);
|
||||||
|
|
||||||
|
const payload: EditLabelOption = {};
|
||||||
|
if (newName !== undefined) {
|
||||||
|
payload.name = newName;
|
||||||
|
}
|
||||||
|
if (color !== undefined) {
|
||||||
|
payload.color = withHash(color);
|
||||||
|
}
|
||||||
|
if (description !== undefined) {
|
||||||
|
payload.description = description;
|
||||||
|
}
|
||||||
|
let edited: Label;
|
||||||
|
try {
|
||||||
|
const response = await api.repos.issueEditLabel(context.owner, context.name, label.id!, payload);
|
||||||
|
edited = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw classifyHttpError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resulting name is the API's echo of the edited label: the new name when
|
||||||
|
// one was given, otherwise the unchanged original.
|
||||||
|
return renderObject({ edit: "ok", label: edited.name ?? newName ?? label.name ?? name }, [
|
||||||
|
suggestCommand(context, "label list", "to see all labels"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function labelDelete(deps: CliDeps, args: string[]): Promise<string> {
|
||||||
|
if (args.includes("--help")) {
|
||||||
|
return LABEL_DELETE_HELP;
|
||||||
|
}
|
||||||
|
const { positionals } = parseFlags(args, {}, "label delete");
|
||||||
|
const name = parseSinglePositional(positionals, "label delete", "a label name", "<name>");
|
||||||
|
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
|
||||||
|
// Deliberately not idempotent (ADR 0010): the positional resolves via the
|
||||||
|
// standard case-insensitive lookup, so an unknown name is refused with a
|
||||||
|
// VALIDATION_ERROR here rather than reported as a deletion that never happened.
|
||||||
|
const label = await resolveLabel(api, context, name);
|
||||||
|
try {
|
||||||
|
await api.repos.issueDeleteLabel(context.owner, context.name, label.id!);
|
||||||
|
} catch (error) {
|
||||||
|
throw classifyHttpError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderObject({ delete: "ok", label: label.name ?? name }, [
|
||||||
|
suggestCommand(context, "label list", "to see the remaining labels"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelCommand(deps: CliDeps) {
|
||||||
|
return async (args: string[]): Promise<string> => {
|
||||||
|
const [subcommand, ...rest] = args;
|
||||||
|
if (!subcommand || subcommand === "--help") {
|
||||||
|
return LABEL_HELP;
|
||||||
|
}
|
||||||
|
if (subcommand === "list") {
|
||||||
|
return labelList(deps, rest);
|
||||||
|
}
|
||||||
|
if (subcommand === "create") {
|
||||||
|
return labelCreate(deps, rest);
|
||||||
|
}
|
||||||
|
if (subcommand === "edit") {
|
||||||
|
return labelEdit(deps, rest);
|
||||||
|
}
|
||||||
|
if (subcommand === "delete") {
|
||||||
|
return labelDelete(deps, rest);
|
||||||
|
}
|
||||||
|
throw axiError(`Unknown label command: ${subcommand}`, "VALIDATION_ERROR", [
|
||||||
|
"Run `gitea-axi label --help` to see available label commands",
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/flags.ts
42
src/flags.ts
@@ -147,6 +147,32 @@ export function parseIssueNumber(raw: string, noun: string, suggestions: string[
|
|||||||
return number;
|
return number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the single positional of a `<command> <arg>` invocation, rejecting a
|
||||||
|
* missing or extra one. `what` is the noun phrase the arg is ("an issue number",
|
||||||
|
* "a label name") and `placeholder` is how it reads in the usage line
|
||||||
|
* (`<number>`, `<name>`); the count checks and their messages are identical for
|
||||||
|
* every single-positional command, number or name.
|
||||||
|
*/
|
||||||
|
export function parseSinglePositional(
|
||||||
|
positionals: string[],
|
||||||
|
command: string,
|
||||||
|
what: string,
|
||||||
|
placeholder: string,
|
||||||
|
): string {
|
||||||
|
if (positionals.length === 0) {
|
||||||
|
throw axiError(`${command} requires ${what}`, "VALIDATION_ERROR", [
|
||||||
|
`Run \`gitea-axi ${command} ${placeholder}\``,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (positionals.length > 1) {
|
||||||
|
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", [
|
||||||
|
`Run \`gitea-axi ${command} --help\` to see available flags`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return positionals[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the single positional number of a `<command> <number>` invocation.
|
* Parse the single positional number of a `<command> <number>` invocation.
|
||||||
* `noun` names what the number identifies ("issue", "pull request") and appears
|
* `noun` names what the number identifies ("issue", "pull request") and appears
|
||||||
@@ -157,18 +183,10 @@ export function parsePositionalNumber(
|
|||||||
command: string,
|
command: string,
|
||||||
noun: string,
|
noun: string,
|
||||||
): number {
|
): number {
|
||||||
const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`];
|
const raw = parseSinglePositional(positionals, command, `${withArticle(noun)} number`, "<number>");
|
||||||
if (positionals.length === 0) {
|
return parseIssueNumber(raw, noun, [
|
||||||
throw axiError(
|
`Run \`gitea-axi ${command} --help\` to see available flags`,
|
||||||
`${command} requires ${withArticle(noun)} number`,
|
]);
|
||||||
"VALIDATION_ERROR",
|
|
||||||
[`Run \`gitea-axi ${command} <number>\``],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (positionals.length > 1) {
|
|
||||||
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion);
|
|
||||||
}
|
|
||||||
return parseIssueNumber(positionals[0]!, noun, helpSuggestion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseFlags(
|
export function parseFlags(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AxiError } from "axi-sdk-js";
|
||||||
import type { Label } from "gitea-js";
|
import type { Label } from "gitea-js";
|
||||||
import type { GiteaClient } from "./client.js";
|
import type { GiteaClient } from "./client.js";
|
||||||
import type { RepoContext } from "./context.js";
|
import type { RepoContext } from "./context.js";
|
||||||
@@ -10,7 +11,7 @@ import { fetchAllPages } from "./paginate.js";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Fetch every label in the repository, paging until the API runs out. */
|
/** Fetch every label in the repository, paging until the API runs out. */
|
||||||
async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
|
export async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
|
||||||
try {
|
try {
|
||||||
const { items } = await fetchAllPages<Label>((page, limit) =>
|
const { items } = await fetchAllPages<Label>((page, limit) =>
|
||||||
api.repos.issueListLabels(context.owner, context.name, { page, limit }),
|
api.repos.issueListLabels(context.owner, context.name, { page, limit }),
|
||||||
@@ -21,6 +22,46 @@ async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<La
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a label by name within an already-fetched set, case-insensitively. First
|
||||||
|
* match wins so a duplicate name resolves deterministically, matching the id
|
||||||
|
* resolution in {@link resolveLabelIds}.
|
||||||
|
*/
|
||||||
|
export function findLabel(labels: Label[], name: string): Label | undefined {
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
return labels.find((label) => label.name?.toLowerCase() === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `Label "x" not found` VALIDATION_ERROR, listing what the repo does have. */
|
||||||
|
function labelNotFound(context: RepoContext, name: string, labels: Label[]): AxiError {
|
||||||
|
const available = labels
|
||||||
|
.map((label) => label.name)
|
||||||
|
.filter((label): label is string => Boolean(label));
|
||||||
|
return axiError(
|
||||||
|
`Label "${name}" not found in ${context.owner}/${context.name}` +
|
||||||
|
(available.length > 0 ? ` (available: ${available.join(", ")})` : ""),
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single label name to its label, case-insensitively, for the commands
|
||||||
|
* that take a positional `<name>` (`label edit`, `label delete`). A name that
|
||||||
|
* matches nothing is a `VALIDATION_ERROR`, never a silent no-op (see ADR 0010).
|
||||||
|
*/
|
||||||
|
export async function resolveLabel(
|
||||||
|
api: GiteaClient,
|
||||||
|
context: RepoContext,
|
||||||
|
name: string,
|
||||||
|
): Promise<Label> {
|
||||||
|
const labels = await listAllLabels(api, context);
|
||||||
|
const match = findLabel(labels, name);
|
||||||
|
if (!match) {
|
||||||
|
throw labelNotFound(context, name, labels);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve label names to their ids, case-insensitively. Every name must exist:
|
* Resolve label names to their ids, case-insensitively. Every name must exist:
|
||||||
* creating an issue while silently dropping a label the caller asked for would
|
* creating an issue while silently dropping a label the caller asked for would
|
||||||
@@ -51,14 +92,7 @@ export async function resolveLabelIds(
|
|||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const id = byName.get(name.toLowerCase());
|
const id = byName.get(name.toLowerCase());
|
||||||
if (id === undefined) {
|
if (id === undefined) {
|
||||||
const available = labels
|
throw labelNotFound(context, name, labels);
|
||||||
.map((label) => label.name)
|
|
||||||
.filter((label): label is string => Boolean(label));
|
|
||||||
throw axiError(
|
|
||||||
`Label "${name}" not found in ${context.owner}/${context.name}` +
|
|
||||||
(available.length > 0 ? ` (available: ${available.join(", ")})` : ""),
|
|
||||||
"VALIDATION_ERROR",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ export function renderScalar(noun: string, value: string, help: string[]): strin
|
|||||||
return [`${noun}: ${value}`, encode({ help })].join("\n");
|
return [`${noun}: ${value}`, encode({ help })].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flat top-level field map followed by the help block — for outputs whose
|
||||||
|
* fields sit at the top level rather than nested under an entity noun (e.g.
|
||||||
|
* `created: ok` / `label: <name>`). Distinct from {@link renderDetail}, which
|
||||||
|
* wraps its fields in a `noun:` block.
|
||||||
|
*/
|
||||||
|
export function renderObject(item: Record<string, unknown>, help: string[]): string {
|
||||||
|
return [encode(item), encode({ help })].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** A secondary list block appended below a detail entity (e.g. comments). */
|
/** A secondary list block appended below a detail entity (e.g. comments). */
|
||||||
export interface DetailBlock {
|
export interface DetailBlock {
|
||||||
noun: string;
|
noun: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { runCliTest } from "../harness.js";
|
|||||||
import {
|
import {
|
||||||
fetchComments,
|
fetchComments,
|
||||||
fetchIssue,
|
fetchIssue,
|
||||||
|
fetchLabels,
|
||||||
fetchOpenPulls,
|
fetchOpenPulls,
|
||||||
provisionInstance,
|
provisionInstance,
|
||||||
seedBranch,
|
seedBranch,
|
||||||
@@ -216,3 +217,88 @@ describe.skipIf(!E2E_URL)("end-to-end: pull request mutations", () => {
|
|||||||
expect(comments[0]!.body).toBe("A PR comment from the e2e tier.");
|
expect(comments[0]!.body).toBe("A PR comment from the e2e tier.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!E2E_URL)("end-to-end: label mutations", () => {
|
||||||
|
let instance: E2EInstance;
|
||||||
|
// Unique to this block so it never collides with the seeded instance.labelName.
|
||||||
|
const NAME = "E2E-Lifecycle";
|
||||||
|
|
||||||
|
function env(): Record<string, string> {
|
||||||
|
return envFor(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
instance = await instanceOnce();
|
||||||
|
}, 150_000);
|
||||||
|
|
||||||
|
/** The repo's labels named `name`, matched case-insensitively. */
|
||||||
|
async function labelsNamed(name: string): Promise<Record<string, unknown>[]> {
|
||||||
|
const labels = await fetchLabels(instance);
|
||||||
|
return labels.filter(
|
||||||
|
(label) => String(label.name).toLowerCase() === name.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A color as Gitea returned it, with any leading `#` stripped for comparison. */
|
||||||
|
function normalizedColor(label: Record<string, unknown>): string {
|
||||||
|
return String(label.color).replace(/^#/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates, re-creates idempotently, edits, and deletes a label against live Gitea", async () => {
|
||||||
|
// 1. Create: the CLI prepends `#` to the color, which live Gitea requires.
|
||||||
|
const created = await runCliTest(
|
||||||
|
["label", "create", "--name", NAME, "--color", "ff0000"],
|
||||||
|
{ env: env() },
|
||||||
|
);
|
||||||
|
expect(created.exitCode).toBe(0);
|
||||||
|
expect(created.stdout).toContain("created: ok");
|
||||||
|
expect(created.stdout).toContain(`label: ${NAME}`);
|
||||||
|
|
||||||
|
const afterCreate = await labelsNamed(NAME);
|
||||||
|
expect(afterCreate).toHaveLength(1);
|
||||||
|
expect(normalizedColor(afterCreate[0]!)).toBe("ff0000");
|
||||||
|
|
||||||
|
// 2. Re-create with different casing: the live listing is checked
|
||||||
|
// case-insensitively, so no second label is made and nothing changes.
|
||||||
|
const recreated = await runCliTest(
|
||||||
|
["label", "create", "--name", NAME.toUpperCase(), "--color", "00ff00"],
|
||||||
|
{ env: env() },
|
||||||
|
);
|
||||||
|
expect(recreated.exitCode).toBe(0);
|
||||||
|
expect(recreated.stdout).toContain("create: already_exists");
|
||||||
|
expect(recreated.stdout).toContain(`label: ${NAME}`);
|
||||||
|
|
||||||
|
const afterRecreate = await labelsNamed(NAME);
|
||||||
|
expect(afterRecreate).toHaveLength(1);
|
||||||
|
expect(normalizedColor(afterRecreate[0]!)).toBe("ff0000");
|
||||||
|
|
||||||
|
// 3. Edit: name→id resolution plus PATCH-by-id, verified live.
|
||||||
|
const edited = await runCliTest(
|
||||||
|
["label", "edit", NAME, "--color", "00ff00"],
|
||||||
|
{ env: env() },
|
||||||
|
);
|
||||||
|
expect(edited.exitCode).toBe(0);
|
||||||
|
expect(edited.stdout).toContain("edit: ok");
|
||||||
|
|
||||||
|
const afterEdit = await labelsNamed(NAME);
|
||||||
|
expect(afterEdit).toHaveLength(1);
|
||||||
|
expect(normalizedColor(afterEdit[0]!)).toBe("00ff00");
|
||||||
|
|
||||||
|
// 4. Delete: name→id resolution plus DELETE-by-id, verified live.
|
||||||
|
const deleted = await runCliTest(["label", "delete", NAME], { env: env() });
|
||||||
|
expect(deleted.exitCode).toBe(0);
|
||||||
|
expect(deleted.stdout).toContain("delete: ok");
|
||||||
|
|
||||||
|
expect(await labelsNamed(NAME)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete a label that does not exist", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "delete", "no-such-label-xyz"],
|
||||||
|
{ env: env() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -250,6 +250,18 @@ export async function fetchOpenPulls(
|
|||||||
return (await res.json()) as Record<string, unknown>[];
|
return (await res.json()) as Record<string, unknown>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch the repository's labels as Gitea returns them, for verifying what a
|
||||||
|
* label mutation wrote against live state rather than the CLI's own echo. */
|
||||||
|
export async function fetchLabels(instance: E2EInstance): Promise<Record<string, unknown>[]> {
|
||||||
|
const res = await apiRequest(
|
||||||
|
instance.baseUrl,
|
||||||
|
"GET",
|
||||||
|
`/repos/${instance.owner}/${instance.repo}/labels`,
|
||||||
|
instance.token,
|
||||||
|
);
|
||||||
|
return (await res.json()) as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */
|
/** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */
|
||||||
export async function fetchIssue(
|
export async function fetchIssue(
|
||||||
instance: E2EInstance,
|
instance: E2EInstance,
|
||||||
|
|||||||
445
test/label.test.ts
Normal file
445
test/label.test.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { postedBody, runCliTest, testModeEnv } from "./harness.js";
|
||||||
|
|
||||||
|
const LABELS_PATH = "/api/v1/repos/testowner/testrepo/labels";
|
||||||
|
|
||||||
|
let server: FixtureServer;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("label list", () => {
|
||||||
|
it("renders the count line and a labels block of names", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
headers: { "X-Total-Count": "2" },
|
||||||
|
body: [
|
||||||
|
{ id: 11, name: "bug" },
|
||||||
|
{ id: 22, name: "enhancement" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "list"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const lines = stdout.split("\n");
|
||||||
|
expect(lines).toContain("count: 2 of 2 total");
|
||||||
|
expect(lines).toContain("labels[2]{name}:");
|
||||||
|
expect(lines).toContain(" bug");
|
||||||
|
expect(lines).toContain(" enhancement");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits an explicit empty state for a repo with no labels", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
headers: { "X-Total-Count": "0" },
|
||||||
|
body: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "list"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("count: 0 of 0 total");
|
||||||
|
expect(stdout).toContain("labels[0]: (none)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a numeric --limit value", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
headers: { "X-Total-Count": "1" },
|
||||||
|
body: [{ id: 11, name: "bug" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "list", "--limit", "5"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("count: 1 of 1 total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a 403 from the labels API as a FORBIDDEN error", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
status: 403,
|
||||||
|
body: { message: "forbidden" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "list"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: FORBIDDEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("label create", () => {
|
||||||
|
it("creates the label, prepending # to the color, and reports success", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [] },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
status: 201,
|
||||||
|
body: { id: 5, name: "bug", color: "#ff0000" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "create", "--name", "bug", "--color", "ff0000"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(postedBody(server, LABELS_PATH)).toEqual({ name: "bug", color: "#ff0000" });
|
||||||
|
expect(stdout).toContain("created: ok");
|
||||||
|
expect(stdout).toContain("label: bug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: an existing label (case-insensitive) is not re-created", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "create", "--name", "BUG", "--color", "ff0000"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("create: already_exists");
|
||||||
|
expect(stdout).toContain("label: bug");
|
||||||
|
expect(server.requests.some((r) => r.method === "POST")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes --description through in the POST body", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [] },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
status: 201,
|
||||||
|
body: { id: 5, name: "bug" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { exitCode } = await runCliTest(
|
||||||
|
["label", "create", "--name", "bug", "--color", "ff0000", "--description", "A bug"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(postedBody(server, LABELS_PATH)).toEqual({
|
||||||
|
name: "bug",
|
||||||
|
color: "#ff0000",
|
||||||
|
description: "A bug",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a 403 on the create call as a FORBIDDEN error", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [] },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
status: 403,
|
||||||
|
body: { message: "forbidden" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "create", "--name", "bug", "--color", "ff0000"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: FORBIDDEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("label edit", () => {
|
||||||
|
it("resolves the positional by name, PATCHes by id with # prepended to color", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: LABELS_PATH,
|
||||||
|
body: [{ id: 11, name: "bug", color: "ff0000" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: `${LABELS_PATH}/11`,
|
||||||
|
status: 200,
|
||||||
|
body: { id: 11, name: "defect", color: "#00ff00", description: "A bug" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
[
|
||||||
|
"label", "edit", "bug",
|
||||||
|
"--name", "defect",
|
||||||
|
"--color", "00ff00",
|
||||||
|
"--description", "A bug",
|
||||||
|
],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const patch = server.requests.find(
|
||||||
|
(r) => r.method === "PATCH" && r.path === `${LABELS_PATH}/11`,
|
||||||
|
);
|
||||||
|
expect(patch?.body).toMatchObject({
|
||||||
|
name: "defect",
|
||||||
|
color: "#00ff00",
|
||||||
|
description: "A bug",
|
||||||
|
});
|
||||||
|
expect(stdout).toContain("edit: ok");
|
||||||
|
expect(stdout).toContain("label: defect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown label name with a VALIDATION_ERROR and no mutation", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "edit", "ghost", "--name", "x"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(
|
||||||
|
server.requests.some((r) => r.method === "PATCH" || r.method === "DELETE"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCHes only the provided field when just --color is given", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: `${LABELS_PATH}/11`,
|
||||||
|
status: 200,
|
||||||
|
body: { id: 11, name: "bug", color: "#00ff00" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "edit", "bug", "--color", "00ff00"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const patch = server.requests.find(
|
||||||
|
(r) => r.method === "PATCH" && r.path === `${LABELS_PATH}/11`,
|
||||||
|
);
|
||||||
|
expect(patch?.body).toEqual({ color: "#00ff00" });
|
||||||
|
expect(stdout).toContain("edit: ok");
|
||||||
|
expect(stdout).toContain("label: bug");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("label delete", () => {
|
||||||
|
it("resolves the positional by name and DELETEs by id", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
|
||||||
|
{ method: "DELETE", path: `${LABELS_PATH}/11`, status: 204 },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "delete", "bug"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(
|
||||||
|
server.requests.some(
|
||||||
|
(r) => r.method === "DELETE" && r.path === `${LABELS_PATH}/11`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(stdout).toContain("delete: ok");
|
||||||
|
expect(stdout).toContain("label: bug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown label name with a VALIDATION_ERROR and no mutation", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "delete", "ghost"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(
|
||||||
|
server.requests.some((r) => r.method === "PATCH" || r.method === "DELETE"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a 403 on the delete call as a FORBIDDEN error", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `${LABELS_PATH}/11`,
|
||||||
|
status: 403,
|
||||||
|
body: { message: "forbidden" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "delete", "bug"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: FORBIDDEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("label help and dispatch", () => {
|
||||||
|
it("prints group usage when no subcommand is given", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi label <command>");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints group usage for label --help", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "--help"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi label <command>");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints subcommand usage for label list --help", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "list", "--help"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi label list");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints subcommand usage for label create --help", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "create", "--help"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi label create");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints subcommand usage for label edit --help", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "edit", "--help"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi label edit");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints subcommand usage for label delete --help", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "delete", "--help"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi label delete");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown label subcommand", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "bogus"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("Unknown label command: bogus");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unexpected positional on label list", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "list", "extra"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("Unexpected argument: extra");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric --limit on label list", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "list", "--limit", "abc"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("Invalid --limit value");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires --name on label create", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "create"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("label create requires --name");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires --color on label create", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["label", "create", "--name", "bug"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("label create requires --color");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires at least one change flag on label edit", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["label", "edit", "bug"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("label edit requires at least one change");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user