@d3lm/pr-stats 0.2.7 → 0.2.9

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 (3) hide show
  1. package/README.md +15 -3
  2. package/dist/tui-app.mjs +993 -540
  3. package/package.json +1 -1
package/dist/tui-app.mjs CHANGED
@@ -146,7 +146,7 @@ import { join as join2 } from "node:path";
146
146
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
147
147
  import { homedir } from "node:os";
148
148
  import { dirname, join } from "node:path";
149
- var VERSION = 2;
149
+ var VERSION = 5;
150
150
  var enabled = false;
151
151
  function configureCache(on) {
152
152
  enabled = on;
@@ -621,10 +621,12 @@ function hintsFor(modal, editing, tab, authoredTab, views, copyLinks2) {
621
621
  if (scope?.view === "list") {
622
622
  return `\u2191/\u2193 select \xB7 enter open \xB7 ${toggle}\u2190/\u2192 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
623
623
  }
624
+ const view = views === null ? null : tab === 1 ? views.merged : tab === 2 ? views.review : tab === 3 ? views.size : views.comments;
625
+ const expand = view?.expandable ? view.expanded ? "x collapse \xB7 " : "x expand \xB7 " : "";
624
626
  if (scope !== null && repos.length > 0) {
625
- return `${toggle}esc back \xB7 j/k scroll \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
627
+ return `${toggle}${expand}esc back \xB7 j/k scroll \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
626
628
  }
627
- return `${toggle}1-5 tabs \xB7 j/k scroll \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
629
+ return `${toggle}${expand}1-5 tabs \xB7 j/k scroll \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
628
630
  }
629
631
 
630
632
  // src/tui/components/Spinner.tsx
@@ -793,7 +795,8 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
793
795
  requestedAt: result.requestedAt,
794
796
  reviewedAt: result.reviewedAt,
795
797
  hours: durationHours(result.requestedAt, result.reviewedAt),
796
- verdict: result.verdict
798
+ verdict: result.verdict,
799
+ lines: result.lines
797
800
  });
798
801
  } else if (result.kind === "pending" && result.pr.state === "open") {
799
802
  pending.push({ pr: result.pr, requestedAt: result.requestedAt, hours: durationHours(result.requestedAt, now) });
@@ -883,6 +886,68 @@ function computeMergeStats(sizes) {
883
886
  closed.sort((a, b) => b.closedAt.getTime() - a.closedAt.getTime());
884
887
  return { merged, closed, open, allHours: merged.map((result) => result.hours) };
885
888
  }
889
+ function computeReviewerStats(sizes, author) {
890
+ const byLogin = /* @__PURE__ */ new Map();
891
+ let mergedReviewed = 0;
892
+ let mergedUnreviewed = 0;
893
+ for (const entry of sizes) {
894
+ const others = entry.reviews.flatMap(
895
+ (review) => review.login === null || review.login === author ? [] : [review.login]
896
+ );
897
+ for (const login of others) {
898
+ const counts = byLogin.get(login) ?? { prs: 0, reviews: 0 };
899
+ counts.reviews += 1;
900
+ byLogin.set(login, counts);
901
+ }
902
+ const distinct = new Set(others);
903
+ for (const login of distinct) {
904
+ const counts = byLogin.get(login);
905
+ if (counts !== void 0) {
906
+ counts.prs += 1;
907
+ }
908
+ }
909
+ if (entry.mergedAt !== null) {
910
+ if (others.length > 0) {
911
+ mergedReviewed += 1;
912
+ } else {
913
+ mergedUnreviewed += 1;
914
+ }
915
+ }
916
+ }
917
+ const leaderboard = [...byLogin.entries()].map(([login, counts]) => {
918
+ return { login, ...counts };
919
+ }).toSorted((a, b) => b.prs - a.prs || b.reviews - a.reviews || a.login.localeCompare(b.login));
920
+ return { leaderboard, mergedReviewed, mergedUnreviewed };
921
+ }
922
+ function firstReviewOf(entry, author) {
923
+ let earliest = null;
924
+ for (const review of entry.reviews) {
925
+ if (review.login === null || review.login === author || review.submittedAt === null) {
926
+ continue;
927
+ }
928
+ if (earliest === null || review.submittedAt < earliest) {
929
+ earliest = review.submittedAt;
930
+ }
931
+ }
932
+ if (earliest === null) {
933
+ return null;
934
+ }
935
+ return { reviewedAt: earliest, hours: durationHours(entry.pr.createdAt, earliest) };
936
+ }
937
+ function computeFirstReviewStats(sizes, author, { now = /* @__PURE__ */ new Date() } = {}) {
938
+ const received = [];
939
+ const awaiting = [];
940
+ for (const entry of sizes) {
941
+ const first = firstReviewOf(entry, author);
942
+ if (first !== null) {
943
+ received.push({ entry, ...first });
944
+ } else if (entry.pr.state === "open") {
945
+ awaiting.push({ entry, hours: durationHours(entry.pr.createdAt, now) });
946
+ }
947
+ }
948
+ awaiting.sort((a, b) => a.entry.pr.createdAt.getTime() - b.entry.pr.createdAt.getTime());
949
+ return { received, awaiting, allHours: received.map((result) => result.hours) };
950
+ }
886
951
  function computeCommentStats(sizes) {
887
952
  const metrics = [
888
953
  { label: "discussion comments", values: sizes.map((size) => size.comments.discussion) },
@@ -1086,9 +1151,24 @@ function useScrollbarSettle(scrollRef, mounted = true) {
1086
1151
  renderer2.off(CliRenderEvents.FRAME, release);
1087
1152
  scrollRef.current?.verticalScrollBar.resetVisibilityControl();
1088
1153
  };
1154
+ const resyncThumb = () => {
1155
+ const bar = scrollRef.current?.verticalScrollBar;
1156
+ if (!bar) {
1157
+ return;
1158
+ }
1159
+ const height = bar.viewportSize;
1160
+ const wanted = Math.max(1, height);
1161
+ if (bar.scrollSize < wanted || bar.slider.viewPortSize === wanted) {
1162
+ return;
1163
+ }
1164
+ bar.viewportSize = 0;
1165
+ bar.viewportSize = height;
1166
+ };
1089
1167
  renderer2.on(CliRenderEvents.FRAME, release);
1168
+ renderer2.on(CliRenderEvents.FRAME, resyncThumb);
1090
1169
  return () => {
1091
1170
  renderer2.off(CliRenderEvents.FRAME, release);
1171
+ renderer2.off(CliRenderEvents.FRAME, resyncThumb);
1092
1172
  };
1093
1173
  }, [scrollRef, renderer2, mounted]);
1094
1174
  }
@@ -1428,7 +1508,8 @@ var initialBrowseState = {
1428
1508
  },
1429
1509
  repoCursors: { pending: 0, open: 0, review: 0, size: 0, comment: 0, merged: 0 },
1430
1510
  rowCursors: { pending: 0, open: 0 },
1431
- grouped: { pending: false, open: false }
1511
+ grouped: { pending: false, open: false },
1512
+ expanded: { review: false, size: false, comment: false, merged: false }
1432
1513
  };
1433
1514
  function dropVanishedRepo(scope, repos) {
1434
1515
  if (scope.view === "detail" && scope.repo !== null && !repos.some((option) => option.repo === scope.repo)) {
@@ -1477,6 +1558,9 @@ function browseReducer(state, action) {
1477
1558
  case "groupingToggled": {
1478
1559
  return { ...state, grouped: { ...state.grouped, [action.tab]: !state.grouped[action.tab] } };
1479
1560
  }
1561
+ case "expandToggled": {
1562
+ return { ...state, expanded: { ...state.expanded, [action.tab]: !state.expanded[action.tab] } };
1563
+ }
1480
1564
  case "dataLoaded": {
1481
1565
  return {
1482
1566
  ...state,
@@ -2315,6 +2399,12 @@ var OPTIONS = [
2315
2399
  default: false,
2316
2400
  help: "Refetch every PR instead of reading the local disk cache. Closed PRs are normally served from a per-PR cache because their timelines and sizes no longer change. Fresh results still update the cache. The TUI settings dialog can save this behavior for every run, and the flag wins over the saved setting."
2317
2401
  },
2402
+ {
2403
+ name: "json",
2404
+ type: "boolean",
2405
+ default: false,
2406
+ help: "Print every stat as JSON to stdout instead of starting the TUI, so the output can be piped into jq or redirected to a file. The report holds the review, size, merge, reviewer, and comment stats with one entry per PR, and every other flag applies to it the same way. Progress renders on stderr, so a piped stdout stays pure JSON."
2407
+ },
2318
2408
  {
2319
2409
  name: "debug",
2320
2410
  type: "string",
@@ -2838,395 +2928,85 @@ function FieldRow({
2838
2928
  // src/tui/components/SettingsModal.tsx
2839
2929
  import { homedir as homedir2 } from "node:os";
2840
2930
 
2841
- // src/tui/state/settings.ts
2842
- var SETTINGS = [
2843
- {
2844
- key: "noCache",
2845
- section: "Cache",
2846
- label: "Disable cache",
2847
- hint: "refetch everything on every load instead of reading cached PRs \xB7 fresh results still update the cache"
2848
- },
2849
- {
2850
- key: "clearCache",
2851
- section: "Cache",
2852
- label: "Clear cache",
2853
- hint: "deletes the cached PR data at this path, so the next reload refetches everything"
2854
- },
2855
- {
2856
- key: "copyLinks",
2857
- section: "Links",
2858
- label: "Copy instead of open",
2859
- hint: "enter and a click on a PR reference copy its link to the clipboard instead of opening the browser"
2860
- },
2861
- {
2862
- key: "themePreset",
2863
- section: "Theme",
2864
- label: "Theme",
2865
- hint: "built-in color theme \xB7 editing colors adds a custom theme to the cycle"
2866
- },
2867
- {
2868
- key: "themeColors",
2869
- section: "Theme",
2870
- label: "Edit colors",
2871
- hint: "opens the color list, where every theme color takes a hex value \xB7 edits become the custom theme"
2872
- },
2873
- {
2874
- key: "resetSettings",
2875
- section: "Settings",
2876
- label: "Reset settings",
2877
- hint: "deletes the settings file with the saved cache setting and theme, so future runs start from the defaults"
2878
- }
2879
- ];
2880
- var THEME_COLORS = [
2881
- { key: "bg", hint: "background of the screen and the dialogs" },
2882
- { key: "border", hint: "borders, rules, and the dialog frames" },
2883
- { key: "text", hint: "primary text" },
2884
- { key: "muted", hint: "secondary text like values and chart labels" },
2885
- { key: "dim", hint: "faint text like axis scales and the footer hints" },
2886
- { key: "accent", hint: "highlights like medians, headings, and the selection marker" },
2887
- { key: "selectedBg", hint: "background of the selected row" },
2888
- { key: "inputBg", hint: "background of text inputs" },
2889
- { key: "inputFocusedBg", hint: "background of the focused text input" },
2890
- { key: "warn", hint: "notices like the reload reminder and confirm prompts" },
2891
- { key: "error", hint: "error messages and failed loads" },
2892
- { key: "success", hint: "the checkmark on the copied-link notice" },
2893
- { key: "chartBar", hint: "histogram and volume bars" },
2894
- { key: "chartLine", hint: "trend lines and the scatter dots" },
2895
- { key: "chartDim", hint: "de-emphasized chart parts like the over-target share" },
2896
- { key: "heat", hint: "the four heatmap colors from cool to hot, separated by spaces" }
2897
- ];
2898
- var CACHE_MESSAGES = {
2899
- confirm: { text: "press enter again to clear the cache \xB7 esc cancels", warn: true },
2900
- cleared: { text: "cache cleared \xB7 the next reload refetches everything" },
2901
- disabled: { text: "the cache is disabled for this session \xB7 nothing to clear" },
2902
- saved: { text: "saved to settings.json \xB7 future runs start with this setting" },
2903
- notSaved: { text: "the cache is disabled for this session \xB7 setting not saved" },
2904
- resetConfirm: { text: "press enter again to delete settings.json \xB7 esc cancels", warn: true },
2905
- resetDone: { text: "settings.json deleted \xB7 future runs start from the defaults" },
2906
- resetDisabled: { text: "the cache is disabled for this session \xB7 nothing to reset" }
2907
- };
2931
+ // src/tui/data/export.ts
2932
+ import { join as join4 } from "node:path";
2908
2933
 
2909
- // src/tui/components/SettingsModal.tsx
2910
- import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
2911
- var SECTIONS2 = [];
2912
- for (const setting of SETTINGS) {
2913
- const last = SECTIONS2.at(-1);
2914
- if (last?.title === setting.section) {
2915
- last.settings.push(setting);
2916
- } else {
2917
- SECTIONS2.push({ title: setting.section, settings: [setting] });
2934
+ // src/github.ts
2935
+ import { execFile } from "node:child_process";
2936
+ import { createHash } from "node:crypto";
2937
+ import { statSync } from "node:fs";
2938
+ import { join as join3, resolve } from "node:path";
2939
+ import { promisify } from "node:util";
2940
+ var execFileAsync = promisify(execFile);
2941
+ var API_BASE = "https://api.github.com";
2942
+ var token;
2943
+ var ghBinary = "gh";
2944
+ function resolveDebugBinary(input) {
2945
+ const resolved = resolve(input);
2946
+ const stats = statSync(resolved, { throwIfNoEntry: false });
2947
+ if (stats?.isDirectory()) {
2948
+ const binary = join3(resolved, "gh");
2949
+ if (!statSync(binary, { throwIfNoEntry: false })?.isFile()) {
2950
+ throw new CliError(`--debug directory "${input}" does not contain a gh executable`);
2951
+ }
2952
+ return binary;
2953
+ }
2954
+ if (stats?.isFile()) {
2955
+ return resolved;
2918
2956
  }
2957
+ throw new CliError(`--debug path "${input}" does not exist`);
2919
2958
  }
2920
- function SettingsModal({
2921
- selected,
2922
- cacheAction,
2923
- noCache: noCache2,
2924
- copyLinks: copyLinks2,
2925
- preset
2926
- }) {
2927
- const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
2928
- return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
2929
- SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
2930
- /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
2931
- section.settings.map((setting) => {
2932
- const isSelected = SETTINGS.indexOf(setting) === selected;
2933
- return /* @__PURE__ */ jsx12(ModalRow, { label: setting.label, isSelected, children: /* @__PURE__ */ jsx12(
2934
- SettingValue,
2935
- {
2936
- setting,
2937
- isSelected,
2938
- cacheAction,
2939
- noCache: noCache2,
2940
- copyLinks: copyLinks2,
2941
- preset
2942
- }
2943
- ) }, setting.key);
2944
- })
2945
- ] }, section.title)),
2946
- /* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg: message?.warn ? theme.warn : theme.muted, marginLeft: 2, marginRight: 2, children: message?.text ?? SETTINGS[selected].hint })
2947
- ] });
2959
+ function configureAuth(cliToken, debugPath) {
2960
+ if (debugPath !== void 0) {
2961
+ ghBinary = resolveDebugBinary(debugPath);
2962
+ token = void 0;
2963
+ return;
2964
+ }
2965
+ ghBinary = "gh";
2966
+ token = cliToken ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
2948
2967
  }
2949
- function SettingValue({
2950
- setting,
2951
- isSelected,
2952
- cacheAction,
2953
- noCache: noCache2,
2954
- copyLinks: copyLinks2,
2955
- preset
2956
- }) {
2957
- switch (setting.key) {
2958
- case "noCache": {
2959
- return /* @__PURE__ */ jsx12(ToggleValue, { value: noCache2 ? "yes" : "no", isSelected });
2960
- }
2961
- case "clearCache": {
2962
- return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
2963
- }
2964
- case "copyLinks": {
2965
- return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
2966
- }
2967
- case "themePreset": {
2968
- return /* @__PURE__ */ jsx12(ToggleValue, { value: preset, isSelected });
2969
- }
2970
- case "themeColors": {
2971
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: ["chartDim", "chartBar", "chartLine", "accent"].map((key) => /* @__PURE__ */ jsx12("span", { fg: theme[key], children: "\u2588\u2588" }, key)) });
2972
- }
2973
- case "resetSettings": {
2974
- return /* @__PURE__ */ jsx12(PathValue, { path: settingsFile(), confirming: cacheAction === "resetConfirm", isSelected });
2975
- }
2976
- default: {
2977
- return null;
2968
+ async function gh(args) {
2969
+ try {
2970
+ const { stdout } = await execFileAsync(ghBinary, args, {
2971
+ maxBuffer: 64 * 1024 * 1024
2972
+ });
2973
+ return stdout;
2974
+ } catch (error) {
2975
+ const execError = error;
2976
+ if (execError.code === "ENOENT") {
2977
+ throw new CliError("the gh CLI is not installed, install it or provide a token via --token or GITHUB_TOKEN");
2978
2978
  }
2979
+ const stderr = execError.stderr?.toString().trim();
2980
+ throw new CliError(`gh ${args.slice(0, 2).join(" ")} failed${stderr ? `
2981
+ ${stderr}` : ""}`);
2979
2982
  }
2980
2983
  }
2981
- function ToggleValue({ value: value2, isSelected }) {
2982
- if (isSelected) {
2983
- return /* @__PURE__ */ jsxs11("text", { wrapMode: "none", children: [
2984
- /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: "\u2039 " }),
2985
- /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }),
2986
- /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: " \u203A" })
2987
- ] });
2984
+ async function api(path, { method = "GET", body } = {}) {
2985
+ let response;
2986
+ try {
2987
+ response = await fetch(`${API_BASE}${path}`, {
2988
+ method,
2989
+ headers: {
2990
+ Authorization: `Bearer ${token}`,
2991
+ Accept: "application/vnd.github+json",
2992
+ "User-Agent": "pr-stats",
2993
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
2994
+ },
2995
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
2996
+ });
2997
+ } catch (error) {
2998
+ const failure = error;
2999
+ throw new CliError(`cannot reach ${API_BASE} (${failure.cause?.message ?? failure.message})`);
2988
3000
  }
2989
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
2990
- }
2991
- function PathValue({ path, confirming, isSelected }) {
2992
- if (confirming) {
2993
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
3001
+ if (!response.ok) {
3002
+ const payload = await response.json().catch(() => null);
3003
+ const message = payload?.message ?? "";
3004
+ const endpoint = path.split("?")[0];
3005
+ throw new CliError(
3006
+ `GitHub API ${method} ${endpoint} failed with ${response.status}${message ? ` (${message})` : ""}`
3007
+ );
2994
3008
  }
2995
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: path.replace(homedir2(), "~") });
2996
- }
2997
-
2998
- // src/tui/components/ThemeModal.tsx
2999
- import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs12 } from "@opentui/react/jsx-runtime";
3000
- function ThemeModal({
3001
- selected,
3002
- editing,
3003
- error,
3004
- cacheAction,
3005
- overrides,
3006
- onDraft,
3007
- onSubmit
3008
- }) {
3009
- const spec = THEME_COLORS[selected];
3010
- const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
3011
- const hint = spec.key in overrides ? `${spec.hint} \xB7 custom color, an empty value restores the theme` : spec.hint;
3012
- return /* @__PURE__ */ jsxs12(ModalFrame, { title: "Theme colors", children: [
3013
- /* @__PURE__ */ jsx13("box", { flexDirection: "column", marginBottom: 1, children: THEME_COLORS.map((color, index) => /* @__PURE__ */ jsx13(
3014
- ColorRow,
3015
- {
3016
- color,
3017
- isSelected: index === selected,
3018
- isEditing: index === selected && editing,
3019
- isCustom: color.key in overrides,
3020
- onDraft,
3021
- onSubmit
3022
- },
3023
- color.key
3024
- )) }),
3025
- /* @__PURE__ */ jsx13("text", { wrapMode: "word", height: 2, fg: error !== null ? theme.error : theme.muted, marginLeft: 2, marginRight: 2, children: error ?? message?.text ?? hint })
3026
- ] });
3027
- }
3028
- function ColorRow({
3029
- color,
3030
- isSelected,
3031
- isEditing,
3032
- isCustom,
3033
- onDraft,
3034
- onSubmit
3035
- }) {
3036
- const value2 = themeColorText(color.key);
3037
- const swatch = theme[color.key];
3038
- return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(
3039
- "input",
3040
- {
3041
- width: 36,
3042
- value: value2,
3043
- focused: true,
3044
- onInput: (next) => {
3045
- onDraft(String(next));
3046
- },
3047
- onSubmit: () => {
3048
- onSubmit();
3049
- },
3050
- backgroundColor: theme.inputBg,
3051
- focusedBackgroundColor: theme.inputFocusedBg,
3052
- textColor: theme.text,
3053
- cursorColor: theme.accent
3054
- }
3055
- ) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
3056
- Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
3057
- /* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
3058
- /* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
3059
- /* @__PURE__ */ jsx13("span", { fg: swatch[2], children: "\u2588" }),
3060
- /* @__PURE__ */ jsx13("span", { fg: swatch[3], children: "\u2588" })
3061
- ] }) : /* @__PURE__ */ jsx13("span", { fg: swatch, children: "\u2588\u2588" }),
3062
- /* @__PURE__ */ jsx13("span", { children: " " }),
3063
- isSelected ? /* @__PURE__ */ jsx13("b", { fg: theme.text, children: value2 }) : /* @__PURE__ */ jsx13("span", { fg: isCustom ? theme.text : theme.muted, children: value2 })
3064
- ] }) });
3065
- }
3066
-
3067
- // src/tui/components/Modals.tsx
3068
- import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
3069
- function Modals({
3070
- ui,
3071
- options,
3072
- saved: saved2,
3073
- noCache: noCache2,
3074
- copyLinks: copyLinks2,
3075
- themeState,
3076
- onDraft,
3077
- onSubmitField,
3078
- onSubmitThemeColor
3079
- }) {
3080
- if (ui.modal === "options") {
3081
- return /* @__PURE__ */ jsx14(
3082
- OptionsModal,
3083
- {
3084
- options,
3085
- saved: saved2,
3086
- selected: ui.selectedField,
3087
- editing: ui.editing,
3088
- fieldError: ui.fieldError,
3089
- onDraft,
3090
- onSubmit: onSubmitField
3091
- }
3092
- );
3093
- }
3094
- if (ui.modal === "settings") {
3095
- return /* @__PURE__ */ jsx14(
3096
- SettingsModal,
3097
- {
3098
- selected: ui.selectedSetting,
3099
- cacheAction: ui.cacheAction,
3100
- noCache: noCache2,
3101
- copyLinks: copyLinks2,
3102
- preset: themeState.preset
3103
- }
3104
- );
3105
- }
3106
- if (ui.modal === "theme") {
3107
- return /* @__PURE__ */ jsx14(
3108
- ThemeModal,
3109
- {
3110
- selected: ui.selectedThemeColor,
3111
- editing: ui.editing,
3112
- error: ui.themeColorError,
3113
- cacheAction: ui.cacheAction,
3114
- overrides: themeState.preset === "custom" ? themeState.overrides : {},
3115
- onDraft,
3116
- onSubmit: onSubmitThemeColor
3117
- }
3118
- );
3119
- }
3120
- return null;
3121
- }
3122
-
3123
- // src/tui/hooks/useDeferredLoading.ts
3124
- import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
3125
- function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
3126
- const [visible, setVisible] = useState2(isLoading && showDelay === 0);
3127
- const shownAtRef = useRef3(null);
3128
- useEffect4(() => {
3129
- if (isLoading) {
3130
- const timer2 = setTimeout(() => {
3131
- shownAtRef.current = Date.now();
3132
- setVisible(true);
3133
- }, showDelay);
3134
- return () => {
3135
- clearTimeout(timer2);
3136
- };
3137
- }
3138
- const shownAt = shownAtRef.current;
3139
- const remaining = shownAt === null ? 0 : Math.max(0, shownAt + minDuration - Date.now());
3140
- const timer = setTimeout(() => {
3141
- shownAtRef.current = null;
3142
- setVisible(false);
3143
- }, remaining);
3144
- return () => {
3145
- clearTimeout(timer);
3146
- };
3147
- }, [isLoading, showDelay, minDuration]);
3148
- return visible;
3149
- }
3150
-
3151
- // src/tui/hooks/useLoader.ts
3152
- import { useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
3153
-
3154
- // src/github.ts
3155
- import { execFile } from "node:child_process";
3156
- import { createHash } from "node:crypto";
3157
- import { statSync } from "node:fs";
3158
- import { join as join3, resolve } from "node:path";
3159
- import { promisify } from "node:util";
3160
- var execFileAsync = promisify(execFile);
3161
- var API_BASE = "https://api.github.com";
3162
- var token;
3163
- var ghBinary = "gh";
3164
- function resolveDebugBinary(input) {
3165
- const resolved = resolve(input);
3166
- const stats = statSync(resolved, { throwIfNoEntry: false });
3167
- if (stats?.isDirectory()) {
3168
- const binary = join3(resolved, "gh");
3169
- if (!statSync(binary, { throwIfNoEntry: false })?.isFile()) {
3170
- throw new CliError(`--debug directory "${input}" does not contain a gh executable`);
3171
- }
3172
- return binary;
3173
- }
3174
- if (stats?.isFile()) {
3175
- return resolved;
3176
- }
3177
- throw new CliError(`--debug path "${input}" does not exist`);
3178
- }
3179
- function configureAuth(cliToken, debugPath) {
3180
- if (debugPath !== void 0) {
3181
- ghBinary = resolveDebugBinary(debugPath);
3182
- token = void 0;
3183
- return;
3184
- }
3185
- ghBinary = "gh";
3186
- token = cliToken ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
3187
- }
3188
- async function gh(args) {
3189
- try {
3190
- const { stdout } = await execFileAsync(ghBinary, args, {
3191
- maxBuffer: 64 * 1024 * 1024
3192
- });
3193
- return stdout;
3194
- } catch (error) {
3195
- const execError = error;
3196
- if (execError.code === "ENOENT") {
3197
- throw new CliError("the gh CLI is not installed, install it or provide a token via --token or GITHUB_TOKEN");
3198
- }
3199
- const stderr = execError.stderr?.toString().trim();
3200
- throw new CliError(`gh ${args.slice(0, 2).join(" ")} failed${stderr ? `
3201
- ${stderr}` : ""}`);
3202
- }
3203
- }
3204
- async function api(path, { method = "GET", body } = {}) {
3205
- let response;
3206
- try {
3207
- response = await fetch(`${API_BASE}${path}`, {
3208
- method,
3209
- headers: {
3210
- Authorization: `Bearer ${token}`,
3211
- Accept: "application/vnd.github+json",
3212
- "User-Agent": "pr-stats",
3213
- ...body === void 0 ? {} : { "Content-Type": "application/json" }
3214
- },
3215
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
3216
- });
3217
- } catch (error) {
3218
- const failure = error;
3219
- throw new CliError(`cannot reach ${API_BASE} (${failure.cause?.message ?? failure.message})`);
3220
- }
3221
- if (!response.ok) {
3222
- const payload = await response.json().catch(() => null);
3223
- const message = payload?.message ?? "";
3224
- const endpoint = path.split("?")[0];
3225
- throw new CliError(
3226
- `GitHub API ${method} ${endpoint} failed with ${response.status}${message ? ` (${message})` : ""}`
3227
- );
3228
- }
3229
- return await response.json().catch(() => null);
3009
+ return await response.json().catch(() => null);
3230
3010
  }
3231
3011
  async function runGraphql(query) {
3232
3012
  if (token) {
@@ -3361,6 +3141,8 @@ async function fetchPrDetails(prs) {
3361
3141
  return `
3362
3142
  pr${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
3363
3143
  pullRequest(number: ${pr.number}) {
3144
+ additions
3145
+ deletions
3364
3146
  timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], first: 100) {
3365
3147
  nodes {
3366
3148
  ... on ReviewRequestedEvent {
@@ -3402,6 +3184,8 @@ async function fetchPrSizes(prs) {
3402
3184
  }
3403
3185
  reviews(first: 100) {
3404
3186
  nodes {
3187
+ author { login }
3188
+ submittedAt
3405
3189
  comments {
3406
3190
  totalCount
3407
3191
  }
@@ -3522,12 +3306,13 @@ function classifyPr(pr, details, user) {
3522
3306
  })
3523
3307
  ].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
3524
3308
  const results = [];
3309
+ const lines = details.additions + details.deletions;
3525
3310
  let openedAt = null;
3526
3311
  for (const event of events) {
3527
3312
  if (event.isRequest) {
3528
3313
  openedAt ??= event.at;
3529
3314
  } else if (openedAt !== null) {
3530
- results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state });
3315
+ results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state, lines });
3531
3316
  openedAt = null;
3532
3317
  }
3533
3318
  }
@@ -3596,6 +3381,14 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3596
3381
  if (details) {
3597
3382
  const discussion = details.comments.totalCount;
3598
3383
  const review = details.reviews.nodes.reduce((sum, node) => sum + (node?.comments.totalCount ?? 0), 0);
3384
+ const reviews = details.reviews.nodes.flatMap(
3385
+ (node) => node === null ? [] : [
3386
+ {
3387
+ login: node.author?.login ?? null,
3388
+ submittedAt: node.submittedAt === null ? null : new Date(node.submittedAt)
3389
+ }
3390
+ ]
3391
+ );
3599
3392
  sizes.push({
3600
3393
  pr,
3601
3394
  files: details.changedFiles,
@@ -3604,147 +3397,685 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3604
3397
  total: details.additions + details.deletions,
3605
3398
  mergedAt: details.mergedAt === null ? null : new Date(details.mergedAt),
3606
3399
  closedAt: details.closedAt === null ? null : new Date(details.closedAt),
3607
- comments: { discussion, review, total: discussion + review }
3400
+ comments: { discussion, review, total: discussion + review },
3401
+ reviews
3608
3402
  });
3609
3403
  }
3610
- }
3611
- return { sizes, cacheHits };
3404
+ }
3405
+ return { sizes, cacheHits };
3406
+ }
3407
+
3408
+ // src/tui/data/load.ts
3409
+ function reviveRawData(data) {
3410
+ return {
3411
+ ...data,
3412
+ fetchedAt: new Date(data.fetchedAt),
3413
+ reviewResults: data.reviewResults.map((result) => {
3414
+ const pr = { ...result.pr, createdAt: new Date(result.pr.createdAt) };
3415
+ if (result.kind === "pending") {
3416
+ return { ...result, pr, requestedAt: new Date(result.requestedAt) };
3417
+ }
3418
+ if (result.kind === "reviewed") {
3419
+ return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
3420
+ }
3421
+ if (result.kind === "unrequested") {
3422
+ return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
3423
+ }
3424
+ return { ...result, pr };
3425
+ }),
3426
+ sizes: data.sizes.map((entry) => {
3427
+ return {
3428
+ ...entry,
3429
+ pr: { ...entry.pr, createdAt: new Date(entry.pr.createdAt) },
3430
+ mergedAt: entry.mergedAt === null ? null : new Date(entry.mergedAt),
3431
+ closedAt: entry.closedAt === null ? null : new Date(entry.closedAt),
3432
+ reviews: entry.reviews.map((review) => {
3433
+ return { ...review, submittedAt: review.submittedAt === null ? null : new Date(review.submittedAt) };
3434
+ })
3435
+ };
3436
+ })
3437
+ };
3438
+ }
3439
+ function loadSnapshot(options) {
3440
+ const stored = readCacheFile("snapshot");
3441
+ if (stored?.params === void 0) {
3442
+ return null;
3443
+ }
3444
+ const { params } = stored;
3445
+ if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts) {
3446
+ return null;
3447
+ }
3448
+ const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3449
+ if (sinceIso < stored.data.sinceIso) {
3450
+ return null;
3451
+ }
3452
+ const data = reviveRawData(stored.data);
3453
+ if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3454
+ return null;
3455
+ }
3456
+ const hasMissingVerdict = data.reviewResults.some((result) => {
3457
+ return result.kind === "reviewed" && result.verdict === void 0;
3458
+ });
3459
+ if (hasMissingVerdict) {
3460
+ return null;
3461
+ }
3462
+ if (sinceIso === data.sinceIso) {
3463
+ return data;
3464
+ }
3465
+ if (data.reviewResults.some((result) => Number.isNaN(result.pr.createdAt.getTime()))) {
3466
+ return null;
3467
+ }
3468
+ const cutoff = new Date(sinceIso);
3469
+ const reviewResults = data.reviewResults.filter((result) => result.pr.createdAt >= cutoff);
3470
+ const sizes = data.sizes.filter((entry) => entry.pr.createdAt >= cutoff);
3471
+ return {
3472
+ ...data,
3473
+ sinceIso,
3474
+ reviewResults,
3475
+ sizes,
3476
+ /**
3477
+ * The creation dates of inaccessible authored PRs are unknown, so the
3478
+ * inaccessible count carries over unchanged.
3479
+ */
3480
+ authoredTotal: sizes.length + (data.authoredTotal - data.sizes.length)
3481
+ };
3482
+ }
3483
+ function saveSnapshot(options, data) {
3484
+ const params = {
3485
+ since: options.since,
3486
+ repos: options.repos,
3487
+ user: options.user,
3488
+ includeDrafts: options.includeDrafts
3489
+ };
3490
+ writeCacheFile("snapshot", { params, data });
3491
+ }
3492
+ async function loadData(options, onPhase, { bypassCache = false } = {}) {
3493
+ const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3494
+ onPhase({ phase: "search" });
3495
+ const repoNames = options.repos.split(",").map((name) => name.trim()).filter((name) => name !== "");
3496
+ const [user, repos] = await Promise.all([resolveUser(options.user, bypassCache), resolveRepos(repoNames)]);
3497
+ const includeDrafts = options.includeDrafts;
3498
+ const [requested, reviewed, authored] = await Promise.all([
3499
+ searchPrs({ user, sinceIso, repos, includeDrafts, mode: "requested" }),
3500
+ searchPrs({ user, sinceIso, repos, includeDrafts, mode: "reviewed" }),
3501
+ searchPrs({ user, sinceIso, repos, includeDrafts, mode: "authored" })
3502
+ ]);
3503
+ const reviewPrs = collectReviewPrs(requested, reviewed);
3504
+ const authoredPrs = collectAuthoredPrs(authored);
3505
+ const progress = { review: 0, sizes: 0 };
3506
+ const total = reviewPrs.length + authoredPrs.length;
3507
+ const report = () => {
3508
+ onPhase({ phase: "details", done: progress.review + progress.sizes, total });
3509
+ };
3510
+ report();
3511
+ const [review, size] = await Promise.all([
3512
+ reviewPrs.length === 0 ? { results: [], cacheHits: 0 } : fetchReviewRaw(
3513
+ reviewPrs,
3514
+ user,
3515
+ (done) => {
3516
+ progress.review = done;
3517
+ report();
3518
+ },
3519
+ { bypassCache }
3520
+ ),
3521
+ authoredPrs.length === 0 ? { sizes: [], cacheHits: 0 } : fetchSizeRaw(
3522
+ authoredPrs,
3523
+ (done) => {
3524
+ progress.sizes = done;
3525
+ report();
3526
+ },
3527
+ { bypassCache }
3528
+ )
3529
+ ]);
3530
+ const data = {
3531
+ user,
3532
+ sinceIso,
3533
+ repos,
3534
+ reviewResults: review.results,
3535
+ sizes: size.sizes,
3536
+ authoredTotal: authoredPrs.length,
3537
+ searchCapped: requested.length >= 1e3 || reviewed.length >= 1e3 || authored.length >= 1e3,
3538
+ fetchedAt: /* @__PURE__ */ new Date()
3539
+ };
3540
+ saveSnapshot(options, data);
3541
+ return data;
3542
+ }
3543
+
3544
+ // src/tui/data/export.ts
3545
+ function round(value2) {
3546
+ return Math.round(value2 * 100) / 100;
3547
+ }
3548
+ function summarize(values) {
3549
+ if (values.length === 0) {
3550
+ return null;
3551
+ }
3552
+ const sorted = [...values].toSorted((a, b) => a - b);
3553
+ const sum = values.reduce((total, value2) => total + value2, 0);
3554
+ return {
3555
+ count: values.length,
3556
+ mean: round(sum / values.length),
3557
+ p50: round(percentile(sorted, 50)),
3558
+ p90: round(percentile(sorted, 90)),
3559
+ min: round(sorted[0]),
3560
+ max: round(sorted.at(-1) ?? 0)
3561
+ };
3562
+ }
3563
+ function prRef(pr) {
3564
+ return { repo: pr.repo, number: pr.number, title: pr.title, url: pr.url, state: pr.state };
3565
+ }
3566
+ function hoursToMerge(entry) {
3567
+ if (entry.mergedAt === null) {
3568
+ return null;
3569
+ }
3570
+ return round(durationHours(entry.pr.createdAt, entry.mergedAt));
3571
+ }
3572
+ function hoursToClose(entry) {
3573
+ if (entry.mergedAt !== null || entry.pr.state === "open" || entry.closedAt === null) {
3574
+ return null;
3575
+ }
3576
+ return round(durationHours(entry.pr.createdAt, entry.closedAt));
3577
+ }
3578
+ function buildStatsReport(raw, options) {
3579
+ const timezone = resolveTimezone(options.tz === "" ? void 0 : options.tz);
3580
+ configureTimeMode({
3581
+ business: !options.wallClock,
3582
+ workWindows: parseWorkHours(options.workHours),
3583
+ tz: timezone
3584
+ });
3585
+ const targetHours = options.target === "" ? void 0 : parseTarget(options.target);
3586
+ const targetLabel = targetLabelOf(options.target);
3587
+ const sizeTarget = options.sizeTarget === "" ? void 0 : parseSizeTarget(options.sizeTarget);
3588
+ const review = computeReviewStats(raw.reviewResults, { targetHours, now: raw.fetchedAt });
3589
+ const merge = computeMergeStats(raw.sizes);
3590
+ const reviewers = computeReviewerStats(raw.sizes, raw.user);
3591
+ const firstReview = computeFirstReviewStats(raw.sizes, raw.user, { now: raw.fetchedAt });
3592
+ const comments = computeCommentStats(raw.sizes);
3593
+ const verdictCount = (state) => review.reviewed.filter((entry) => entry.verdict === state).length;
3594
+ const approved = verdictCount("APPROVED");
3595
+ const changesRequested = verdictCount("CHANGES_REQUESTED");
3596
+ const commented = verdictCount("COMMENTED");
3597
+ let target = null;
3598
+ if (targetHours !== void 0 && targetLabel !== void 0) {
3599
+ const inside = review.allHours.filter((hours) => hours <= targetHours).length;
3600
+ target = {
3601
+ label: targetLabel,
3602
+ hours: round(targetHours),
3603
+ inside,
3604
+ over: review.allHours.length - inside,
3605
+ pendingOverdue: review.pending.filter((entry) => entry.hours > targetHours).length
3606
+ };
3607
+ }
3608
+ const sizes = computeSizeStats(raw.sizes, { sizeTarget });
3609
+ const sizeTargetReport = sizes.met === void 0 || sizes.targetLabel === void 0 ? null : { label: sizes.targetLabel, inside: sizes.met, over: raw.sizes.length - sizes.met };
3610
+ return {
3611
+ generatedAt: raw.fetchedAt.toISOString(),
3612
+ user: raw.user,
3613
+ since: raw.sinceIso,
3614
+ repos: raw.repos,
3615
+ searchCapped: raw.searchCapped,
3616
+ options: {
3617
+ workHours: options.workHours,
3618
+ timezone,
3619
+ wallClock: options.wallClock,
3620
+ includeDrafts: options.includeDrafts,
3621
+ reviewTarget: targetLabel ?? null,
3622
+ sizeTarget: options.sizeTarget === "" ? null : options.sizeTarget
3623
+ },
3624
+ review: {
3625
+ counts: {
3626
+ reviewed: review.reviewed.length,
3627
+ pending: review.pending.length,
3628
+ reviewing: review.reviewing.length,
3629
+ closedUnreviewed: review.expired.length,
3630
+ reviewedUnrequested: review.unrequested.length
3631
+ },
3632
+ reviewTimeHours: summarize(review.allHours),
3633
+ cyclesPerPr: summarize(review.cycles),
3634
+ verdicts: {
3635
+ approved,
3636
+ changesRequested,
3637
+ commented,
3638
+ other: review.reviewed.length - approved - changesRequested - commented
3639
+ },
3640
+ target,
3641
+ byRepo: review.byRepo.map(([repo, hours]) => {
3642
+ return {
3643
+ repo,
3644
+ reviews: hours.length,
3645
+ p50Hours: round(
3646
+ percentile(
3647
+ hours.toSorted((a, b) => a - b),
3648
+ 50
3649
+ )
3650
+ )
3651
+ };
3652
+ }),
3653
+ reviewed: review.reviewed.map((entry) => {
3654
+ return {
3655
+ ...prRef(entry.pr),
3656
+ requestedAt: entry.requestedAt.toISOString(),
3657
+ reviewedAt: entry.reviewedAt.toISOString(),
3658
+ hours: round(entry.hours),
3659
+ verdict: entry.verdict,
3660
+ totalLines: entry.lines
3661
+ };
3662
+ }),
3663
+ pending: review.pending.map((entry) => {
3664
+ return { ...prRef(entry.pr), requestedAt: entry.requestedAt.toISOString(), hours: round(entry.hours) };
3665
+ }),
3666
+ reviewing: review.reviewing.map((entry) => {
3667
+ return { ...prRef(entry.pr), reviewedAt: entry.reviewedAt.toISOString(), hours: round(entry.hours) };
3668
+ })
3669
+ },
3670
+ authored: {
3671
+ counts: {
3672
+ total: raw.authoredTotal,
3673
+ analyzed: raw.sizes.length,
3674
+ inaccessible: raw.authoredTotal - raw.sizes.length,
3675
+ open: merge.open.length,
3676
+ merged: merge.merged.length,
3677
+ closedUnmerged: merge.closed.length
3678
+ },
3679
+ sizeLines: summarize(raw.sizes.map((entry) => entry.total)),
3680
+ mergeTimeHours: summarize(merge.allHours),
3681
+ firstReviewHours: summarize(firstReview.allHours),
3682
+ awaitingFirstReview: firstReview.awaiting.length,
3683
+ sizeTarget: sizeTargetReport,
3684
+ reviewers: {
3685
+ leaderboard: reviewers.leaderboard,
3686
+ mergedReviewed: reviewers.mergedReviewed,
3687
+ mergedUnreviewed: reviewers.mergedUnreviewed
3688
+ },
3689
+ prs: raw.sizes.map((entry) => {
3690
+ const first = firstReviewOf(entry, raw.user);
3691
+ return {
3692
+ ...prRef(entry.pr),
3693
+ createdAt: entry.pr.createdAt.toISOString(),
3694
+ mergedAt: entry.mergedAt === null ? null : entry.mergedAt.toISOString(),
3695
+ closedAt: entry.closedAt === null ? null : entry.closedAt.toISOString(),
3696
+ firstReviewAt: first === null ? null : first.reviewedAt.toISOString(),
3697
+ hoursToMerge: hoursToMerge(entry),
3698
+ hoursToClose: hoursToClose(entry),
3699
+ hoursToFirstReview: first === null ? null : round(first.hours),
3700
+ files: entry.files,
3701
+ additions: entry.additions,
3702
+ deletions: entry.deletions,
3703
+ totalLines: entry.total,
3704
+ comments: entry.comments,
3705
+ reviewers: entry.reviews.flatMap((review2) => review2.login === null ? [] : [review2.login])
3706
+ };
3707
+ })
3708
+ },
3709
+ comments: {
3710
+ received: comments.totals.reduce((sum, total) => sum + total, 0),
3711
+ prsWithoutComments: comments.uncommented,
3712
+ perPr: summarize(comments.totals)
3713
+ }
3714
+ };
3715
+ }
3716
+ function exportFile() {
3717
+ return join4(process.cwd(), "pr-stats.json");
3718
+ }
3719
+ function exportStatsFile(raw, options) {
3720
+ writeFileAtomic(exportFile(), `${JSON.stringify(buildStatsReport(raw, options), null, 2)}
3721
+ `);
3722
+ }
3723
+ function reportPhase(phase) {
3724
+ if (!process.stderr.isTTY) {
3725
+ return;
3726
+ }
3727
+ const text = phase.phase === "search" ? "searching PRs..." : `fetching PR details ${phase.done}/${phase.total}`;
3728
+ process.stderr.write(`\r\x1B[K${text}`);
3729
+ }
3730
+ function clearPhase() {
3731
+ if (process.stderr.isTTY) {
3732
+ process.stderr.write("\r\x1B[K");
3733
+ }
3734
+ }
3735
+ async function runJsonStats(options, bypassCache) {
3736
+ try {
3737
+ const raw = await loadData(options, reportPhase, { bypassCache });
3738
+ clearPhase();
3739
+ await new Promise((resolve2) => {
3740
+ process.stdout.write(`${JSON.stringify(buildStatsReport(raw, options), null, 2)}
3741
+ `, () => {
3742
+ resolve2();
3743
+ });
3744
+ });
3745
+ process.exit(0);
3746
+ } catch (error) {
3747
+ clearPhase();
3748
+ if (error instanceof CliError) {
3749
+ fail(error.message);
3750
+ }
3751
+ throw error;
3752
+ }
3753
+ }
3754
+
3755
+ // src/tui/state/settings.ts
3756
+ var SETTINGS = [
3757
+ {
3758
+ key: "noCache",
3759
+ section: "Cache",
3760
+ label: "Disable cache",
3761
+ hint: "refetch everything on every load instead of reading cached PRs \xB7 fresh results still update the cache"
3762
+ },
3763
+ {
3764
+ key: "clearCache",
3765
+ section: "Cache",
3766
+ label: "Clear cache",
3767
+ hint: "deletes the cached PR data at this path, so the next reload refetches everything"
3768
+ },
3769
+ {
3770
+ key: "copyLinks",
3771
+ section: "Links",
3772
+ label: "Copy instead of open",
3773
+ hint: "enter and a click on a PR reference copy its link to the clipboard instead of opening the browser"
3774
+ },
3775
+ {
3776
+ key: "themePreset",
3777
+ section: "Theme",
3778
+ label: "Theme",
3779
+ hint: "built-in color theme \xB7 editing colors adds a custom theme to the cycle"
3780
+ },
3781
+ {
3782
+ key: "themeColors",
3783
+ section: "Theme",
3784
+ label: "Edit colors",
3785
+ hint: "opens the color list, where every theme color takes a hex value \xB7 edits become the custom theme"
3786
+ },
3787
+ {
3788
+ key: "resetSettings",
3789
+ section: "Settings",
3790
+ label: "Reset settings",
3791
+ hint: "deletes the settings file with the saved cache setting and theme, so future runs start from the defaults"
3792
+ },
3793
+ {
3794
+ key: "exportJson",
3795
+ section: "Export",
3796
+ label: "Export stats as JSON",
3797
+ hint: "writes the loaded stats to this file, the same report the --json flag prints \xB7 overwrites a previous export"
3798
+ }
3799
+ ];
3800
+ var THEME_COLORS = [
3801
+ { key: "bg", hint: "background of the screen and the dialogs" },
3802
+ { key: "border", hint: "borders, rules, and the dialog frames" },
3803
+ { key: "text", hint: "primary text" },
3804
+ { key: "muted", hint: "secondary text like values and chart labels" },
3805
+ { key: "dim", hint: "faint text like axis scales and the footer hints" },
3806
+ { key: "accent", hint: "highlights like medians, headings, and the selection marker" },
3807
+ { key: "selectedBg", hint: "background of the selected row" },
3808
+ { key: "inputBg", hint: "background of text inputs" },
3809
+ { key: "inputFocusedBg", hint: "background of the focused text input" },
3810
+ { key: "warn", hint: "notices like the reload reminder and confirm prompts" },
3811
+ { key: "error", hint: "error messages and failed loads" },
3812
+ { key: "success", hint: "the checkmark on the copied-link notice" },
3813
+ { key: "chartBar", hint: "histogram and volume bars" },
3814
+ { key: "chartLine", hint: "trend lines and the scatter dots" },
3815
+ { key: "chartDim", hint: "de-emphasized chart parts like the over-target share" },
3816
+ { key: "heat", hint: "the four heatmap colors from cool to hot, separated by spaces" }
3817
+ ];
3818
+ var CACHE_MESSAGES = {
3819
+ confirm: { text: "press enter again to clear the cache \xB7 esc cancels", warn: true },
3820
+ cleared: { text: "cache cleared \xB7 the next reload refetches everything" },
3821
+ disabled: { text: "the cache is disabled for this session \xB7 nothing to clear" },
3822
+ saved: { text: "saved to settings.json \xB7 future runs start with this setting" },
3823
+ notSaved: { text: "the cache is disabled for this session \xB7 setting not saved" },
3824
+ resetConfirm: { text: "press enter again to delete settings.json \xB7 esc cancels", warn: true },
3825
+ resetDone: { text: "settings.json deleted \xB7 future runs start from the defaults" },
3826
+ resetDisabled: { text: "the cache is disabled for this session \xB7 nothing to reset" },
3827
+ exported: { text: "stats exported \xB7 the same report prints to stdout with the --json flag" },
3828
+ exportFailed: { text: "the export failed \xB7 the file could not be written", warn: true },
3829
+ exportNoData: { text: "no loaded stats to export yet \xB7 export again once the load finishes", warn: true }
3830
+ };
3831
+
3832
+ // src/tui/components/SettingsModal.tsx
3833
+ import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
3834
+ var SECTIONS2 = [];
3835
+ for (const setting of SETTINGS) {
3836
+ const last = SECTIONS2.at(-1);
3837
+ if (last?.title === setting.section) {
3838
+ last.settings.push(setting);
3839
+ } else {
3840
+ SECTIONS2.push({ title: setting.section, settings: [setting] });
3841
+ }
3842
+ }
3843
+ function SettingsModal({
3844
+ selected,
3845
+ cacheAction,
3846
+ noCache: noCache2,
3847
+ copyLinks: copyLinks2,
3848
+ preset
3849
+ }) {
3850
+ const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
3851
+ return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
3852
+ SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
3853
+ /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
3854
+ section.settings.map((setting) => {
3855
+ const isSelected = SETTINGS.indexOf(setting) === selected;
3856
+ return /* @__PURE__ */ jsx12(ModalRow, { label: setting.label, isSelected, children: /* @__PURE__ */ jsx12(
3857
+ SettingValue,
3858
+ {
3859
+ setting,
3860
+ isSelected,
3861
+ cacheAction,
3862
+ noCache: noCache2,
3863
+ copyLinks: copyLinks2,
3864
+ preset
3865
+ }
3866
+ ) }, setting.key);
3867
+ })
3868
+ ] }, section.title)),
3869
+ /* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg: message?.warn ? theme.warn : theme.muted, marginLeft: 2, marginRight: 2, children: message?.text ?? SETTINGS[selected].hint })
3870
+ ] });
3871
+ }
3872
+ function SettingValue({
3873
+ setting,
3874
+ isSelected,
3875
+ cacheAction,
3876
+ noCache: noCache2,
3877
+ copyLinks: copyLinks2,
3878
+ preset
3879
+ }) {
3880
+ switch (setting.key) {
3881
+ case "noCache": {
3882
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: noCache2 ? "yes" : "no", isSelected });
3883
+ }
3884
+ case "clearCache": {
3885
+ return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
3886
+ }
3887
+ case "copyLinks": {
3888
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
3889
+ }
3890
+ case "themePreset": {
3891
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: preset, isSelected });
3892
+ }
3893
+ case "themeColors": {
3894
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: ["chartDim", "chartBar", "chartLine", "accent"].map((key) => /* @__PURE__ */ jsx12("span", { fg: theme[key], children: "\u2588\u2588" }, key)) });
3895
+ }
3896
+ case "resetSettings": {
3897
+ return /* @__PURE__ */ jsx12(PathValue, { path: settingsFile(), confirming: cacheAction === "resetConfirm", isSelected });
3898
+ }
3899
+ case "exportJson": {
3900
+ return /* @__PURE__ */ jsx12(PathValue, { path: exportFile(), confirming: false, isSelected });
3901
+ }
3902
+ default: {
3903
+ return null;
3904
+ }
3905
+ }
3906
+ }
3907
+ function ToggleValue({ value: value2, isSelected }) {
3908
+ if (isSelected) {
3909
+ return /* @__PURE__ */ jsxs11("text", { wrapMode: "none", children: [
3910
+ /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: "\u2039 " }),
3911
+ /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }),
3912
+ /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: " \u203A" })
3913
+ ] });
3914
+ }
3915
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
3916
+ }
3917
+ function PathValue({ path, confirming, isSelected }) {
3918
+ if (confirming) {
3919
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
3920
+ }
3921
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: path.replace(homedir2(), "~") });
3922
+ }
3923
+
3924
+ // src/tui/components/ThemeModal.tsx
3925
+ import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs12 } from "@opentui/react/jsx-runtime";
3926
+ function ThemeModal({
3927
+ selected,
3928
+ editing,
3929
+ error,
3930
+ cacheAction,
3931
+ overrides,
3932
+ onDraft,
3933
+ onSubmit
3934
+ }) {
3935
+ const spec = THEME_COLORS[selected];
3936
+ const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
3937
+ const hint = spec.key in overrides ? `${spec.hint} \xB7 custom color, an empty value restores the theme` : spec.hint;
3938
+ return /* @__PURE__ */ jsxs12(ModalFrame, { title: "Theme colors", children: [
3939
+ /* @__PURE__ */ jsx13("box", { flexDirection: "column", marginBottom: 1, children: THEME_COLORS.map((color, index) => /* @__PURE__ */ jsx13(
3940
+ ColorRow,
3941
+ {
3942
+ color,
3943
+ isSelected: index === selected,
3944
+ isEditing: index === selected && editing,
3945
+ isCustom: color.key in overrides,
3946
+ onDraft,
3947
+ onSubmit
3948
+ },
3949
+ color.key
3950
+ )) }),
3951
+ /* @__PURE__ */ jsx13("text", { wrapMode: "word", height: 2, fg: error !== null ? theme.error : theme.muted, marginLeft: 2, marginRight: 2, children: error ?? message?.text ?? hint })
3952
+ ] });
3953
+ }
3954
+ function ColorRow({
3955
+ color,
3956
+ isSelected,
3957
+ isEditing,
3958
+ isCustom,
3959
+ onDraft,
3960
+ onSubmit
3961
+ }) {
3962
+ const value2 = themeColorText(color.key);
3963
+ const swatch = theme[color.key];
3964
+ return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(
3965
+ "input",
3966
+ {
3967
+ width: 36,
3968
+ value: value2,
3969
+ focused: true,
3970
+ onInput: (next) => {
3971
+ onDraft(String(next));
3972
+ },
3973
+ onSubmit: () => {
3974
+ onSubmit();
3975
+ },
3976
+ backgroundColor: theme.inputBg,
3977
+ focusedBackgroundColor: theme.inputFocusedBg,
3978
+ textColor: theme.text,
3979
+ cursorColor: theme.accent
3980
+ }
3981
+ ) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
3982
+ Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
3983
+ /* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
3984
+ /* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
3985
+ /* @__PURE__ */ jsx13("span", { fg: swatch[2], children: "\u2588" }),
3986
+ /* @__PURE__ */ jsx13("span", { fg: swatch[3], children: "\u2588" })
3987
+ ] }) : /* @__PURE__ */ jsx13("span", { fg: swatch, children: "\u2588\u2588" }),
3988
+ /* @__PURE__ */ jsx13("span", { children: " " }),
3989
+ isSelected ? /* @__PURE__ */ jsx13("b", { fg: theme.text, children: value2 }) : /* @__PURE__ */ jsx13("span", { fg: isCustom ? theme.text : theme.muted, children: value2 })
3990
+ ] }) });
3612
3991
  }
