@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.
- package/README.md +14 -2
- package/dist/tui-app.mjs +880 -531
- 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;
|
|
@@ -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.
|
|
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/
|
|
2894
|
-
|
|
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/
|
|
2962
|
-
import {
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
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
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
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
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
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
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
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
|
-
|
|
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
|
|
3044
|
-
if (
|
|
3045
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
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
|
-
|
|
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 (
|
|
3721
|
-
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
|
+
);
|
|
3722
4031
|
}
|
|
3723
|
-
if (
|
|
3724
|
-
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
|
+
);
|
|
3725
4045
|
}
|
|
3726
|
-
|
|
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
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
const [
|
|
3755
|
-
const
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
(
|
|
3774
|
-
|
|
3775
|
-
|
|
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 {
|
|
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 {
|
|
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"]) {
|