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,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 <command> [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 <command> --help\` for the flags of a command.
`;
export const ISSUE_CREATE_HELP = `usage: gitea-axi issue create --title <text> [flags]
Create an issue in the current repository.
flags:
--title <text> Issue title (required)
--body <text> Issue body
--body-file <path> Read the issue body from a file (mutually exclusive with --body)
--assignee <login> Assign the issue to a user
--label <name> Apply a label by name (repeatable, case-insensitive)
--milestone <name> Assign a milestone by name (case-insensitive)
--fields <a,b,c> Append extra fields: labels, assignees, milestone, body
--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 ISSUE_COMMENT_HELP = `usage: gitea-axi issue comment <number> [flags]
Post a comment on an issue. Pull request numbers are accepted — issues and pull
requests share the comment endpoint.
flags:
--body <text> Comment body (required unless --body-file is given)
--body-file <path> 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 <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const ISSUE_VIEW_HELP = `usage: gitea-axi issue view <number> [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<string> {
});
}
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} <number>\``,
@@ -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<string> {
});
}
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<Issue>[] = [
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<string, FieldDef<Issue>> = {
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<string> {
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 <text>", "VALIDATION_ERROR", [
"Run `gitea-axi issue create --title <text>`",
]);
}
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<string> {
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<string> => {
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",
]);