3613
3992
 
3614
- // src/tui/data/load.ts
3615
- function reviveRawData(data) {
3616
- return {
3617
- ...data,
3618
- fetchedAt: new Date(data.fetchedAt),
3619
- reviewResults: data.reviewResults.map((result) => {
3620
- const pr = { ...result.pr, createdAt: new Date(result.pr.createdAt) };
3621
- if (result.kind === "pending") {
3622
- return { ...result, pr, requestedAt: new Date(result.requestedAt) };
3623
- }
3624
- if (result.kind === "reviewed") {
3625
- return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
3626
- }
3627
- if (result.kind === "unrequested") {
3628
- return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
3993
+ // src/tui/components/Modals.tsx
3994
+ import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
3995
+ function Modals({
3996
+ ui,
3997
+ options,
3998
+ saved: saved2,
3999
+ noCache: noCache2,
4000
+ copyLinks: copyLinks2,
4001
+ themeState,
4002
+ onDraft,
4003
+ onSubmitField,
4004
+ onSubmitThemeColor
4005
+ }) {
4006
+ if (ui.modal === "options") {
4007
+ return /* @__PURE__ */ jsx14(
4008
+ OptionsModal,
4009
+ {
4010
+ options,
4011
+ saved: saved2,
4012
+ selected: ui.selectedField,
4013
+ editing: ui.editing,
4014
+ fieldError: ui.fieldError,
4015
+ onDraft,
4016
+ onSubmit: onSubmitField
3629
4017
  }
3630
- return { ...result, pr };
3631
- }),
3632
- sizes: data.sizes.map((entry) => {
3633
- return {
3634
- ...entry,
3635
- pr: { ...entry.pr, createdAt: new Date(entry.pr.createdAt) },
3636
- mergedAt: entry.mergedAt === null ? null : new Date(entry.mergedAt),
3637
- closedAt: entry.closedAt === null ? null : new Date(entry.closedAt)
3638
- };
3639
- })
3640
- };
3641
- }
3642
- function loadSnapshot(options) {
3643
- const stored = readCacheFile("snapshot");
3644
- if (stored?.params === void 0) {
3645
- return null;
3646
- }
3647
- const { params } = stored;
3648
- if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts) {
3649
- return null;
3650
- }
3651
- const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3652
- if (sinceIso < stored.data.sinceIso) {
3653
- return null;
3654
- }
3655
- const data = reviveRawData(stored.data);
3656
- if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3657
- return null;
3658
- }
3659
- const hasMissingVerdict = data.reviewResults.some((result) => {
3660
- return result.kind === "reviewed" && result.verdict === void 0;
3661
- });
3662
- if (hasMissingVerdict) {
3663
- return null;
4018
+ );
3664
4019
  }
3665
- if (sinceIso === data.sinceIso) {
3666
- return data;
4020
+ if (ui.modal === "settings") {
4021
+ return /* @__PURE__ */ jsx14(
4022
+ SettingsModal,
4023
+ {
4024
+ selected: ui.selectedSetting,
4025
+ cacheAction: ui.cacheAction,
4026
+ noCache: noCache2,
4027
+ copyLinks: copyLinks2,
4028
+ preset: themeState.preset
4029
+ }
4030
+ );
3667
4031
  }
3668
- if (data.reviewResults.some((result) => Number.isNaN(result.pr.createdAt.getTime()))) {
3669
- return null;
4032
+ if (ui.modal === "theme") {
4033
+ return /* @__PURE__ */ jsx14(
4034
+ ThemeModal,
4035
+ {
4036
+ selected: ui.selectedThemeColor,
4037
+ editing: ui.editing,
4038
+ error: ui.themeColorError,
4039
+ cacheAction: ui.cacheAction,
4040
+ overrides: themeState.preset === "custom" ? themeState.overrides : {},
4041
+ onDraft,
4042
+ onSubmit: onSubmitThemeColor
4043
+ }
4044
+ );
3670
4045
  }
3671
- const cutoff = new Date(sinceIso);
3672
- const reviewResults = data.reviewResults.filter((result) => result.pr.createdAt >= cutoff);
3673
- const sizes = data.sizes.filter((entry) => entry.pr.createdAt >= cutoff);
3674
- return {
3675
- ...data,
3676
- sinceIso,
3677
- reviewResults,
3678
- sizes,
3679
- /**
3680
- * The creation dates of inaccessible authored PRs are unknown, so the
3681
- * inaccessible count carries over unchanged.
3682
- */
3683
- authoredTotal: sizes.length + (data.authoredTotal - data.sizes.length)
3684
- };
3685
- }
3686
- function saveSnapshot(options, data) {
3687
- const params = {
3688
- since: options.since,
3689
- repos: options.repos,
3690
- user: options.user,
3691
- includeDrafts: options.includeDrafts
3692
- };
3693
- writeCacheFile("snapshot", { params, data });
4046
+ return null;
3694
4047
  }
3695
- async function loadData(options, onPhase, { bypassCache = false } = {}) {
3696
- const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3697
- onPhase({ phase: "search" });
3698
- const repoNames = options.repos.split(",").map((name) => name.trim()).filter((name) => name !== "");
3699
- const [user, repos] = await Promise.all([resolveUser(options.user, bypassCache), resolveRepos(repoNames)]);
3700
- const includeDrafts = options.includeDrafts;
3701
- const [requested, reviewed, authored] = await Promise.all([
3702
- searchPrs({ user, sinceIso, repos, includeDrafts, mode: "requested" }),
3703
- searchPrs({ user, sinceIso, repos, includeDrafts, mode: "reviewed" }),
3704
- searchPrs({ user, sinceIso, repos, includeDrafts, mode: "authored" })
3705
- ]);
3706
- const reviewPrs = collectReviewPrs(requested, reviewed);
3707
- const authoredPrs = collectAuthoredPrs(authored);
3708
- const progress = { review: 0, sizes: 0 };
3709
- const total = reviewPrs.length + authoredPrs.length;
3710
- const report = () => {
3711
- onPhase({ phase: "details", done: progress.review + progress.sizes, total });
3712
- };
3713
- report();
3714
- const [review, size] = await Promise.all([
3715
- reviewPrs.length === 0 ? { results: [], cacheHits: 0 } : fetchReviewRaw(
3716
- reviewPrs,
3717
- user,
3718
- (done) => {
3719
- progress.review = done;
3720
- report();
3721
- },
3722
- { bypassCache }
3723
- ),
3724
- authoredPrs.length === 0 ? { sizes: [], cacheHits: 0 } : fetchSizeRaw(
3725
- authoredPrs,
3726
- (done) => {
3727
- progress.sizes = done;
3728
- report();
3729
- },
3730
- { bypassCache }
3731
- )
3732
- ]);
3733
- const data = {
3734
- user,
3735
- sinceIso,
3736
- repos,
3737
- reviewResults: review.results,
3738
- sizes: size.sizes,
3739
- authoredTotal: authoredPrs.length,
3740
- searchCapped: requested.length >= 1e3 || reviewed.length >= 1e3 || authored.length >= 1e3,
3741
- fetchedAt: /* @__PURE__ */ new Date()
3742
- };
3743
- saveSnapshot(options, data);
3744
- return data;
4048
+
4049
+ // src/tui/hooks/useDeferredLoading.ts
4050
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
4051
+ function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
4052
+ const [visible, setVisible] = useState2(isLoading && showDelay === 0);
4053
+ const shownAtRef = useRef3(null);
4054
+ useEffect4(() => {
4055
+ if (isLoading) {
4056
+ const timer2 = setTimeout(() => {
4057
+ shownAtRef.current = Date.now();
4058
+ setVisible(true);
4059
+ }, showDelay);
4060
+ return () => {
4061
+ clearTimeout(timer2);
4062
+ };
4063
+ }
4064
+ const shownAt = shownAtRef.current;
4065
+ const remaining = shownAt === null ? 0 : Math.max(0, shownAt + minDuration - Date.now());
4066
+ const timer = setTimeout(() => {
4067
+ shownAtRef.current = null;
4068
+ setVisible(false);
4069
+ }, remaining);
4070
+ return () => {
4071
+ clearTimeout(timer);
4072
+ };
4073
+ }, [isLoading, showDelay, minDuration]);
4074
+ return visible;
3745
4075
  }
3746
4076
 
3747
4077
  // src/tui/hooks/useLoader.ts
4078
+ import { useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
3748
4079
  function useLoader(options, noCache2, onLoaded) {
3749
4080
  const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
3750
4081
  const [raw, setRaw] = useState3(startupSnapshot);
@@ -4048,8 +4379,8 @@ function hbar(fraction, width, color) {
4048
4379
  // src/tui/views/charts/bars.ts
4049
4380
  var BAR_WIDTH2 = 24;
4050
4381
  var MAX_BARS = 8;
4051
- function buildBarsCard({ title, subtitle, rows, format }) {
4052
- const shown = rows.slice(0, MAX_BARS);
4382
+ function buildBarsCard({ title, subtitle, rows, format, expanded = false }) {
4383
+ const shown = expanded ? rows : rows.slice(0, MAX_BARS);
4053
4384
  const max = Math.max(...shown.map((row) => row.value), 0);
4054
4385
  const labelWidth = Math.max(...shown.map((row) => row.label.length));
4055
4386
  const valueWidth = Math.max(...shown.map((row) => format(row.value).length));
@@ -4067,7 +4398,7 @@ function buildBarsCard({ title, subtitle, rows, format }) {
4067
4398
  return line;
4068
4399
  });
4069
4400
  if (rows.length > shown.length) {
4070
- lines.push([{ text: `+ ${rows.length - shown.length} more`, fg: theme.dim }]);
4401
+ lines.push([{ text: `+ ${rows.length - shown.length} more \xB7 x expands`, fg: theme.dim }]);
4071
4402
  }
4072
4403
  return { title, subtitle, lines };
4073
4404
  }
@@ -4736,7 +5067,7 @@ function buildVerdictCard(reviewed) {
4736
5067
  })
4737
5068
  });
4738
5069
  }
4739
- function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100) {
5070
+ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100, expanded = false) {
4740
5071
  const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
4741
5072
  const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
4742
5073
  const strip = [
@@ -4751,7 +5082,9 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4751
5082
  distributionTitle: "Review time distribution",
4752
5083
  noCharts: "No completed reviews to chart.",
4753
5084
  cards: [],
4754
- distribution: null
5085
+ distribution: null,
5086
+ expandable: false,
5087
+ expanded
4755
5088
  };
4756
5089
  if (results.length === 0) {
4757
5090
  return { empty: "No reviewed or review-requested PRs found.", ...base, lists: [] };
@@ -4814,7 +5147,8 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4814
5147
  detail: `n=${hours.length}`
4815
5148
  };
4816
5149
  }).toSorted((a, b) => b.value - a.value),
