feat: add issue blocks and blocked-by (task 0007)
This commit was merged in pull request #10.
This commit is contained in:
@@ -13,8 +13,22 @@ No gh-axi equivalent exists — the interface shape follows this spec alone.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `issue blocks list <n>` and `issue blocked-by list <n>` render their respective output blocks with count lines and explicit empty states
|
- [x] `issue blocks list <n>` and `issue blocked-by list <n>` render their respective output blocks with count lines and explicit empty states
|
||||||
- [ ] `issue blocks add <n> <target>` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add <n> <blocker>` outputs `blocked_by: { issue: n, blocked_by: blocker }`
|
- [x] `issue blocks add <n> <target>` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add <n> <blocker>` outputs `blocked_by: { issue: n, blocked_by: blocker }`
|
||||||
- [ ] Adding an existing relationship returns `already: true` (fetch-first check, no duplicate POST); removing a nonexistent relationship exits 0 silently-successfully
|
- [x] Adding an existing relationship returns `already: true` (fetch-first check, no duplicate POST); removing a nonexistent relationship exits 0 silently-successfully
|
||||||
- [ ] Self-reference and cycle rejections from Gitea surface as `VALIDATION_ERROR` (exit 2) with the server's message
|
- [x] Self-reference and cycle rejections from Gitea surface as `VALIDATION_ERROR` (exit 2) with the server's message
|
||||||
- [ ] Fixture-server tests cover list, add, idempotent re-add, remove, idempotent re-remove, and a 422 cycle rejection for both groups
|
- [x] Fixture-server tests cover list, add, idempotent re-add, remove, idempotent re-remove, and a 422 cycle rejection for both groups
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Both groups are one config-parameterised implementation (`DependencyGroup`): `blocks` over `/issues/{index}/blocks`, `blocked-by` over `/issues/{index}/dependencies`, differing only in endpoint calls and the spec-fixed output names (`blocked_issues`/`blocking_issues`, `blocks`/`blocked_by`).
|
||||||
|
|
||||||
|
Decisions made mid-implementation, where the spec was silent:
|
||||||
|
|
||||||
|
- **`remove` output.** The spec fixes the `add` output shape but not `remove`'s. A successful deletion reports `<noun>: { issue, <target>, removed: true }`; a no-op removal (relationship already absent) reports `already: true` instead, mirroring `add`'s no-op and honouring the action/entity-block convention (a no-op reports the already-reached state rather than claiming an action it did not perform). Both `add` and `remove` are fetch-first against the fully paginated current set, so a nonexistent issue surfaces as `ISSUE_NOT_FOUND` before any mutation.
|
||||||
|
- **`list` row fields.** Rendered as `number`, `title`, `state` — the identifying essentials; the spec did not fix a row shape.
|
||||||
|
|
||||||
|
Review follow-ups addressed in this change:
|
||||||
|
|
||||||
|
- Extracted `parseIssueNumber` into `flags.ts` so the two-positional dependency parser and the existing `parsePositionalNumber` share one positive-integer rule and message (was duplicated).
|
||||||
|
- Added test coverage for the `ISSUE_NOT_FOUND` path (issue itself absent), which the code comments claim but nothing exercised.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Comment, CreateIssueOption, EditIssueOption, Issue } from "gitea-js";
|
import type { Comment, CreateIssueOption, EditIssueOption, Issue, IssueMeta } from "gitea-js";
|
||||||
import { BODY_TRUNCATE_LIMIT, truncateBody } from "../body.js";
|
import { BODY_TRUNCATE_LIMIT, truncateBody } from "../body.js";
|
||||||
import { requireBodySource, resolveBodySource } from "../body-source.js";
|
import { requireBodySource, resolveBodySource } from "../body-source.js";
|
||||||
import { createClient, type GiteaClient } from "../client.js";
|
import { createClient, type GiteaClient } from "../client.js";
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
flagValue,
|
flagValue,
|
||||||
parseEnumFlag,
|
parseEnumFlag,
|
||||||
parseFlags,
|
parseFlags,
|
||||||
|
parseIssueNumber,
|
||||||
parsePositionalNumber,
|
parsePositionalNumber,
|
||||||
parsePositiveInt,
|
parsePositiveInt,
|
||||||
splitFlag,
|
splitFlag,
|
||||||
@@ -41,10 +42,56 @@ commands:
|
|||||||
pin Pin an issue to the repository
|
pin Pin an issue to the repository
|
||||||
unpin Unpin an issue
|
unpin Unpin an issue
|
||||||
comment Post a comment on an issue or pull request
|
comment Post a comment on an issue or pull request
|
||||||
|
blocks Manage the issues this issue blocks (Gitea-specific)
|
||||||
|
blocked-by Manage the issues that block this issue (Gitea-specific)
|
||||||
|
|
||||||
Run \`gitea-axi issue <command> --help\` for the flags of a command.
|
Run \`gitea-axi issue <command> --help\` for the flags of a command.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const ISSUE_BLOCKS_HELP = `usage: gitea-axi issue blocks <list|add|remove> <number> [target]
|
||||||
|
|
||||||
|
Manage the issues that an issue blocks — downstream dependents that cannot
|
||||||
|
proceed until it is resolved (Gitea-specific; no gh-axi equivalent).
|
||||||
|
|
||||||
|
commands:
|
||||||
|
list <n> List the issues blocked by issue <n>
|
||||||
|
add <n> <target> Make issue <n> block issue <target>
|
||||||
|
remove <n> <target> Remove the blocking relationship
|
||||||
|
|
||||||
|
Adding a relationship that already exists is a no-op that reports \`already: true\`;
|
||||||
|
removing one that does not exist succeeds silently. Self-reference and cycle
|
||||||
|
rejections from Gitea surface as VALIDATION_ERROR.
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--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_BLOCKED_BY_HELP = `usage: gitea-axi issue blocked-by <list|add|remove> <number> [blocker]
|
||||||
|
|
||||||
|
Manage the issues that block an issue — upstream blockers that must be resolved
|
||||||
|
before it can proceed (Gitea-specific; no gh-axi equivalent).
|
||||||
|
|
||||||
|
commands:
|
||||||
|
list <n> List the issues that block issue <n>
|
||||||
|
add <n> <blocker> Make issue <n> depend on issue <blocker>
|
||||||
|
remove <n> <blocker> Remove the dependency
|
||||||
|
|
||||||
|
Adding a relationship that already exists is a no-op that reports \`already: true\`;
|
||||||
|
removing one that does not exist succeeds silently. Self-reference and cycle
|
||||||
|
rejections from Gitea surface as VALIDATION_ERROR.
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--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_EDIT_HELP = `usage: gitea-axi issue edit <number> [flags]
|
export const ISSUE_EDIT_HELP = `usage: gitea-axi issue edit <number> [flags]
|
||||||
|
|
||||||
Edit an issue in the current repository. At least one change is required.
|
Edit an issue in the current repository. At least one change is required.
|
||||||
@@ -983,6 +1030,293 @@ async function issueUnpin(deps: CliDeps, args: string[]): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A response page from a relationship-listing endpoint, in the shape
|
||||||
|
* {@link fetchAllPages} consumes. Both `/blocks` and `/dependencies` return
|
||||||
|
* `Issue[]` with the standard pagination headers.
|
||||||
|
*/
|
||||||
|
interface RelationshipPage {
|
||||||
|
data?: Issue[];
|
||||||
|
headers: Headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the two Gitea-specific dependency directions. `blocks` and `blocked-by`
|
||||||
|
* are the same three operations (`list`/`add`/`remove`) over two different
|
||||||
|
* endpoints, so a single implementation is parameterised by this config: the
|
||||||
|
* endpoint calls, the output block/field names the spec fixes, and the noun the
|
||||||
|
* second positional carries in errors and help.
|
||||||
|
*/
|
||||||
|
interface DependencyGroup {
|
||||||
|
/** Subcommand as typed: "blocks" | "blocked-by". */
|
||||||
|
command: string;
|
||||||
|
/** What the second positional identifies: "target" | "blocker". */
|
||||||
|
targetNoun: string;
|
||||||
|
/** Output block name for `list`. */
|
||||||
|
listNoun: string;
|
||||||
|
/** Output entity name for `add`/`remove`. */
|
||||||
|
mutationNoun: string;
|
||||||
|
/** Key naming the related issue in `add`/`remove` output. */
|
||||||
|
targetKey: string;
|
||||||
|
/** Help text for the group. */
|
||||||
|
help: string;
|
||||||
|
listPage: (
|
||||||
|
api: GiteaClient,
|
||||||
|
context: RepoContext,
|
||||||
|
index: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
) => Promise<RelationshipPage>;
|
||||||
|
add: (
|
||||||
|
api: GiteaClient,
|
||||||
|
context: RepoContext,
|
||||||
|
index: number,
|
||||||
|
meta: IssueMeta,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
remove: (
|
||||||
|
api: GiteaClient,
|
||||||
|
context: RepoContext,
|
||||||
|
index: number,
|
||||||
|
meta: IssueMeta,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The identifying essentials of a related issue — enough to recognise it without
|
||||||
|
// the noise of a full issue listing.
|
||||||
|
const RELATIONSHIP_FIELDS: FieldDef<Issue>[] = [
|
||||||
|
pluck("number"),
|
||||||
|
pluck("title"),
|
||||||
|
lowercased("state"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCKS_GROUP: DependencyGroup = {
|
||||||
|
command: "blocks",
|
||||||
|
targetNoun: "target",
|
||||||
|
listNoun: "blocked_issues",
|
||||||
|
mutationNoun: "blocks",
|
||||||
|
targetKey: "blocks",
|
||||||
|
help: ISSUE_BLOCKS_HELP,
|
||||||
|
listPage: (api, context, index, page, limit) =>
|
||||||
|
api.repos.issueListBlocks(context.owner, context.name, String(index), { page, limit }),
|
||||||
|
add: (api, context, index, meta) =>
|
||||||
|
api.repos.issueCreateIssueBlocking(context.owner, context.name, String(index), meta),
|
||||||
|
remove: (api, context, index, meta) =>
|
||||||
|
api.repos.issueRemoveIssueBlocking(context.owner, context.name, String(index), meta),
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCKED_BY_GROUP: DependencyGroup = {
|
||||||
|
command: "blocked-by",
|
||||||
|
targetNoun: "blocker",
|
||||||
|
listNoun: "blocking_issues",
|
||||||
|
mutationNoun: "blocked_by",
|
||||||
|
targetKey: "blocked_by",
|
||||||
|
help: ISSUE_BLOCKED_BY_HELP,
|
||||||
|
listPage: (api, context, index, page, limit) =>
|
||||||
|
api.repos.issueListIssueDependencies(context.owner, context.name, String(index), {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
add: (api, context, index, meta) =>
|
||||||
|
api.repos.issueCreateIssueDependencies(context.owner, context.name, String(index), meta),
|
||||||
|
remove: (api, context, index, meta) =>
|
||||||
|
api.repos.issueRemoveIssueDependencies(context.owner, context.name, String(index), meta),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the `<number> <target>` positionals shared by `add` and `remove`. Both
|
||||||
|
* arguments are required issue numbers; a missing or extra one is a
|
||||||
|
* VALIDATION_ERROR naming what is expected.
|
||||||
|
*/
|
||||||
|
function parseIssueAndTarget(
|
||||||
|
positionals: string[],
|
||||||
|
command: string,
|
||||||
|
targetNoun: string,
|
||||||
|
): { issue: number; target: 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> <${targetNoun}>\``,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (positionals.length === 1) {
|
||||||
|
throw axiError(`${command} requires a ${targetNoun} issue number`, "VALIDATION_ERROR", [
|
||||||
|
`Run \`gitea-axi ${command} <number> <${targetNoun}>\``,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (positionals.length > 2) {
|
||||||
|
throw axiError(`Unexpected argument: ${positionals[2]}`, "VALIDATION_ERROR", helpSuggestion);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
issue: parseIssueNumber(positionals[0]!, "issue", helpSuggestion),
|
||||||
|
target: parseIssueNumber(positionals[1]!, targetNoun, helpSuggestion),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every related issue currently on this side of the relationship. Fully
|
||||||
|
* paginated so the fetch-first idempotency check (add) and the
|
||||||
|
* remove-if-present check (remove) see the complete set, and so `list`'s count
|
||||||
|
* describes the whole set rather than a first page (see ADR 0005).
|
||||||
|
*/
|
||||||
|
async function fetchRelationships(
|
||||||
|
api: GiteaClient,
|
||||||
|
context: RepoContext,
|
||||||
|
group: DependencyGroup,
|
||||||
|
index: number,
|
||||||
|
): Promise<Issue[]> {
|
||||||
|
try {
|
||||||
|
const result = await fetchAllPages<Issue>((page, limit) =>
|
||||||
|
group.listPage(api, context, index, page, limit),
|
||||||
|
);
|
||||||
|
return result.items;
|
||||||
|
} catch (error) {
|
||||||
|
throw classifyHttpError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRelationships(
|
||||||
|
deps: CliDeps,
|
||||||
|
args: string[],
|
||||||
|
group: DependencyGroup,
|
||||||
|
): Promise<string> {
|
||||||
|
const command = `issue ${group.command} list`;
|
||||||
|
const { positionals } = parseFlags(args, {}, command);
|
||||||
|
const number = parsePositionalNumber(positionals, command, "issue");
|
||||||
|
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
const issues = await fetchRelationships(api, context, group, number);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const rows = issues.map((issue) => extractRow(issue, RELATIONSHIP_FIELDS, { now }));
|
||||||
|
return renderList({
|
||||||
|
noun: group.listNoun,
|
||||||
|
rows,
|
||||||
|
// The whole set is in hand, so its own size is the total and nothing is
|
||||||
|
// withheld by a limit.
|
||||||
|
countLine: formatCountLine(rows.length, rows.length, false),
|
||||||
|
help: [
|
||||||
|
suggestCommand(
|
||||||
|
context,
|
||||||
|
`issue ${group.command} add ${number} <${group.targetNoun}>`,
|
||||||
|
`to add a ${group.command} relationship`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRelationship(
|
||||||
|
deps: CliDeps,
|
||||||
|
args: string[],
|
||||||
|
group: DependencyGroup,
|
||||||
|
): Promise<string> {
|
||||||
|
const command = `issue ${group.command} add`;
|
||||||
|
const { positionals } = parseFlags(args, {}, command);
|
||||||
|
const { issue, target } = parseIssueAndTarget(positionals, command, group.targetNoun);
|
||||||
|
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
|
||||||
|
// Fetch-first idempotency check: an existing relationship is reported as a
|
||||||
|
// no-op rather than re-POSTed. A nonexistent issue surfaces here as its own
|
||||||
|
// ISSUE_NOT_FOUND, before any mutation is attempted.
|
||||||
|
const existing = await fetchRelationships(api, context, group, issue);
|
||||||
|
const help = [
|
||||||
|
suggestCommand(context, `issue ${group.command} list ${issue}`, "to see the current relationships"),
|
||||||
|
];
|
||||||
|
if (existing.some((related) => related.number === target)) {
|
||||||
|
return renderDetail({
|
||||||
|
noun: group.mutationNoun,
|
||||||
|
item: { issue, [group.targetKey]: target, already: true },
|
||||||
|
help,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body names the OTHER issue; the issue in the path is `issue`. Self-
|
||||||
|
// reference and cycle rejections come back from Gitea as 422, which
|
||||||
|
// classifyHttpError maps to VALIDATION_ERROR with the server's own message.
|
||||||
|
const meta: IssueMeta = { owner: context.owner, repo: context.name, index: target };
|
||||||
|
try {
|
||||||
|
await group.add(api, context, issue, meta);
|
||||||
|
} catch (error) {
|
||||||
|
throw classifyHttpError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderDetail({
|
||||||
|
noun: group.mutationNoun,
|
||||||
|
item: { issue, [group.targetKey]: target },
|
||||||
|
help,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRelationship(
|
||||||
|
deps: CliDeps,
|
||||||
|
args: string[],
|
||||||
|
group: DependencyGroup,
|
||||||
|
): Promise<string> {
|
||||||
|
const command = `issue ${group.command} remove`;
|
||||||
|
const { positionals } = parseFlags(args, {}, command);
|
||||||
|
const { issue, target } = parseIssueAndTarget(positionals, command, group.targetNoun);
|
||||||
|
|
||||||
|
const context = await resolveRepoContext(deps);
|
||||||
|
const api = createClient(context);
|
||||||
|
|
||||||
|
// Fetch-first: a relationship that is not present is an idempotent no-op —
|
||||||
|
// the caller's intent (relationship absent) already holds, so no DELETE is
|
||||||
|
// sent and the output marks it `already: true` (per the action/entity-block
|
||||||
|
// convention: a no-op reports the already-reached state rather than claiming
|
||||||
|
// an action it did not perform, mirroring `add`). A nonexistent issue still
|
||||||
|
// surfaces here as ISSUE_NOT_FOUND.
|
||||||
|
const existing = await fetchRelationships(api, context, group, issue);
|
||||||
|
const help = [
|
||||||
|
suggestCommand(context, `issue ${group.command} list ${issue}`, "to see the current relationships"),
|
||||||
|
];
|
||||||
|
if (!existing.some((related) => related.number === target)) {
|
||||||
|
return renderDetail({
|
||||||
|
noun: group.mutationNoun,
|
||||||
|
item: { issue, [group.targetKey]: target, already: true },
|
||||||
|
help,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: IssueMeta = { owner: context.owner, repo: context.name, index: target };
|
||||||
|
try {
|
||||||
|
await group.remove(api, context, issue, meta);
|
||||||
|
} catch (error) {
|
||||||
|
throw classifyHttpError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderDetail({
|
||||||
|
noun: group.mutationNoun,
|
||||||
|
item: { issue, [group.targetKey]: target, removed: true },
|
||||||
|
help,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch the `list`/`add`/`remove` sub-operation of a dependency group. */
|
||||||
|
async function issueDependencyGroup(
|
||||||
|
deps: CliDeps,
|
||||||
|
args: string[],
|
||||||
|
group: DependencyGroup,
|
||||||
|
): Promise<string> {
|
||||||
|
const [operation, ...rest] = args;
|
||||||
|
if (!operation || operation === "--help") {
|
||||||
|
return group.help;
|
||||||
|
}
|
||||||
|
if (operation === "list") {
|
||||||
|
return listRelationships(deps, rest, group);
|
||||||
|
}
|
||||||
|
if (operation === "add") {
|
||||||
|
return addRelationship(deps, rest, group);
|
||||||
|
}
|
||||||
|
if (operation === "remove") {
|
||||||
|
return removeRelationship(deps, rest, group);
|
||||||
|
}
|
||||||
|
throw axiError(`Unknown issue ${group.command} command: ${operation}`, "VALIDATION_ERROR", [
|
||||||
|
`Run \`gitea-axi issue ${group.command} --help\` to see available operations`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function issueCommand(deps: CliDeps) {
|
export function issueCommand(deps: CliDeps) {
|
||||||
return async (args: string[]): Promise<string> => {
|
return async (args: string[]): Promise<string> => {
|
||||||
const [subcommand, ...rest] = args;
|
const [subcommand, ...rest] = args;
|
||||||
@@ -1019,6 +1353,12 @@ export function issueCommand(deps: CliDeps) {
|
|||||||
if (subcommand === "comment") {
|
if (subcommand === "comment") {
|
||||||
return issueComment(deps, rest);
|
return issueComment(deps, rest);
|
||||||
}
|
}
|
||||||
|
if (subcommand === "blocks") {
|
||||||
|
return issueDependencyGroup(deps, rest, BLOCKS_GROUP);
|
||||||
|
}
|
||||||
|
if (subcommand === "blocked-by") {
|
||||||
|
return issueDependencyGroup(deps, rest, BLOCKED_BY_GROUP);
|
||||||
|
}
|
||||||
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
|
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
|
||||||
"Run `gitea-axi issue --help` to see available issue commands",
|
"Run `gitea-axi issue --help` to see available issue commands",
|
||||||
]);
|
]);
|
||||||
|
|||||||
29
src/flags.ts
29
src/flags.ts
@@ -129,6 +129,24 @@ function withArticle(noun: string): string {
|
|||||||
return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;
|
return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single raw argument as a positive issue number. `noun` names what
|
||||||
|
* the number identifies ("issue", "target") in the error; `suggestions` carries
|
||||||
|
* the caller's help line. Shared by the single-positional parser and the
|
||||||
|
* two-positional dependency parser so the rule and its message live in one place.
|
||||||
|
*/
|
||||||
|
export function parseIssueNumber(raw: string, noun: string, suggestions: string[]): number {
|
||||||
|
const number = Number(raw);
|
||||||
|
if (!Number.isInteger(number) || number < 1) {
|
||||||
|
throw axiError(
|
||||||
|
`Invalid ${noun} number: ${raw} (expected a positive integer)`,
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
suggestions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the single positional number of a `<command> <number>` invocation.
|
* Parse the single positional number of a `<command> <number>` invocation.
|
||||||
* `noun` names what the number identifies ("issue", "pull request") and appears
|
* `noun` names what the number identifies ("issue", "pull request") and appears
|
||||||
@@ -150,16 +168,7 @@ export function parsePositionalNumber(
|
|||||||
if (positionals.length > 1) {
|
if (positionals.length > 1) {
|
||||||
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion);
|
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion);
|
||||||
}
|
}
|
||||||
const raw = positionals[0]!;
|
return parseIssueNumber(positionals[0]!, noun, helpSuggestion);
|
||||||
const number = Number(raw);
|
|
||||||
if (!Number.isInteger(number) || number < 1) {
|
|
||||||
throw axiError(
|
|
||||||
`Invalid ${noun} number: ${raw} (expected a positive integer)`,
|
|
||||||
"VALIDATION_ERROR",
|
|
||||||
helpSuggestion,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseFlags(
|
export function parseFlags(
|
||||||
|
|||||||
211
test/issue-blocks.test.ts
Normal file
211
test/issue-blocks.test.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { postedBody, runCliTest, testModeEnv } from "./harness.js";
|
||||||
|
|
||||||
|
const ISSUE = 7;
|
||||||
|
const TARGET = 9;
|
||||||
|
const BLOCKS_PATH = `/api/v1/repos/testowner/testrepo/issues/${ISSUE}/blocks`;
|
||||||
|
const DEPENDENCIES_PATH = `/api/v1/repos/testowner/testrepo/issues/${ISSUE}/dependencies`;
|
||||||
|
|
||||||
|
let server: FixtureServer;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A minimal Issue as the relationship endpoints return one. */
|
||||||
|
function relatedIssue(number: number): Record<string, unknown> {
|
||||||
|
return { number, title: `Issue ${number}`, state: "open" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each dependency group is the same command shape over a different endpoint, so
|
||||||
|
* the behaviour is asserted once against a parameterised description. `path` is
|
||||||
|
* the endpoint the group's list/add/remove all share; `mutationNoun`/`targetKey`
|
||||||
|
* are the output field names the spec fixes for the group.
|
||||||
|
*/
|
||||||
|
interface GroupCase {
|
||||||
|
command: "blocks" | "blocked-by";
|
||||||
|
path: string;
|
||||||
|
listNoun: string;
|
||||||
|
mutationNoun: string;
|
||||||
|
targetKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: GroupCase[] = [
|
||||||
|
{
|
||||||
|
command: "blocks",
|
||||||
|
path: BLOCKS_PATH,
|
||||||
|
listNoun: "blocked_issues",
|
||||||
|
mutationNoun: "blocks",
|
||||||
|
targetKey: "blocks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "blocked-by",
|
||||||
|
path: DEPENDENCIES_PATH,
|
||||||
|
listNoun: "blocking_issues",
|
||||||
|
mutationNoun: "blocked_by",
|
||||||
|
targetKey: "blocked_by",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const group of GROUPS) {
|
||||||
|
describe(`issue ${group.command}`, () => {
|
||||||
|
it(`list renders the ${group.listNoun} block with a count line`, async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, body: [relatedIssue(TARGET)] },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "list", String(ISSUE)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
// TOON renders list rows in compact tabular form (`9,Issue 9,open`), so
|
||||||
|
// the related issue shows up by its title, not a `number:` field line.
|
||||||
|
expect(stdout).toContain(`${group.listNoun}[1]`);
|
||||||
|
expect(stdout).toContain(`Issue ${TARGET}`);
|
||||||
|
expect(stdout).toContain("count: 1 of 1 total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list renders an explicit empty state when there are none", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, body: [] },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "list", String(ISSUE)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain(`${group.listNoun}[0]: (none)`);
|
||||||
|
expect(stdout).toContain("count: 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`add reports ${group.mutationNoun} and posts the target as IssueMeta`, async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, body: [] },
|
||||||
|
{ method: "POST", path: group.path, body: relatedIssue(TARGET) },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "add", String(ISSUE), String(TARGET)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain(`${group.mutationNoun}:`);
|
||||||
|
expect(stdout).toContain(`issue: ${ISSUE}`);
|
||||||
|
expect(stdout).toContain(`${group.targetKey}: ${TARGET}`);
|
||||||
|
expect(stdout).not.toContain("already");
|
||||||
|
expect(postedBody(server, group.path)).toEqual({
|
||||||
|
owner: "testowner",
|
||||||
|
repo: "testrepo",
|
||||||
|
index: TARGET,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add of an existing relationship returns already: true without posting", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, body: [relatedIssue(TARGET)] },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "add", String(ISSUE), String(TARGET)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("already: true");
|
||||||
|
expect(server.requests.some((request) => request.method === "POST")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove of an existing relationship deletes it", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, body: [relatedIssue(TARGET)] },
|
||||||
|
{ method: "DELETE", path: group.path, body: relatedIssue(TARGET) },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "remove", String(ISSUE), String(TARGET)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("removed: true");
|
||||||
|
expect(server.requests.some((request) => request.method === "DELETE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove of a nonexistent relationship is an idempotent no-op without deleting", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, body: [] },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "remove", String(ISSUE), String(TARGET)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
// A no-op remove reports the already-reached state, not a deletion it did
|
||||||
|
// not perform (mirrors add's `already: true`).
|
||||||
|
expect(stdout).toContain("already: true");
|
||||||
|
expect(stdout).not.toContain("removed: true");
|
||||||
|
expect(server.requests.some((request) => request.method === "DELETE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ISSUE_NOT_FOUND when the issue itself does not exist", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, status: 404, body: { message: "issue does not exist" } },
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "add", String(ISSUE), String(TARGET)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: ISSUE_NOT_FOUND");
|
||||||
|
expect(server.requests.some((request) => request.method === "POST")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a cycle rejection from Gitea as VALIDATION_ERROR", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: group.path, body: [] },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: group.path,
|
||||||
|
status: 422,
|
||||||
|
body: { message: "circular dependencies are not allowed" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "add", String(ISSUE), String(TARGET)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("circular dependencies are not allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing target before calling the API", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "add", String(ISSUE)],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints help with --help without calling the API", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", group.command, "--help"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain(`usage: gitea-axi issue ${group.command}`);
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user