feat: add label commands (task 0015)
Some checks failed
CI / test (pull_request) Failing after 38s

This commit is contained in:
2026-07-14 07:24:16 -04:00
parent d034fce3ea
commit 0bf914cbdd
7 changed files with 612 additions and 28 deletions

View File

@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
import { issueCommand } from "./commands/issue.js";
import { labelCommand } from "./commands/label.js";
import { prCommand } from "./commands/pr.js";
import { resolveRepoContext } from "./context.js";
import type { CliDeps, GlobalFlags } from "./deps.js";
@@ -19,6 +20,8 @@ commands:
issue comment Post a comment on an issue or pull request
pr create Create 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:
-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: {
issue: issueCommand(deps),
pr: prCommand(deps),
label: labelCommand(deps),
},
home: homeCommand(deps),
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;
}
/**
* 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.
* `noun` names what the number identifies ("issue", "pull request") and appears
@@ -157,18 +183,10 @@ export function parsePositionalNumber(
command: string,
noun: string,
): number {
const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`];
if (positionals.length === 0) {
throw axiError(
`${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);
const raw = parseSinglePositional(positionals, command, `${withArticle(noun)} number`, "<number>");
return parseIssueNumber(raw, noun, [
`Run \`gitea-axi ${command} --help\` to see available flags`,
]);
}
export function parseFlags(

View File

@@ -1,3 +1,4 @@
import type { AxiError } from "axi-sdk-js";
import type { Label } from "gitea-js";
import type { GiteaClient } from "./client.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. */
async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
export async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
try {
const { items } = await fetchAllPages<Label>((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:
* 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) {
const id = byName.get(name.toLowerCase());
if (id === undefined) {
const available = 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",
);
throw labelNotFound(context, name, labels);
}
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");
}
/**
* 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). */
export interface DetailBlock {
noun: string;