feat: add issue create and comment (task 0004)
All checks were successful
CI / test (pull_request) Successful in 24s
CI / test (push) Successful in 28s

Introduce the first mutations, along with the shared machinery the later
issue and PR mutation slices reuse.

- `issue create` with --title/--body/--body-file/--assignee/--label/
  --milestone/--fields, emitting `issue: { number, title, state, url }`
- `issue comment <n>`, echoing the created comment from the POST response
  with the body cleaned and truncated at 800 chars
- body-source resolution (--body vs --body-file), label and milestone
  name->ID lookup, repeatable flags, and the `joined`/`selectExtraFields`
  field extractors

Label lookup pages until exhausted, since a repo with more labels than one
page would otherwise fail to resolve a valid name. The end-to-end tier seeds
a mixed-case label and milestone and passes both in a different case, so the
case-insensitive lookup is verified against live Gitea rather than only
against fixtures.
This commit was merged in pull request #3.
This commit is contained in:
2026-07-11 20:39:01 -04:00
parent 5e66b4d746
commit f82414a933
13 changed files with 1328 additions and 41 deletions

74
src/body-source.ts Normal file
View File

@@ -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<string, string | true>,
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<string, string | true>,
command: string,
): string {
const body = resolveBodySource(deps, flags, command);
if (body === undefined) {
throw axiError(
`${command} requires --body <text> or --body-file <path>`,
"VALIDATION_ERROR",
bodyFlagSuggestion(command),
);
}
return body;
}
function bodyFlagSuggestion(command: string): string[] {
return [
`Run \`gitea-axi ${command} --body <text>\` or \`gitea-axi ${command} --body-file <path>\``,
];
}
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),
);
}
}