4817
- format: formatDuration
5150
+ format: formatDuration,
5151
+ expanded
4818
5152
  }) : null;
4819
5153
  const cards = [
4820
5154
  buildHistogramCard({
@@ -4843,6 +5177,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4843
5177
  legend: "reviews in that hour"
4844
5178
  }),
4845
5179
  buildVolumeCard("Reviews completed per week", reviewDates),
5180
+ buildScatterCard({
5181
+ title: "Review time vs size",
5182
+ subtitle: "time to review against lines changed, log scale",
5183
+ points: stats.reviewed.map((entry) => {
5184
+ return { x: entry.lines, y: entry.hours };
5185
+ }),
5186
+ formatX: count,
5187
+ formatY: formatDuration
5188
+ }),
4846
5189
  buildHistogramCard({
4847
5190
  title: "Review cycles per PR",
4848
5191
  subtitle: "completed request \u2192 review rounds per PR",
@@ -4870,7 +5213,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4870
5213
  ticks: DURATION_TICKS,
4871
5214
  flat: (count2, value2) => `all ${count2} ${count2 === 1 ? "review" : "reviews"} took ${value2}`
4872
5215
  });
4873
- return { empty: null, ...base, headline, cards, distribution, lists };
5216
+ return {
5217
+ empty: null,
5218
+ ...base,
5219
+ headline,
5220
+ cards,
5221
+ distribution,
5222
+ lists,
5223
+ expandable: byRepoCard !== null && stats.byRepo.length > MAX_BARS
5224
+ };
4874
5225
  }
