create-interview-cockpit 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/codeowners.ts +792 -0
- package/template/client/src/components/CodeContextPanel.tsx +44 -0
- package/template/client/src/components/DiagramsModal.tsx +839 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +291 -264
- package/template/client/src/components/LabsPanel.tsx +3 -3
- package/template/client/src/components/PullRequestPanel.tsx +1142 -0
- package/template/client/src/components/SettingsPanel.tsx +1395 -0
- package/template/client/src/githubActionsLab.ts +461 -3
- package/template/client/src/types.ts +219 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +1 -1
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
// ─── Settings panel — the GitHub Lab "Settings" tab ────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Mirrors github.com's repository Settings page: a left sidebar with
|
|
4
|
+
// section links, and a content pane on the right. The sections we
|
|
5
|
+
// implement are the ones that are meaningful in the lab simulation:
|
|
6
|
+
//
|
|
7
|
+
// General — quick read-only overview + danger-zone Reset.
|
|
8
|
+
// Branches — list of branches + default branch picker.
|
|
9
|
+
// Rules / Rulesets — branch protection: required approving reviews,
|
|
10
|
+
// require code-owner review, required status checks.
|
|
11
|
+
// Environments — repo variables, secrets, and runner env values
|
|
12
|
+
// (was the old "Env" top tab).
|
|
13
|
+
// Concurrency — the concurrency simulator (was its own top tab).
|
|
14
|
+
//
|
|
15
|
+
// We intentionally keep Pull-request review + reviewers UI in the PR
|
|
16
|
+
// page (that's where github.com surfaces them too). Settings only owns
|
|
17
|
+
// the *configuration* knobs that gate merging.
|
|
18
|
+
|
|
19
|
+
import { useMemo, useState } from "react";
|
|
20
|
+
import {
|
|
21
|
+
AlertTriangle,
|
|
22
|
+
Check,
|
|
23
|
+
ChevronLeft,
|
|
24
|
+
ChevronRight,
|
|
25
|
+
GitBranch,
|
|
26
|
+
KeyRound,
|
|
27
|
+
Plus,
|
|
28
|
+
RotateCcw,
|
|
29
|
+
Settings as SettingsIcon,
|
|
30
|
+
Shield,
|
|
31
|
+
Star,
|
|
32
|
+
Trash2,
|
|
33
|
+
Users,
|
|
34
|
+
Workflow as WorkflowIcon,
|
|
35
|
+
X,
|
|
36
|
+
} from "lucide-react";
|
|
37
|
+
import type {
|
|
38
|
+
GithubActionsLabEnvironment,
|
|
39
|
+
GithubActionsLabEnvironmentEntry,
|
|
40
|
+
GithubActionsLabWorkspace,
|
|
41
|
+
GithubLabRuleset,
|
|
42
|
+
GithubLabRulesetEnforcement,
|
|
43
|
+
GithubLabRulesetRules,
|
|
44
|
+
} from "../types";
|
|
45
|
+
import GhaConcurrencyPanel from "./GhaConcurrencyPanel";
|
|
46
|
+
import {
|
|
47
|
+
parseConcurrencyBlock,
|
|
48
|
+
type GhaConcurrencyContext,
|
|
49
|
+
type GhaConcurrencyRun,
|
|
50
|
+
} from "../ghaConcurrency";
|
|
51
|
+
|
|
52
|
+
// ─── Shared constants (kept in sync with the modal's old env panel) ────
|
|
53
|
+
|
|
54
|
+
type EnvKind = "variables" | "secrets" | "env";
|
|
55
|
+
|
|
56
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
57
|
+
|
|
58
|
+
interface EnvSectionConfig {
|
|
59
|
+
kind: EnvKind;
|
|
60
|
+
title: string;
|
|
61
|
+
addLabel: string;
|
|
62
|
+
namePlaceholder: string;
|
|
63
|
+
valuePlaceholder: string;
|
|
64
|
+
help: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ENV_SECTIONS: EnvSectionConfig[] = [
|
|
68
|
+
{
|
|
69
|
+
kind: "variables",
|
|
70
|
+
title: "Repository variables",
|
|
71
|
+
addLabel: "Add variable",
|
|
72
|
+
namePlaceholder: "BUILD_ENV",
|
|
73
|
+
valuePlaceholder: "production",
|
|
74
|
+
help: "Plain-text values referenced as ${{ vars.NAME }} in workflows; passed to act through --var-file.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
kind: "secrets",
|
|
78
|
+
title: "Repository secrets",
|
|
79
|
+
addLabel: "Add secret",
|
|
80
|
+
namePlaceholder: "GITHUB_TOKEN",
|
|
81
|
+
valuePlaceholder: "ghp_xxx",
|
|
82
|
+
help: "Masked values referenced as ${{ secrets.NAME }}; passed to act through --secret-file. Saved snapshots store these as plain text — use fakes.",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
kind: "env",
|
|
86
|
+
title: "Runner environment",
|
|
87
|
+
addLabel: "Add env",
|
|
88
|
+
namePlaceholder: "NODE_ENV",
|
|
89
|
+
valuePlaceholder: "production",
|
|
90
|
+
help: "Available to shell steps as environment variables, e.g. $NAME, through act's --env-file.",
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// ─── Props ─────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
interface SettingsPanelProps {
|
|
97
|
+
workspace: GithubActionsLabWorkspace;
|
|
98
|
+
onChange: (
|
|
99
|
+
updater: (prev: GithubActionsLabWorkspace) => GithubActionsLabWorkspace,
|
|
100
|
+
) => void;
|
|
101
|
+
workflowPath: string;
|
|
102
|
+
workflowYaml: string | undefined;
|
|
103
|
+
concurrencyRuns: GhaConcurrencyRun[];
|
|
104
|
+
concurrencyContext: GhaConcurrencyContext;
|
|
105
|
+
onConcurrencyContextChange: (next: GhaConcurrencyContext) => void;
|
|
106
|
+
onClearConcurrencyRuns: () => void;
|
|
107
|
+
/** Reset every workspace file back to the template baseline. */
|
|
108
|
+
onResetWorkspace: () => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type SectionId =
|
|
112
|
+
| "general"
|
|
113
|
+
| "branches"
|
|
114
|
+
| "rules"
|
|
115
|
+
| "environments"
|
|
116
|
+
| "concurrency";
|
|
117
|
+
|
|
118
|
+
interface SectionDef {
|
|
119
|
+
id: SectionId;
|
|
120
|
+
label: string;
|
|
121
|
+
icon: typeof SettingsIcon;
|
|
122
|
+
group: "general" | "code" | "ci";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SECTIONS: SectionDef[] = [
|
|
126
|
+
{ id: "general", label: "General", icon: SettingsIcon, group: "general" },
|
|
127
|
+
{ id: "branches", label: "Branches", icon: GitBranch, group: "code" },
|
|
128
|
+
{ id: "rules", label: "Rulesets", icon: Shield, group: "code" },
|
|
129
|
+
{
|
|
130
|
+
id: "environments",
|
|
131
|
+
label: "Environments",
|
|
132
|
+
icon: KeyRound,
|
|
133
|
+
group: "ci",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "concurrency",
|
|
137
|
+
label: "Concurrency",
|
|
138
|
+
icon: WorkflowIcon,
|
|
139
|
+
group: "ci",
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const GROUP_LABELS: Record<SectionDef["group"], string> = {
|
|
144
|
+
general: "",
|
|
145
|
+
code: "Code and automation",
|
|
146
|
+
ci: "CI / CD",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ─── Main component ────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export default function SettingsPanel(props: SettingsPanelProps) {
|
|
152
|
+
const { workspace, onChange } = props;
|
|
153
|
+
const [active, setActive] = useState<SectionId>("general");
|
|
154
|
+
|
|
155
|
+
// Group sections for the sidebar, preserving insertion order.
|
|
156
|
+
const grouped = useMemo(() => {
|
|
157
|
+
const out: Array<{ group: SectionDef["group"]; items: SectionDef[] }> = [];
|
|
158
|
+
for (const sec of SECTIONS) {
|
|
159
|
+
const last = out[out.length - 1];
|
|
160
|
+
if (last && last.group === sec.group) last.items.push(sec);
|
|
161
|
+
else out.push({ group: sec.group, items: [sec] });
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] gap-0 overflow-hidden text-xs text-slate-300">
|
|
168
|
+
{/* Sidebar */}
|
|
169
|
+
<nav className="min-h-0 overflow-auto border-r border-slate-800/60 p-2">
|
|
170
|
+
{grouped.map((g, idx) => (
|
|
171
|
+
<div key={g.group} className={idx === 0 ? "" : "mt-3"}>
|
|
172
|
+
{GROUP_LABELS[g.group] && (
|
|
173
|
+
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-slate-500">
|
|
174
|
+
{GROUP_LABELS[g.group]}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
{g.items.map((s) => {
|
|
178
|
+
const Icon = s.icon;
|
|
179
|
+
const isActive = active === s.id;
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
key={s.id}
|
|
183
|
+
onClick={() => setActive(s.id)}
|
|
184
|
+
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] ${
|
|
185
|
+
isActive
|
|
186
|
+
? "bg-amber-500/15 text-amber-200"
|
|
187
|
+
: "text-slate-300 hover:bg-slate-800/60"
|
|
188
|
+
}`}
|
|
189
|
+
>
|
|
190
|
+
<Icon className="h-3.5 w-3.5" />
|
|
191
|
+
{s.label}
|
|
192
|
+
</button>
|
|
193
|
+
);
|
|
194
|
+
})}
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
</nav>
|
|
198
|
+
|
|
199
|
+
{/* Content pane */}
|
|
200
|
+
<div className="min-h-0 overflow-auto p-4">
|
|
201
|
+
{active === "general" && (
|
|
202
|
+
<GeneralSection
|
|
203
|
+
workspace={workspace}
|
|
204
|
+
onChange={onChange}
|
|
205
|
+
onResetWorkspace={props.onResetWorkspace}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
{active === "branches" && (
|
|
209
|
+
<BranchesSection workspace={workspace} onChange={onChange} />
|
|
210
|
+
)}
|
|
211
|
+
{active === "rules" && (
|
|
212
|
+
<RulesetsSection workspace={workspace} onChange={onChange} />
|
|
213
|
+
)}
|
|
214
|
+
{active === "environments" && (
|
|
215
|
+
<EnvironmentsSection workspace={workspace} onChange={onChange} />
|
|
216
|
+
)}
|
|
217
|
+
{active === "concurrency" && (
|
|
218
|
+
<div className="-m-4 flex h-full min-h-0 flex-col">
|
|
219
|
+
<GhaConcurrencyPanel
|
|
220
|
+
parsed={parseConcurrencyBlock(props.workflowYaml)}
|
|
221
|
+
workflowPath={props.workflowPath}
|
|
222
|
+
runs={props.concurrencyRuns}
|
|
223
|
+
context={props.concurrencyContext}
|
|
224
|
+
onContextChange={props.onConcurrencyContextChange}
|
|
225
|
+
onClearRuns={props.onClearConcurrencyRuns}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Page header (reused across sections) ──────────────────────────────
|
|
235
|
+
|
|
236
|
+
function PageHeader({
|
|
237
|
+
title,
|
|
238
|
+
description,
|
|
239
|
+
}: {
|
|
240
|
+
title: string;
|
|
241
|
+
description?: string;
|
|
242
|
+
}) {
|
|
243
|
+
return (
|
|
244
|
+
<header className="mb-4 border-b border-slate-800/60 pb-3">
|
|
245
|
+
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
|
|
246
|
+
{description && (
|
|
247
|
+
<p className="mt-1 text-[12px] leading-5 text-slate-500">
|
|
248
|
+
{description}
|
|
249
|
+
</p>
|
|
250
|
+
)}
|
|
251
|
+
</header>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── General ───────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
function GeneralSection({
|
|
258
|
+
workspace,
|
|
259
|
+
onChange,
|
|
260
|
+
onResetWorkspace,
|
|
261
|
+
}: {
|
|
262
|
+
workspace: GithubActionsLabWorkspace;
|
|
263
|
+
onChange: SettingsPanelProps["onChange"];
|
|
264
|
+
onResetWorkspace: () => void;
|
|
265
|
+
}) {
|
|
266
|
+
const [confirming, setConfirming] = useState(false);
|
|
267
|
+
return (
|
|
268
|
+
<>
|
|
269
|
+
<PageHeader title="General" description="Repository-level metadata." />
|
|
270
|
+
<div className="space-y-4">
|
|
271
|
+
<Field label="Repository name">
|
|
272
|
+
<input
|
|
273
|
+
value={workspace.label}
|
|
274
|
+
onChange={(e) => onChange((p) => ({ ...p, label: e.target.value }))}
|
|
275
|
+
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1.5 text-[12px] text-slate-100 outline-none focus:border-amber-500/60"
|
|
276
|
+
/>
|
|
277
|
+
</Field>
|
|
278
|
+
<Field label="Include run history in LLM context">
|
|
279
|
+
<label className="inline-flex items-center gap-2 text-[12px] text-slate-300">
|
|
280
|
+
<input
|
|
281
|
+
type="checkbox"
|
|
282
|
+
checked={!!workspace.includeRunHistoryInContext}
|
|
283
|
+
onChange={(e) =>
|
|
284
|
+
onChange((p) => ({
|
|
285
|
+
...p,
|
|
286
|
+
includeRunHistoryInContext: e.target.checked,
|
|
287
|
+
}))
|
|
288
|
+
}
|
|
289
|
+
className="accent-amber-500"
|
|
290
|
+
/>
|
|
291
|
+
Send the last few act runs (statuses + durations) when chatting.
|
|
292
|
+
</label>
|
|
293
|
+
</Field>
|
|
294
|
+
|
|
295
|
+
{/* Danger zone */}
|
|
296
|
+
<section className="mt-6 rounded-lg border border-red-500/30">
|
|
297
|
+
<header className="border-b border-red-500/30 bg-red-500/5 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-red-300">
|
|
298
|
+
<AlertTriangle className="mr-1 inline h-3 w-3" />
|
|
299
|
+
Danger zone
|
|
300
|
+
</header>
|
|
301
|
+
<div className="flex items-center justify-between gap-3 px-3 py-3">
|
|
302
|
+
<div className="text-[12px]">
|
|
303
|
+
<div className="font-medium text-slate-200">Reset workspace</div>
|
|
304
|
+
<p className="text-[11px] text-slate-500">
|
|
305
|
+
Drop every file change and PR/state mutation and reload the lab
|
|
306
|
+
from the template baseline. Cannot be undone.
|
|
307
|
+
</p>
|
|
308
|
+
</div>
|
|
309
|
+
{confirming ? (
|
|
310
|
+
<div className="flex items-center gap-1">
|
|
311
|
+
<button
|
|
312
|
+
onClick={() => {
|
|
313
|
+
setConfirming(false);
|
|
314
|
+
onResetWorkspace();
|
|
315
|
+
}}
|
|
316
|
+
className="rounded bg-red-600 px-2 py-1 text-[11px] text-white hover:bg-red-500"
|
|
317
|
+
>
|
|
318
|
+
Yes, reset
|
|
319
|
+
</button>
|
|
320
|
+
<button
|
|
321
|
+
onClick={() => setConfirming(false)}
|
|
322
|
+
className="rounded bg-slate-800 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-700"
|
|
323
|
+
>
|
|
324
|
+
Cancel
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
) : (
|
|
328
|
+
<button
|
|
329
|
+
onClick={() => setConfirming(true)}
|
|
330
|
+
className="inline-flex items-center gap-1 rounded border border-red-500/40 px-2 py-1 text-[11px] text-red-300 hover:bg-red-500/10"
|
|
331
|
+
>
|
|
332
|
+
<RotateCcw className="h-3 w-3" /> Reset
|
|
333
|
+
</button>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
</section>
|
|
337
|
+
</div>
|
|
338
|
+
</>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Branches ──────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
function BranchesSection({
|
|
345
|
+
workspace,
|
|
346
|
+
onChange,
|
|
347
|
+
}: {
|
|
348
|
+
workspace: GithubActionsLabWorkspace;
|
|
349
|
+
onChange: SettingsPanelProps["onChange"];
|
|
350
|
+
}) {
|
|
351
|
+
const branches = workspace.branches ?? ["main"];
|
|
352
|
+
const defaultBranch = workspace.defaultBranch ?? "main";
|
|
353
|
+
const [draft, setDraft] = useState("");
|
|
354
|
+
|
|
355
|
+
const addBranch = () => {
|
|
356
|
+
const name = draft.trim();
|
|
357
|
+
if (!name || branches.includes(name)) return;
|
|
358
|
+
onChange((p) => ({ ...p, branches: [...branches, name] }));
|
|
359
|
+
setDraft("");
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const removeBranch = (name: string) => {
|
|
363
|
+
if (name === defaultBranch) return; // can't delete default
|
|
364
|
+
onChange((p) => ({
|
|
365
|
+
...p,
|
|
366
|
+
branches: branches.filter((b) => b !== name),
|
|
367
|
+
}));
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const setDefault = (name: string) => {
|
|
371
|
+
onChange((p) => ({ ...p, defaultBranch: name }));
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
<PageHeader
|
|
377
|
+
title="Branches"
|
|
378
|
+
description="Branches in the simulated repo. The default branch is the merge target shown on the Pull Request page."
|
|
379
|
+
/>
|
|
380
|
+
<ul className="space-y-1">
|
|
381
|
+
{branches.map((name) => {
|
|
382
|
+
const isDefault = name === defaultBranch;
|
|
383
|
+
return (
|
|
384
|
+
<li
|
|
385
|
+
key={name}
|
|
386
|
+
className="flex items-center justify-between rounded border border-slate-800/60 bg-slate-900/40 px-3 py-2"
|
|
387
|
+
>
|
|
388
|
+
<div className="flex items-center gap-2">
|
|
389
|
+
<GitBranch className="h-3.5 w-3.5 text-slate-500" />
|
|
390
|
+
<span className="font-mono text-[12px] text-slate-100">
|
|
391
|
+
{name}
|
|
392
|
+
</span>
|
|
393
|
+
{isDefault && (
|
|
394
|
+
<span className="inline-flex items-center gap-1 rounded bg-emerald-500/15 px-1.5 py-px text-[10px] text-emerald-300">
|
|
395
|
+
<Star className="h-2.5 w-2.5" /> Default
|
|
396
|
+
</span>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
<div className="flex items-center gap-1">
|
|
400
|
+
{!isDefault && (
|
|
401
|
+
<button
|
|
402
|
+
onClick={() => setDefault(name)}
|
|
403
|
+
className="rounded px-2 py-1 text-[11px] text-slate-400 hover:bg-slate-800 hover:text-amber-200"
|
|
404
|
+
>
|
|
405
|
+
Make default
|
|
406
|
+
</button>
|
|
407
|
+
)}
|
|
408
|
+
<button
|
|
409
|
+
onClick={() => removeBranch(name)}
|
|
410
|
+
disabled={isDefault}
|
|
411
|
+
className="rounded p-1 text-slate-500 hover:bg-red-500/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-30"
|
|
412
|
+
title={
|
|
413
|
+
isDefault
|
|
414
|
+
? "Cannot delete the default branch"
|
|
415
|
+
: "Delete branch"
|
|
416
|
+
}
|
|
417
|
+
>
|
|
418
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
419
|
+
</button>
|
|
420
|
+
</div>
|
|
421
|
+
</li>
|
|
422
|
+
);
|
|
423
|
+
})}
|
|
424
|
+
</ul>
|
|
425
|
+
<div className="mt-3 flex items-center gap-2">
|
|
426
|
+
<input
|
|
427
|
+
value={draft}
|
|
428
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
429
|
+
onKeyDown={(e) => e.key === "Enter" && addBranch()}
|
|
430
|
+
placeholder="new-branch-name"
|
|
431
|
+
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1.5 font-mono text-[12px] outline-none focus:border-amber-500/60"
|
|
432
|
+
/>
|
|
433
|
+
<button
|
|
434
|
+
onClick={addBranch}
|
|
435
|
+
className="inline-flex items-center gap-1 rounded bg-slate-800 px-2 py-1.5 text-[11px] text-slate-200 hover:bg-slate-700"
|
|
436
|
+
>
|
|
437
|
+
<Plus className="h-3 w-3" /> Create
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
</>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Rulesets ─────────────────────────────────────────────────────────
|
|
445
|
+
//
|
|
446
|
+
// Mirrors github.com → Settings → Rules. We support multiple rulesets
|
|
447
|
+
// per repo, each with: name, enforcement state, target branches
|
|
448
|
+
// (include/exclude patterns + tokens like ~DEFAULT_BRANCH / ~ALL),
|
|
449
|
+
// bypass list, and a bag of branch rules. The list view summarises
|
|
450
|
+
// each ruleset; clicking a row opens a github-style edit page.
|
|
451
|
+
|
|
452
|
+
const ENFORCEMENT_LABEL: Record<GithubLabRulesetEnforcement, string> = {
|
|
453
|
+
active: "Active",
|
|
454
|
+
evaluate: "Evaluate",
|
|
455
|
+
disabled: "Disabled",
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const ENFORCEMENT_DESC: Record<GithubLabRulesetEnforcement, string> = {
|
|
459
|
+
active:
|
|
460
|
+
"Rules will be enforced. Operations that violate them are blocked unless someone has bypass.",
|
|
461
|
+
evaluate:
|
|
462
|
+
"Rules are evaluated but not enforced. Use this to dry-run changes — violations show up in the PR sidebar without blocking the merge.",
|
|
463
|
+
disabled:
|
|
464
|
+
"Ruleset is paused. Targets and rules are saved but no operations are evaluated against it.",
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
function RulesetsSection({
|
|
468
|
+
workspace,
|
|
469
|
+
onChange,
|
|
470
|
+
}: {
|
|
471
|
+
workspace: GithubActionsLabWorkspace;
|
|
472
|
+
onChange: SettingsPanelProps["onChange"];
|
|
473
|
+
}) {
|
|
474
|
+
const rulesets = workspace.rulesets ?? [];
|
|
475
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
476
|
+
|
|
477
|
+
const selected = useMemo(
|
|
478
|
+
() => rulesets.find((r) => r.id === selectedId) ?? null,
|
|
479
|
+
[rulesets, selectedId],
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const upsert = (ruleset: GithubLabRuleset) =>
|
|
483
|
+
onChange((p) => {
|
|
484
|
+
const list = (p.rulesets ?? []).slice();
|
|
485
|
+
const idx = list.findIndex((r) => r.id === ruleset.id);
|
|
486
|
+
if (idx >= 0) list[idx] = ruleset;
|
|
487
|
+
else list.push(ruleset);
|
|
488
|
+
return { ...p, rulesets: list };
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const remove = (id: string) =>
|
|
492
|
+
onChange((p) => ({
|
|
493
|
+
...p,
|
|
494
|
+
rulesets: (p.rulesets ?? []).filter((r) => r.id !== id),
|
|
495
|
+
}));
|
|
496
|
+
|
|
497
|
+
const createNew = () => {
|
|
498
|
+
const id = `ruleset_${Date.now().toString(36)}`;
|
|
499
|
+
const fresh: GithubLabRuleset = {
|
|
500
|
+
id,
|
|
501
|
+
name: "Untitled ruleset",
|
|
502
|
+
enforcement: "active",
|
|
503
|
+
targetInclude: ["~DEFAULT_BRANCH"],
|
|
504
|
+
targetExclude: [],
|
|
505
|
+
bypass: [],
|
|
506
|
+
rules: {},
|
|
507
|
+
};
|
|
508
|
+
upsert(fresh);
|
|
509
|
+
setSelectedId(id);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (selected) {
|
|
513
|
+
return (
|
|
514
|
+
<RulesetEditor
|
|
515
|
+
workspace={workspace}
|
|
516
|
+
ruleset={selected}
|
|
517
|
+
onBack={() => setSelectedId(null)}
|
|
518
|
+
onChange={upsert}
|
|
519
|
+
onDelete={() => {
|
|
520
|
+
remove(selected.id);
|
|
521
|
+
setSelectedId(null);
|
|
522
|
+
}}
|
|
523
|
+
/>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<>
|
|
529
|
+
<PageHeader
|
|
530
|
+
title="Rulesets"
|
|
531
|
+
description="Rules that gate operations on branches — like requiring a pull request, blocking force pushes, or running status checks."
|
|
532
|
+
/>
|
|
533
|
+
<div className="mb-3 flex justify-end">
|
|
534
|
+
<button
|
|
535
|
+
onClick={createNew}
|
|
536
|
+
className="flex items-center gap-1 rounded bg-emerald-600 px-2 py-1 text-[12px] font-medium text-white hover:bg-emerald-500"
|
|
537
|
+
>
|
|
538
|
+
<Plus className="h-3 w-3" /> New branch ruleset
|
|
539
|
+
</button>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
{rulesets.length === 0 ? (
|
|
543
|
+
<div className="rounded-lg border border-dashed border-slate-700 px-4 py-8 text-center text-[12px] text-slate-500">
|
|
544
|
+
<Shield className="mx-auto mb-2 h-5 w-5 text-slate-600" />
|
|
545
|
+
No rulesets yet. Create one to start enforcing rules on branches.
|
|
546
|
+
</div>
|
|
547
|
+
) : (
|
|
548
|
+
<ul className="space-y-1.5">
|
|
549
|
+
{rulesets.map((rs) => (
|
|
550
|
+
<li key={rs.id}>
|
|
551
|
+
<button
|
|
552
|
+
onClick={() => setSelectedId(rs.id)}
|
|
553
|
+
className="group flex w-full items-center gap-3 rounded-lg border border-slate-800/60 bg-slate-900/40 px-3 py-2 text-left hover:border-amber-500/40"
|
|
554
|
+
>
|
|
555
|
+
<Shield className="h-4 w-4 shrink-0 text-amber-300" />
|
|
556
|
+
<div className="min-w-0 flex-1">
|
|
557
|
+
<div className="flex items-center gap-2">
|
|
558
|
+
<span className="truncate text-[13px] font-semibold text-slate-100">
|
|
559
|
+
{rs.name}
|
|
560
|
+
</span>
|
|
561
|
+
<EnforcementBadge enforcement={rs.enforcement} />
|
|
562
|
+
</div>
|
|
563
|
+
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
|
|
564
|
+
<GitBranch className="h-3 w-3" />
|
|
565
|
+
<span className="truncate">
|
|
566
|
+
{rs.targetInclude.length > 0
|
|
567
|
+
? rs.targetInclude.join(", ")
|
|
568
|
+
: "No targets"}
|
|
569
|
+
</span>
|
|
570
|
+
<span className="text-slate-700">•</span>
|
|
571
|
+
<span>{countActiveRules(rs.rules)} rules</span>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-amber-300" />
|
|
575
|
+
</button>
|
|
576
|
+
</li>
|
|
577
|
+
))}
|
|
578
|
+
</ul>
|
|
579
|
+
)}
|
|
580
|
+
</>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function EnforcementBadge({
|
|
585
|
+
enforcement,
|
|
586
|
+
}: {
|
|
587
|
+
enforcement: GithubLabRulesetEnforcement;
|
|
588
|
+
}) {
|
|
589
|
+
const cls =
|
|
590
|
+
enforcement === "active"
|
|
591
|
+
? "bg-emerald-500/15 text-emerald-300"
|
|
592
|
+
: enforcement === "evaluate"
|
|
593
|
+
? "bg-amber-500/15 text-amber-200"
|
|
594
|
+
: "bg-slate-700/40 text-slate-400";
|
|
595
|
+
return (
|
|
596
|
+
<span
|
|
597
|
+
className={`rounded px-1.5 py-px text-[10px] uppercase tracking-wider ${cls}`}
|
|
598
|
+
>
|
|
599
|
+
{ENFORCEMENT_LABEL[enforcement]}
|
|
600
|
+
</span>
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function countActiveRules(r: GithubLabRulesetRules): number {
|
|
605
|
+
let n = 0;
|
|
606
|
+
if (r.restrictCreations) n++;
|
|
607
|
+
if (r.restrictUpdates) n++;
|
|
608
|
+
if (r.restrictDeletions) n++;
|
|
609
|
+
if (r.requireLinearHistory) n++;
|
|
610
|
+
if (r.requireDeployments) n++;
|
|
611
|
+
if (r.requireSignedCommits) n++;
|
|
612
|
+
if (r.pullRequest) n++;
|
|
613
|
+
if (r.statusChecks) n++;
|
|
614
|
+
if (r.blockForcePushes) n++;
|
|
615
|
+
if (r.requireCodeScanning) n++;
|
|
616
|
+
if (r.requireCodeQuality) n++;
|
|
617
|
+
return n;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─── Ruleset editor ────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
function RulesetEditor({
|
|
623
|
+
workspace,
|
|
624
|
+
ruleset,
|
|
625
|
+
onBack,
|
|
626
|
+
onChange,
|
|
627
|
+
onDelete,
|
|
628
|
+
}: {
|
|
629
|
+
workspace: GithubActionsLabWorkspace;
|
|
630
|
+
ruleset: GithubLabRuleset;
|
|
631
|
+
onBack: () => void;
|
|
632
|
+
onChange: (next: GithubLabRuleset) => void;
|
|
633
|
+
onDelete: () => void;
|
|
634
|
+
}) {
|
|
635
|
+
const update = (patch: Partial<GithubLabRuleset>) =>
|
|
636
|
+
onChange({ ...ruleset, ...patch });
|
|
637
|
+
const updateRules = (patch: Partial<GithubLabRulesetRules>) =>
|
|
638
|
+
onChange({ ...ruleset, rules: { ...ruleset.rules, ...patch } });
|
|
639
|
+
|
|
640
|
+
const org = workspace.ghOrg;
|
|
641
|
+
const bypassOptions = useMemo(() => {
|
|
642
|
+
const list: string[] = [];
|
|
643
|
+
if (org) {
|
|
644
|
+
list.push(...org.users);
|
|
645
|
+
org.teams.forEach((t) => list.push(`${org.slug}/${t.slug}`));
|
|
646
|
+
}
|
|
647
|
+
return list;
|
|
648
|
+
}, [org]);
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
<>
|
|
652
|
+
<button
|
|
653
|
+
onClick={onBack}
|
|
654
|
+
className="mb-2 flex items-center gap-1 text-[11px] text-slate-400 hover:text-amber-300"
|
|
655
|
+
>
|
|
656
|
+
<ChevronLeft className="h-3 w-3" /> Back to rulesets
|
|
657
|
+
</button>
|
|
658
|
+
|
|
659
|
+
<PageHeader
|
|
660
|
+
title="Branch ruleset"
|
|
661
|
+
description="Configure the rules that gate operations on the targeted branches. Mirrors github.com → Settings → Rules → New branch ruleset."
|
|
662
|
+
/>
|
|
663
|
+
|
|
664
|
+
{/* Name */}
|
|
665
|
+
<Field label="Ruleset Name">
|
|
666
|
+
<input
|
|
667
|
+
value={ruleset.name}
|
|
668
|
+
onChange={(e) => update({ name: e.target.value })}
|
|
669
|
+
placeholder="my ruleset"
|
|
670
|
+
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[12px] outline-none focus:border-amber-500/60"
|
|
671
|
+
/>
|
|
672
|
+
</Field>
|
|
673
|
+
|
|
674
|
+
{/* Enforcement */}
|
|
675
|
+
<Field
|
|
676
|
+
label="Enforcement status"
|
|
677
|
+
help={ENFORCEMENT_DESC[ruleset.enforcement]}
|
|
678
|
+
>
|
|
679
|
+
<select
|
|
680
|
+
value={ruleset.enforcement}
|
|
681
|
+
onChange={(e) =>
|
|
682
|
+
update({
|
|
683
|
+
enforcement: e.target.value as GithubLabRulesetEnforcement,
|
|
684
|
+
})
|
|
685
|
+
}
|
|
686
|
+
className="w-44 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[12px] outline-none focus:border-amber-500/60"
|
|
687
|
+
>
|
|
688
|
+
<option value="disabled">Disabled</option>
|
|
689
|
+
<option value="active">Active</option>
|
|
690
|
+
<option value="evaluate">Evaluate</option>
|
|
691
|
+
</select>
|
|
692
|
+
</Field>
|
|
693
|
+
|
|
694
|
+
{/* Bypass list */}
|
|
695
|
+
<SubSection
|
|
696
|
+
title="Bypass list"
|
|
697
|
+
description="Roles, teams, or users who can perform the targeted operations even when the rules would block them."
|
|
698
|
+
>
|
|
699
|
+
<ChipList
|
|
700
|
+
values={ruleset.bypass}
|
|
701
|
+
options={bypassOptions}
|
|
702
|
+
onChange={(next) => update({ bypass: next })}
|
|
703
|
+
placeholder="@octocat or acme/admins"
|
|
704
|
+
emptyText="No bypasses. Only the rules apply."
|
|
705
|
+
/>
|
|
706
|
+
</SubSection>
|
|
707
|
+
|
|
708
|
+
{/* Targets */}
|
|
709
|
+
<SubSection
|
|
710
|
+
title="Target branches"
|
|
711
|
+
description="Which branches this ruleset applies to. Use ~DEFAULT_BRANCH for the default branch, ~ALL for every branch, or glob patterns like release/*."
|
|
712
|
+
>
|
|
713
|
+
<div className="mb-2">
|
|
714
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
|
|
715
|
+
Include patterns
|
|
716
|
+
</div>
|
|
717
|
+
<ChipList
|
|
718
|
+
values={ruleset.targetInclude}
|
|
719
|
+
options={["~DEFAULT_BRANCH", "~ALL"]}
|
|
720
|
+
onChange={(next) => update({ targetInclude: next })}
|
|
721
|
+
placeholder="main, release/*"
|
|
722
|
+
/>
|
|
723
|
+
</div>
|
|
724
|
+
<div>
|
|
725
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
|
|
726
|
+
Exclude patterns
|
|
727
|
+
</div>
|
|
728
|
+
<ChipList
|
|
729
|
+
values={ruleset.targetExclude}
|
|
730
|
+
onChange={(next) => update({ targetExclude: next })}
|
|
731
|
+
placeholder="dependabot/*"
|
|
732
|
+
emptyText="No exclusions"
|
|
733
|
+
/>
|
|
734
|
+
</div>
|
|
735
|
+
</SubSection>
|
|
736
|
+
|
|
737
|
+
{/* Branch rules */}
|
|
738
|
+
<SubSection
|
|
739
|
+
title="Branch rules"
|
|
740
|
+
description="Choose which rules to enforce on operations matching this ruleset's targets."
|
|
741
|
+
>
|
|
742
|
+
<div className="space-y-2">
|
|
743
|
+
<RuleToggle
|
|
744
|
+
on={!!ruleset.rules.restrictCreations}
|
|
745
|
+
onToggle={(v) => updateRules({ restrictCreations: v })}
|
|
746
|
+
title="Restrict creations"
|
|
747
|
+
description="Only allow users with bypass permission to create matching refs."
|
|
748
|
+
kind="informational"
|
|
749
|
+
/>
|
|
750
|
+
<RuleToggle
|
|
751
|
+
on={!!ruleset.rules.restrictUpdates}
|
|
752
|
+
onToggle={(v) => updateRules({ restrictUpdates: v })}
|
|
753
|
+
title="Restrict updates"
|
|
754
|
+
description="Only allow users with bypass permission to update matching refs."
|
|
755
|
+
kind="informational"
|
|
756
|
+
/>
|
|
757
|
+
<RuleToggle
|
|
758
|
+
on={!!ruleset.rules.restrictDeletions}
|
|
759
|
+
onToggle={(v) => updateRules({ restrictDeletions: v })}
|
|
760
|
+
title="Restrict deletions"
|
|
761
|
+
description="Only allow users with bypass permissions to delete matching refs."
|
|
762
|
+
kind="informational"
|
|
763
|
+
/>
|
|
764
|
+
<RuleToggle
|
|
765
|
+
on={!!ruleset.rules.requireLinearHistory}
|
|
766
|
+
onToggle={(v) => updateRules({ requireLinearHistory: v })}
|
|
767
|
+
title="Require linear history"
|
|
768
|
+
description="Prevent merge commits from being pushed to matching refs."
|
|
769
|
+
kind="informational"
|
|
770
|
+
/>
|
|
771
|
+
<RuleToggle
|
|
772
|
+
on={!!ruleset.rules.requireDeployments}
|
|
773
|
+
onToggle={(v) =>
|
|
774
|
+
updateRules({
|
|
775
|
+
requireDeployments: v
|
|
776
|
+
? { environments: ["production"] }
|
|
777
|
+
: undefined,
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
title="Require deployments to succeed"
|
|
781
|
+
description="Choose which environments must be successfully deployed-to before refs can be pushed into a matching ref."
|
|
782
|
+
kind="enforced"
|
|
783
|
+
>
|
|
784
|
+
{ruleset.rules.requireDeployments && (
|
|
785
|
+
<div className="mt-2">
|
|
786
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
|
|
787
|
+
Required environments
|
|
788
|
+
</div>
|
|
789
|
+
<ChipList
|
|
790
|
+
values={ruleset.rules.requireDeployments.environments}
|
|
791
|
+
onChange={(next) =>
|
|
792
|
+
updateRules({ requireDeployments: { environments: next } })
|
|
793
|
+
}
|
|
794
|
+
placeholder="production"
|
|
795
|
+
/>
|
|
796
|
+
</div>
|
|
797
|
+
)}
|
|
798
|
+
</RuleToggle>
|
|
799
|
+
<RuleToggle
|
|
800
|
+
on={!!ruleset.rules.requireSignedCommits}
|
|
801
|
+
onToggle={(v) => updateRules({ requireSignedCommits: v })}
|
|
802
|
+
title="Require signed commits"
|
|
803
|
+
description="Commits pushed to matching refs must have verified signatures."
|
|
804
|
+
kind="informational"
|
|
805
|
+
/>
|
|
806
|
+
<RuleToggle
|
|
807
|
+
on={!!ruleset.rules.pullRequest}
|
|
808
|
+
onToggle={(v) =>
|
|
809
|
+
updateRules({
|
|
810
|
+
pullRequest: v
|
|
811
|
+
? {
|
|
812
|
+
requiredApprovingReviewCount: 1,
|
|
813
|
+
requireCodeOwnerReview: true,
|
|
814
|
+
dismissStaleReviewsOnPush: false,
|
|
815
|
+
requireLastPushApproval: false,
|
|
816
|
+
}
|
|
817
|
+
: undefined,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
title="Require a pull request before merging"
|
|
821
|
+
description="Require all commits be made to a non-target branch and submitted via a pull request before they can be merged."
|
|
822
|
+
kind="enforced"
|
|
823
|
+
>
|
|
824
|
+
{ruleset.rules.pullRequest && (
|
|
825
|
+
<PullRequestRuleEditor
|
|
826
|
+
value={ruleset.rules.pullRequest}
|
|
827
|
+
onChange={(next) => updateRules({ pullRequest: next })}
|
|
828
|
+
/>
|
|
829
|
+
)}
|
|
830
|
+
</RuleToggle>
|
|
831
|
+
<RuleToggle
|
|
832
|
+
on={!!ruleset.rules.statusChecks}
|
|
833
|
+
onToggle={(v) =>
|
|
834
|
+
updateRules({
|
|
835
|
+
statusChecks: v ? { checks: [], strict: false } : undefined,
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
title="Require status checks to pass"
|
|
839
|
+
description="Choose which status checks must pass before the ref is updated. When enabled, commits must first be pushed to another branch, then merged or pushed directly to a ref that matches this rule after status checks have passed."
|
|
840
|
+
kind="enforced"
|
|
841
|
+
>
|
|
842
|
+
{ruleset.rules.statusChecks && (
|
|
843
|
+
<StatusChecksRuleEditor
|
|
844
|
+
workspace={workspace}
|
|
845
|
+
value={ruleset.rules.statusChecks}
|
|
846
|
+
onChange={(next) => updateRules({ statusChecks: next })}
|
|
847
|
+
/>
|
|
848
|
+
)}
|
|
849
|
+
</RuleToggle>
|
|
850
|
+
<RuleToggle
|
|
851
|
+
on={!!ruleset.rules.blockForcePushes}
|
|
852
|
+
onToggle={(v) => updateRules({ blockForcePushes: v })}
|
|
853
|
+
title="Block force pushes"
|
|
854
|
+
description="Prevent users with push access from force pushing to refs."
|
|
855
|
+
kind="informational"
|
|
856
|
+
/>
|
|
857
|
+
<RuleToggle
|
|
858
|
+
on={!!ruleset.rules.requireCodeScanning}
|
|
859
|
+
onToggle={(v) =>
|
|
860
|
+
updateRules({
|
|
861
|
+
requireCodeScanning: v ? { tools: [] } : undefined,
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
title="Require code scanning results"
|
|
865
|
+
description="Choose which tools must provide code scanning results before the reference is updated. When enabled, commits must first be pushed to another reference where the alerts can be evaluated, then merged or pushed directly to a reference that matches this rule after the alert evaluations have completed."
|
|
866
|
+
kind="informational"
|
|
867
|
+
>
|
|
868
|
+
{ruleset.rules.requireCodeScanning && (
|
|
869
|
+
<div className="mt-2">
|
|
870
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
|
|
871
|
+
Required tools
|
|
872
|
+
</div>
|
|
873
|
+
<ChipList
|
|
874
|
+
values={ruleset.rules.requireCodeScanning.tools}
|
|
875
|
+
onChange={(next) =>
|
|
876
|
+
updateRules({ requireCodeScanning: { tools: next } })
|
|
877
|
+
}
|
|
878
|
+
placeholder="CodeQL"
|
|
879
|
+
/>
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
</RuleToggle>
|
|
883
|
+
<RuleToggle
|
|
884
|
+
on={!!ruleset.rules.requireCodeQuality}
|
|
885
|
+
onToggle={(v) => updateRules({ requireCodeQuality: v })}
|
|
886
|
+
title="Require code quality"
|
|
887
|
+
description="Choose providers that must report acceptable quality on the head commit before the reference is updated."
|
|
888
|
+
kind="informational"
|
|
889
|
+
/>
|
|
890
|
+
</div>
|
|
891
|
+
</SubSection>
|
|
892
|
+
|
|
893
|
+
{/* Danger zone */}
|
|
894
|
+
<div className="mt-6 rounded-lg border border-rose-500/20 bg-rose-500/5 p-3">
|
|
895
|
+
<div className="mb-1 flex items-center gap-2 text-[12px] font-semibold text-rose-200">
|
|
896
|
+
<AlertTriangle className="h-3.5 w-3.5" /> Delete ruleset
|
|
897
|
+
</div>
|
|
898
|
+
<p className="mb-2 text-[11px] text-slate-400">
|
|
899
|
+
Removes this ruleset from the workspace. Branches stop being gated by
|
|
900
|
+
its rules immediately.
|
|
901
|
+
</p>
|
|
902
|
+
<button
|
|
903
|
+
onClick={onDelete}
|
|
904
|
+
className="flex items-center gap-1 rounded border border-rose-500/40 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200 hover:bg-rose-500/20"
|
|
905
|
+
>
|
|
906
|
+
<Trash2 className="h-3 w-3" /> Delete ruleset
|
|
907
|
+
</button>
|
|
908
|
+
</div>
|
|
909
|
+
</>
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// ─── Ruleset editor sub-components ────────────────────────────────────
|
|
914
|
+
|
|
915
|
+
function SubSection({
|
|
916
|
+
title,
|
|
917
|
+
description,
|
|
918
|
+
children,
|
|
919
|
+
}: {
|
|
920
|
+
title: string;
|
|
921
|
+
description?: string;
|
|
922
|
+
children: React.ReactNode;
|
|
923
|
+
}) {
|
|
924
|
+
return (
|
|
925
|
+
<section className="mt-5 rounded-lg border border-slate-800/60 bg-slate-900/40">
|
|
926
|
+
<header className="border-b border-slate-800/60 px-3 py-2">
|
|
927
|
+
<div className="text-[12px] font-semibold text-slate-100">{title}</div>
|
|
928
|
+
{description && (
|
|
929
|
+
<p className="mt-0.5 text-[11px] text-slate-500">{description}</p>
|
|
930
|
+
)}
|
|
931
|
+
</header>
|
|
932
|
+
<div className="p-3">{children}</div>
|
|
933
|
+
</section>
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function RuleToggle({
|
|
938
|
+
on,
|
|
939
|
+
onToggle,
|
|
940
|
+
title,
|
|
941
|
+
description,
|
|
942
|
+
kind,
|
|
943
|
+
children,
|
|
944
|
+
}: {
|
|
945
|
+
on: boolean;
|
|
946
|
+
onToggle: (next: boolean) => void;
|
|
947
|
+
title: string;
|
|
948
|
+
description: string;
|
|
949
|
+
/** "enforced" = the lab actually evaluates this rule against the PR.
|
|
950
|
+
* "informational" = saved + surfaced as a sidebar note, but the lab
|
|
951
|
+
* can't simulate the underlying git/branch operation. */
|
|
952
|
+
kind: "enforced" | "informational";
|
|
953
|
+
children?: React.ReactNode;
|
|
954
|
+
}) {
|
|
955
|
+
return (
|
|
956
|
+
<div
|
|
957
|
+
className={`rounded border p-2 ${
|
|
958
|
+
on
|
|
959
|
+
? "border-amber-500/30 bg-amber-500/5"
|
|
960
|
+
: "border-slate-800/60 bg-slate-950/30"
|
|
961
|
+
}`}
|
|
962
|
+
>
|
|
963
|
+
<label className="flex items-start gap-2 text-[12px]">
|
|
964
|
+
<input
|
|
965
|
+
type="checkbox"
|
|
966
|
+
checked={on}
|
|
967
|
+
onChange={(e) => onToggle(e.target.checked)}
|
|
968
|
+
className="mt-0.5 accent-amber-500"
|
|
969
|
+
/>
|
|
970
|
+
<span className="flex-1">
|
|
971
|
+
<span className="flex items-center gap-1.5">
|
|
972
|
+
<span className="text-slate-100">{title}</span>
|
|
973
|
+
<span
|
|
974
|
+
className={`rounded px-1 py-px text-[9px] uppercase tracking-wider ${
|
|
975
|
+
kind === "enforced"
|
|
976
|
+
? "bg-emerald-500/15 text-emerald-300"
|
|
977
|
+
: "bg-slate-700/50 text-slate-400"
|
|
978
|
+
}`}
|
|
979
|
+
title={
|
|
980
|
+
kind === "enforced"
|
|
981
|
+
? "Lab actually evaluates this rule against the PR"
|
|
982
|
+
: "Lab can't simulate this; surfaced as a sidebar note for awareness"
|
|
983
|
+
}
|
|
984
|
+
>
|
|
985
|
+
{kind === "enforced" ? "Enforced" : "Info"}
|
|
986
|
+
</span>
|
|
987
|
+
</span>
|
|
988
|
+
<span className="block text-[11px] text-slate-500">
|
|
989
|
+
{description}
|
|
990
|
+
</span>
|
|
991
|
+
</span>
|
|
992
|
+
</label>
|
|
993
|
+
{on && children && <div className="ml-6 mt-2">{children}</div>}
|
|
994
|
+
</div>
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function PullRequestRuleEditor({
|
|
999
|
+
value,
|
|
1000
|
+
onChange,
|
|
1001
|
+
}: {
|
|
1002
|
+
value: NonNullable<GithubLabRulesetRules["pullRequest"]>;
|
|
1003
|
+
onChange: (next: NonNullable<GithubLabRulesetRules["pullRequest"]>) => void;
|
|
1004
|
+
}) {
|
|
1005
|
+
const update = (patch: Partial<typeof value>) =>
|
|
1006
|
+
onChange({ ...value, ...patch });
|
|
1007
|
+
return (
|
|
1008
|
+
<div className="space-y-2">
|
|
1009
|
+
<Field label="Required approving reviews">
|
|
1010
|
+
<input
|
|
1011
|
+
type="number"
|
|
1012
|
+
min={0}
|
|
1013
|
+
max={6}
|
|
1014
|
+
value={value.requiredApprovingReviewCount}
|
|
1015
|
+
onChange={(e) =>
|
|
1016
|
+
update({
|
|
1017
|
+
requiredApprovingReviewCount: Math.max(
|
|
1018
|
+
0,
|
|
1019
|
+
Number(e.target.value) || 0,
|
|
1020
|
+
),
|
|
1021
|
+
})
|
|
1022
|
+
}
|
|
1023
|
+
className="w-20 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-right text-[12px]"
|
|
1024
|
+
/>
|
|
1025
|
+
</Field>
|
|
1026
|
+
<SubToggle
|
|
1027
|
+
on={value.requireCodeOwnerReview}
|
|
1028
|
+
onToggle={(v) => update({ requireCodeOwnerReview: v })}
|
|
1029
|
+
title="Require review from Code Owners"
|
|
1030
|
+
description="Required reviewers must approve the changes to files they own."
|
|
1031
|
+
/>
|
|
1032
|
+
<SubToggle
|
|
1033
|
+
on={value.dismissStaleReviewsOnPush}
|
|
1034
|
+
onToggle={(v) => update({ dismissStaleReviewsOnPush: v })}
|
|
1035
|
+
title="Dismiss stale pull request approvals when new commits are pushed"
|
|
1036
|
+
description="New, reviewable commits push the PR back to needs-review."
|
|
1037
|
+
/>
|
|
1038
|
+
<SubToggle
|
|
1039
|
+
on={value.requireLastPushApproval}
|
|
1040
|
+
onToggle={(v) => update({ requireLastPushApproval: v })}
|
|
1041
|
+
title="Require approval of the most recent reviewable push"
|
|
1042
|
+
description="The latest push must be approved by someone other than its author."
|
|
1043
|
+
/>
|
|
1044
|
+
</div>
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function StatusChecksRuleEditor({
|
|
1049
|
+
workspace,
|
|
1050
|
+
value,
|
|
1051
|
+
onChange,
|
|
1052
|
+
}: {
|
|
1053
|
+
workspace: GithubActionsLabWorkspace;
|
|
1054
|
+
value: NonNullable<GithubLabRulesetRules["statusChecks"]>;
|
|
1055
|
+
onChange: (next: NonNullable<GithubLabRulesetRules["statusChecks"]>) => void;
|
|
1056
|
+
}) {
|
|
1057
|
+
const observed = useMemo(() => {
|
|
1058
|
+
const set = new Set<string>();
|
|
1059
|
+
workspace.pullRequest?.lastCheckRun?.jobs.forEach((j) => set.add(j.name));
|
|
1060
|
+
value.checks.forEach((c) => set.add(c));
|
|
1061
|
+
return [...set].sort();
|
|
1062
|
+
}, [workspace.pullRequest?.lastCheckRun, value.checks]);
|
|
1063
|
+
|
|
1064
|
+
const toggle = (name: string) => {
|
|
1065
|
+
const set = new Set(value.checks);
|
|
1066
|
+
if (set.has(name)) set.delete(name);
|
|
1067
|
+
else set.add(name);
|
|
1068
|
+
onChange({ ...value, checks: [...set] });
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
return (
|
|
1072
|
+
<div className="space-y-2">
|
|
1073
|
+
<SubToggle
|
|
1074
|
+
on={value.strict}
|
|
1075
|
+
onToggle={(v) => onChange({ ...value, strict: v })}
|
|
1076
|
+
title="Require branches to be up to date before merging"
|
|
1077
|
+
description="The PR's branch must be current with the base branch when checks run."
|
|
1078
|
+
/>
|
|
1079
|
+
<div>
|
|
1080
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
|
|
1081
|
+
Required checks
|
|
1082
|
+
</div>
|
|
1083
|
+
{observed.length === 0 ? (
|
|
1084
|
+
<p className="rounded border border-dashed border-slate-700 px-3 py-3 text-[11px] text-slate-500">
|
|
1085
|
+
No checks observed yet. Run a workflow first, or add one by name
|
|
1086
|
+
below.
|
|
1087
|
+
</p>
|
|
1088
|
+
) : (
|
|
1089
|
+
<div className="flex flex-wrap gap-1">
|
|
1090
|
+
{observed.map((name) => {
|
|
1091
|
+
const on = value.checks.includes(name);
|
|
1092
|
+
return (
|
|
1093
|
+
<button
|
|
1094
|
+
key={name}
|
|
1095
|
+
onClick={() => toggle(name)}
|
|
1096
|
+
className={`rounded px-2 py-1 text-[11px] ${
|
|
1097
|
+
on
|
|
1098
|
+
? "bg-amber-500/20 text-amber-200"
|
|
1099
|
+
: "bg-slate-800 text-slate-400 hover:bg-slate-700"
|
|
1100
|
+
}`}
|
|
1101
|
+
>
|
|
1102
|
+
{name}
|
|
1103
|
+
</button>
|
|
1104
|
+
);
|
|
1105
|
+
})}
|
|
1106
|
+
</div>
|
|
1107
|
+
)}
|
|
1108
|
+
<ChipList
|
|
1109
|
+
values={value.checks}
|
|
1110
|
+
onChange={(next) => onChange({ ...value, checks: next })}
|
|
1111
|
+
placeholder="custom-check-name"
|
|
1112
|
+
renderInline
|
|
1113
|
+
/>
|
|
1114
|
+
</div>
|
|
1115
|
+
</div>
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function SubToggle({
|
|
1120
|
+
on,
|
|
1121
|
+
onToggle,
|
|
1122
|
+
title,
|
|
1123
|
+
description,
|
|
1124
|
+
}: {
|
|
1125
|
+
on: boolean;
|
|
1126
|
+
onToggle: (v: boolean) => void;
|
|
1127
|
+
title: string;
|
|
1128
|
+
description?: string;
|
|
1129
|
+
}) {
|
|
1130
|
+
return (
|
|
1131
|
+
<label className="flex items-start gap-2 text-[12px]">
|
|
1132
|
+
<input
|
|
1133
|
+
type="checkbox"
|
|
1134
|
+
checked={on}
|
|
1135
|
+
onChange={(e) => onToggle(e.target.checked)}
|
|
1136
|
+
className="mt-0.5 accent-amber-500"
|
|
1137
|
+
/>
|
|
1138
|
+
<span>
|
|
1139
|
+
<span className="text-slate-200">{title}</span>
|
|
1140
|
+
{description && (
|
|
1141
|
+
<span className="block text-[11px] text-slate-500">
|
|
1142
|
+
{description}
|
|
1143
|
+
</span>
|
|
1144
|
+
)}
|
|
1145
|
+
</span>
|
|
1146
|
+
</label>
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// ─── Generic chip list for string arrays (patterns / handles / etc.) ──
|
|
1151
|
+
|
|
1152
|
+
function ChipList({
|
|
1153
|
+
values,
|
|
1154
|
+
onChange,
|
|
1155
|
+
options,
|
|
1156
|
+
placeholder,
|
|
1157
|
+
emptyText,
|
|
1158
|
+
renderInline,
|
|
1159
|
+
}: {
|
|
1160
|
+
values: string[];
|
|
1161
|
+
onChange: (next: string[]) => void;
|
|
1162
|
+
options?: string[];
|
|
1163
|
+
placeholder?: string;
|
|
1164
|
+
emptyText?: string;
|
|
1165
|
+
renderInline?: boolean;
|
|
1166
|
+
}) {
|
|
1167
|
+
const [draft, setDraft] = useState("");
|
|
1168
|
+
const add = (raw: string) => {
|
|
1169
|
+
const v = raw.trim();
|
|
1170
|
+
if (!v || values.includes(v)) return;
|
|
1171
|
+
onChange([...values, v]);
|
|
1172
|
+
setDraft("");
|
|
1173
|
+
};
|
|
1174
|
+
const remove = (v: string) => onChange(values.filter((x) => x !== v));
|
|
1175
|
+
|
|
1176
|
+
const suggestions = (options ?? []).filter((o) => !values.includes(o));
|
|
1177
|
+
|
|
1178
|
+
return (
|
|
1179
|
+
<div>
|
|
1180
|
+
{!renderInline && values.length === 0 && emptyText && (
|
|
1181
|
+
<p className="mb-2 text-[11px] text-slate-500">{emptyText}</p>
|
|
1182
|
+
)}
|
|
1183
|
+
{values.length > 0 && (
|
|
1184
|
+
<div className="mb-2 flex flex-wrap gap-1">
|
|
1185
|
+
{values.map((v) => (
|
|
1186
|
+
<span
|
|
1187
|
+
key={v}
|
|
1188
|
+
className="flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] text-amber-100"
|
|
1189
|
+
>
|
|
1190
|
+
<span className="font-mono">{v}</span>
|
|
1191
|
+
<button
|
|
1192
|
+
onClick={() => remove(v)}
|
|
1193
|
+
className="rounded text-amber-300/80 hover:text-amber-100"
|
|
1194
|
+
aria-label={`Remove ${v}`}
|
|
1195
|
+
>
|
|
1196
|
+
<X className="h-3 w-3" />
|
|
1197
|
+
</button>
|
|
1198
|
+
</span>
|
|
1199
|
+
))}
|
|
1200
|
+
</div>
|
|
1201
|
+
)}
|
|
1202
|
+
{suggestions.length > 0 && (
|
|
1203
|
+
<div className="mb-2 flex flex-wrap gap-1">
|
|
1204
|
+
{suggestions.map((s) => (
|
|
1205
|
+
<button
|
|
1206
|
+
key={s}
|
|
1207
|
+
onClick={() => add(s)}
|
|
1208
|
+
className="flex items-center gap-1 rounded border border-dashed border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-400 hover:border-amber-500/40 hover:text-amber-200"
|
|
1209
|
+
>
|
|
1210
|
+
<Plus className="h-2.5 w-2.5" />
|
|
1211
|
+
<span className="font-mono">{s}</span>
|
|
1212
|
+
</button>
|
|
1213
|
+
))}
|
|
1214
|
+
</div>
|
|
1215
|
+
)}
|
|
1216
|
+
<div className="flex items-center gap-1">
|
|
1217
|
+
<input
|
|
1218
|
+
value={draft}
|
|
1219
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
1220
|
+
onKeyDown={(e) => e.key === "Enter" && add(draft)}
|
|
1221
|
+
placeholder={placeholder}
|
|
1222
|
+
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px]"
|
|
1223
|
+
/>
|
|
1224
|
+
<button
|
|
1225
|
+
onClick={() => add(draft)}
|
|
1226
|
+
className="rounded bg-slate-800 px-2 py-1 text-slate-300 hover:bg-slate-700"
|
|
1227
|
+
aria-label="Add"
|
|
1228
|
+
>
|
|
1229
|
+
<Plus className="h-3 w-3" />
|
|
1230
|
+
</button>
|
|
1231
|
+
</div>
|
|
1232
|
+
</div>
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ─── Environments ──────────────────────────────────────────────────────
|
|
1237
|
+
|
|
1238
|
+
function EnvironmentsSection({
|
|
1239
|
+
workspace,
|
|
1240
|
+
onChange,
|
|
1241
|
+
}: {
|
|
1242
|
+
workspace: GithubActionsLabWorkspace;
|
|
1243
|
+
onChange: SettingsPanelProps["onChange"];
|
|
1244
|
+
}) {
|
|
1245
|
+
const rows = (kind: EnvKind): GithubActionsLabEnvironmentEntry[] =>
|
|
1246
|
+
workspace.environment?.[kind] ?? [];
|
|
1247
|
+
|
|
1248
|
+
const writeRows = (
|
|
1249
|
+
kind: EnvKind,
|
|
1250
|
+
next: GithubActionsLabEnvironmentEntry[],
|
|
1251
|
+
) => {
|
|
1252
|
+
onChange((prev) => {
|
|
1253
|
+
const env: GithubActionsLabEnvironment = { ...(prev.environment ?? {}) };
|
|
1254
|
+
env[kind] = next;
|
|
1255
|
+
return { ...prev, environment: env };
|
|
1256
|
+
});
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const addRow = (kind: EnvKind) =>
|
|
1260
|
+
writeRows(kind, [...rows(kind), { name: "", value: "" }]);
|
|
1261
|
+
const updateRow = (
|
|
1262
|
+
kind: EnvKind,
|
|
1263
|
+
index: number,
|
|
1264
|
+
patch: Partial<GithubActionsLabEnvironmentEntry>,
|
|
1265
|
+
) => {
|
|
1266
|
+
const next = rows(kind).slice();
|
|
1267
|
+
next[index] = { ...next[index]!, ...patch };
|
|
1268
|
+
writeRows(kind, next);
|
|
1269
|
+
};
|
|
1270
|
+
const removeRow = (kind: EnvKind, index: number) =>
|
|
1271
|
+
writeRows(
|
|
1272
|
+
kind,
|
|
1273
|
+
rows(kind).filter((_, i) => i !== index),
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
return (
|
|
1277
|
+
<>
|
|
1278
|
+
<PageHeader
|
|
1279
|
+
title="Environments"
|
|
1280
|
+
description="Repository variables, secrets, and runner env. Forwarded to act for every run."
|
|
1281
|
+
/>
|
|
1282
|
+
<div className="space-y-3">
|
|
1283
|
+
{ENV_SECTIONS.map((section) => {
|
|
1284
|
+
const list = rows(section.kind);
|
|
1285
|
+
return (
|
|
1286
|
+
<section
|
|
1287
|
+
key={section.kind}
|
|
1288
|
+
className="rounded-lg border border-slate-800/60 bg-slate-900/40"
|
|
1289
|
+
>
|
|
1290
|
+
<div className="flex items-start justify-between gap-2 border-b border-slate-800/70 px-3 py-2">
|
|
1291
|
+
<div>
|
|
1292
|
+
<h3 className="text-[12px] font-semibold text-slate-100">
|
|
1293
|
+
{section.title}
|
|
1294
|
+
</h3>
|
|
1295
|
+
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">
|
|
1296
|
+
{section.help}
|
|
1297
|
+
</p>
|
|
1298
|
+
</div>
|
|
1299
|
+
<button
|
|
1300
|
+
onClick={() => addRow(section.kind)}
|
|
1301
|
+
className="shrink-0 rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:border-amber-500/40 hover:text-amber-200"
|
|
1302
|
+
>
|
|
1303
|
+
{section.addLabel}
|
|
1304
|
+
</button>
|
|
1305
|
+
</div>
|
|
1306
|
+
<div className="space-y-2 p-3">
|
|
1307
|
+
{list.length === 0 ? (
|
|
1308
|
+
<button
|
|
1309
|
+
onClick={() => addRow(section.kind)}
|
|
1310
|
+
className="w-full rounded-lg border border-dashed border-slate-700 px-3 py-3 text-left text-[11px] text-slate-500 hover:border-amber-500/40 hover:text-amber-200"
|
|
1311
|
+
>
|
|
1312
|
+
No entries yet — click to add one.
|
|
1313
|
+
</button>
|
|
1314
|
+
) : (
|
|
1315
|
+
list.map((row, index) => {
|
|
1316
|
+
const nameOk =
|
|
1317
|
+
!row.name.trim() || ENV_NAME_RE.test(row.name.trim());
|
|
1318
|
+
return (
|
|
1319
|
+
<div
|
|
1320
|
+
key={`${section.kind}-${index}`}
|
|
1321
|
+
className="rounded-lg border border-slate-800 bg-slate-950/60 p-2"
|
|
1322
|
+
>
|
|
1323
|
+
<div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)_auto] gap-2">
|
|
1324
|
+
<input
|
|
1325
|
+
value={row.name}
|
|
1326
|
+
onChange={(e) =>
|
|
1327
|
+
updateRow(section.kind, index, {
|
|
1328
|
+
name: e.target.value,
|
|
1329
|
+
})
|
|
1330
|
+
}
|
|
1331
|
+
placeholder={section.namePlaceholder}
|
|
1332
|
+
className={`min-w-0 rounded border bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 ${
|
|
1333
|
+
nameOk
|
|
1334
|
+
? "border-slate-700 focus:border-amber-500/60"
|
|
1335
|
+
: "border-red-500/60 focus:border-red-400"
|
|
1336
|
+
}`}
|
|
1337
|
+
/>
|
|
1338
|
+
<input
|
|
1339
|
+
type={
|
|
1340
|
+
section.kind === "secrets" ? "password" : "text"
|
|
1341
|
+
}
|
|
1342
|
+
value={row.value}
|
|
1343
|
+
onChange={(e) =>
|
|
1344
|
+
updateRow(section.kind, index, {
|
|
1345
|
+
value: e.target.value,
|
|
1346
|
+
})
|
|
1347
|
+
}
|
|
1348
|
+
placeholder={section.valuePlaceholder}
|
|
1349
|
+
className="min-w-0 rounded border border-slate-700 bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 focus:border-amber-500/60"
|
|
1350
|
+
/>
|
|
1351
|
+
<button
|
|
1352
|
+
onClick={() => removeRow(section.kind, index)}
|
|
1353
|
+
className="rounded px-2 text-slate-500 hover:bg-red-500/10 hover:text-red-300"
|
|
1354
|
+
title="Remove entry"
|
|
1355
|
+
>
|
|
1356
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
1357
|
+
</button>
|
|
1358
|
+
</div>
|
|
1359
|
+
{!nameOk && (
|
|
1360
|
+
<div className="mt-1 text-[10px] text-red-300">
|
|
1361
|
+
Use letters, numbers, and underscores; do not start
|
|
1362
|
+
with a number.
|
|
1363
|
+
</div>
|
|
1364
|
+
)}
|
|
1365
|
+
</div>
|
|
1366
|
+
);
|
|
1367
|
+
})
|
|
1368
|
+
)}
|
|
1369
|
+
</div>
|
|
1370
|
+
</section>
|
|
1371
|
+
);
|
|
1372
|
+
})}
|
|
1373
|
+
</div>
|
|
1374
|
+
</>
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ─── Tiny label/field wrapper ──────────────────────────────────────────
|
|
1379
|
+
|
|
1380
|
+
function Field({
|
|
1381
|
+
label,
|
|
1382
|
+
children,
|
|
1383
|
+
}: {
|
|
1384
|
+
label: string;
|
|
1385
|
+
children: React.ReactNode;
|
|
1386
|
+
}) {
|
|
1387
|
+
return (
|
|
1388
|
+
<div>
|
|
1389
|
+
<div className="mb-1 text-[11px] font-semibold text-slate-200">
|
|
1390
|
+
{label}
|
|
1391
|
+
</div>
|
|
1392
|
+
{children}
|
|
1393
|
+
</div>
|
|
1394
|
+
);
|
|
1395
|
+
}
|