@d3lm/pr-stats 0.2.8 → 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 +14 -2
  2. package/dist/tui-app.mjs +880 -531
  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) },
@@ -2367,6 +2399,12 @@ var OPTIONS = [
2367
2399
  default: false,
2368
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."
2369
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
+ },
2370
2408
  {
2371
2409
  name: "debug",
2372
2410
  type: "string",
@@ -2890,410 +2928,100 @@ function FieldRow({
2890
2928
  // src/tui/components/SettingsModal.tsx
2891
2929
  import { homedir as homedir2 } from "node:os";
2892
2930
 
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
- };
2931
+ // src/tui/data/export.ts
2932
+ import { join as join4 } from "node:path";
2960
2933
 
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] });
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;
2970
2956
  }
2957
+ throw new CliError(`--debug path "${input}" does not exist`);
2971
2958
  }
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
- ] });
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;
3000
2967
  }
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;
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");
3030
2978
  }
2979
+ const stderr = execError.stderr?.toString().trim();
2980
+ throw new CliError(`gh ${args.slice(0, 2).join(" ")} failed${stderr ? `
2981
+ ${stderr}` : ""}`);
3031
2982
  }
3032
2983
  }
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
- ] });
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})`);
3040
3000
  }
3041
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
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
+ );
3008
+ }
3009
+ return await response.json().catch(() => null);
3042
3010
  }
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" }) });
3011
+ async function runGraphql(query) {
3012
+ if (token) {
3013
+ const result = await api("/graphql", {
3014
+ method: "POST",
3015
+ body: { query }
3016
+ });
3017
+ if (!result.data) {
3018
+ const message = result.errors?.[0]?.message;
3019
+ throw new CliError(`GitHub GraphQL query failed${message ? ` (${message})` : ""}`);
3020
+ }
3021
+ return result.data;
3046
3022
  }
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}` : ""}`);
3254
- }
3255
- }
3256
- async function api(path, { method = "GET", body } = {}) {
3257
- let response;
3258
- try {
3259
- response = await fetch(`${API_BASE}${path}`, {
3260
- method,
3261
- headers: {
3262
- Authorization: `Bearer ${token}`,
3263
- Accept: "application/vnd.github+json",
3264
- "User-Agent": "pr-stats",
3265
- ...body === void 0 ? {} : { "Content-Type": "application/json" }
3266
- },
3267
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
3268
- });
3269
- } catch (error) {
3270
- const failure = error;
3271
- throw new CliError(`cannot reach ${API_BASE} (${failure.cause?.message ?? failure.message})`);
3272
- }
3273
- if (!response.ok) {
3274
- const payload = await response.json().catch(() => null);
3275
- const message = payload?.message ?? "";
3276
- const endpoint = path.split("?")[0];
3277
- throw new CliError(
3278
- `GitHub API ${method} ${endpoint} failed with ${response.status}${message ? ` (${message})` : ""}`
3279
- );
3280
- }
3281
- return await response.json().catch(() => null);
3282
- }
3283
- async function runGraphql(query) {
3284
- if (token) {
3285
- const result = await api("/graphql", {
3286
- method: "POST",
3287
- body: { query }
3288
- });
3289
- if (!result.data) {
3290
- const message = result.errors?.[0]?.message;
3291
- throw new CliError(`GitHub GraphQL query failed${message ? ` (${message})` : ""}`);
3292
- }
3293
- return result.data;
3294
- }
3295
- const stdout = await gh(["api", "graphql", "-f", `query=${query}`]);
3296
- return JSON.parse(stdout).data;
3023
+ const stdout = await gh(["api", "graphql", "-f", `query=${query}`]);
3024
+ return JSON.parse(stdout).data;
3297
3025
  }
3298
3026
  async function authFingerprint() {
3299
3027
  let credential = token;
@@ -3413,6 +3141,8 @@ async function fetchPrDetails(prs) {
3413
3141
  return `
3414
3142
  pr${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
3415
3143
  pullRequest(number: ${pr.number}) {
3144
+ additions
3145
+ deletions
3416
3146
  timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], first: 100) {
3417
3147
  nodes {
3418
3148
  ... on ReviewRequestedEvent {
@@ -3455,6 +3185,7 @@ async function fetchPrSizes(prs) {
3455
3185
  reviews(first: 100) {
3456
3186
  nodes {
3457
3187
  author { login }
3188
+ submittedAt
3458
3189
  comments {
3459
3190
  totalCount
3460
3191
  }
@@ -3575,12 +3306,13 @@ function classifyPr(pr, details, user) {
3575
3306
  })
3576
3307
  ].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
3577
3308
  const results = [];
3309
+ const lines = details.additions + details.deletions;
3578
3310
  let openedAt = null;
3579
3311
  for (const event of events) {
3580
3312
  if (event.isRequest) {
3581
3313
  openedAt ??= event.at;
3582
3314
  } else if (openedAt !== null) {
3583
- 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 });
3584
3316
  openedAt = null;
3585
3317
  }
3586
3318
  }
@@ -3649,7 +3381,14 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3649
3381
  if (details) {
3650
3382
  const discussion = details.comments.totalCount;
3651
3383
  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]);
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
+ );
3653
3392
  sizes.push({
3654
3393
  pr,
3655
3394
  files: details.changedFiles,
@@ -3659,147 +3398,684 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3659
3398
  mergedAt: details.mergedAt === null ? null : new Date(details.mergedAt),
3660
3399
  closedAt: details.closedAt === null ? null : new Date(details.closedAt),
3661
3400
  comments: { discussion, review, total: discussion + review },
3662
- reviewers
3401
+ reviews
3663
3402
  });
