feat: add benchmark seed provisioning (task 0025)
Add the deterministic, idempotent seed that brings a freshly provisioned throwaway repository to a known ground truth before a trial runs, scripted over the live Gitea API. - bench/seed-plan.ts: the pure ground truth (fixed labels, an issue spread across the discriminating dimensions, and labelled/reviewed/real-branch pull requests) plus groundTruth(user), realizing it into a RepoState. - bench/seed.ts: idempotent seeding reconciled by natural key, reusing gitea-axi's own tea-login credential discovery (no new secret handling). - A live smoke tier (test:bench:smoke) validating the seed end-to-end and skipping cleanly when no host is configured, kept out of the deterministic bench tier. Export selectLogin from src/context.ts so the bench reuses the exact credential-selection path.
This commit was merged in pull request #26.
This commit is contained in:
447
bench/seed.ts
Normal file
447
bench/seed.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
// Seed provisioning: bring a freshly provisioned throwaway repository to the
|
||||
// deterministic ground truth of SEED_PLAN, scripted entirely over the Gitea API
|
||||
// against the live host. This is the imperative boundary the seed-plan realizes;
|
||||
// its value is the real API interaction, so it is validated by a smoke run
|
||||
// (seed.smoke.test.ts) rather than mocked unit tests — see the benchmark-harness
|
||||
// spec's testing decisions.
|
||||
//
|
||||
// Authentication reuses gitea-axi's own credential discovery (the tea login store
|
||||
// and its git-credential token helper) rather than introducing new secret
|
||||
// handling: resolveBenchAccess is a thin adapter over src/tea.ts and the login
|
||||
// selection in src/context.ts.
|
||||
//
|
||||
// Every step is idempotent, keyed by a natural identity — a label by name, an
|
||||
// issue or pull request by title, a comment or review by body, a branch by name —
|
||||
// so re-running the seed against an already-seeded repository reconciles to the
|
||||
// same ground truth instead of duplicating it.
|
||||
|
||||
import type { CliDeps } from "../src/deps.js";
|
||||
import { selectLogin } from "../src/context.js";
|
||||
import { getToken, listLogins } from "../src/tea.js";
|
||||
import { groundTruth, SEED_PLAN, type SeedIssue, type SeedPullRequest } from "./seed-plan.js";
|
||||
import type { RepoState, ReviewKind } from "./scoring-spec.js";
|
||||
|
||||
/** The live host coordinates a seeding run authenticates and talks to. */
|
||||
export interface BenchAccess {
|
||||
/** Gitea instance base URL, without the /api/v1 suffix. */
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** Owner and name of a throwaway repository on the host. */
|
||||
export interface RepoCoords {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host and token for a benchmark run by reusing gitea-axi's own
|
||||
* credential discovery: list the tea logins, pick the named one exactly as the
|
||||
* CLI does, and mint the token through tea's git-credential helper. No new secret
|
||||
* handling is introduced — the benchmark rides the same path the product ships.
|
||||
*/
|
||||
export async function resolveBenchAccess(deps: CliDeps, loginName: string): Promise<BenchAccess> {
|
||||
const logins = await listLogins(deps);
|
||||
const login = selectLogin(logins, loginName, undefined);
|
||||
const host = new URL(login.url).hostname;
|
||||
const token = await getToken(deps, login, host);
|
||||
return { apiUrl: login.url.replace(/\/+$/, ""), token };
|
||||
}
|
||||
|
||||
/** One authenticated Gitea API round-trip; returns the raw response unchecked. */
|
||||
async function request(
|
||||
access: BenchAccess,
|
||||
method: string,
|
||||
path: string,
|
||||
payload?: unknown,
|
||||
): Promise<Response> {
|
||||
return fetch(`${access.apiUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `token ${access.token}`,
|
||||
...(payload !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: payload !== undefined ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fail on any non-2xx response, surfacing the method, path, status, and body. */
|
||||
async function requireOk(res: Response, method: string, path: string): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
throw new Error(`${method} ${path} failed (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Issue a request and require a 2xx, returning the parsed JSON body. */
|
||||
async function send<T>(
|
||||
access: BenchAccess,
|
||||
method: string,
|
||||
path: string,
|
||||
payload?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await requireOk(await request(access, method, path, payload), method, path);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** The single available user: the account the token authenticates as. */
|
||||
export async function currentUser(access: BenchAccess): Promise<string> {
|
||||
const me = await send<{ login: string }>(access, "GET", "/user");
|
||||
return me.login;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh, private, auto-initialized throwaway repository under the
|
||||
* authenticated user and return its coordinates. Each call mints a distinct name,
|
||||
* so trials never collide.
|
||||
*/
|
||||
export async function provisionRepo(
|
||||
access: BenchAccess,
|
||||
name = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
): Promise<RepoCoords> {
|
||||
const owner = await currentUser(access);
|
||||
await send(access, "POST", "/user/repos", {
|
||||
name,
|
||||
auto_init: true,
|
||||
default_branch: "main",
|
||||
private: true,
|
||||
});
|
||||
return { owner, repo: name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort deletion of a throwaway repository. `request` does not throw on a
|
||||
* non-2xx, so a token lacking delete scope is ignored silently; the try/catch
|
||||
* additionally tolerates a network-level failure. Cleanup must never fail a run.
|
||||
*/
|
||||
export async function deleteRepo(access: BenchAccess, coords: RepoCoords): Promise<void> {
|
||||
try {
|
||||
await request(access, "DELETE", `/repos/${coords.owner}/${coords.repo}`);
|
||||
} catch {
|
||||
// Swallow network-level failures; a non-2xx never reaches here.
|
||||
}
|
||||
}
|
||||
|
||||
/** Colours compare equal regardless of a leading `#` or letter case. */
|
||||
function normalizeColor(color: string): string {
|
||||
return color.replace(/^#/, "").toLowerCase();
|
||||
}
|
||||
|
||||
interface GiteaLabel {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the repository's labels to the plan, keyed by name: create a missing
|
||||
* label, patch one whose colour or description drifted, and leave a matching one
|
||||
* untouched. Returns the name→id map the issue and pull-request steps need to
|
||||
* apply labels.
|
||||
*/
|
||||
async function ensureLabels(access: BenchAccess, coords: RepoCoords): Promise<Map<string, number>> {
|
||||
const base = `/repos/${coords.owner}/${coords.repo}/labels`;
|
||||
const existing = await send<GiteaLabel[]>(access, "GET", `${base}?limit=100`);
|
||||
const byName = new Map(existing.map((label) => [label.name, label]));
|
||||
for (const label of SEED_PLAN.labels) {
|
||||
const found = byName.get(label.name);
|
||||
if (!found) {
|
||||
const created = await send<GiteaLabel>(access, "POST", base, {
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description ?? "",
|
||||
});
|
||||
byName.set(created.name, created);
|
||||
} else if (
|
||||
normalizeColor(found.color) !== normalizeColor(label.color) ||
|
||||
(found.description ?? "") !== (label.description ?? "")
|
||||
) {
|
||||
await send<GiteaLabel>(access, "PATCH", `${base}/${found.id}`, {
|
||||
color: label.color,
|
||||
description: label.description ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Map([...byName].map(([name, label]) => [name, label.id]));
|
||||
}
|
||||
|
||||
/** Replace an issue-or-pull-request's applied labels with exactly the given ids. */
|
||||
async function applyLabels(
|
||||
access: BenchAccess,
|
||||
coords: RepoCoords,
|
||||
number: number,
|
||||
names: string[],
|
||||
labelIds: Map<string, number>,
|
||||
): Promise<void> {
|
||||
const ids = names.map((name) => labelIds.get(name)).filter((id): id is number => id !== undefined);
|
||||
await send(access, "PUT", `/repos/${coords.owner}/${coords.repo}/issues/${number}/labels`, {
|
||||
labels: ids,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add each item whose body is not already present at `base`, keyed by body. Both
|
||||
* comments and reviews live at a single endpoint that lists and creates at the
|
||||
* same path, so one reconciler serves them: it lists what is there, then posts
|
||||
* only the items whose body is missing — which is what makes re-seeding a no-op.
|
||||
*/
|
||||
async function addMissingByBody<T>(
|
||||
access: BenchAccess,
|
||||
base: string,
|
||||
items: T[],
|
||||
bodyOf: (item: T) => string,
|
||||
payloadOf: (item: T) => unknown,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existing = await send<{ body: string }[]>(access, "GET", base);
|
||||
const present = new Set(existing.map((entry) => entry.body));
|
||||
for (const item of items) {
|
||||
if (!present.has(bodyOf(item))) {
|
||||
await send(access, "POST", base, payloadOf(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add each comment body not already present on an issue or pull request. */
|
||||
async function ensureComments(
|
||||
access: BenchAccess,
|
||||
coords: RepoCoords,
|
||||
number: number,
|
||||
bodies: string[],
|
||||
): Promise<void> {
|
||||
await addMissingByBody(
|
||||
access,
|
||||
`/repos/${coords.owner}/${coords.repo}/issues/${number}/comments`,
|
||||
bodies,
|
||||
(body) => body,
|
||||
(body) => ({ body }),
|
||||
);
|
||||
}
|
||||
|
||||
interface GiteaIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile one plan issue, keyed by title: create it if absent, then declare its
|
||||
* body, state, applied labels, and assignee presence (the single user or nobody)
|
||||
* and add any missing comments. Every field is set to the desired value, so the
|
||||
* step is idempotent whether the issue was just created or already seeded.
|
||||
*/
|
||||
async function ensureIssue(
|
||||
access: BenchAccess,
|
||||
coords: RepoCoords,
|
||||
user: string,
|
||||
issue: SeedIssue,
|
||||
labelIds: Map<string, number>,
|
||||
byTitle: Map<string, number>,
|
||||
): Promise<void> {
|
||||
const base = `/repos/${coords.owner}/${coords.repo}/issues`;
|
||||
let number = byTitle.get(issue.title);
|
||||
if (number === undefined) {
|
||||
const created = await send<GiteaIssue>(access, "POST", base, {
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
});
|
||||
number = created.number;
|
||||
byTitle.set(issue.title, number);
|
||||
}
|
||||
await send(access, "PATCH", `${base}/${number}`, {
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
assignees: issue.assignToSelf ? [user] : [],
|
||||
});
|
||||
await applyLabels(access, coords, number, issue.labels, labelIds);
|
||||
await ensureComments(access, coords, number, issue.comments);
|
||||
}
|
||||
|
||||
/** The Gitea review event verb for each seed review kind. */
|
||||
const REVIEW_EVENT: Record<ReviewKind, string> = {
|
||||
comment: "COMMENT",
|
||||
approved: "APPROVED",
|
||||
"request-changes": "REQUEST_CHANGES",
|
||||
};
|
||||
|
||||
/** Ensure the pull request's feature branch exists, creating it with its file. */
|
||||
async function ensureBranch(
|
||||
access: BenchAccess,
|
||||
coords: RepoCoords,
|
||||
pr: SeedPullRequest,
|
||||
): Promise<void> {
|
||||
const branchPath = `/repos/${coords.owner}/${coords.repo}/branches/${pr.headBranch}`;
|
||||
const res = await request(access, "GET", branchPath);
|
||||
if (res.ok) {
|
||||
return;
|
||||
}
|
||||
if (res.status !== 404) {
|
||||
await requireOk(res, "GET", branchPath);
|
||||
}
|
||||
await send(access, "POST", `/repos/${coords.owner}/${coords.repo}/contents/${pr.filePath}`, {
|
||||
content: Buffer.from(pr.fileContent).toString("base64"),
|
||||
message: `Seed ${pr.headBranch}`,
|
||||
new_branch: pr.headBranch,
|
||||
});
|
||||
}
|
||||
|
||||
/** Add each review (matched by body) not already present on the pull request. */
|
||||
async function ensureReviews(
|
||||
access: BenchAccess,
|
||||
coords: RepoCoords,
|
||||
number: number,
|
||||
reviews: SeedPullRequest["reviews"],
|
||||
): Promise<void> {
|
||||
await addMissingByBody(
|
||||
access,
|
||||
`/repos/${coords.owner}/${coords.repo}/pulls/${number}/reviews`,
|
||||
reviews,
|
||||
(review) => review.body,
|
||||
(review) => ({ event: REVIEW_EVENT[review.kind], body: review.body }),
|
||||
);
|
||||
}
|
||||
|
||||
interface GiteaPull {
|
||||
number: number;
|
||||
title: string;
|
||||
state?: string;
|
||||
/** True once merged; a merged pull request cannot be reopened. */
|
||||
merged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile one plan pull request, keyed by title: ensure its feature branch,
|
||||
* open the pull request if absent, then declare its labels and add any missing
|
||||
* comments and reviews. All content is authored by the single available user.
|
||||
*/
|
||||
async function ensurePullRequest(
|
||||
access: BenchAccess,
|
||||
coords: RepoCoords,
|
||||
pr: SeedPullRequest,
|
||||
labelIds: Map<string, number>,
|
||||
byTitle: Map<string, GiteaPull>,
|
||||
): Promise<void> {
|
||||
await ensureBranch(access, coords, pr);
|
||||
const base = `/repos/${coords.owner}/${coords.repo}/pulls`;
|
||||
let number: number;
|
||||
const existing = byTitle.get(pr.title);
|
||||
if (existing === undefined) {
|
||||
const created = await send<GiteaPull>(access, "POST", base, {
|
||||
title: pr.title,
|
||||
body: pr.body,
|
||||
base: "main",
|
||||
head: pr.headBranch,
|
||||
});
|
||||
number = created.number;
|
||||
byTitle.set(pr.title, created);
|
||||
} else {
|
||||
number = existing.number;
|
||||
// The ground truth declares every seeded pull request open. Reopen one that
|
||||
// drifted closed (but never a merged one, which Gitea cannot reopen), so the
|
||||
// seed reconciles state as declaratively as it does for issues.
|
||||
if (existing.state === "closed" && existing.merged !== true) {
|
||||
await send(access, "PATCH", `${base}/${number}`, { state: "open" });
|
||||
}
|
||||
}
|
||||
await applyLabels(access, coords, number, pr.labels, labelIds);
|
||||
await ensureComments(access, coords, number, pr.comments);
|
||||
await ensureReviews(access, coords, number, pr.reviews);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a freshly provisioned repository to the ground truth, idempotently. Labels
|
||||
* come first (so issues and pull requests can apply them), then the issue spread,
|
||||
* then the pull requests. Returns the deterministic ground-truth RepoState the
|
||||
* checker scores against; on a fresh repository the created numbers match it,
|
||||
* and a re-run leaves them unchanged.
|
||||
*/
|
||||
export async function seedRepo(access: BenchAccess, coords: RepoCoords): Promise<RepoState> {
|
||||
const user = await currentUser(access);
|
||||
const labelIds = await ensureLabels(access, coords);
|
||||
|
||||
const issuesPath = `/repos/${coords.owner}/${coords.repo}/issues?type=issues&state=all&limit=100`;
|
||||
const existingIssues = await send<GiteaIssue[]>(access, "GET", issuesPath);
|
||||
const issuesByTitle = new Map(existingIssues.map((issue) => [issue.title, issue.number]));
|
||||
for (const issue of SEED_PLAN.issues) {
|
||||
await ensureIssue(access, coords, user, issue, labelIds, issuesByTitle);
|
||||
}
|
||||
|
||||
const pullsPath = `/repos/${coords.owner}/${coords.repo}/pulls?state=all&limit=100`;
|
||||
const existingPulls = await send<GiteaPull[]>(access, "GET", pullsPath);
|
||||
const pullsByTitle = new Map(existingPulls.map((pull) => [pull.title, pull]));
|
||||
for (const pr of SEED_PLAN.pullRequests) {
|
||||
await ensurePullRequest(access, coords, pr, labelIds, pullsByTitle);
|
||||
}
|
||||
|
||||
return groundTruth(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* The observable facts the smoke run checks, read back from live Gitea (not from
|
||||
* the plan) so the assertion compares the real repository against the declared
|
||||
* ground truth rather than the plan against itself.
|
||||
*/
|
||||
export interface SeedSummary {
|
||||
labelNames: string[];
|
||||
openIssueTitles: string[];
|
||||
closedIssueTitles: string[];
|
||||
selfAssignedIssueCount: number;
|
||||
issuesWithCommentsCount: number;
|
||||
pullTitles: string[];
|
||||
labeledPullTitles: string[];
|
||||
reviewedPullTitles: string[];
|
||||
}
|
||||
|
||||
interface GiteaIssueSummary {
|
||||
title: string;
|
||||
state: string;
|
||||
assignees: { login: string }[] | null;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
interface GiteaPullSummary {
|
||||
number: number;
|
||||
title: string;
|
||||
labels: { name: string }[] | null;
|
||||
}
|
||||
|
||||
/** Read the live repository into the summary the smoke run asserts against. */
|
||||
export async function readSeedSummary(
|
||||
access: BenchAccess,
|
||||
coords: RepoCoords,
|
||||
): Promise<SeedSummary> {
|
||||
const repo = `/repos/${coords.owner}/${coords.repo}`;
|
||||
const labels = await send<GiteaLabel[]>(access, "GET", `${repo}/labels?limit=100`);
|
||||
const issues = await send<GiteaIssueSummary[]>(
|
||||
access,
|
||||
"GET",
|
||||
`${repo}/issues?type=issues&state=all&limit=100`,
|
||||
);
|
||||
const pulls = await send<GiteaPullSummary[]>(access, "GET", `${repo}/pulls?state=all&limit=100`);
|
||||
|
||||
const reviewedPullTitles: string[] = [];
|
||||
for (const pull of pulls) {
|
||||
const reviews = await send<{ body: string }[]>(
|
||||
access,
|
||||
"GET",
|
||||
`${repo}/pulls/${pull.number}/reviews`,
|
||||
);
|
||||
if (reviews.length > 0) {
|
||||
reviewedPullTitles.push(pull.title);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labelNames: labels.map((label) => label.name),
|
||||
openIssueTitles: issues.filter((i) => i.state === "open").map((i) => i.title),
|
||||
closedIssueTitles: issues.filter((i) => i.state === "closed").map((i) => i.title),
|
||||
selfAssignedIssueCount: issues.filter((i) => (i.assignees ?? []).length > 0).length,
|
||||
issuesWithCommentsCount: issues.filter((i) => i.comments > 0).length,
|
||||
pullTitles: pulls.map((p) => p.title),
|
||||
labeledPullTitles: pulls.filter((p) => (p.labels ?? []).length > 0).map((p) => p.title),
|
||||
reviewedPullTitles,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user