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.
@@ -0,0 +1,1142 @@
1
+ // ─── Pull Request panel — the GitHub Lab "PR page" ──────────────────────
2
+ //
3
+ // Shown in the GitHub Lab modal's right pane when the user picks the
4
+ // "Pull Request" tab. Replicates the essentials of a real github.com
5
+ // PR page:
6
+ //
7
+ // • Header: title, author, base/compare summary.
8
+ // • Files changed: checkbox picker over workspace files.
9
+ // • Checks: per-job pass/fail badge from the most recent act run,
10
+ // plus a callout for any required checks that are missing or red.
11
+ // • CODEOWNERS evaluation: which rule wins for each changed file
12
+ // and which @handles get auto-requested as reviewers.
13
+ // • Review composer: radio (Approve / Request changes / Comment) +
14
+ // freeform body. Submitting drops a row into the review timeline.
15
+ // • Review timeline: every review with state badge + author + body.
16
+ // • Sidebar: reviewers list (Code-owner derived), branch protection
17
+ // settings (incl. required status checks picker), org roster, and
18
+ // the green/grey merge box that ties it all together.
19
+ //
20
+ // The panel is self-contained: it reads & writes the `pullRequest`,
21
+ // `ghOrg`, and `branchProtection` slices of the workspace via the
22
+ // `onChange` prop so persistence, save round-tripping, and undo all
23
+ // flow through the same path as the rest of the lab.
24
+
25
+ import { useMemo, useState } from "react";
26
+ import {
27
+ Check,
28
+ CircleDot,
29
+ GitBranch,
30
+ GitPullRequest,
31
+ Info,
32
+ MessageSquare,
33
+ Plus,
34
+ Shield,
35
+ ShieldCheck,
36
+ Trash2,
37
+ Users,
38
+ X,
39
+ } from "lucide-react";
40
+ import type {
41
+ GithubActionsLabWorkspace,
42
+ GithubLabOrg,
43
+ GithubLabPullRequest,
44
+ GithubLabReview,
45
+ GithubLabRuleset,
46
+ GithubLabRulesetRules,
47
+ GithubLabTeam,
48
+ } from "../types";
49
+ import {
50
+ approvedLogins,
51
+ changesRequestedLogins,
52
+ evaluateCodeOwners,
53
+ evaluateMergeability,
54
+ findCodeOwnersPath,
55
+ latestReviewByAuthor,
56
+ parseCodeOwners,
57
+ type CheckStatus,
58
+ type MergeabilityResult,
59
+ } from "../codeowners";
60
+
61
+ interface PullRequestPanelProps {
62
+ workspace: GithubActionsLabWorkspace;
63
+ onChange: (
64
+ updater: (prev: GithubActionsLabWorkspace) => GithubActionsLabWorkspace,
65
+ ) => void;
66
+ }
67
+
68
+ const DEFAULT_ORG: GithubLabOrg = {
69
+ slug: "acme",
70
+ viewerLogin: "octocat",
71
+ users: ["octocat"],
72
+ teams: [],
73
+ };
74
+
75
+ const DEFAULT_PR: GithubLabPullRequest = {
76
+ changedFiles: [],
77
+ reviews: [],
78
+ title: "Draft pull request",
79
+ };
80
+
81
+ interface PullRequestPanelExtraProps {
82
+ /** Click-through to Settings → Rulesets when a rule needs editing. */
83
+ onOpenRulesets?: () => void;
84
+ }
85
+
86
+ export default function PullRequestPanel({
87
+ workspace,
88
+ onChange,
89
+ onOpenRulesets,
90
+ }: PullRequestPanelProps & PullRequestPanelExtraProps) {
91
+ const org = workspace.ghOrg ?? DEFAULT_ORG;
92
+ const pr = workspace.pullRequest ?? DEFAULT_PR;
93
+ const baseBranch = workspace.defaultBranch ?? "main";
94
+ const defaultBranch = workspace.defaultBranch ?? "main";
95
+
96
+ const codeownersPath = useMemo(
97
+ () => findCodeOwnersPath(workspace.files),
98
+ [workspace.files],
99
+ );
100
+ const codeownersBody = codeownersPath ? workspace.files[codeownersPath] : "";
101
+ const parsed = useMemo(
102
+ () => parseCodeOwners(codeownersBody ?? ""),
103
+ [codeownersBody],
104
+ );
105
+ const evaluation = useMemo(
106
+ () => evaluateCodeOwners(parsed.rules, pr.changedFiles, org),
107
+ [parsed.rules, pr.changedFiles, org],
108
+ );
109
+ const merge = useMemo(
110
+ () =>
111
+ evaluateMergeability(
112
+ evaluation,
113
+ pr,
114
+ workspace.rulesets,
115
+ org,
116
+ baseBranch,
117
+ defaultBranch,
118
+ ),
119
+ [evaluation, pr, workspace.rulesets, org, baseBranch, defaultBranch],
120
+ );
121
+
122
+ // ── Mutators ────────────────────────────────────────────────────
123
+ const updatePR = (next: Partial<GithubLabPullRequest>) =>
124
+ onChange((prev) => ({
125
+ ...prev,
126
+ pullRequest: { ...DEFAULT_PR, ...prev.pullRequest, ...next },
127
+ }));
128
+
129
+ const updateOrg = (next: Partial<GithubLabOrg>) =>
130
+ onChange((prev) => ({
131
+ ...prev,
132
+ ghOrg: { ...DEFAULT_ORG, ...prev.ghOrg, ...next },
133
+ }));
134
+
135
+ const toggleChangedFile = (path: string) => {
136
+ const set = new Set(pr.changedFiles);
137
+ if (set.has(path)) set.delete(path);
138
+ else set.add(path);
139
+ updatePR({ changedFiles: [...set].sort() });
140
+ };
141
+
142
+ const submitReview = (review: Omit<GithubLabReview, "id" | "createdAt">) => {
143
+ const next: GithubLabReview = {
144
+ ...review,
145
+ id: `rev_${Date.now().toString(36)}_${Math.random()
146
+ .toString(36)
147
+ .slice(2, 6)}`,
148
+ createdAt: new Date().toISOString(),
149
+ };
150
+ updatePR({ reviews: [...(pr.reviews ?? []), next] });
151
+ };
152
+
153
+ const dismissReview = (id: string) => {
154
+ updatePR({ reviews: (pr.reviews ?? []).filter((r) => r.id !== id) });
155
+ };
156
+
157
+ // Files we offer in the picker — everything in the lab except .actrc
158
+ // (which isn't really PR-able). Sorted for stability.
159
+ const eligibleFiles = useMemo(
160
+ () =>
161
+ Object.keys(workspace.files)
162
+ .filter((p) => p !== ".actrc")
163
+ .sort(),
164
+ [workspace.files],
165
+ );
166
+
167
+ const approvedSet = useMemo(
168
+ () =>
169
+ new Set(
170
+ approvedLogins(pr.reviews, org.viewerLogin).map((a) => a.toLowerCase()),
171
+ ),
172
+ [pr.reviews, org.viewerLogin],
173
+ );
174
+ const changesRequestedSet = useMemo(
175
+ () =>
176
+ new Set(
177
+ changesRequestedLogins(pr.reviews, org.viewerLogin).map((a) =>
178
+ a.toLowerCase(),
179
+ ),
180
+ ),
181
+ [pr.reviews, org.viewerLogin],
182
+ );
183
+
184
+ return (
185
+ <div className="flex-1 min-h-0 grid grid-cols-[1fr_320px] gap-3 overflow-hidden text-xs text-slate-300">
186
+ {/* ── Main column ──────────────────────────────────────────── */}
187
+ <div className="min-h-0 overflow-auto p-3">
188
+ <header className="mb-3 flex items-start gap-2">
189
+ <GitPullRequest className="mt-0.5 h-4 w-4 text-emerald-300" />
190
+ <div className="flex-1">
191
+ <input
192
+ value={pr.title ?? ""}
193
+ onChange={(e) => updatePR({ title: e.target.value })}
194
+ placeholder="Pull request title"
195
+ className="w-full bg-transparent text-sm font-medium text-slate-100 outline-none border-b border-transparent focus:border-emerald-500/40"
196
+ />
197
+ <div className="mt-0.5 text-[11px] text-slate-500">
198
+ <span className="rounded bg-emerald-500/15 px-1.5 py-px text-emerald-300">
199
+ Open
200
+ </span>{" "}
201
+ by <span className="text-slate-300">@{org.viewerLogin}</span> ·
202
+ wants to merge {pr.changedFiles.length} file
203
+ {pr.changedFiles.length === 1 ? "" : "s"} into{" "}
204
+ <span className="text-slate-300">main</span>
205
+ </div>
206
+ </div>
207
+ </header>
208
+
209
+ {!codeownersPath && (
210
+ <div className="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2 text-amber-200">
211
+ No CODEOWNERS file found. Add one at <code>.github/CODEOWNERS</code>{" "}
212
+ (or <code>CODEOWNERS</code>, or <code>docs/CODEOWNERS</code>) to
213
+ start matching reviewers.
214
+ </div>
215
+ )}
216
+
217
+ {/* ── Files changed ── */}
218
+ <section className="mb-3 rounded-xl border border-slate-800/60 bg-slate-900/40 p-2">
219
+ <div className="mb-2 flex items-center justify-between">
220
+ <h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
221
+ Files changed ({pr.changedFiles.length})
222
+ </h3>
223
+ <button
224
+ onClick={() =>
225
+ updatePR({
226
+ changedFiles:
227
+ pr.changedFiles.length === eligibleFiles.length
228
+ ? []
229
+ : [...eligibleFiles],
230
+ })
231
+ }
232
+ className="text-[10px] text-slate-500 hover:text-amber-200"
233
+ >
234
+ {pr.changedFiles.length === eligibleFiles.length
235
+ ? "Clear"
236
+ : "Select all"}
237
+ </button>
238
+ </div>
239
+ <div className="max-h-44 overflow-auto pr-1">
240
+ {eligibleFiles.map((path) => {
241
+ const checked = pr.changedFiles.includes(path);
242
+ return (
243
+ <label
244
+ key={path}
245
+ className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-800/50 cursor-pointer"
246
+ >
247
+ <input
248
+ type="checkbox"
249
+ checked={checked}
250
+ onChange={() => toggleChangedFile(path)}
251
+ className="accent-amber-500"
252
+ />
253
+ <span className="font-mono text-[11px] text-slate-300">
254
+ {path}
255
+ </span>
256
+ </label>
257
+ );
258
+ })}
259
+ </div>
260
+ </section>
261
+
262
+ {/* ── Checks (CI) ── */}
263
+ <ChecksSection pr={pr} checkStatuses={merge.checkStatuses} />
264
+
265
+ {/* ── CODEOWNERS evaluation ── */}
266
+ <section className="mb-3 rounded-xl border border-slate-800/60 bg-slate-900/40 p-2">
267
+ <h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
268
+ CODEOWNERS evaluation
269
+ </h3>
270
+ {pr.changedFiles.length === 0 ? (
271
+ <div className="px-1 py-3 text-slate-500">
272
+ Select files above to see which CODEOWNERS rule wins for each one
273
+ and who gets auto-requested as a reviewer.
274
+ </div>
275
+ ) : (
276
+ <ul className="space-y-1.5">
277
+ {evaluation.perFile.map((m) => (
278
+ <li
279
+ key={m.path}
280
+ className="rounded border border-slate-800/60 bg-slate-950/50 p-1.5"
281
+ >
282
+ <div className="flex items-center justify-between gap-2">
283
+ <span className="font-mono text-[11px] text-slate-200">
284
+ {m.path}
285
+ </span>
286
+ {m.rule ? (
287
+ <span className="rounded bg-emerald-500/10 px-1.5 py-px text-[10px] text-emerald-300">
288
+ line {m.rule.line}
289
+ </span>
290
+ ) : (
291
+ <span className="rounded bg-slate-700/40 px-1.5 py-px text-[10px] text-slate-400">
292
+ no owner
293
+ </span>
294
+ )}
295
+ </div>
296
+ {m.rule && (
297
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-[10px]">
298
+ <code className="text-slate-500">{m.rule.pattern}</code>
299
+ <span className="text-slate-600">→</span>
300
+ {m.owners.length === 0 ? (
301
+ <span className="text-slate-500">
302
+ ownership cleared
303
+ </span>
304
+ ) : (
305
+ m.owners.map((o) => (
306
+ <span
307
+ key={o}
308
+ className="rounded bg-amber-500/15 px-1.5 py-px text-amber-200"
309
+ >
310
+ @{o}
311
+ </span>
312
+ ))
313
+ )}
314
+ </div>
315
+ )}
316
+ </li>
317
+ ))}
318
+ </ul>
319
+ )}
320
+ </section>
321
+
322
+ {/* ── Review composer ── */}
323
+ <ReviewComposer org={org} onSubmit={submitReview} />
324
+
325
+ {/* ── Review timeline ── */}
326
+ <ReviewTimeline reviews={pr.reviews ?? []} onDismiss={dismissReview} />
327
+ </div>
328
+
329
+ {/* ── Sidebar ─────────────────────────────────────────────── */}
330
+ <aside className="min-h-0 overflow-auto border-l border-slate-800/60 p-3">
331
+ <SidebarSection title="Reviewers">
332
+ {evaluation.requestedHandles.length === 0 ? (
333
+ <p className="text-slate-500">
334
+ No reviewers auto-requested yet — change some files first.
335
+ </p>
336
+ ) : (
337
+ <div className="space-y-1.5">
338
+ {evaluation.requestedHandles.map((handle) => {
339
+ const isTeam = handle.includes("/");
340
+ const logins = isTeam
341
+ ? (org.teams.find(
342
+ (t) =>
343
+ `${org.slug}/${t.slug}`.toLowerCase() ===
344
+ handle.toLowerCase(),
345
+ )?.members ?? [])
346
+ : [handle];
347
+ return (
348
+ <div
349
+ key={handle}
350
+ className="rounded border border-slate-800/60 bg-slate-950/50 p-1.5"
351
+ >
352
+ <div className="flex items-center gap-1 text-[11px] text-amber-200">
353
+ {isTeam ? (
354
+ <Users className="h-3 w-3" />
355
+ ) : (
356
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400" />
357
+ )}
358
+ <span>@{handle}</span>
359
+ </div>
360
+ <div className="mt-1 space-y-0.5">
361
+ {logins.map((login) => {
362
+ const lc = login.toLowerCase();
363
+ const approved = approvedSet.has(lc);
364
+ const requested = changesRequestedSet.has(lc);
365
+ return (
366
+ <div
367
+ key={login}
368
+ className="flex items-center justify-between text-[11px]"
369
+ >
370
+ <span className="text-slate-300">{login}</span>
371
+ {approved ? (
372
+ <span className="inline-flex items-center gap-1 text-[10px] text-emerald-300">
373
+ <Check className="h-3 w-3" /> Approved
374
+ </span>
375
+ ) : requested ? (
376
+ <span className="inline-flex items-center gap-1 text-[10px] text-red-300">
377
+ <X className="h-3 w-3" /> Changes requested
378
+ </span>
379
+ ) : (
380
+ <span className="text-[10px] text-slate-500">
381
+ Awaiting review
382
+ </span>
383
+ )}
384
+ </div>
385
+ );
386
+ })}
387
+ </div>
388
+ </div>
389
+ );
390
+ })}
391
+ </div>
392
+ )}
393
+ </SidebarSection>
394
+
395
+ {/* Active rulesets */}
396
+ <SidebarSection title={`Rulesets on ${baseBranch}`}>
397
+ <RulesetsSummary merge={merge} onOpenRulesets={onOpenRulesets} />
398
+ </SidebarSection>
399
+
400
+ {/* Roster */}
401
+ <SidebarSection title={`Org @${org.slug} — roster`}>
402
+ <OrgEditor org={org} onChange={updateOrg} />
403
+ </SidebarSection>
404
+
405
+ {/* Merge box */}
406
+ <div
407
+ className={`mt-3 rounded-lg border p-3 ${
408
+ merge.mergeable
409
+ ? "border-emerald-500/40 bg-emerald-500/10"
410
+ : "border-amber-500/30 bg-amber-500/10"
411
+ }`}
412
+ >
413
+ <div className="flex items-center gap-2 text-sm font-semibold">
414
+ {merge.mergeable ? (
415
+ <>
416
+ <Check className="h-4 w-4 text-emerald-300" />
417
+ <span className="text-emerald-200">
418
+ This pull request can be merged
419
+ </span>
420
+ </>
421
+ ) : (
422
+ <>
423
+ <X className="h-4 w-4 text-amber-300" />
424
+ <span className="text-amber-200">Merging is blocked</span>
425
+ </>
426
+ )}
427
+ </div>
428
+ {merge.reasons.length > 0 && (
429
+ <ul className="mt-2 list-disc pl-5 text-[11px] text-amber-100/90">
430
+ {merge.reasons.map((r) => (
431
+ <li key={r}>{r}</li>
432
+ ))}
433
+ </ul>
434
+ )}
435
+ {merge.evaluateOnlyReasons.length > 0 && (
436
+ <div className="mt-2 rounded border border-amber-500/30 bg-slate-950/40 p-1.5">
437
+ <div className="mb-0.5 flex items-center gap-1 text-[10px] uppercase tracking-wider text-amber-200/80">
438
+ <Info className="h-2.5 w-2.5" />
439
+ Would block (evaluate mode)
440
+ </div>
441
+ <ul className="list-disc pl-4 text-[11px] text-amber-100/70">
442
+ {merge.evaluateOnlyReasons.map((r) => (
443
+ <li key={r}>{r}</li>
444
+ ))}
445
+ </ul>
446
+ </div>
447
+ )}
448
+ <button
449
+ disabled={!merge.mergeable}
450
+ className={`mt-3 w-full rounded px-2 py-1.5 text-sm font-medium ${
451
+ merge.mergeable
452
+ ? "bg-emerald-600 text-white hover:bg-emerald-500"
453
+ : "bg-slate-800 text-slate-500 cursor-not-allowed"
454
+ }`}
455
+ >
456
+ Merge pull request
457
+ </button>
458
+ </div>
459
+ </aside>
460
+ </div>
461
+ );
462
+ }
463
+
464
+ // ─── Checks section ────────────────────────────────────────────────────
465
+ //
466
+ // Renders the latest act run as a GitHub-style checks list. The data
467
+ // comes from `pr.lastCheckRun`, which the modal captures off the
468
+ // streamed job snapshots when a run completes. `checkStatuses` is the
469
+ // merge-time view (only the required ones), which we cross-reference
470
+ // to badge a job as "Required" and to surface any missing-but-required
471
+ // job names in the callout at the bottom.
472
+
473
+ function ChecksSection({
474
+ pr,
475
+ checkStatuses,
476
+ }: {
477
+ pr: GithubLabPullRequest;
478
+ checkStatuses: CheckStatus[];
479
+ }) {
480
+ const run = pr.lastCheckRun;
481
+ const requiredByName = new Map(checkStatuses.map((c) => [c.name, c.status]));
482
+ return (
483
+ <section className="mb-3 rounded-xl border border-slate-800/60 bg-slate-900/40 p-2">
484
+ <h3 className="mb-2 flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
485
+ <ShieldCheck className="h-3 w-3" /> Checks
486
+ </h3>
487
+ {!run ? (
488
+ <p className="px-1 py-2 text-slate-500">
489
+ No CI run yet. Click <span className="text-amber-300">Run</span> from
490
+ the Console tab — the latest result will land here and grade any
491
+ required status checks.
492
+ </p>
493
+ ) : (
494
+ <ul className="space-y-1">
495
+ {run.jobs.map((j) => {
496
+ const isRequired = requiredByName.has(j.name);
497
+ return (
498
+ <li
499
+ key={j.name}
500
+ className="flex items-center justify-between rounded border border-slate-800/60 bg-slate-950/50 px-2 py-1"
501
+ >
502
+ <div className="flex items-center gap-2">
503
+ <StatusDot status={j.status} />
504
+ <span className="font-mono text-[11px] text-slate-200">
505
+ {j.name}
506
+ </span>
507
+ {isRequired && (
508
+ <span className="rounded bg-amber-500/15 px-1 py-px text-[9px] uppercase tracking-wider text-amber-200">
509
+ Required
510
+ </span>
511
+ )}
512
+ </div>
513
+ <span className="text-[10px] text-slate-500">
514
+ {labelForStatus(j.status)}
515
+ {typeof j.durationMs === "number" &&
516
+ ` · ${(j.durationMs / 1000).toFixed(1)}s`}
517
+ </span>
518
+ </li>
519
+ );
520
+ })}
521
+ </ul>
522
+ )}
523
+ {checkStatuses.some((c) => c.status === "missing") && (
524
+ <p className="mt-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[10px] text-amber-200">
525
+ Some required checks have not been observed yet:{" "}
526
+ {checkStatuses
527
+ .filter((c) => c.status === "missing")
528
+ .map((c) => c.name)
529
+ .join(", ")}
530
+ .
531
+ </p>
532
+ )}
533
+ </section>
534
+ );
535
+ }
536
+
537
+ function StatusDot({ status }: { status: string }) {
538
+ if (status === "success") {
539
+ return (
540
+ <span className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-300">
541
+ <Check className="h-2.5 w-2.5" />
542
+ </span>
543
+ );
544
+ }
545
+ if (status === "failed" || status === "cancelled") {
546
+ return (
547
+ <span className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500/20 text-red-300">
548
+ <X className="h-2.5 w-2.5" />
549
+ </span>
550
+ );
551
+ }
552
+ if (status === "skipped") {
553
+ return (
554
+ <span className="inline-block h-3.5 w-3.5 rounded-full border border-dashed border-slate-600" />
555
+ );
556
+ }
557
+ return (
558
+ <span className="inline-flex h-3.5 w-3.5 items-center justify-center text-amber-300">
559
+ <CircleDot className="h-3 w-3 animate-pulse" />
560
+ </span>
561
+ );
562
+ }
563
+
564
+ function labelForStatus(s: string): string {
565
+ switch (s) {
566
+ case "success":
567
+ return "Passed";
568
+ case "failed":
569
+ return "Failed";
570
+ case "cancelled":
571
+ return "Cancelled";
572
+ case "skipped":
573
+ return "Skipped";
574
+ case "running":
575
+ return "In progress";
576
+ default:
577
+ return s;
578
+ }
579
+ }
580
+
581
+ // ─── Rulesets summary (read-only) ──────────────────────────────────────
582
+ //
583
+ // On real github.com the PR sidebar shows a tiny summary of which
584
+ // rulesets gate the merge; editing happens in Settings → Rules. We
585
+ // mirror that split: this component renders effective rules and links
586
+ // out to Settings.
587
+
588
+ function RulesetsSummary({
589
+ merge,
590
+ onOpenRulesets,
591
+ }: {
592
+ merge: MergeabilityResult;
593
+ onOpenRulesets?: () => void;
594
+ }) {
595
+ const { appliedRulesets, effectiveRules, informationalNotes } = merge;
596
+ return (
597
+ <>
598
+ {appliedRulesets.length === 0 ? (
599
+ <p className="text-[11px] text-slate-500">
600
+ No rulesets target this branch — anyone with write access can merge.
601
+ </p>
602
+ ) : (
603
+ <ul className="space-y-1">
604
+ {appliedRulesets.map((rs) => (
605
+ <li
606
+ key={rs.id}
607
+ className="rounded border border-slate-800/60 bg-slate-950/50 p-1.5"
608
+ >
609
+ <div className="flex items-center gap-1 text-[11px]">
610
+ <Shield className="h-3 w-3 text-amber-300" />
611
+ <span className="truncate text-slate-200">{rs.name}</span>
612
+ <RulesetEnforcementBadge enforcement={rs.enforcement} />
613
+ </div>
614
+ {rs.targetInclude.length > 0 && (
615
+ <div className="mt-0.5 flex items-center gap-1 text-[10px] text-slate-500">
616
+ <GitBranch className="h-2.5 w-2.5" />
617
+ {rs.targetInclude.join(", ")}
618
+ </div>
619
+ )}
620
+ </li>
621
+ ))}
622
+ </ul>
623
+ )}
624
+ <EffectiveRulesList rules={effectiveRules} />
625
+ {informationalNotes.length > 0 && (
626
+ <div className="mt-2 space-y-1">
627
+ {informationalNotes.map((n) => (
628
+ <p
629
+ key={n}
630
+ className="flex items-start gap-1 text-[10px] text-slate-500"
631
+ >
632
+ <Info className="mt-0.5 h-2.5 w-2.5 shrink-0" />
633
+ <span>{n}</span>
634
+ </p>
635
+ ))}
636
+ </div>
637
+ )}
638
+ {onOpenRulesets && (
639
+ <button
640
+ onClick={onOpenRulesets}
641
+ className="mt-2 w-full rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:border-amber-500/40 hover:text-amber-200"
642
+ >
643
+ Edit in Settings → Rulesets
644
+ </button>
645
+ )}
646
+ </>
647
+ );
648
+ }
649
+
650
+ function RulesetEnforcementBadge({
651
+ enforcement,
652
+ }: {
653
+ enforcement: GithubLabRuleset["enforcement"];
654
+ }) {
655
+ if (enforcement === "active") {
656
+ return (
657
+ <span className="rounded bg-emerald-500/15 px-1 py-px text-[9px] uppercase tracking-wider text-emerald-300">
658
+ Active
659
+ </span>
660
+ );
661
+ }
662
+ if (enforcement === "evaluate") {
663
+ return (
664
+ <span className="rounded bg-amber-500/15 px-1 py-px text-[9px] uppercase tracking-wider text-amber-200">
665
+ Evaluate
666
+ </span>
667
+ );
668
+ }
669
+ return (
670
+ <span className="rounded bg-slate-700/40 px-1 py-px text-[9px] uppercase tracking-wider text-slate-400">
671
+ Disabled
672
+ </span>
673
+ );
674
+ }
675
+
676
+ function EffectiveRulesList({ rules }: { rules: GithubLabRulesetRules }) {
677
+ const lines: string[] = [];
678
+ if (rules.pullRequest) {
679
+ lines.push(
680
+ `PR required → ${rules.pullRequest.requiredApprovingReviewCount} approval${
681
+ rules.pullRequest.requiredApprovingReviewCount === 1 ? "" : "s"
682
+ }${rules.pullRequest.requireCodeOwnerReview ? " + code owners" : ""}`,
683
+ );
684
+ }
685
+ if (rules.statusChecks && rules.statusChecks.checks.length > 0) {
686
+ lines.push(
687
+ `Status checks: ${rules.statusChecks.checks.join(", ")}${
688
+ rules.statusChecks.strict ? " (strict)" : ""
689
+ }`,
690
+ );
691
+ }
692
+ if (rules.requireDeployments) {
693
+ lines.push(
694
+ `Deploys required: ${rules.requireDeployments.environments.join(", ")}`,
695
+ );
696
+ }
697
+ if (lines.length === 0) return null;
698
+ return (
699
+ <div className="mt-2 rounded border border-slate-800/60 bg-slate-950/40 p-1.5">
700
+ <div className="mb-0.5 text-[9px] uppercase tracking-wider text-slate-500">
701
+ Effective rules
702
+ </div>
703
+ <ul className="space-y-0.5 text-[10px] text-slate-300">
704
+ {lines.map((l) => (
705
+ <li key={l}>{l}</li>
706
+ ))}
707
+ </ul>
708
+ </div>
709
+ );
710
+ }
711
+
712
+ // ─── Review composer ───────────────────────────────────────────────────
713
+ //
714
+ // The composer mirrors the github.com "Finish your review" tray. We
715
+ // force the author to be someone other than the viewer (you can't
716
+ // review your own PR), which makes the "Reviewing as @other" workflow
717
+ // explicit. Submitting builds an immutable review row that lands in
718
+ // the timeline below.
719
+
720
+ function ReviewComposer({
721
+ org,
722
+ onSubmit,
723
+ }: {
724
+ org: GithubLabOrg;
725
+ onSubmit: (r: Omit<GithubLabReview, "id" | "createdAt">) => void;
726
+ }) {
727
+ // Default to a non-viewer reviewer so users don't have to switch
728
+ // identities just to leave a review.
729
+ const defaultAuthor =
730
+ org.users.find((u) => u.toLowerCase() !== org.viewerLogin.toLowerCase()) ??
731
+ org.viewerLogin;
732
+ const [author, setAuthor] = useState(defaultAuthor);
733
+ const [state, setState] = useState<GithubLabReview["state"]>("approved");
734
+ const [body, setBody] = useState("");
735
+
736
+ const isViewer = author.toLowerCase() === org.viewerLogin.toLowerCase();
737
+
738
+ const submit = () => {
739
+ if (isViewer) return;
740
+ onSubmit({
741
+ author,
742
+ state,
743
+ ...(body.trim() ? { body: body.trim() } : {}),
744
+ });
745
+ setBody("");
746
+ };
747
+
748
+ return (
749
+ <section className="mb-3 rounded-xl border border-slate-800/60 bg-slate-900/40 p-2">
750
+ <h3 className="mb-2 flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
751
+ <MessageSquare className="h-3 w-3" /> Leave a review
752
+ </h3>
753
+ <div className="mb-2 flex items-center gap-2">
754
+ <span className="text-[10px] text-slate-500">Reviewing as</span>
755
+ <select
756
+ value={author}
757
+ onChange={(e) => setAuthor(e.target.value)}
758
+ className="rounded border border-slate-700 bg-slate-900 px-1 py-0.5 text-[11px]"
759
+ >
760
+ {org.users.map((u) => (
761
+ <option key={u} value={u}>
762
+ @{u}
763
+ {u.toLowerCase() === org.viewerLogin.toLowerCase()
764
+ ? " (you)"
765
+ : ""}
766
+ </option>
767
+ ))}
768
+ </select>
769
+ </div>
770
+ <textarea
771
+ value={body}
772
+ onChange={(e) => setBody(e.target.value)}
773
+ placeholder="Leave a comment (optional)"
774
+ rows={3}
775
+ className="w-full resize-y rounded border border-slate-800 bg-slate-950 p-1.5 text-[11px] text-slate-200 outline-none focus:border-amber-500/40"
776
+ />
777
+ <div className="mt-2 grid grid-cols-3 gap-1 text-[11px]">
778
+ {[
779
+ {
780
+ value: "commented" as const,
781
+ label: "Comment",
782
+ hint: "no approval, no block",
783
+ },
784
+ {
785
+ value: "approved" as const,
786
+ label: "Approve",
787
+ hint: "counts toward required reviews",
788
+ },
789
+ {
790
+ value: "changes_requested" as const,
791
+ label: "Request changes",
792
+ hint: "blocks merge",
793
+ },
794
+ ].map((opt) => (
795
+ <label
796
+ key={opt.value}
797
+ className={`flex flex-col items-start gap-0.5 rounded border p-1.5 cursor-pointer ${
798
+ state === opt.value
799
+ ? "border-amber-500/40 bg-amber-500/10"
800
+ : "border-slate-800 hover:bg-slate-800/40"
801
+ }`}
802
+ >
803
+ <span className="flex items-center gap-1">
804
+ <input
805
+ type="radio"
806
+ checked={state === opt.value}
807
+ onChange={() => setState(opt.value)}
808
+ className="accent-amber-500"
809
+ />
810
+ <span className="text-slate-200">{opt.label}</span>
811
+ </span>
812
+ <span className="text-[10px] text-slate-500">{opt.hint}</span>
813
+ </label>
814
+ ))}
815
+ </div>
816
+ <div className="mt-2 flex items-center justify-between">
817
+ {isViewer ? (
818
+ <span className="text-[10px] text-amber-300">
819
+ You can't review your own pull request — pick another reviewer
820
+ above.
821
+ </span>
822
+ ) : (
823
+ <span className="text-[10px] text-slate-500">
824
+ Submitting as <span className="text-slate-300">@{author}</span>
825
+ </span>
826
+ )}
827
+ <button
828
+ onClick={submit}
829
+ disabled={isViewer}
830
+ className="rounded bg-emerald-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-emerald-500 disabled:bg-slate-800 disabled:text-slate-500 disabled:cursor-not-allowed"
831
+ >
832
+ Submit review
833
+ </button>
834
+ </div>
835
+ </section>
836
+ );
837
+ }
838
+
839
+ // ─── Review timeline ───────────────────────────────────────────────────
840
+ //
841
+ // Shows every review (newest first). A review is "Superseded" when the
842
+ // same author has a newer review of a kind that overrides it (matches
843
+ // `latestReviewByAuthor` semantics in the evaluator).
844
+
845
+ function ReviewTimeline({
846
+ reviews,
847
+ onDismiss,
848
+ }: {
849
+ reviews: GithubLabReview[];
850
+ onDismiss: (id: string) => void;
851
+ }) {
852
+ if (reviews.length === 0) {
853
+ return null;
854
+ }
855
+ const latestByAuthor = latestReviewByAuthor(reviews);
856
+ const sorted = [...reviews].sort(
857
+ (a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
858
+ );
859
+ return (
860
+ <section className="mb-3 rounded-xl border border-slate-800/60 bg-slate-900/40 p-2">
861
+ <h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
862
+ Reviews ({reviews.length})
863
+ </h3>
864
+ <ul className="space-y-1.5">
865
+ {sorted.map((r) => {
866
+ const isStale =
867
+ latestByAuthor.get(r.author.toLowerCase())?.id !== r.id;
868
+ return (
869
+ <li
870
+ key={r.id}
871
+ className={`rounded border border-slate-800/60 bg-slate-950/50 p-1.5 ${
872
+ isStale ? "opacity-50" : ""
873
+ }`}
874
+ >
875
+ <div className="flex items-center justify-between gap-2">
876
+ <div className="flex items-center gap-1.5 text-[11px]">
877
+ <span className="text-amber-200">@{r.author}</span>
878
+ <ReviewStateBadge state={r.state} />
879
+ {isStale && (
880
+ <span className="rounded bg-slate-800 px-1 py-px text-[9px] text-slate-500">
881
+ Superseded
882
+ </span>
883
+ )}
884
+ </div>
885
+ <div className="flex items-center gap-2">
886
+ <span className="text-[10px] text-slate-600">
887
+ {timeAgo(r.createdAt)}
888
+ </span>
889
+ <button
890
+ onClick={() => onDismiss(r.id)}
891
+ className="text-slate-600 hover:text-red-300"
892
+ title="Delete this review"
893
+ >
894
+ <Trash2 className="h-3 w-3" />
895
+ </button>
896
+ </div>
897
+ </div>
898
+ {r.body && (
899
+ <p className="mt-1 whitespace-pre-wrap text-[11px] text-slate-300">
900
+ {r.body}
901
+ </p>
902
+ )}
903
+ </li>
904
+ );
905
+ })}
906
+ </ul>
907
+ </section>
908
+ );
909
+ }
910
+
911
+ function ReviewStateBadge({ state }: { state: GithubLabReview["state"] }) {
912
+ if (state === "approved") {
913
+ return (
914
+ <span className="inline-flex items-center gap-1 rounded bg-emerald-500/15 px-1.5 py-px text-[10px] text-emerald-300">
915
+ <Check className="h-2.5 w-2.5" /> Approved
916
+ </span>
917
+ );
918
+ }
919
+ if (state === "changes_requested") {
920
+ return (
921
+ <span className="inline-flex items-center gap-1 rounded bg-red-500/15 px-1.5 py-px text-[10px] text-red-300">
922
+ <X className="h-2.5 w-2.5" /> Requested changes
923
+ </span>
924
+ );
925
+ }
926
+ return (
927
+ <span className="inline-flex items-center gap-1 rounded bg-slate-700/40 px-1.5 py-px text-[10px] text-slate-300">
928
+ <MessageSquare className="h-2.5 w-2.5" /> Commented
929
+ </span>
930
+ );
931
+ }
932
+
933
+ function timeAgo(iso: string): string {
934
+ const diff = Date.now() - Date.parse(iso);
935
+ if (Number.isNaN(diff) || diff < 0) return iso;
936
+ const s = Math.floor(diff / 1000);
937
+ if (s < 60) return `${s}s ago`;
938
+ const m = Math.floor(s / 60);
939
+ if (m < 60) return `${m}m ago`;
940
+ const h = Math.floor(m / 60);
941
+ if (h < 24) return `${h}h ago`;
942
+ return `${Math.floor(h / 24)}d ago`;
943
+ }
944
+
945
+ // ─── Sidebar helpers ───────────────────────────────────────────────────
946
+
947
+ function SidebarSection({
948
+ title,
949
+ children,
950
+ }: {
951
+ title: string;
952
+ children: React.ReactNode;
953
+ }) {
954
+ return (
955
+ <section className="mb-3">
956
+ <h4 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-slate-500">
957
+ {title}
958
+ </h4>
959
+ <div className="rounded border border-slate-800/60 bg-slate-900/40 p-2 text-[11px] text-slate-300">
960
+ {children}
961
+ </div>
962
+ </section>
963
+ );
964
+ }
965
+
966
+ function OrgEditor({
967
+ org,
968
+ onChange,
969
+ }: {
970
+ org: GithubLabOrg;
971
+ onChange: (next: Partial<GithubLabOrg>) => void;
972
+ }) {
973
+ const [newUser, setNewUser] = useState("");
974
+ const [newTeamSlug, setNewTeamSlug] = useState("");
975
+
976
+ const addUser = () => {
977
+ const u = newUser.trim();
978
+ if (!u || org.users.some((x) => x.toLowerCase() === u.toLowerCase()))
979
+ return;
980
+ onChange({ users: [...org.users, u] });
981
+ setNewUser("");
982
+ };
983
+
984
+ const removeUser = (login: string) => {
985
+ onChange({
986
+ users: org.users.filter((u) => u.toLowerCase() !== login.toLowerCase()),
987
+ teams: org.teams.map((t) => ({
988
+ ...t,
989
+ members: t.members.filter(
990
+ (m) => m.toLowerCase() !== login.toLowerCase(),
991
+ ),
992
+ })),
993
+ });
994
+ };
995
+
996
+ const addTeam = () => {
997
+ const slug = newTeamSlug
998
+ .trim()
999
+ .toLowerCase()
1000
+ .replace(/[^a-z0-9-]/g, "-");
1001
+ if (!slug || org.teams.some((t) => t.slug === slug)) return;
1002
+ onChange({ teams: [...org.teams, { slug, members: [] }] });
1003
+ setNewTeamSlug("");
1004
+ };
1005
+
1006
+ const removeTeam = (slug: string) => {
1007
+ onChange({ teams: org.teams.filter((t) => t.slug !== slug) });
1008
+ };
1009
+
1010
+ const toggleTeamMember = (team: GithubLabTeam, login: string) => {
1011
+ const has = team.members.some(
1012
+ (m) => m.toLowerCase() === login.toLowerCase(),
1013
+ );
1014
+ const members = has
1015
+ ? team.members.filter((m) => m.toLowerCase() !== login.toLowerCase())
1016
+ : [...team.members, login];
1017
+ onChange({
1018
+ teams: org.teams.map((t) =>
1019
+ t.slug === team.slug ? { ...t, members } : t,
1020
+ ),
1021
+ });
1022
+ };
1023
+
1024
+ return (
1025
+ <div className="space-y-2">
1026
+ <div>
1027
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
1028
+ You are
1029
+ </div>
1030
+ <select
1031
+ value={org.viewerLogin}
1032
+ onChange={(e) => onChange({ viewerLogin: e.target.value })}
1033
+ className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-0.5"
1034
+ >
1035
+ {org.users.map((u) => (
1036
+ <option key={u} value={u}>
1037
+ @{u}
1038
+ </option>
1039
+ ))}
1040
+ </select>
1041
+ </div>
1042
+
1043
+ <div>
1044
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
1045
+ Users
1046
+ </div>
1047
+ <div className="flex flex-wrap gap-1">
1048
+ {org.users.map((u) => (
1049
+ <span
1050
+ key={u}
1051
+ className="inline-flex items-center gap-1 rounded bg-slate-800 px-1.5 py-0.5 text-[10px]"
1052
+ >
1053
+ @{u}
1054
+ <button
1055
+ onClick={() => removeUser(u)}
1056
+ className="text-slate-500 hover:text-red-300"
1057
+ title={`Remove @${u}`}
1058
+ >
1059
+ <Trash2 className="h-2.5 w-2.5" />
1060
+ </button>
1061
+ </span>
1062
+ ))}
1063
+ </div>
1064
+ <div className="mt-1 flex items-center gap-1">
1065
+ <input
1066
+ value={newUser}
1067
+ onChange={(e) => setNewUser(e.target.value)}
1068
+ onKeyDown={(e) => e.key === "Enter" && addUser()}
1069
+ placeholder="login"
1070
+ className="flex-1 rounded border border-slate-700 bg-slate-900 px-1 py-0.5 text-[11px]"
1071
+ />
1072
+ <button
1073
+ onClick={addUser}
1074
+ className="rounded bg-slate-800 px-1.5 py-0.5 text-slate-300 hover:bg-slate-700"
1075
+ >
1076
+ <Plus className="h-3 w-3" />
1077
+ </button>
1078
+ </div>
1079
+ </div>
1080
+
1081
+ <div>
1082
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-slate-500">
1083
+ Teams
1084
+ </div>
1085
+ {org.teams.map((t) => (
1086
+ <div
1087
+ key={t.slug}
1088
+ className="mb-1 rounded border border-slate-800/60 bg-slate-950/50 p-1.5"
1089
+ >
1090
+ <div className="flex items-center justify-between">
1091
+ <span className="text-amber-200">
1092
+ @{org.slug}/{t.slug}
1093
+ </span>
1094
+ <button
1095
+ onClick={() => removeTeam(t.slug)}
1096
+ className="text-slate-500 hover:text-red-300"
1097
+ title="Remove team"
1098
+ >
1099
+ <Trash2 className="h-3 w-3" />
1100
+ </button>
1101
+ </div>
1102
+ <div className="mt-1 flex flex-wrap gap-0.5">
1103
+ {org.users.map((u) => {
1104
+ const isMember = t.members.some(
1105
+ (m) => m.toLowerCase() === u.toLowerCase(),
1106
+ );
1107
+ return (
1108
+ <button
1109
+ key={u}
1110
+ onClick={() => toggleTeamMember(t, u)}
1111
+ className={`rounded px-1 py-0.5 text-[10px] ${
1112
+ isMember
1113
+ ? "bg-amber-500/20 text-amber-200"
1114
+ : "bg-slate-800 text-slate-500 hover:bg-slate-700"
1115
+ }`}
1116
+ >
1117
+ {u}
1118
+ </button>
1119
+ );
1120
+ })}
1121
+ </div>
1122
+ </div>
1123
+ ))}
1124
+ <div className="mt-1 flex items-center gap-1">
1125
+ <input
1126
+ value={newTeamSlug}
1127
+ onChange={(e) => setNewTeamSlug(e.target.value)}
1128
+ onKeyDown={(e) => e.key === "Enter" && addTeam()}
1129
+ placeholder="team-slug"
1130
+ className="flex-1 rounded border border-slate-700 bg-slate-900 px-1 py-0.5 text-[11px]"
1131
+ />
1132
+ <button
1133
+ onClick={addTeam}
1134
+ className="rounded bg-slate-800 px-1.5 py-0.5 text-slate-300 hover:bg-slate-700"
1135
+ >
1136
+ <Plus className="h-3 w-3" />
1137
+ </button>
1138
+ </div>
1139
+ </div>
1140
+ </div>
1141
+ );
1142
+ }