swarm-wire, swarm-ui: the flow schema lists the values a field takes, and the editor picks them instead of asking for typed names
The schema's event fields gain values, a values_from marker (agents, repos) and a closed flag; a node field gains the marker and the flag. kind is now listed as issue and PR, what the router sends. The flow editor shows a dropdown where the list is complete, suggestions where it is only examples, and a plain box where there is none.
This commit is contained in:
parent
3d142feb6a
commit
b6da2bc9d0
8 changed files with 503 additions and 26 deletions
|
|
@ -102,12 +102,21 @@ from the constants the check reads, so an editor offers only what a `PUT`
|
|||
accepts:
|
||||
|
||||
- `source_events`: `[{name, description}]`, the source events.
|
||||
- `fields`: `[{name, description}]`, the condition and recipient fields, and
|
||||
the `{field}` placeholders.
|
||||
- `fields`: `[{name, description, values, values_from?, closed}]`, the condition
|
||||
and recipient fields, and the `{field}` placeholders. `values` lists what the
|
||||
field holds, as far as the controller knows, as `{name, description}` pairs, such as the `issue` and
|
||||
`PR` of `kind`.
|
||||
- `node_kinds`: `[{kind, description, fields}]` for `source`, `match`, `todo`
|
||||
and `request_reviewer`; each field is `{name, description, required, values}`, and `values`
|
||||
and `request_reviewer`; each field is
|
||||
`{name, description, required, values, values_from?, closed}`, and `values`
|
||||
lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a
|
||||
todo's `op`), empty for other fields.
|
||||
- `values_from` on a field says where an editor asks for values that are only
|
||||
known live: `agents` is the names `GET /api/agents` lists, `repos` the ones
|
||||
`GET /api/repos` lists. `closed` says `values` and that source are every value
|
||||
the field takes, which an editor can show as a dropdown. When false, they
|
||||
are suggestions: the field still takes any other text, such as an `action` the
|
||||
forge adds later or the login of a reviewer who is no agent.
|
||||
- `limits`: `{max_nodes, max_depth, max_flows}`.
|
||||
|
||||
A controller that predates `schema` leaves it out of the answer; a client reads
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ the ends. Pick a node to edit it in the panel beside the canvas:
|
|||
|
||||
- a **source** picks the forge events the flow starts from;
|
||||
- a **match** lists conditions, each an event field and the value it must equal;
|
||||
- a **todo** says who gets it (the agents an event field names, or one agent),
|
||||
- a **todo** says who gets it (the agents an event field names, or one agent, picked from the agents the swarm has),
|
||||
whether it sets or clears a todo, the todo's key, and its text. The key and the
|
||||
text can use `{field}` placeholders;
|
||||
- a **request_reviewer** names the forge login the router asks to review the
|
||||
|
|
@ -296,6 +296,16 @@ editor offers to reload and drop your edits, or to keep them and replace this
|
|||
flow's latest version with them. For any other refusal the editor shows the
|
||||
controller's own message. The router runs a saved flow from its next event.
|
||||
|
||||
Where the controller's schema knows the values, the editor offers them instead of
|
||||
asking you to type a name: `kind` and `event` conditions and the todo's `op` are
|
||||
dropdowns, `action` and `repo` conditions and a reviewer are boxes that list the
|
||||
known values to pick from and still take any other text, and a todo's agent is a
|
||||
dropdown of the swarm's agents. A value set in a flow that the list no longer
|
||||
holds stays selected, marked "not listed." If a list fails to load, or the
|
||||
controller predates the schema, the box is plain text. The values come from the
|
||||
controller's schema ([notify-router](notify-router.md)), so a new one shows up
|
||||
without a UI change.
|
||||
|
||||
**preview** runs the draft, saved or not, over the newest forge events and
|
||||
shows on each box how many of them reached it, as a dry run does for the saved
|
||||
flows in the list. It needs no save, so you can try a half-built flow. The
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
// What the editor offers for a value the operator would otherwise have to
|
||||
// know: the server's schema says which values a field takes, or where to ask
|
||||
// for them. Pure, so `npm test` can run it.
|
||||
import type { Described, FlowSchema } from "./FlowTypes.js";
|
||||
|
||||
// Where each `values_from` marker of the schema is fetched, a list of names.
|
||||
export const SOURCE_URLS: Record<string, string> = {
|
||||
agents: "/api/agents",
|
||||
repos: "/api/repos",
|
||||
};
|
||||
|
||||
// Names fetched so far per marker. A marker is absent until its list arrives,
|
||||
// and stays absent when the fetch fails.
|
||||
export type Sources = Record<string, string[]>;
|
||||
|
||||
/** What the schema says one field can hold. */
|
||||
export interface ValueSpec {
|
||||
values: Described[];
|
||||
values_from?: string | null;
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
options: { value: string; label: string }[];
|
||||
// The options are every value there is, so a dropdown will do; otherwise
|
||||
// they are suggestions beside a box that takes any text.
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** The markers the schema names that this editor can fetch, once each. */
|
||||
export function markersIn(schema: FlowSchema): string[] {
|
||||
const specs: ValueSpec[] = [
|
||||
...schema.fields,
|
||||
...schema.node_kinds.flatMap((k) => k.fields),
|
||||
];
|
||||
const wanted = specs.flatMap((s) => (s.values_from ? [s.values_from] : []));
|
||||
return [...new Set(wanted)].filter((m) => m in SOURCE_URLS).sort();
|
||||
}
|
||||
|
||||
/** The spec of node field `field` of `kind`, if the schema has one. */
|
||||
export function kindSpec(
|
||||
schema: FlowSchema,
|
||||
kind: string,
|
||||
field: string,
|
||||
): ValueSpec | undefined {
|
||||
return schema.node_kinds
|
||||
.find((k) => k.kind === kind)
|
||||
?.fields.find((f) => f.name === field);
|
||||
}
|
||||
|
||||
/** The spec of event field `name`, if the schema has one. */
|
||||
export function eventSpec(
|
||||
schema: FlowSchema,
|
||||
name: string,
|
||||
): ValueSpec | undefined {
|
||||
return schema.fields.find((f) => f.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* What to offer for a field that holds `current`, or null when it has no
|
||||
* known values and stays a plain text box.
|
||||
*
|
||||
* A dropdown needs every value, so one whose source has not arrived becomes
|
||||
* suggestions; a value already set that the list lacks (an agent since
|
||||
* removed) stays selectable so opening a flow never changes it.
|
||||
*/
|
||||
export function offerFor(
|
||||
spec: ValueSpec | undefined,
|
||||
sources: Sources,
|
||||
current: string,
|
||||
): Offer | null {
|
||||
if (!spec) return null;
|
||||
const from = spec.values_from;
|
||||
const fetched = from ? sources[from] : undefined;
|
||||
const named = spec.values.map((v) => ({
|
||||
value: v.name,
|
||||
label: v.description ? `${v.name} — ${v.description}` : v.name,
|
||||
}));
|
||||
const options = [
|
||||
...named,
|
||||
...(fetched ?? [])
|
||||
.filter((n) => !spec.values.some((v) => v.name === n))
|
||||
.map((n) => ({ value: n, label: n })),
|
||||
];
|
||||
if (options.length === 0) return null;
|
||||
const complete = spec.closed === true && (!from || fetched !== undefined);
|
||||
if (!complete) return { options, closed: false };
|
||||
const known = options.some((o) => o.value === current);
|
||||
return {
|
||||
options:
|
||||
current === "" || known
|
||||
? options
|
||||
: [{ value: current, label: `${current} (not listed)` }, ...options],
|
||||
closed: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import {
|
|||
} from "./FlowDraft.js";
|
||||
import { useLeaveGuard } from "./useLeaveGuard.js";
|
||||
import { NodeInspector } from "./NodeInspector.js";
|
||||
import { markersIn, SOURCE_URLS, type Sources } from "./FlowChoices.js";
|
||||
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
|
||||
import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js";
|
||||
import "./NotificationsPage.css";
|
||||
|
|
@ -85,6 +86,7 @@ export function FlowEditorPage({
|
|||
dry: DryRun;
|
||||
} | null>(null);
|
||||
const [previewError, setPreviewError] = useState<ProblemDetails | null>(null);
|
||||
const [sources, setSources] = useState<Sources>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
|
|
@ -121,6 +123,19 @@ export function FlowEditorPage({
|
|||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
// The lists the schema's values come from. One that cannot be fetched is
|
||||
// left out and its field falls back to a box that takes any text.
|
||||
const markers = view?.schema ? markersIn(view.schema).join(",") : "";
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
for (const marker of markers === "" ? [] : markers.split(",")) {
|
||||
fetchJson<string[]>(SOURCE_URLS[marker], abort.signal)
|
||||
.then((names) => setSources((all) => ({ ...all, [marker]: names })))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return () => abort.abort();
|
||||
}, [markers]);
|
||||
|
||||
const dirty =
|
||||
view !== null &&
|
||||
draft !== null &&
|
||||
|
|
@ -377,6 +392,7 @@ export function FlowEditorPage({
|
|||
node={node}
|
||||
schema={schema}
|
||||
limits={limits}
|
||||
sources={sources}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -83,9 +83,19 @@ export interface Described {
|
|||
description: string;
|
||||
}
|
||||
|
||||
export interface KindField extends Described {
|
||||
required: boolean;
|
||||
// What a field can hold: `values` it is known to take, and `values_from`, a
|
||||
// marker for where to fetch the rest (see FlowChoices.ts). `closed` says the
|
||||
// values are every one there is; absent from a controller that predates it.
|
||||
export interface Choices {
|
||||
values: Described[];
|
||||
values_from?: string | null;
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
export interface EventField extends Described, Choices {}
|
||||
|
||||
export interface KindField extends Described, Choices {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface KindSchema {
|
||||
|
|
@ -96,7 +106,7 @@ export interface KindSchema {
|
|||
|
||||
export interface FlowSchema {
|
||||
source_events: Described[];
|
||||
fields: Described[];
|
||||
fields: EventField[];
|
||||
node_kinds: KindSchema[];
|
||||
limits: { max_nodes: number; max_depth: number; max_flows: number };
|
||||
}
|
||||
|
|
@ -123,7 +133,7 @@ export const FALLBACK_SCHEMA: FlowSchema = {
|
|||
"assignees_added",
|
||||
"assignees_removed",
|
||||
"todo_holders",
|
||||
].map((name) => ({ name, description: "" })),
|
||||
].map((name) => ({ name, description: "", values: [] })),
|
||||
node_kinds: [],
|
||||
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ import {
|
|||
type Limits,
|
||||
withOp,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
eventSpec,
|
||||
kindSpec,
|
||||
offerFor,
|
||||
type Sources,
|
||||
type ValueSpec,
|
||||
} from "./FlowChoices.js";
|
||||
import {
|
||||
kindValues,
|
||||
ordinal,
|
||||
|
|
@ -36,6 +43,47 @@ const OPS_FALLBACK = ["upsert", "clear"];
|
|||
// The kinds this editor has fields for.
|
||||
const KNOWN_KINDS: string[] = ["source", "match", "todo", "request_reviewer"];
|
||||
|
||||
// A value box: a dropdown when the server lists every value, a box with the
|
||||
// known ones to pick from when it lists some, a plain box when it lists none.
|
||||
function ValueField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
spec,
|
||||
sources,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
spec: ValueSpec | undefined;
|
||||
sources: Sources;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const offer = offerFor(spec, sources, value);
|
||||
if (offer?.closed) {
|
||||
return (
|
||||
<SelectField
|
||||
id={id}
|
||||
label={label}
|
||||
value={value}
|
||||
options={offer.options}
|
||||
placeholder="pick one"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
label={label}
|
||||
value={value}
|
||||
suggestions={offer?.options}
|
||||
onInput={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function options(list: Described[]) {
|
||||
return list.map((d) => ({
|
||||
value: d.name,
|
||||
|
|
@ -48,12 +96,15 @@ export function NodeInspector({
|
|||
node,
|
||||
schema,
|
||||
limits,
|
||||
sources,
|
||||
onEdit,
|
||||
}: {
|
||||
flow: Flow;
|
||||
node: FlowNode;
|
||||
schema: FlowSchema;
|
||||
limits: Limits;
|
||||
// The lists the schema's `values_from` markers name, as fetched.
|
||||
sources: Sources;
|
||||
// `select` is the node to pick once the edit is applied.
|
||||
onEdit: (edit: Edit, select?: string) => void;
|
||||
}) {
|
||||
|
|
@ -111,6 +162,8 @@ export function NodeInspector({
|
|||
<MatchFields
|
||||
node={node}
|
||||
keyPrefix={key}
|
||||
schema={schema}
|
||||
sources={sources}
|
||||
fieldOptions={fieldOptions}
|
||||
firstField={firstField}
|
||||
onChange={set}
|
||||
|
|
@ -122,6 +175,7 @@ export function NodeInspector({
|
|||
node={node}
|
||||
keyPrefix={key}
|
||||
schema={schema}
|
||||
sources={sources}
|
||||
fieldOptions={fieldOptions}
|
||||
firstField={firstField}
|
||||
onChange={set}
|
||||
|
|
@ -131,11 +185,13 @@ export function NodeInspector({
|
|||
{node.kind === "request_reviewer" ? (
|
||||
<fieldset>
|
||||
<legend>asks for a review</legend>
|
||||
<TextField
|
||||
<ValueField
|
||||
id={`${key}-reviewer`}
|
||||
label="reviewer (a forge login)"
|
||||
value={node.reviewer}
|
||||
onInput={(reviewer) => set({ ...node, reviewer })}
|
||||
spec={kindSpec(schema, "request_reviewer", "reviewer")}
|
||||
sources={sources}
|
||||
onChange={(reviewer) => set({ ...node, reviewer })}
|
||||
/>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
|
@ -198,12 +254,16 @@ export function NodeInspector({
|
|||
function MatchFields({
|
||||
node,
|
||||
keyPrefix,
|
||||
schema,
|
||||
sources,
|
||||
fieldOptions,
|
||||
firstField,
|
||||
onChange,
|
||||
}: {
|
||||
node: Extract<FlowNode, { kind: "match" }>;
|
||||
keyPrefix: string;
|
||||
schema: FlowSchema;
|
||||
sources: Sources;
|
||||
fieldOptions: { value: string; label: string }[];
|
||||
firstField: string;
|
||||
onChange: (next: FlowNode) => void;
|
||||
|
|
@ -224,11 +284,13 @@ function MatchFields({
|
|||
setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x)))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
<ValueField
|
||||
id={`${keyPrefix}-c${i}-equals`}
|
||||
label="equals"
|
||||
value={c.equals}
|
||||
onInput={(equals) =>
|
||||
spec={eventSpec(schema, c.field)}
|
||||
sources={sources}
|
||||
onChange={(equals) =>
|
||||
setWhen(node.when.map((x, j) => (j === i ? { ...x, equals } : x)))
|
||||
}
|
||||
/>
|
||||
|
|
@ -252,6 +314,7 @@ function TodoFields({
|
|||
node,
|
||||
keyPrefix,
|
||||
schema,
|
||||
sources,
|
||||
fieldOptions,
|
||||
firstField,
|
||||
onChange,
|
||||
|
|
@ -259,6 +322,7 @@ function TodoFields({
|
|||
node: TodoNode;
|
||||
keyPrefix: string;
|
||||
schema: FlowSchema;
|
||||
sources: Sources;
|
||||
fieldOptions: { value: string; label: string }[];
|
||||
firstField: string;
|
||||
onChange: (next: FlowNode) => void;
|
||||
|
|
@ -304,11 +368,13 @@ function TodoFields({
|
|||
onChange={(field) => onChange({ ...node, to: { field } })}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
<ValueField
|
||||
id={`${keyPrefix}-to-agent`}
|
||||
label="agent"
|
||||
value={node.to.agent}
|
||||
onInput={(agent) => onChange({ ...node, to: { agent } })}
|
||||
spec={kindSpec(schema, "todo", "to")}
|
||||
sources={sources}
|
||||
onChange={(agent) => onChange({ ...node, to: { agent } })}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
|
|
|
|||
148
frontend/packages/swarm-ui/test/flow-choices.test.mjs
Normal file
148
frontend/packages/swarm-ui/test/flow-choices.test.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// What the flow editor offers for a value: dropdown, suggestions or plain
|
||||
// text, from the server's schema. Run by `npm test`.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
eventSpec,
|
||||
kindSpec,
|
||||
markersIn,
|
||||
offerFor,
|
||||
} from "../src/pages/notifications/FlowChoices.ts";
|
||||
|
||||
const d = (name, description = "") => ({ name, description });
|
||||
|
||||
const schema = {
|
||||
source_events: [d("issues")],
|
||||
fields: [
|
||||
{
|
||||
...d("kind"),
|
||||
values: [d("issue"), d("PR", "A pull request")],
|
||||
closed: true,
|
||||
},
|
||||
{ ...d("action"), values: [d("opened"), d("closed")], closed: false },
|
||||
{ ...d("repo"), values: [], values_from: "repos", closed: false },
|
||||
{ ...d("title"), values: [] },
|
||||
],
|
||||
node_kinds: [
|
||||
{
|
||||
kind: "todo",
|
||||
description: "",
|
||||
fields: [
|
||||
{
|
||||
...d("to"),
|
||||
required: true,
|
||||
values: [],
|
||||
values_from: "agents",
|
||||
closed: true,
|
||||
},
|
||||
{
|
||||
...d("op"),
|
||||
required: true,
|
||||
values: [d("upsert"), d("clear")],
|
||||
closed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "request_reviewer",
|
||||
description: "",
|
||||
fields: [
|
||||
{
|
||||
...d("reviewer"),
|
||||
required: true,
|
||||
values: [],
|
||||
values_from: "agents",
|
||||
closed: false,
|
||||
},
|
||||
{
|
||||
...d("later"),
|
||||
required: false,
|
||||
values: [],
|
||||
values_from: "somewhere-new",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
limits: { max_nodes: 1, max_depth: 1, max_flows: 1 },
|
||||
};
|
||||
|
||||
test("a field with a complete list is a dropdown, its labels carrying the description", () => {
|
||||
const offer = offerFor(eventSpec(schema, "kind"), {}, "PR");
|
||||
assert.equal(offer.closed, true);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["issue", "PR"],
|
||||
);
|
||||
assert.equal(offer.options[1].label, "PR — A pull request");
|
||||
});
|
||||
|
||||
test("a list that is only examples stays a text box with suggestions", () => {
|
||||
const offer = offerFor(eventSpec(schema, "action"), {}, "");
|
||||
assert.equal(offer.closed, false);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["opened", "closed"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a field with no known values is plain text", () => {
|
||||
assert.equal(offerFor(eventSpec(schema, "title"), {}, ""), null);
|
||||
assert.equal(offerFor(undefined, {}, ""), null, "no schema entry at all");
|
||||
});
|
||||
|
||||
test("a list fetched from the swarm fills the options, and a closed one becomes a dropdown", () => {
|
||||
const spec = kindSpec(schema, "todo", "to");
|
||||
const offer = offerFor(spec, { agents: ["atlas", "iris"] }, "iris");
|
||||
assert.equal(offer.closed, true);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["atlas", "iris"],
|
||||
);
|
||||
});
|
||||
|
||||
test("until its list arrives, or when it cannot be fetched, there is nothing to offer", () => {
|
||||
const spec = kindSpec(schema, "todo", "to");
|
||||
assert.equal(offerFor(spec, {}, ""), null);
|
||||
// Open lists with names fetched are suggestions: any login still goes.
|
||||
const reviewer = kindSpec(schema, "request_reviewer", "reviewer");
|
||||
const offer = offerFor(reviewer, { agents: ["argus"] }, "review-classifier");
|
||||
assert.equal(offer.closed, false);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["argus"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a dropdown whose source has not arrived falls back to suggestions", () => {
|
||||
const spec = {
|
||||
values: [d("a")],
|
||||
values_from: "agents",
|
||||
closed: true,
|
||||
};
|
||||
assert.equal(offerFor(spec, {}, "").closed, false);
|
||||
assert.equal(offerFor(spec, { agents: ["b"] }, "").closed, true);
|
||||
});
|
||||
|
||||
test("a value the list lacks stays selectable, marked as not listed", () => {
|
||||
const spec = kindSpec(schema, "todo", "to");
|
||||
const offer = offerFor(spec, { agents: ["atlas"] }, "gone");
|
||||
assert.equal(offer.options[0].value, "gone");
|
||||
assert.equal(offer.options[0].label, "gone (not listed)");
|
||||
assert.equal(offer.options.length, 2);
|
||||
// Nothing set yet adds nothing.
|
||||
assert.equal(offerFor(spec, { agents: ["atlas"] }, "").options.length, 1);
|
||||
});
|
||||
|
||||
test("a name in both the fixed values and the fetched list is offered once", () => {
|
||||
const spec = { values: [d("x")], values_from: "agents" };
|
||||
const offer = offerFor(spec, { agents: ["x", "y"] }, "");
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["x", "y"],
|
||||
);
|
||||
});
|
||||
|
||||
test("the markers to fetch are the ones the schema names and the editor knows, once each", () => {
|
||||
assert.deepEqual(markersIn(schema), ["agents", "repos"]);
|
||||
assert.deepEqual(markersIn({ ...schema, fields: [], node_kinds: [] }), []);
|
||||
});
|
||||
|
|
@ -358,6 +358,48 @@ pub struct Described {
|
|||
pub description: String,
|
||||
}
|
||||
|
||||
/// `values_from` marker: the names of the agents, as `GET /api/agents` lists
|
||||
/// them.
|
||||
pub const VALUES_FROM_AGENTS: &str = "agents";
|
||||
|
||||
/// `values_from` marker: the repositories, as `GET /api/repos` lists them.
|
||||
pub const VALUES_FROM_REPOS: &str = "repos";
|
||||
|
||||
/// The `kind` field's values: what the router puts there, which is not the
|
||||
/// words of the field's description.
|
||||
const KINDS: &[(&str, &str)] = &[("issue", "An issue"), ("PR", "A pull request")];
|
||||
|
||||
/// Some of what the forge sends as `action`. Others exist, so it is a list of
|
||||
/// suggestions only.
|
||||
const ACTIONS: &[(&str, &str)] = &[
|
||||
("opened", "Created"),
|
||||
("closed", "Closed, or a pull request merged"),
|
||||
("reopened", "Opened again after being closed"),
|
||||
("edited", "Title or text changed"),
|
||||
("assigned", "An assignee added"),
|
||||
("unassigned", "An assignee removed"),
|
||||
("synchronized", "New commits pushed to a pull request"),
|
||||
];
|
||||
|
||||
/// One event field a condition or recipient can name, and what it can hold.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct EventField {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// Values the field is known to hold; empty when none are listed.
|
||||
pub values: Vec<Described>,
|
||||
/// Where an editor asks for the values only known live: one of
|
||||
/// [`VALUES_FROM_AGENTS`], [`VALUES_FROM_REPOS`]. Absent when there is
|
||||
/// none.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_from: Option<String>,
|
||||
/// Whether `values` or the source is every value the field can hold,
|
||||
/// which an editor can show as a dropdown. When false they are
|
||||
/// suggestions and the editor still takes any other text.
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// One field of a node kind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
|
|
@ -369,6 +411,13 @@ pub struct KindField {
|
|||
/// The accepted values when the field takes one of a fixed few; empty
|
||||
/// otherwise.
|
||||
pub values: Vec<Described>,
|
||||
/// Where an editor asks for the values only known live, as
|
||||
/// [`EventField::values_from`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_from: Option<String>,
|
||||
/// Whether the values are every one the field accepts, as
|
||||
/// [`EventField::closed`].
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// One kind of node and the fields it takes besides `id` and `kind`.
|
||||
|
|
@ -395,7 +444,7 @@ pub struct Limits {
|
|||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct Schema {
|
||||
pub source_events: Vec<Described>,
|
||||
pub fields: Vec<Described>,
|
||||
pub fields: Vec<EventField>,
|
||||
pub node_kinds: Vec<KindSchema>,
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
|
@ -415,17 +464,56 @@ fn kind_field(name: &str, description: &str, required: bool) -> KindField {
|
|||
description: description.to_owned(),
|
||||
required,
|
||||
values: Vec::new(),
|
||||
values_from: None,
|
||||
closed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`FIELDS`] with what each is known to hold.
|
||||
fn event_fields() -> Vec<EventField> {
|
||||
FIELDS
|
||||
.iter()
|
||||
.map(|(name, description)| {
|
||||
let (values, values_from, closed) = match *name {
|
||||
"event" => (described(SOURCE_EVENTS), None, true),
|
||||
"kind" => (described(KINDS), None, true),
|
||||
"action" => (described(ACTIONS), None, false),
|
||||
"repo" => (Vec::new(), Some(VALUES_FROM_REPOS), false),
|
||||
"assignees" | "assignees_added" | "assignees_removed" | "todo_holders" => {
|
||||
(Vec::new(), Some(VALUES_FROM_AGENTS), false)
|
||||
}
|
||||
_ => (Vec::new(), None, false),
|
||||
};
|
||||
EventField {
|
||||
name: (*name).to_owned(),
|
||||
description: (*description).to_owned(),
|
||||
values,
|
||||
values_from: values_from.map(str::to_owned),
|
||||
closed,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The schema of this build's flows.
|
||||
#[must_use]
|
||||
pub fn schema() -> Schema {
|
||||
let mut op = kind_field("op", "What the todo does to the recipient's list", true);
|
||||
op.values = described(OPS);
|
||||
op.closed = true;
|
||||
let mut to = kind_field(
|
||||
"to",
|
||||
"The recipients: {field: <event field>} or {agent: <agent name>}",
|
||||
true,
|
||||
);
|
||||
to.values_from = Some(VALUES_FROM_AGENTS.to_owned());
|
||||
to.closed = true;
|
||||
let mut reviewer = kind_field("reviewer", "The forge login to request a review from", true);
|
||||
// A login need not belong to an agent: the classifier's does not.
|
||||
reviewer.values_from = Some(VALUES_FROM_AGENTS.to_owned());
|
||||
Schema {
|
||||
source_events: described(SOURCE_EVENTS),
|
||||
fields: described(FIELDS),
|
||||
fields: event_fields(),
|
||||
node_kinds: vec![
|
||||
KindSchema {
|
||||
kind: "source".to_owned(),
|
||||
|
|
@ -449,11 +537,7 @@ pub fn schema() -> Schema {
|
|||
kind: "todo".to_owned(),
|
||||
description: "A leaf: emits one todo op per recipient".to_owned(),
|
||||
fields: vec![
|
||||
kind_field(
|
||||
"to",
|
||||
"The recipients: {field: <event field>} or {agent: <agent name>}",
|
||||
true,
|
||||
),
|
||||
to,
|
||||
op,
|
||||
kind_field("key", "The todo key, with {field} placeholders", true),
|
||||
kind_field("prefix", "For a clear: key is a prefix", false),
|
||||
|
|
@ -467,11 +551,7 @@ pub fn schema() -> Schema {
|
|||
KindSchema {
|
||||
kind: "request_reviewer".to_owned(),
|
||||
description: "A leaf: asks the forge for a review of the pull request the event concerns, unless the reviewer's review of its head already stands; does nothing for an issue".to_owned(),
|
||||
fields: vec![kind_field(
|
||||
"reviewer",
|
||||
"The forge login to request a review from",
|
||||
true,
|
||||
)],
|
||||
fields: vec![reviewer],
|
||||
},
|
||||
],
|
||||
limits: Limits {
|
||||
|
|
@ -906,6 +986,48 @@ mod tests {
|
|||
assert_eq!(listed.len(), 2);
|
||||
assert!(op_names("nope").is_none(), "control");
|
||||
|
||||
// What an editor offers for a field is what the router puts there.
|
||||
let field = |name: &str| s.fields.iter().find(|f| f.name == name).expect(name);
|
||||
let names =
|
||||
|f: &EventField| -> Vec<String> { f.values.iter().map(|v| v.name.clone()).collect() };
|
||||
assert_eq!(names(field("kind")), ["issue", "PR"]);
|
||||
assert!(field("kind").closed);
|
||||
let events: Vec<String> = s.source_events.iter().map(|e| e.name.clone()).collect();
|
||||
assert_eq!(names(field("event")), events);
|
||||
assert!(field("event").closed);
|
||||
assert!(!field("action").closed, "the forge may send more actions");
|
||||
assert!(names(field("action")).contains(&"closed".to_owned()));
|
||||
assert_eq!(
|
||||
field("repo").values_from.as_deref(),
|
||||
Some(VALUES_FROM_REPOS)
|
||||
);
|
||||
assert_eq!(
|
||||
field("assignees").values_from.as_deref(),
|
||||
Some(VALUES_FROM_AGENTS)
|
||||
);
|
||||
assert!(field("title").values.is_empty() && field("title").values_from.is_none());
|
||||
// A marker is one an editor knows to resolve.
|
||||
for f in &s.fields {
|
||||
if let Some(from) = &f.values_from {
|
||||
assert!([VALUES_FROM_AGENTS, VALUES_FROM_REPOS].contains(&from.as_str()));
|
||||
}
|
||||
}
|
||||
let kind_field_of = |kind: &str, name: &str| {
|
||||
s.node_kinds
|
||||
.iter()
|
||||
.find(|k| k.kind == kind)
|
||||
.and_then(|k| k.fields.iter().find(|f| f.name == name))
|
||||
.expect(name)
|
||||
};
|
||||
assert!(kind_field_of("todo", "op").closed);
|
||||
assert_eq!(
|
||||
kind_field_of("todo", "to").values_from.as_deref(),
|
||||
Some(VALUES_FROM_AGENTS)
|
||||
);
|
||||
let reviewer = kind_field_of("request_reviewer", "reviewer");
|
||||
assert_eq!(reviewer.values_from.as_deref(), Some(VALUES_FROM_AGENTS));
|
||||
assert!(!reviewer.closed, "any forge login is allowed");
|
||||
|
||||
assert_eq!(
|
||||
(s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows),
|
||||
(MAX_NODES, MAX_DEPTH, MAX_FLOWS)
|
||||
|
|
|
|||
Loading…
Reference in a new issue