4875
5226
  function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4876
5227
  const base = {
@@ -4880,7 +5231,9 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4880
5231
  noCharts: "No authored PRs to chart.",
4881
5232
  cards: [],
4882
5233
  distribution: null,
4883
- lists: []
5234
+ lists: [],
5235
+ expandable: false,
5236
+ expanded: false
4884
5237
  };
4885
5238
  if (raw.authoredTotal === 0) {
4886
5239
  return { empty: "No authored PRs found.", ...base };
@@ -4989,7 +5342,9 @@ function buildCommentView(raw, repo = null, width = 100) {
4989
5342
  noCharts: "No authored PRs to chart.",
4990
5343
  cards: [],
4991
5344
  distribution: null,
4992
- lists: []
5345
+ lists: [],
5346
+ expandable: false,
5347
+ expanded: false
4993
5348
  };
4994
5349
  if (raw.authoredTotal === 0) {
4995
5350
  return { empty: "No authored PRs found.", ...base };
@@ -5075,7 +5430,7 @@ function buildCommentView(raw, repo = null, width = 100) {
5075
5430
  }
5076
5431
  return { empty: null, ...base, strip, headline, cards, distribution, lists };
5077
5432
  }
5078
- function buildMergedView(raw, repo = null, width = 100) {
5433
+ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
5079
5434
  const base = {
5080
5435
  strip: [],
5081
5436
  headline: null,
@@ -5083,7 +5438,9 @@ function buildMergedView(raw, repo = null, width = 100) {
5083
5438
  noCharts: "No merged PRs to chart.",
5084
5439
  cards: [],
5085
5440
  distribution: null,
5086
- lists: []
5441
+ lists: [],
5442
+ expandable: false,
5443
+ expanded
5087
5444
  };
5088
5445
  if (raw.authoredTotal === 0) {
5089
5446
  return { empty: "No authored PRs found.", ...base };
@@ -5093,6 +5450,8 @@ function buildMergedView(raw, repo = null, width = 100) {
5093
5450
  return { empty: "No accessible authored PRs to analyze.", ...base };
5094
5451
  }
5095
5452
  const stats = computeMergeStats(sizes);
5453
+ const reviewers = computeReviewerStats(sizes, raw.user);
5454
+ const firstReview = computeFirstReviewStats(sizes, raw.user, { now: raw.fetchedAt });
5096
5455
  const strip = [
5097
5456
  countCell(sizes.length, "PRs created"),
5098
5457
  countCell(stats.merged.length, "merged"),
@@ -5123,8 +5482,57 @@ function buildMergedView(raw, repo = null, width = 100) {
5123
5482
  )
5124
5483
  });
5125
5484
  }
5485
+ const reviewerCard = reviewers.leaderboard.length === 0 ? null : buildBarsCard({
5486
+ title: "Who reviews your PRs",
5487
+ subtitle: "distinct PRs reviewed per person",
5488
+ rows: reviewers.leaderboard.map((row) => {
5489
+ return {
5490
+ label: row.login,
5491
+ value: row.prs,
5492
+ detail: row.reviews === 1 ? "1 review" : `${row.reviews} reviews`
5493
+ };
5494
+ }),
5495
+ format: count,
5496
+ expanded
5497
+ });
5498
+ const expandable = reviewers.leaderboard.length > MAX_BARS;
5499
+ const firstReviewCards = [
5500
+ ...firstReview.received.length === 0 ? [] : [
5501
+ buildHistogramCard({
5502
+ title: "Time to first review",
5503
+ subtitle: "elapsed time, created \u2192 first review received",
5504
+ values: firstReview.allHours,
5505
+ buckets: currentBuckets(),
5506
+ format: formatDuration
5507
+ }),
5508
+ buildTrendCard({
5509
+ title: "First review time trend",
5510
+ entries: firstReview.received.map((result) => {
5511
+ return { date: result.reviewedAt, value: result.hours };
5512
+ }),
5513
+ format: formatDuration,
5514
+ floor: 1 / 60
5515
+ })
5516
+ ],
5517
+ ...firstReview.awaiting.length === 0 ? [] : [
5518
+ buildHistogramCard({
5519
+ title: "Awaiting first review",
5520
+ subtitle: "how long open unreviewed PRs have waited",
5521
+ values: firstReview.awaiting.map((result) => result.hours),
5522
+ buckets: currentBuckets(),
5523
+ format: formatDuration
5524
+ })
5525
+ ]
5526
+ ];
5126
5527
  if (stats.merged.length === 0) {
5127
- return { empty: null, ...base, strip, lists };
5528
+ return {
5529
+ empty: null,
5530
+ ...base,
5531
+ strip,
5532
+ cards: [...firstReviewCards, ...reviewerCard === null ? [] : [reviewerCard]],
5533
+ lists,
5534
+ expandable
5535
+ };
5128
5536
  }
5129
5537
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
5130
5538
  const headline = [
@@ -5152,6 +5560,7 @@ function buildMergedView(raw, repo = null, width = 100) {
5152
5560
  format: formatDuration,
5153
5561
  floor: 1 / 60
5154
5562
  }),
5563
+ ...firstReviewCards,
5155
5564
  buildHeatmapCard({
5156
5565
  title: "When your PRs merge",
5157
5566
  subtitle: "PRs merged, weekday \xD7 hour, local time",
@@ -5202,7 +5611,16 @@ function buildMergedView(raw, repo = null, width = 100) {
5202
5611
  { label: "closed unmerged", count: stats.closed.length, color: theme.warn },
5203
5612
  ...stats.open.length > 0 ? [{ label: "still open", count: stats.open.length, color: theme.chartDim }] : []
5204
5613
  ]
5205
- })
5614
+ }),
5615
+ buildGaugeCard({
5616
+ title: "Review coverage",
5617
+ subtitle: "merged PRs that received a review",
5618
+ rows: [
5619
+ { label: "reviewed", count: reviewers.mergedReviewed, color: theme.accent },
5620
+ { label: "merged unreviewed", count: reviewers.mergedUnreviewed, color: theme.warn }
5621
+ ]
5622
+ }),
5623
+ ...reviewerCard === null ? [] : [reviewerCard]
5206
5624
  ];
