feat: add pr create and comment (task 0010)
`pr create` takes --title (required), --body/--body-file, --base, --head,
--assignee, --reviewer, repeatable name-resolved --label, and --milestone.
An omitted --head defaults to the current local branch; an omitted --base to
the repository's default branch. Before creating, an existing open PR for the
same base/head pair short-circuits to `pull_request: { number, url, already:
true }` rather than opening a duplicate; only an open PR does, since a closed
one's branches are free to be proposed again.
`pr comment <n>` posts through the shared issue-comment endpoint and returns
the created comment as `comment: { number, author, created, body }` (ADR 0008),
reporting a 404 as PR_NOT_FOUND since the caller asked about a pull request.
That comment block is now built in one place (src/comment.ts) for both issue
and pr comment, as ADR 0008 requires them to stay identical.
This commit was merged in pull request #4.
This commit is contained in:
@@ -14,10 +14,53 @@ Success output is the action block `created: { number, url }` — action block w
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `pr create --title` creates a PR and outputs `created: { number, url }`
|
||||
- [ ] Omitted `--head` resolves to the current local branch; omitted `--base` resolves to the repo's default branch
|
||||
- [ ] An existing open PR for the same branch pair short-circuits to `pull_request: { number, url, already: true }` with no duplicate created
|
||||
- [ ] `--label` and `--milestone` resolve names case-insensitively with `VALIDATION_ERROR` on unknown names; `--assignee` and `--reviewer` pass through
|
||||
- [ ] `pr comment <n> --body` outputs `comment: { number, author, created, body }` from the POST response, body truncated at 800 chars
|
||||
- [ ] Missing required inputs (`--title` on create, body on comment) fail with `VALIDATION_ERROR` (exit 2) before any API call
|
||||
- [ ] Fixture-server tests cover creation with defaults, the idempotent short-circuit, name resolution failures, and the comment output shape
|
||||
- [x] `pr create --title` creates a PR and outputs `created: { number, url }`
|
||||
- [x] Omitted `--head` resolves to the current local branch; omitted `--base` resolves to the repo's default branch
|
||||
- [x] An existing open PR for the same branch pair short-circuits to `pull_request: { number, url, already: true }` with no duplicate created
|
||||
- [x] `--label` and `--milestone` resolve names case-insensitively with `VALIDATION_ERROR` on unknown names; `--assignee` and `--reviewer` pass through
|
||||
- [x] `pr comment <n> --body` outputs `comment: { number, author, created, body }` from the POST response, body truncated at 800 chars
|
||||
- [x] Missing required inputs (`--title` on create, body on comment) fail with `VALIDATION_ERROR` (exit 2) before any API call
|
||||
- [x] Fixture-server tests cover creation with defaults, the idempotent short-circuit, name resolution failures, and the comment output shape
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
**Reused wholesale from task 0004.**
|
||||
`resolveBodySource`/`requireBodySource`, `resolveLabelIds`/`resolveMilestoneId`, and the `repeatable` flag kind all carried over untouched — `pr create` added no new shared machinery of its own beyond what is listed below.
|
||||
|
||||
**New shared machinery.**
|
||||
`src/comment.ts` (`COMMENT_FLAGS`, `commentItem`) now owns the `comment: { number, author, created, body }` block that ADR 0008 requires `issue comment` and `pr comment` to emit identically; it existed twice after the first draft, which is exactly the drift that ADR forbids, so it was extracted and `issue comment` moved onto it.
|
||||
`parsePositionalNumber` (in `src/flags.ts`) replaces `issue.ts`'s private `parseIssueNumber`, taking the noun ("issue", "pull request") as a parameter; the issue-side messages are unchanged.
|
||||
`httpStatus` (in `src/errors.ts`) exposes the status of a failed call for the callers that give one status a meaning of their own before falling back to `classifyHttpError`.
|
||||
`currentBranch` (in `src/git.ts`) reads `git rev-parse --abbrev-ref HEAD`, as the spec names.
|
||||
|
||||
**A closed PR for the same branch pair does not short-circuit.**
|
||||
Gitea's by-base-head lookup matches on the branches alone, so it can answer with a closed or merged PR.
|
||||
The spec says the check is for "an existing *open* PR", and the branches of a closed one are free to be proposed again, so only an open PR short-circuits.
|
||||
|
||||
**Names are resolved before the existence check, not after.**
|
||||
Whether a label name is real does not depend on remote state, so a typo is reported the same way whether or not the PR already exists.
|
||||
The alternative ordering saves one API call on the short-circuit path but makes a misspelled `--label` fail on the first run and pass silently on the second.
|
||||
|
||||
**Deviation: `pr comment` also accepts `--full`.**
|
||||
The spec lists only `--body`/`--body-file` for it, but the shared 800-char truncation hint reads "use `--full` to see complete body", and without the flag that hint names a command that errors out.
|
||||
This is the same deviation, for the same reason, that `issue comment` took in task 0004.
|
||||
|
||||
**Deviation: a 404 from `pr comment` is `PR_NOT_FOUND`, not `ISSUE_NOT_FOUND`.**
|
||||
The spec's status table classifies 404s by path, and PR comments go through `/issues/{index}/comments`, which would report a missing PR as a missing issue.
|
||||
The table's own header is "HTTP status | Context | Error code", and the command knows its target is a pull request, so the calling context wins over the path here.
|
||||
|
||||
**Deviation: next-step suggestions point at `pr comment`, not `pr view`.**
|
||||
gh-axi's reference suggests `pr view <id>` after both commands, but `pr view` does not exist until task 0009, and task 0004 already established that this tool does not hand back a command guaranteed to fail.
|
||||
**Follow-up:** task 0009 should upgrade the `pr create` and `pr comment` help lines to `pr view` once it lands.
|
||||
|
||||
**Beyond the ask: the end-to-end tier.**
|
||||
The criteria call only for fixture-server tests, but fixtures can only replay an answer they were told to give, and the whole idempotency check rests on how live Gitea's by-base-head lookup actually behaves (404 when no PR matches; the open PR when one does).
|
||||
`test/e2e/mutations.test.ts` now seeds a branch and asserts both against a live instance, so a wrong assumption fails CI rather than surfacing as a duplicate PR.
|
||||
The two e2e suites share one provisioned instance via `instanceOnce()`.
|
||||
|
||||
**Review findings left unaddressed.**
|
||||
The optional-field payload assembly in `prCreate` mirrors `issueCreate`'s, and `repoOnBranch`/`gitEnv` in `test/pr-create.test.ts` overlap with `detection.test.ts`'s private git-sandbox helpers; both are shapes rather than logic, and collapsing them would mean either a generic `assignDefined` helper or dragging the fake-`tea` sandbox machinery into `harness.ts`.
|
||||
Left alone deliberately, to be revisited if a third caller appears.
|
||||
|
||||
**Follow-up worth flagging.**
|
||||
Coverage is now 95.9% statements / 90.1% branches against thresholds of 92/87; the ratchet in `vitest.config.ts` invites raising them, but that belongs in its own commit rather than a feature task, as the last raise was.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
|
||||
import { issueCommand } from "./commands/issue.js";
|
||||
import { prCommand } from "./commands/pr.js";
|
||||
import { resolveRepoContext } from "./context.js";
|
||||
import type { CliDeps, GlobalFlags } from "./deps.js";
|
||||
import { consumeFlagValue, splitFlag } from "./flags.js";
|
||||
@@ -16,6 +17,8 @@ commands:
|
||||
issue view Show a single issue's details
|
||||
issue create Create an issue
|
||||
issue comment Post a comment on an issue or pull request
|
||||
pr create Create a pull request
|
||||
pr comment Post a comment on a pull request
|
||||
|
||||
global flags:
|
||||
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||
@@ -111,6 +114,7 @@ export async function runCli(options: RunCliOptions): Promise<number> {
|
||||
topLevelHelp: TOP_LEVEL_HELP,
|
||||
commands: {
|
||||
issue: issueCommand(deps),
|
||||
pr: prCommand(deps),
|
||||
},
|
||||
home: homeCommand(deps),
|
||||
stdout: options.stdout,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { Comment, CreateIssueOption, Issue } from "gitea-js";
|
||||
import {
|
||||
BODY_TRUNCATE_LIMIT,
|
||||
COMMENT_TRUNCATE_LIMIT,
|
||||
truncateBody,
|
||||
} from "../body.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 { COMMENT_FLAGS, commentItem } from "../comment.js";
|
||||
import { resolveRepoContext, type RepoContext } from "../context.js";
|
||||
import type { CliDeps } from "../deps.js";
|
||||
import { axiError, classifyHttpError } from "../errors.js";
|
||||
@@ -18,7 +15,7 @@ import {
|
||||
selectExtraFields,
|
||||
type FieldDef,
|
||||
} from "../fields.js";
|
||||
import { flagValue, parseFlags } from "../flags.js";
|
||||
import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js";
|
||||
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
||||
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
|
||||
import { relativeTime } from "../time.js";
|
||||
@@ -214,32 +211,6 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
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>\``,
|
||||
]);
|
||||
}
|
||||
if (positionals.length > 1) {
|
||||
throw axiError(
|
||||
`Unexpected argument: ${positionals[1]}`,
|
||||
"VALIDATION_ERROR",
|
||||
helpSuggestion,
|
||||
);
|
||||
}
|
||||
const raw = positionals[0]!;
|
||||
const number = Number(raw);
|
||||
if (!Number.isInteger(number) || number < 1) {
|
||||
throw axiError(
|
||||
`Invalid issue number: ${raw} (expected a positive integer)`,
|
||||
"VALIDATION_ERROR",
|
||||
helpSuggestion,
|
||||
);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
// The default detail fields reuse the same declarative extraction as the list
|
||||
// path; only `body` (truncation) and `comment_count` need bespoke handling.
|
||||
const ISSUE_VIEW_FIELDS: FieldDef<Issue>[] = [
|
||||
@@ -309,7 +280,7 @@ async function issueView(deps: CliDeps, args: string[]): Promise<string> {
|
||||
{ "--comments": { takesValue: false }, "--full": { takesValue: false } },
|
||||
"issue view",
|
||||
);
|
||||
const number = parseIssueNumber(positionals, "issue view");
|
||||
const number = parsePositionalNumber(positionals, "issue view", "issue");
|
||||
const full = flags["--full"] === true;
|
||||
const withComments = flags["--comments"] === true;
|
||||
|
||||
@@ -460,16 +431,8 @@ 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 { flags, positionals } = parseFlags(args, COMMENT_FLAGS, "issue comment");
|
||||
const number = parsePositionalNumber(positionals, "issue comment", "issue");
|
||||
const body = requireBodySource(deps, flags, "issue comment");
|
||||
const full = flags["--full"] === true;
|
||||
|
||||
@@ -487,14 +450,7 @@ async function issueComment(deps: CliDeps, args: string[]): Promise<string> {
|
||||
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),
|
||||
};
|
||||
const item = commentItem(comment, { number, full, host: context.host, now: new Date() });
|
||||
|
||||
// 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
|
||||
|
||||
311
src/commands/pr.ts
Normal file
311
src/commands/pr.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { Comment, CreatePullRequestOption, PullRequest, Repository } from "gitea-js";
|
||||
import { requireBodySource, resolveBodySource } from "../body-source.js";
|
||||
import { createClient, type GiteaClient } from "../client.js";
|
||||
import { COMMENT_FLAGS, commentItem } from "../comment.js";
|
||||
import { resolveRepoContext, type RepoContext } from "../context.js";
|
||||
import type { CliDeps } from "../deps.js";
|
||||
import { axiError, classifyHttpError, httpStatus } from "../errors.js";
|
||||
import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js";
|
||||
import { currentBranch } from "../git.js";
|
||||
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
||||
import { renderDetail } from "../render.js";
|
||||
import { suggestCommand } from "../suggestions.js";
|
||||
|
||||
export const PR_HELP = `usage: gitea-axi pr <command> [flags]
|
||||
|
||||
commands:
|
||||
create Create a pull request
|
||||
comment Post a comment on a pull request
|
||||
|
||||
Run \`gitea-axi pr <command> --help\` for the flags of a command.
|
||||
`;
|
||||
|
||||
export const PR_CREATE_HELP = `usage: gitea-axi pr create --title <text> [flags]
|
||||
|
||||
Create a pull request in the current repository. An open pull request already
|
||||
existing for the same base and head branches is reported instead of duplicated.
|
||||
|
||||
flags:
|
||||
--title <text> Pull request title (required)
|
||||
--body <text> Pull request body
|
||||
--body-file <path> Read the body from a file (mutually exclusive with --body)
|
||||
--base <branch> Branch to merge into (default: the repository's default branch)
|
||||
--head <branch> Branch to merge from (default: the current local branch)
|
||||
--assignee <login> Assign the pull request to a user
|
||||
--reviewer <login> Request a review from a user
|
||||
--label <name> Apply a label by name (repeatable, case-insensitive)
|
||||
--milestone <name> Assign a milestone by name (case-insensitive)
|
||||
--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 PR_COMMENT_HELP = `usage: gitea-axi pr comment <number> [flags]
|
||||
|
||||
Post a comment on a pull request.
|
||||
|
||||
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
|
||||
`;
|
||||
|
||||
const PR_CREATE_HELP_SUGGESTION = [
|
||||
"Run `gitea-axi pr create --help` to see available flags",
|
||||
];
|
||||
|
||||
/** The branch to merge from: the caller's `--head`, else the local checkout's. */
|
||||
async function resolveHead(deps: CliDeps, head: string | undefined): Promise<string> {
|
||||
if (head !== undefined) {
|
||||
return head;
|
||||
}
|
||||
const branch = await currentBranch(deps);
|
||||
if (branch === null) {
|
||||
throw axiError(
|
||||
"Could not determine the current branch to use as the head branch",
|
||||
"VALIDATION_ERROR",
|
||||
["Pass `--head <branch>` to name the branch to merge from"],
|
||||
);
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
/** The branch to merge into: the caller's `--base`, else the repository's default. */
|
||||
async function resolveBase(
|
||||
api: GiteaClient,
|
||||
context: RepoContext,
|
||||
base: string | undefined,
|
||||
): Promise<string> {
|
||||
if (base !== undefined) {
|
||||
return base;
|
||||
}
|
||||
let repo: Repository;
|
||||
try {
|
||||
const response = await api.repos.repoGet(context.owner, context.name);
|
||||
repo = response.data;
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
if (!repo.default_branch) {
|
||||
throw axiError(
|
||||
`Repository ${context.owner}/${context.name} reports no default branch`,
|
||||
"VALIDATION_ERROR",
|
||||
["Pass `--base <branch>` to name the branch to merge into"],
|
||||
);
|
||||
}
|
||||
return repo.default_branch;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open pull request for a base/head pair, or undefined when there is none.
|
||||
* Gitea answers its by-base-head lookup with a 404 when no pull request matches
|
||||
* the pair at all, which is the ordinary "nothing to short-circuit to" case
|
||||
* rather than a failure. The lookup matches on the branches alone, so a closed
|
||||
* or merged pull request can come back too — that must not block a fresh one,
|
||||
* since its branches are free to be proposed again.
|
||||
*/
|
||||
async function findOpenPull(
|
||||
api: GiteaClient,
|
||||
context: RepoContext,
|
||||
base: string,
|
||||
head: string,
|
||||
): Promise<PullRequest | undefined> {
|
||||
let pull: PullRequest;
|
||||
try {
|
||||
const response = await api.repos.repoGetPullRequestByBaseHead(
|
||||
context.owner,
|
||||
context.name,
|
||||
base,
|
||||
head,
|
||||
);
|
||||
pull = response.data;
|
||||
} catch (error) {
|
||||
if (httpStatus(error) === 404) {
|
||||
return undefined;
|
||||
}
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
return pull.state === "open" ? pull : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number Gitea gave a pull request. The generated client types it optional,
|
||||
* but every real pull request has one, and a number invented to fill the gap
|
||||
* would be reported as fact and interpolated into the next command to run — so a
|
||||
* response without one is treated as the broken answer it is.
|
||||
*/
|
||||
function pullNumber(pull: PullRequest): number {
|
||||
if (pull.number === undefined) {
|
||||
throw axiError("Gitea returned a pull request with no number", "UNKNOWN");
|
||||
}
|
||||
return pull.number;
|
||||
}
|
||||
|
||||
async function prCreate(deps: CliDeps, args: string[]): Promise<string> {
|
||||
if (args.includes("--help")) {
|
||||
return PR_CREATE_HELP;
|
||||
}
|
||||
const { flags, lists, positionals } = parseFlags(
|
||||
args,
|
||||
{
|
||||
"--title": { takesValue: true },
|
||||
"--body": { takesValue: true },
|
||||
"--body-file": { takesValue: true },
|
||||
"--base": { takesValue: true },
|
||||
"--head": { takesValue: true },
|
||||
"--assignee": { takesValue: true },
|
||||
"--reviewer": { takesValue: true },
|
||||
"--label": { takesValue: true, repeatable: true },
|
||||
"--milestone": { takesValue: true },
|
||||
},
|
||||
"pr create",
|
||||
);
|
||||
if (positionals.length > 0) {
|
||||
throw axiError(
|
||||
`Unexpected argument: ${positionals[0]}`,
|
||||
"VALIDATION_ERROR",
|
||||
PR_CREATE_HELP_SUGGESTION,
|
||||
);
|
||||
}
|
||||
|
||||
// Everything that can fail on the caller's own input — including the head
|
||||
// branch, which git alone can answer — is settled before any request goes out,
|
||||
// so a rejected invocation never half-creates a pull request.
|
||||
const title = flagValue(flags, "--title");
|
||||
if (title === undefined) {
|
||||
throw axiError("pr create requires --title <text>", "VALIDATION_ERROR", [
|
||||
"Run `gitea-axi pr create --title <text>`",
|
||||
]);
|
||||
}
|
||||
const body = resolveBodySource(deps, flags, "pr create");
|
||||
const assignee = flagValue(flags, "--assignee");
|
||||
const reviewer = flagValue(flags, "--reviewer");
|
||||
const milestoneName = flagValue(flags, "--milestone");
|
||||
const labelNames = lists["--label"] ?? [];
|
||||
const head = await resolveHead(deps, flagValue(flags, "--head"));
|
||||
|
||||
const context = await resolveRepoContext(deps);
|
||||
const api = createClient(context);
|
||||
const base = await resolveBase(api, context, flagValue(flags, "--base"));
|
||||
|
||||
// Names are resolved before the existence check, not after: whether a label
|
||||
// name is real does not depend on remote state, so a typo must be reported the
|
||||
// same way whether or not the pull request happens to exist already.
|
||||
const labelIds = await resolveLabelIds(api, context, labelNames);
|
||||
const milestoneId =
|
||||
milestoneName !== undefined
|
||||
? await resolveMilestoneId(api, context, milestoneName)
|
||||
: undefined;
|
||||
|
||||
const existing = await findOpenPull(api, context, base, head);
|
||||
if (existing) {
|
||||
const number = pullNumber(existing);
|
||||
return renderDetail({
|
||||
noun: "pull_request",
|
||||
item: { number, url: existing.html_url ?? "", already: true },
|
||||
help: [
|
||||
suggestCommand(
|
||||
context,
|
||||
`pr comment ${number} --body <text>`,
|
||||
"to comment on the existing pull request",
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const payload: CreatePullRequestOption = { title, base, head };
|
||||
if (body !== undefined) {
|
||||
payload.body = body;
|
||||
}
|
||||
if (assignee !== undefined) {
|
||||
payload.assignees = [assignee];
|
||||
}
|
||||
if (reviewer !== undefined) {
|
||||
payload.reviewers = [reviewer];
|
||||
}
|
||||
if (labelIds.length > 0) {
|
||||
payload.labels = labelIds;
|
||||
}
|
||||
if (milestoneId !== undefined) {
|
||||
payload.milestone = milestoneId;
|
||||
}
|
||||
|
||||
let pull: PullRequest;
|
||||
try {
|
||||
const response = await api.repos.repoCreatePullRequest(context.owner, context.name, payload);
|
||||
pull = response.data;
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
|
||||
// The mutation ran, so the block is named for the action; the no-op path above
|
||||
// reports the entity instead.
|
||||
const number = pullNumber(pull);
|
||||
return renderDetail({
|
||||
noun: "created",
|
||||
item: { number, url: pull.html_url ?? "" },
|
||||
help: [
|
||||
suggestCommand(context, `pr comment ${number} --body <text>`, "to comment on the pull request"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function prComment(deps: CliDeps, args: string[]): Promise<string> {
|
||||
if (args.includes("--help")) {
|
||||
return PR_COMMENT_HELP;
|
||||
}
|
||||
const { flags, positionals } = parseFlags(args, COMMENT_FLAGS, "pr comment");
|
||||
const number = parsePositionalNumber(positionals, "pr comment", "pull request");
|
||||
const body = requireBodySource(deps, flags, "pr comment");
|
||||
const full = flags["--full"] === true;
|
||||
|
||||
const context = await resolveRepoContext(deps);
|
||||
const api = createClient(context);
|
||||
let comment: Comment;
|
||||
try {
|
||||
// Pull requests share the issue comment endpoint, and it answers with the
|
||||
// created comment — no follow-up view call is needed to report it (ADR 0008).
|
||||
const response = await api.repos.issueCreateComment(context.owner, context.name, number, {
|
||||
body,
|
||||
});
|
||||
comment = response.data;
|
||||
} catch (error) {
|
||||
// The endpoint's path says `issues`, but the caller asked about a pull
|
||||
// request, so a missing target is reported as the pull request it is.
|
||||
if (httpStatus(error) === 404) {
|
||||
throw axiError(`Pull request #${number} not found`, "PR_NOT_FOUND");
|
||||
}
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
|
||||
return renderDetail({
|
||||
noun: "comment",
|
||||
item: commentItem(comment, { number, full, host: context.host, now: new Date() }),
|
||||
help: [suggestCommand(context, "pr comment --help", "to see all pr comment flags")],
|
||||
});
|
||||
}
|
||||
|
||||
export function prCommand(deps: CliDeps) {
|
||||
return async (args: string[]): Promise<string> => {
|
||||
const [subcommand, ...rest] = args;
|
||||
if (!subcommand || subcommand === "--help") {
|
||||
return PR_HELP;
|
||||
}
|
||||
if (subcommand === "create") {
|
||||
return prCreate(deps, rest);
|
||||
}
|
||||
if (subcommand === "comment") {
|
||||
return prComment(deps, rest);
|
||||
}
|
||||
throw axiError(`Unknown pr command: ${subcommand}`, "VALIDATION_ERROR", [
|
||||
"Run `gitea-axi pr --help` to see available pr commands",
|
||||
]);
|
||||
};
|
||||
}
|
||||
40
src/comment.ts
Normal file
40
src/comment.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Comment } from "gitea-js";
|
||||
import { COMMENT_TRUNCATE_LIMIT, truncateBody } from "./body.js";
|
||||
import type { FlagSpec } from "./flags.js";
|
||||
import { relativeTime } from "./time.js";
|
||||
|
||||
/**
|
||||
* The one comment-posting shape `issue comment` and `pr comment` share. ADR 0008
|
||||
* requires both to emit the same `comment` block with the same schema, so the
|
||||
* block is built in exactly one place; what the two commands genuinely differ on
|
||||
* — how a missing target is reported, and what to suggest next — stays with them.
|
||||
*/
|
||||
|
||||
export const COMMENT_FLAGS: FlagSpec = {
|
||||
"--body": { takesValue: true },
|
||||
"--body-file": { takesValue: true },
|
||||
"--full": { takesValue: false },
|
||||
};
|
||||
|
||||
export interface CommentItemOptions {
|
||||
/** The issue or pull request commented on — not the comment's own id. */
|
||||
number: number;
|
||||
/** Echo the body untruncated, as `--full` asks. */
|
||||
full: boolean;
|
||||
host: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
/** The `comment: { number, author, created, body }` block, body truncated at 800 chars. */
|
||||
export function commentItem(
|
||||
comment: Comment,
|
||||
options: CommentItemOptions,
|
||||
): Record<string, unknown> {
|
||||
const body = comment.body ?? "";
|
||||
return {
|
||||
number: options.number,
|
||||
author: comment.user?.login ?? "",
|
||||
created: relativeTime(comment.created_at, options.now),
|
||||
body: options.full ? body : truncateBody(body, COMMENT_TRUNCATE_LIMIT, options.host),
|
||||
};
|
||||
}
|
||||
@@ -87,6 +87,17 @@ function classify404(response: HttpResponseLike): AxiError {
|
||||
return axiError(`Not found: ${path}`, "UNKNOWN");
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP status of a failed API call, or undefined if the failure was not an
|
||||
* HTTP response at all. For the callers that give one status a meaning of their
|
||||
* own before falling back to {@link classifyHttpError} — a 404 from Gitea's
|
||||
* by-base-head pull lookup, for instance, means "no such pull request exists",
|
||||
* which is an ordinary answer rather than an error.
|
||||
*/
|
||||
export function httpStatus(error: unknown): number | undefined {
|
||||
return isHttpResponseLike(error) ? error.status : undefined;
|
||||
}
|
||||
|
||||
export function classifyHttpError(error: unknown): AxiError {
|
||||
if (error instanceof AxiError) {
|
||||
return error;
|
||||
|
||||
38
src/flags.ts
38
src/flags.ts
@@ -70,6 +70,44 @@ export function consumeFlagValue(
|
||||
return { value: next, lastIndex: index + 1 };
|
||||
}
|
||||
|
||||
/** "issue" → "an issue"; "pull request" → "a pull request". */
|
||||
function withArticle(noun: string): string {
|
||||
return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the single positional number of a `<command> <number>` invocation.
|
||||
* `noun` names what the number identifies ("issue", "pull request") and appears
|
||||
* in the errors; the parsing itself is identical for both.
|
||||
*/
|
||||
export function parsePositionalNumber(
|
||||
positionals: string[],
|
||||
command: string,
|
||||
noun: string,
|
||||
): number {
|
||||
const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`];
|
||||
if (positionals.length === 0) {
|
||||
throw axiError(
|
||||
`${command} requires ${withArticle(noun)} number`,
|
||||
"VALIDATION_ERROR",
|
||||
[`Run \`gitea-axi ${command} <number>\``],
|
||||
);
|
||||
}
|
||||
if (positionals.length > 1) {
|
||||
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion);
|
||||
}
|
||||
const raw = positionals[0]!;
|
||||
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(
|
||||
args: string[],
|
||||
spec: FlagSpec,
|
||||
|
||||
19
src/git.ts
19
src/git.ts
@@ -43,6 +43,25 @@ export function parseRemoteUrl(url: string): RemoteRepo | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch currently checked out, or null when git cannot name one — it is
|
||||
* absent, this is not a repository, or HEAD is detached. Every null case is
|
||||
* repaired the same way, by the caller naming the branch explicitly, so they
|
||||
* are not distinguished here.
|
||||
*/
|
||||
export async function currentBranch(deps: CliDeps): Promise<string | null> {
|
||||
const result = await runSubprocess("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
});
|
||||
if (result.enoent || result.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
const branch = result.stdout.trim();
|
||||
// A detached HEAD abbreviates to the literal "HEAD", which is no branch.
|
||||
return branch && branch !== "HEAD" ? branch : null;
|
||||
}
|
||||
|
||||
export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
|
||||
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
|
||||
cwd: deps.cwd,
|
||||
|
||||
@@ -3,16 +3,20 @@ import { runCliTest } from "../harness.js";
|
||||
import {
|
||||
fetchComments,
|
||||
fetchIssue,
|
||||
fetchOpenPulls,
|
||||
provisionInstance,
|
||||
seedBranch,
|
||||
type E2EInstance,
|
||||
} from "./provision.js";
|
||||
|
||||
/**
|
||||
* The end-to-end tier for the issue mutations. These commands lean on behavior
|
||||
* the fixture server cannot attest to — above all that Gitea's label and
|
||||
* milestone name lookups really are case-insensitive, and that `CreateIssueOption`
|
||||
* really takes label *ids* rather than names. Both are asserted here against a
|
||||
* live instance by passing names in a different case than they were seeded in.
|
||||
* The end-to-end tier for the issue and pull request mutations. These commands
|
||||
* lean on behavior the fixture server cannot attest to — that Gitea's label and
|
||||
* milestone name lookups really are case-insensitive, that `CreateIssueOption`
|
||||
* really takes label *ids* rather than names, and that the by-base-head pull
|
||||
* lookup behind `pr create`'s idempotency check really answers a 404 when no
|
||||
* pull request exists for the pair and the open one when it does. Each is
|
||||
* asserted here against a live instance rather than a recorded shape.
|
||||
*/
|
||||
const E2E_URL = process.env.GITEA_AXI_E2E_URL;
|
||||
|
||||
@@ -23,10 +27,18 @@ function renderedNumber(stdout: string): number {
|
||||
return Number(match![1]);
|
||||
}
|
||||
|
||||
describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => {
|
||||
let instance: E2EInstance;
|
||||
/**
|
||||
* One provisioned instance for every suite in this file: the suites run
|
||||
* sequentially within the file, and sharing the instance keeps the bootstrap
|
||||
* (which registers the site administrator) to a single run.
|
||||
*/
|
||||
let provisioned: Promise<E2EInstance> | undefined;
|
||||
function instanceOnce(): Promise<E2EInstance> {
|
||||
provisioned ??= provisionInstance(E2E_URL!);
|
||||
return provisioned;
|
||||
}
|
||||
|
||||
function env(): Record<string, string> {
|
||||
function envFor(instance: E2EInstance): Record<string, string> {
|
||||
return {
|
||||
GITEA_AXI_API_URL: instance.baseUrl,
|
||||
GITEA_AXI_TOKEN: instance.token,
|
||||
@@ -34,8 +46,15 @@ describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => {
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => {
|
||||
let instance: E2EInstance;
|
||||
|
||||
function env(): Record<string, string> {
|
||||
return envFor(instance);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
instance = await provisionInstance(E2E_URL!);
|
||||
instance = await instanceOnce();
|
||||
}, 150_000);
|
||||
|
||||
it("creates an issue and reports the live number, state, and url", async () => {
|
||||
@@ -119,3 +138,81 @@ describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => {
|
||||
expect(comments[0]!.body).toBe("A comment from the e2e tier.");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!E2E_URL)("end-to-end: pull request mutations", () => {
|
||||
let instance: E2EInstance;
|
||||
const branch = "e2e-pr-branch";
|
||||
|
||||
function env(): Record<string, string> {
|
||||
return envFor(instance);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
instance = await instanceOnce();
|
||||
// The head branch has to exist, with a diff to propose, before a pull
|
||||
// request can be opened from it.
|
||||
await seedBranch(instance, branch);
|
||||
}, 150_000);
|
||||
|
||||
it("creates a pull request, defaulting the base to the live repo's default branch", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "E2E created PR", "--head", branch, "--body", "From the e2e tier."],
|
||||
{ env: env() },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("created:");
|
||||
expect(stdout).toContain(`${instance.owner}/${instance.repo}/pulls/`);
|
||||
|
||||
const pulls = await fetchOpenPulls(instance);
|
||||
expect(pulls).toHaveLength(1);
|
||||
const created = pulls[0]!;
|
||||
expect(created.number).toBe(renderedNumber(stdout));
|
||||
expect(created.title).toBe("E2E created PR");
|
||||
// The base was never passed: it came from the repository's own default branch.
|
||||
expect((created.base as { ref?: string }).ref).toBe("main");
|
||||
expect((created.head as { ref?: string }).ref).toBe(branch);
|
||||
});
|
||||
|
||||
it("short-circuits a second create for the same branch pair, creating no duplicate", async () => {
|
||||
// Whether Gitea's by-base-head lookup really finds the pull request opened
|
||||
// above is the assumption the whole idempotency check rests on; fixtures can
|
||||
// only assert the shape of an answer they were told to give.
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "E2E duplicate PR", "--head", branch],
|
||||
{ env: env() },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("pull_request:");
|
||||
expect(stdout).toContain("already: true");
|
||||
expect(stdout).not.toContain("created:");
|
||||
|
||||
const pulls = await fetchOpenPulls(instance);
|
||||
expect(pulls).toHaveLength(1);
|
||||
// The existing pull request is reported untouched — not retitled, not replaced.
|
||||
expect(pulls[0]!.title).toBe("E2E created PR");
|
||||
});
|
||||
|
||||
it("posts a comment on a live pull request and echoes it back", async () => {
|
||||
const pulls = await fetchOpenPulls(instance);
|
||||
const number = pulls[0]!.number as number;
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "comment", String(number), "--body", "A PR comment from the e2e tier."],
|
||||
{ env: env() },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("comment:");
|
||||
expect(stdout).toContain(`number: ${number}`);
|
||||
expect(stdout).toContain(`author: ${instance.owner}`);
|
||||
expect(stdout).toContain("body: A PR comment from the e2e tier.");
|
||||
|
||||
// Pull requests really do share the issue comment endpoint, so the comment
|
||||
// is readable back through it.
|
||||
const comments = await fetchComments(instance, number);
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]!.body).toBe("A PR comment from the e2e tier.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +219,37 @@ export async function provisionInstance(baseUrl: string): Promise<E2EInstance> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create `branch` off the default branch, carrying one new file, so that a pull
|
||||
* request opened from it has a real diff to propose.
|
||||
*/
|
||||
export async function seedBranch(instance: E2EInstance, branch: string): Promise<void> {
|
||||
await apiRequest(
|
||||
instance.baseUrl,
|
||||
"POST",
|
||||
`/repos/${instance.owner}/${instance.repo}/contents/${branch}.txt`,
|
||||
instance.token,
|
||||
{
|
||||
content: Buffer.from(`Seeded on ${branch}.\n`).toString("base64"),
|
||||
message: `Seed ${branch}`,
|
||||
new_branch: branch,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch the repository's open pull requests as Gitea returns them. */
|
||||
export async function fetchOpenPulls(
|
||||
instance: E2EInstance,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const res = await apiRequest(
|
||||
instance.baseUrl,
|
||||
"GET",
|
||||
`/repos/${instance.owner}/${instance.repo}/pulls?state=open`,
|
||||
instance.token,
|
||||
);
|
||||
return (await res.json()) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */
|
||||
export async function fetchIssue(
|
||||
instance: E2EInstance,
|
||||
|
||||
@@ -29,6 +29,23 @@ describe("--help", () => {
|
||||
expect(stdout).toContain("list");
|
||||
});
|
||||
|
||||
it("prints the pr group help and exits 0", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "--help"]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("usage: gitea-axi pr");
|
||||
expect(stdout).toContain("create");
|
||||
expect(stdout).toContain("comment");
|
||||
});
|
||||
|
||||
it("rejects an unknown pr subcommand with exit code 2", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "frobnicate"]);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("frobnicate");
|
||||
});
|
||||
|
||||
it("prints the version for --version", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["--version"]);
|
||||
|
||||
|
||||
145
test/pr-comment.test.ts
Normal file
145
test/pr-comment.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||
import { postedBody, runCliTest, tempFiles, testModeEnv } from "./harness.js";
|
||||
|
||||
// Pull request comments go through the shared issue-comment endpoint.
|
||||
const COMMENTS_PATH = "/api/v1/repos/testowner/testrepo/issues/12/comments";
|
||||
|
||||
let server: FixtureServer;
|
||||
const files = tempFiles();
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
files.cleanup();
|
||||
});
|
||||
|
||||
function createdComment(fields: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 900,
|
||||
user: { login: "alexion" },
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
body: "Looks good to me.",
|
||||
pull_request_url: "http://127.0.0.1/testowner/testrepo/pulls/12",
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function postedComment(): Record<string, unknown> {
|
||||
return postedBody(server, COMMENTS_PATH);
|
||||
}
|
||||
|
||||
describe("pr comment", () => {
|
||||
it("posts the comment and renders number, author, created, and body", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment() },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "comment", "12", "--body", "Looks good to me."],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// The same `comment` block name as `issue comment`, per ADR 0008.
|
||||
expect(stdout).toContain("comment:");
|
||||
// `number` is the PR commented on, not the comment's own id.
|
||||
expect(stdout).toContain("number: 12");
|
||||
expect(stdout).toContain("author: alexion");
|
||||
expect(stdout).toContain("body: Looks good to me.");
|
||||
expect(stdout).not.toContain("900");
|
||||
expect(postedComment()).toEqual({ body: "Looks good to me." });
|
||||
});
|
||||
|
||||
it("reads the body from --body-file", async () => {
|
||||
const path = files.write("comment.md", "From a file.");
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment() },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["pr", "comment", "12", "--body-file", path], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(postedComment()).toEqual({ body: "From a file." });
|
||||
});
|
||||
|
||||
it("truncates a body over 800 chars in the output, with the inline hint", async () => {
|
||||
const body = "z".repeat(1000);
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment({ body }) },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "comment", "12", "--body", body], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(
|
||||
`... (truncated, ${body.length} chars total - use --full to see complete body)`,
|
||||
);
|
||||
// The posted body itself is never truncated — only its echo in the output.
|
||||
expect(postedComment()).toEqual({ body });
|
||||
});
|
||||
|
||||
it("echoes the untruncated body with --full", async () => {
|
||||
const body = "z".repeat(1000);
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment({ body }) },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "comment", "12", "--body", body, "--full"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(body);
|
||||
expect(stdout).not.toContain("truncated");
|
||||
});
|
||||
|
||||
it("rejects a missing body before calling the API", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "comment", "12"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("--body");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a missing pull request number before calling the API", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "comment", "--body", "hi"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("pull request number");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports a nonexistent pull request as PR_NOT_FOUND, not ISSUE_NOT_FOUND", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: COMMENTS_PATH, status: 404, body: { message: "Not Found" } },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "comment", "12", "--body", "hi"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
// The shared endpoint lives under /issues/, but the caller asked about a PR.
|
||||
expect(stdout).toContain("code: PR_NOT_FOUND");
|
||||
expect(stdout).toContain("Pull request #12");
|
||||
});
|
||||
|
||||
it("prints help with --help without calling the API", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "comment", "--help"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("usage: gitea-axi pr comment <number>");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
370
test/pr-create.test.ts
Normal file
370
test/pr-create.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||
import { postedBody, runCliTest, tempFiles, testModeEnv } from "./harness.js";
|
||||
|
||||
const REPO_PATH = "/api/v1/repos/testowner/testrepo";
|
||||
const PULLS_PATH = `${REPO_PATH}/pulls`;
|
||||
const LABELS_PATH = `${REPO_PATH}/labels`;
|
||||
const MILESTONES_PATH = `${REPO_PATH}/milestones`;
|
||||
/** The by-base-head lookup Gitea exposes for the idempotency check. */
|
||||
const BASE_HEAD_PATH = `${PULLS_PATH}/main/feature-x`;
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "gitea-axi-pr-"));
|
||||
let repoCounter = 0;
|
||||
|
||||
let server: FixtureServer;
|
||||
const files = tempFiles();
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
files.cleanup();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* A real git repository checked out on `branch`, for the tests that exercise
|
||||
* the `--head` default. It needs a commit: `git rev-parse --abbrev-ref HEAD`
|
||||
* has no revision to resolve on an unborn branch.
|
||||
*/
|
||||
function repoOnBranch(branch: string): string {
|
||||
const dir = mkdtempSync(join(root, `repo-${repoCounter++}-`));
|
||||
execFileSync("git", ["init", "--quiet", "-b", branch], { cwd: dir });
|
||||
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet",
|
||||
"--allow-empty", "-m", "seed"], { cwd: dir });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Test-mode env plus the PATH the git subprocess needs to be found on. */
|
||||
function gitEnv(url: string): Record<string, string | undefined> {
|
||||
return { ...testModeEnv(url), PATH: process.env.PATH };
|
||||
}
|
||||
|
||||
function createdPull(fields: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
number: 12,
|
||||
title: "Add the thing",
|
||||
state: "open",
|
||||
html_url: "http://127.0.0.1/testowner/testrepo/pulls/12",
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
/** No PR exists for the branch pair — Gitea answers the by-base-head lookup with a 404. */
|
||||
const NO_EXISTING_PR = {
|
||||
method: "GET",
|
||||
path: BASE_HEAD_PATH,
|
||||
status: 404,
|
||||
body: { message: "Not Found" },
|
||||
} as const;
|
||||
|
||||
const DEFAULT_BRANCH = {
|
||||
method: "GET",
|
||||
path: REPO_PATH,
|
||||
body: { default_branch: "main" },
|
||||
} as const;
|
||||
|
||||
function postedPull(): Record<string, unknown> {
|
||||
return postedBody(server, PULLS_PATH);
|
||||
}
|
||||
|
||||
function posted(): boolean {
|
||||
return server.requests.some((request) => request.method === "POST");
|
||||
}
|
||||
|
||||
describe("pr create", () => {
|
||||
it("creates a pull request and renders the created action block", async () => {
|
||||
server = await startFixtureServer([
|
||||
NO_EXISTING_PR,
|
||||
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "Add the thing", "--base", "main", "--head", "feature-x"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// The mutation ran, so the block is named for the action, not the entity.
|
||||
expect(stdout).toContain("created:");
|
||||
expect(stdout).not.toContain("pull_request:");
|
||||
expect(stdout).toContain("number: 12");
|
||||
expect(stdout).toContain('url: "http://127.0.0.1/testowner/testrepo/pulls/12"');
|
||||
expect(postedPull()).toEqual({
|
||||
title: "Add the thing",
|
||||
base: "main",
|
||||
head: "feature-x",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults --head to the current branch and --base to the repo's default branch", async () => {
|
||||
server = await startFixtureServer([
|
||||
DEFAULT_BRANCH,
|
||||
NO_EXISTING_PR,
|
||||
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["pr", "create", "--title", "T"], {
|
||||
env: gitEnv(server.url),
|
||||
cwd: repoOnBranch("feature-x"),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(postedPull()).toEqual({ title: "T", base: "main", head: "feature-x" });
|
||||
});
|
||||
|
||||
it("short-circuits to the existing open pull request without creating a duplicate", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: BASE_HEAD_PATH, body: createdPull() },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "Add the thing", "--base", "main", "--head", "feature-x"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// A no-op reports the entity, not the action.
|
||||
expect(stdout).toContain("pull_request:");
|
||||
expect(stdout).not.toContain("created:");
|
||||
expect(stdout).toContain("number: 12");
|
||||
expect(stdout).toContain('url: "http://127.0.0.1/testowner/testrepo/pulls/12"');
|
||||
expect(stdout).toContain("already: true");
|
||||
expect(posted()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a fresh pull request when the only match for the branch pair is closed", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: BASE_HEAD_PATH,
|
||||
body: createdPull({ number: 3, state: "closed" }),
|
||||
},
|
||||
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "Add the thing", "--base", "main", "--head", "feature-x"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("created:");
|
||||
expect(stdout).toContain("number: 12");
|
||||
expect(posted()).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves --label and --milestone names and passes --assignee and --reviewer through", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: LABELS_PATH,
|
||||
body: [
|
||||
{ id: 11, name: "bug" },
|
||||
{ id: 22, name: "Priority: High" },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: MILESTONES_PATH,
|
||||
query: { name: "v1.0" },
|
||||
body: [{ id: 5, title: "V1.0" }],
|
||||
},
|
||||
NO_EXISTING_PR,
|
||||
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(
|
||||
[
|
||||
"pr", "create",
|
||||
"--title", "T",
|
||||
"--base", "main",
|
||||
"--head", "feature-x",
|
||||
"--label", "BUG",
|
||||
"--label", "priority: high",
|
||||
"--milestone", "v1.0",
|
||||
"--assignee", "alexion",
|
||||
"--reviewer", "reviewer-one",
|
||||
],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(postedPull()).toEqual({
|
||||
title: "T",
|
||||
base: "main",
|
||||
head: "feature-x",
|
||||
labels: [11, 22],
|
||||
milestone: 5,
|
||||
assignees: ["alexion"],
|
||||
reviewers: ["reviewer-one"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown --label name with VALIDATION_ERROR and creates nothing", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
|
||||
NO_EXISTING_PR,
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x", "--label", "nope"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("nope");
|
||||
expect(posted()).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown --milestone name with VALIDATION_ERROR and creates nothing", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: MILESTONES_PATH, body: [] },
|
||||
NO_EXISTING_PR,
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x", "--milestone", "ghost"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("ghost");
|
||||
expect(posted()).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the body from --body-file", async () => {
|
||||
const path = files.write("body.md", "From a file.\n");
|
||||
server = await startFixtureServer([
|
||||
NO_EXISTING_PR,
|
||||
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x", "--body-file", path],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(postedPull()).toEqual({
|
||||
title: "T",
|
||||
base: "main",
|
||||
head: "feature-x",
|
||||
body: "From a file.\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing --title before calling the API", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "create", "--base", "main", "--head", "x"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("--title");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects an unresolvable current branch with VALIDATION_ERROR, before calling the API", async () => {
|
||||
// Not a git repository, so the head branch cannot be read from git and the
|
||||
// caller must name it themselves.
|
||||
const cwd = mkdtempSync(join(root, "bare-"));
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "create", "--title", "T"], {
|
||||
env: gitEnv(server.url),
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("--head");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces a failure to fetch the default branch", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: REPO_PATH, status: 403, body: { message: "forbidden" } },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "T", "--head", "feature-x"],
|
||||
{ env: gitEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: FORBIDDEN");
|
||||
expect(posted()).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces a server-side rejection of the create", async () => {
|
||||
server = await startFixtureServer([
|
||||
NO_EXISTING_PR,
|
||||
{
|
||||
method: "POST",
|
||||
path: PULLS_PATH,
|
||||
status: 422,
|
||||
body: { message: "head branch does not exist" },
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("head branch does not exist");
|
||||
});
|
||||
|
||||
it("rejects an unexpected positional argument before calling the API", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "create", "12", "--title", "T"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("12");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces a failure of the existing-pull-request check that is not a 404", async () => {
|
||||
// Only a 404 means "no such pull request"; anything else is a real failure
|
||||
// and must not be mistaken for a clear runway to create one.
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: BASE_HEAD_PATH, status: 403, body: { message: "forbidden" } },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: FORBIDDEN");
|
||||
expect(posted()).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for --base when the repository reports no default branch", async () => {
|
||||
server = await startFixtureServer([{ method: "GET", path: REPO_PATH, body: {} }]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "create", "--title", "T", "--head", "feature-x"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("--base");
|
||||
expect(posted()).toBe(false);
|
||||
});
|
||||
|
||||
it("prints help with --help without calling the API", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "create", "--help"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("usage: gitea-axi pr create");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user