3664
3403
  }
3665
- }
3666
- 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
+ ] }) });
3667
3991
  }
3668
3992
 
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) };
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
3684
4017
  }
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;
4018
+ );
3719
4019
  }
3720
- if (sinceIso === data.sinceIso) {
3721
- 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
+ );
3722
4031
  }
3723
- if (data.reviewResults.some((result) => Number.isNaN(result.pr.createdAt.getTime()))) {
3724
- 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
+ );
3725
4045
  }
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 });
4046
+ return null;
3749
4047
  }
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;
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;
3800
4075
  }
3801
4076
 
3802
4077
  // src/tui/hooks/useLoader.ts
4078
+ import { useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
3803
4079
  function useLoader(options, noCache2, onLoaded) {
3804
4080
  const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
3805
4081
  const [raw, setRaw] = useState3(startupSnapshot);
@@ -4901,6 +5177,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4901
5177
  legend: "reviews in that hour"
4902
5178
  }),
4903
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
+ }),
4904
5189
  buildHistogramCard({
4905
5190
  title: "Review cycles per PR",
4906
5191
  subtitle: "completed request \u2192 review rounds per PR",
@@ -5166,6 +5451,7 @@ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
5166
5451
  }
5167
5452
  const stats = computeMergeStats(sizes);
5168
5453
  const reviewers = computeReviewerStats(sizes, raw.user);
5454
+ const firstReview = computeFirstReviewStats(sizes, raw.user, { now: raw.fetchedAt });
5169
5455
  const strip = [
5170
5456
  countCell(sizes.length, "PRs created"),
5171
5457
  countCell(stats.merged.length, "merged"),
@@ -5210,8 +5496,43 @@ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
5210
5496
  expanded
5211
5497
  });
5212
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
+ ];
5213
5527
  if (stats.merged.length === 0) {
5214
- return { empty: null, ...base, strip, cards: reviewerCard === null ? [] : [reviewerCard], lists, expandable };
5528
+ return {
5529
+ empty: null,
5530
+ ...base,
5531
+ strip,
5532
+ cards: [...firstReviewCards, ...reviewerCard === null ? [] : [reviewerCard]],
5533
+ lists,
5534
+ expandable
5535
+ };
5215
5536
  }
5216
5537
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
5217
5538
  const headline = [
@@ -5239,6 +5560,7 @@ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
5239
5560
  format: formatDuration,
5240
5561
  floor: 1 / 60
5241
5562
  }),
5563
+ ...firstReviewCards,
5242
5564
  buildHeatmapCard({
5243
5565
  title: "When your PRs merge",
5244
5566
  subtitle: "PRs merged, weekday \xD7 hour, local time",
@@ -5508,6 +5830,22 @@ function handleSettingsModalKey(key, context) {
5508
5830
  }
5509
5831
  break;
5510
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
+ }
5511
5849
  }
5512
5850
  break;
5513
5851
  }
@@ -5976,6 +6314,7 @@ function App({
5976
6314
  themeState,
5977
6315
  options,
5978
6316
  views,
6317
+ raw,
5979
6318
  dispatchUi,
5980
6319
  dispatchBrowse,
5981
6320
  setOptions,
@@ -6084,7 +6423,14 @@ function bootstrap() {
6084
6423
  validateField(field.key, value2);
6085
6424
  }
6086
6425
  }
6087
- 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
+ };
6088
6434
  } catch (error) {
6089
6435
  if (error instanceof CliError) {
6090
6436
  fail(error.message);
@@ -6095,7 +6441,10 @@ function bootstrap() {
6095
6441
 
6096
6442
  // src/tui/main.tsx
6097
6443
  import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
6098
- 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
+ }
6099
6448
  var exitSignals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGBUS"];
6100
6449
  var renderer = await createCliRenderer({ exitOnCtrlC: true, exitSignals });
6101
6450
  for (const signal of ["SIGTERM", "SIGHUP"]) {