@volter/twin-github 0.1.1 → 2.0.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.
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 +230 -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 +101 -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 +159 -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 +223 -95
  161. package/src/github-git-http.ts +47 -83
  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 +47 -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 +186 -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
@@ -1,6 +1,25 @@
1
1
  import React, { useEffect, useMemo, useState } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
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
+ }
4
23
 
5
24
  export type PullRequest = {
6
25
  repo: string;
@@ -34,6 +53,10 @@ export type PullRequest = {
34
53
  requestedReviewers?: string[];
35
54
  milestone?: string;
36
55
  ciStatus?: string;
56
+ closedAt?: string;
57
+ mergedBy?: string;
58
+ mergeable?: boolean;
59
+ draft?: boolean;
37
60
  };
38
61
  export type LinkedRef = { type: "pull_request" | "issue"; number: number; url?: string };
39
62
  export type Issue = {
@@ -57,6 +80,7 @@ export type Issue = {
57
80
  export type Review = {
58
81
  repo: string;
59
82
  number: number;
83
+ id?: number;
60
84
  reviewer?: string;
61
85
  state?: string;
62
86
  body?: string;
@@ -79,6 +103,8 @@ export type Commit = {
79
103
  export type Comment = {
80
104
  repo: string;
81
105
  number: number;
106
+ id?: number;
107
+ authorAssociation?: string;
82
108
  author?: string;
83
109
  authorAvatarUrl?: string;
84
110
  body?: string;
@@ -220,6 +246,10 @@ type Payload = {
220
246
  projectBoards?: ProjectBoard[];
221
247
  insights?: RepoInsights[];
222
248
  settings?: RepoSettings[];
249
+ viewer?: string;
250
+ checks?: Check[];
251
+ repoFacts?: Record<string, RepoFacts>;
252
+ stateEvents?: StateEvent[];
223
253
  };
224
254
  fetchedAt?: string;
225
255
  };
@@ -249,14 +279,6 @@ function shortSha(value?: string) {
249
279
  return value ? value.slice(0, 7) : "unknown";
250
280
  }
251
281
 
252
- // CI rollup glyph per PR head_sha (✓ success, ✗ failure, ● pending). Undefined for
253
- // observed-only PRs with no statuses/check-runs — they show no CI indicator (honesty).
254
- const CI_GLYPH: Record<string, string> = { success: "✓", failure: "✗", pending: "●" };
255
- export function CiStatus({ status }: { status?: string }) {
256
- if (!status) return null;
257
- return <span className={`ci-status ${status}`} title={`checks: ${status}`}>{CI_GLYPH[status] ?? "●"}</span>;
258
- }
259
-
260
282
  function prKey(pr: PullRequest) {
261
283
  return `${pr.repo}#${pr.number}`;
262
284
  }
@@ -270,18 +292,6 @@ export function RunStatusBadge({ status, conclusion }: { status?: string; conclu
270
292
  return <span className={`run-badge ${effective}`} title={`run: ${effective}`}>{RUN_GLYPH[effective] ?? "●"} {effective}</span>;
271
293
  }
272
294
 
273
- function initials(name?: string): string {
274
- return (name || "?").split(/[-_\s]+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase() || "?";
275
- }
276
-
277
- function Avatar({ src, label }: { src?: string; label?: string }) {
278
- return (
279
- <div className="avatar">
280
- {src ? <img src={src} alt="" loading="lazy" referrerPolicy="no-referrer" /> : initials(label)}
281
- </div>
282
- );
283
- }
284
-
285
295
  // ── Real-app URL routing (TWIN-49 / H3) ─────────────────────────────────────────────────
286
296
  // Vendor-faithful path shapes (credible-sim fidelity — recognizable to an agent that knows
287
297
  // the real GitHub UI):
@@ -294,8 +304,10 @@ function Avatar({ src, label }: { src?: string; label?: string }) {
294
304
  // all stay in sync through one small function pair.
295
305
  type ViewName = "pulls" | "issues" | "actions" | "releases" | "discussions" | "code" | "search" | "notifications" | "deployments" | "projects" | "insights" | "settings";
296
306
  const VIEW_NAMES: ViewName[] = ["pulls", "issues", "actions", "releases", "discussions", "code", "search", "notifications", "deployments", "projects", "insights", "settings"];
297
- type RouteState = { repo: string; view: ViewName; selectedKey: string; selectedIssueKey: string };
307
+ type RouteState = { repo: string; view: ViewName; selectedKey: string; selectedIssueKey: string; pullTab?: PullTab };
298
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" };
299
311
 
300
312
  /** `selectedKey`/`selectedIssueKey` are `${repo}#${number}` — split on the LAST `#` (repo
301
313
  * names never contain one) to recover the PR/issue's OWN repo, independent of whichever
@@ -315,13 +327,15 @@ function pathFor(route: RouteState): string {
315
327
  // each PR/issue `url` (github-shared.ts), so a rendered link and the address bar agree.
316
328
  if (view === "pulls" && selectedKey) {
317
329
  const { repo: prRepo, number } = splitRepoScopedKey(selectedKey);
318
- return githubPullPath(prRepo, number);
330
+ const segment = PULL_TAB_SEGMENT[route.pullTab ?? "conversation"];
331
+ return `${githubPullPath(prRepo, number)}${segment ? `/${segment}` : ""}`;
319
332
  }
320
333
  if (view === "issues" && selectedIssueKey) {
321
334
  const { repo: issueRepo, number } = splitRepoScopedKey(selectedIssueKey);
322
335
  return githubIssuePath(issueRepo, number);
323
336
  }
324
337
  if (repo === "all" || !repo) return `/${view}`;
338
+ if (view === "code") return `/${repo}`;
325
339
  return `/${repo}/${view}`;
326
340
  }
327
341
 
@@ -333,9 +347,12 @@ function stateFor(pathname: string): RouteState {
333
347
  }
334
348
  if (segments.length >= 2) {
335
349
  const repo = `${segments[0]}/${segments[1]}`;
336
- if (segments.length === 2) return { ...DEFAULT_ROUTE, repo, view: "pulls" };
350
+ if (segments.length === 2) return { ...DEFAULT_ROUTE, repo, view: "code" };
337
351
  const tab = segments[2];
338
- if (tab === "pull" && segments[3]) return { ...DEFAULT_ROUTE, repo, view: "pulls", selectedKey: `${repo}#${segments[3]}` };
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
+ }
339
356
  if (tab === "pulls") return { ...DEFAULT_ROUTE, repo, view: "pulls" };
340
357
  if (tab === "issues" && segments[3]) return { ...DEFAULT_ROUTE, repo, view: "issues", selectedIssueKey: `${repo}#${segments[3]}` };
341
358
  if (tab === "issues") return { ...DEFAULT_ROUTE, repo, view: "issues" };
@@ -347,7 +364,7 @@ function stateFor(pathname: string): RouteState {
347
364
 
348
365
  function App() {
349
366
  const [payload, setPayload] = useState<Payload | null>(null);
350
- const initialRoute = typeof window !== "undefined" ? stateFor(window.location.pathname) : DEFAULT_ROUTE;
367
+ const initialRoute = typeof window !== "undefined" ? stateFor(appPath(window.location.pathname)) : DEFAULT_ROUTE;
351
368
  const [repo, setRepo] = useState(initialRoute.repo);
352
369
  const [selectedKey, setSelectedKey] = useState(initialRoute.selectedKey);
353
370
  const [query, setQuery] = useState("");
@@ -357,20 +374,32 @@ function App() {
357
374
  const [selectedReleaseId, setSelectedReleaseId] = useState<number | undefined>(undefined);
358
375
  const [selectedDiscussionKey, setSelectedDiscussionKey] = useState<string | undefined>(undefined);
359
376
  const [selectedIssueKey, setSelectedIssueKey] = useState<string>(initialRoute.selectedIssueKey);
360
- const [showFilesDiff, setShowFilesDiff] = useState(false);
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");
361
381
  const [error, setError] = useState("");
362
382
  // Set while applying a popstate-derived route, so the pushState effect below (which reacts
363
383
  // to the same state) skips re-pushing the entry the browser just navigated to.
364
384
  const skipNextPush = React.useRef(false);
365
385
 
366
- // The twin's store door (`GET /twin/store/mirror` — the ONE named projection the twin
367
- // declares for this console) is the only read of twin state; it is the twin's own
368
- // surface on this origin, never a mirror-private endpoint.
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);
369
392
  async function refresh() {
393
+ const seq = ++refreshSeq.current;
370
394
  try {
371
- const response = await fetch(GITHUB_MIRROR_STORE_PATH);
395
+ const response = await fetch(`${WIRE_BASE}${GITHUB_MIRROR_STORE_PATH}`);
372
396
  const json = await response.json();
373
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;
374
403
  setPayload(json);
375
404
  setError("");
376
405
  } catch (err) {
@@ -380,30 +409,35 @@ function App() {
380
409
 
381
410
  useEffect(() => {
382
411
  void refresh();
383
- const timer = window.setInterval(() => void refresh(), 1500);
412
+ const timer = window.setInterval(() => void refresh(), 3000);
384
413
  return () => window.clearInterval(timer);
385
414
  }, []);
415
+ // opening a pull request loads its thread now, not at the next poll
416
+ useEffect(() => { void refresh(); }, [selectedKey]);
386
417
 
387
418
  // pushState on navigation: any click that changes repo/view/selection updates the address
388
419
  // bar (no reload) to the vendor-faithful path for the new state.
389
420
  useEffect(() => {
390
- const path = pathFor({ repo, view, selectedKey, selectedIssueKey });
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, "+")}` : "";
391
423
  if (skipNextPush.current) {
392
424
  skipNextPush.current = false;
393
425
  return;
394
426
  }
395
- if (window.location.pathname !== path) window.history.pushState(null, "", path);
396
- }, [repo, view, selectedKey, selectedIssueKey]);
427
+ if (window.location.pathname !== path || window.location.search !== search) window.history.pushState(null, "", `${path}${search}`);
428
+ }, [repo, view, selectedKey, selectedIssueKey, pullTab, listState]);
397
429
 
398
430
  // popstate (back/forward): re-derive the view + selection from the restored pathname.
399
431
  useEffect(() => {
400
432
  function onPopState() {
401
- const route = stateFor(window.location.pathname);
433
+ const route = stateFor(appPath(window.location.pathname));
402
434
  skipNextPush.current = true;
403
435
  setRepo(route.repo);
404
436
  setView(route.view);
405
437
  setSelectedKey(route.selectedKey);
406
438
  setSelectedIssueKey(route.selectedIssueKey);
439
+ setPullTab(route.pullTab ?? "conversation");
440
+ setListState(listStateOf(window.location.search));
407
441
  }
408
442
  window.addEventListener("popstate", onPopState);
409
443
  return () => window.removeEventListener("popstate", onPopState);
@@ -554,75 +588,87 @@ function App() {
554
588
  // used to implement is REPLACED by real path routing (see pathFor/stateFor above) — no
555
589
  // dual/legacy path.
556
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
+
557
669
  return (
558
- <main className="browser-shell">
559
- <section className="browser-tabs" aria-label="Repository browser tabs">
560
- <button
561
- className={`browser-tab ${repo === "all" ? "active" : ""}`}
562
- onClick={() => {
563
- setRepo("all");
564
- setSelectedKey("");
565
- }}
566
- >
567
- <span>All repositories</span>
568
- <strong>{payload?.github.pullRequests?.length ?? 0}</strong>
569
- </button>
570
- {repos.map((name) => {
571
- const count = (payload?.github.pullRequests ?? []).filter((pr) => pr.repo === name).length;
572
- return (
573
- <button
574
- key={name}
575
- className={`browser-tab ${name === repo ? "active" : ""}`}
576
- onClick={() => {
577
- setRepo(name);
578
- setSelectedKey("");
579
- }}
580
- >
581
- <span>{name}</span>
582
- <strong>{count}</strong>
583
- </button>
584
- );
585
- })}
586
- </section>
587
- <section className="browser-address">
588
- <span className="browser-dot red" />
589
- <span className="browser-dot yellow" />
590
- <span className="browser-dot green" />
591
- <div className="address-bar">github.local{pathFor({ repo, view, selectedKey, selectedIssueKey })}</div>
592
- </section>
593
- <section className="github-shell">
594
- <header className="global-header">
595
- <div className="octo">GH</div>
596
- <input className="search" value="Search or jump to..." readOnly />
597
- <nav className="global-nav"><span>Pull requests</span><span>Issues</span><span>Marketplace</span><span>Explore</span></nav>
598
- <div className="global-spacer" />
599
- <span className="pill-dark">mock github</span>
600
- </header>
601
- <section className="repo-head">
602
- <div className="repo-line">
603
- {repo === "all" ? (
604
- <><button>all</button><span>/</span><strong>repositories</strong></>
605
- ) : (
606
- <><button>{repo.split("/")[0] || "owner"}</button><span>/</span><strong>{repo.split("/")[1] || "repository"}</strong></>
607
- )}
608
- </div>
609
- <nav className="repo-tabs">
610
- <button>Code</button>
611
- <button className={view === "issues" ? "active" : ""} onClick={() => setView("issues")}>Issues <span className="count">{issues.length}</span></button>
612
- <button className={view === "pulls" ? "active" : ""} onClick={() => setView("pulls")}>Pull requests <span className="count">{prs.length}</span></button>
613
- <button className={view === "actions" ? "active" : ""} onClick={() => setView("actions")}>Actions <span className="count">{runs.length}</span></button>
614
- <button className={view === "releases" ? "active" : ""} onClick={() => setView("releases")}>Releases <span className="count">{releases.length}</span></button>
615
- <button className={view === "discussions" ? "active" : ""} onClick={() => setView("discussions")}>Discussions <span className="count">{discussions.length}</span></button>
616
- <button className={view === "code" ? "active" : ""} onClick={() => setView("code")}>Code <span className="count">{(payload?.github.contents ?? []).length}</span></button>
617
- <button className={view === "search" ? "active" : ""} onClick={() => setView("search")}>Search</button>
618
- <button className={view === "notifications" ? "active" : ""} onClick={() => setView("notifications")}>Notifications <span className="count">{(payload?.github.notifications ?? []).filter((n) => n.unread).length}</span></button>
619
- <button className={view === "deployments" ? "active" : ""} onClick={() => setView("deployments")}>Deployments <span className="count">{deployments.length}</span></button>
620
- <button className={view === "projects" ? "active" : ""} onClick={() => setView("projects")}>Projects <span className="count">{projectBoards.length}</span></button>
621
- <button>Security</button>
622
- <button className={view === "insights" ? "active" : ""} onClick={() => setView("insights")}>Insights</button>
623
- <button className={view === "settings" ? "active" : ""} onClick={() => setView("settings")}>Settings <span className="count">{settings.length}</span></button>
624
- </nav>
625
- </section>
670
+ <div className="gh-app">
671
+ {header}
626
672
  <div className="content">
627
673
  {error ? <div className="error">{error}</div> : null}
628
674
  <div className="toolbar">
@@ -630,9 +676,9 @@ function App() {
630
676
  className="pr-filter"
631
677
  value={query}
632
678
  onChange={(event) => setQuery(event.target.value)}
633
- 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"}
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"}
634
680
  />
635
- <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>
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}
636
682
  </div>
637
683
  {view === "issues" ? (
638
684
  <div className="split">
@@ -661,74 +707,9 @@ function App() {
661
707
  <InsightsView insights={insights} />
662
708
  ) : view === "settings" ? (
663
709
  <SettingsView settings={settings} />
664
- ) : (
665
- <div className="split">
666
- <main>
667
- <PrList prs={prs} reviews={payload?.github.reviews ?? []} selectedKey={selectedKey} onSelect={setSelectedKey} />
668
- <div className="pr-tabs">
669
- <button className={showFilesDiff ? "" : "active"} onClick={() => setShowFilesDiff(false)}>Conversation</button>
670
- <button className={showFilesDiff ? "active" : ""} onClick={() => setShowFilesDiff(true)}>Files changed <span className="count">{current?.changedFiles ?? currentFiles.length}</span></button>
671
- </div>
672
- {showFilesDiff ? (
673
- <FilesDiff files={currentFiles} />
674
- ) : (
675
- <Conversation
676
- pr={current}
677
- reviews={currentReviews}
678
- commits={currentCommits}
679
- comments={currentComments}
680
- reviewComments={currentReviewComments}
681
- files={currentFiles}
682
- />
683
- )}
684
- </main>
685
- <Aside pr={current} reviews={currentReviews} commits={currentCommits} files={currentFiles} />
686
- </div>
687
- )}
710
+ ) : null}
688
711
  </div>
689
- </section>
690
- </main>
691
- );
692
- }
693
-
694
- export function PrList({ prs, reviews, selectedKey, onSelect }: {
695
- prs: PullRequest[];
696
- reviews: Review[];
697
- selectedKey?: string;
698
- onSelect?: (key: string) => void;
699
- }) {
700
- return (
701
- <section className="list-box">
702
- <div className="list-head"><span><strong>{prs.length} pull requests</strong></span><span>Updated</span></div>
703
- {prs.length ? prs.map((pr) => {
704
- const approvedCount = reviews.filter((review) => review.repo === pr.repo && Number(review.number) === Number(pr.number) && review.state === "approved").length;
705
- const state = pr.merged ? "merged" : (pr.state ?? "open");
706
- return (
707
- <button key={prKey(pr)} className={`pr-row ${prKey(pr) === selectedKey ? "active" : ""}`} onClick={() => onSelect?.(prKey(pr))}>
708
- <span className={`status-icon ${state}`}>o</span>
709
- <span>
710
- <span className="pr-title">{pr.title || pr.url || `${pr.repo}#${pr.number}`}</span>
711
- <div className="pr-meta">
712
- {pr.repo} #{pr.number} · {state}
713
- {pr.author ? ` · opened by ${pr.author}` : ""}
714
- {pr.updatedAt ? ` · updated ${new Date(pr.updatedAt).toLocaleString()}` : pr.createdAt ? ` · ${new Date(pr.createdAt).toLocaleString()}` : ""}
715
- {pr.sourceIssue ? ` · ${pr.sourceIssue}` : ""}
716
- {pr.changedFiles != null ? ` · ${pr.changedFiles} files` : ""}
717
- {pr.commitsCount != null ? ` · ${pr.commitsCount} commits` : ""}
718
- </div>
719
- </span>
720
- <span className="labels">
721
- <CiStatus status={pr.ciStatus} />
722
- <span className={`state-badge ${state}`}>{state}</span>
723
- {approvedCount ? <span className="label approved">{approvedCount} approved</span> : null}
724
- {pr.milestone ? <span className="label milestone" title="Milestone">🏷 {pr.milestone}</span> : null}
725
- {(pr.labels ?? []).slice(0, 2).map((label) => label.name ? <span className="label" key={label.name}>{label.name}</span> : null)}
726
- {(pr.assignees ?? []).slice(0, 2).map((login) => <span className="label assignee" key={login}>@{login}</span>)}
727
- </span>
728
- </button>
729
- );
730
- }) : <div className="empty">No pull requests match this view.</div>}
731
- </section>
712
+ </div>
732
713
  );
733
714
  }
734
715
 
@@ -771,40 +752,6 @@ export function IssuesTable({ issues, onSelect }: { issues: Issue[]; onSelect?:
771
752
  );
772
753
  }
773
754
 
774
- // The PR Files-changed DIFF view: renders each changed file's unified-diff hunks as
775
- // +/- lines (the GitHub "Files changed" tab). Hunk text comes from the LOCAL PR write's
776
- // `patch`; observed PRs carry no per-file diff, so a file with no patch shows a summary row
777
- // only (no fabricated hunks). Each diff line is classed add/del/context so it renders like GitHub.
778
- export function FilesDiff({ files }: { files: ChangedFile[] }) {
779
- if (!files.length) return <div className="empty">No file diffs in this snapshot.</div>;
780
- return (
781
- <section className="files-diff" data-testid="files-diff">
782
- <h3>Files changed <span className="count">{files.length}</span></h3>
783
- {files.map((file) => (
784
- <article className="diff-file" key={`${file.repo}#${file.number}#${file.filename}`}>
785
- <div className="diff-file-head">
786
- <span className={`diff-status ${file.status ?? "modified"}`}>{file.status ?? "modified"}</span>
787
- <strong className="diff-filename">{file.filename}</strong>
788
- <span className="diff-stat">+{file.additions ?? 0} −{file.deletions ?? 0}</span>
789
- </div>
790
- {file.patch ? (
791
- <pre className="diff-hunk" data-testid="diff-hunk">
792
- {file.patch.split("\n").map((line, idx) => {
793
- const cls = line.startsWith("+") && !line.startsWith("+++") ? "diff-line-add"
794
- : line.startsWith("-") && !line.startsWith("---") ? "diff-line-del"
795
- : line.startsWith("@@") ? "diff-line-hunk" : "diff-line-ctx";
796
- return <div className={cls} key={idx}>{line || " "}</div>;
797
- })}
798
- </pre>
799
- ) : (
800
- <div className="sha diff-no-patch">No diff hunk in snapshot (observed PRs carry counts only).</div>
801
- )}
802
- </article>
803
- ))}
804
- </section>
805
- );
806
- }
807
-
808
755
  // The Issue DETAIL page: the selected issue's header (state/labels/assignees/milestone),
809
756
  // its derived state-change Timeline (labeled/assigned/locked/closed events), and its
810
757
  // comments feed. Mirrors GitHub's single-issue screen. Observed comments are labelled.
@@ -1360,157 +1307,6 @@ export function DiscussionsView({ discussions, selectedKey, onSelect }: {
1360
1307
  );
1361
1308
  }
1362
1309
 
1363
- export function Conversation({ pr, reviews, commits, comments, reviewComments, files }: {
1364
- pr?: PullRequest;
1365
- reviews: Review[];
1366
- commits: Commit[];
1367
- comments: Comment[];
1368
- reviewComments: Comment[];
1369
- files: ChangedFile[];
1370
- }) {
1371
- if (!pr) return <div className="empty">Select a pull request.</div>;
1372
- const state = pr.merged ? "merged" : (pr.state ?? "open");
1373
- const labels = pr.labels ?? [];
1374
- const assignees = pr.assignees ?? [];
1375
- return (
1376
- <section className="conversation">
1377
- <h2>{pr.title || pr.url || `${pr.repo}#${pr.number}`} <span>#{pr.number}</span></h2>
1378
- <div className="state-line">
1379
- <span className={`state-badge ${state}`}>{state}</span>
1380
- <CiStatus status={pr.ciStatus} />
1381
- <strong>{pr.repo}</strong>
1382
- <span className="sha">
1383
- {pr.changedFiles ?? 0} files changed · {pr.commitsCount ?? 0} commits · {pr.reviewCount ?? 0} reviews · {pr.commentsCount ?? 0} comments · {pr.reviewCommentsCount ?? 0} review comments
1384
- </span>
1385
- {pr.milestone ? <span className="label milestone" title="Milestone">🏷 {pr.milestone}</span> : null}
1386
- {pr.mergedAt ? <span className="sha">merged {new Date(pr.mergedAt).toLocaleString()}</span> : null}
1387
- {pr.url ? <a className="branch" href={pr.url} target="_blank" rel="noreferrer">View on GitHub</a> : null}
1388
- </div>
1389
- {labels.length || assignees.length ? (
1390
- <div className="meta-row">
1391
- {labels.map((label) => label.name ? <span className="label" key={`l-${label.name}`}>{label.name}</span> : null)}
1392
- {assignees.map((login) => <span className="label assignee" key={`a-${login}`}>@{login}</span>)}
1393
- </div>
1394
- ) : null}
1395
- <div className="timeline-item">
1396
- <Avatar src={pr.authorAvatarUrl} label={pr.author || "PR"} />
1397
- <article className="comment">
1398
- <div className="comment-head"><strong>{pr.author || "GitHub"}</strong> opened this pull request</div>
1399
- <div className="comment-body">
1400
- {pr.body || "No real PR body was included in the synced seed."}
1401
- {pr.sourceIssue ? <div className="sha">source issue: {pr.sourceIssue}{pr.sourceIssueTitle ? ` ${pr.sourceIssueTitle}` : ""}</div> : null}
1402
- {pr.mergeCommit ? <div className="sha">merge commit: {pr.mergeCommit}</div> : null}
1403
- </div>
1404
- </article>
1405
- </div>
1406
- {commits.length ? (
1407
- <section className="snapshot-section">
1408
- <h3>Commits</h3>
1409
- {commits.map((commit) => (
1410
- <div className="timeline-item" key={commit.sha}>
1411
- <Avatar src={commit.authorAvatarUrl} label={commit.authorLogin || commit.author} />
1412
- <article className="comment">
1413
- <div className="comment-head"><strong>{commit.authorLogin || commit.author || "Git author"}</strong> committed {commit.date ? new Date(commit.date).toLocaleString() : ""}</div>
1414
- <div className="comment-body">{commit.message || commit.sha}<div className="sha">{shortSha(commit.sha)}</div></div>
1415
- </article>
1416
- </div>
1417
- ))}
1418
- </section>
1419
- ) : null}
1420
- {comments.map((comment, index) => (
1421
- <div className="timeline-item" key={`comment-${comment.url || index}`}>
1422
- <Avatar src={comment.authorAvatarUrl} label={comment.author} />
1423
- <article className="comment">
1424
- <div className="comment-head"><strong>{comment.author || "GitHub user"}</strong> commented {comment.createdAt ? new Date(comment.createdAt).toLocaleString() : ""}</div>
1425
- <div className="comment-body">{comment.body || ""}</div>
1426
- </article>
1427
- </div>
1428
- ))}
1429
- {reviews.map((review, index) => (
1430
- <div className="timeline-item" key={`${review.reviewer}-${index}`}>
1431
- <Avatar src={review.reviewerAvatarUrl} label={review.reviewer} />
1432
- <article className="review-card">
1433
- <div className="review-head">{review.reviewer || "GitHub user"} {review.state || "reviewed"} {review.submittedAt ? new Date(review.submittedAt).toLocaleString() : ""}</div>
1434
- <div className="review-body">{review.body || "No review body."}</div>
1435
- </article>
1436
- </div>
1437
- ))}
1438
- {reviewComments.map((comment, index) => (
1439
- <div className="timeline-item" key={`review-comment-${comment.url || index}`}>
1440
- <Avatar src={comment.authorAvatarUrl} label={comment.author} />
1441
- <article className="comment">
1442
- <div className="comment-head"><strong>{comment.author || "GitHub user"}</strong> commented on {comment.path || "a file"}</div>
1443
- <div className="comment-body">{comment.body || ""}</div>
1444
- </article>
1445
- </div>
1446
- ))}
1447
- {files.length ? (
1448
- <section className="snapshot-section">
1449
- <h3>Files changed</h3>
1450
- <div className="file-list">
1451
- {files.map((file) => (
1452
- <div className="file-row" key={file.filename}>
1453
- <span>{file.status || "modified"}</span>
1454
- <strong>{file.filename}</strong>
1455
- <span>+{file.additions ?? 0} -{file.deletions ?? 0}</span>
1456
- </div>
1457
- ))}
1458
- </div>
1459
- </section>
1460
- ) : null}
1461
- </section>
1462
- );
1463
- }
1464
-
1465
- export function Aside({ pr, reviews, commits, files }: { pr?: PullRequest; reviews: Review[]; commits: Commit[]; files: ChangedFile[] }) {
1466
- if (!pr) return <aside className="sidebar" />;
1467
- return (
1468
- <aside className="sidebar">
1469
- <section className="side-section">
1470
- <h3>Reviewers</h3>
1471
- {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>}
1472
- {(pr.requestedReviewers ?? []).length ? (
1473
- <div className="requested-reviewers">
1474
- {(pr.requestedReviewers ?? []).map((login) => <span className="label requested" key={`req-${login}`} title="Requested reviewer">@{login} (requested)</span>)}
1475
- </div>
1476
- ) : null}
1477
- </section>
1478
- <section className="side-section">
1479
- <h3>Checks</h3>
1480
- {pr.ciStatus ? <div className={`ci-line ${pr.ciStatus}`}><CiStatus status={pr.ciStatus} /> {pr.ciStatus}</div> : <p className="sha">No CI checks in snapshot.</p>}
1481
- </section>
1482
- <section className="side-section">
1483
- <h3>Milestone</h3>
1484
- {pr.milestone ? <span className="label milestone">🏷 {pr.milestone}</span> : <p className="sha">No milestone.</p>}
1485
- </section>
1486
- <section className="side-section">
1487
- <h3>Labels</h3>
1488
- {(pr.labels ?? []).length ? (pr.labels ?? []).map((label) => label.name ? <span className="label" key={label.name}>{label.name}</span> : null) : <p className="sha">None.</p>}
1489
- </section>
1490
- <section className="side-section">
1491
- <h3>Assignees</h3>
1492
- {(pr.assignees ?? []).length ? (pr.assignees ?? []).map((login) => <div className="label assignee" key={login}>@{login}</div>) : <p className="sha">No one assigned.</p>}
1493
- </section>
1494
- <section className="side-section">
1495
- <h3>Development</h3>
1496
- {pr.baseBranch ? <div className="branch">base: {pr.baseBranch}</div> : null}
1497
- {pr.headBranch ? <div className="branch">head: {pr.headBranch}</div> : null}
1498
- {pr.headSha ? <p className="sha">{shortSha(pr.headSha)}</p> : <p className="sha">No branch or commit list in synced seed.</p>}
1499
- </section>
1500
- <section className="side-section">
1501
- <h3>Repository</h3>
1502
- <p>{pr.repo}</p>
1503
- </section>
1504
- <section className="side-section">
1505
- <h3>Snapshot Counts</h3>
1506
- <p className="sha">{commits.length} commits</p>
1507
- <p className="sha">{files.length} files</p>
1508
- <p className="sha">{pr.additions ?? 0} additions / {pr.deletions ?? 0} deletions</p>
1509
- </section>
1510
- </aside>
1511
- );
1512
- }
1513
-
1514
1310
  // Guard the top-level mount: the browser bundle has a DOM and mounts exactly as before,
1515
1311
  // but importing this module in a non-DOM test (for renderToStaticMarkup of the exported
1516
1312
  // presentational components) must not touch `document`.