5207
5625
  const distribution = buildDistribution({
5208
5626
  values: stats.allHours,
@@ -5211,7 +5629,7 @@ function buildMergedView(raw, repo = null, width = 100) {
5211
5629
  ticks: DURATION_TICKS,
5212
5630
  flat: (n, value2) => `all ${n} merged ${n === 1 ? "PR" : "PRs"} took ${value2}`
5213
5631
  });
5214
- return { empty: null, ...base, strip, headline, cards, distribution, lists };
5632
+ return { empty: null, ...base, strip, headline, cards, distribution, lists, expandable };
5215
5633
  }
5216
5634
  function count(value2) {
5217
5635
  return formatCount(Math.round(value2));
@@ -5227,7 +5645,7 @@ function resolveScope(scope, repos) {
5227
5645
  }
5228
5646
  return dropVanishedRepo(scope, repos);
5229
5647
  }
5230
- function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
5648
+ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoch) {
5231
5649
  return useMemo(() => {
5232
5650
  void themeEpoch;
5233
5651
  configureTimeMode({
@@ -5253,7 +5671,7 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
5253
5671
  const reviewScope = resolveScope(scopes.review, reviewRepos);
5254
5672
  const sizeScope = resolveScope(scopes.size, sizeRepos);
5255
5673
  const commentScope = resolveScope(scopes.comment, commentRepos);
5256
- const review = reviewScope.view === "detail" ? buildReviewView(raw, targetHours, targetLabelOf(options.target), reviewScope.repo, width) : null;
5674
+ const review = reviewScope.view === "detail" ? buildReviewView(raw, targetHours, targetLabelOf(options.target), reviewScope.repo, width, expanded.review) : null;
5257
5675
  return {
5258
5676
  pendingRepos,
5259
5677
  openRepos,
@@ -5269,7 +5687,7 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
5269
5687
  commentScope,
5270
5688
  pending: pendingScope.view === "detail" ? buildPendingReviewView(raw, pendingScope.repo, grouping.pending) : null,
5271
5689
  open: openScope.view === "detail" ? buildOpenAuthoredView(raw, openScope.repo, grouping.open) : null,
5272
- merged: mergedScope.view === "detail" ? buildMergedView(raw, mergedScope.repo, width) : null,
5690
+ merged: mergedScope.view === "detail" ? buildMergedView(raw, mergedScope.repo, width, expanded.merged) : null,
5273
5691
  review,
5274
5692
  size: sizeScope.view === "detail" ? buildSizeView(raw, sizeTarget, sizeScope.repo, width) : null,
5275
5693
  comments: commentScope.view === "detail" ? buildCommentView(raw, commentScope.repo, width) : null
@@ -5290,6 +5708,8 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
5290
5708
  scopes.comment,
5291
5709
  grouping.pending,
5292
5710
  grouping.open,
5711
+ expanded.review,
5712
+ expanded.merged,
5293
5713
  themeEpoch
5294
5714
  ]);
5295
5715
  }
@@ -5410,6 +5830,22 @@ function handleSettingsModalKey(key, context) {
5410
5830
  }
5411
5831
  break;
5412
5832
  }
5833
+ case "exportJson": {
5834
+ if (key.name !== "return") {
5835
+ break;
5836
+ }
5837
+ if (context.raw === null) {
5838
+ context.dispatchUi({ type: "cacheActionReported", action: "exportNoData" });
5839
+ break;
5840
+ }
5841
+ try {
5842
+ exportStatsFile(context.raw, context.options);
5843
+ context.dispatchUi({ type: "cacheActionReported", action: "exported" });
5844
+ } catch {
5845
+ context.dispatchUi({ type: "cacheActionReported", action: "exportFailed" });
5846
+ }
5847
+ break;
5848
+ }
5413
5849
  }
5414
5850
  break;
5415
5851
  }
