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

View File

@@ -1,3 +1,4 @@
import { axiError } from "./errors.js";
import { relativeTime } from "./time.js";
export interface ExtractContext {
@@ -31,6 +32,26 @@ export function lowercased<T>(name: string, path: string = name): FieldDef<T> {
};
}
/**
* Join a field holding an array of objects (labels, assignees) into a single
* string of each element's `key` property.
*/
export function joined<T>(name: string, path: string, key: string): FieldDef<T> {
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<T>(name: string, path: string): FieldDef<T> {
return {
name,
@@ -41,6 +62,48 @@ export function relativeTimeField<T>(name: string, path: string): FieldDef<T> {
};
}
/**
* 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<T>(
value: string | undefined,
registry: Record<string, FieldDef<T>>,
command: string,
): FieldDef<T>[] {
if (value === undefined) {
return [];
}
const available = Object.keys(registry);
const suggestion = [
`Run \`gitea-axi ${command} --fields <a,b,c>\` with any of: ${available.join(", ")}`,
];
const selected: FieldDef<T>[] = [];
const seen = new Set<string>();
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<T>(
raw: T,
fields: FieldDef<T>[],