feat: add pr create and comment (task 0010) #4
@@ -14,10 +14,53 @@ Success output is the action block `created: { number, url }` — action block w
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `pr create --title` creates a PR and outputs `created: { number, url }`
|
- [x] `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
|
- [x] 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
|
- [x] 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
|
- [x] `--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
|
- [x] `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
|
- [x] 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] 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 { readFileSync } from "node:fs";
|
||||||
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
|
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
|
||||||
import { issueCommand } from "./commands/issue.js";
|
import { issueCommand } from "./commands/issue.js";
|
||||||
|
import { prCommand } from "./commands/pr.js";
|
||||||
import { resolveRepoContext } from "./context.js";
|
import { resolveRepoContext } from "./context.js";
|
||||||
import type { CliDeps, GlobalFlags } from "./deps.js";
|
import type { CliDeps, GlobalFlags } from "./deps.js";
|
||||||
import { consumeFlagValue, splitFlag } from "./flags.js";
|
import { consumeFlagValue, splitFlag } from "./flags.js";
|
||||||
@@ -16,6 +17,8 @@ commands:
|
|||||||
issue view Show a single issue's details
|
issue view Show a single issue's details
|
||||||
issue create Create an issue
|
issue create Create an issue
|
||||||
issue comment Post a comment on an issue or pull request
|
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:
|
global flags:
|
||||||
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
-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,
|
topLevelHelp: TOP_LEVEL_HELP,
|
||||||
commands: {
|
commands: {
|
||||||
issue: issueCommand(deps),
|
issue: issueCommand(deps),
|
||||||
|
pr: prCommand(deps),
|
||||||
},
|
},
|
||||||
home: homeCommand(deps),
|
home: homeCommand(deps),
|
||||||
stdout: options.stdout,
|
stdout: options.stdout,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { Comment, CreateIssueOption, Issue } from "gitea-js";
|
import type { Comment, CreateIssueOption, Issue } from "gitea-js";
|
||||||
import {
|
import { BODY_TRUNCATE_LIMIT, COMMENT_TRUNCATE_LIMIT, truncateBody } from "../body.js";
|
||||||
BODY_TRUNCATE_LIMIT,
|
|
||||||
COMMENT_TRUNCATE_LIMIT,
|
|
||||||
truncateBody,
|
|
||||||
} from "../body.js";
|
|
||||||
import { requireBodySource, resolveBodySource } from "../body-source.js";
|
import { requireBodySource, resolveBodySource } from "../body-source.js";
|
||||||
import { createClient } from "../client.js";
|
import { createClient } from "../client.js";
|
||||||
|
import { COMMENT_FLAGS, commentItem } from "../comment.js";
|
||||||
import { resolveRepoContext, type RepoContext } from "../context.js";
|
import { resolveRepoContext, type RepoContext } from "../context.js";
|
||||||
import type { CliDeps } from "../deps.js";
|
import type { CliDeps } from "../deps.js";
|
||||||
import { axiError, classifyHttpError } from "../errors.js";
|
import { axiError, classifyHttpError } from "../errors.js";
|
||||||
@@ -18,7 +15,7 @@ import {
|
|||||||
selectExtraFields,
|
selectExtraFields,
|
||||||
type FieldDef,
|
type FieldDef,
|
||||||
} from "../fields.js";
|
} from "../fields.js";
|
||||||
import { flagValue, parseFlags } from "../flags.js";
|
import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js";
|
||||||
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
||||||
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
|
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
|
||||||
import { relativeTime } from "../time.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
|
// The default detail fields reuse the same declarative extraction as the list
|
||||||
// path; only `body` (truncation) and `comment_count` need bespoke handling.
|
// path; only `body` (truncation) and `comment_count` need bespoke handling.
|
||||||
const ISSUE_VIEW_FIELDS: FieldDef<Issue>[] = [
|
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 } },
|
{ "--comments": { takesValue: false }, "--full": { takesValue: false } },
|
||||||
"issue view",
|
"issue view",
|
||||||
);
|
);
|
||||||
const number = parseIssueNumber(positionals, "issue view");
|
const number = parsePositionalNumber(positionals, "issue view", "issue");
|
||||||
const full = flags["--full"] === true;
|
const full = flags["--full"] === true;
|
||||||
const withComments = flags["--comments"] === true;
|
const withComments = flags["--comments"] === true;
|
||||||
|
|
||||||
@@ -460,16 +431,8 @@ async function issueComment(deps: CliDeps, args: string[]): Promise<string> {
|
|||||||
if (args.includes("--help")) {
|
if (args.includes("--help")) {
|
||||||
return ISSUE_COMMENT_HELP;
|
return ISSUE_COMMENT_HELP;
|
||||||
}
|
}
|
||||||
const { flags, positionals } = parseFlags(
|
const { flags, positionals } = parseFlags(args, COMMENT_FLAGS, "issue comment");
|
||||||
args,
|
const number = parsePositionalNumber(positionals, "issue comment", "issue");
|
||||||
{
|
|
||||||
"--body": { takesValue: true },
|
|
||||||
"--body-file": { takesValue: true },
|
|
||||||
"--full": { takesValue: false },
|
|
||||||
},
|
|
||||||
"issue comment",
|
|
||||||
);
|
|
||||||
const number = parseIssueNumber(positionals, "issue comment");
|
|
||||||
const body = requireBodySource(deps, flags, "issue comment");
|
const body = requireBodySource(deps, flags, "issue comment");
|
||||||
const full = flags["--full"] === true;
|
const full = flags["--full"] === true;
|
||||||
|
|
||||||
@@ -487,14 +450,7 @@ async function issueComment(deps: CliDeps, args: string[]): Promise<string> {
|
|||||||
throw classifyHttpError(error);
|
throw classifyHttpError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = comment.body ?? "";
|
const item = commentItem(comment, { number, full, host: context.host, now: new Date() });
|
||||||
const item = {
|
|
||||||
// The issue the comment was posted to — the comment's own id is not output.
|
|
||||||
number,
|
|
||||||
author: comment.user?.login ?? "",
|
|
||||||
created: relativeTime(comment.created_at, new Date()),
|
|
||||||
body: full ? raw : truncateBody(raw, COMMENT_TRUNCATE_LIMIT, context.host),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gitea marks a comment posted on a pull request with `pull_request_url`. Only
|
// 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
|
// 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");
|
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 {
|
export function classifyHttpError(error: unknown): AxiError {
|
||||||
if (error instanceof AxiError) {
|
if (error instanceof AxiError) {
|
||||||
return error;
|
return error;
|
||||||
|
|||||||
38
src/flags.ts
38
src/flags.ts
@@ -70,6 +70,44 @@ export function consumeFlagValue(
|
|||||||
return { value: next, lastIndex: index + 1 };
|
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(
|
export function parseFlags(
|
||||||
args: string[],
|
args: string[],
|
||||||
spec: FlagSpec,
|
spec: FlagSpec,
|
||||||
|
|||||||
19
src/git.ts
19
src/git.ts
@@ -43,6 +43,25 @@ export function parseRemoteUrl(url: string): RemoteRepo | null {
|
|||||||
return 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> {
|
export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
|
||||||
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
|
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
|
||||||
cwd: deps.cwd,
|
cwd: deps.cwd,
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import { runCliTest } from "../harness.js";
|
|||||||
import {
|
import {
|
||||||
fetchComments,
|
fetchComments,
|
||||||
fetchIssue,
|
fetchIssue,
|
||||||
|
fetchOpenPulls,
|
||||||
provisionInstance,
|
provisionInstance,
|
||||||
|
seedBranch,
|
||||||
type E2EInstance,
|
type E2EInstance,
|
||||||
} from "./provision.js";
|
} from "./provision.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The end-to-end tier for the issue mutations. These commands lean on behavior
|
* The end-to-end tier for the issue and pull request mutations. These commands
|
||||||
* the fixture server cannot attest to — above all that Gitea's label and
|
* lean on behavior the fixture server cannot attest to — that Gitea's label and
|
||||||
* milestone name lookups really are case-insensitive, and that `CreateIssueOption`
|
* milestone name lookups really are case-insensitive, that `CreateIssueOption`
|
||||||
* really takes label *ids* rather than names. Both are asserted here against a
|
* really takes label *ids* rather than names, and that the by-base-head pull
|
||||||
* live instance by passing names in a different case than they were seeded in.
|
* 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;
|
const E2E_URL = process.env.GITEA_AXI_E2E_URL;
|
||||||
|
|
||||||
@@ -23,19 +27,34 @@ function renderedNumber(stdout: string): number {
|
|||||||
return Number(match![1]);
|
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 {
|
return {
|
||||||
GITEA_AXI_API_URL: instance.baseUrl,
|
GITEA_AXI_API_URL: instance.baseUrl,
|
||||||
GITEA_AXI_TOKEN: instance.token,
|
GITEA_AXI_TOKEN: instance.token,
|
||||||
GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`,
|
GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => {
|
||||||
|
let instance: E2EInstance;
|
||||||
|
|
||||||
|
function env(): Record<string, string> {
|
||||||
|
return envFor(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
instance = await provisionInstance(E2E_URL!);
|
instance = await instanceOnce();
|
||||||
}, 150_000);
|
}, 150_000);
|
||||||
|
|
||||||
it("creates an issue and reports the live number, state, and url", async () => {
|
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.");
|
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. */
|
/** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */
|
||||||
export async function fetchIssue(
|
export async function fetchIssue(
|
||||||
instance: E2EInstance,
|
instance: E2EInstance,
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ describe("--help", () => {
|
|||||||
expect(stdout).toContain("list");
|
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 () => {
|
it("prints the version for --version", async () => {
|
||||||
const { stdout, exitCode } = await runCliTest(["--version"]);
|
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