@d3lm/pr-stats 0.2.8 → 0.2.10

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 +18 -3
  2. package/dist/tui-app.mjs +965 -503
  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 = 3;
149
+ var VERSION = 5;
150
150
  var enabled = false;
151
151
  function configureCache(on) {
152
152
  enabled = on;
@@ -795,7 +795,8 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
795
795
  requestedAt: result.requestedAt,
796
796
  reviewedAt: result.reviewedAt,
797
797
  hours: durationHours(result.requestedAt, result.reviewedAt),
798
- verdict: result.verdict
798
+ verdict: result.verdict,
799
+ lines: result.lines
799
800
  });
800
801
  } else if (result.kind === "pending" && result.pr.state === "open") {
801
802
  pending.push({ pr: result.pr, requestedAt: result.requestedAt, hours: durationHours(result.requestedAt, now) });
@@ -890,7 +891,9 @@ function computeReviewerStats(sizes, author) {
890
891
  let mergedReviewed = 0;
891
892
  let mergedUnreviewed = 0;
892
893
  for (const entry of sizes) {
893
- const others = entry.reviewers.filter((login) => login !== author);
894
+ const others = entry.reviews.flatMap(
895
+ (review) => review.login === null || review.login === author ? [] : [review.login]
896
+ );
894
897
  for (const login of others) {
895
898
  const counts = byLogin.get(login) ?? { prs: 0, reviews: 0 };
896
899
  counts.reviews += 1;
@@ -916,6 +919,35 @@ function computeReviewerStats(sizes, author) {
916
919
  }).toSorted((a, b) => b.prs - a.prs || b.reviews - a.reviews || a.login.localeCompare(b.login));
917
920
  return { leaderboard, mergedReviewed, mergedUnreviewed };
918
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
+ }
919
951
  function computeCommentStats(sizes) {
920
952
  const metrics = [
921
953
  { label: "discussion comments", values: sizes.map((size) => size.comments.discussion) },
@@ -2361,12 +2393,24 @@ var OPTIONS = [
2361
2393
  default: false,
2362
2394
  help: "Include PRs that are currently drafts. Excluded by default."
2363
2395
  },
2396
+ {
2397
+ name: "review-types",
2398
+ type: "string",
2399
+ placeholder: "<list>",
2400
+ help: "Count only these review types as a review. Accepts a comma-separated list of `approve`, `comment`, and `request-changes`, for example `approve,request-changes`. A review of another type never answers a request, so the PR stays in the awaiting queue until a counted review lands. Without this flag, every submitted review counts."
2401
+ },
2364
2402
  {
2365
2403
  name: "no-cache",
2366
2404
  type: "boolean",
2367
2405
  default: false,
2368
2406
  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."
2369
2407
  },
2408
+ {
2409
+ name: "json",
2410
+ type: "boolean",
2411
+ default: false,
2412
+ 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."
2413
+ },
2370
2414
  {
2371
2415
  name: "debug",
2372
2416
  type: "string",
@@ -2496,6 +2540,22 @@ function parseSizeTarget(input) {
2496
2540
  }
2497
2541
  return target;
2498
2542
  }
2543
+ var REVIEW_TYPES = /* @__PURE__ */ new Map([
2544
+ ["approve", "APPROVED"],
2545
+ ["comment", "COMMENTED"],
2546
+ ["request-changes", "CHANGES_REQUESTED"]
2547
+ ]);
2548
+ function parseReviewTypes(input) {
2549
+ const states = /* @__PURE__ */ new Set();
2550
+ for (const part of input.split(",")) {
2551
+ const state = REVIEW_TYPES.get(part.trim().toLowerCase());
2552
+ if (state === void 0) {
2553
+ throw new CliError(`invalid --review-types value "${part.trim()}", use approve, comment, or request-changes`);
2554
+ }
2555
+ states.add(state);
2556
+ }
2557
+ return states;
2558
+ }
2499
2559
  function toMinutesOfDay(hourText, minuteText, meridiem) {
2500
2560
  const minute = Number(minuteText ?? 0);
2501
2561
  if (minute > 59) {
@@ -2577,6 +2637,13 @@ var FIELDS = [
2577
2637
  kind: "toggle",
2578
2638
  fetch: true
2579
2639
  },
2640
+ {
2641
+ key: "reviewTypes",
2642
+ label: "Review types",
2643
+ hint: "enter opens the type list, checked types count as a review",
2644
+ kind: "multi",
2645
+ fetch: true
2646
+ },
2580
2647
  {
2581
2648
  key: "target",
2582
2649
  label: "Review target",
@@ -2613,6 +2680,26 @@ var FIELDS = [
2613
2680
  fetch: false
2614
2681
  }
2615
2682
  ];
2683
+ var REVIEW_TYPE_CHOICES = ["approve", "comment", "request-changes"];
2684
+ function checkedReviewTypes(value2) {
2685
+ if (value2.trim() === "") {
2686
+ return new Set(REVIEW_TYPE_CHOICES);
2687
+ }
2688
+ return new Set(value2.split(",").map((part) => part.trim().toLowerCase()));
2689
+ }
2690
+ function toggleReviewType(value2, type) {
2691
+ const checked = checkedReviewTypes(value2);
2692
+ if (checked.has(type)) {
2693
+ checked.delete(type);
2694
+ } else {
2695
+ checked.add(type);
2696
+ }
2697
+ const next = REVIEW_TYPE_CHOICES.filter((choice) => checked.has(choice));
2698
+ if (next.length === 0) {
2699
+ return value2;
2700
+ }
2701
+ return next.length === REVIEW_TYPE_CHOICES.length ? "" : next.join(",");
2702
+ }
2616
2703
  function validateField(key, value2) {
2617
2704
  if (value2 === "" && key !== "workHours" && key !== "since") {
2618
2705
  return;
@@ -2638,6 +2725,10 @@ function validateField(key, value2) {
2638
2725
  resolveTimezone(value2);
2639
2726
  break;
2640
2727
  }
2728
+ case "reviewTypes": {
2729
+ parseReviewTypes(value2);
2730
+ break;
2731
+ }
2641
2732
  }
2642
2733
  }
2643
2734
  function sameOptions(a, b) {
@@ -2649,6 +2740,7 @@ function readSavedOptions() {
2649
2740
  return null;
2650
2741
  }
2651
2742
  const record = value2;
2743
+ record.reviewTypes ??= "";
2652
2744
  const options = {};
2653
2745
  for (const field of FIELDS) {
2654
2746
  const raw = record[field.key];
@@ -2707,10 +2799,13 @@ function applySavedOptions(values, explicit) {
2707
2799
  if (!explicit.has("include-drafts")) {
2708
2800
  values["include-drafts"] = saved2.includeDrafts;
2709
2801
  }
2802
+ if (!explicit.has("review-types") && saved2.reviewTypes !== "") {
2803
+ values["review-types"] = saved2.reviewTypes;
2804
+ }
2710
2805
  return saved2;
2711
2806
  }
2712
2807
  function fetchParamsKey(options) {
2713
- return JSON.stringify([options.since, options.repos, options.user, options.includeDrafts]);
2808
+ return JSON.stringify([options.since, options.repos, options.user, options.includeDrafts, options.reviewTypes]);
2714
2809
  }
2715
2810
  function targetLabelOf(target) {
2716
2811
  if (target === "") {
@@ -2786,7 +2881,8 @@ var EMPTY_PLACEHOLDERS = {
2786
2881
  user: "(authenticated user)",
2787
2882
  target: "(none)",
2788
2883
  sizeTarget: "(none)",
2789
- tz: "(system)"
2884
+ tz: "(system)",
2885
+ reviewTypes: "(every type)"
2790
2886
  };
2791
2887
  var SECTIONS = [
2792
2888
  { title: "Data", fields: FIELDS.filter((field) => field.fetch) },
@@ -2799,7 +2895,8 @@ function OptionsModal({
2799
2895
  editing,
2800
2896
  fieldError,
2801
2897
  onDraft,
2802
- onSubmit
2898
+ onSubmit,
2899
+ onToggleReviewType
2803
2900
  }) {
2804
2901
  const savedState = savedLine(options, saved2);
2805
2902
  return /* @__PURE__ */ jsxs10(ModalFrame, { title: "Options", children: [
@@ -2813,9 +2910,10 @@ function OptionsModal({
2813
2910
  field,
2814
2911
  options,
2815
2912
  isSelected: index === selected,
2816
- isEditing: index === selected && editing && field.kind === "text",
2913
+ isEditing: index === selected && editing && field.kind !== "toggle",
2817
2914
  onDraft,
2818
- onSubmit
2915
+ onSubmit,
2916
+ onToggleType: onToggleReviewType
2819
2917
  },
2820
2918
  field.key
2821
2919
  );
@@ -2859,10 +2957,17 @@ function FieldRow({
2859
2957
  isSelected,
2860
2958
  isEditing,
2861
2959
  onDraft,
2862
- onSubmit
2960
+ onSubmit,
2961
+ onToggleType
2863
2962
  }) {
2864
2963
  const value2 = displayValue(field.key, options[field.key]);
2865
2964
  const valueColor = value2.isPlaceholder ? isSelected ? theme.muted : theme.dim : theme.muted;
2965
+ if (isEditing && field.kind === "multi") {
2966
+ return /* @__PURE__ */ jsxs10("box", { flexDirection: "column", children: [
2967
+ /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: /* @__PURE__ */ jsx11("text", { wrapMode: "none", children: /* @__PURE__ */ jsx11("b", { fg: value2.isPlaceholder ? theme.muted : theme.text, children: value2.text }) }) }),
2968
+ /* @__PURE__ */ jsx11(TypeChecklist, { value: String(options[field.key]), onToggle: onToggleType })
2969
+ ] });
2970
+ }
2866
2971
  return /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: isEditing ? /* @__PURE__ */ jsx11(
2867
2972
  "input",
2868
2973
  {
@@ -2886,371 +2991,90 @@ function FieldRow({
2886
2991
  /* @__PURE__ */ jsx11("span", { fg: theme.muted, children: " \u203A" })
2887
2992
  ] }) : isSelected ? /* @__PURE__ */ jsx11("text", { wrapMode: "none", children: /* @__PURE__ */ jsx11("b", { fg: value2.isPlaceholder ? theme.muted : theme.text, children: value2.text }) }) : /* @__PURE__ */ jsx11("text", { wrapMode: "none", fg: valueColor, children: value2.text }) });
2888
2993
  }
2994
+ function TypeChecklist({ value: value2, onToggle }) {
2995
+ const checked = checkedReviewTypes(value2);
2996
+ return /* @__PURE__ */ jsx11("box", { alignSelf: "flex-end", width: 30, height: REVIEW_TYPE_CHOICES.length, marginRight: 2, children: /* @__PURE__ */ jsx11(
2997
+ "select",
2998
+ {
2999
+ focused: true,
3000
+ width: "100%",
3001
+ height: REVIEW_TYPE_CHOICES.length,
3002
+ options: REVIEW_TYPE_CHOICES.map((choice) => {
3003
+ return { name: `[${checked.has(choice) ? "x" : " "}] ${choice}`, description: "", value: choice };
3004
+ }),
3005
+ showDescription: false,
3006
+ showScrollIndicator: false,
3007
+ showSelectionIndicator: false,
3008
+ wrapSelection: true,
3009
+ backgroundColor: theme.inputBg,
3010
+ focusedBackgroundColor: theme.inputFocusedBg,
3011
+ textColor: theme.muted,
3012
+ focusedTextColor: theme.muted,
3013
+ selectedBackgroundColor: theme.selectedBg,
3014
+ selectedTextColor: theme.text,
3015
+ onSelect: (_index, option) => {
3016
+ if (option !== null) {
3017
+ onToggle(option.value);
3018
+ }
3019
+ }
3020
+ }
3021
+ ) });
3022
+ }
2889
3023
 
2890
3024
  // src/tui/components/SettingsModal.tsx
2891
3025
  import { homedir as homedir2 } from "node:os";
2892
3026
 
2893
- // src/tui/state/settings.ts
2894
- var SETTINGS = [
2895
- {
2896
- key: "noCache",
2897
- section: "Cache",
2898
- label: "Disable cache",
2899
- hint: "refetch everything on every load instead of reading cached PRs \xB7 fresh results still update the cache"
2900
- },
2901
- {
2902
- key: "clearCache",
2903
- section: "Cache",
2904
- label: "Clear cache",
2905
- hint: "deletes the cached PR data at this path, so the next reload refetches everything"
2906
- },
2907
- {
2908
- key: "copyLinks",
2909
- section: "Links",
2910
- label: "Copy instead of open",
2911
- hint: "enter and a click on a PR reference copy its link to the clipboard instead of opening the browser"
2912
- },
2913
- {
2914
- key: "themePreset",
2915
- section: "Theme",
2916
- label: "Theme",
2917
- hint: "built-in color theme \xB7 editing colors adds a custom theme to the cycle"
2918
- },
2919
- {
2920
- key: "themeColors",
2921
- section: "Theme",
2922
- label: "Edit colors",
2923
- hint: "opens the color list, where every theme color takes a hex value \xB7 edits become the custom theme"
2924
- },
2925
- {
2926
- key: "resetSettings",
2927
- section: "Settings",
2928
- label: "Reset settings",
2929
- hint: "deletes the settings file with the saved cache setting and theme, so future runs start from the defaults"
2930
- }
2931
- ];
2932
- var THEME_COLORS = [
2933
- { key: "bg", hint: "background of the screen and the dialogs" },
2934
- { key: "border", hint: "borders, rules, and the dialog frames" },
2935
- { key: "text", hint: "primary text" },
2936
- { key: "muted", hint: "secondary text like values and chart labels" },
2937
- { key: "dim", hint: "faint text like axis scales and the footer hints" },
2938
- { key: "accent", hint: "highlights like medians, headings, and the selection marker" },
2939
- { key: "selectedBg", hint: "background of the selected row" },
2940
- { key: "inputBg", hint: "background of text inputs" },
2941
- { key: "inputFocusedBg", hint: "background of the focused text input" },
2942
- { key: "warn", hint: "notices like the reload reminder and confirm prompts" },
2943
- { key: "error", hint: "error messages and failed loads" },
2944
- { key: "success", hint: "the checkmark on the copied-link notice" },
2945
- { key: "chartBar", hint: "histogram and volume bars" },
2946
- { key: "chartLine", hint: "trend lines and the scatter dots" },
2947
- { key: "chartDim", hint: "de-emphasized chart parts like the over-target share" },
2948
- { key: "heat", hint: "the four heatmap colors from cool to hot, separated by spaces" }
2949
- ];
2950
- var CACHE_MESSAGES = {
2951
- confirm: { text: "press enter again to clear the cache \xB7 esc cancels", warn: true },
2952
- cleared: { text: "cache cleared \xB7 the next reload refetches everything" },
2953
- disabled: { text: "the cache is disabled for this session \xB7 nothing to clear" },
2954
- saved: { text: "saved to settings.json \xB7 future runs start with this setting" },
2955
- notSaved: { text: "the cache is disabled for this session \xB7 setting not saved" },
2956
- resetConfirm: { text: "press enter again to delete settings.json \xB7 esc cancels", warn: true },
2957
- resetDone: { text: "settings.json deleted \xB7 future runs start from the defaults" },
2958
- resetDisabled: { text: "the cache is disabled for this session \xB7 nothing to reset" }
2959
- };
3027
+ // src/tui/data/export.ts
3028
+ import { join as join4 } from "node:path";
2960
3029
 
2961
- // src/tui/components/SettingsModal.tsx
2962
- import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
2963
- var SECTIONS2 = [];
2964
- for (const setting of SETTINGS) {
2965
- const last = SECTIONS2.at(-1);
2966
- if (last?.title === setting.section) {
2967
- last.settings.push(setting);
2968
- } else {
2969
- SECTIONS2.push({ title: setting.section, settings: [setting] });
2970
- }
2971
- }
2972
- function SettingsModal({
2973
- selected,
2974
- cacheAction,
2975
- noCache: noCache2,
2976
- copyLinks: copyLinks2,
2977
- preset
2978
- }) {
2979
- const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
2980
- return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
2981
- SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
2982
- /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
2983
- section.settings.map((setting) => {
2984
- const isSelected = SETTINGS.indexOf(setting) === selected;
2985
- return /* @__PURE__ */ jsx12(ModalRow, { label: setting.label, isSelected, children: /* @__PURE__ */ jsx12(
2986
- SettingValue,
2987
- {
2988
- setting,
2989
- isSelected,
2990
- cacheAction,
2991
- noCache: noCache2,
2992
- copyLinks: copyLinks2,
2993
- preset
2994
- }
2995
- ) }, setting.key);
2996
- })
2997
- ] }, section.title)),
2998
- /* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg: message?.warn ? theme.warn : theme.muted, marginLeft: 2, marginRight: 2, children: message?.text ?? SETTINGS[selected].hint })
2999
- ] });
3000
- }
3001
- function SettingValue({
3002
- setting,
3003
- isSelected,
3004
- cacheAction,
3005
- noCache: noCache2,
3006
- copyLinks: copyLinks2,
3007
- preset
3008
- }) {
3009
- switch (setting.key) {
3010
- case "noCache": {
3011
- return /* @__PURE__ */ jsx12(ToggleValue, { value: noCache2 ? "yes" : "no", isSelected });
3012
- }
3013
- case "clearCache": {
3014
- return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
3015
- }
3016
- case "copyLinks": {
3017
- return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
3018
- }
3019
- case "themePreset": {
3020
- return /* @__PURE__ */ jsx12(ToggleValue, { value: preset, isSelected });
3021
- }
3022
- case "themeColors": {
3023
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: ["chartDim", "chartBar", "chartLine", "accent"].map((key) => /* @__PURE__ */ jsx12("span", { fg: theme[key], children: "\u2588\u2588" }, key)) });
3024
- }
3025
- case "resetSettings": {
3026
- return /* @__PURE__ */ jsx12(PathValue, { path: settingsFile(), confirming: cacheAction === "resetConfirm", isSelected });
3027
- }
3028
- default: {
3029
- return null;
3030
+ // src/github.ts
3031
+ import { execFile } from "node:child_process";
3032
+ import { createHash } from "node:crypto";
3033
+ import { statSync } from "node:fs";
3034
+ import { join as join3, resolve } from "node:path";
3035
+ import { promisify } from "node:util";
3036
+ var execFileAsync = promisify(execFile);
3037
+ var API_BASE = "https://api.github.com";
3038
+ var token;
3039
+ var ghBinary = "gh";
3040
+ function resolveDebugBinary(input) {
3041
+ const resolved = resolve(input);
3042
+ const stats = statSync(resolved, { throwIfNoEntry: false });
3043
+ if (stats?.isDirectory()) {
3044
+ const binary = join3(resolved, "gh");
3045
+ if (!statSync(binary, { throwIfNoEntry: false })?.isFile()) {
3046
+ throw new CliError(`--debug directory "${input}" does not contain a gh executable`);
3030
3047
  }
3048
+ return binary;
3031
3049
  }
3050
+ if (stats?.isFile()) {
3051
+ return resolved;
3052
+ }
3053
+ throw new CliError(`--debug path "${input}" does not exist`);
3032
3054
  }
3033
- function ToggleValue({ value: value2, isSelected }) {
3034
- if (isSelected) {
3035
- return /* @__PURE__ */ jsxs11("text", { wrapMode: "none", children: [
3036
- /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: "\u2039 " }),
3037
- /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }),
3038
- /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: " \u203A" })
3039
- ] });
3055
+ function configureAuth(cliToken, debugPath) {
3056
+ if (debugPath !== void 0) {
3057
+ ghBinary = resolveDebugBinary(debugPath);
3058
+ token = void 0;
3059
+ return;
3040
3060
  }
3041
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
3061
+ ghBinary = "gh";
3062
+ token = cliToken ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
3042
3063
  }
3043
- function PathValue({ path, confirming, isSelected }) {
3044
- if (confirming) {
3045
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
3046
- }
3047
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: path.replace(homedir2(), "~") });
3048
- }
3049
-
3050
- // src/tui/components/ThemeModal.tsx
3051
- import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs12 } from "@opentui/react/jsx-runtime";
3052
- function ThemeModal({
3053
- selected,
3054
- editing,
3055
- error,
3056
- cacheAction,
3057
- overrides,
3058
- onDraft,
3059
- onSubmit
3060
- }) {
3061
- const spec = THEME_COLORS[selected];
3062
- const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
3063
- const hint = spec.key in overrides ? `${spec.hint} \xB7 custom color, an empty value restores the theme` : spec.hint;
3064
- return /* @__PURE__ */ jsxs12(ModalFrame, { title: "Theme colors", children: [
3065
- /* @__PURE__ */ jsx13("box", { flexDirection: "column", marginBottom: 1, children: THEME_COLORS.map((color, index) => /* @__PURE__ */ jsx13(
3066
- ColorRow,
3067
- {
3068
- color,
3069
- isSelected: index === selected,
3070
- isEditing: index === selected && editing,
3071
- isCustom: color.key in overrides,
3072
- onDraft,
3073
- onSubmit
3074
- },
3075
- color.key
3076
- )) }),
3077
- /* @__PURE__ */ jsx13("text", { wrapMode: "word", height: 2, fg: error !== null ? theme.error : theme.muted, marginLeft: 2, marginRight: 2, children: error ?? message?.text ?? hint })
3078
- ] });
3079
- }
3080
- function ColorRow({
3081
- color,
3082
- isSelected,
3083
- isEditing,
3084
- isCustom,
3085
- onDraft,
3086
- onSubmit
3087
- }) {
3088
- const value2 = themeColorText(color.key);
3089
- const swatch = theme[color.key];
3090
- return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(
3091
- "input",
3092
- {
3093
- width: 36,
3094
- value: value2,
3095
- focused: true,
3096
- onInput: (next) => {
3097
- onDraft(String(next));
3098
- },
3099
- onSubmit: () => {
3100
- onSubmit();
3101
- },
3102
- backgroundColor: theme.inputBg,
3103
- focusedBackgroundColor: theme.inputFocusedBg,
3104
- textColor: theme.text,
3105
- cursorColor: theme.accent
3106
- }
3107
- ) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
3108
- Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
3109
- /* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
3110
- /* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
3111
- /* @__PURE__ */ jsx13("span", { fg: swatch[2], children: "\u2588" }),
3112
- /* @__PURE__ */ jsx13("span", { fg: swatch[3], children: "\u2588" })
3113
- ] }) : /* @__PURE__ */ jsx13("span", { fg: swatch, children: "\u2588\u2588" }),
3114
- /* @__PURE__ */ jsx13("span", { children: " " }),
3115
- isSelected ? /* @__PURE__ */ jsx13("b", { fg: theme.text, children: value2 }) : /* @__PURE__ */ jsx13("span", { fg: isCustom ? theme.text : theme.muted, children: value2 })
3116
- ] }) });
3117
- }
3118
-
3119
- // src/tui/components/Modals.tsx
3120
- import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
3121
- function Modals({
3122
- ui,
3123
- options,
3124
- saved: saved2,
3125
- noCache: noCache2,
3126
- copyLinks: copyLinks2,
3127
- themeState,
3128
- onDraft,
3129
- onSubmitField,
3130
- onSubmitThemeColor
3131
- }) {
3132
- if (ui.modal === "options") {
3133
- return /* @__PURE__ */ jsx14(
3134
- OptionsModal,
3135
- {
3136
- options,
3137
- saved: saved2,
3138
- selected: ui.selectedField,
3139
- editing: ui.editing,
3140
- fieldError: ui.fieldError,
3141
- onDraft,
3142
- onSubmit: onSubmitField
3143
- }
3144
- );
3145
- }
3146
- if (ui.modal === "settings") {
3147
- return /* @__PURE__ */ jsx14(
3148
- SettingsModal,
3149
- {
3150
- selected: ui.selectedSetting,
3151
- cacheAction: ui.cacheAction,
3152
- noCache: noCache2,
3153
- copyLinks: copyLinks2,
3154
- preset: themeState.preset
3155
- }
3156
- );
3157
- }
3158
- if (ui.modal === "theme") {
3159
- return /* @__PURE__ */ jsx14(
3160
- ThemeModal,
3161
- {
3162
- selected: ui.selectedThemeColor,
3163
- editing: ui.editing,
3164
- error: ui.themeColorError,
3165
- cacheAction: ui.cacheAction,
3166
- overrides: themeState.preset === "custom" ? themeState.overrides : {},
3167
- onDraft,
3168
- onSubmit: onSubmitThemeColor
3169
- }
3170
- );
3171
- }
3172
- return null;
3173
- }
3174
-
3175
- // src/tui/hooks/useDeferredLoading.ts
3176
- import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
3177
- function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
3178
- const [visible, setVisible] = useState2(isLoading && showDelay === 0);
3179
- const shownAtRef = useRef3(null);
3180
- useEffect4(() => {
3181
- if (isLoading) {
3182
- const timer2 = setTimeout(() => {
3183
- shownAtRef.current = Date.now();
3184
- setVisible(true);
3185
- }, showDelay);
3186
- return () => {
3187
- clearTimeout(timer2);
3188
- };
3189
- }
3190
- const shownAt = shownAtRef.current;
3191
- const remaining = shownAt === null ? 0 : Math.max(0, shownAt + minDuration - Date.now());
3192
- const timer = setTimeout(() => {
3193
- shownAtRef.current = null;
3194
- setVisible(false);
3195
- }, remaining);
3196
- return () => {
3197
- clearTimeout(timer);
3198
- };
3199
- }, [isLoading, showDelay, minDuration]);
3200
- return visible;
3201
- }
3202
-
3203
- // src/tui/hooks/useLoader.ts
3204
- import { useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
3205
-
3206
- // src/github.ts
3207
- import { execFile } from "node:child_process";
3208
- import { createHash } from "node:crypto";
3209
- import { statSync } from "node:fs";
3210
- import { join as join3, resolve } from "node:path";
3211
- import { promisify } from "node:util";
3212
- var execFileAsync = promisify(execFile);
3213
- var API_BASE = "https://api.github.com";
3214
- var token;
3215
- var ghBinary = "gh";
3216
- function resolveDebugBinary(input) {
3217
- const resolved = resolve(input);
3218
- const stats = statSync(resolved, { throwIfNoEntry: false });
3219
- if (stats?.isDirectory()) {
3220
- const binary = join3(resolved, "gh");
3221
- if (!statSync(binary, { throwIfNoEntry: false })?.isFile()) {
3222
- throw new CliError(`--debug directory "${input}" does not contain a gh executable`);
3223
- }
3224
- return binary;
3225
- }
3226
- if (stats?.isFile()) {
3227
- return resolved;
3228
- }
3229
- throw new CliError(`--debug path "${input}" does not exist`);
3230
- }
3231
- function configureAuth(cliToken, debugPath) {
3232
- if (debugPath !== void 0) {
3233
- ghBinary = resolveDebugBinary(debugPath);
3234
- token = void 0;
3235
- return;
3236
- }
3237
- ghBinary = "gh";
3238
- token = cliToken ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
3239
- }
3240
- async function gh(args) {
3241
- try {
3242
- const { stdout } = await execFileAsync(ghBinary, args, {
3243
- maxBuffer: 64 * 1024 * 1024
3244
- });
3245
- return stdout;
3246
- } catch (error) {
3247
- const execError = error;
3248
- if (execError.code === "ENOENT") {
3249
- throw new CliError("the gh CLI is not installed, install it or provide a token via --token or GITHUB_TOKEN");
3250
- }
3251
- const stderr = execError.stderr?.toString().trim();
3252
- throw new CliError(`gh ${args.slice(0, 2).join(" ")} failed${stderr ? `
3253
- ${stderr}` : ""}`);
3064
+ async function gh(args) {
3065
+ try {
3066
+ const { stdout } = await execFileAsync(ghBinary, args, {
3067
+ maxBuffer: 64 * 1024 * 1024
3068
+ });
3069
+ return stdout;
3070
+ } catch (error) {
3071
+ const execError = error;
3072
+ if (execError.code === "ENOENT") {
3073
+ throw new CliError("the gh CLI is not installed, install it or provide a token via --token or GITHUB_TOKEN");
3074
+ }
3075
+ const stderr = execError.stderr?.toString().trim();
3076
+ throw new CliError(`gh ${args.slice(0, 2).join(" ")} failed${stderr ? `
3077
+ ${stderr}` : ""}`);
3254
3078
  }
3255
3079
  }
3256
3080
  async function api(path, { method = "GET", body } = {}) {
@@ -3413,6 +3237,8 @@ async function fetchPrDetails(prs) {
3413
3237
  return `
3414
3238
  pr${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
3415
3239
  pullRequest(number: ${pr.number}) {
3240
+ additions
3241
+ deletions
3416
3242
  timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], first: 100) {
3417
3243
  nodes {
3418
3244
  ... on ReviewRequestedEvent {
@@ -3455,6 +3281,7 @@ async function fetchPrSizes(prs) {
3455
3281
  reviews(first: 100) {
3456
3282
  nodes {
3457
3283
  author { login }
3284
+ submittedAt
3458
3285
  comments {
3459
3286
  totalCount
3460
3287
  }
@@ -3548,7 +3375,7 @@ function collectReviewPrs(requested, reviewed) {
3548
3375
  }
3549
3376
  return [...prByKey.values()];
3550
3377
  }
3551
- function classifyPr(pr, details, user) {
3378
+ function classifyPr(pr, details, user, countedStates) {
3552
3379
  if (!details) {
3553
3380
  return [{ kind: "inaccessible", pr }];
3554
3381
  }
@@ -3556,7 +3383,7 @@ function classifyPr(pr, details, user) {
3556
3383
  (node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
3557
3384
  );
3558
3385
  const reviews = details.reviews.nodes.flatMap(
3559
- (node) => node?.author?.login === user && node.submittedAt ? [{ at: new Date(node.submittedAt), state: node.state }] : []
3386
+ (node) => node?.author?.login === user && node.submittedAt && (countedStates === void 0 || countedStates.has(node.state)) ? [{ at: new Date(node.submittedAt), state: node.state }] : []
3560
3387
  );
3561
3388
  if (requests.length === 0) {
3562
3389
  if (reviews.length === 0) {
@@ -3575,12 +3402,13 @@ function classifyPr(pr, details, user) {
3575
3402
  })
3576
3403
  ].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
3577
3404
  const results = [];
3405
+ const lines = details.additions + details.deletions;
3578
3406
  let openedAt = null;
3579
3407
  for (const event of events) {
3580
3408
  if (event.isRequest) {
3581
3409
  openedAt ??= event.at;
3582
3410
  } else if (openedAt !== null) {
3583
- results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state });
3411
+ results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state, lines });
3584
3412
  openedAt = null;
3585
3413
  }
3586
3414
  }
@@ -3618,7 +3446,9 @@ async function fetchReviewRaw(prs, user, onProgress, options = {}) {
3618
3446
  });
3619
3447
  cache.save();
3620
3448
  return {
3621
- results: prs.flatMap((pr) => classifyPr(pr, found.get(prKey(pr.repo, pr.number)) ?? null, user)),
3449
+ results: prs.flatMap(
3450
+ (pr) => classifyPr(pr, found.get(prKey(pr.repo, pr.number)) ?? null, user, options.countedStates)
3451
+ ),
3622
3452
  cacheHits
3623
3453
  };
3624
3454
  }
@@ -3649,7 +3479,14 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3649
3479
  if (details) {
3650
3480
  const discussion = details.comments.totalCount;
3651
3481
  const review = details.reviews.nodes.reduce((sum, node) => sum + (node?.comments.totalCount ?? 0), 0);
3652
- const reviewers = details.reviews.nodes.flatMap((node) => node?.author == null ? [] : [node.author.login]);
3482
+ const reviews = details.reviews.nodes.flatMap(
3483
+ (node) => node === null ? [] : [
3484
+ {
3485
+ login: node.author?.login ?? null,
3486
+ submittedAt: node.submittedAt === null ? null : new Date(node.submittedAt)
3487
+ }
3488
+ ]
3489
+ );
3653
3490
  sizes.push({
3654
3491
  pr,
3655
3492
  files: details.changedFiles,
@@ -3659,147 +3496,689 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3659
3496
  mergedAt: details.mergedAt === null ? null : new Date(details.mergedAt),
3660
3497
  closedAt: details.closedAt === null ? null : new Date(details.closedAt),
3661
3498
  comments: { discussion, review, total: discussion + review },
3662
- reviewers
3499
+ reviews
3663
3500
  });
3664
3501
  }
3665
- }
3666
- return { sizes, cacheHits };
3502
+ }
3503
+ return { sizes, cacheHits };
3504
+ }
3505
+
3506
+ // src/tui/data/load.ts
3507
+ function reviveRawData(data) {
3508
+ return {
3509
+ ...data,
3510
+ fetchedAt: new Date(data.fetchedAt),
3511
+ reviewResults: data.reviewResults.map((result) => {
3512
+ const pr = { ...result.pr, createdAt: new Date(result.pr.createdAt) };
3513
+ if (result.kind === "pending") {
3514
+ return { ...result, pr, requestedAt: new Date(result.requestedAt) };
3515
+ }
3516
+ if (result.kind === "reviewed") {
3517
+ return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
3518
+ }
3519
+ if (result.kind === "unrequested") {
3520
+ return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
3521
+ }
3522
+ return { ...result, pr };
3523
+ }),
3524
+ sizes: data.sizes.map((entry) => {
3525
+ return {
3526
+ ...entry,
3527
+ pr: { ...entry.pr, createdAt: new Date(entry.pr.createdAt) },
3528
+ mergedAt: entry.mergedAt === null ? null : new Date(entry.mergedAt),
3529
+ closedAt: entry.closedAt === null ? null : new Date(entry.closedAt),
3530
+ reviews: entry.reviews.map((review) => {
3531
+ return { ...review, submittedAt: review.submittedAt === null ? null : new Date(review.submittedAt) };
3532
+ })
3533
+ };
3534
+ })
3535
+ };
3536
+ }
3537
+ function loadSnapshot(options) {
3538
+ const stored = readCacheFile("snapshot");
3539
+ if (stored?.params === void 0) {
3540
+ return null;
3541
+ }
3542
+ const { params } = stored;
3543
+ if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts || params.reviewTypes !== options.reviewTypes) {
3544
+ return null;
3545
+ }
3546
+ const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3547
+ if (sinceIso < stored.data.sinceIso) {
3548
+ return null;
3549
+ }
3550
+ const data = reviveRawData(stored.data);
3551
+ if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3552
+ return null;
3553
+ }
3554
+ const hasMissingVerdict = data.reviewResults.some((result) => {
3555
+ return result.kind === "reviewed" && result.verdict === void 0;
3556
+ });
3557
+ if (hasMissingVerdict) {
3558
+ return null;
3559
+ }
3560
+ if (sinceIso === data.sinceIso) {
3561
+ return data;
3562
+ }
3563
+ if (data.reviewResults.some((result) => Number.isNaN(result.pr.createdAt.getTime()))) {
3564
+ return null;
3565
+ }
3566
+ const cutoff = new Date(sinceIso);
3567
+ const reviewResults = data.reviewResults.filter((result) => result.pr.createdAt >= cutoff);
3568
+ const sizes = data.sizes.filter((entry) => entry.pr.createdAt >= cutoff);
3569
+ return {
3570
+ ...data,
3571
+ sinceIso,
3572
+ reviewResults,
3573
+ sizes,
3574
+ /**
3575
+ * The creation dates of inaccessible authored PRs are unknown, so the
3576
+ * inaccessible count carries over unchanged.
3577
+ */
3578
+ authoredTotal: sizes.length + (data.authoredTotal - data.sizes.length)
3579
+ };
3580
+ }
3581
+ function saveSnapshot(options, data) {
3582
+ const params = {
3583
+ since: options.since,
3584
+ repos: options.repos,
3585
+ user: options.user,
3586
+ includeDrafts: options.includeDrafts,
3587
+ reviewTypes: options.reviewTypes
3588
+ };
3589
+ writeCacheFile("snapshot", { params, data });
3590
+ }
3591
+ async function loadData(options, onPhase, { bypassCache = false } = {}) {
3592
+ const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3593
+ onPhase({ phase: "search" });
3594
+ const repoNames = options.repos.split(",").map((name) => name.trim()).filter((name) => name !== "");
3595
+ const [user, repos] = await Promise.all([resolveUser(options.user, bypassCache), resolveRepos(repoNames)]);
3596
+ const includeDrafts = options.includeDrafts;
3597
+ const [requested, reviewed, authored] = await Promise.all([
3598
+ searchPrs({ user, sinceIso, repos, includeDrafts, mode: "requested" }),
3599
+ searchPrs({ user, sinceIso, repos, includeDrafts, mode: "reviewed" }),
3600
+ searchPrs({ user, sinceIso, repos, includeDrafts, mode: "authored" })
3601
+ ]);
3602
+ const reviewPrs = collectReviewPrs(requested, reviewed);
3603
+ const authoredPrs = collectAuthoredPrs(authored);
3604
+ const progress = { review: 0, sizes: 0 };
3605
+ const total = reviewPrs.length + authoredPrs.length;
3606
+ const report = () => {
3607
+ onPhase({ phase: "details", done: progress.review + progress.sizes, total });
3608
+ };
3609
+ report();
3610
+ const countedStates = options.reviewTypes === "" ? void 0 : parseReviewTypes(options.reviewTypes);
3611
+ const [review, size] = await Promise.all([
3612
+ reviewPrs.length === 0 ? { results: [], cacheHits: 0 } : fetchReviewRaw(
3613
+ reviewPrs,
3614
+ user,
3615
+ (done) => {
3616
+ progress.review = done;
3617
+ report();
3618
+ },
3619
+ { bypassCache, countedStates }
3620
+ ),
3621
+ authoredPrs.length === 0 ? { sizes: [], cacheHits: 0 } : fetchSizeRaw(
3622
+ authoredPrs,
3623
+ (done) => {
3624
+ progress.sizes = done;
3625
+ report();
3626
+ },
3627
+ { bypassCache }
3628
+ )
3629
+ ]);
3630
+ const data = {
3631
+ user,
3632
+ sinceIso,
3633
+ repos,
3634
+ reviewResults: review.results,
3635
+ sizes: size.sizes,
3636
+ authoredTotal: authoredPrs.length,
3637
+ searchCapped: requested.length >= 1e3 || reviewed.length >= 1e3 || authored.length >= 1e3,
3638
+ fetchedAt: /* @__PURE__ */ new Date()
3639
+ };
3640
+ saveSnapshot(options, data);
3641
+ return data;
3642
+ }
3643
+
3644
+ // src/tui/data/export.ts
3645
+ function round(value2) {
3646
+ return Math.round(value2 * 100) / 100;
3647
+ }
3648
+ function summarize(values) {
3649
+ if (values.length === 0) {
3650
+ return null;
3651
+ }
3652
+ const sorted = [...values].toSorted((a, b) => a - b);
3653
+ const sum = values.reduce((total, value2) => total + value2, 0);
3654
+ return {
3655
+ count: values.length,
3656
+ mean: round(sum / values.length),
3657
+ p50: round(percentile(sorted, 50)),
3658
+ p90: round(percentile(sorted, 90)),
3659
+ min: round(sorted[0]),
3660
+ max: round(sorted.at(-1) ?? 0)
3661
+ };
3662
+ }
3663
+ function prRef(pr) {
3664
+ return { repo: pr.repo, number: pr.number, title: pr.title, url: pr.url, state: pr.state };
3665
+ }
3666
+ function hoursToMerge(entry) {
3667
+ if (entry.mergedAt === null) {
3668
+ return null;
3669
+ }
3670
+ return round(durationHours(entry.pr.createdAt, entry.mergedAt));
3671
+ }
3672
+ function hoursToClose(entry) {
3673
+ if (entry.mergedAt !== null || entry.pr.state === "open" || entry.closedAt === null) {
3674
+ return null;
3675
+ }
3676
+ return round(durationHours(entry.pr.createdAt, entry.closedAt));
3677
+ }
3678
+ function buildStatsReport(raw, options) {
3679
+ const timezone = resolveTimezone(options.tz === "" ? void 0 : options.tz);
3680
+ configureTimeMode({
3681
+ business: !options.wallClock,
3682
+ workWindows: parseWorkHours(options.workHours),
3683
+ tz: timezone
3684
+ });
3685
+ const targetHours = options.target === "" ? void 0 : parseTarget(options.target);
3686
+ const targetLabel = targetLabelOf(options.target);
3687
+ const sizeTarget = options.sizeTarget === "" ? void 0 : parseSizeTarget(options.sizeTarget);
3688
+ const review = computeReviewStats(raw.reviewResults, { targetHours, now: raw.fetchedAt });
3689
+ const merge = computeMergeStats(raw.sizes);
3690
+ const reviewers = computeReviewerStats(raw.sizes, raw.user);
3691
+ const firstReview = computeFirstReviewStats(raw.sizes, raw.user, { now: raw.fetchedAt });
3692
+ const comments = computeCommentStats(raw.sizes);
3693
+ const verdictCount = (state) => review.reviewed.filter((entry) => entry.verdict === state).length;
3694
+ const approved = verdictCount("APPROVED");
3695
+ const changesRequested = verdictCount("CHANGES_REQUESTED");
3696
+ const commented = verdictCount("COMMENTED");
3697
+ let target = null;
3698
+ if (targetHours !== void 0 && targetLabel !== void 0) {
3699
+ const inside = review.allHours.filter((hours) => hours <= targetHours).length;
3700
+ target = {
3701
+ label: targetLabel,
3702
+ hours: round(targetHours),
3703
+ inside,
3704
+ over: review.allHours.length - inside,
3705
+ pendingOverdue: review.pending.filter((entry) => entry.hours > targetHours).length
3706
+ };
3707
+ }
3708
+ const sizes = computeSizeStats(raw.sizes, { sizeTarget });
3709
+ const sizeTargetReport = sizes.met === void 0 || sizes.targetLabel === void 0 ? null : { label: sizes.targetLabel, inside: sizes.met, over: raw.sizes.length - sizes.met };
3710
+ return {
3711
+ generatedAt: raw.fetchedAt.toISOString(),
3712
+ user: raw.user,
3713
+ since: raw.sinceIso,
3714
+ repos: raw.repos,
3715
+ searchCapped: raw.searchCapped,
3716
+ options: {
3717
+ workHours: options.workHours,
3718
+ timezone,
3719
+ wallClock: options.wallClock,
3720
+ includeDrafts: options.includeDrafts,
3721
+ reviewTypes: options.reviewTypes === "" ? null : options.reviewTypes,
3722
+ reviewTarget: targetLabel ?? null,
3723
+ sizeTarget: options.sizeTarget === "" ? null : options.sizeTarget
3724
+ },
3725
+ review: {
3726
+ counts: {
3727
+ reviewed: review.reviewed.length,
3728
+ pending: review.pending.length,
3729
+ reviewing: review.reviewing.length,
3730
+ closedUnreviewed: review.expired.length,
3731
+ reviewedUnrequested: review.unrequested.length
3732
+ },
3733
+ reviewTimeHours: summarize(review.allHours),
3734
+ cyclesPerPr: summarize(review.cycles),
3735
+ verdicts: {
3736
+ approved,
3737
+ changesRequested,
3738
+ commented,
3739
+ other: review.reviewed.length - approved - changesRequested - commented
3740
+ },
3741
+ target,
3742
+ byRepo: review.byRepo.map(([repo, hours]) => {
3743
+ return {
3744
+ repo,
3745
+ reviews: hours.length,
3746
+ p50Hours: round(
3747
+ percentile(
3748
+ hours.toSorted((a, b) => a - b),
3749
+ 50
3750
+ )
3751
+ )
3752
+ };
3753
+ }),
3754
+ reviewed: review.reviewed.map((entry) => {
3755
+ return {
3756
+ ...prRef(entry.pr),
3757
+ requestedAt: entry.requestedAt.toISOString(),
3758
+ reviewedAt: entry.reviewedAt.toISOString(),
3759
+ hours: round(entry.hours),
3760
+ verdict: entry.verdict,
3761
+ totalLines: entry.lines
3762
+ };
3763
+ }),
3764
+ pending: review.pending.map((entry) => {
3765
+ return { ...prRef(entry.pr), requestedAt: entry.requestedAt.toISOString(), hours: round(entry.hours) };
3766
+ }),
3767
+ reviewing: review.reviewing.map((entry) => {
3768
+ return { ...prRef(entry.pr), reviewedAt: entry.reviewedAt.toISOString(), hours: round(entry.hours) };
3769
+ })
3770
+ },
3771
+ authored: {
3772
+ counts: {
3773
+ total: raw.authoredTotal,
3774
+ analyzed: raw.sizes.length,
3775
+ inaccessible: raw.authoredTotal - raw.sizes.length,
3776
+ open: merge.open.length,
3777
+ merged: merge.merged.length,
3778
+ closedUnmerged: merge.closed.length
3779
+ },
3780
+ sizeLines: summarize(raw.sizes.map((entry) => entry.total)),
3781
+ mergeTimeHours: summarize(merge.allHours),
3782
+ firstReviewHours: summarize(firstReview.allHours),
3783
+ awaitingFirstReview: firstReview.awaiting.length,
3784
+ sizeTarget: sizeTargetReport,
3785
+ reviewers: {
3786
+ leaderboard: reviewers.leaderboard,
3787
+ mergedReviewed: reviewers.mergedReviewed,
3788
+ mergedUnreviewed: reviewers.mergedUnreviewed
3789
+ },
3790
+ prs: raw.sizes.map((entry) => {
3791
+ const first = firstReviewOf(entry, raw.user);
3792
+ return {
3793
+ ...prRef(entry.pr),
3794
+ createdAt: entry.pr.createdAt.toISOString(),
3795
+ mergedAt: entry.mergedAt === null ? null : entry.mergedAt.toISOString(),
3796
+ closedAt: entry.closedAt === null ? null : entry.closedAt.toISOString(),
3797
+ firstReviewAt: first === null ? null : first.reviewedAt.toISOString(),
3798
+ hoursToMerge: hoursToMerge(entry),
3799
+ hoursToClose: hoursToClose(entry),
3800
+ hoursToFirstReview: first === null ? null : round(first.hours),
3801
+ files: entry.files,
3802
+ additions: entry.additions,
3803
+ deletions: entry.deletions,
3804
+ totalLines: entry.total,
3805
+ comments: entry.comments,
3806
+ reviewers: entry.reviews.flatMap((review2) => review2.login === null ? [] : [review2.login])
3807
+ };
3808
+ })
3809
+ },
3810
+ comments: {
3811
+ received: comments.totals.reduce((sum, total) => sum + total, 0),
3812
+ prsWithoutComments: comments.uncommented,
3813
+ perPr: summarize(comments.totals)
3814
+ }
3815
+ };
3816
+ }
3817
+ function exportFile() {
3818
+ return join4(process.cwd(), "pr-stats.json");
3819
+ }
3820
+ function exportStatsFile(raw, options) {
3821
+ writeFileAtomic(exportFile(), `${JSON.stringify(buildStatsReport(raw, options), null, 2)}
3822
+ `);
3823
+ }
3824
+ function reportPhase(phase) {
3825
+ if (!process.stderr.isTTY) {
3826
+ return;
3827
+ }
3828
+ const text = phase.phase === "search" ? "searching PRs..." : `fetching PR details ${phase.done}/${phase.total}`;
3829
+ process.stderr.write(`\r\x1B[K${text}`);
3830
+ }
3831
+ function clearPhase() {
3832
+ if (process.stderr.isTTY) {
3833
+ process.stderr.write("\r\x1B[K");
3834
+ }
3835
+ }
3836
+ async function runJsonStats(options, bypassCache) {
3837
+ try {
3838
+ const raw = await loadData(options, reportPhase, { bypassCache });
3839
+ clearPhase();
3840
+ await new Promise((resolve2) => {
3841
+ process.stdout.write(`${JSON.stringify(buildStatsReport(raw, options), null, 2)}
3842
+ `, () => {
3843
+ resolve2();
3844
+ });
3845
+ });
3846
+ process.exit(0);
3847
+ } catch (error) {
3848
+ clearPhase();
3849
+ if (error instanceof CliError) {
3850
+ fail(error.message);
3851
+ }
3852
+ throw error;
3853
+ }
3854
+ }
3855
+
3856
+ // src/tui/state/settings.ts
3857
+ var SETTINGS = [
3858
+ {
3859
+ key: "noCache",
3860
+ section: "Cache",
3861
+ label: "Disable cache",
3862
+ hint: "refetch everything on every load instead of reading cached PRs \xB7 fresh results still update the cache"
3863
+ },
3864
+ {
3865
+ key: "clearCache",
3866
+ section: "Cache",
3867
+ label: "Clear cache",
3868
+ hint: "deletes the cached PR data at this path, so the next reload refetches everything"
3869
+ },
3870
+ {
3871
+ key: "copyLinks",
3872
+ section: "Links",
3873
+ label: "Copy instead of open",
3874
+ hint: "enter and a click on a PR reference copy its link to the clipboard instead of opening the browser"
3875
+ },
3876
+ {
3877
+ key: "themePreset",
3878
+ section: "Theme",
3879
+ label: "Theme",
3880
+ hint: "built-in color theme \xB7 editing colors adds a custom theme to the cycle"
3881
+ },
3882
+ {
3883
+ key: "themeColors",
3884
+ section: "Theme",
3885
+ label: "Edit colors",
3886
+ hint: "opens the color list, where every theme color takes a hex value \xB7 edits become the custom theme"
3887
+ },
3888
+ {
3889
+ key: "resetSettings",
3890
+ section: "Settings",
3891
+ label: "Reset settings",
3892
+ hint: "deletes the settings file with the saved cache setting and theme, so future runs start from the defaults"
3893
+ },
3894
+ {
3895
+ key: "exportJson",
3896
+ section: "Export",
3897
+ label: "Export stats as JSON",
3898
+ hint: "writes the loaded stats to this file, the same report the --json flag prints \xB7 overwrites a previous export"
3899
+ }
3900
+ ];
3901
+ var THEME_COLORS = [
3902
+ { key: "bg", hint: "background of the screen and the dialogs" },
3903
+ { key: "border", hint: "borders, rules, and the dialog frames" },
3904
+ { key: "text", hint: "primary text" },
3905
+ { key: "muted", hint: "secondary text like values and chart labels" },
3906
+ { key: "dim", hint: "faint text like axis scales and the footer hints" },
3907
+ { key: "accent", hint: "highlights like medians, headings, and the selection marker" },
3908
+ { key: "selectedBg", hint: "background of the selected row" },
3909
+ { key: "inputBg", hint: "background of text inputs" },
3910
+ { key: "inputFocusedBg", hint: "background of the focused text input" },
3911
+ { key: "warn", hint: "notices like the reload reminder and confirm prompts" },
3912
+ { key: "error", hint: "error messages and failed loads" },
3913
+ { key: "success", hint: "the checkmark on the copied-link notice" },
3914
+ { key: "chartBar", hint: "histogram and volume bars" },
3915
+ { key: "chartLine", hint: "trend lines and the scatter dots" },
3916
+ { key: "chartDim", hint: "de-emphasized chart parts like the over-target share" },
3917
+ { key: "heat", hint: "the four heatmap colors from cool to hot, separated by spaces" }
3918
+ ];
3919
+ var CACHE_MESSAGES = {
3920
+ confirm: { text: "press enter again to clear the cache \xB7 esc cancels", warn: true },
3921
+ cleared: { text: "cache cleared \xB7 the next reload refetches everything" },
3922
+ disabled: { text: "the cache is disabled for this session \xB7 nothing to clear" },
3923
+ saved: { text: "saved to settings.json \xB7 future runs start with this setting" },
3924
+ notSaved: { text: "the cache is disabled for this session \xB7 setting not saved" },
3925
+ resetConfirm: { text: "press enter again to delete settings.json \xB7 esc cancels", warn: true },
3926
+ resetDone: { text: "settings.json deleted \xB7 future runs start from the defaults" },
3927
+ resetDisabled: { text: "the cache is disabled for this session \xB7 nothing to reset" },
3928
+ exported: { text: "stats exported \xB7 the same report prints to stdout with the --json flag" },
3929
+ exportFailed: { text: "the export failed \xB7 the file could not be written", warn: true },
3930
+ exportNoData: { text: "no loaded stats to export yet \xB7 export again once the load finishes", warn: true }
3931
+ };
3932
+
3933
+ // src/tui/components/SettingsModal.tsx
3934
+ import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
3935
+ var SECTIONS2 = [];
3936
+ for (const setting of SETTINGS) {
3937
+ const last = SECTIONS2.at(-1);
3938
+ if (last?.title === setting.section) {
3939
+ last.settings.push(setting);
3940
+ } else {
3941
+ SECTIONS2.push({ title: setting.section, settings: [setting] });
3942
+ }
3943
+ }
3944
+ function SettingsModal({
3945
+ selected,
3946
+ cacheAction,
3947
+ noCache: noCache2,
3948
+ copyLinks: copyLinks2,
3949
+ preset
3950
+ }) {
3951
+ const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
3952
+ return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
3953
+ SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
3954
+ /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
3955
+ section.settings.map((setting) => {
3956
+ const isSelected = SETTINGS.indexOf(setting) === selected;
3957
+ return /* @__PURE__ */ jsx12(ModalRow, { label: setting.label, isSelected, children: /* @__PURE__ */ jsx12(
3958
+ SettingValue,
3959
+ {
3960
+ setting,
3961
+ isSelected,
3962
+ cacheAction,
3963
+ noCache: noCache2,
3964
+ copyLinks: copyLinks2,
3965
+ preset
3966
+ }
3967
+ ) }, setting.key);
3968
+ })
3969
+ ] }, section.title)),
3970
+ /* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg: message?.warn ? theme.warn : theme.muted, marginLeft: 2, marginRight: 2, children: message?.text ?? SETTINGS[selected].hint })
3971
+ ] });
3972
+ }
3973
+ function SettingValue({
3974
+ setting,
3975
+ isSelected,
3976
+ cacheAction,
3977
+ noCache: noCache2,
3978
+ copyLinks: copyLinks2,
3979
+ preset
3980
+ }) {
3981
+ switch (setting.key) {
3982
+ case "noCache": {
3983
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: noCache2 ? "yes" : "no", isSelected });
3984
+ }
3985
+ case "clearCache": {
3986
+ return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
3987
+ }
3988
+ case "copyLinks": {
3989
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
3990
+ }
3991
+ case "themePreset": {
3992
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: preset, isSelected });
3993
+ }
3994
+ case "themeColors": {
3995
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: ["chartDim", "chartBar", "chartLine", "accent"].map((key) => /* @__PURE__ */ jsx12("span", { fg: theme[key], children: "\u2588\u2588" }, key)) });
3996
+ }
3997
+ case "resetSettings": {
3998
+ return /* @__PURE__ */ jsx12(PathValue, { path: settingsFile(), confirming: cacheAction === "resetConfirm", isSelected });
3999
+ }
4000
+ case "exportJson": {
4001
+ return /* @__PURE__ */ jsx12(PathValue, { path: exportFile(), confirming: false, isSelected });
4002
+ }
4003
+ default: {
4004
+ return null;
4005
+ }
4006
+ }
4007
+ }
4008
+ function ToggleValue({ value: value2, isSelected }) {
4009
+ if (isSelected) {
4010
+ return /* @__PURE__ */ jsxs11("text", { wrapMode: "none", children: [
4011
+ /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: "\u2039 " }),
4012
+ /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }),
4013
+ /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: " \u203A" })
4014
+ ] });
4015
+ }
4016
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
4017
+ }
4018
+ function PathValue({ path, confirming, isSelected }) {
4019
+ if (confirming) {
4020
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
4021
+ }
4022
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: path.replace(homedir2(), "~") });
4023
+ }
4024
+
4025
+ // src/tui/components/ThemeModal.tsx
4026
+ import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs12 } from "@opentui/react/jsx-runtime";
4027
+ function ThemeModal({
4028
+ selected,
4029
+ editing,
4030
+ error,
4031
+ cacheAction,
4032
+ overrides,
4033
+ onDraft,
4034
+ onSubmit
4035
+ }) {
4036
+ const spec = THEME_COLORS[selected];
4037
+ const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
4038
+ const hint = spec.key in overrides ? `${spec.hint} \xB7 custom color, an empty value restores the theme` : spec.hint;
4039
+ return /* @__PURE__ */ jsxs12(ModalFrame, { title: "Theme colors", children: [
4040
+ /* @__PURE__ */ jsx13("box", { flexDirection: "column", marginBottom: 1, children: THEME_COLORS.map((color, index) => /* @__PURE__ */ jsx13(
4041
+ ColorRow,
4042
+ {
4043
+ color,
4044
+ isSelected: index === selected,
4045
+ isEditing: index === selected && editing,
4046
+ isCustom: color.key in overrides,
4047
+ onDraft,
4048
+ onSubmit
4049
+ },
4050
+ color.key
4051
+ )) }),
4052
+ /* @__PURE__ */ jsx13("text", { wrapMode: "word", height: 2, fg: error !== null ? theme.error : theme.muted, marginLeft: 2, marginRight: 2, children: error ?? message?.text ?? hint })
4053
+ ] });
4054
+ }
4055
+ function ColorRow({
4056
+ color,
4057
+ isSelected,
4058
+ isEditing,
4059
+ isCustom,
4060
+ onDraft,
4061
+ onSubmit
4062
+ }) {
4063
+ const value2 = themeColorText(color.key);
4064
+ const swatch = theme[color.key];
4065
+ return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(
4066
+ "input",
4067
+ {
4068
+ width: 36,
4069
+ value: value2,
4070
+ focused: true,
4071
+ onInput: (next) => {
4072
+ onDraft(String(next));
4073
+ },
4074
+ onSubmit: () => {
4075
+ onSubmit();
4076
+ },
4077
+ backgroundColor: theme.inputBg,
4078
+ focusedBackgroundColor: theme.inputFocusedBg,
4079
+ textColor: theme.text,
4080
+ cursorColor: theme.accent
4081
+ }
4082
+ ) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
4083
+ Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
4084
+ /* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
4085
+ /* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
4086
+ /* @__PURE__ */ jsx13("span", { fg: swatch[2], children: "\u2588" }),
4087
+ /* @__PURE__ */ jsx13("span", { fg: swatch[3], children: "\u2588" })
4088
+ ] }) : /* @__PURE__ */ jsx13("span", { fg: swatch, children: "\u2588\u2588" }),
4089
+ /* @__PURE__ */ jsx13("span", { children: " " }),
4090
+ isSelected ? /* @__PURE__ */ jsx13("b", { fg: theme.text, children: value2 }) : /* @__PURE__ */ jsx13("span", { fg: isCustom ? theme.text : theme.muted, children: value2 })
4091
+ ] }) });
3667
4092
  }
3668
4093
 
3669
- // src/tui/data/load.ts
3670
- function reviveRawData(data) {
3671
- return {
3672
- ...data,
3673
- fetchedAt: new Date(data.fetchedAt),
3674
- reviewResults: data.reviewResults.map((result) => {
3675
- const pr = { ...result.pr, createdAt: new Date(result.pr.createdAt) };
3676
- if (result.kind === "pending") {
3677
- return { ...result, pr, requestedAt: new Date(result.requestedAt) };
3678
- }
3679
- if (result.kind === "reviewed") {
3680
- return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
3681
- }
3682
- if (result.kind === "unrequested") {
3683
- return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
4094
+ // src/tui/components/Modals.tsx
4095
+ import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
4096
+ function Modals({
4097
+ ui,
4098
+ options,
4099
+ saved: saved2,
4100
+ noCache: noCache2,
4101
+ copyLinks: copyLinks2,
4102
+ themeState,
4103
+ onDraft,
4104
+ onSubmitField,
4105
+ onSubmitThemeColor,
4106
+ onToggleReviewType
4107
+ }) {
4108
+ if (ui.modal === "options") {
4109
+ return /* @__PURE__ */ jsx14(
4110
+ OptionsModal,
4111
+ {
4112
+ options,
4113
+ saved: saved2,
4114
+ selected: ui.selectedField,
4115
+ editing: ui.editing,
4116
+ fieldError: ui.fieldError,
4117
+ onDraft,
4118
+ onSubmit: onSubmitField,
4119
+ onToggleReviewType
3684
4120
  }
3685
- return { ...result, pr };
3686
- }),
3687
- sizes: data.sizes.map((entry) => {
3688
- return {
3689
- ...entry,
3690
- pr: { ...entry.pr, createdAt: new Date(entry.pr.createdAt) },
3691
- mergedAt: entry.mergedAt === null ? null : new Date(entry.mergedAt),
3692
- closedAt: entry.closedAt === null ? null : new Date(entry.closedAt)
3693
- };
3694
- })
3695
- };
3696
- }
3697
- function loadSnapshot(options) {
3698
- const stored = readCacheFile("snapshot");
3699
- if (stored?.params === void 0) {
3700
- return null;
3701
- }
3702
- const { params } = stored;
3703
- if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts) {
3704
- return null;
3705
- }
3706
- const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3707
- if (sinceIso < stored.data.sinceIso) {
3708
- return null;
3709
- }
3710
- const data = reviveRawData(stored.data);
3711
- if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3712
- return null;
3713
- }
3714
- const hasMissingVerdict = data.reviewResults.some((result) => {
3715
- return result.kind === "reviewed" && result.verdict === void 0;
3716
- });
3717
- if (hasMissingVerdict) {
3718
- return null;
4121
+ );
3719
4122
  }
3720
- if (sinceIso === data.sinceIso) {
3721
- return data;
4123
+ if (ui.modal === "settings") {
4124
+ return /* @__PURE__ */ jsx14(
4125
+ SettingsModal,
4126
+ {
4127
+ selected: ui.selectedSetting,
4128
+ cacheAction: ui.cacheAction,
4129
+ noCache: noCache2,
4130
+ copyLinks: copyLinks2,
4131
+ preset: themeState.preset
4132
+ }
4133
+ );
3722
4134
  }
3723
- if (data.reviewResults.some((result) => Number.isNaN(result.pr.createdAt.getTime()))) {
3724
- return null;
4135
+ if (ui.modal === "theme") {
4136
+ return /* @__PURE__ */ jsx14(
4137
+ ThemeModal,
4138
+ {
4139
+ selected: ui.selectedThemeColor,
4140
+ editing: ui.editing,
4141
+ error: ui.themeColorError,
4142
+ cacheAction: ui.cacheAction,
4143
+ overrides: themeState.preset === "custom" ? themeState.overrides : {},
4144
+ onDraft,
4145
+ onSubmit: onSubmitThemeColor
4146
+ }
4147
+ );
3725
4148
  }
3726
- const cutoff = new Date(sinceIso);
3727
- const reviewResults = data.reviewResults.filter((result) => result.pr.createdAt >= cutoff);
3728
- const sizes = data.sizes.filter((entry) => entry.pr.createdAt >= cutoff);
3729
- return {
3730
- ...data,
3731
- sinceIso,
3732
- reviewResults,
3733
- sizes,
3734
- /**
3735
- * The creation dates of inaccessible authored PRs are unknown, so the
3736
- * inaccessible count carries over unchanged.
3737
- */
3738
- authoredTotal: sizes.length + (data.authoredTotal - data.sizes.length)
3739
- };
3740
- }
3741
- function saveSnapshot(options, data) {
3742
- const params = {
3743
- since: options.since,
3744
- repos: options.repos,
3745
- user: options.user,
3746
- includeDrafts: options.includeDrafts
3747
- };
3748
- writeCacheFile("snapshot", { params, data });
4149
+ return null;
3749
4150
  }
3750
- async function loadData(options, onPhase, { bypassCache = false } = {}) {
3751
- const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3752
- onPhase({ phase: "search" });
3753
- const repoNames = options.repos.split(",").map((name) => name.trim()).filter((name) => name !== "");
3754
- const [user, repos] = await Promise.all([resolveUser(options.user, bypassCache), resolveRepos(repoNames)]);
3755
- const includeDrafts = options.includeDrafts;
3756
- const [requested, reviewed, authored] = await Promise.all([
3757
- searchPrs({ user, sinceIso, repos, includeDrafts, mode: "requested" }),
3758
- searchPrs({ user, sinceIso, repos, includeDrafts, mode: "reviewed" }),
3759
- searchPrs({ user, sinceIso, repos, includeDrafts, mode: "authored" })
3760
- ]);
3761
- const reviewPrs = collectReviewPrs(requested, reviewed);
3762
- const authoredPrs = collectAuthoredPrs(authored);
3763
- const progress = { review: 0, sizes: 0 };
3764
- const total = reviewPrs.length + authoredPrs.length;
3765
- const report = () => {
3766
- onPhase({ phase: "details", done: progress.review + progress.sizes, total });
3767
- };
3768
- report();
3769
- const [review, size] = await Promise.all([
3770
- reviewPrs.length === 0 ? { results: [], cacheHits: 0 } : fetchReviewRaw(
3771
- reviewPrs,
3772
- user,
3773
- (done) => {
3774
- progress.review = done;
3775
- report();
3776
- },
3777
- { bypassCache }
3778
- ),
3779
- authoredPrs.length === 0 ? { sizes: [], cacheHits: 0 } : fetchSizeRaw(
3780
- authoredPrs,
3781
- (done) => {
3782
- progress.sizes = done;
3783
- report();
3784
- },
3785
- { bypassCache }
3786
- )
3787
- ]);
3788
- const data = {
3789
- user,
3790
- sinceIso,
3791
- repos,
3792
- reviewResults: review.results,
3793
- sizes: size.sizes,
3794
- authoredTotal: authoredPrs.length,
3795
- searchCapped: requested.length >= 1e3 || reviewed.length >= 1e3 || authored.length >= 1e3,
3796
- fetchedAt: /* @__PURE__ */ new Date()
3797
- };
3798
- saveSnapshot(options, data);
3799
- return data;
4151
+
4152
+ // src/tui/hooks/useDeferredLoading.ts
4153
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
4154
+ function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
4155
+ const [visible, setVisible] = useState2(isLoading && showDelay === 0);
4156
+ const shownAtRef = useRef3(null);
4157
+ useEffect4(() => {
4158
+ if (isLoading) {
4159
+ const timer2 = setTimeout(() => {
4160
+ shownAtRef.current = Date.now();
4161
+ setVisible(true);
4162
+ }, showDelay);
4163
+ return () => {
4164
+ clearTimeout(timer2);
4165
+ };
4166
+ }
4167
+ const shownAt = shownAtRef.current;
4168
+ const remaining = shownAt === null ? 0 : Math.max(0, shownAt + minDuration - Date.now());
4169
+ const timer = setTimeout(() => {
4170
+ shownAtRef.current = null;
4171
+ setVisible(false);
4172
+ }, remaining);
4173
+ return () => {
4174
+ clearTimeout(timer);
4175
+ };
4176
+ }, [isLoading, showDelay, minDuration]);
4177
+ return visible;
3800
4178
  }
3801
4179
 
3802
4180
  // src/tui/hooks/useLoader.ts
4181
+ import { useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
3803
4182
  function useLoader(options, noCache2, onLoaded) {
3804
4183
  const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
3805
4184
  const [raw, setRaw] = useState3(startupSnapshot);
@@ -4901,6 +5280,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4901
5280
  legend: "reviews in that hour"
4902
5281
  }),
4903
5282
  buildVolumeCard("Reviews completed per week", reviewDates),
5283
+ buildScatterCard({
5284
+ title: "Review time vs size",
5285
+ subtitle: "time to review against lines changed, log scale",
5286
+ points: stats.reviewed.map((entry) => {
5287
+ return { x: entry.lines, y: entry.hours };
5288
+ }),
5289
+ formatX: count,
5290
+ formatY: formatDuration
5291
+ }),
4904
5292
  buildHistogramCard({
4905
5293
  title: "Review cycles per PR",
4906
5294
  subtitle: "completed request \u2192 review rounds per PR",
@@ -5166,6 +5554,7 @@ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
5166
5554
  }
5167
5555
  const stats = computeMergeStats(sizes);
5168
5556
  const reviewers = computeReviewerStats(sizes, raw.user);
5557
+ const firstReview = computeFirstReviewStats(sizes, raw.user, { now: raw.fetchedAt });
5169
5558
  const strip = [
5170
5559
  countCell(sizes.length, "PRs created"),
5171
5560
  countCell(stats.merged.length, "merged"),
@@ -5210,8 +5599,43 @@ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
5210
5599
  expanded
5211
5600
  });
5212
5601
  const expandable = reviewers.leaderboard.length > MAX_BARS;
5602
+ const firstReviewCards = [
5603
+ ...firstReview.received.length === 0 ? [] : [
5604
+ buildHistogramCard({
5605
+ title: "Time to first review",
5606
+ subtitle: "elapsed time, created \u2192 first review received",
5607
+ values: firstReview.allHours,
5608
+ buckets: currentBuckets(),
5609
+ format: formatDuration
5610
+ }),
5611
+ buildTrendCard({
5612
+ title: "First review time trend",
5613
+ entries: firstReview.received.map((result) => {
5614
+ return { date: result.reviewedAt, value: result.hours };
5615
+ }),
5616
+ format: formatDuration,
5617
+ floor: 1 / 60
5618
+ })
5619
+ ],
5620
+ ...firstReview.awaiting.length === 0 ? [] : [
5621
+ buildHistogramCard({
5622
+ title: "Awaiting first review",
5623
+ subtitle: "how long open unreviewed PRs have waited",
5624
+ values: firstReview.awaiting.map((result) => result.hours),
5625
+ buckets: currentBuckets(),
5626
+ format: formatDuration
5627
+ })
5628
+ ]
5629
+ ];
5213
5630
  if (stats.merged.length === 0) {
5214
- return { empty: null, ...base, strip, cards: reviewerCard === null ? [] : [reviewerCard], lists, expandable };
5631
+ return {
5632
+ empty: null,
5633
+ ...base,
5634
+ strip,
5635
+ cards: [...firstReviewCards, ...reviewerCard === null ? [] : [reviewerCard]],
5636
+ lists,
5637
+ expandable
5638
+ };
5215
5639
  }
5216
5640
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
5217
5641
  const headline = [
@@ -5239,6 +5663,7 @@ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
5239
5663
  format: formatDuration,
5240
5664
  floor: 1 / 60
5241
5665
  }),
5666
+ ...firstReviewCards,
5242
5667
  buildHeatmapCard({
5243
5668
  title: "When your PRs merge",
5244
5669
  subtitle: "PRs merged, weekday \xD7 hour, local time",
@@ -5418,6 +5843,10 @@ function handleOptionsModalKey(key, context) {
5418
5843
  context.setOptions((previous) => {
5419
5844
  return { ...previous, [field.key]: !previous[field.key] };
5420
5845
  });
5846
+ } else if (field.kind === "multi") {
5847
+ if (key.name === "return" || key.name === "space") {
5848
+ context.dispatchUi({ type: "editStarted" });
5849
+ }
5421
5850
  } else if (key.name === "return") {
5422
5851
  context.beginEdit(String(context.options[field.key]));
5423
5852
  }
@@ -5508,6 +5937,22 @@ function handleSettingsModalKey(key, context) {
5508
5937
  }
5509
5938
  break;
5510
5939
  }
5940
+ case "exportJson": {
5941
+ if (key.name !== "return") {
5942
+ break;
5943
+ }
5944
+ if (context.raw === null) {
5945
+ context.dispatchUi({ type: "cacheActionReported", action: "exportNoData" });
5946
+ break;
5947
+ }
5948
+ try {
5949
+ exportStatsFile(context.raw, context.options);
5950
+ context.dispatchUi({ type: "cacheActionReported", action: "exported" });
5951
+ } catch {
5952
+ context.dispatchUi({ type: "cacheActionReported", action: "exportFailed" });
5953
+ }
5954
+ break;
5955
+ }
5511
5956
  }
5512
5957
  break;
5513
5958
  }
@@ -5976,6 +6421,7 @@ function App({
5976
6421
  themeState,
5977
6422
  options,
5978
6423
  views,
6424
+ raw,
5979
6425
  dispatchUi,
5980
6426
  dispatchBrowse,
5981
6427
  setOptions,
@@ -6048,7 +6494,12 @@ function App({
6048
6494
  draftRef.current = value2;
6049
6495
  },
6050
6496
  onSubmitField: commitField,
6051
- onSubmitThemeColor: commitThemeColor
6497
+ onSubmitThemeColor: commitThemeColor,
6498
+ onToggleReviewType: (type) => {
6499
+ setOptions((previous) => {
6500
+ return { ...previous, reviewTypes: toggleReviewType(previous.reviewTypes, type) };
6501
+ });
6502
+ }
6052
6503
  }
6053
6504
  )
6054
6505
  ] });
@@ -6076,7 +6527,8 @@ function bootstrap() {
6076
6527
  workHours: values["work-hours"],
6077
6528
  tz: values.tz ?? "",
6078
6529
  wallClock: values["wall-clock"],
6079
- includeDrafts: values["include-drafts"]
6530
+ includeDrafts: values["include-drafts"],
6531
+ reviewTypes: values["review-types"] ?? ""
6080
6532
  };
6081
6533
  for (const field of FIELDS) {
6082
6534
  const value2 = initial2[field.key];
@@ -6084,7 +6536,14 @@ function bootstrap() {
6084
6536
  validateField(field.key, value2);
6085
6537
  }
6086
6538
  }
6087
- return { initial: initial2, saved: saved2, noCache: values["no-cache"], copyLinks: settings.copyLinks === true, theme: theme3 };
6539
+ return {
6540
+ initial: initial2,
6541
+ saved: saved2,
6542
+ noCache: values["no-cache"],
6543
+ copyLinks: settings.copyLinks === true,
6544
+ theme: theme3,
6545
+ json: values.json
6546
+ };
6088
6547
  } catch (error) {
6089
6548
  if (error instanceof CliError) {
6090
6549
  fail(error.message);
@@ -6095,7 +6554,10 @@ function bootstrap() {
6095
6554
 
6096
6555
  // src/tui/main.tsx
6097
6556
  import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
6098
- var { initial, saved, noCache, copyLinks, theme: theme2 } = bootstrap();
6557
+ var { initial, saved, noCache, copyLinks, theme: theme2, json } = bootstrap();
6558
+ if (json) {
6559
+ await runJsonStats(initial, noCache);
6560
+ }
6099
6561
  var exitSignals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGBUS"];
6100
6562
  var renderer = await createCliRenderer({ exitOnCtrlC: true, exitSignals });
6101
6563
  for (const signal of ["SIGTERM", "SIGHUP"]) {