@volter/twin-github 0.1.2 → 2.0.1

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