@volter/twin-github 0.1.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,1513 @@
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ export type PullRequest = {
5
+ repo: string;
6
+ number: number;
7
+ title?: string;
8
+ body?: string;
9
+ state?: string;
10
+ url?: string;
11
+ baseBranch?: string;
12
+ headBranch?: string;
13
+ headSha?: string;
14
+ mergeCommit?: string;
15
+ mergedAt?: string;
16
+ sourceIssue?: string;
17
+ sourceIssueTitle?: string;
18
+ source?: string;
19
+ author?: string;
20
+ authorAvatarUrl?: string;
21
+ createdAt?: string;
22
+ updatedAt?: string;
23
+ additions?: number;
24
+ deletions?: number;
25
+ changedFiles?: number;
26
+ commitsCount?: number;
27
+ commentsCount?: number;
28
+ reviewCount?: number;
29
+ reviewCommentsCount?: number;
30
+ merged?: boolean;
31
+ labels?: Array<{ name: string; color?: string; description?: string }>;
32
+ assignees?: string[];
33
+ requestedReviewers?: string[];
34
+ milestone?: string;
35
+ ciStatus?: string;
36
+ };
37
+ export type LinkedRef = { type: "pull_request" | "issue"; number: number; url?: string };
38
+ export type Issue = {
39
+ repo: string;
40
+ number: number;
41
+ title?: string;
42
+ body?: string;
43
+ state?: string;
44
+ url?: string;
45
+ createdAt?: string;
46
+ updatedAt?: string;
47
+ labels?: Array<{ name: string; color?: string; description?: string }>;
48
+ assignees?: string[];
49
+ milestone?: string;
50
+ linked?: LinkedRef[];
51
+ stateReason?: string;
52
+ locked?: boolean;
53
+ comments?: Array<{ id: number; author?: string; body?: string; createdAt?: string }>;
54
+ timeline?: Array<{ event: string; detail?: string }>;
55
+ };
56
+ export type Review = {
57
+ repo: string;
58
+ number: number;
59
+ reviewer?: string;
60
+ state?: string;
61
+ body?: string;
62
+ score?: number;
63
+ reviewerAvatarUrl?: string;
64
+ submittedAt?: string;
65
+ observed?: boolean;
66
+ };
67
+ export type Commit = {
68
+ repo: string;
69
+ number: number;
70
+ sha: string;
71
+ message?: string;
72
+ author?: string;
73
+ authorLogin?: string;
74
+ authorAvatarUrl?: string;
75
+ date?: string;
76
+ url?: string;
77
+ };
78
+ export type Comment = {
79
+ repo: string;
80
+ number: number;
81
+ author?: string;
82
+ authorAvatarUrl?: string;
83
+ body?: string;
84
+ createdAt?: string;
85
+ url?: string;
86
+ path?: string;
87
+ observed?: boolean;
88
+ };
89
+ export type ChangedFile = {
90
+ repo: string;
91
+ number: number;
92
+ filename: string;
93
+ status?: string;
94
+ additions?: number;
95
+ deletions?: number;
96
+ changes?: number;
97
+ patch?: string;
98
+ };
99
+ export type Workflow = {
100
+ repo: string;
101
+ id: number;
102
+ name: string;
103
+ path: string;
104
+ state?: string;
105
+ };
106
+ export type JobStep = { name: string; status?: string; conclusion?: string; number: number };
107
+ export type Job = {
108
+ repo: string;
109
+ id: number;
110
+ runId: number;
111
+ name: string;
112
+ status?: string;
113
+ conclusion?: string;
114
+ steps?: JobStep[];
115
+ };
116
+ export type WorkflowRun = {
117
+ repo: string;
118
+ id: number;
119
+ workflowId: number;
120
+ workflowName?: string;
121
+ name?: string;
122
+ runNumber: number;
123
+ event?: string;
124
+ status?: string;
125
+ conclusion?: string;
126
+ headSha?: string;
127
+ headBranch?: string;
128
+ createdAt?: string;
129
+ updatedAt?: string;
130
+ url?: string;
131
+ jobCount?: number;
132
+ };
133
+ export type ReleaseAsset = {
134
+ id: number;
135
+ name: string;
136
+ label?: string;
137
+ contentType?: string;
138
+ size?: number;
139
+ downloadCount?: number;
140
+ };
141
+ export type Release = {
142
+ repo: string;
143
+ id: number;
144
+ tagName: string;
145
+ name?: string;
146
+ body?: string;
147
+ draft?: boolean;
148
+ prerelease?: boolean;
149
+ createdAt?: string;
150
+ publishedAt?: string;
151
+ targetCommitish?: string;
152
+ url?: string;
153
+ assets?: ReleaseAsset[];
154
+ };
155
+ export type Tag = { repo: string; name: string; commitSha: string };
156
+ export type DiscussionComment = {
157
+ id: number;
158
+ parentId?: number | null;
159
+ body?: string;
160
+ isAnswer?: boolean;
161
+ createdAt?: string;
162
+ };
163
+ export type Discussion = {
164
+ repo: string;
165
+ number: number;
166
+ title?: string;
167
+ body?: string;
168
+ state?: string;
169
+ locked?: boolean;
170
+ categorySlug?: string;
171
+ categoryName?: string;
172
+ categoryEmoji?: string;
173
+ isAnswerable?: boolean;
174
+ answerCommentId?: number | null;
175
+ isAnswered?: boolean;
176
+ commentsCount?: number;
177
+ createdAt?: string;
178
+ updatedAt?: string;
179
+ url?: string;
180
+ comments?: DiscussionComment[];
181
+ };
182
+ export type DeploymentStatusEntry = { id: number; state: string; description?: string; createdAt?: string };
183
+ export type Deployment = {
184
+ repo: string;
185
+ id: number;
186
+ ref: string;
187
+ sha?: string;
188
+ environment: string;
189
+ task?: string;
190
+ description?: string;
191
+ production?: boolean;
192
+ transient?: boolean;
193
+ createdAt?: string;
194
+ state: string;
195
+ environmentUrl?: string;
196
+ url?: string;
197
+ statuses?: DeploymentStatusEntry[];
198
+ };
199
+ export type Environment = { repo: string; name: string; waitTimer?: number; reviewers?: string[]; protected?: boolean };
200
+ type Payload = {
201
+ github: {
202
+ pullRequests: PullRequest[];
203
+ issues: Issue[];
204
+ reviews: Review[];
205
+ commits: Commit[];
206
+ comments: Comment[];
207
+ reviewComments: Comment[];
208
+ files: ChangedFile[];
209
+ workflows: Workflow[];
210
+ runs: WorkflowRun[];
211
+ jobs: Job[];
212
+ releases: Release[];
213
+ tags: Tag[];
214
+ discussions: Discussion[];
215
+ contents?: ContentEntry[];
216
+ notifications?: NotificationEntry[];
217
+ deployments?: Deployment[];
218
+ environments?: Environment[];
219
+ projectBoards?: ProjectBoard[];
220
+ insights?: RepoInsights[];
221
+ settings?: RepoSettings[];
222
+ };
223
+ fetchedAt: string;
224
+ };
225
+
226
+ type ProjectCard = { id: number; title: string; contentType: string };
227
+ type ProjectColumn = { name: string; cards: ProjectCard[] };
228
+ type ProjectBoard = {
229
+ id: number; number: number; title: string; owner: string; closed: boolean;
230
+ views: Array<{ number: number; name: string; layout: string }>;
231
+ fields: Array<{ name: string; dataType: string }>;
232
+ itemCount: number; columns: ProjectColumn[]; noStatus: ProjectColumn; url: string;
233
+ };
234
+ type RepoInsights = {
235
+ repo: string;
236
+ pulse: { mergedPrs: number; openPrs: number; openedIssues: number; closedIssues: number };
237
+ contributors: Array<{ login: string; commits: number }>;
238
+ };
239
+ type RepoSettings = {
240
+ repo: string;
241
+ general: { defaultBranch?: string; private: boolean; hasIssues: boolean; hasProjects: boolean; hasWiki: boolean; hasDiscussions: boolean; description: string | null };
242
+ collaborators: Array<{ login: string; permission: string }>;
243
+ webhooks: Array<{ id: number; url?: string; active: boolean; events: string[] }>;
244
+ branches: Array<{ name: string; protected: boolean }>;
245
+ };
246
+
247
+ function shortSha(value?: string) {
248
+ return value ? value.slice(0, 7) : "unknown";
249
+ }
250
+
251
+ // CI rollup glyph per PR head_sha (✓ success, ✗ failure, ● pending). Undefined for
252
+ // observed-only PRs with no statuses/check-runs — they show no CI indicator (honesty).
253
+ const CI_GLYPH: Record<string, string> = { success: "✓", failure: "✗", pending: "●" };
254
+ export function CiStatus({ status }: { status?: string }) {
255
+ if (!status) return null;
256
+ return <span className={`ci-status ${status}`} title={`checks: ${status}`}>{CI_GLYPH[status] ?? "●"}</span>;
257
+ }
258
+
259
+ function prKey(pr: PullRequest) {
260
+ return `${pr.repo}#${pr.number}`;
261
+ }
262
+
263
+ // A workflow-run status/conclusion badge (✓ success, ✗ failure, ⊘ cancelled, ● queued/
264
+ // in_progress) — mirrors GitHub's run-list status glyphs. A completed run shows its
265
+ // conclusion; an in-flight one shows its status.
266
+ const RUN_GLYPH: Record<string, string> = { success: "✓", failure: "✗", cancelled: "⊘", skipped: "–", queued: "●", in_progress: "●", completed: "✓" };
267
+ export function RunStatusBadge({ status, conclusion }: { status?: string; conclusion?: string | null }) {
268
+ const effective = status === "completed" ? (conclusion || "completed") : (status || "queued");
269
+ return <span className={`run-badge ${effective}`} title={`run: ${effective}`}>{RUN_GLYPH[effective] ?? "●"} {effective}</span>;
270
+ }
271
+
272
+ function initials(name?: string): string {
273
+ return (name || "?").split(/[-_\s]+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase() || "?";
274
+ }
275
+
276
+ function Avatar({ src, label }: { src?: string; label?: string }) {
277
+ return (
278
+ <div className="avatar">
279
+ {src ? <img src={src} alt="" loading="lazy" referrerPolicy="no-referrer" /> : initials(label)}
280
+ </div>
281
+ );
282
+ }
283
+
284
+ // ── Real-app URL routing (TWIN-49 / H3) ─────────────────────────────────────────────────
285
+ // Vendor-faithful path shapes (credible-sim fidelity — recognizable to an agent that knows
286
+ // the real GitHub UI):
287
+ // all-repos: /pulls | /issues | /{tab} (github's real cross-repo pages)
288
+ // repo-scoped: /{owner}/{repo}/pulls | /{owner}/{repo}/pull/{number}
289
+ // /{owner}/{repo}/issues | /{owner}/{repo}/issues/{number}
290
+ // /{owner}/{repo}/{tab} (Actions/Releases/Discussions/…)
291
+ // This is the ONLY place route shapes live — pathFor() encodes state -> URL, stateFor()
292
+ // decodes URL -> state, so deep links, pushState navigation, and popstate (back/forward)
293
+ // all stay in sync through one small function pair.
294
+ type ViewName = "pulls" | "issues" | "actions" | "releases" | "discussions" | "code" | "search" | "notifications" | "deployments" | "projects" | "insights" | "settings";
295
+ const VIEW_NAMES: ViewName[] = ["pulls", "issues", "actions", "releases", "discussions", "code", "search", "notifications", "deployments", "projects", "insights", "settings"];
296
+ type RouteState = { repo: string; view: ViewName; selectedKey: string; selectedIssueKey: string };
297
+ const DEFAULT_ROUTE: RouteState = { repo: "all", view: "pulls", selectedKey: "", selectedIssueKey: "" };
298
+
299
+ /** `selectedKey`/`selectedIssueKey` are `${repo}#${number}` — split on the LAST `#` (repo
300
+ * names never contain one) to recover the PR/issue's OWN repo, independent of whichever
301
+ * repo tab is currently selected (opening a PR from the "all repos" list still routes to
302
+ * that PR's own `/{owner}/{repo}/pull/{n}`, matching real GitHub). */
303
+ function splitRepoScopedKey(key: string): { repo: string; number: string } {
304
+ const hashIndex = key.lastIndexOf("#");
305
+ return { repo: key.slice(0, hashIndex), number: key.slice(hashIndex + 1) };
306
+ }
307
+
308
+ function pathFor(route: RouteState): string {
309
+ const { repo, view, selectedKey, selectedIssueKey } = route;
310
+ // An opened PR/issue always routes to ITS OWN repo's path, regardless of the "all repos"
311
+ // vs single-repo tab currently selected (mirrors real GitHub: /pulls can link out to any
312
+ // repo's /owner/repo/pull/n).
313
+ if (view === "pulls" && selectedKey) {
314
+ const { repo: prRepo, number } = splitRepoScopedKey(selectedKey);
315
+ return `/${prRepo}/pull/${number}`;
316
+ }
317
+ if (view === "issues" && selectedIssueKey) {
318
+ const { repo: issueRepo, number } = splitRepoScopedKey(selectedIssueKey);
319
+ return `/${issueRepo}/issues/${number}`;
320
+ }
321
+ if (repo === "all" || !repo) return `/${view}`;
322
+ return `/${repo}/${view}`;
323
+ }
324
+
325
+ function stateFor(pathname: string): RouteState {
326
+ const segments = pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
327
+ if (segments.length === 0) return DEFAULT_ROUTE;
328
+ if (segments.length === 1 && (VIEW_NAMES as string[]).includes(segments[0]!)) {
329
+ return { ...DEFAULT_ROUTE, view: segments[0] as ViewName };
330
+ }
331
+ if (segments.length >= 2) {
332
+ const repo = `${segments[0]}/${segments[1]}`;
333
+ if (segments.length === 2) return { ...DEFAULT_ROUTE, repo, view: "pulls" };
334
+ const tab = segments[2];
335
+ if (tab === "pull" && segments[3]) return { ...DEFAULT_ROUTE, repo, view: "pulls", selectedKey: `${repo}#${segments[3]}` };
336
+ if (tab === "pulls") return { ...DEFAULT_ROUTE, repo, view: "pulls" };
337
+ if (tab === "issues" && segments[3]) return { ...DEFAULT_ROUTE, repo, view: "issues", selectedIssueKey: `${repo}#${segments[3]}` };
338
+ if (tab === "issues") return { ...DEFAULT_ROUTE, repo, view: "issues" };
339
+ if ((VIEW_NAMES as string[]).includes(tab!)) return { ...DEFAULT_ROUTE, repo, view: tab as ViewName };
340
+ return { ...DEFAULT_ROUTE, repo, view: "pulls" };
341
+ }
342
+ return DEFAULT_ROUTE;
343
+ }
344
+
345
+ function App() {
346
+ const [payload, setPayload] = useState<Payload | null>(null);
347
+ const initialRoute = typeof window !== "undefined" ? stateFor(window.location.pathname) : DEFAULT_ROUTE;
348
+ const [repo, setRepo] = useState(initialRoute.repo);
349
+ const [selectedKey, setSelectedKey] = useState(initialRoute.selectedKey);
350
+ const [query, setQuery] = useState("");
351
+ const [view, setView] = useState<ViewName>(initialRoute.view);
352
+ const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
353
+ const [selectedRunId, setSelectedRunId] = useState<number | undefined>(undefined);
354
+ const [selectedReleaseId, setSelectedReleaseId] = useState<number | undefined>(undefined);
355
+ const [selectedDiscussionKey, setSelectedDiscussionKey] = useState<string | undefined>(undefined);
356
+ const [selectedIssueKey, setSelectedIssueKey] = useState<string>(initialRoute.selectedIssueKey);
357
+ const [showFilesDiff, setShowFilesDiff] = useState(false);
358
+ const [error, setError] = useState("");
359
+ // Set while applying a popstate-derived route, so the pushState effect below (which reacts
360
+ // to the same state) skips re-pushing the entry the browser just navigated to.
361
+ const skipNextPush = React.useRef(false);
362
+
363
+ async function refresh() {
364
+ try {
365
+ const response = await fetch("/api/state");
366
+ const json = await response.json();
367
+ if (!response.ok) throw new Error(json.error ?? "Failed to load GitHub state");
368
+ setPayload(json);
369
+ setError("");
370
+ } catch (err) {
371
+ setError(err instanceof Error ? err.message : String(err));
372
+ }
373
+ }
374
+
375
+ useEffect(() => {
376
+ void refresh();
377
+ const timer = window.setInterval(() => void refresh(), 1500);
378
+ return () => window.clearInterval(timer);
379
+ }, []);
380
+
381
+ // pushState on navigation: any click that changes repo/view/selection updates the address
382
+ // bar (no reload) to the vendor-faithful path for the new state.
383
+ useEffect(() => {
384
+ const path = pathFor({ repo, view, selectedKey, selectedIssueKey });
385
+ if (skipNextPush.current) {
386
+ skipNextPush.current = false;
387
+ return;
388
+ }
389
+ if (window.location.pathname !== path) window.history.pushState(null, "", path);
390
+ }, [repo, view, selectedKey, selectedIssueKey]);
391
+
392
+ // popstate (back/forward): re-derive the view + selection from the restored pathname.
393
+ useEffect(() => {
394
+ function onPopState() {
395
+ const route = stateFor(window.location.pathname);
396
+ skipNextPush.current = true;
397
+ setRepo(route.repo);
398
+ setView(route.view);
399
+ setSelectedKey(route.selectedKey);
400
+ setSelectedIssueKey(route.selectedIssueKey);
401
+ }
402
+ window.addEventListener("popstate", onPopState);
403
+ return () => window.removeEventListener("popstate", onPopState);
404
+ }, []);
405
+
406
+ const repos = useMemo(() => Array.from(new Set((payload?.github.pullRequests ?? []).map((pr) => pr.repo))).sort(), [payload]);
407
+ const prs = useMemo(() => {
408
+ const search = query.trim().toLowerCase();
409
+ return (payload?.github.pullRequests ?? [])
410
+ .filter((pr) => repo === "all" || !repo || pr.repo === repo)
411
+ .filter((pr) => {
412
+ if (!search) return true;
413
+ return [
414
+ pr.repo,
415
+ String(pr.number),
416
+ pr.title,
417
+ pr.author,
418
+ pr.sourceIssue,
419
+ pr.sourceIssueTitle,
420
+ pr.state,
421
+ pr.url,
422
+ ].filter(Boolean).join(" ").toLowerCase().includes(search);
423
+ })
424
+ .sort((a, b) => {
425
+ const aDate = Date.parse(a.updatedAt || a.mergedAt || a.createdAt || "");
426
+ const bDate = Date.parse(b.updatedAt || b.mergedAt || b.createdAt || "");
427
+ if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) return bDate - aDate;
428
+ return Number(b.number) - Number(a.number);
429
+ });
430
+ }, [payload, query, repo]);
431
+ const issues = useMemo(() => {
432
+ const search = query.trim().toLowerCase();
433
+ return (payload?.github.issues ?? [])
434
+ .filter((issue) => repo === "all" || !repo || issue.repo === repo)
435
+ .filter((issue) => {
436
+ if (!search) return true;
437
+ return [issue.repo, String(issue.number), issue.title, issue.state, issue.url]
438
+ .filter(Boolean).join(" ").toLowerCase().includes(search);
439
+ })
440
+ .sort((a, b) => {
441
+ const aDate = Date.parse(a.updatedAt || a.createdAt || "");
442
+ const bDate = Date.parse(b.updatedAt || b.createdAt || "");
443
+ if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) return bDate - aDate;
444
+ return Number(b.number) - Number(a.number);
445
+ });
446
+ }, [payload, query, repo]);
447
+ const runs = useMemo(() => {
448
+ const search = query.trim().toLowerCase();
449
+ return (payload?.github.runs ?? [])
450
+ .filter((run) => repo === "all" || !repo || run.repo === repo)
451
+ .filter((run) => {
452
+ if (!search) return true;
453
+ return [run.repo, String(run.runNumber), run.workflowName, run.name, run.event, run.headBranch, run.status, run.conclusion]
454
+ .filter(Boolean).join(" ").toLowerCase().includes(search);
455
+ })
456
+ .sort((a, b) => {
457
+ const aDate = Date.parse(a.updatedAt || a.createdAt || "");
458
+ const bDate = Date.parse(b.updatedAt || b.createdAt || "");
459
+ if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) return bDate - aDate;
460
+ return Number(b.id) - Number(a.id);
461
+ });
462
+ }, [payload, query, repo]);
463
+ const jobs = payload?.github.jobs ?? [];
464
+ const releases = useMemo(() => {
465
+ const search = query.trim().toLowerCase();
466
+ return (payload?.github.releases ?? [])
467
+ .filter((rel) => repo === "all" || !repo || rel.repo === repo)
468
+ .filter((rel) => {
469
+ if (!search) return true;
470
+ return [rel.repo, rel.tagName, rel.name, rel.body, rel.draft ? "draft" : "", rel.prerelease ? "prerelease" : ""]
471
+ .filter(Boolean).join(" ").toLowerCase().includes(search);
472
+ })
473
+ .sort((a, b) => {
474
+ const aDate = Date.parse(a.publishedAt || a.createdAt || "");
475
+ const bDate = Date.parse(b.publishedAt || b.createdAt || "");
476
+ if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) return bDate - aDate;
477
+ return Number(b.id) - Number(a.id);
478
+ });
479
+ }, [payload, query, repo]);
480
+ const discussions = useMemo(() => {
481
+ const search = query.trim().toLowerCase();
482
+ return (payload?.github.discussions ?? [])
483
+ .filter((d) => repo === "all" || !repo || d.repo === repo)
484
+ .filter((d) => {
485
+ if (!search) return true;
486
+ return [d.repo, String(d.number), d.title, d.body, d.categoryName, d.state, d.isAnswered ? "answered" : ""]
487
+ .filter(Boolean).join(" ").toLowerCase().includes(search);
488
+ })
489
+ .sort((a, b) => {
490
+ const aDate = Date.parse(a.updatedAt || a.createdAt || "");
491
+ const bDate = Date.parse(b.updatedAt || b.createdAt || "");
492
+ if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) return bDate - aDate;
493
+ return Number(b.number) - Number(a.number);
494
+ });
495
+ }, [payload, query, repo]);
496
+ const deployments = useMemo(() => {
497
+ const search = query.trim().toLowerCase();
498
+ return (payload?.github.deployments ?? [])
499
+ .filter((d) => repo === "all" || !repo || d.repo === repo)
500
+ .filter((d) => {
501
+ if (!search) return true;
502
+ return [d.repo, d.environment, d.ref, d.state, d.description].filter(Boolean).join(" ").toLowerCase().includes(search);
503
+ })
504
+ .sort((a, b) => Number(b.id) - Number(a.id));
505
+ }, [payload, query, repo]);
506
+ const environments = useMemo(
507
+ () => (payload?.github.environments ?? []).filter((e) => repo === "all" || !repo || e.repo === repo),
508
+ [payload, repo],
509
+ );
510
+ // Projects v2 boards are owner-scoped (not repo-scoped) — show all when "all", else those
511
+ // whose owner matches the selected repo's owner. Insights/Settings are repo-scoped.
512
+ const projectBoards = useMemo(
513
+ () => (payload?.github.projectBoards ?? []).filter((p) => repo === "all" || !repo || p.owner === repo.split("/")[0]),
514
+ [payload, repo],
515
+ );
516
+ const insights = useMemo(
517
+ () => (payload?.github.insights ?? []).filter((i) => repo === "all" || !repo || i.repo === repo),
518
+ [payload, repo],
519
+ );
520
+ const settings = useMemo(
521
+ () => (payload?.github.settings ?? []).filter((s) => repo === "all" || !repo || s.repo === repo),
522
+ [payload, repo],
523
+ );
524
+ const current = prs.find((pr) => prKey(pr) === selectedKey) ?? prs[0];
525
+ const currentReviews = current
526
+ ? (payload?.github.reviews ?? []).filter((review) => review.repo === current.repo && Number(review.number) === Number(current.number))
527
+ : [];
528
+ const currentCommits = current
529
+ ? (payload?.github.commits ?? []).filter((commit) => commit.repo === current.repo && Number(commit.number) === Number(current.number))
530
+ : [];
531
+ const currentComments = current
532
+ ? (payload?.github.comments ?? []).filter((comment) => comment.repo === current.repo && Number(comment.number) === Number(current.number))
533
+ : [];
534
+ const currentReviewComments = current
535
+ ? (payload?.github.reviewComments ?? []).filter((comment) => comment.repo === current.repo && Number(comment.number) === Number(current.number))
536
+ : [];
537
+ const currentFiles = current
538
+ ? (payload?.github.files ?? []).filter((file) => file.repo === current.repo && Number(file.number) === Number(current.number))
539
+ : [];
540
+
541
+ // NOTE: `selectedKey` is intentionally NOT auto-synced to `current` here (a prior version
542
+ // did) — `current` falling back to `prs[0]` is purely a display default for the split-pane
543
+ // preview; auto-writing that fallback into `selectedKey` would immediately pushState the
544
+ // address bar to `/pull/{n}` on every list view, which is not vendor-faithful (GitHub's
545
+ // `/pulls` list never auto-navigates into a PR). `selectedKey` only changes on an explicit
546
+ // row click or a deep link (`stateFor`), so the URL only reflects a PR the user actually
547
+ // opened. The legacy `?repo=&pr=|number=|url=` query-param deep-link mechanism this effect
548
+ // used to implement is REPLACED by real path routing (see pathFor/stateFor above) — no
549
+ // dual/legacy path.
550
+
551
+ return (
552
+ <main className="browser-shell">
553
+ <section className="browser-tabs" aria-label="Repository browser tabs">
554
+ <button
555
+ className={`browser-tab ${repo === "all" ? "active" : ""}`}
556
+ onClick={() => {
557
+ setRepo("all");
558
+ setSelectedKey("");
559
+ }}
560
+ >
561
+ <span>All repositories</span>
562
+ <strong>{payload?.github.pullRequests?.length ?? 0}</strong>
563
+ </button>
564
+ {repos.map((name) => {
565
+ const count = (payload?.github.pullRequests ?? []).filter((pr) => pr.repo === name).length;
566
+ return (
567
+ <button
568
+ key={name}
569
+ className={`browser-tab ${name === repo ? "active" : ""}`}
570
+ onClick={() => {
571
+ setRepo(name);
572
+ setSelectedKey("");
573
+ }}
574
+ >
575
+ <span>{name}</span>
576
+ <strong>{count}</strong>
577
+ </button>
578
+ );
579
+ })}
580
+ </section>
581
+ <section className="browser-address">
582
+ <span className="browser-dot red" />
583
+ <span className="browser-dot yellow" />
584
+ <span className="browser-dot green" />
585
+ <div className="address-bar">github.local{pathFor({ repo, view, selectedKey, selectedIssueKey })}</div>
586
+ </section>
587
+ <section className="github-shell">
588
+ <header className="global-header">
589
+ <div className="octo">GH</div>
590
+ <input className="search" value="Search or jump to..." readOnly />
591
+ <nav className="global-nav"><span>Pull requests</span><span>Issues</span><span>Marketplace</span><span>Explore</span></nav>
592
+ <div className="global-spacer" />
593
+ <span className="pill-dark">mock github</span>
594
+ </header>
595
+ <section className="repo-head">
596
+ <div className="repo-line">
597
+ {repo === "all" ? (
598
+ <><button>all</button><span>/</span><strong>repositories</strong></>
599
+ ) : (
600
+ <><button>{repo.split("/")[0] || "owner"}</button><span>/</span><strong>{repo.split("/")[1] || "repository"}</strong></>
601
+ )}
602
+ </div>
603
+ <nav className="repo-tabs">
604
+ <button>Code</button>
605
+ <button className={view === "issues" ? "active" : ""} onClick={() => setView("issues")}>Issues <span className="count">{issues.length}</span></button>
606
+ <button className={view === "pulls" ? "active" : ""} onClick={() => setView("pulls")}>Pull requests <span className="count">{prs.length}</span></button>
607
+ <button className={view === "actions" ? "active" : ""} onClick={() => setView("actions")}>Actions <span className="count">{runs.length}</span></button>
608
+ <button className={view === "releases" ? "active" : ""} onClick={() => setView("releases")}>Releases <span className="count">{releases.length}</span></button>
609
+ <button className={view === "discussions" ? "active" : ""} onClick={() => setView("discussions")}>Discussions <span className="count">{discussions.length}</span></button>
610
+ <button className={view === "code" ? "active" : ""} onClick={() => setView("code")}>Code <span className="count">{(payload?.github.contents ?? []).length}</span></button>
611
+ <button className={view === "search" ? "active" : ""} onClick={() => setView("search")}>Search</button>
612
+ <button className={view === "notifications" ? "active" : ""} onClick={() => setView("notifications")}>Notifications <span className="count">{(payload?.github.notifications ?? []).filter((n) => n.unread).length}</span></button>
613
+ <button className={view === "deployments" ? "active" : ""} onClick={() => setView("deployments")}>Deployments <span className="count">{deployments.length}</span></button>
614
+ <button className={view === "projects" ? "active" : ""} onClick={() => setView("projects")}>Projects <span className="count">{projectBoards.length}</span></button>
615
+ <button>Security</button>
616
+ <button className={view === "insights" ? "active" : ""} onClick={() => setView("insights")}>Insights</button>
617
+ <button className={view === "settings" ? "active" : ""} onClick={() => setView("settings")}>Settings <span className="count">{settings.length}</span></button>
618
+ </nav>
619
+ </section>
620
+ <div className="content">
621
+ {error ? <div className="error">{error}</div> : null}
622
+ <div className="toolbar">
623
+ <input
624
+ className="pr-filter"
625
+ value={query}
626
+ onChange={(event) => setQuery(event.target.value)}
627
+ placeholder={view === "issues" ? "Search issues by title, number, repo" : view === "actions" ? "Search runs by workflow, number, branch, status" : view === "releases" ? "Search releases by tag, name, draft/prerelease" : view === "discussions" ? "Search discussions by title, category, answered" : "Search PRs by title, number, repo, issue, author"}
628
+ />
629
+ <button className="new-pr">{view === "issues" ? "New issue" : view === "actions" ? "Run workflow" : view === "releases" ? "Draft a new release" : view === "discussions" ? "New discussion" : "New pull request"}</button>
630
+ </div>
631
+ {view === "issues" ? (
632
+ <div className="split">
633
+ <main>
634
+ <IssuesTable issues={issues} onSelect={setSelectedIssueKey} />
635
+ </main>
636
+ <IssueDetail issue={issues.find((i) => `${i.repo}#${i.number}` === selectedIssueKey) ?? issues[0]} />
637
+ </div>
638
+ ) : view === "actions" ? (
639
+ <ActionsView runs={runs} jobs={jobs} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
640
+ ) : view === "releases" ? (
641
+ <ReleasesView releases={releases} selectedReleaseId={selectedReleaseId} onSelect={setSelectedReleaseId} />
642
+ ) : view === "discussions" ? (
643
+ <DiscussionsView discussions={discussions} selectedKey={selectedDiscussionKey} onSelect={setSelectedDiscussionKey} />
644
+ ) : view === "code" ? (
645
+ <CodeBrowser contents={(payload?.github.contents ?? []).filter((c) => repo === "all" || !repo || c.repo === repo)} />
646
+ ) : view === "search" ? (
647
+ <SearchView query={query} prs={payload?.github.pullRequests ?? []} issues={payload?.github.issues ?? []} contents={payload?.github.contents ?? []} />
648
+ ) : view === "notifications" ? (
649
+ <NotificationsView notifications={(payload?.github.notifications ?? []).filter((n) => repo === "all" || !repo || n.repo === repo)} />
650
+ ) : view === "deployments" ? (
651
+ <DeploymentsView deployments={deployments} environments={environments} />
652
+ ) : view === "projects" ? (
653
+ <ProjectBoardView boards={projectBoards} selectedId={selectedProjectId} onSelect={setSelectedProjectId} />
654
+ ) : view === "insights" ? (
655
+ <InsightsView insights={insights} />
656
+ ) : view === "settings" ? (
657
+ <SettingsView settings={settings} />
658
+ ) : (
659
+ <div className="split">
660
+ <main>
661
+ <PrList prs={prs} reviews={payload?.github.reviews ?? []} selectedKey={selectedKey} onSelect={setSelectedKey} />
662
+ <div className="pr-tabs">
663
+ <button className={showFilesDiff ? "" : "active"} onClick={() => setShowFilesDiff(false)}>Conversation</button>
664
+ <button className={showFilesDiff ? "active" : ""} onClick={() => setShowFilesDiff(true)}>Files changed <span className="count">{current?.changedFiles ?? currentFiles.length}</span></button>
665
+ </div>
666
+ {showFilesDiff ? (
667
+ <FilesDiff files={currentFiles} />
668
+ ) : (
669
+ <Conversation
670
+ pr={current}
671
+ reviews={currentReviews}
672
+ commits={currentCommits}
673
+ comments={currentComments}
674
+ reviewComments={currentReviewComments}
675
+ files={currentFiles}
676
+ />
677
+ )}
678
+ </main>
679
+ <Aside pr={current} reviews={currentReviews} commits={currentCommits} files={currentFiles} />
680
+ </div>
681
+ )}
682
+ </div>
683
+ </section>
684
+ </main>
685
+ );
686
+ }
687
+
688
+ export function PrList({ prs, reviews, selectedKey, onSelect }: {
689
+ prs: PullRequest[];
690
+ reviews: Review[];
691
+ selectedKey?: string;
692
+ onSelect?: (key: string) => void;
693
+ }) {
694
+ return (
695
+ <section className="list-box">
696
+ <div className="list-head"><span><strong>{prs.length} pull requests</strong></span><span>Updated</span></div>
697
+ {prs.length ? prs.map((pr) => {
698
+ const approvedCount = reviews.filter((review) => review.repo === pr.repo && Number(review.number) === Number(pr.number) && review.state === "approved").length;
699
+ const state = pr.merged ? "merged" : (pr.state ?? "open");
700
+ return (
701
+ <button key={prKey(pr)} className={`pr-row ${prKey(pr) === selectedKey ? "active" : ""}`} onClick={() => onSelect?.(prKey(pr))}>
702
+ <span className={`status-icon ${state}`}>o</span>
703
+ <span>
704
+ <span className="pr-title">{pr.title || pr.url || `${pr.repo}#${pr.number}`}</span>
705
+ <div className="pr-meta">
706
+ {pr.repo} #{pr.number} · {state}
707
+ {pr.author ? ` · opened by ${pr.author}` : ""}
708
+ {pr.updatedAt ? ` · updated ${new Date(pr.updatedAt).toLocaleString()}` : pr.createdAt ? ` · ${new Date(pr.createdAt).toLocaleString()}` : ""}
709
+ {pr.sourceIssue ? ` · ${pr.sourceIssue}` : ""}
710
+ {pr.changedFiles != null ? ` · ${pr.changedFiles} files` : ""}
711
+ {pr.commitsCount != null ? ` · ${pr.commitsCount} commits` : ""}
712
+ </div>
713
+ </span>
714
+ <span className="labels">
715
+ <CiStatus status={pr.ciStatus} />
716
+ <span className={`state-badge ${state}`}>{state}</span>
717
+ {approvedCount ? <span className="label approved">{approvedCount} approved</span> : null}
718
+ {pr.milestone ? <span className="label milestone" title="Milestone">🏷 {pr.milestone}</span> : null}
719
+ {(pr.labels ?? []).slice(0, 2).map((label) => label.name ? <span className="label" key={label.name}>{label.name}</span> : null)}
720
+ {(pr.assignees ?? []).slice(0, 2).map((login) => <span className="label assignee" key={login}>@{login}</span>)}
721
+ </span>
722
+ </button>
723
+ );
724
+ }) : <div className="empty">No pull requests match this view.</div>}
725
+ </section>
726
+ );
727
+ }
728
+
729
+ export function IssuesTable({ issues, onSelect }: { issues: Issue[]; onSelect?: (key: string) => void }) {
730
+ return (
731
+ <section className="list-box issues-box">
732
+ <div className="list-head"><span><strong>{issues.length} issues</strong></span><span>Updated</span></div>
733
+ {issues.length ? issues.map((issue) => {
734
+ const state = issue.state ?? "open";
735
+ return (
736
+ <button key={`${issue.repo}#${issue.number}`} className="pr-row issue-row" onClick={() => onSelect?.(`${issue.repo}#${issue.number}`)}>
737
+ <span className={`status-icon ${state === "closed" ? "closed" : "open"}`}>!</span>
738
+ <span>
739
+ <span className="pr-title">{issue.title || issue.url || `${issue.repo}#${issue.number}`}</span>
740
+ <div className="pr-meta">
741
+ {issue.repo} #{issue.number} · {state}
742
+ {issue.updatedAt ? ` · updated ${new Date(issue.updatedAt).toLocaleString()}` : issue.createdAt ? ` · ${new Date(issue.createdAt).toLocaleString()}` : ""}
743
+ </div>
744
+ {(issue.linked ?? []).length ? (
745
+ <div className="linked-issues">
746
+ linked:{" "}
747
+ {(issue.linked ?? []).map((link, idx) => (
748
+ <a className="linked-ref" key={`linked-${idx}`} href={link.url} target="_blank" rel="noreferrer">
749
+ {link.type === "pull_request" ? "PR" : "issue"} #{link.number}
750
+ </a>
751
+ ))}
752
+ </div>
753
+ ) : null}
754
+ </span>
755
+ <span className="labels">
756
+ <span className={`state-badge ${state}`}>{state}</span>
757
+ {issue.milestone ? <span className="label milestone" title="Milestone">🏷 {issue.milestone}</span> : null}
758
+ {(issue.labels ?? []).slice(0, 3).map((label) => label.name ? <span className="label" key={label.name}>{label.name}</span> : null)}
759
+ {(issue.assignees ?? []).slice(0, 2).map((login) => <span className="label assignee" key={login}>@{login}</span>)}
760
+ </span>
761
+ </button>
762
+ );
763
+ }) : <div className="empty">No issues match this view.</div>}
764
+ </section>
765
+ );
766
+ }
767
+
768
+ // The PR Files-changed DIFF view: renders each changed file's unified-diff hunks as
769
+ // +/- lines (the GitHub "Files changed" tab). Hunk text comes from the LOCAL PR write's
770
+ // `patch`; observed PRs carry no per-file diff, so a file with no patch shows a summary row
771
+ // only (no fabricated hunks). Each diff line is classed add/del/context so it renders like GitHub.
772
+ export function FilesDiff({ files }: { files: ChangedFile[] }) {
773
+ if (!files.length) return <div className="empty">No file diffs in this snapshot.</div>;
774
+ return (
775
+ <section className="files-diff" data-testid="files-diff">
776
+ <h3>Files changed <span className="count">{files.length}</span></h3>
777
+ {files.map((file) => (
778
+ <article className="diff-file" key={`${file.repo}#${file.number}#${file.filename}`}>
779
+ <div className="diff-file-head">
780
+ <span className={`diff-status ${file.status ?? "modified"}`}>{file.status ?? "modified"}</span>
781
+ <strong className="diff-filename">{file.filename}</strong>
782
+ <span className="diff-stat">+{file.additions ?? 0} −{file.deletions ?? 0}</span>
783
+ </div>
784
+ {file.patch ? (
785
+ <pre className="diff-hunk" data-testid="diff-hunk">
786
+ {file.patch.split("\n").map((line, idx) => {
787
+ const cls = line.startsWith("+") && !line.startsWith("+++") ? "diff-line-add"
788
+ : line.startsWith("-") && !line.startsWith("---") ? "diff-line-del"
789
+ : line.startsWith("@@") ? "diff-line-hunk" : "diff-line-ctx";
790
+ return <div className={cls} key={idx}>{line || " "}</div>;
791
+ })}
792
+ </pre>
793
+ ) : (
794
+ <div className="sha diff-no-patch">No diff hunk in snapshot (observed PRs carry counts only).</div>
795
+ )}
796
+ </article>
797
+ ))}
798
+ </section>
799
+ );
800
+ }
801
+
802
+ // The Issue DETAIL page: the selected issue's header (state/labels/assignees/milestone),
803
+ // its derived state-change Timeline (labeled/assigned/locked/closed events), and its
804
+ // comments feed. Mirrors GitHub's single-issue screen. Observed comments are labelled.
805
+ export function IssueDetail({ issue }: { issue?: Issue }) {
806
+ if (!issue) return <div className="empty">Select an issue.</div>;
807
+ const state = issue.state ?? "open";
808
+ return (
809
+ <section className="issue-detail" data-testid="issue-detail">
810
+ <h2>{issue.title || `${issue.repo}#${issue.number}`} <span>#{issue.number}</span></h2>
811
+ <div className="state-line">
812
+ <span className={`state-badge ${state}`}>{state}{issue.stateReason ? ` (${issue.stateReason})` : ""}</span>
813
+ {issue.locked ? <span className="label" title="Locked">🔒 locked</span> : null}
814
+ <strong>{issue.repo}</strong>
815
+ {issue.milestone ? <span className="label milestone" title="Milestone">🏷 {issue.milestone}</span> : null}
816
+ {issue.url ? <a className="branch" href={issue.url} target="_blank" rel="noreferrer">View on GitHub</a> : null}
817
+ </div>
818
+ <div className="meta-row">
819
+ {(issue.labels ?? []).map((l) => l.name ? <span className="label" key={l.name}>{l.name}</span> : null)}
820
+ {(issue.assignees ?? []).map((login) => <span className="label assignee" key={login}>@{login}</span>)}
821
+ </div>
822
+ <article className="comment">
823
+ <div className="comment-head"><strong>opened this issue</strong></div>
824
+ <div className="comment-body">{issue.body || "No issue body in snapshot."}</div>
825
+ </article>
826
+ <section className="snapshot-section issue-timeline" data-testid="issue-timeline">
827
+ <h3>Timeline</h3>
828
+ {(issue.timeline ?? []).length ? (issue.timeline ?? []).map((ev, idx) => (
829
+ <div className="timeline-event" key={`tl-${idx}`}><span className="event-kind">{ev.event}</span>{ev.detail ? <span className="event-detail"> {ev.detail}</span> : null}</div>
830
+ )) : <p className="sha">No timeline events.</p>}
831
+ </section>
832
+ <section className="snapshot-section">
833
+ <h3>Comments <span className="count">{(issue.comments ?? []).length}</span></h3>
834
+ {(issue.comments ?? []).length ? (issue.comments ?? []).map((c) => (
835
+ <article className="comment issue-detail-comment" data-testid="issue-detail-comment" key={`c-${c.id}`}>
836
+ <div className="comment-head"><strong>{c.author || "GitHub user"}</strong> commented {c.createdAt ? new Date(c.createdAt).toLocaleString() : ""}</div>
837
+ <div className="comment-body">{c.body || ""}</div>
838
+ </article>
839
+ )) : <p className="sha">No comments.</p>}
840
+ </section>
841
+ </section>
842
+ );
843
+ }
844
+
845
+ // The Code/file browser tree (GitHub's Code tab). Renders the repo's stored file paths as
846
+ // a directory tree; selecting a file shows its metadata (path/size/sha). No blob BYTES are
847
+ // rendered (the declared Non-goal) — the tree + metadata only.
848
+ export type ContentEntry = { repo: string; path: string; size?: number; sha?: string; branch?: string };
849
+ export function CodeBrowser({ contents }: { contents: ContentEntry[] }) {
850
+ if (!contents.length) return <div className="empty" data-testid="code-browser">No files in this snapshot.</div>;
851
+ const dirs = new Set<string>();
852
+ for (const c of contents) { const parts = c.path.split("/"); for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/")); }
853
+ return (
854
+ <section className="code-browser" data-testid="code-browser">
855
+ <h3>Code <span className="count">{contents.length} files</span></h3>
856
+ <div className="file-tree" data-testid="file-tree">
857
+ {[...dirs].sort().map((d) => (
858
+ <div className="tree-row tree-dir" data-testid="tree-dir" key={`d-${d}`}>📁 {d}</div>
859
+ ))}
860
+ {contents.map((c) => (
861
+ <div className="tree-row tree-file" data-testid="tree-file" key={`${c.repo}#${c.path}`}>
862
+ <span>📄 {c.path}</span>
863
+ <span className="sha">{c.size ?? 0} bytes</span>
864
+ </div>
865
+ ))}
866
+ </div>
867
+ </section>
868
+ );
869
+ }
870
+
871
+ // The global Search results screen — filters PRs/issues/repos files by the query. Mirrors
872
+ // GitHub's search page (a single box over multiple result kinds). Local, over the snapshot.
873
+ export function SearchView({ query, prs, issues, contents }: { query: string; prs: PullRequest[]; issues: Issue[]; contents: ContentEntry[] }) {
874
+ const q = query.trim().toLowerCase();
875
+ const match = (...vals: Array<string | undefined>) => q === "" || vals.some((v) => (v ?? "").toLowerCase().includes(q));
876
+ const prHits = prs.filter((p) => match(p.title, p.repo));
877
+ const issueHits = issues.filter((i) => match(i.title, i.repo));
878
+ const codeHits = contents.filter((c) => match(c.path, c.repo));
879
+ const total = prHits.length + issueHits.length + codeHits.length;
880
+ return (
881
+ <section className="search-view" data-testid="search-view">
882
+ <h3>Search results <span className="count">{total}</span></h3>
883
+ <div className="search-group" data-testid="search-prs">
884
+ <h4>Pull requests ({prHits.length})</h4>
885
+ {prHits.map((p) => <div className="search-hit" key={`p-${p.repo}#${p.number}`}>{p.repo} #{p.number} · {p.title}</div>)}
886
+ </div>
887
+ <div className="search-group" data-testid="search-issues">
888
+ <h4>Issues ({issueHits.length})</h4>
889
+ {issueHits.map((i) => <div className="search-hit" key={`i-${i.repo}#${i.number}`}>{i.repo} #{i.number} · {i.title}</div>)}
890
+ </div>
891
+ <div className="search-group" data-testid="search-code">
892
+ <h4>Code ({codeHits.length})</h4>
893
+ {codeHits.map((c) => <div className="search-hit" key={`c-${c.repo}#${c.path}`}>{c.repo} · {c.path}</div>)}
894
+ </div>
895
+ </section>
896
+ );
897
+ }
898
+
899
+ // The Notifications inbox screen. Each thread shows its subject/reason and an unread dot;
900
+ // mirrors GitHub's notifications page. Local, over the snapshot.
901
+ export type NotificationEntry = { id: string; repo: string; title: string; type: string; reason: string; unread: boolean; updatedAt?: string };
902
+ export function NotificationsView({ notifications }: { notifications: NotificationEntry[] }) {
903
+ if (!notifications.length) return <div className="empty" data-testid="notifications-view">No notifications.</div>;
904
+ return (
905
+ <section className="notifications-view" data-testid="notifications-view">
906
+ <h3>Notifications <span className="count">{notifications.filter((n) => n.unread).length} unread</span></h3>
907
+ {notifications.map((n) => (
908
+ <div className={`notification-row ${n.unread ? "unread" : "read"}`} data-testid="notification-row" key={n.id}>
909
+ {n.unread ? <span className="unread-dot" data-testid="unread-dot">●</span> : <span className="read-dot">○</span>}
910
+ <span className="notif-subject"><strong>{n.title}</strong> <span className="sha">{n.type}</span></span>
911
+ <span className="notif-reason label">{n.reason}</span>
912
+ <span className="sha">{n.repo}</span>
913
+ </div>
914
+ ))}
915
+ </section>
916
+ );
917
+ }
918
+
919
+ // The Deployments view (the repo's Deployments/Environments screen). Models the Deployments
920
+ // API objects — each deployment carries its environment, ref, and LATEST status (the
921
+ // deployment's state), plus a per-environment protection summary. Maps GitHub's Environments
922
+ // view: most-recent deployments with their state badge + the environment list.
923
+ export function DeploymentState({ state }: { state?: string }) {
924
+ const s = (state || "pending").toLowerCase();
925
+ const cls = s === "success" ? "success" : (s === "failure" || s === "error" ? "failure" : (s === "inactive" ? "inactive" : "pending"));
926
+ return <span className={`deployment-state ${cls}`} data-testid="deployment-state" title={`deployment: ${s}`}>{s}</span>;
927
+ }
928
+ export function DeploymentsView({ deployments, environments }: { deployments: Deployment[]; environments: Environment[] }) {
929
+ if (!deployments.length && !environments.length) return <div className="empty" data-testid="deployments-view">No deployments.</div>;
930
+ return (
931
+ <section className="deployments-view" data-testid="deployments-view">
932
+ {environments.length ? (
933
+ <div className="environments-list" data-testid="environments-list">
934
+ <h3>Environments <span className="count">{environments.length}</span></h3>
935
+ {environments.map((env) => (
936
+ <div className="environment-row" data-testid="environment-row" key={`${env.repo}#${env.name}`}>
937
+ <span className="environment-name"><strong>{env.name}</strong></span>
938
+ <span className="sha">{env.repo}</span>
939
+ {env.protected ? (
940
+ <span className="environment-protected label" data-testid="environment-protected">
941
+ protected{env.waitTimer ? ` · ${env.waitTimer}m wait` : ""}{env.reviewers && env.reviewers.length ? ` · ${env.reviewers.length} reviewers` : ""}
942
+ </span>
943
+ ) : <span className="label">no protection rules</span>}
944
+ </div>
945
+ ))}
946
+ </div>
947
+ ) : null}
948
+ <div className="deployments-list">
949
+ <h3>Deployments <span className="count">{deployments.length}</span></h3>
950
+ {deployments.length ? deployments.map((d) => (
951
+ <div className="deployment-row" data-testid="deployment-row" key={d.id}>
952
+ <DeploymentState state={d.state} />
953
+ <span className="deployment-env label" data-testid="deployment-env">{d.environment}{d.production ? " · production" : ""}</span>
954
+ <span className="deployment-ref">
955
+ <strong>{d.ref}</strong>
956
+ <div className="deployment-meta">{d.repo} · deploy #{d.id}{d.sha ? ` · ${shortSha(d.sha)}` : ""}{d.description ? ` · ${d.description}` : ""}</div>
957
+ </span>
958
+ {d.environmentUrl ? <a className="branch" href={d.environmentUrl} target="_blank" rel="noreferrer">View deployment</a> : null}
959
+ </div>
960
+ )) : <div className="empty">No deployments yet.</div>}
961
+ </div>
962
+ </section>
963
+ );
964
+ }
965
+
966
+ // The Projects v2 board view (GitHub Projects). Renders the selected project's BOARD: its
967
+ // Status field's options as COLUMNS (swim-lanes) with the items grouped into them by their
968
+ // Status value, plus a "No Status" lane. Data-coupled: the column card counts come straight
969
+ // from the modeled project items + their field values (seeding items moves the counts).
970
+ export function ProjectBoardView({ boards, selectedId, onSelect }: { boards: ProjectBoard[]; selectedId?: number; onSelect: (id: number) => void }) {
971
+ if (!boards.length) return <div className="empty" data-testid="project-board-view">No projects.</div>;
972
+ const board = boards.find((b) => b.id === selectedId) ?? boards[0];
973
+ const columns = [...board.columns, board.noStatus];
974
+ return (
975
+ <section className="project-board-view" data-testid="project-board-view">
976
+ <div className="project-list">
977
+ <h3>Projects <span className="count">{boards.length}</span></h3>
978
+ {boards.map((b) => (
979
+ <div className={`project-row${b.id === board.id ? " active" : ""}`} data-testid="project-row" key={b.id} onClick={() => onSelect(b.id)}>
980
+ <span className="project-title"><strong>{b.title}</strong> #{b.number}</span>
981
+ <span className="project-item-count label" data-testid="project-item-count">{b.itemCount} items</span>
982
+ {b.closed ? <span className="label">closed</span> : null}
983
+ <span className="sha">{b.owner}</span>
984
+ </div>
985
+ ))}
986
+ </div>
987
+ <div className="project-board" data-testid="project-board">
988
+ <div className="project-views">{board.views.map((v) => <span className="project-view label" data-testid="project-view" key={v.number}>{v.name} ({v.layout})</span>)}</div>
989
+ <div className="board-columns" data-testid="board-columns">
990
+ {columns.map((col) => (
991
+ <div className="board-column" data-testid="board-column" key={col.name}>
992
+ <div className="board-column-head"><strong>{col.name}</strong> <span className="count" data-testid="board-column-count">{col.cards.length}</span></div>
993
+ {col.cards.map((card) => (
994
+ <div className="board-card" data-testid="board-card" key={card.id}>
995
+ <span className="board-card-title">{card.title}</span>
996
+ <span className="board-card-type label">{card.contentType}</span>
997
+ </div>
998
+ ))}
999
+ </div>
1000
+ ))}
1001
+ </div>
1002
+ </div>
1003
+ </section>
1004
+ );
1005
+ }
1006
+
1007
+ // The Insights / Pulse / Contributors screen. Pulse summarizes activity (merged/open PRs,
1008
+ // opened/closed issues); Contributors is a commit leaderboard. Data-coupled: every count is
1009
+ // derived from the modeled PRs/issues/commits the twin tracks (new objects move the numbers).
1010
+ export function InsightsView({ insights }: { insights: RepoInsights[] }) {
1011
+ if (!insights.length) return <div className="empty" data-testid="insights-view">No insights.</div>;
1012
+ return (
1013
+ <section className="insights-view" data-testid="insights-view">
1014
+ {insights.map((ins) => (
1015
+ <div className="insights-repo" data-testid="insights-repo" key={ins.repo}>
1016
+ <h3>{ins.repo}</h3>
1017
+ <div className="pulse" data-testid="pulse">
1018
+ <span className="pulse-stat" data-testid="pulse-merged-prs">{ins.pulse.mergedPrs} Merged pull requests</span>
1019
+ <span className="pulse-stat" data-testid="pulse-open-prs">{ins.pulse.openPrs} Open pull requests</span>
1020
+ <span className="pulse-stat" data-testid="pulse-closed-issues">{ins.pulse.closedIssues} Closed issues</span>
1021
+ <span className="pulse-stat" data-testid="pulse-open-issues">{ins.pulse.openedIssues} New issues</span>
1022
+ </div>
1023
+ <div className="contributors" data-testid="contributors">
1024
+ <h4>Contributors</h4>
1025
+ {ins.contributors.length ? ins.contributors.map((c) => (
1026
+ <div className="contributor-row" data-testid="contributor-row" key={c.login}>
1027
+ <span className="contributor-login"><strong>{c.login}</strong></span>
1028
+ <span className="contributor-commits label" data-testid="contributor-commits">{c.commits} commits</span>
1029
+ </div>
1030
+ )) : <div className="empty">No commit data.</div>}
1031
+ </div>
1032
+ </div>
1033
+ ))}
1034
+ </section>
1035
+ );
1036
+ }
1037
+
1038
+ // The repo Settings screens (General / Collaborators / Webhooks / Branches). Data-coupled:
1039
+ // every row + flag is read from the modeled repo config + collaborators/webhooks/branches
1040
+ // (adding a collaborator or webhook moves the rows/counts this screen renders).
1041
+ export function SettingsView({ settings }: { settings: RepoSettings[] }) {
1042
+ if (!settings.length) return <div className="empty" data-testid="settings-view">No repositories.</div>;
1043
+ return (
1044
+ <section className="settings-view" data-testid="settings-view">
1045
+ {settings.map((s) => (
1046
+ <div className="settings-repo" data-testid="settings-repo" key={s.repo}>
1047
+ <h3>{s.repo} settings</h3>
1048
+ <div className="settings-general" data-testid="settings-general">
1049
+ <span className="label" data-testid="settings-default-branch">default branch: {s.general.defaultBranch ?? "main"}</span>
1050
+ <span className="label">{s.general.private ? "private" : "public"}</span>
1051
+ {s.general.hasIssues ? <span className="label">Issues</span> : null}
1052
+ {s.general.hasProjects ? <span className="label">Projects</span> : null}
1053
+ {s.general.hasWiki ? <span className="label">Wiki</span> : null}
1054
+ {s.general.hasDiscussions ? <span className="label">Discussions</span> : null}
1055
+ </div>
1056
+ <div className="settings-section" data-testid="settings-collaborators">
1057
+ <h4>Manage access <span className="count" data-testid="collaborators-count">{s.collaborators.length}</span></h4>
1058
+ {s.collaborators.map((c) => (
1059
+ <div className="collaborator-row" data-testid="collaborator-row" key={c.login}>
1060
+ <strong>{c.login}</strong> <span className="label">{c.permission}</span>
1061
+ </div>
1062
+ ))}
1063
+ </div>
1064
+ <div className="settings-section" data-testid="settings-webhooks">
1065
+ <h4>Webhooks <span className="count" data-testid="webhooks-count">{s.webhooks.length}</span></h4>
1066
+ {s.webhooks.map((h) => (
1067
+ <div className="webhook-row" data-testid="webhook-row" key={h.id}>
1068
+ <span className="webhook-url">{h.url ?? "(no url)"}</span>
1069
+ <span className="label">{h.active ? "active" : "inactive"}</span>
1070
+ <span className="label">{h.events.join(", ")}</span>
1071
+ </div>
1072
+ ))}
1073
+ </div>
1074
+ <div className="settings-section" data-testid="settings-branches">
1075
+ <h4>Branches <span className="count" data-testid="branches-count">{s.branches.length}</span></h4>
1076
+ {s.branches.map((b) => (
1077
+ <div className="branch-row" data-testid="branch-row" key={b.name}>
1078
+ <strong>{b.name}</strong> {b.protected ? <span className="label" data-testid="branch-protected">protected</span> : null}
1079
+ </div>
1080
+ ))}
1081
+ </div>
1082
+ </div>
1083
+ ))}
1084
+ </section>
1085
+ );
1086
+ }
1087
+
1088
+ // The Actions view: a workflow-runs list (status/conclusion badge per run, run number,
1089
+ // workflow name, branch, sha) and, on selecting a run, a detail pane with its jobs +
1090
+ // each job's steps. Models the Actions API objects — the only Actions non-goal is running
1091
+ // real CI, so runs/jobs render with the status/conclusion the twin tracks (no live logs).
1092
+ export function ActionsView({ runs, jobs, selectedRunId, onSelect }: {
1093
+ runs: WorkflowRun[];
1094
+ jobs: Job[];
1095
+ selectedRunId?: number;
1096
+ onSelect?: (id: number) => void;
1097
+ }) {
1098
+ const selected = runs.find((r) => r.id === selectedRunId) ?? runs[0];
1099
+ const selectedJobs = selected ? jobs.filter((j) => j.repo === selected.repo && Number(j.runId) === Number(selected.id)) : [];
1100
+ return (
1101
+ <div className="actions-view split">
1102
+ <section className="list-box actions-box">
1103
+ <div className="list-head"><span><strong>{runs.length} workflow runs</strong></span><span>Status</span></div>
1104
+ {runs.length ? runs.map((run) => {
1105
+ const effective = run.status === "completed" ? (run.conclusion || "completed") : (run.status || "queued");
1106
+ return (
1107
+ <button
1108
+ key={run.id}
1109
+ className={`run-row ${run.id === (selected?.id) ? "active" : ""}`}
1110
+ onClick={() => onSelect?.(run.id)}
1111
+ >
1112
+ <RunStatusBadge status={run.status} conclusion={run.conclusion} />
1113
+ <span>
1114
+ <span className="run-title">{run.workflowName || run.name || `Run #${run.runNumber}`}</span>
1115
+ <div className="run-meta">
1116
+ {run.repo} · run #{run.runNumber} · {run.event || "workflow_dispatch"}
1117
+ {run.headBranch ? ` · ${run.headBranch}` : ""}
1118
+ {run.headSha ? ` · ${shortSha(run.headSha)}` : ""}
1119
+ {` · ${effective}`}
1120
+ {run.jobCount != null ? ` · ${run.jobCount} jobs` : ""}
1121
+ </div>
1122
+ </span>
1123
+ </button>
1124
+ );
1125
+ }) : <div className="empty">No workflow runs in this view.</div>}
1126
+ </section>
1127
+ <section className="conversation run-detail">
1128
+ {selected ? (
1129
+ <>
1130
+ <h2>{selected.workflowName || selected.name || `Run #${selected.runNumber}`} <span>#{selected.runNumber}</span></h2>
1131
+ <div className="state-line">
1132
+ <RunStatusBadge status={selected.status} conclusion={selected.conclusion} />
1133
+ <strong>{selected.repo}</strong>
1134
+ <span className="sha">{selected.event || "workflow_dispatch"}{selected.headBranch ? ` · ${selected.headBranch}` : ""}{selected.headSha ? ` · ${shortSha(selected.headSha)}` : ""}</span>
1135
+ {selected.url ? <a className="branch" href={selected.url} target="_blank" rel="noreferrer">View on GitHub</a> : null}
1136
+ </div>
1137
+ <section className="snapshot-section">
1138
+ <h3>Jobs</h3>
1139
+ <div className="job-list">
1140
+ {selectedJobs.length ? selectedJobs.map((job) => (
1141
+ <div className="job-row" key={job.id}>
1142
+ <div className="job-head">
1143
+ <RunStatusBadge status={job.status} conclusion={job.conclusion} />
1144
+ <strong>{job.name}</strong>
1145
+ </div>
1146
+ {(job.steps ?? []).length ? (
1147
+ <ol className="step-list">
1148
+ {(job.steps ?? []).map((step) => (
1149
+ <li className="step-row" key={step.number}>
1150
+ <RunStatusBadge status={step.status} conclusion={step.conclusion} /> {step.name}
1151
+ </li>
1152
+ ))}
1153
+ </ol>
1154
+ ) : null}
1155
+ </div>
1156
+ )) : <p className="sha">No jobs in this run.</p>}
1157
+ </div>
1158
+ </section>
1159
+ </>
1160
+ ) : <div className="empty">Select a workflow run.</div>}
1161
+ </section>
1162
+ </div>
1163
+ );
1164
+ }
1165
+
1166
+ function fmtBytes(size?: number): string {
1167
+ if (!size) return "0 B";
1168
+ if (size < 1024) return `${size} B`;
1169
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
1170
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
1171
+ }
1172
+
1173
+ // A release-channel badge: a draft release, a prerelease, or the published "Latest"/release.
1174
+ export function ReleaseBadge({ draft, prerelease }: { draft?: boolean; prerelease?: boolean }) {
1175
+ const kind = draft ? "draft" : prerelease ? "prerelease" : "release";
1176
+ const label = draft ? "Draft" : prerelease ? "Pre-release" : "Release";
1177
+ return <span className={`release-badge ${kind}`} title={`release channel: ${kind}`}>{label}</span>;
1178
+ }
1179
+
1180
+ // The Releases view: a list of releases (tag + name + draft/prerelease badge) and, on
1181
+ // selecting one, a detail pane with the release body + its ASSET catalog (name, type,
1182
+ // size, downloads). Models the Releases API objects — the only Non-goal is the asset
1183
+ // binary BYTES (declared), so assets render as metadata rows, never downloadable content.
1184
+ export function ReleasesView({ releases, selectedReleaseId, onSelect }: {
1185
+ releases: Release[];
1186
+ selectedReleaseId?: number;
1187
+ onSelect?: (id: number) => void;
1188
+ }) {
1189
+ const selected = releases.find((r) => r.id === selectedReleaseId) ?? releases[0];
1190
+ const assets = selected?.assets ?? [];
1191
+ return (
1192
+ <div className="releases-view split">
1193
+ <section className="list-box releases-box">
1194
+ <div className="list-head"><span><strong>{releases.length} releases</strong></span><span>Published</span></div>
1195
+ {releases.length ? releases.map((rel) => (
1196
+ <button
1197
+ key={rel.id}
1198
+ className={`release-row ${rel.id === (selected?.id) ? "active" : ""}`}
1199
+ onClick={() => onSelect?.(rel.id)}
1200
+ >
1201
+ <span className="release-tag">🏷 {rel.tagName}</span>
1202
+ <span>
1203
+ <span className="release-title">{rel.name || rel.tagName}</span>
1204
+ <div className="release-meta">
1205
+ {rel.repo}
1206
+ {rel.publishedAt ? ` · published ${new Date(rel.publishedAt).toLocaleString()}` : rel.createdAt ? ` · created ${new Date(rel.createdAt).toLocaleString()}` : ""}
1207
+ {(rel.assets ?? []).length ? ` · ${(rel.assets ?? []).length} assets` : ""}
1208
+ </div>
1209
+ </span>
1210
+ <span className="labels">
1211
+ <ReleaseBadge draft={rel.draft} prerelease={rel.prerelease} />
1212
+ </span>
1213
+ </button>
1214
+ )) : <div className="empty">No releases in this view.</div>}
1215
+ </section>
1216
+ <section className="conversation release-detail">
1217
+ {selected ? (
1218
+ <>
1219
+ <h2>{selected.name || selected.tagName} <span>{selected.tagName}</span></h2>
1220
+ <div className="state-line">
1221
+ <ReleaseBadge draft={selected.draft} prerelease={selected.prerelease} />
1222
+ <strong>{selected.repo}</strong>
1223
+ <span className="sha">
1224
+ target: {selected.targetCommitish || "main"}
1225
+ {selected.publishedAt ? ` · published ${new Date(selected.publishedAt).toLocaleString()}` : selected.draft ? " · unpublished draft" : ""}
1226
+ </span>
1227
+ {selected.url ? <a className="branch" href={selected.url} target="_blank" rel="noreferrer">View on GitHub</a> : null}
1228
+ </div>
1229
+ <div className="timeline-item">
1230
+ <article className="comment">
1231
+ <div className="comment-head"><strong>Release notes</strong></div>
1232
+ <div className="comment-body">{selected.body || "No release notes."}</div>
1233
+ </article>
1234
+ </div>
1235
+ <section className="snapshot-section">
1236
+ <h3>Assets</h3>
1237
+ <div className="asset-list">
1238
+ {assets.length ? assets.map((asset) => (
1239
+ <div className="asset-row" key={asset.id}>
1240
+ <strong className="asset-name">{asset.name}</strong>
1241
+ <span className="asset-type">{asset.contentType || "application/octet-stream"}</span>
1242
+ <span className="asset-size">{fmtBytes(asset.size)}</span>
1243
+ <span className="asset-downloads">{asset.downloadCount ?? 0} downloads</span>
1244
+ </div>
1245
+ )) : <p className="sha">No assets on this release (asset bytes are a declared Non-goal — metadata only).</p>}
1246
+ </div>
1247
+ </section>
1248
+ </>
1249
+ ) : <div className="empty">Select a release.</div>}
1250
+ </section>
1251
+ </div>
1252
+ );
1253
+ }
1254
+
1255
+ // A discussion category chip (emoji + name). Q&A categories are answerable.
1256
+ export function DiscussionCategoryBadge({ name, emoji, answerable }: { name?: string; emoji?: string; answerable?: boolean }) {
1257
+ if (!name) return null;
1258
+ return <span className={`discussion-category ${answerable ? "answerable" : ""}`} title={answerable ? "answerable (Q&A) category" : "category"}>{emoji ? `${emoji} ` : ""}{name}</span>;
1259
+ }
1260
+
1261
+ // An "answered"/"unanswered" status badge for a Q&A discussion (only shown for answerable
1262
+ // categories). Mirrors the green check GitHub shows once a discussion has an accepted answer.
1263
+ export function DiscussionAnswerBadge({ answerable, answered }: { answerable?: boolean; answered?: boolean }) {
1264
+ if (!answerable) return null;
1265
+ return <span className={`answer-badge ${answered ? "answered" : "unanswered"}`} title={answered ? "marked answered" : "awaiting an answer"}>{answered ? "✓ Answered" : "Unanswered"}</span>;
1266
+ }
1267
+
1268
+ // The Discussions view: a list of discussions (category + title + answered badge) and, on
1269
+ // selecting one, a detail pane with the opening post + the comment/reply thread, marking
1270
+ // the accepted answer. Models the Discussions objects (modeled over REST in the twin — a
1271
+ // declared deviation from GitHub's GraphQL Discussions API).
1272
+ export function DiscussionsView({ discussions, selectedKey, onSelect }: {
1273
+ discussions: Discussion[];
1274
+ selectedKey?: string;
1275
+ onSelect?: (key: string) => void;
1276
+ }) {
1277
+ const keyOf = (d: Discussion) => `${d.repo}#${d.number}`;
1278
+ const selected = discussions.find((d) => keyOf(d) === selectedKey) ?? discussions[0];
1279
+ const comments = selected?.comments ?? [];
1280
+ const answer = comments.find((c) => c.isAnswer);
1281
+ return (
1282
+ <div className="discussions-view split">
1283
+ <section className="list-box discussions-box">
1284
+ <div className="list-head"><span><strong>{discussions.length} discussions</strong></span><span>Updated</span></div>
1285
+ {discussions.length ? discussions.map((d) => (
1286
+ <button
1287
+ key={keyOf(d)}
1288
+ className={`discussion-row ${keyOf(d) === (selected ? keyOf(selected) : "") ? "active" : ""}`}
1289
+ onClick={() => onSelect?.(keyOf(d))}
1290
+ >
1291
+ <span className={`status-icon ${d.state === "closed" ? "closed" : "open"}`}>💬</span>
1292
+ <span>
1293
+ <span className="discussion-title">{d.title || `${d.repo}#${d.number}`}</span>
1294
+ <div className="discussion-meta">
1295
+ {d.repo} #{d.number}
1296
+ {d.categoryName ? ` · ${d.categoryName}` : ""}
1297
+ {d.commentsCount != null ? ` · ${d.commentsCount} comments` : ""}
1298
+ {d.updatedAt ? ` · updated ${new Date(d.updatedAt).toLocaleString()}` : d.createdAt ? ` · ${new Date(d.createdAt).toLocaleString()}` : ""}
1299
+ </div>
1300
+ </span>
1301
+ <span className="labels">
1302
+ <DiscussionCategoryBadge name={d.categoryName} emoji={d.categoryEmoji} answerable={d.isAnswerable} />
1303
+ <DiscussionAnswerBadge answerable={d.isAnswerable} answered={d.isAnswered} />
1304
+ {d.locked ? <span className="label locked" title="locked">🔒</span> : null}
1305
+ </span>
1306
+ </button>
1307
+ )) : <div className="empty">No discussions in this view.</div>}
1308
+ </section>
1309
+ <section className="conversation discussion-detail">
1310
+ {selected ? (
1311
+ <>
1312
+ <h2>{selected.title || `${selected.repo}#${selected.number}`} <span>#{selected.number}</span></h2>
1313
+ <div className="state-line">
1314
+ <DiscussionCategoryBadge name={selected.categoryName} emoji={selected.categoryEmoji} answerable={selected.isAnswerable} />
1315
+ <DiscussionAnswerBadge answerable={selected.isAnswerable} answered={selected.isAnswered} />
1316
+ <strong>{selected.repo}</strong>
1317
+ <span className="sha">{selected.state}{selected.locked ? " · locked" : ""}</span>
1318
+ {selected.url ? <a className="branch" href={selected.url} target="_blank" rel="noreferrer">View on GitHub</a> : null}
1319
+ </div>
1320
+ <div className="timeline-item">
1321
+ <article className="comment">
1322
+ <div className="comment-head"><strong>Opening post</strong></div>
1323
+ <div className="comment-body">{selected.body || "No description."}</div>
1324
+ </article>
1325
+ </div>
1326
+ {answer ? (
1327
+ <div className="timeline-item answer-highlight">
1328
+ <article className="comment accepted-answer">
1329
+ <div className="comment-head"><strong>✓ Marked as answer</strong> <span className="sha">comment #{answer.id}</span></div>
1330
+ <div className="comment-body">{answer.body || ""}</div>
1331
+ </article>
1332
+ </div>
1333
+ ) : null}
1334
+ <section className="snapshot-section">
1335
+ <h3>Comments</h3>
1336
+ <div className="comment-thread">
1337
+ {comments.length ? comments.map((c) => (
1338
+ <div className={`thread-comment ${c.parentId != null ? "reply" : ""} ${c.isAnswer ? "is-answer" : ""}`} key={c.id}>
1339
+ <div className="comment-head">
1340
+ <strong>comment #{c.id}</strong>
1341
+ {c.parentId != null ? <span className="sha"> · reply to #{c.parentId}</span> : null}
1342
+ {c.isAnswer ? <span className="answer-badge answered"> ✓ answer</span> : null}
1343
+ {c.createdAt ? <span className="sha"> · {new Date(c.createdAt).toLocaleString()}</span> : null}
1344
+ </div>
1345
+ <div className="comment-body">{c.body || ""}</div>
1346
+ </div>
1347
+ )) : <p className="sha">No comments yet.</p>}
1348
+ </div>
1349
+ </section>
1350
+ </>
1351
+ ) : <div className="empty">Select a discussion.</div>}
1352
+ </section>
1353
+ </div>
1354
+ );
1355
+ }
1356
+
1357
+ export function Conversation({ pr, reviews, commits, comments, reviewComments, files }: {
1358
+ pr?: PullRequest;
1359
+ reviews: Review[];
1360
+ commits: Commit[];
1361
+ comments: Comment[];
1362
+ reviewComments: Comment[];
1363
+ files: ChangedFile[];
1364
+ }) {
1365
+ if (!pr) return <div className="empty">Select a pull request.</div>;
1366
+ const state = pr.merged ? "merged" : (pr.state ?? "open");
1367
+ const labels = pr.labels ?? [];
1368
+ const assignees = pr.assignees ?? [];
1369
+ return (
1370
+ <section className="conversation">
1371
+ <h2>{pr.title || pr.url || `${pr.repo}#${pr.number}`} <span>#{pr.number}</span></h2>
1372
+ <div className="state-line">
1373
+ <span className={`state-badge ${state}`}>{state}</span>
1374
+ <CiStatus status={pr.ciStatus} />
1375
+ <strong>{pr.repo}</strong>
1376
+ <span className="sha">
1377
+ {pr.changedFiles ?? 0} files changed · {pr.commitsCount ?? 0} commits · {pr.reviewCount ?? 0} reviews · {pr.commentsCount ?? 0} comments · {pr.reviewCommentsCount ?? 0} review comments
1378
+ </span>
1379
+ {pr.milestone ? <span className="label milestone" title="Milestone">🏷 {pr.milestone}</span> : null}
1380
+ {pr.mergedAt ? <span className="sha">merged {new Date(pr.mergedAt).toLocaleString()}</span> : null}
1381
+ {pr.url ? <a className="branch" href={pr.url} target="_blank" rel="noreferrer">View on GitHub</a> : null}
1382
+ </div>
1383
+ {labels.length || assignees.length ? (
1384
+ <div className="meta-row">
1385
+ {labels.map((label) => label.name ? <span className="label" key={`l-${label.name}`}>{label.name}</span> : null)}
1386
+ {assignees.map((login) => <span className="label assignee" key={`a-${login}`}>@{login}</span>)}
1387
+ </div>
1388
+ ) : null}
1389
+ <div className="timeline-item">
1390
+ <Avatar src={pr.authorAvatarUrl} label={pr.author || "PR"} />
1391
+ <article className="comment">
1392
+ <div className="comment-head"><strong>{pr.author || "GitHub"}</strong> opened this pull request</div>
1393
+ <div className="comment-body">
1394
+ {pr.body || "No real PR body was included in the synced seed."}
1395
+ {pr.sourceIssue ? <div className="sha">source issue: {pr.sourceIssue}{pr.sourceIssueTitle ? ` ${pr.sourceIssueTitle}` : ""}</div> : null}
1396
+ {pr.mergeCommit ? <div className="sha">merge commit: {pr.mergeCommit}</div> : null}
1397
+ </div>
1398
+ </article>
1399
+ </div>
1400
+ {commits.length ? (
1401
+ <section className="snapshot-section">
1402
+ <h3>Commits</h3>
1403
+ {commits.map((commit) => (
1404
+ <div className="timeline-item" key={commit.sha}>
1405
+ <Avatar src={commit.authorAvatarUrl} label={commit.authorLogin || commit.author} />
1406
+ <article className="comment">
1407
+ <div className="comment-head"><strong>{commit.authorLogin || commit.author || "Git author"}</strong> committed {commit.date ? new Date(commit.date).toLocaleString() : ""}</div>
1408
+ <div className="comment-body">{commit.message || commit.sha}<div className="sha">{shortSha(commit.sha)}</div></div>
1409
+ </article>
1410
+ </div>
1411
+ ))}
1412
+ </section>
1413
+ ) : null}
1414
+ {comments.map((comment, index) => (
1415
+ <div className="timeline-item" key={`comment-${comment.url || index}`}>
1416
+ <Avatar src={comment.authorAvatarUrl} label={comment.author} />
1417
+ <article className="comment">
1418
+ <div className="comment-head"><strong>{comment.author || "GitHub user"}</strong> commented {comment.createdAt ? new Date(comment.createdAt).toLocaleString() : ""}</div>
1419
+ <div className="comment-body">{comment.body || ""}</div>
1420
+ </article>
1421
+ </div>
1422
+ ))}
1423
+ {reviews.map((review, index) => (
1424
+ <div className="timeline-item" key={`${review.reviewer}-${index}`}>
1425
+ <Avatar src={review.reviewerAvatarUrl} label={review.reviewer} />
1426
+ <article className="review-card">
1427
+ <div className="review-head">{review.reviewer || "GitHub user"} {review.state || "reviewed"} {review.submittedAt ? new Date(review.submittedAt).toLocaleString() : ""}</div>
1428
+ <div className="review-body">{review.body || "No review body."}</div>
1429
+ </article>
1430
+ </div>
1431
+ ))}
1432
+ {reviewComments.map((comment, index) => (
1433
+ <div className="timeline-item" key={`review-comment-${comment.url || index}`}>
1434
+ <Avatar src={comment.authorAvatarUrl} label={comment.author} />
1435
+ <article className="comment">
1436
+ <div className="comment-head"><strong>{comment.author || "GitHub user"}</strong> commented on {comment.path || "a file"}</div>
1437
+ <div className="comment-body">{comment.body || ""}</div>
1438
+ </article>
1439
+ </div>
1440
+ ))}
1441
+ {files.length ? (
1442
+ <section className="snapshot-section">
1443
+ <h3>Files changed</h3>
1444
+ <div className="file-list">
1445
+ {files.map((file) => (
1446
+ <div className="file-row" key={file.filename}>
1447
+ <span>{file.status || "modified"}</span>
1448
+ <strong>{file.filename}</strong>
1449
+ <span>+{file.additions ?? 0} -{file.deletions ?? 0}</span>
1450
+ </div>
1451
+ ))}
1452
+ </div>
1453
+ </section>
1454
+ ) : null}
1455
+ </section>
1456
+ );
1457
+ }
1458
+
1459
+ export function Aside({ pr, reviews, commits, files }: { pr?: PullRequest; reviews: Review[]; commits: Commit[]; files: ChangedFile[] }) {
1460
+ if (!pr) return <aside className="sidebar" />;
1461
+ return (
1462
+ <aside className="sidebar">
1463
+ <section className="side-section">
1464
+ <h3>Reviewers</h3>
1465
+ {reviews.length ? reviews.map((review, index) => <div className={`label ${review.observed ? "" : "approved"}`} key={`${review.reviewer}-${index}`}>{review.state} {review.reviewer}</div>) : <p className="sha">No reviews in snapshot.</p>}
1466
+ {(pr.requestedReviewers ?? []).length ? (
1467
+ <div className="requested-reviewers">
1468
+ {(pr.requestedReviewers ?? []).map((login) => <span className="label requested" key={`req-${login}`} title="Requested reviewer">@{login} (requested)</span>)}
1469
+ </div>
1470
+ ) : null}
1471
+ </section>
1472
+ <section className="side-section">
1473
+ <h3>Checks</h3>
1474
+ {pr.ciStatus ? <div className={`ci-line ${pr.ciStatus}`}><CiStatus status={pr.ciStatus} /> {pr.ciStatus}</div> : <p className="sha">No CI checks in snapshot.</p>}
1475
+ </section>
1476
+ <section className="side-section">
1477
+ <h3>Milestone</h3>
1478
+ {pr.milestone ? <span className="label milestone">🏷 {pr.milestone}</span> : <p className="sha">No milestone.</p>}
1479
+ </section>
1480
+ <section className="side-section">
1481
+ <h3>Labels</h3>
1482
+ {(pr.labels ?? []).length ? (pr.labels ?? []).map((label) => label.name ? <span className="label" key={label.name}>{label.name}</span> : null) : <p className="sha">None.</p>}
1483
+ </section>
1484
+ <section className="side-section">
1485
+ <h3>Assignees</h3>
1486
+ {(pr.assignees ?? []).length ? (pr.assignees ?? []).map((login) => <div className="label assignee" key={login}>@{login}</div>) : <p className="sha">No one assigned.</p>}
1487
+ </section>
1488
+ <section className="side-section">
1489
+ <h3>Development</h3>
1490
+ {pr.baseBranch ? <div className="branch">base: {pr.baseBranch}</div> : null}
1491
+ {pr.headBranch ? <div className="branch">head: {pr.headBranch}</div> : null}
1492
+ {pr.headSha ? <p className="sha">{shortSha(pr.headSha)}</p> : <p className="sha">No branch or commit list in synced seed.</p>}
1493
+ </section>
1494
+ <section className="side-section">
1495
+ <h3>Repository</h3>
1496
+ <p>{pr.repo}</p>
1497
+ </section>
1498
+ <section className="side-section">
1499
+ <h3>Snapshot Counts</h3>
1500
+ <p className="sha">{commits.length} commits</p>
1501
+ <p className="sha">{files.length} files</p>
1502
+ <p className="sha">{pr.additions ?? 0} additions / {pr.deletions ?? 0} deletions</p>
1503
+ </section>
1504
+ </aside>
1505
+ );
1506
+ }
1507
+
1508
+ // Guard the top-level mount: the browser bundle has a DOM and mounts exactly as before,
1509
+ // but importing this module in a non-DOM test (for renderToStaticMarkup of the exported
1510
+ // presentational components) must not touch `document`.
1511
+ if (typeof document !== "undefined") {
1512
+ createRoot(document.getElementById("root")!).render(<App />);
1513
+ }