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