feat: add label commands (task 0015) #15

Merged
alexion merged 2 commits from task-0015-label-commands into main 2026-07-14 09:32:47 -04:00
7 changed files with 612 additions and 28 deletions
Showing only changes of commit 0bf914cbdd - Show all commits

View File

@@ -13,10 +13,28 @@ 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
## 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 342343) 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.

View File

@@ -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
View 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",
]);
};
}

View File

@@ -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(

View File

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

View File

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

184
test/label.test.ts Normal file
View File

@@ -0,0 +1,184 @@
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)");
});
});
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);
});
});
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);
});
});
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);
});
});