@@ -5519,31 +5955,35 @@ function statsTabOf(context) {
5519
5955
  return {
5520
5956
  key: "merged",
5521
5957
  repos: views?.mergedRepos ?? [],
5522
- scope: views?.mergedScope ?? null
5958
+ scope: views?.mergedScope ?? null,
5959
+ view: views?.merged ?? null
5523
5960
  };
5524
5961
  }
5525
5962
  if (context.browse.tab === 2) {
5526
5963
  return {
5527
5964
  key: "review",
5528
5965
  repos: views?.reviewRepos ?? [],
5529
- scope: views?.reviewScope ?? null
5966
+ scope: views?.reviewScope ?? null,
5967
+ view: views?.review ?? null
5530
5968
  };
5531
5969
  }
5532
5970
  if (context.browse.tab === 3) {
5533
5971
  return {
5534
5972
  key: "size",
5535
5973
  repos: views?.sizeRepos ?? [],
5536
- scope: views?.sizeScope ?? null
5974
+ scope: views?.sizeScope ?? null,
5975
+ view: views?.size ?? null
5537
5976
  };
5538
5977
  }
5539
5978
  return {
5540
5979
  key: "comment",
5541
5980
  repos: views?.commentRepos ?? [],
5542
- scope: views?.commentScope ?? null
5981
+ scope: views?.commentScope ?? null,
5982
+ view: views?.comments ?? null
5543
5983
  };
