@d3lm/pr-stats 0.2.9 → 0.2.11
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 +4 -1
- package/dist/tui-app.mjs +201 -34
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ pr-stats
|
|
|
27
27
|
|
|
28
28
|
Without flags, it looks at PRs from the last 90 days across all repositories you can access. The tabs hold these views.
|
|
29
29
|
|
|
30
|
-
- The queue tab, which it opens on, shows two lists. The open PRs awaiting your review come first with how long each has been waiting, and below them sit the open PRs you already reviewed or commented on with how long ago that was, so a PR stays visible until it merges or closes. A fresh review request moves a PR from the reviewing list back into the awaiting one.
|
|
30
|
+
- The queue tab, which it opens on, shows two lists. The open PRs awaiting your review come first with how long each has been waiting, and below them sit the open PRs you already reviewed or commented on with how long ago that was, so a PR stays visible until it merges or closes. A fresh review request moves a PR from the reviewing list back into the awaiting one. The `--review-types` flag narrows what counts as a review to a subset of `approve`, `comment`, and `request-changes`, so with `--review-types approve,request-changes` a comment alone no longer answers a request and the PR stays in the awaiting list.
|
|
31
31
|
- The Your PRs tab has two sub-tabs, which the `t` key switches. The first lists your own authored PRs that are still open with their age and size. The second reports how your authored PRs got created, merged, and closed, telling a merge apart from a close without one. It charts time-to-merge percentiles, a histogram and trend, a merge-time heatmap, and a scatter of merge time against PR size. It also measures how long your PRs wait for their first review from someone else, with a histogram and trend of the time from creation to that review and a histogram of how long the open PRs still without one have waited. It also plots a merge-rate trend over the concluded PRs, cumulative created and merged lines whose gap shows the backlog, weekly created and merged volumes, an outcome gauge, and the most recently merged and closed PRs. A reviewer leaderboard ranks who reviews your PRs by distinct PRs reviewed, and a review-coverage gauge counts the merged PRs that never received a review. Your own replies to review threads never count as a review for any of them.
|
|
32
32
|
- The time-to-review report pairs its histogram, trend, heatmap, and weekly volume with a scatter of review time against PR size, the completed review cycles per PR, and a verdict gauge splitting approvals from change requests. It also shows the age of the requests still waiting on you, how old PRs already were when the request reached you, and an off-hours gauge that splits weekdays into work hours and after hours once `--work-hours` is set. On the aggregate view it additionally compares median review times by repo.
|
|
33
33
|
- The PR size report carries the same histogram, trend, heatmap, and weekly volume for PR sizes and adds a net-lines trend that sums additions minus deletions per week.
|
|
@@ -49,6 +49,9 @@ pr-stats --work-hours 9-17 --target 1d
|
|
|
49
49
|
# Report how many authored PRs stayed under 400 changed lines
|
|
50
50
|
pr-stats --size-target 400
|
|
51
51
|
|
|
52
|
+
# Count only approvals and change requests as reviews
|
|
53
|
+
pr-stats --review-types approve,request-changes
|
|
54
|
+
|
|
52
55
|
# Authenticate with an access token instead of the gh CLI
|
|
53
56
|
pr-stats --token your-access-token
|
|
54
57
|
```
|
package/dist/tui-app.mjs
CHANGED
|
@@ -2361,6 +2361,12 @@ var OPTIONS = [
|
|
|
2361
2361
|
placeholder: "<value>",
|
|
2362
2362
|
help: "Report how many reviews finished within this time. Accepts hours (`24h` or plain `24`), minutes (`90m`), or days (`2d`). A day means 24 counted hours, or one working day when --work-hours is set. Open PRs that have already waited longer than the target count as misses."
|
|
2363
2363
|
},
|
|
2364
|
+
{
|
|
2365
|
+
name: "target-percentile",
|
|
2366
|
+
type: "string",
|
|
2367
|
+
placeholder: "<p>",
|
|
2368
|
+
help: "Check the --target against this percentile of your review times. Accepts a whole percentile from 1 to 100, with an optional p prefix (`90` or `p90`). The default is `90`, so the review headline reports whether your p90 review time meets the target."
|
|
2369
|
+
},
|
|
2364
2370
|
{
|
|
2365
2371
|
name: "size-target",
|
|
2366
2372
|
type: "string",
|
|
@@ -2393,6 +2399,12 @@ var OPTIONS = [
|
|
|
2393
2399
|
default: false,
|
|
2394
2400
|
help: "Include PRs that are currently drafts. Excluded by default."
|
|
2395
2401
|
},
|
|
2402
|
+
{
|
|
2403
|
+
name: "review-types",
|
|
2404
|
+
type: "string",
|
|
2405
|
+
placeholder: "<list>",
|
|
2406
|
+
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."
|
|
2407
|
+
},
|
|
2396
2408
|
{
|
|
2397
2409
|
name: "no-cache",
|
|
2398
2410
|
type: "boolean",
|
|
@@ -2519,6 +2531,15 @@ function parseTarget(input) {
|
|
|
2519
2531
|
}
|
|
2520
2532
|
return amount;
|
|
2521
2533
|
}
|
|
2534
|
+
var DEFAULT_TARGET_PERCENTILE = 90;
|
|
2535
|
+
function parseTargetPercentile(input) {
|
|
2536
|
+
const match = /^p?(\d{1,3})$/i.exec(input);
|
|
2537
|
+
const value2 = match === null ? Number.NaN : Number(match[1]);
|
|
2538
|
+
if (!Number.isInteger(value2) || value2 < 1 || value2 > 100) {
|
|
2539
|
+
throw new CliError(`invalid --target-percentile value "${input}", use a percentile from 1 to 100 like 90 or p90`);
|
|
2540
|
+
}
|
|
2541
|
+
return value2;
|
|
2542
|
+
}
|
|
2522
2543
|
function parseSizeTarget(input) {
|
|
2523
2544
|
const target = {};
|
|
2524
2545
|
for (const part of input.split(",")) {
|
|
@@ -2534,6 +2555,22 @@ function parseSizeTarget(input) {
|
|
|
2534
2555
|
}
|
|
2535
2556
|
return target;
|
|
2536
2557
|
}
|
|
2558
|
+
var REVIEW_TYPES = /* @__PURE__ */ new Map([
|
|
2559
|
+
["approve", "APPROVED"],
|
|
2560
|
+
["comment", "COMMENTED"],
|
|
2561
|
+
["request-changes", "CHANGES_REQUESTED"]
|
|
2562
|
+
]);
|
|
2563
|
+
function parseReviewTypes(input) {
|
|
2564
|
+
const states = /* @__PURE__ */ new Set();
|
|
2565
|
+
for (const part of input.split(",")) {
|
|
2566
|
+
const state = REVIEW_TYPES.get(part.trim().toLowerCase());
|
|
2567
|
+
if (state === void 0) {
|
|
2568
|
+
throw new CliError(`invalid --review-types value "${part.trim()}", use approve, comment, or request-changes`);
|
|
2569
|
+
}
|
|
2570
|
+
states.add(state);
|
|
2571
|
+
}
|
|
2572
|
+
return states;
|
|
2573
|
+
}
|
|
2537
2574
|
function toMinutesOfDay(hourText, minuteText, meridiem) {
|
|
2538
2575
|
const minute = Number(minuteText ?? 0);
|
|
2539
2576
|
if (minute > 59) {
|
|
@@ -2615,6 +2652,13 @@ var FIELDS = [
|
|
|
2615
2652
|
kind: "toggle",
|
|
2616
2653
|
fetch: true
|
|
2617
2654
|
},
|
|
2655
|
+
{
|
|
2656
|
+
key: "reviewTypes",
|
|
2657
|
+
label: "Review types",
|
|
2658
|
+
hint: "enter opens the type list, checked types count as a review",
|
|
2659
|
+
kind: "multi",
|
|
2660
|
+
fetch: true
|
|
2661
|
+
},
|
|
2618
2662
|
{
|
|
2619
2663
|
key: "target",
|
|
2620
2664
|
label: "Review target",
|
|
@@ -2622,6 +2666,13 @@ var FIELDS = [
|
|
|
2622
2666
|
kind: "text",
|
|
2623
2667
|
fetch: false
|
|
2624
2668
|
},
|
|
2669
|
+
{
|
|
2670
|
+
key: "targetPercentile",
|
|
2671
|
+
label: "Target percentile",
|
|
2672
|
+
hint: "the percentile the review target checks, like 90 or p99, empty means p90",
|
|
2673
|
+
kind: "text",
|
|
2674
|
+
fetch: false
|
|
2675
|
+
},
|
|
2625
2676
|
{
|
|
2626
2677
|
key: "sizeTarget",
|
|
2627
2678
|
label: "Size target",
|
|
@@ -2651,6 +2702,26 @@ var FIELDS = [
|
|
|
2651
2702
|
fetch: false
|
|
2652
2703
|
}
|
|
2653
2704
|
];
|
|
2705
|
+
var REVIEW_TYPE_CHOICES = ["approve", "comment", "request-changes"];
|
|
2706
|
+
function checkedReviewTypes(value2) {
|
|
2707
|
+
if (value2.trim() === "") {
|
|
2708
|
+
return new Set(REVIEW_TYPE_CHOICES);
|
|
2709
|
+
}
|
|
2710
|
+
return new Set(value2.split(",").map((part) => part.trim().toLowerCase()));
|
|
2711
|
+
}
|
|
2712
|
+
function toggleReviewType(value2, type) {
|
|
2713
|
+
const checked = checkedReviewTypes(value2);
|
|
2714
|
+
if (checked.has(type)) {
|
|
2715
|
+
checked.delete(type);
|
|
2716
|
+
} else {
|
|
2717
|
+
checked.add(type);
|
|
2718
|
+
}
|
|
2719
|
+
const next = REVIEW_TYPE_CHOICES.filter((choice) => checked.has(choice));
|
|
2720
|
+
if (next.length === 0) {
|
|
2721
|
+
return value2;
|
|
2722
|
+
}
|
|
2723
|
+
return next.length === REVIEW_TYPE_CHOICES.length ? "" : next.join(",");
|
|
2724
|
+
}
|
|
2654
2725
|
function validateField(key, value2) {
|
|
2655
2726
|
if (value2 === "" && key !== "workHours" && key !== "since") {
|
|
2656
2727
|
return;
|
|
@@ -2664,6 +2735,10 @@ function validateField(key, value2) {
|
|
|
2664
2735
|
parseTarget(value2);
|
|
2665
2736
|
break;
|
|
2666
2737
|
}
|
|
2738
|
+
case "targetPercentile": {
|
|
2739
|
+
parseTargetPercentile(value2);
|
|
2740
|
+
break;
|
|
2741
|
+
}
|
|
2667
2742
|
case "sizeTarget": {
|
|
2668
2743
|
parseSizeTarget(value2);
|
|
2669
2744
|
break;
|
|
@@ -2676,6 +2751,10 @@ function validateField(key, value2) {
|
|
|
2676
2751
|
resolveTimezone(value2);
|
|
2677
2752
|
break;
|
|
2678
2753
|
}
|
|
2754
|
+
case "reviewTypes": {
|
|
2755
|
+
parseReviewTypes(value2);
|
|
2756
|
+
break;
|
|
2757
|
+
}
|
|
2679
2758
|
}
|
|
2680
2759
|
}
|
|
2681
2760
|
function sameOptions(a, b) {
|
|
@@ -2687,6 +2766,8 @@ function readSavedOptions() {
|
|
|
2687
2766
|
return null;
|
|
2688
2767
|
}
|
|
2689
2768
|
const record = value2;
|
|
2769
|
+
record.reviewTypes ??= "";
|
|
2770
|
+
record.targetPercentile ??= "";
|
|
2690
2771
|
const options = {};
|
|
2691
2772
|
for (const field of FIELDS) {
|
|
2692
2773
|
const raw = record[field.key];
|
|
@@ -2730,6 +2811,9 @@ function applySavedOptions(values, explicit) {
|
|
|
2730
2811
|
if (!explicit.has("target") && saved2.target !== "") {
|
|
2731
2812
|
values.target = saved2.target;
|
|
2732
2813
|
}
|
|
2814
|
+
if (!explicit.has("target-percentile") && saved2.targetPercentile !== "") {
|
|
2815
|
+
values["target-percentile"] = saved2.targetPercentile;
|
|
2816
|
+
}
|
|
2733
2817
|
if (!explicit.has("size-target") && saved2.sizeTarget !== "") {
|
|
2734
2818
|
values["size-target"] = saved2.sizeTarget;
|
|
2735
2819
|
}
|
|
@@ -2745,10 +2829,13 @@ function applySavedOptions(values, explicit) {
|
|
|
2745
2829
|
if (!explicit.has("include-drafts")) {
|
|
2746
2830
|
values["include-drafts"] = saved2.includeDrafts;
|
|
2747
2831
|
}
|
|
2832
|
+
if (!explicit.has("review-types") && saved2.reviewTypes !== "") {
|
|
2833
|
+
values["review-types"] = saved2.reviewTypes;
|
|
2834
|
+
}
|
|
2748
2835
|
return saved2;
|
|
2749
2836
|
}
|
|
2750
2837
|
function fetchParamsKey(options) {
|
|
2751
|
-
return JSON.stringify([options.since, options.repos, options.user, options.includeDrafts]);
|
|
2838
|
+
return JSON.stringify([options.since, options.repos, options.user, options.includeDrafts, options.reviewTypes]);
|
|
2752
2839
|
}
|
|
2753
2840
|
function targetLabelOf(target) {
|
|
2754
2841
|
if (target === "") {
|
|
@@ -2823,8 +2910,10 @@ var EMPTY_PLACEHOLDERS = {
|
|
|
2823
2910
|
repos: "(all accessible)",
|
|
2824
2911
|
user: "(authenticated user)",
|
|
2825
2912
|
target: "(none)",
|
|
2913
|
+
targetPercentile: "(p90)",
|
|
2826
2914
|
sizeTarget: "(none)",
|
|
2827
|
-
tz: "(system)"
|
|
2915
|
+
tz: "(system)",
|
|
2916
|
+
reviewTypes: "(every type)"
|
|
2828
2917
|
};
|
|
2829
2918
|
var SECTIONS = [
|
|
2830
2919
|
{ title: "Data", fields: FIELDS.filter((field) => field.fetch) },
|
|
@@ -2837,7 +2926,8 @@ function OptionsModal({
|
|
|
2837
2926
|
editing,
|
|
2838
2927
|
fieldError,
|
|
2839
2928
|
onDraft,
|
|
2840
|
-
onSubmit
|
|
2929
|
+
onSubmit,
|
|
2930
|
+
onToggleReviewType
|
|
2841
2931
|
}) {
|
|
2842
2932
|
const savedState = savedLine(options, saved2);
|
|
2843
2933
|
return /* @__PURE__ */ jsxs10(ModalFrame, { title: "Options", children: [
|
|
@@ -2851,9 +2941,10 @@ function OptionsModal({
|
|
|
2851
2941
|
field,
|
|
2852
2942
|
options,
|
|
2853
2943
|
isSelected: index === selected,
|
|
2854
|
-
isEditing: index === selected && editing && field.kind
|
|
2944
|
+
isEditing: index === selected && editing && field.kind !== "toggle",
|
|
2855
2945
|
onDraft,
|
|
2856
|
-
onSubmit
|
|
2946
|
+
onSubmit,
|
|
2947
|
+
onToggleType: onToggleReviewType
|
|
2857
2948
|
},
|
|
2858
2949
|
field.key
|
|
2859
2950
|
);
|
|
@@ -2897,10 +2988,17 @@ function FieldRow({
|
|
|
2897
2988
|
isSelected,
|
|
2898
2989
|
isEditing,
|
|
2899
2990
|
onDraft,
|
|
2900
|
-
onSubmit
|
|
2991
|
+
onSubmit,
|
|
2992
|
+
onToggleType
|
|
2901
2993
|
}) {
|
|
2902
2994
|
const value2 = displayValue(field.key, options[field.key]);
|
|
2903
2995
|
const valueColor = value2.isPlaceholder ? isSelected ? theme.muted : theme.dim : theme.muted;
|
|
2996
|
+
if (isEditing && field.kind === "multi") {
|
|
2997
|
+
return /* @__PURE__ */ jsxs10("box", { flexDirection: "column", children: [
|
|
2998
|
+
/* @__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 }) }) }),
|
|
2999
|
+
/* @__PURE__ */ jsx11(TypeChecklist, { value: String(options[field.key]), onToggle: onToggleType })
|
|
3000
|
+
] });
|
|
3001
|
+
}
|
|
2904
3002
|
return /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: isEditing ? /* @__PURE__ */ jsx11(
|
|
2905
3003
|
"input",
|
|
2906
3004
|
{
|
|
@@ -2924,6 +3022,35 @@ function FieldRow({
|
|
|
2924
3022
|
/* @__PURE__ */ jsx11("span", { fg: theme.muted, children: " \u203A" })
|
|
2925
3023
|
] }) : 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 }) });
|
|
2926
3024
|
}
|
|
3025
|
+
function TypeChecklist({ value: value2, onToggle }) {
|
|
3026
|
+
const checked = checkedReviewTypes(value2);
|
|
3027
|
+
return /* @__PURE__ */ jsx11("box", { alignSelf: "flex-end", width: 30, height: REVIEW_TYPE_CHOICES.length, marginRight: 2, children: /* @__PURE__ */ jsx11(
|
|
3028
|
+
"select",
|
|
3029
|
+
{
|
|
3030
|
+
focused: true,
|
|
3031
|
+
width: "100%",
|
|
3032
|
+
height: REVIEW_TYPE_CHOICES.length,
|
|
3033
|
+
options: REVIEW_TYPE_CHOICES.map((choice) => {
|
|
3034
|
+
return { name: `[${checked.has(choice) ? "x" : " "}] ${choice}`, description: "", value: choice };
|
|
3035
|
+
}),
|
|
3036
|
+
showDescription: false,
|
|
3037
|
+
showScrollIndicator: false,
|
|
3038
|
+
showSelectionIndicator: false,
|
|
3039
|
+
wrapSelection: true,
|
|
3040
|
+
backgroundColor: theme.inputBg,
|
|
3041
|
+
focusedBackgroundColor: theme.inputFocusedBg,
|
|
3042
|
+
textColor: theme.muted,
|
|
3043
|
+
focusedTextColor: theme.muted,
|
|
3044
|
+
selectedBackgroundColor: theme.selectedBg,
|
|
3045
|
+
selectedTextColor: theme.text,
|
|
3046
|
+
onSelect: (_index, option) => {
|
|
3047
|
+
if (option !== null) {
|
|
3048
|
+
onToggle(option.value);
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
) });
|
|
3053
|
+
}
|
|
2927
3054
|
|
|
2928
3055
|
// src/tui/components/SettingsModal.tsx
|
|
2929
3056
|
import { homedir as homedir2 } from "node:os";
|
|
@@ -3279,7 +3406,7 @@ function collectReviewPrs(requested, reviewed) {
|
|
|
3279
3406
|
}
|
|
3280
3407
|
return [...prByKey.values()];
|
|
3281
3408
|
}
|
|
3282
|
-
function classifyPr(pr, details, user) {
|
|
3409
|
+
function classifyPr(pr, details, user, countedStates) {
|
|
3283
3410
|
if (!details) {
|
|
3284
3411
|
return [{ kind: "inaccessible", pr }];
|
|
3285
3412
|
}
|
|
@@ -3287,7 +3414,7 @@ function classifyPr(pr, details, user) {
|
|
|
3287
3414
|
(node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
|
|
3288
3415
|
);
|
|
3289
3416
|
const reviews = details.reviews.nodes.flatMap(
|
|
3290
|
-
(node) => node?.author?.login === user && node.submittedAt ? [{ at: new Date(node.submittedAt), state: node.state }] : []
|
|
3417
|
+
(node) => node?.author?.login === user && node.submittedAt && (countedStates === void 0 || countedStates.has(node.state)) ? [{ at: new Date(node.submittedAt), state: node.state }] : []
|
|
3291
3418
|
);
|
|
3292
3419
|
if (requests.length === 0) {
|
|
3293
3420
|
if (reviews.length === 0) {
|
|
@@ -3350,7 +3477,9 @@ async function fetchReviewRaw(prs, user, onProgress, options = {}) {
|
|
|
3350
3477
|
});
|
|
3351
3478
|
cache.save();
|
|
3352
3479
|
return {
|
|
3353
|
-
results: prs.flatMap(
|
|
3480
|
+
results: prs.flatMap(
|
|
3481
|
+
(pr) => classifyPr(pr, found.get(prKey(pr.repo, pr.number)) ?? null, user, options.countedStates)
|
|
3482
|
+
),
|
|
3354
3483
|
cacheHits
|
|
3355
3484
|
};
|
|
3356
3485
|
}
|
|
@@ -3442,7 +3571,7 @@ function loadSnapshot(options) {
|
|
|
3442
3571
|
return null;
|
|
3443
3572
|
}
|
|
3444
3573
|
const { params } = stored;
|
|
3445
|
-
if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts) {
|
|
3574
|
+
if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts || params.reviewTypes !== options.reviewTypes) {
|
|
3446
3575
|
return null;
|
|
3447
3576
|
}
|
|
3448
3577
|
const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
|
|
@@ -3485,7 +3614,8 @@ function saveSnapshot(options, data) {
|
|
|
3485
3614
|
since: options.since,
|
|
3486
3615
|
repos: options.repos,
|
|
3487
3616
|
user: options.user,
|
|
3488
|
-
includeDrafts: options.includeDrafts
|
|
3617
|
+
includeDrafts: options.includeDrafts,
|
|
3618
|
+
reviewTypes: options.reviewTypes
|
|
3489
3619
|
};
|
|
3490
3620
|
writeCacheFile("snapshot", { params, data });
|
|
3491
3621
|
}
|
|
@@ -3508,6 +3638,7 @@ async function loadData(options, onPhase, { bypassCache = false } = {}) {
|
|
|
3508
3638
|
onPhase({ phase: "details", done: progress.review + progress.sizes, total });
|
|
3509
3639
|
};
|
|
3510
3640
|
report();
|
|
3641
|
+
const countedStates = options.reviewTypes === "" ? void 0 : parseReviewTypes(options.reviewTypes);
|
|
3511
3642
|
const [review, size] = await Promise.all([
|
|
3512
3643
|
reviewPrs.length === 0 ? { results: [], cacheHits: 0 } : fetchReviewRaw(
|
|
3513
3644
|
reviewPrs,
|
|
@@ -3516,7 +3647,7 @@ async function loadData(options, onPhase, { bypassCache = false } = {}) {
|
|
|
3516
3647
|
progress.review = done;
|
|
3517
3648
|
report();
|
|
3518
3649
|
},
|
|
3519
|
-
{ bypassCache }
|
|
3650
|
+
{ bypassCache, countedStates }
|
|
3520
3651
|
),
|
|
3521
3652
|
authoredPrs.length === 0 ? { sizes: [], cacheHits: 0 } : fetchSizeRaw(
|
|
3522
3653
|
authoredPrs,
|
|
@@ -3618,6 +3749,7 @@ function buildStatsReport(raw, options) {
|
|
|
3618
3749
|
timezone,
|
|
3619
3750
|
wallClock: options.wallClock,
|
|
3620
3751
|
includeDrafts: options.includeDrafts,
|
|
3752
|
+
reviewTypes: options.reviewTypes === "" ? null : options.reviewTypes,
|
|
3621
3753
|
reviewTarget: targetLabel ?? null,
|
|
3622
3754
|
sizeTarget: options.sizeTarget === "" ? null : options.sizeTarget
|
|
3623
3755
|
},
|
|
@@ -4001,7 +4133,8 @@ function Modals({
|
|
|
4001
4133
|
themeState,
|
|
4002
4134
|
onDraft,
|
|
4003
4135
|
onSubmitField,
|
|
4004
|
-
onSubmitThemeColor
|
|
4136
|
+
onSubmitThemeColor,
|
|
4137
|
+
onToggleReviewType
|
|
4005
4138
|
}) {
|
|
4006
4139
|
if (ui.modal === "options") {
|
|
4007
4140
|
return /* @__PURE__ */ jsx14(
|
|
@@ -4013,7 +4146,8 @@ function Modals({
|
|
|
4013
4146
|
editing: ui.editing,
|
|
4014
4147
|
fieldError: ui.fieldError,
|
|
4015
4148
|
onDraft,
|
|
4016
|
-
onSubmit: onSubmitField
|
|
4149
|
+
onSubmit: onSubmitField,
|
|
4150
|
+
onToggleReviewType
|
|
4017
4151
|
}
|
|
4018
4152
|
);
|
|
4019
4153
|
}
|
|
@@ -5067,9 +5201,17 @@ function buildVerdictCard(reviewed) {
|
|
|
5067
5201
|
})
|
|
5068
5202
|
});
|
|
5069
5203
|
}
|
|
5070
|
-
function
|
|
5204
|
+
function targetStatus(sorted, target) {
|
|
5205
|
+
const margin = target.hours - percentile(sorted, target.percentile);
|
|
5206
|
+
if (margin < 0) {
|
|
5207
|
+
return { text: ` ${formatDuration(-margin)} over the ${target.label} target`, fg: theme.error };
|
|
5208
|
+
}
|
|
5209
|
+
const lead = margin === 0 ? "at" : `${formatDuration(margin)} under`;
|
|
5210
|
+
return { text: ` ${lead} the ${target.label} target`, fg: theme.success };
|
|
5211
|
+
}
|
|
5212
|
+
function buildReviewView(raw, target, repo = null, width = 100, expanded = false) {
|
|
5071
5213
|
const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
|
|
5072
|
-
const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
|
|
5214
|
+
const stats = computeReviewStats(results, { targetHours: target?.hours, now: raw.fetchedAt });
|
|
5073
5215
|
const strip = [
|
|
5074
5216
|
countCell(stats.reviewed.length, "reviewed on request"),
|
|
5075
5217
|
countCell(stats.pending.length, "awaiting you", true),
|
|
@@ -5090,9 +5232,9 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5090
5232
|
return { empty: "No reviewed or review-requested PRs found.", ...base, lists: [] };
|
|
5091
5233
|
}
|
|
5092
5234
|
const lists = [];
|
|
5093
|
-
if (
|
|
5235
|
+
if (target !== void 0 && stats.misses.length > 0) {
|
|
5094
5236
|
lists.push({
|
|
5095
|
-
title: `Reviews that missed the <= ${
|
|
5237
|
+
title: `Reviews that missed the <= ${target.label} target`,
|
|
5096
5238
|
rows: toPrRows(stats.misses, stats.misses.map(durationLead))
|
|
5097
5239
|
});
|
|
5098
5240
|
}
|
|
@@ -5108,11 +5250,19 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5108
5250
|
}
|
|
5109
5251
|
const sorted = [...stats.allHours].toSorted((a, b) => a - b);
|
|
5110
5252
|
const total = raw.reviewResults.filter((result) => result.kind === "reviewed").length;
|
|
5253
|
+
const secondPercentile = target === void 0 || target.percentile === 50 ? 90 : target.percentile;
|
|
5254
|
+
const percentileColor = (percent) => {
|
|
5255
|
+
if (target === void 0 || percent !== target.percentile) {
|
|
5256
|
+
return theme.accent;
|
|
5257
|
+
}
|
|
5258
|
+
return percentile(sorted, target.percentile) <= target.hours ? theme.success : theme.error;
|
|
5259
|
+
};
|
|
5111
5260
|
const headline = [
|
|
5112
5261
|
{ text: "p50 ", fg: theme.muted },
|
|
5113
|
-
{ text: formatDuration(percentile(sorted, 50)), fg:
|
|
5114
|
-
{ text:
|
|
5115
|
-
{ text: formatDuration(percentile(sorted,
|
|
5262
|
+
{ text: formatDuration(percentile(sorted, 50)), fg: percentileColor(50) },
|
|
5263
|
+
{ text: ` p${secondPercentile} `, fg: theme.muted },
|
|
5264
|
+
{ text: formatDuration(percentile(sorted, secondPercentile)), fg: percentileColor(secondPercentile) },
|
|
5265
|
+
...target === void 0 ? [] : [targetStatus(sorted, target)],
|
|
5116
5266
|
{ text: ` ${stats.reviewed.length} of ${total} reviews`, fg: theme.muted }
|
|
5117
5267
|
];
|
|
5118
5268
|
const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
|
|
@@ -5121,15 +5271,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5121
5271
|
(entry) => durationHours(entry.pr.createdAt, entry.requestedAt)
|
|
5122
5272
|
);
|
|
5123
5273
|
let serviceCard = null;
|
|
5124
|
-
if (
|
|
5125
|
-
const inside = stats.allHours.filter((value2) => value2 <=
|
|
5126
|
-
const overdue = stats.pending.filter((entry) => entry.hours >
|
|
5274
|
+
if (target !== void 0) {
|
|
5275
|
+
const inside = stats.allHours.filter((value2) => value2 <= target.hours).length;
|
|
5276
|
+
const overdue = stats.pending.filter((entry) => entry.hours > target.hours).length;
|
|
5127
5277
|
serviceCard = buildGaugeCard({
|
|
5128
5278
|
title: "Service level",
|
|
5129
|
-
subtitle: `reviewed within ${
|
|
5279
|
+
subtitle: `reviewed within ${target.label}`,
|
|
5130
5280
|
rows: [
|
|
5131
|
-
{ label: `inside ${
|
|
5132
|
-
{ label: `over ${
|
|
5281
|
+
{ label: `inside ${target.label}`, count: inside, color: theme.accent },
|
|
5282
|
+
{ label: `over ${target.label}`, count: stats.allHours.length - inside, color: theme.chartDim },
|
|
5133
5283
|
...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
|
|
5134
5284
|
]
|
|
5135
5285
|
});
|
|
@@ -5151,6 +5301,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5151
5301
|
expanded
|
|
5152
5302
|
}) : null;
|
|
5153
5303
|
const cards = [
|
|
5304
|
+
...serviceCard === null ? [] : [serviceCard],
|
|
5154
5305
|
buildHistogramCard({
|
|
5155
5306
|
title: "Time to review",
|
|
5156
5307
|
subtitle: "elapsed time, request \u2192 review",
|
|
@@ -5203,7 +5354,6 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5203
5354
|
buildVerdictCard(stats.reviewed),
|
|
5204
5355
|
...pendingCard === null ? [] : [pendingCard],
|
|
5205
5356
|
buildOffHoursCard("reviews submitted, local time", reviewDates),
|
|
5206
|
-
...serviceCard === null ? [] : [serviceCard],
|
|
5207
5357
|
...byRepoCard === null ? [] : [byRepoCard]
|
|
5208
5358
|
];
|
|
5209
5359
|
const distribution = buildDistribution({
|
|
@@ -5657,7 +5807,12 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
|
|
|
5657
5807
|
if (!raw) {
|
|
5658
5808
|
return null;
|
|
5659
5809
|
}
|
|
5660
|
-
const
|
|
5810
|
+
const targetLabel = targetLabelOf(options.target);
|
|
5811
|
+
const reviewTarget = targetLabel === void 0 ? void 0 : {
|
|
5812
|
+
hours: parseTarget(options.target),
|
|
5813
|
+
label: targetLabel,
|
|
5814
|
+
percentile: options.targetPercentile === "" ? DEFAULT_TARGET_PERCENTILE : parseTargetPercentile(options.targetPercentile)
|
|
5815
|
+
};
|
|
5661
5816
|
const sizeTarget = options.sizeTarget === "" ? void 0 : parseSizeTarget(options.sizeTarget);
|
|
5662
5817
|
const pendingRepos = buildPendingRepoOptions(raw);
|
|
5663
5818
|
const openRepos = buildOpenRepoOptions(raw);
|
|
@@ -5671,7 +5826,7 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
|
|
|
5671
5826
|
const reviewScope = resolveScope(scopes.review, reviewRepos);
|
|
5672
5827
|
const sizeScope = resolveScope(scopes.size, sizeRepos);
|
|
5673
5828
|
const commentScope = resolveScope(scopes.comment, commentRepos);
|
|
5674
|
-
const review = reviewScope.view === "detail" ? buildReviewView(raw,
|
|
5829
|
+
const review = reviewScope.view === "detail" ? buildReviewView(raw, reviewTarget, reviewScope.repo, width, expanded.review) : null;
|
|
5675
5830
|
return {
|
|
5676
5831
|
pendingRepos,
|
|
5677
5832
|
openRepos,
|
|
@@ -5698,6 +5853,7 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
|
|
|
5698
5853
|
options.tz,
|
|
5699
5854
|
options.wallClock,
|
|
5700
5855
|
options.target,
|
|
5856
|
+
options.targetPercentile,
|
|
5701
5857
|
options.sizeTarget,
|
|
5702
5858
|
width,
|
|
5703
5859
|
scopes.pending,
|
|
@@ -5740,6 +5896,10 @@ function handleOptionsModalKey(key, context) {
|
|
|
5740
5896
|
context.setOptions((previous) => {
|
|
5741
5897
|
return { ...previous, [field.key]: !previous[field.key] };
|
|
5742
5898
|
});
|
|
5899
|
+
} else if (field.kind === "multi") {
|
|
5900
|
+
if (key.name === "return" || key.name === "space") {
|
|
5901
|
+
context.dispatchUi({ type: "editStarted" });
|
|
5902
|
+
}
|
|
5743
5903
|
} else if (key.name === "return") {
|
|
5744
5904
|
context.beginEdit(String(context.options[field.key]));
|
|
5745
5905
|
}
|
|
@@ -5892,7 +6052,7 @@ function queueTabOf(context) {
|
|
|
5892
6052
|
}
|
|
5893
6053
|
function handleQueueKey(key, context) {
|
|
5894
6054
|
const { key: tab, view, repos, scope } = queueTabOf(context);
|
|
5895
|
-
if (scope
|
|
6055
|
+
if (scope?.view === "list") {
|
|
5896
6056
|
switch (key.name) {
|
|
5897
6057
|
case "up":
|
|
5898
6058
|
case "k": {
|
|
@@ -5984,7 +6144,7 @@ function statsTabOf(context) {
|
|
|
5984
6144
|
}
|
|
5985
6145
|
function handleStatsKey(key, context) {
|
|
5986
6146
|
const { key: tab, repos, scope, view } = statsTabOf(context);
|
|
5987
|
-
if (scope
|
|
6147
|
+
if (scope?.view === "list") {
|
|
5988
6148
|
switch (key.name) {
|
|
5989
6149
|
case "up":
|
|
5990
6150
|
case "k": {
|
|
@@ -6387,7 +6547,12 @@ function App({
|
|
|
6387
6547
|
draftRef.current = value2;
|
|
6388
6548
|
},
|
|
6389
6549
|
onSubmitField: commitField,
|
|
6390
|
-
onSubmitThemeColor: commitThemeColor
|
|
6550
|
+
onSubmitThemeColor: commitThemeColor,
|
|
6551
|
+
onToggleReviewType: (type) => {
|
|
6552
|
+
setOptions((previous) => {
|
|
6553
|
+
return { ...previous, reviewTypes: toggleReviewType(previous.reviewTypes, type) };
|
|
6554
|
+
});
|
|
6555
|
+
}
|
|
6391
6556
|
}
|
|
6392
6557
|
)
|
|
6393
6558
|
] });
|
|
@@ -6411,11 +6576,13 @@ function bootstrap() {
|
|
|
6411
6576
|
repos: values.repo.join(","),
|
|
6412
6577
|
user: values.user ?? "",
|
|
6413
6578
|
target: values.target ?? "",
|
|
6579
|
+
targetPercentile: values["target-percentile"] ?? "",
|
|
6414
6580
|
sizeTarget: values["size-target"] ?? "",
|
|
6415
6581
|
workHours: values["work-hours"],
|
|
6416
6582
|
tz: values.tz ?? "",
|
|
6417
6583
|
wallClock: values["wall-clock"],
|
|
6418
|
-
includeDrafts: values["include-drafts"]
|
|
6584
|
+
includeDrafts: values["include-drafts"],
|
|
6585
|
+
reviewTypes: values["review-types"] ?? ""
|
|
6419
6586
|
};
|
|
6420
6587
|
for (const field of FIELDS) {
|
|
6421
6588
|
const value2 = initial2[field.key];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3lm/pr-stats",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "GitHub PR stats in an interactive terminal UI, via the gh CLI or an access token",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Dominic Elm",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"prepublishOnly": "pnpm build"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@d3lm/lint-preset": "^1.
|
|
46
|
+
"@d3lm/lint-preset": "^1.2.0",
|
|
47
47
|
"@opentui/core": "^0.5.8",
|
|
48
48
|
"@opentui/react": "^0.5.8",
|
|
49
49
|
"asciichart": "^1.5.25",
|