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