`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.
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
/**
|
|
* Provisioning for the end-to-end tier: bring a fresh, disposable Gitea instance
|
|
* to a usable state entirely over its HTTP API, with no `docker exec` or shell
|
|
* access to the container. Everything here runs identically on Gitea Actions,
|
|
* GitHub Actions, and a developer's local `docker run gitea/gitea`.
|
|
*
|
|
* Bootstrap chain:
|
|
* 1. Wait for the instance to answer `GET /api/v1/version`.
|
|
* 2. Register the first user through the web sign-up form — Gitea makes the
|
|
* first registered account the site administrator.
|
|
* 3. Mint a scoped API token for that user via HTTP Basic auth (no CSRF).
|
|
* 4. Create a repository and seed issues with that token.
|
|
*/
|
|
|
|
export interface E2EInstance {
|
|
/** Instance base URL, without the /api/v1 suffix (what the CLI expects). */
|
|
baseUrl: string;
|
|
owner: string;
|
|
repo: string;
|
|
token: string;
|
|
/** Titles of the seeded open issues, in creation order (newest number last). */
|
|
openTitles: string[];
|
|
/** Title of the single seeded closed issue. */
|
|
closedTitle: string;
|
|
/**
|
|
* A label seeded in mixed case. The mutation tier passes it in a *different*
|
|
* case, so the case-insensitive name→id lookup is exercised against live Gitea
|
|
* rather than only against fixtures.
|
|
*/
|
|
labelName: string;
|
|
/** A milestone seeded in mixed case, for the same reason as {@link labelName}. */
|
|
milestoneTitle: string;
|
|
}
|
|
|
|
const USERNAME = "e2e-admin";
|
|
const PASSWORD = "e2e-admin-password-123";
|
|
const EMAIL = "e2e-admin@example.com";
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function waitForGitea(baseUrl: string): Promise<void> {
|
|
const deadline = Date.now() + 120_000;
|
|
let lastError: unknown;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/v1/version`);
|
|
if (res.ok) {
|
|
return;
|
|
}
|
|
lastError = new Error(`GET /api/v1/version returned ${res.status}`);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
throw new Error(`Gitea at ${baseUrl} never became ready: ${String(lastError)}`);
|
|
}
|
|
|
|
/** A minimal cookie jar: keep the latest value per cookie name. */
|
|
function collectCookies(jar: Map<string, string>, res: Response): void {
|
|
for (const header of res.headers.getSetCookie()) {
|
|
const pair = header.split(";", 1)[0]!;
|
|
const eq = pair.indexOf("=");
|
|
if (eq > 0) {
|
|
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
function cookieHeader(jar: Map<string, string>): string {
|
|
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
}
|
|
|
|
/**
|
|
* Register the first account through the web sign-up form. Gitea protects the
|
|
* form with a double-submit CSRF token that must be scraped from the rendered
|
|
* HTML and echoed back alongside the matching cookie. Best-effort: a fresh
|
|
* instance succeeds here, and {@link mintToken} is the real gate on success (a
|
|
* pre-existing account from a local re-run is tolerated).
|
|
*/
|
|
async function registerFirstUser(baseUrl: string): Promise<void> {
|
|
const jar = new Map<string, string>();
|
|
const getRes = await fetch(`${baseUrl}/user/sign_up`);
|
|
collectCookies(jar, getRes);
|
|
const html = await getRes.text();
|
|
const csrf = html.match(/name="_csrf"\s+value="([^"]+)"/)?.[1];
|
|
if (!csrf) {
|
|
throw new Error("Could not find a CSRF token on the Gitea sign-up page");
|
|
}
|
|
const form = new URLSearchParams({
|
|
_csrf: csrf,
|
|
user_name: USERNAME,
|
|
email: EMAIL,
|
|
password: PASSWORD,
|
|
retype: PASSWORD,
|
|
});
|
|
await fetch(`${baseUrl}/user/sign_up`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
cookie: cookieHeader(jar),
|
|
},
|
|
body: form.toString(),
|
|
redirect: "manual",
|
|
});
|
|
}
|
|
|
|
function basicAuth(): string {
|
|
return `Basic ${Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64")}`;
|
|
}
|
|
|
|
async function mintToken(baseUrl: string): Promise<string> {
|
|
const res = await fetch(`${baseUrl}/api/v1/users/${USERNAME}/tokens`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: basicAuth(),
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
// write:user is what Gitea requires to create a repo under the user
|
|
// (POST /user/repos); write:repository/write:issue cover the repo + issue
|
|
// reads and writes the provisioning and CLI seam then perform.
|
|
name: `e2e-${Date.now()}`,
|
|
scopes: ["write:user", "write:repository", "write:issue"],
|
|
}),
|
|
});
|
|
if (res.status !== 201) {
|
|
throw new Error(
|
|
`Token creation failed (${res.status}); first-user registration likely did not take. Body: ${await res.text()}`,
|
|
);
|
|
}
|
|
const body = (await res.json()) as { sha1?: string };
|
|
if (!body.sha1) {
|
|
throw new Error("Token response had no sha1 field");
|
|
}
|
|
return body.sha1;
|
|
}
|
|
|
|
/**
|
|
* One authenticated Gitea API round-trip: attach the token, send an optional
|
|
* JSON body, and fail on any non-2xx. Returns the raw {@link Response} so callers
|
|
* can read the body or headers (e.g. the x-total-count the count line uses).
|
|
*/
|
|
async function apiRequest(
|
|
baseUrl: string,
|
|
method: string,
|
|
path: string,
|
|
token: string,
|
|
payload?: unknown,
|
|
): Promise<Response> {
|
|
const res = await fetch(`${baseUrl}/api/v1${path}`, {
|
|
method,
|
|
headers: {
|
|
authorization: `token ${token}`,
|
|
...(payload !== undefined ? { "content-type": "application/json" } : {}),
|
|
},
|
|
body: payload !== undefined ? JSON.stringify(payload) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${method} ${path} failed (${res.status}): ${await res.text()}`);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
export async function provisionInstance(baseUrl: string): Promise<E2EInstance> {
|
|
const normalized = baseUrl.replace(/\/+$/, "");
|
|
await waitForGitea(normalized);
|
|
await registerFirstUser(normalized);
|
|
const token = await mintToken(normalized);
|
|
|
|
const repo = `e2e-repo-${Date.now()}`;
|
|
await apiRequest(normalized, "POST", "/user/repos", token, {
|
|
name: repo,
|
|
auto_init: true,
|
|
default_branch: "main",
|
|
private: false,
|
|
});
|
|
|
|
const openTitles = ["E2E first issue", "E2E second issue", "E2E third issue"];
|
|
for (const title of openTitles) {
|
|
await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/issues`, token, {
|
|
title,
|
|
body: `Seeded body for ${title}.`,
|
|
});
|
|
}
|
|
|
|
const closedTitle = "E2E closed issue";
|
|
const closedRes = await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/issues`, token, {
|
|
title: closedTitle,
|
|
body: "Seeded closed issue.",
|
|
});
|
|
const closed = (await closedRes.json()) as { number: number };
|
|
await apiRequest(normalized, "PATCH", `/repos/${USERNAME}/${repo}/issues/${closed.number}`, token, {
|
|
state: "closed",
|
|
});
|
|
|
|
const labelName = "E2E-Bug";
|
|
await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/labels`, token, {
|
|
name: labelName,
|
|
color: "#ff0000",
|
|
});
|
|
|
|
const milestoneTitle = "E2E-Milestone";
|
|
await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/milestones`, token, {
|
|
title: milestoneTitle,
|
|
});
|
|
|
|
return {
|
|
baseUrl: normalized,
|
|
owner: USERNAME,
|
|
repo,
|
|
token,
|
|
openTitles,
|
|
closedTitle,
|
|
labelName,
|
|
milestoneTitle,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
number: number,
|
|
): Promise<Record<string, unknown>> {
|
|
const res = await apiRequest(
|
|
instance.baseUrl,
|
|
"GET",
|
|
`/repos/${instance.owner}/${instance.repo}/issues/${number}`,
|
|
instance.token,
|
|
);
|
|
return (await res.json()) as Record<string, unknown>;
|
|
}
|
|
|
|
/** Fetch an issue's comments as Gitea returns them. */
|
|
export async function fetchComments(
|
|
instance: E2EInstance,
|
|
number: number,
|
|
): Promise<Record<string, unknown>[]> {
|
|
const res = await apiRequest(
|
|
instance.baseUrl,
|
|
"GET",
|
|
`/repos/${instance.owner}/${instance.repo}/issues/${number}/comments`,
|
|
instance.token,
|
|
);
|
|
return (await res.json()) as Record<string, unknown>[];
|
|
}
|
|
|
|
/**
|
|
* The response-shape paths the issue-list command's FieldDef extractors read
|
|
* (see ISSUE_LIST_FIELDS in src/commands/issue.ts). This one contract anchors
|
|
* three things that must agree: the extractors, the recorded fixtures, and the
|
|
* live Gitea response. The end-to-end shape guard asserts both the fixture and
|
|
* the live payload satisfy it, so a divergence in either fails the tier.
|
|
*/
|
|
export const COVERED_ISSUE_PATHS = ["number", "title", "state", "created_at", "user.login"];
|
|
|
|
/** Whether `obj` has a defined value at a dotted `path` (e.g. "user.login"). */
|
|
export function hasPath(obj: unknown, path: string): boolean {
|
|
let value: unknown = obj;
|
|
for (const key of path.split(".")) {
|
|
if (typeof value !== "object" || value === null) {
|
|
return false;
|
|
}
|
|
value = (value as Record<string, unknown>)[key];
|
|
}
|
|
return value !== undefined && value !== null;
|
|
}
|
|
|
|
/**
|
|
* Fetch the raw issues-list response the CLI's issue-list command consumes, for
|
|
* the response-shape guard. Returns both the parsed array and the header the
|
|
* count line is built from.
|
|
*/
|
|
export async function fetchRawIssues(
|
|
instance: E2EInstance,
|
|
state: "open" | "closed" | "all",
|
|
): Promise<{ issues: Record<string, unknown>[]; totalCount: string | null }> {
|
|
const res = await apiRequest(
|
|
instance.baseUrl,
|
|
"GET",
|
|
`/repos/${instance.owner}/${instance.repo}/issues?type=issues&state=${state}&limit=30&page=1`,
|
|
instance.token,
|
|
);
|
|
return {
|
|
issues: (await res.json()) as Record<string, unknown>[],
|
|
totalCount: res.headers.get("x-total-count"),
|
|
};
|
|
}
|