5544
5984
  }
5545
5985
  function handleStatsKey(key, context) {
5546
- const { key: tab, repos, scope } = statsTabOf(context);
5986
+ const { key: tab, repos, scope, view } = statsTabOf(context);
5547
5987
  if (scope !== null && scope.view === "list") {
5548
5988
  switch (key.name) {
5549
5989
  case "up":
@@ -5564,6 +6004,8 @@ function handleStatsKey(key, context) {
5564
6004
  }
5565
6005
  } else if ((key.name === "escape" || key.name === "backspace") && repos.length > 0) {
5566
6006
  context.dispatchBrowse({ type: "pickerReturned", tab });
6007
+ } else if (key.name === "x" && view?.expandable === true) {
6008
+ context.dispatchBrowse({ type: "expandToggled", tab });
5567
6009
  } else if (key.name === "j") {
5568
6010
  context.scrollBy(context.browse.tab, 2);
5569
6011
  } else if (key.name === "k") {
@@ -5829,7 +6271,7 @@ function App({
5829
6271
  }
5830
6272
  });
5831
6273
  });
5832
- const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, themeState);
6274
+ const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, browse.expanded, themeState);
5833
6275
  const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
5834
6276
  const draftRef = useRef5("");
5835
6277
  const commitField = () => {
@@ -5872,6 +6314,7 @@ function App({
5872
6314
  themeState,
5873
6315
  options,
5874
6316
  views,
6317
+ raw,
5875
6318
  dispatchUi,
5876
6319
  dispatchBrowse,
5877
6320
  setOptions,
@@ -5980,7 +6423,14 @@ function bootstrap() {
5980
6423
  validateField(field.key, value2);
5981
6424
  }
5982
6425
  }
5983
- return { initial: initial2, saved: saved2, noCache: values["no-cache"], copyLinks: settings.copyLinks === true, theme: theme3 };
6426
+ return {
6427
+ initial: initial2,
6428
+ saved: saved2,
6429
+ noCache: values["no-cache"],
6430
+ copyLinks: settings.copyLinks === true,
6431
+ theme: theme3,
6432
+ json: values.json
6433
+ };
5984
6434
  } catch (error) {
5985
6435
  if (error instanceof CliError) {
5986
6436
  fail(error.message);
@@ -5991,7 +6441,10 @@ function bootstrap() {
5991
6441
 
5992
6442
  // src/tui/main.tsx
5993
6443
  import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
5994
- var { initial, saved, noCache, copyLinks, theme: theme2 } = bootstrap();
6444
+ var { initial, saved, noCache, copyLinks, theme: theme2, json } = bootstrap();
6445
+ if (json) {
6446
+ await runJsonStats(initial, noCache);
6447
+ }
5995
6448
  var exitSignals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGBUS"];
5996
6449
  var renderer = await createCliRenderer({ exitOnCtrlC: true, exitSignals });
5997
6450
  for (const signal of ["SIGTERM", "SIGHUP"]) {