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:
109
src/lookup.ts
Normal file
109
src/lookup.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Label } from "gitea-js";
|
||||
import type { GiteaClient } from "./client.js";
|
||||
import type { RepoContext } from "./context.js";
|
||||
import { axiError, classifyHttpError } from "./errors.js";
|
||||
|
||||
/**
|
||||
* Name→ID resolution for the Gitea endpoints that only accept numeric ids.
|
||||
* Shared by every command that takes a `--label` or `--milestone` name.
|
||||
*/
|
||||
|
||||
const LABEL_PAGE_SIZE = 50;
|
||||
/** Guard against an unbounded loop if a server ignores paging and always returns a full page. */
|
||||
const LABEL_PAGE_LIMIT = 20;
|
||||
|
||||
/** Fetch every label in the repository, paging until the API runs out. */
|
||||
async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
|
||||
const labels: Label[] = [];
|
||||
for (let page = 1; page <= LABEL_PAGE_LIMIT; page++) {
|
||||
let batch: Label[];
|
||||
try {
|
||||
const response = await api.repos.issueListLabels(context.owner, context.name, {
|
||||
page,
|
||||
limit: LABEL_PAGE_SIZE,
|
||||
});
|
||||
batch = response.data ?? [];
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
labels.push(...batch);
|
||||
if (batch.length < LABEL_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve label names to their ids, case-insensitively. Every name must exist:
|
||||
* creating an issue while silently dropping a label the caller asked for would
|
||||
* misreport what was created.
|
||||
*/
|
||||
export async function resolveLabelIds(
|
||||
api: GiteaClient,
|
||||
context: RepoContext,
|
||||
names: string[],
|
||||
): Promise<number[]> {
|
||||
if (names.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const labels = await listAllLabels(api, context);
|
||||
const byName = new Map<string, number>();
|
||||
for (const label of labels) {
|
||||
if (label.name === undefined || label.id === undefined) {
|
||||
continue;
|
||||
}
|
||||
const key = label.name.toLowerCase();
|
||||
// First match wins, so a duplicate name resolves deterministically.
|
||||
if (!byName.has(key)) {
|
||||
byName.set(key, label.id);
|
||||
}
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
for (const name of names) {
|
||||
const id = byName.get(name.toLowerCase());
|
||||
if (id === undefined) {
|
||||
const available = labels
|
||||
.map((label) => label.name)
|
||||
.filter((label): label is string => Boolean(label));
|
||||
throw axiError(
|
||||
`Label "${name}" not found in ${context.owner}/${context.name}` +
|
||||
(available.length > 0 ? ` (available: ${available.join(", ")})` : ""),
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Resolve a milestone name to its id via the name-filtered milestone query. */
|
||||
export async function resolveMilestoneId(
|
||||
api: GiteaClient,
|
||||
context: RepoContext,
|
||||
name: string,
|
||||
): Promise<number> {
|
||||
let milestones;
|
||||
try {
|
||||
const response = await api.repos.issueGetMilestonesList(context.owner, context.name, {
|
||||
name,
|
||||
});
|
||||
milestones = response.data ?? [];
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
// The `name` query only narrows the candidates — the title is re-checked here
|
||||
// so that neither the caller's casing nor a looser server-side match (Gitea
|
||||
// filters with a LIKE) can resolve to a milestone the caller did not name.
|
||||
const match = milestones.find(
|
||||
(milestone) => milestone.title?.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (!match?.id) {
|
||||
throw axiError(
|
||||
`Milestone "${name}" not found in ${context.owner}/${context.name}`,
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
Reference in New Issue
Block a user