feat: add issue create and comment (task 0004)
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:
@@ -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",
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user