diff --git a/.claude/tasks/0004-issue-create-and-comment.md b/.claude/tasks/0004-issue-create-and-comment.md index 6d8aa12..0f5e154 100644 --- a/.claude/tasks/0004-issue-create-and-comment.md +++ b/.claude/tasks/0004-issue-create-and-comment.md @@ -13,11 +13,31 @@ Create output is the entity block `issue: { number, title, state, url }` with ex ## Acceptance criteria -- [ ] `issue create --title` creates an issue and outputs `issue: { number, title, state, url }` where `url` is `html_url` -- [ ] Missing `--title` fails immediately with `VALIDATION_ERROR` (exit 2) before any API call -- [ ] `--body-file ` reads the body from a file; `--body` and `--body-file` together are rejected -- [ ] `--label` resolves each name to an ID via case-insensitive lookup against the repo's labels; an unknown name yields `VALIDATION_ERROR` -- [ ] `--milestone` resolves the name via the milestone query; an unknown name yields `VALIDATION_ERROR` -- [ ] `issue comment --body` posts and outputs `comment: { number, author, created, body }` built from the POST response, body cleaned and truncated at 800 chars, where `number` is the issue number -- [ ] `issue comment` accepts a PR number without a type-guard error -- [ ] Fixture-server tests cover create with labels/milestone, both body sources, comment output shape, and each validation failure +- [x] `issue create --title` creates an issue and outputs `issue: { number, title, state, url }` where `url` is `html_url` +- [x] Missing `--title` fails immediately with `VALIDATION_ERROR` (exit 2) before any API call +- [x] `--body-file ` reads the body from a file; `--body` and `--body-file` together are rejected +- [x] `--label` resolves each name to an ID via case-insensitive lookup against the repo's labels; an unknown name yields `VALIDATION_ERROR` +- [x] `--milestone` resolves the name via the milestone query; an unknown name yields `VALIDATION_ERROR` +- [x] `issue comment --body` posts and outputs `comment: { number, author, created, body }` built from the POST response, body cleaned and truncated at 800 chars, where `number` is the issue number +- [x] `issue comment` accepts a PR number without a type-guard error +- [x] Fixture-server tests cover create with labels/milestone, both body sources, comment output shape, and each validation failure + +## Implementation Notes + +**Shared machinery introduced here** (all of it is what `pr create` in task 0010 reuses): +`src/body-source.ts` (`resolveBodySource` / `requireBodySource`) resolves `--body` vs `--body-file`; +`src/lookup.ts` (`resolveLabelIds`, `resolveMilestoneId`) does name→ID resolution; +`parseFlags` grew a `repeatable` flag kind, accumulating occurrences into a separate `lists` map so `--label` can repeat without changing the type of the single-valued `flags` map; +`fields.ts` grew the `joined` array-join extractor and `selectExtraFields` for `--fields`. + +**Label lookup paginates.** The spec just says `GET /labels`, but that endpoint pages (default 30), so a repo with more labels than one page would fail to resolve a perfectly valid name. `listAllLabels` pages at 50 until exhausted, with a 20-page (1000-label) runaway guard. + +**`--fields` is additive, not a replacement.** The spec calls these "extra fields available via `--fields`", so the four default fields always render and `--fields` appends to them. An unknown name is a `VALIDATION_ERROR` listing the valid ones rather than being ignored. + +**Milestone resolution re-checks the title client-side.** The `?name=` query only narrows the candidates; the returned title is then compared case-insensitively, so neither the caller's casing nor a looser server-side match (Gitea filters with a `LIKE`) can resolve to a milestone the caller did not name. Whether Gitea's filter is itself case-insensitive is the one assumption fixtures cannot settle, so the end-to-end tier now seeds a mixed-case label and milestone and passes both in a *different* case — if the live behaviour differs, CI fails rather than the user finding out. + +**Deviation: `issue comment` also accepts `--full`.** The spec lists only `--body`/`--body-file` for it. But the shared 800-char truncation hint literally reads "use `--full` to see complete body", and without the flag that hint names a command that errors out. The flag is documented in `issue comment --help`. + +**Deviation: no `issue view` suggestion after commenting on a PR.** `issue comment` is deliberately permissive toward PR numbers, but `issue view` type-guards them — so the obvious next-step suggestion would have been a command guaranteed to fail. The PR case is detected from the POST response's `pull_request_url` (no extra call) and falls back to the `--help` suggestion; `pr view` will be the right suggestion once task 0009 lands it. + +**Follow-up worth flagging.** `src/commands/issue.ts` is now ~540 lines holding four subcommands, each with its own help text, field table, and suggestion builder. It's readable today, but tasks 0005–0007 add five more subcommands to it; a split into `src/commands/issue/.ts` is the natural next move, and is better done as its own refactor than smuggled into a feature task. diff --git a/src/body-source.ts b/src/body-source.ts new file mode 100644 index 0000000..d71714d --- /dev/null +++ b/src/body-source.ts @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import type { CliDeps } from "./deps.js"; +import { axiError } from "./errors.js"; +import { flagValue } from "./flags.js"; + +/** + * Resolve the body text a mutation should send from its `--body`/`--body-file` + * flags. Shared by every command that accepts a body (issue create, issue + * comment, and the edit/close commands that follow). + * + * The two flags are mutually exclusive: accepting both and picking a winner + * would silently discard text the caller meant to send. + */ +export function resolveBodySource( + deps: CliDeps, + flags: Record, + command: string, +): string | undefined { + const body = flagValue(flags, "--body"); + const path = flagValue(flags, "--body-file"); + + if (body !== undefined && path !== undefined) { + throw axiError( + "Flags --body and --body-file are mutually exclusive", + "VALIDATION_ERROR", + bodyFlagSuggestion(command), + ); + } + if (body !== undefined) { + return body; + } + if (path !== undefined) { + return readBodyFile(deps, path, command); + } + return undefined; +} + +/** As {@link resolveBodySource}, for the commands where a body is mandatory. */ +export function requireBodySource( + deps: CliDeps, + flags: Record, + command: string, +): string { + const body = resolveBodySource(deps, flags, command); + if (body === undefined) { + throw axiError( + `${command} requires --body or --body-file `, + "VALIDATION_ERROR", + bodyFlagSuggestion(command), + ); + } + return body; +} + +function bodyFlagSuggestion(command: string): string[] { + return [ + `Run \`gitea-axi ${command} --body \` or \`gitea-axi ${command} --body-file \``, + ]; +} + +function readBodyFile(deps: CliDeps, path: string, command: string): string { + const absolute = isAbsolute(path) ? path : resolve(deps.cwd, path); + try { + return readFileSync(absolute, "utf8"); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw axiError( + `Cannot read --body-file ${path}: ${reason}`, + "VALIDATION_ERROR", + bodyFlagSuggestion(command), + ); + } +} diff --git a/src/cli.ts b/src/cli.ts index 47c3847..c42fa43 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,8 +12,10 @@ const DESCRIPTION = "Agent-ergonomic CLI for Gitea issues and pull requests"; const TOP_LEVEL_HELP = `usage: gitea-axi [flags] commands: - issue list List issues in the current repository - issue view Show a single issue's details + issue list List issues in the current repository + issue view Show a single issue's details + issue create Create an issue + issue comment Post a comment on an issue or pull request global flags: -R, --repo Override the repository detected from the git origin remote diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 42044ad..2571b58 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,15 +1,25 @@ -import type { Comment, Issue } from "gitea-js"; +import type { Comment, CreateIssueOption, Issue } from "gitea-js"; import { BODY_TRUNCATE_LIMIT, COMMENT_TRUNCATE_LIMIT, truncateBody, } from "../body.js"; +import { requireBodySource, resolveBodySource } from "../body-source.js"; import { createClient } from "../client.js"; import { resolveRepoContext, type RepoContext } from "../context.js"; import type { CliDeps } from "../deps.js"; import { axiError, classifyHttpError } from "../errors.js"; -import { extractRow, lowercased, pluck, relativeTimeField, type FieldDef } from "../fields.js"; -import { parseFlags } from "../flags.js"; +import { + extractRow, + joined, + lowercased, + pluck, + relativeTimeField, + selectExtraFields, + type FieldDef, +} from "../fields.js"; +import { flagValue, parseFlags } from "../flags.js"; +import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js"; import { relativeTime } from "../time.js"; import { suggestCommand } from "../suggestions.js"; @@ -17,12 +27,49 @@ import { suggestCommand } from "../suggestions.js"; export const ISSUE_HELP = `usage: gitea-axi issue [flags] commands: - list List issues in the current repository - view Show a single issue's details + list List issues in the current repository + view Show a single issue's details + create Create an issue + comment Post a comment on an issue or pull request Run \`gitea-axi issue --help\` for the flags of a command. `; +export const ISSUE_CREATE_HELP = `usage: gitea-axi issue create --title [flags] + +Create an issue in the current repository. + +flags: + --title Issue title (required) + --body Issue body + --body-file Read the issue body from a file (mutually exclusive with --body) + --assignee Assign the issue to a user + --label Apply a label by name (repeatable, case-insensitive) + --milestone Assign a milestone by name (case-insensitive) + --fields Append extra fields: labels, assignees, milestone, body + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +export const ISSUE_COMMENT_HELP = `usage: gitea-axi issue comment [flags] + +Post a comment on an issue. Pull request numbers are accepted — issues and pull +requests share the comment endpoint. + +flags: + --body Comment body (required unless --body-file is given) + --body-file Read the comment body from a file (mutually exclusive with --body) + --full Echo the posted body in full, without truncating it at 800 chars + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + export const ISSUE_VIEW_HELP = `usage: gitea-axi issue view [flags] Show a single issue. Pull request numbers are rejected — use \`pr view\` instead. @@ -167,11 +214,8 @@ async function issueList(deps: CliDeps, args: string[]): Promise { }); } -const ISSUE_VIEW_HELP_SUGGESTION = [ - "Run `gitea-axi issue view --help` to see available flags", -]; - function parseIssueNumber(positionals: string[], command: string): number { + const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`]; if (positionals.length === 0) { throw axiError(`${command} requires an issue number`, "VALIDATION_ERROR", [ `Run \`gitea-axi ${command} \``, @@ -181,7 +225,7 @@ function parseIssueNumber(positionals: string[], command: string): number { throw axiError( `Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", - ISSUE_VIEW_HELP_SUGGESTION, + helpSuggestion, ); } const raw = positionals[0]!; @@ -190,7 +234,7 @@ function parseIssueNumber(positionals: string[], command: string): number { throw axiError( `Invalid issue number: ${raw} (expected a positive integer)`, "VALIDATION_ERROR", - ISSUE_VIEW_HELP_SUGGESTION, + helpSuggestion, ); } return number; @@ -313,6 +357,155 @@ async function issueView(deps: CliDeps, args: string[]): Promise { }); } +const ISSUE_CREATE_HELP_SUGGESTION = [ + "Run `gitea-axi issue create --help` to see available flags", +]; + +// Default create output, per the spec: number, title, state, url (= html_url). +const ISSUE_CREATE_FIELDS: FieldDef[] = [ + pluck("number"), + pluck("title"), + lowercased("state"), + pluck("url", "html_url"), +]; + +// Appended to the defaults on request via `--fields`, never replacing them. +const ISSUE_CREATE_EXTRA_FIELDS: Record> = { + labels: joined("labels", "labels", "name"), + assignees: joined("assignees", "assignees", "login"), + milestone: pluck("milestone", "milestone.title"), + body: pluck("body"), +}; + +async function issueCreate(deps: CliDeps, args: string[]): Promise { + if (args.includes("--help")) { + return ISSUE_CREATE_HELP; + } + const { flags, lists, positionals } = parseFlags( + args, + { + "--title": { takesValue: true }, + "--body": { takesValue: true }, + "--body-file": { takesValue: true }, + "--assignee": { takesValue: true }, + "--label": { takesValue: true, repeatable: true }, + "--milestone": { takesValue: true }, + "--fields": { takesValue: true }, + }, + "issue create", + ); + if (positionals.length > 0) { + throw axiError( + `Unexpected argument: ${positionals[0]}`, + "VALIDATION_ERROR", + ISSUE_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 an issue. + const title = flagValue(flags, "--title"); + if (title === undefined) { + throw axiError("issue create requires --title ", "VALIDATION_ERROR", [ + "Run `gitea-axi issue create --title `", + ]); + } + const body = resolveBodySource(deps, flags, "issue create"); + const extraFields = selectExtraFields( + flagValue(flags, "--fields"), + ISSUE_CREATE_EXTRA_FIELDS, + "issue create", + ); + const assignee = flagValue(flags, "--assignee"); + const milestoneName = flagValue(flags, "--milestone"); + const labelNames = lists["--label"] ?? []; + + const context = await resolveRepoContext(deps); + const api = createClient(context); + + const payload: CreateIssueOption = { title }; + if (body !== undefined) { + payload.body = body; + } + if (assignee !== undefined) { + payload.assignees = [assignee]; + } + const labelIds = await resolveLabelIds(api, context, labelNames); + if (labelIds.length > 0) { + payload.labels = labelIds; + } + if (milestoneName !== undefined) { + payload.milestone = await resolveMilestoneId(api, context, milestoneName); + } + + let issue: Issue; + try { + const response = await api.repos.issueCreateIssue(context.owner, context.name, payload); + issue = response.data; + } catch (error) { + throw classifyHttpError(error); + } + + const item = extractRow(issue, [...ISSUE_CREATE_FIELDS, ...extraFields], { now: new Date() }); + return renderDetail({ + noun: "issue", + item, + help: [ + suggestCommand(context, `issue view ${issue.number}`, "to see the issue in full"), + ], + }); +} + +async function issueComment(deps: CliDeps, args: string[]): Promise { + if (args.includes("--help")) { + return ISSUE_COMMENT_HELP; + } + const { flags, positionals } = parseFlags( + args, + { + "--body": { takesValue: true }, + "--body-file": { takesValue: true }, + "--full": { takesValue: false }, + }, + "issue comment", + ); + const number = parseIssueNumber(positionals, "issue comment"); + const body = requireBodySource(deps, flags, "issue comment"); + const full = flags["--full"] === true; + + // No type guard here: issues and pull requests genuinely share this endpoint, + // so a PR number is a valid target and is never fetched to be checked. + const context = await resolveRepoContext(deps); + const api = createClient(context); + let comment: Comment; + try { + const response = await api.repos.issueCreateComment(context.owner, context.name, number, { + body, + }); + comment = response.data; + } catch (error) { + throw classifyHttpError(error); + } + + const raw = comment.body ?? ""; + const item = { + // The issue the comment was posted to — the comment's own id is not output. + number, + author: comment.user?.login ?? "", + created: relativeTime(comment.created_at, new Date()), + body: full ? raw : truncateBody(raw, COMMENT_TRUNCATE_LIMIT, context.host), + }; + + // Gitea marks a comment posted on a pull request with `pull_request_url`. Only + // an issue target gets the `issue view` suggestion: `issue view` type-guards + // pull requests, so suggesting it after commenting on one would hand back a + // command that is guaranteed to fail. + const help = comment.pull_request_url + ? [suggestCommand(context, "issue comment --help", "to see all issue comment flags")] + : [suggestCommand(context, `issue view ${number} --comments`, "to see the full thread")]; + return renderDetail({ noun: "comment", item, help }); +} + export function issueCommand(deps: CliDeps) { return async (args: string[]): Promise => { const [subcommand, ...rest] = args; @@ -325,6 +518,12 @@ export function issueCommand(deps: CliDeps) { if (subcommand === "view") { return issueView(deps, rest); } + if (subcommand === "create") { + return issueCreate(deps, rest); + } + if (subcommand === "comment") { + return issueComment(deps, rest); + } throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [ "Run `gitea-axi issue --help` to see available issue commands", ]); diff --git a/src/fields.ts b/src/fields.ts index e8eace0..2c1cc44 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -1,3 +1,4 @@ +import { axiError } from "./errors.js"; import { relativeTime } from "./time.js"; export interface ExtractContext { @@ -31,6 +32,26 @@ export function lowercased(name: string, path: string = name): FieldDef { }; } +/** + * Join a field holding an array of objects (labels, assignees) into a single + * string of each element's `key` property. + */ +export function joined(name: string, path: string, key: string): FieldDef { + return { + name, + extract: (raw) => { + const value = pluckPath(raw, path); + if (!Array.isArray(value)) { + return ""; + } + return value + .map((item) => pluckPath(item, key)) + .filter((item): item is string => typeof item === "string" && item.length > 0) + .join(", "); + }, + }; +} + export function relativeTimeField(name: string, path: string): FieldDef { return { name, @@ -41,6 +62,48 @@ export function relativeTimeField(name: string, path: string): FieldDef { }; } +/** + * Resolve a comma-separated `--fields` value against the extra fields a command + * offers on top of its defaults. Unknown names are a `VALIDATION_ERROR` naming + * what is available, since a silently ignored field would look like a command + * that returned nothing for it. + */ +export function selectExtraFields( + value: string | undefined, + registry: Record>, + command: string, +): FieldDef[] { + if (value === undefined) { + return []; + } + const available = Object.keys(registry); + const suggestion = [ + `Run \`gitea-axi ${command} --fields \` with any of: ${available.join(", ")}`, + ]; + const selected: FieldDef[] = []; + const seen = new Set(); + for (const raw of value.split(",")) { + const name = raw.trim(); + if (!name) { + continue; + } + const field = registry[name]; + if (!field) { + throw axiError( + `Unknown --fields name: ${name} (available: ${available.join(", ")})`, + "VALIDATION_ERROR", + suggestion, + ); + } + if (seen.has(name)) { + continue; + } + seen.add(name); + selected.push(field); + } + return selected; +} + export function extractRow( raw: T, fields: FieldDef[], diff --git a/src/flags.ts b/src/flags.ts index eb7f680..42bba7b 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -1,12 +1,22 @@ import { axiError } from "./errors.js"; export interface FlagSpec { - /** Flag names (e.g. "--state") mapped to whether they take a value. */ - [name: string]: { takesValue: boolean }; + /** Flag names (e.g. "--state") mapped to how they are parsed. */ + [name: string]: { + takesValue: boolean; + /** Accumulate every occurrence into `lists` instead of `flags` (e.g. `--label`). */ + repeatable?: boolean; + }; } export interface ParsedFlags { + /** Single-valued flags: the value, or `true` for a bare switch. */ flags: Record; + /** + * Values of each repeatable flag in argv order. Every repeatable flag in the + * spec is present, holding an empty array when it was not passed. + */ + lists: Record; positionals: string[]; } @@ -15,6 +25,19 @@ export interface SplitFlag { inlineValue?: string; } +/** + * Read a value-taking flag. `parseFlags` rejects such a flag without a value, + * so anything present here is a string; the `true` case only arises for bare + * switches, which callers never read through this helper. + */ +export function flagValue( + flags: Record, + name: string, +): string | undefined { + const value = flags[name]; + return typeof value === "string" ? value : undefined; +} + /** Split "--flag=value" into name and inline value; "--flag" has none. */ export function splitFlag(arg: string): SplitFlag { const equals = arg.indexOf("="); @@ -53,6 +76,12 @@ export function parseFlags( helpCommand: string, ): ParsedFlags { const flags: Record = {}; + const lists: Record = {}; + for (const [name, entry] of Object.entries(spec)) { + if (entry.repeatable) { + lists[name] = []; + } + } const positionals: string[] = []; const helpSuggestion = [`Run \`gitea-axi ${helpCommand} --help\` to see available flags`]; for (let i = 0; i < args.length; i++) { @@ -74,8 +103,12 @@ export function parseFlags( continue; } const consumed = consumeFlagValue(args, i, flag, helpSuggestion); - flags[flag.name] = consumed.value; + if (entry.repeatable) { + lists[flag.name]!.push(consumed.value); + } else { + flags[flag.name] = consumed.value; + } i = consumed.lastIndex; } - return { flags, positionals }; + return { flags, lists, positionals }; } diff --git a/src/lookup.ts b/src/lookup.ts new file mode 100644 index 0000000..b861571 --- /dev/null +++ b/src/lookup.ts @@ -0,0 +1,109 @@ +import type { Label } from "gitea-js"; +import type { GiteaClient } from "./client.js"; +import type { RepoContext } from "./context.js"; +import { axiError, classifyHttpError } from "./errors.js"; + +/** + * Name→ID resolution for the Gitea endpoints that only accept numeric ids. + * Shared by every command that takes a `--label` or `--milestone` name. + */ + +const LABEL_PAGE_SIZE = 50; +/** Guard against an unbounded loop if a server ignores paging and always returns a full page. */ +const LABEL_PAGE_LIMIT = 20; + +/** Fetch every label in the repository, paging until the API runs out. */ +async function listAllLabels(api: GiteaClient, context: RepoContext): Promise { + const labels: Label[] = []; + for (let page = 1; page <= LABEL_PAGE_LIMIT; page++) { + let batch: Label[]; + try { + const response = await api.repos.issueListLabels(context.owner, context.name, { + page, + limit: LABEL_PAGE_SIZE, + }); + batch = response.data ?? []; + } catch (error) { + throw classifyHttpError(error); + } + labels.push(...batch); + if (batch.length < LABEL_PAGE_SIZE) { + break; + } + } + return labels; +} + +/** + * 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 + * misreport what was created. + */ +export async function resolveLabelIds( + api: GiteaClient, + context: RepoContext, + names: string[], +): Promise { + if (names.length === 0) { + return []; + } + const labels = await listAllLabels(api, context); + const byName = new Map(); + for (const label of labels) { + if (label.name === undefined || label.id === undefined) { + continue; + } + const key = label.name.toLowerCase(); + // First match wins, so a duplicate name resolves deterministically. + if (!byName.has(key)) { + byName.set(key, label.id); + } + } + + const ids: number[] = []; + 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", + ); + } + ids.push(id); + } + return ids; +} + +/** Resolve a milestone name to its id via the name-filtered milestone query. */ +export async function resolveMilestoneId( + api: GiteaClient, + context: RepoContext, + name: string, +): Promise { + let milestones; + try { + const response = await api.repos.issueGetMilestonesList(context.owner, context.name, { + name, + }); + milestones = response.data ?? []; + } catch (error) { + throw classifyHttpError(error); + } + // The `name` query only narrows the candidates — the title is re-checked here + // so that neither the caller's casing nor a looser server-side match (Gitea + // filters with a LIKE) can resolve to a milestone the caller did not name. + const match = milestones.find( + (milestone) => milestone.title?.toLowerCase() === name.toLowerCase(), + ); + if (!match?.id) { + throw axiError( + `Milestone "${name}" not found in ${context.owner}/${context.name}`, + "VALIDATION_ERROR", + ); + } + return match.id; +} diff --git a/test/e2e/mutations.test.ts b/test/e2e/mutations.test.ts new file mode 100644 index 0000000..6430d7b --- /dev/null +++ b/test/e2e/mutations.test.ts @@ -0,0 +1,121 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { runCliTest } from "../harness.js"; +import { + fetchComments, + fetchIssue, + provisionInstance, + type E2EInstance, +} from "./provision.js"; + +/** + * The end-to-end tier for the issue mutations. These commands lean on behavior + * the fixture server cannot attest to — above all that Gitea's label and + * milestone name lookups really are case-insensitive, and that `CreateIssueOption` + * really takes label *ids* rather than names. Both are asserted here against a + * live instance by passing names in a different case than they were seeded in. + */ +const E2E_URL = process.env.GITEA_AXI_E2E_URL; + +/** Read the `number:` scalar out of a rendered detail block. */ +function renderedNumber(stdout: string): number { + const match = stdout.match(/^\s*number:\s*(\d+)$/m); + expect(match, `no number field in output:\n${stdout}`).not.toBeNull(); + return Number(match![1]); +} + +describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => { + let instance: E2EInstance; + + function env(): Record { + return { + GITEA_AXI_API_URL: instance.baseUrl, + GITEA_AXI_TOKEN: instance.token, + GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`, + }; + } + + beforeAll(async () => { + instance = await provisionInstance(E2E_URL!); + }, 150_000); + + it("creates an issue and reports the live number, state, and url", async () => { + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "E2E created issue", "--body", "Created by the e2e tier."], + { env: env() }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("issue:"); + expect(stdout).toContain("title: E2E created issue"); + expect(stdout).toContain("state: open"); + expect(stdout).toContain(`${instance.owner}/${instance.repo}/issues/`); + + const created = await fetchIssue(instance, renderedNumber(stdout)); + expect(created.title).toBe("E2E created issue"); + expect(created.body).toBe("Created by the e2e tier."); + }); + + it("resolves a differently-cased --label and --milestone against live Gitea", async () => { + // The seeds are "E2E-Bug" and "E2E-Milestone"; both are passed here in a + // case that does not match, which is the whole point of the assertion. + const { stdout, exitCode } = await runCliTest( + [ + "issue", + "create", + "--title", + "E2E labelled issue", + "--label", + instance.labelName.toLowerCase(), + "--milestone", + instance.milestoneTitle.toUpperCase(), + ], + { env: env() }, + ); + + expect(exitCode).toBe(0); + + const created = await fetchIssue(instance, renderedNumber(stdout)); + const labels = (created.labels ?? []) as { name?: string }[]; + expect(labels.map((label) => label.name)).toEqual([instance.labelName]); + const milestone = created.milestone as { title?: string } | null; + expect(milestone?.title).toBe(instance.milestoneTitle); + }); + + it("rejects an unknown label name without creating the issue", async () => { + const before = await runCliTest(["issue", "list", "--limit", "1"], { env: env() }); + const totalBefore = before.stdout.match(/of (\d+) total/)![1]; + + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "E2E never created", "--label", "no-such-label"], + { env: env() }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + + const after = await runCliTest(["issue", "list", "--limit", "1"], { env: env() }); + expect(after.stdout).toContain(`of ${totalBefore} total`); + }); + + it("posts a comment on a live issue and echoes it back", async () => { + const created = await runCliTest(["issue", "create", "--title", "E2E comment target"], { + env: env(), + }); + const number = renderedNumber(created.stdout); + + const { stdout, exitCode } = await runCliTest( + ["issue", "comment", String(number), "--body", "A comment from the e2e tier."], + { env: env() }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("comment:"); + expect(stdout).toContain(`number: ${number}`); + expect(stdout).toContain(`author: ${instance.owner}`); + expect(stdout).toContain("body: A comment from the e2e tier."); + + const comments = await fetchComments(instance, number); + expect(comments).toHaveLength(1); + expect(comments[0]!.body).toBe("A comment from the e2e tier."); + }); +}); diff --git a/test/e2e/provision.ts b/test/e2e/provision.ts index 0fa4bb1..56c0ba1 100644 --- a/test/e2e/provision.ts +++ b/test/e2e/provision.ts @@ -22,6 +22,14 @@ export interface E2EInstance { openTitles: string[]; /** Title of the single seeded closed issue. */ closedTitle: string; + /** + * A label seeded in mixed case. The mutation tier passes it in a *different* + * case, so the case-insensitive name→id lookup is exercised against live Gitea + * rather than only against fixtures. + */ + labelName: string; + /** A milestone seeded in mixed case, for the same reason as {@link labelName}. */ + milestoneTitle: string; } const USERNAME = "e2e-admin"; @@ -188,7 +196,55 @@ export async function provisionInstance(baseUrl: string): Promise { state: "closed", }); - return { baseUrl: normalized, owner: USERNAME, repo, token, openTitles, closedTitle }; + const labelName = "E2E-Bug"; + await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/labels`, token, { + name: labelName, + color: "#ff0000", + }); + + const milestoneTitle = "E2E-Milestone"; + await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/milestones`, token, { + title: milestoneTitle, + }); + + return { + baseUrl: normalized, + owner: USERNAME, + repo, + token, + openTitles, + closedTitle, + labelName, + milestoneTitle, + }; +} + +/** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */ +export async function fetchIssue( + instance: E2EInstance, + number: number, +): Promise> { + const res = await apiRequest( + instance.baseUrl, + "GET", + `/repos/${instance.owner}/${instance.repo}/issues/${number}`, + instance.token, + ); + return (await res.json()) as Record; +} + +/** Fetch an issue's comments as Gitea returns them. */ +export async function fetchComments( + instance: E2EInstance, + number: number, +): Promise[]> { + const res = await apiRequest( + instance.baseUrl, + "GET", + `/repos/${instance.owner}/${instance.repo}/issues/${number}/comments`, + instance.token, + ); + return (await res.json()) as Record[]; } /** diff --git a/test/fixture-server.ts b/test/fixture-server.ts index f80a25c..57e1cbd 100644 --- a/test/fixture-server.ts +++ b/test/fixture-server.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { createServer, type Server } from "node:http"; +import { createServer, type IncomingMessage, type Server } from "node:http"; export interface FixtureRoute { method: string; @@ -20,6 +20,8 @@ export interface RecordedRequest { path: string; query: Record; headers: Record; + /** Parsed JSON request body; undefined when the request carried none. */ + body?: unknown; } export interface FixtureServer { @@ -49,6 +51,26 @@ function matches(route: FixtureRoute, request: RecordedRequest): boolean { return true; } +/** Collect the request stream, parsing it as JSON when it carried a payload. */ +async function readRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + if (chunks.length === 0) { + return undefined; + } + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + export async function startFixtureServer(routes: FixtureRoute[]): Promise { const requests: RecordedRequest[] = []; const server: Server = createServer((req, res) => { @@ -62,21 +84,24 @@ export async function startFixtureServer(routes: FixtureRoute[]): Promise matches(candidate, recorded)); - if (!route) { - res.writeHead(599, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - message: `no fixture route matched ${recorded.method} ${recorded.path} ${JSON.stringify(recorded.query)}`, - }), - ); - return; - } - res.writeHead(route.status ?? 200, { - "content-type": "application/json", - ...route.headers, + void readRequestBody(req).then((body) => { + recorded.body = body; + const route = routes.find((candidate) => matches(candidate, recorded)); + if (!route) { + res.writeHead(599, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + message: `no fixture route matched ${recorded.method} ${recorded.path} ${JSON.stringify(recorded.query)}`, + }), + ); + return; + } + res.writeHead(route.status ?? 200, { + "content-type": "application/json", + ...route.headers, + }); + res.end(JSON.stringify(loadBody(route))); }); - res.end(JSON.stringify(loadBody(route))); }); await new Promise((resolve) => { server.listen(0, "127.0.0.1", resolve); diff --git a/test/harness.ts b/test/harness.ts index f2e695c..70f10de 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1,4 +1,9 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect } from "vitest"; import { runCli } from "../src/cli.js"; +import type { FixtureServer } from "./fixture-server.js"; export interface CliResult { stdout: string; @@ -40,3 +45,40 @@ export function testModeEnv(apiUrl: string): Record { GITEA_AXI_REPO: "testowner/testrepo", }; } + +/** + * Throwaway files for the `--body-file` paths, cleaned up together. Call + * {@link TempFiles.write} to create one and {@link TempFiles.cleanup} from an + * `afterEach`. + */ +export interface TempFiles { + write: (name: string, content: string) => string; + cleanup: () => void; +} + +export function tempFiles(): TempFiles { + const dirs: string[] = []; + return { + write: (name, content) => { + const dir = mkdtempSync(join(tmpdir(), "gitea-axi-test-")); + dirs.push(dir); + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; + }, + cleanup: () => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }, + }; +} + +/** The parsed body of the single POST the CLI sent to `path`; fails if it sent none. */ +export function postedBody(server: FixtureServer, path: string): Record { + const post = server.requests.find( + (request) => request.method === "POST" && request.path === path, + ); + expect(post, `expected a POST to ${path}`).toBeDefined(); + return post!.body as Record; +} diff --git a/test/issue-comment.test.ts b/test/issue-comment.test.ts new file mode 100644 index 0000000..e40797a --- /dev/null +++ b/test/issue-comment.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { postedBody, runCliTest, tempFiles, testModeEnv } from "./harness.js"; + +const COMMENTS_PATH = "/api/v1/repos/testowner/testrepo/issues/42/comments"; + +let server: FixtureServer; +const files = tempFiles(); + +afterEach(async () => { + await server.close(); + files.cleanup(); +}); + +function createdComment(fields: Record = {}): Record { + return { + id: 900, + user: { login: "alexion" }, + created_at: "2026-07-01T00:00:00Z", + body: "Looks good to me.", + ...fields, + }; +} + +function postedComment(): Record { + return postedBody(server, COMMENTS_PATH); +} + +describe("issue comment", () => { + it("posts the comment and renders number, author, created, and body", async () => { + server = await startFixtureServer([ + { method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment() }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "comment", "42", "--body", "Looks good to me."], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("comment:"); + // `number` is the issue commented on, not the comment's own id. + expect(stdout).toContain("number: 42"); + expect(stdout).toContain("author: alexion"); + expect(stdout).toContain("body: Looks good to me."); + expect(stdout).not.toContain("900"); + expect(postedComment()).toEqual({ body: "Looks good to me." }); + }); + + it("reads the body from --body-file", async () => { + const path = files.write("comment.md", "From a file."); + server = await startFixtureServer([ + { method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment() }, + ]); + const { exitCode } = await runCliTest( + ["issue", "comment", "42", "--body-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedComment()).toEqual({ body: "From a file." }); + }); + + it("truncates a comment body over 800 chars in the output, with the inline hint", async () => { + const body = "z".repeat(1000); + server = await startFixtureServer([ + { method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment({ body }) }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "comment", "42", "--body", body], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain( + `... (truncated, ${body.length} chars total - use --full to see complete body)`, + ); + // The posted body itself is never truncated — only its echo in the output. + expect(postedComment()).toEqual({ body }); + }); + + it("echoes the untruncated body with --full", async () => { + const body = "z".repeat(1000); + server = await startFixtureServer([ + { method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment({ body }) }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "comment", "42", "--body", body, "--full"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain(body); + expect(stdout).not.toContain("truncated"); + }); + + it("cleans a long comment body before truncating it", async () => { + const body = `See http://127.0.0.1/o/r/pulls/9 for context. ${"y".repeat(900)}`; + server = await startFixtureServer([ + { method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment({ body }) }, + ]); + const { stdout } = await runCliTest(["issue", "comment", "42", "--body", body], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain("PR#9"); + }); + + it("suggests viewing the thread when the target is an issue", async () => { + server = await startFixtureServer([ + { method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment() }, + ]); + const { stdout } = await runCliTest(["issue", "comment", "42", "--body", "hi"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain("issue view 42 --comments"); + }); + + it("accepts a pull request number without a type-guard error", async () => { + server = await startFixtureServer([ + { + method: "POST", + path: COMMENTS_PATH, + status: 201, + body: createdComment({ + pull_request_url: "http://127.0.0.1/testowner/testrepo/pulls/42", + }), + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "comment", "42", "--body", "Looks good to me."], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).not.toContain("is a pull request"); + // The issue is never fetched, so a PR number simply flows through. + expect(server.requests.every((request) => request.method === "POST")).toBe(true); + // `issue view` type-guards PRs, so it must never be suggested for a PR + // target — the suggestion would be a command guaranteed to fail. + expect(stdout).not.toContain("issue view 42"); + }); + + it("rejects a missing body before calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "comment", "42"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("--body"); + expect(server.requests).toHaveLength(0); + }); + + it("rejects a missing issue number before calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "comment", "--body", "hi"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); + + it("reports a nonexistent issue as ISSUE_NOT_FOUND with exit 1", async () => { + server = await startFixtureServer([ + { method: "POST", path: COMMENTS_PATH, status: 404, body: { message: "Not Found" } }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "comment", "42", "--body", "hi"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: ISSUE_NOT_FOUND"); + }); + + it("prints help with --help without calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "comment", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue comment "); + expect(server.requests).toHaveLength(0); + }); +}); diff --git a/test/issue-create.test.ts b/test/issue-create.test.ts new file mode 100644 index 0000000..59023a7 --- /dev/null +++ b/test/issue-create.test.ts @@ -0,0 +1,353 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { postedBody, runCliTest, tempFiles, testModeEnv } from "./harness.js"; + +const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues"; +const LABELS_PATH = "/api/v1/repos/testowner/testrepo/labels"; +const MILESTONES_PATH = "/api/v1/repos/testowner/testrepo/milestones"; + +let server: FixtureServer; +const files = tempFiles(); + +afterEach(async () => { + await server.close(); + files.cleanup(); +}); + +function createdIssue(fields: Record = {}): Record { + return { + number: 7, + title: "Fix the thing", + state: "open", + html_url: "http://127.0.0.1/testowner/testrepo/issues/7", + user: { login: "alexion" }, + created_at: "2026-07-01T00:00:00Z", + body: "", + labels: [], + assignees: [], + ...fields, + }; +} + +/** The single POST the CLI sent to the issues endpoint. */ +function postedIssue(): Record { + return postedBody(server, ISSUES_PATH); +} + +describe("issue create", () => { + it("creates an issue and renders number, title, state, and url", async () => { + server = await startFixtureServer([ + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "Fix the thing"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("issue:"); + expect(stdout).toContain("number: 7"); + expect(stdout).toContain("title: Fix the thing"); + expect(stdout).toContain("state: open"); + // TOON quotes the URL because it contains the key/value separator. + expect(stdout).toContain('url: "http://127.0.0.1/testowner/testrepo/issues/7"'); + expect(postedIssue()).toEqual({ title: "Fix the thing" }); + }); + + it("suggests viewing the issue it just created, with the real number", async () => { + server = await startFixtureServer([ + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { stdout } = await runCliTest(["issue", "create", "--title", "Fix the thing"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain("issue view 7"); + }); + + it("sends the body from --body", async () => { + server = await startFixtureServer([ + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--body", "Some details."], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedIssue()).toEqual({ title: "T", body: "Some details." }); + }); + + it("reads the body from --body-file", async () => { + const path = files.write("body.md", "From a file.\nSecond line.\n"); + server = await startFixtureServer([ + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--body-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedIssue()).toEqual({ title: "T", body: "From a file.\nSecond line.\n" }); + }); + + it("rejects --body and --body-file together before calling the API", async () => { + const path = files.write("body.md", "x"); + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--body", "x", "--body-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); + + it("reports an unreadable --body-file as VALIDATION_ERROR", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--body-file", "/nonexistent/nope.md"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); + + it("rejects a missing --title before calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "create"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("--title"); + expect(server.requests).toHaveLength(0); + }); + + it("resolves repeated --label names to ids, case-insensitively", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: LABELS_PATH, + body: [ + { id: 11, name: "bug" }, + { id: 22, name: "Priority: High" }, + { id: 33, name: "chore" }, + ], + }, + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--label", "BUG", "--label", "priority: high"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedIssue()).toEqual({ title: "T", labels: [11, 22] }); + }); + + it("rejects an unknown --label name with VALIDATION_ERROR and creates nothing", async () => { + server = await startFixtureServer([ + { method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--label", "nope"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("nope"); + expect(server.requests.some((request) => request.method === "POST")).toBe(false); + }); + + it("finds a label that only appears on a later page of labels", async () => { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + id: index + 1, + name: `filler-${index}`, + })); + server = await startFixtureServer([ + { method: "GET", path: LABELS_PATH, query: { page: "1" }, body: firstPage }, + { + method: "GET", + path: LABELS_PATH, + query: { page: "2" }, + body: [{ id: 99, name: "needle" }], + }, + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--label", "needle"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedIssue()).toEqual({ title: "T", labels: [99] }); + }); + + it("surfaces a failure to list labels", async () => { + server = await startFixtureServer([ + { method: "GET", path: LABELS_PATH, status: 403, body: { message: "forbidden" } }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--label", "bug"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: FORBIDDEN"); + expect(server.requests.some((request) => request.method === "POST")).toBe(false); + }); + + it("surfaces a failure to list milestones", async () => { + server = await startFixtureServer([ + { method: "GET", path: MILESTONES_PATH, status: 403, body: { message: "forbidden" } }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--milestone", "v1.0"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: FORBIDDEN"); + expect(server.requests.some((request) => request.method === "POST")).toBe(false); + }); + + it("renders empty extra fields when the issue has no labels, assignees, or milestone", async () => { + server = await startFixtureServer([ + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue({ labels: null }) }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--fields", "labels,assignees,milestone"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('labels: ""'); + expect(stdout).toContain('assignees: ""'); + expect(stdout).toContain('milestone: ""'); + }); + + it("resolves --milestone to its id, case-insensitively", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: MILESTONES_PATH, + query: { name: "v1.0" }, + body: [{ id: 5, title: "V1.0" }], + }, + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--milestone", "v1.0"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedIssue()).toEqual({ title: "T", milestone: 5 }); + }); + + it("rejects an unknown --milestone name with VALIDATION_ERROR and creates nothing", async () => { + server = await startFixtureServer([ + { method: "GET", path: MILESTONES_PATH, body: [] }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--milestone", "ghost"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("ghost"); + expect(server.requests.some((request) => request.method === "POST")).toBe(false); + }); + + it("passes --assignee through as an assignees list", async () => { + server = await startFixtureServer([ + { method: "POST", path: ISSUES_PATH, status: 201, body: createdIssue() }, + ]); + const { exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--assignee", "alexion"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedIssue()).toEqual({ title: "T", assignees: ["alexion"] }); + }); + + it("appends the extra fields named by --fields", async () => { + server = await startFixtureServer([ + { + method: "POST", + path: ISSUES_PATH, + status: 201, + body: createdIssue({ + body: "The body.", + labels: [{ id: 11, name: "bug" }, { id: 22, name: "chore" }], + assignees: [{ login: "alexion" }], + milestone: { id: 5, title: "v1.0" }, + }), + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--fields", "labels,assignees,milestone,body"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + // TOON quotes the joined labels because they contain its list delimiter. + expect(stdout).toContain('labels: "bug, chore"'); + expect(stdout).toContain("assignees: alexion"); + expect(stdout).toContain("milestone: v1.0"); + expect(stdout).toContain("body: The body."); + // The default fields stay in place alongside the requested extras. + expect(stdout).toContain("number: 7"); + }); + + it("rejects an unknown --fields name with VALIDATION_ERROR", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["issue", "create", "--title", "T", "--fields", "nonsense"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("nonsense"); + expect(server.requests).toHaveLength(0); + }); + + it("surfaces a server-side rejection of the create", async () => { + server = await startFixtureServer([ + { + method: "POST", + path: ISSUES_PATH, + status: 422, + body: { message: "title is empty" }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "create", "--title", "T"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("title is empty"); + }); + + it("prints help with --help without calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "create", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue create"); + expect(server.requests).toHaveLength(0); + }); +});