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:
parent
6ed88ad70a
commit
49b15e7775
10 changed files with 1231 additions and 53 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue