Skip to content

Commit 46065b9

Browse files
julietshenclaude
andcommitted
Update rule-authoring UI to the draft-table API
The backend pivoted from git submission backends to a rule_drafts table (roostorg#402), so the editor's submit/pending calls no longer exist. Rewire the UI to the new endpoints: - submit (POST rule-drafts/submit) -> create (POST rule-drafts) - pending (GET rule-drafts/pending) -> list (GET rule-drafts) - add a Deploy action (POST rule-drafts/<id>/deploy) carrying wire_into_main The editor is now save-draft then deploy rather than open-a-PR, and the Rules page lists in-progress drafts (status tag, link to edit) instead of pending-review pull requests. The source/validate/vocabulary/parse endpoints were unchanged, so those calls stay. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
1 parent 0333d51 commit 46065b9

4 files changed

Lines changed: 167 additions & 117 deletions

File tree

osprey_ui/src/actions/RulesActions.tsx

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import HTTPUtils, { HTTPResponse } from '../utils/HTTPUtils';
22
import {
3+
DeployRuleDraftResponse,
34
ParseIntoBuilderResponse,
4-
PendingDraftsResponse,
5+
RuleDraft,
56
RuleDraftSourceResponse,
6-
RuleDraftSubmitResponse,
7+
RuleDraftsListResponse,
78
RuleDraftValidationResponse,
89
RuleDraftVocabulary,
910
RulesListResponse,
@@ -54,35 +55,48 @@ export async function getRuleDraftVocabulary(): Promise<RuleDraftVocabulary> {
5455
throw new Error(response.error.message ?? 'Failed to fetch rule vocabulary');
5556
}
5657

57-
export interface SubmitRuleDraftBody {
58+
export interface CreateRuleDraftBody {
5859
path: string;
5960
source: string;
6061
rule_name: string;
6162
summary: string;
62-
is_new_rule: boolean;
63+
}
64+
65+
// Saves a draft into the rule_drafts table (upserted by path). The draft is staged,
66+
// not live; deployRuleDraft writes it into the rules directory.
67+
export async function createRuleDraft(body: CreateRuleDraftBody): Promise<RuleDraft> {
68+
const response: HTTPResponse = await HTTPUtils.post('rule-drafts', body);
69+
if (response.ok) {
70+
return response.data;
71+
}
72+
const errPayload = response.error.response?.data as { error?: string } | undefined;
73+
throw new Error(errPayload?.error ?? response.error.message ?? 'Failed to save rule draft');
74+
}
75+
76+
export interface DeployRuleDraftBody {
77+
// Also append a Require line to main.sml so the rule takes effect.
6378
wire_into_main?: boolean;
64-
branch?: string;
6579
}
6680

67-
export async function submitRuleDraft(body: SubmitRuleDraftBody): Promise<RuleDraftSubmitResponse> {
68-
const response: HTTPResponse = await HTTPUtils.post('rule-drafts/submit', body);
81+
export async function deployRuleDraft(id: number, body: DeployRuleDraftBody = {}): Promise<DeployRuleDraftResponse> {
82+
const response: HTTPResponse = await HTTPUtils.post(`rule-drafts/${id}/deploy`, body);
6983
if (response.ok) {
7084
return response.data;
7185
}
7286
const errPayload = response.error.response?.data as { error?: string } | undefined;
73-
throw new Error(errPayload?.error ?? response.error.message ?? 'Failed to submit rule draft');
87+
throw new Error(errPayload?.error ?? response.error.message ?? 'Failed to deploy rule draft');
7488
}
7589

76-
export async function getPendingRuleDrafts(): Promise<PendingDraftsResponse> {
90+
export async function getRuleDrafts(): Promise<RuleDraftsListResponse> {
7791
// Returns an empty list rather than throwing on failure so the RulesPage still renders
78-
// when GitHub isn't configured.
79-
const response: HTTPResponse = await HTTPUtils.get('rule-drafts/pending');
92+
// when the caller lacks the rule-drafts ability.
93+
const response: HTTPResponse = await HTTPUtils.get('rule-drafts');
8094
if (response.ok) {
8195
return response.data;
8296
}
83-
const errPayload = response.error.response?.data as PendingDraftsResponse | undefined;
84-
if (errPayload && Array.isArray(errPayload.pending)) {
97+
const errPayload = response.error.response?.data as RuleDraftsListResponse | undefined;
98+
if (errPayload && Array.isArray(errPayload.drafts)) {
8599
return errPayload;
86100
}
87-
return { pending: [], error: response.error.message ?? 'Failed to fetch pending drafts' };
101+
return { drafts: [] };
88102
}

osprey_ui/src/components/rules/RuleEditorPage.tsx

Lines changed: 98 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,21 @@ import {
1414
Typography,
1515
message,
1616
} from 'antd';
17-
import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
17+
import { CloudUploadOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
1818
import { useHistory, useLocation } from 'react-router-dom';
1919

2020
import {
21+
createRuleDraft,
22+
deployRuleDraft,
2123
getRuleDraftSource,
2224
getRuleDraftVocabulary,
2325
parseRuleDraftIntoBuilder,
24-
submitRuleDraft,
2526
validateRuleDraft,
2627
} from '../../actions/RulesActions';
2728
import usePromiseResult from '../../hooks/usePromiseResult';
2829
import {
2930
ParseIntoBuilderResponse,
31+
RuleDraft,
3032
RuleDraftValidationMessage,
3133
RuleDraftValidationResponse,
3234
RuleDraftVocabulary,
@@ -53,6 +55,16 @@ const { Title, Text, Paragraph } = Typography;
5355

5456
type EditorMode = 'builder' | 'code';
5557

58+
// Saving stages a draft in the rule_drafts table; deploying writes it into the
59+
// rules directory. The saved draft's id is what a subsequent deploy targets.
60+
type SubmitState =
61+
| { kind: 'idle' }
62+
| { kind: 'saving' }
63+
| { kind: 'saved'; draft: RuleDraft }
64+
| { kind: 'deploying'; draft: RuleDraft }
65+
| { kind: 'deployed'; draft: RuleDraft; mainSmlUpdated: boolean; pathOnDisk: string }
66+
| { kind: 'error'; message: string };
67+
5668
const VALIDATE_DEBOUNCE_MS = 600;
5769

5870
interface BootstrapData {
@@ -118,17 +130,12 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
118130
return EMPTY_BUILDER_MODEL;
119131
});
120132
const [summary, setSummary] = React.useState<string>('');
121-
// Off by default: turning a rule on is a deliberate opt-in, so a submit never
133+
// Off by default: turning a rule on is a deliberate opt-in, so a deploy never
122134
// wires a new rule into the live ruleset unless the author checks the box.
123135
const [wireIntoMain, setWireIntoMain] = React.useState<boolean>(false);
124136
const [validation, setValidation] = React.useState<RuleDraftValidationResponse | null>(null);
125137
const [isValidating, setIsValidating] = React.useState<boolean>(false);
126-
const [submitState, setSubmitState] = React.useState<
127-
| { kind: 'idle' }
128-
| { kind: 'submitting' }
129-
| { kind: 'done'; title: string; prUrl: string | null }
130-
| { kind: 'error'; message: string }
131-
>({ kind: 'idle' });
138+
const [submitState, setSubmitState] = React.useState<SubmitState>({ kind: 'idle' });
132139

133140
const effectiveSource = mode === 'builder' ? generateSmlFromBuilder(builder, data.vocabulary.features) : codeSource;
134141

@@ -174,11 +181,14 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
174181

175182
const ruleNameForSubmit = mode === 'builder' ? builder.ruleName : guessRuleNameFromSource(codeSource);
176183

177-
const canSubmit =
178-
!!validation?.ok &&
179-
SML_IDENTIFIER_RE.test(ruleNameForSubmit) &&
180-
submitState.kind !== 'submitting' &&
181-
!!effectiveSource.trim();
184+
const isBusy = submitState.kind === 'saving' || submitState.kind === 'deploying';
185+
const canSave = !!validation?.ok && SML_IDENTIFIER_RE.test(ruleNameForSubmit) && !isBusy && !!effectiveSource.trim();
186+
// A draft must exist (be saved) before it can be deployed; the deploy targets its id.
187+
const savedDraft =
188+
submitState.kind === 'saved' || submitState.kind === 'deployed' || submitState.kind === 'deploying'
189+
? submitState.draft
190+
: null;
191+
const canDeploy = savedDraft !== null && !isBusy;
182192

183193
// The builder and code editor hold independent state, so a tab switch has to
184194
// carry content across: builder -> code dumps the generated SML into the
@@ -208,21 +218,37 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
208218
}
209219
};
210220

211-
const onSubmit = async () => {
212-
if (!canSubmit) return;
213-
setSubmitState({ kind: 'submitting' });
221+
const onSave = async () => {
222+
if (!canSave) return;
223+
setSubmitState({ kind: 'saving' });
214224
try {
215-
const res = await submitRuleDraft({
225+
const draft = await createRuleDraft({
216226
path,
217227
source: effectiveSource,
218228
rule_name: ruleNameForSubmit,
219229
summary,
220-
is_new_rule: data.isNewRule,
221-
wire_into_main: wireIntoMain,
222230
});
223-
setSubmitState({ kind: 'done', title: res.title, prUrl: res.url });
231+
setSubmitState({ kind: 'saved', draft });
232+
message.success('Draft saved.');
233+
} catch (e) {
234+
const msg = e instanceof Error ? e.message : String(e);
235+
setSubmitState({ kind: 'error', message: msg });
236+
}
237+
};
238+
239+
const onDeploy = async () => {
240+
if (savedDraft === null || isBusy) return;
241+
setSubmitState({ kind: 'deploying', draft: savedDraft });
242+
try {
243+
const res = await deployRuleDraft(savedDraft.id, { wire_into_main: wireIntoMain });
244+
setSubmitState({
245+
kind: 'deployed',
246+
draft: res,
247+
mainSmlUpdated: res.main_sml_updated,
248+
pathOnDisk: res.path_on_disk,
249+
});
224250
const wiredMsg = res.main_sml_updated ? ' (main.sml updated)' : '';
225-
message.success(`${res.title}${wiredMsg}.`);
251+
message.success(`Deployed to ${res.path_on_disk}${wiredMsg}.`);
226252
} catch (e) {
227253
const msg = e instanceof Error ? e.message : String(e);
228254
setSubmitState({ kind: 'error', message: msg });
@@ -238,8 +264,8 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
238264
{data.isNewRule ? 'Add rule' : 'Edit rule'}
239265
</Title>
240266
<Text type="secondary">
241-
Drafts open a pull request against the rules repo. Nothing applies until the PR is merged and the engine
242-
reloads.
267+
Save stages this rule in the drafts table. Deploy writes it into the rules directory; nothing applies
268+
until the engine reloads.
243269
</Text>
244270
</div>
245271
<div className={styles.headerActions}>
@@ -262,9 +288,25 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
262288
/>
263289
</Tooltip>
264290
<Button onClick={() => history.push('/rules')}>Cancel</Button>
265-
<Button type="primary" icon={<SaveOutlined />} disabled={!canSubmit} onClick={onSubmit}>
266-
Submit for review
291+
<Button
292+
icon={<SaveOutlined />}
293+
disabled={!canSave}
294+
onClick={onSave}
295+
loading={submitState.kind === 'saving'}
296+
>
297+
Save draft
267298
</Button>
299+
<Tooltip title={canDeploy ? '' : 'Save the draft first, then deploy it.'}>
300+
<Button
301+
type="primary"
302+
icon={<CloudUploadOutlined />}
303+
disabled={!canDeploy}
304+
onClick={onDeploy}
305+
loading={submitState.kind === 'deploying'}
306+
>
307+
Deploy
308+
</Button>
309+
</Tooltip>
268310
</div>
269311
</div>
270312

@@ -274,12 +316,12 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
274316
<div>
275317
<Card size="small" style={{ marginBottom: 12 }}>
276318
<Form layout="vertical" size="small">
277-
<Form.Item label="File path" tooltip="Path inside the rules repo where this file will live.">
319+
<Form.Item label="File path" tooltip="Path inside the rules directory where this file will live.">
278320
<Input value={path} onChange={(e) => setPath(e.target.value)} disabled={!data.isNewRule} />
279321
</Form.Item>
280322
<Form.Item
281-
label={data.isNewRule ? 'Why this rule? (for reviewers)' : "What's changing? (for reviewers)"}
282-
tooltip="Becomes the pull request description. Not saved into the rule file itself."
323+
label={data.isNewRule ? 'Why this rule?' : "What's changing?"}
324+
tooltip="Saved alongside the draft. Not written into the rule file itself."
283325
>
284326
<Input.TextArea
285327
value={summary}
@@ -294,11 +336,11 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
294336
</Form.Item>
295337
<Form.Item style={{ marginBottom: 0 }}>
296338
<Checkbox checked={wireIntoMain} onChange={(e) => setWireIntoMain(e.target.checked)}>
297-
Turn this rule on once the review is approved.
339+
Turn this rule on when I deploy it.
298340
</Checkbox>
299341
<div className={styles.footnote}>
300-
Adds your rule to the list Osprey runs, as part of the same review. If it&apos;s already on the
301-
list, nothing changes.
342+
Adds your rule to the list Osprey runs (a Require line in main.sml) as part of the deploy. If
343+
it&apos;s already on the list, nothing changes.
302344
</div>
303345
</Form.Item>
304346
</Form>
@@ -339,8 +381,7 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
339381
<Card size="small" title="Generated SML preview" style={{ marginTop: 12 }}>
340382
<pre className={styles.previewBlock}>{effectiveSource}</pre>
341383
<div className={styles.footnote}>
342-
This is the code that will be submitted in a pull request on Github. Make further changes in the Code
343-
Editor view.
384+
This is the code that will be saved as the draft. Make further changes in the Code Editor view.
344385
</div>
345386
</Card>
346387
)}
@@ -356,42 +397,42 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
356397
);
357398
};
358399

359-
const SubmitBanner: React.FC<{
360-
submitState:
361-
| { kind: 'idle' }
362-
| { kind: 'submitting' }
363-
| { kind: 'done'; title: string; prUrl: string | null }
364-
| { kind: 'error'; message: string };
365-
}> = ({ submitState }) => {
400+
const SubmitBanner: React.FC<{ submitState: SubmitState }> = ({ submitState }) => {
366401
if (submitState.kind === 'idle') return null;
367-
if (submitState.kind === 'submitting') {
368-
return <Alert type="info" message="Submitting draft..." showIcon style={{ marginBottom: 12 }} />;
402+
if (submitState.kind === 'saving') {
403+
return <Alert type="info" message="Saving draft..." showIcon style={{ marginBottom: 12 }} />;
404+
}
405+
if (submitState.kind === 'deploying') {
406+
return <Alert type="info" message="Deploying..." showIcon style={{ marginBottom: 12 }} />;
407+
}
408+
if (submitState.kind === 'saved') {
409+
return (
410+
<Alert
411+
type="success"
412+
showIcon
413+
style={{ marginBottom: 12 }}
414+
message="Draft saved"
415+
description="Staged in the drafts table. Deploy it to write it into the rules directory."
416+
/>
417+
);
369418
}
370-
if (submitState.kind === 'done') {
419+
if (submitState.kind === 'deployed') {
371420
return (
372421
<Alert
373422
type="success"
374423
showIcon
375424
style={{ marginBottom: 12 }}
376-
message={submitState.title}
425+
message={`Deployed to ${submitState.pathOnDisk}`}
377426
description={
378-
submitState.prUrl ? (
379-
<a href={submitState.prUrl} target="_blank" rel="noopener noreferrer">
380-
{submitState.prUrl}
381-
</a>
382-
) : null
427+
submitState.mainSmlUpdated
428+
? 'Added to main.sml. Takes effect when the engine reloads.'
429+
: 'Takes effect once something requires it and the engine reloads.'
383430
}
384431
/>
385432
);
386433
}
387434
return (
388-
<Alert
389-
type="error"
390-
showIcon
391-
style={{ marginBottom: 12 }}
392-
message="Submit failed"
393-
description={submitState.message}
394-
/>
435+
<Alert type="error" showIcon style={{ marginBottom: 12 }} message="Failed" description={submitState.message} />
395436
);
396437
};
397438

0 commit comments

Comments
 (0)