create-interview-cockpit 0.26.1 → 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 +47 -2
- package/template/client/src/components/DiagramsModal.tsx +839 -0
- package/template/client/src/components/FileViewerModal.tsx +350 -30
- 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,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
|
+
}
|