Watch
0
0
Fork
You've already forked hyperhive
0

review try-out: open a PR with the classifications on the page

The try-out page gets an "open a PR" button. It posts the page's
classifications to the new POST /api/review-classifier/open-pr, which
merges them by name into the repo's rules file on the default branch
and has the review-classifier forge account commit the result on a new
branch, review-classifier/<user>-<8 hex>, through the contents API. The
commit's author is the forge account named like the session's
Remote-User, read with the controller's admin token. The answer links
to the forge's compare page, where the user opens the PR as themselves.

The push uses the account's existing token from the store
(swarm/services/review-classifier/forge/token, scope write:repository),
so the account needs write access to the repo; a refused push answers
403 naming that. In a YAML rules file, the entries the merge leaves
alone keep their text, as long as the result reads back as the merged
list; otherwise the file is written out whole.

DefaultBranchFiles now carries each file's blob sha, which the forge
needs to replace a file, and the repo's web URL for the link.

Fixes #5259
Refs #5096
This commit is contained in:
atlas 2026-10-14 15:33:49 +02:00 • committed by mara
commit 49b15e7775
10 changed files with 1231 additions and 53 deletions

View file

@ -13,13 +13,18 @@
// **load the repo's rules** fills the rows from the rules on the repo's default
// branch (`GET /api/review-classifier/rules`), to run as they are or to edit.
//
// **open a PR** posts the rows to `POST /api/review-classifier/open-pr`, which
// has the classifier's forge account push a branch adding them to the repo's
// rules file, with the operator as commit author, and links to the forge's page
// for opening the PR from that branch.
//
// Under the form, the classifier's own settings: its model, effort and review
// concurrency (`ClassifierSettings`).
//
// The inputs stay as typed after a run, so the operator can edit and run
// again. Apart from the settings, the page keeps nothing you typed and no
// result, and posts nothing to the forge; the only thing the browser remembers
// is the result table's column filters, as for every table in the UI.
// result, and a run posts nothing to the forge; the only thing the browser
// remembers is the result table's column filters, as for every table in the UI.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
@ -39,6 +44,7 @@ import {
repoProblem,
toBody,
type ClassificationDraft,
type OpenPrAnswer,
type OpenPull,
type RulesAnswer,
type TryOutResult as Result,
@ -47,6 +53,7 @@ import "./ReviewTryOutPage.css";
const TRY_OUT = "/api/review-classifier/try-out";
const RULES = "/api/review-classifier/rules";
const OPEN_PR = "/api/review-classifier/open-pr";
const PULL_REPOS = "/api/pulls/repos";
const PULLS = "/api/pulls";
// How long the repo field rests before its PRs are fetched, so typing a repo
@ -91,6 +98,12 @@ export function ReviewTryOutPage() {
);
const [confirmLoad, setConfirmLoad] = useState(false);
const [opening, setOpening] = useState(false);
const [opened, setOpened] = useState<
(OpenPrAnswer & { repo: string }) | null
>(null);
const [openError, setOpenError] = useState<ProblemDetails | null>(null);
// What the two fields offer to complete to. The fields stay free text, so a
// repo or a PR the lists lack, a merged one say, can still be typed.
const [repoChoices, setRepoChoices] = useState<string[]>([]);
@ -205,12 +218,15 @@ export function ReviewTryOutPage() {
};
}, [runId]);
const problem =
// What keeps the rows from going to the repo; a PR number is not needed.
const rowsProblem =
repoProblem(repo) ??
(prNumber(pr) === null ? "a PR number is a whole number above 0" : null) ??
drafts.map(draftProblem).find((p) => p !== null) ??
duplicateName(drafts) ??
null;
const problem =
(prNumber(pr) === null ? "a PR number is a whole number above 0" : null) ??
rowsProblem;
async function run(e: Event) {
e.preventDefault();
@ -264,6 +280,34 @@ export function ReviewTryOutPage() {
}
}
// Pushes the rows on a new branch of the repo and keeps the link to open
// the PR from it.
async function openPr() {
const target = repo.trim();
setOpening(true);
setOpened(null);
setOpenError(null);
try {
const res = await fetch(OPEN_PR, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
repo: target,
classifications: drafts.map(toBody),
}),
});
if (!res.ok) {
setOpenError(await readApiError(res));
return;
}
setOpened({ ...((await res.json()) as OpenPrAnswer), repo: target });
} catch (err: unknown) {
setOpenError({ detail: String(err) });
} finally {
setOpening(false);
}
}
// Typed rows are the operator's work, so replacing them is asked first.
function askLoadRules() {
if (hasTypedText(drafts)) setConfirmLoad(true);
@ -279,9 +323,9 @@ export function ReviewTryOutPage() {
<Panel title="review try-out" icon="🧪">
<p>
Run a review classification against a real PR and see how the model
answers it. Nothing is posted to the forge, and nothing is saved:
answers it. A run posts nothing to the forge, and nothing is saved:
change the classification and run it again until it answers the way
you want.
you want. Then open a PR to add it to the repo's rules.
</p>
<form class="ui-tryout-form" onSubmit={(e) => void run(e)}>
<div class="ui-tryout-target">
@ -364,8 +408,30 @@ export function ReviewTryOutPage() {
>
{running ? "running…" : "run"}
</Button>
<Button
onClick={() => void openPr()}
disabled={opening || rowsProblem !== null}
>
{opening ? "pushing…" : "open a PR"}
</Button>
</div>
{problem !== null ? <p class="ui-tryout-hint">{problem}</p> : null}
{openError ? (
<ApiErrorPanel
context="the branch for the PR could not be pushed"
problem={openError}
/>
) : null}
{opened ? (
<p class="ui-tryout-loaded">
Pushed <code>{opened.branch}</code> to {opened.repo}, changing{" "}
<code>{opened.path}</code> with you as the author.{" "}
<a href={opened.compare_url} target="_blank" rel="noopener">
Open the PR on the forge
</a>
.
</p>
) : null}
</form>
</Panel>
<ConfirmDialog

View file

@ -1,5 +1,6 @@
// Wire shapes of the review classifier's try-out endpoints
// (`POST /api/review-classifier/try-out`, `GET .../try-out/{id}`), and the
// (`POST /api/review-classifier/try-out`, `GET .../try-out/{id}`,
// `POST .../open-pr`), and the
// checks the form makes before a request is worth sending. The backend stays
// the real gate: these only keep an obviously bad form from a round trip.
@ -34,6 +35,17 @@ export interface RulesAnswer {
classifications: ClassificationBody[];
}
/** The open-PR route's answer (`POST /api/review-classifier/open-pr`): the
* branch the classifications were pushed on, authored by the operator. */
export interface OpenPrAnswer {
branch: string;
/** The rules file the commit wrote. */
path: string;
/** The forge's page comparing the branch with the default branch, where the
* operator opens the PR. */
compare_url: string;
}
/** An open PR, as `GET /api/pulls?repo=…` lists it. */
export interface OpenPull {
number: number;