@d3lm/pr-stats 0.2.9 → 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 +4 -1
- package/dist/tui-app.mjs +129 -16
- package/package.json +1 -1
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
|
@@ -2393,6 +2393,12 @@ var OPTIONS = [
|
|
|
2393
2393
|
default: false,
|
|
2394
2394
|
help: "Include PRs that are currently drafts. Excluded by default."
|
|
2395
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
|
+
},
|
|
2396
2402
|
{
|
|
2397
2403
|
name: "no-cache",
|
|
2398
2404
|
type: "boolean",
|
|
@@ -2534,6 +2540,22 @@ function parseSizeTarget(input) {
|
|
|
2534
2540
|
}
|
|
2535
2541
|
return target;
|
|
2536
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
|
+
}
|
|
2537
2559
|
function toMinutesOfDay(hourText, minuteText, meridiem) {
|
|
2538
2560
|
const minute = Number(minuteText ?? 0);
|
|
2539
2561
|
if (minute > 59) {
|
|
@@ -2615,6 +2637,13 @@ var FIELDS = [
|
|
|
2615
2637
|
kind: "toggle",
|
|
2616
2638
|
fetch: true
|
|
2617
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
|
+
},
|
|
2618
2647
|
{
|
|
2619
2648
|
key: "target",
|
|
2620
2649
|
label: "Review target",
|
|
@@ -2651,6 +2680,26 @@ var FIELDS = [
|
|
|
2651
2680
|
fetch: false
|
|
2652
2681
|
}
|
|
2653
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
|
+
}
|
|
2654
2703
|
function validateField(key, value2) {
|
|
2655
2704
|
if (value2 === "" && key !== "workHours" && key !== "since") {
|
|
2656
2705
|
return;
|
|
@@ -2676,6 +2725,10 @@ function validateField(key, value2) {
|
|
|
2676
2725
|
resolveTimezone(value2);
|
|
2677
2726
|
break;
|
|
2678
2727
|
}
|
|
2728
|
+
case "reviewTypes": {
|
|
2729
|
+
parseReviewTypes(value2);
|
|
2730
|
+
break;
|
|
2731
|
+
}
|
|
2679
2732
|
}
|
|
2680
2733
|
}
|
|
2681
2734
|
function sameOptions(a, b) {
|
|
@@ -2687,6 +2740,7 @@ function readSavedOptions() {
|
|
|
2687
2740
|
return null;
|
|
2688
2741
|
}
|
|
2689
2742
|
const record = value2;
|
|
2743
|
+
record.reviewTypes ??= "";
|
|
2690
2744
|
const options = {};
|
|
2691
2745
|
for (const field of FIELDS) {
|
|
2692
2746
|
const raw = record[field.key];
|
|
@@ -2745,10 +2799,13 @@ function applySavedOptions(values, explicit) {
|
|
|
2745
2799
|
if (!explicit.has("include-drafts")) {
|
|
2746
2800
|
values["include-drafts"] = saved2.includeDrafts;
|
|
2747
2801
|
}
|
|
2802
|
+
if (!explicit.has("review-types") && saved2.reviewTypes !== "") {
|
|
2803
|
+
values["review-types"] = saved2.reviewTypes;
|
|
2804
|
+
}
|
|
2748
2805
|
return saved2;
|
|
2749
2806
|
}
|
|
2750
2807
|
function fetchParamsKey(options) {
|
|
2751
|
-
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]);
|
|
2752
2809
|
}
|
|
2753
2810
|
function targetLabelOf(target) {
|
|
2754
2811
|
if (target === "") {
|
|
@@ -2824,7 +2881,8 @@ var EMPTY_PLACEHOLDERS = {
|
|
|
2824
2881
|
user: "(authenticated user)",
|
|
2825
2882
|
target: "(none)",
|
|
2826
2883
|
sizeTarget: "(none)",
|
|
2827
|
-
tz: "(system)"
|
|
2884
|
+
tz: "(system)",
|
|
2885
|
+
reviewTypes: "(every type)"
|
|
2828
2886
|
};
|
|
2829
2887
|
var SECTIONS = [
|
|
2830
2888
|
{ title: "Data", fields: FIELDS.filter((field) => field.fetch) },
|
|
@@ -2837,7 +2895,8 @@ function OptionsModal({
|
|
|
2837
2895
|
editing,
|
|
2838
2896
|
fieldError,
|
|
2839
2897
|
onDraft,
|
|
2840
|
-
onSubmit
|
|
2898
|
+
onSubmit,
|
|
2899
|
+
onToggleReviewType
|
|
2841
2900
|
}) {
|
|
2842
2901
|
const savedState = savedLine(options, saved2);
|
|
2843
2902
|
return /* @__PURE__ */ jsxs10(ModalFrame, { title: "Options", children: [
|
|
@@ -2851,9 +2910,10 @@ function OptionsModal({
|
|
|
2851
2910
|
field,
|
|
2852
2911
|
options,
|
|
2853
2912
|
isSelected: index === selected,
|
|
2854
|
-
isEditing: index === selected && editing && field.kind
|
|
2913
|
+
isEditing: index === selected && editing && field.kind !== "toggle",
|
|
2855
2914
|
onDraft,
|
|
2856
|
-
onSubmit
|
|
2915
|
+
onSubmit,
|
|
2916
|
+
onToggleType: onToggleReviewType
|
|
2857
2917
|
},
|
|
2858
2918
|
field.key
|
|
2859
2919
|
);
|
|
@@ -2897,10 +2957,17 @@ function FieldRow({
|
|
|
2897
2957
|
isSelected,
|
|
2898
2958
|
isEditing,
|
|
2899
2959
|
onDraft,
|
|
2900
|
-
onSubmit
|
|
2960
|
+
onSubmit,
|
|
2961
|
+
onToggleType
|
|
2901
2962
|
}) {
|
|
2902
2963
|
const value2 = displayValue(field.key, options[field.key]);
|
|
2903
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
|
+
}
|
|
2904
2971
|
return /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: isEditing ? /* @__PURE__ */ jsx11(
|
|
2905
2972
|
"input",
|
|
2906
2973
|
{
|
|
@@ -2924,6 +2991,35 @@ function FieldRow({
|
|
|
2924
2991
|
/* @__PURE__ */ jsx11("span", { fg: theme.muted, children: " \u203A" })
|
|
2925
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 }) });
|
|
2926
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
|
+
}
|
|
2927
3023
|
|
|
2928
3024
|
// src/tui/components/SettingsModal.tsx
|
|
2929
3025
|
import { homedir as homedir2 } from "node:os";
|
|
@@ -3279,7 +3375,7 @@ function collectReviewPrs(requested, reviewed) {
|
|
|
3279
3375
|
}
|
|
3280
3376
|
return [...prByKey.values()];
|
|
3281
3377
|
}
|
|
3282
|
-
function classifyPr(pr, details, user) {
|
|
3378
|
+
function classifyPr(pr, details, user, countedStates) {
|
|
3283
3379
|
if (!details) {
|
|
3284
3380
|
return [{ kind: "inaccessible", pr }];
|
|
3285
3381
|
}
|
|
@@ -3287,7 +3383,7 @@ function classifyPr(pr, details, user) {
|
|
|
3287
3383
|
(node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
|
|
3288
3384
|
);
|
|
3289
3385
|
const reviews = details.reviews.nodes.flatMap(
|
|
3290
|
-
(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 }] : []
|
|
3291
3387
|
);
|
|
3292
3388
|
if (requests.length === 0) {
|
|
3293
3389
|
if (reviews.length === 0) {
|
|
@@ -3350,7 +3446,9 @@ async function fetchReviewRaw(prs, user, onProgress, options = {}) {
|
|
|
3350
3446
|
});
|
|
3351
3447
|
cache.save();
|
|
3352
3448
|
return {
|
|
3353
|
-
results: prs.flatMap(
|
|
3449
|
+
results: prs.flatMap(
|
|
3450
|
+
(pr) => classifyPr(pr, found.get(prKey(pr.repo, pr.number)) ?? null, user, options.countedStates)
|
|
3451
|
+
),
|
|
3354
3452
|
cacheHits
|
|
3355
3453
|
};
|
|
3356
3454
|
}
|
|
@@ -3442,7 +3540,7 @@ function loadSnapshot(options) {
|
|
|
3442
3540
|
return null;
|
|
3443
3541
|
}
|
|
3444
3542
|
const { params } = stored;
|
|
3445
|
-
if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts) {
|
|
3543
|
+
if (params.repos !== options.repos || params.user !== options.user || params.includeDrafts !== options.includeDrafts || params.reviewTypes !== options.reviewTypes) {
|
|
3446
3544
|
return null;
|
|
3447
3545
|
}
|
|
3448
3546
|
const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
|
|
@@ -3485,7 +3583,8 @@ function saveSnapshot(options, data) {
|
|
|
3485
3583
|
since: options.since,
|
|
3486
3584
|
repos: options.repos,
|
|
3487
3585
|
user: options.user,
|
|
3488
|
-
includeDrafts: options.includeDrafts
|
|
3586
|
+
includeDrafts: options.includeDrafts,
|
|
3587
|
+
reviewTypes: options.reviewTypes
|
|
3489
3588
|
};
|
|
3490
3589
|
writeCacheFile("snapshot", { params, data });
|
|
3491
3590
|
}
|
|
@@ -3508,6 +3607,7 @@ async function loadData(options, onPhase, { bypassCache = false } = {}) {
|
|
|
3508
3607
|
onPhase({ phase: "details", done: progress.review + progress.sizes, total });
|
|
3509
3608
|
};
|
|
3510
3609
|
report();
|
|
3610
|
+
const countedStates = options.reviewTypes === "" ? void 0 : parseReviewTypes(options.reviewTypes);
|
|
3511
3611
|
const [review, size] = await Promise.all([
|
|
3512
3612
|
reviewPrs.length === 0 ? { results: [], cacheHits: 0 } : fetchReviewRaw(
|
|
3513
3613
|
reviewPrs,
|
|
@@ -3516,7 +3616,7 @@ async function loadData(options, onPhase, { bypassCache = false } = {}) {
|
|
|
3516
3616
|
progress.review = done;
|
|
3517
3617
|
report();
|
|
3518
3618
|
},
|
|
3519
|
-
{ bypassCache }
|
|
3619
|
+
{ bypassCache, countedStates }
|
|
3520
3620
|
),
|
|
3521
3621
|
authoredPrs.length === 0 ? { sizes: [], cacheHits: 0 } : fetchSizeRaw(
|
|
3522
3622
|
authoredPrs,
|
|
@@ -3618,6 +3718,7 @@ function buildStatsReport(raw, options) {
|
|
|
3618
3718
|
timezone,
|
|
3619
3719
|
wallClock: options.wallClock,
|
|
3620
3720
|
includeDrafts: options.includeDrafts,
|
|
3721
|
+
reviewTypes: options.reviewTypes === "" ? null : options.reviewTypes,
|
|
3621
3722
|
reviewTarget: targetLabel ?? null,
|
|
3622
3723
|
sizeTarget: options.sizeTarget === "" ? null : options.sizeTarget
|
|
3623
3724
|
},
|
|
@@ -4001,7 +4102,8 @@ function Modals({
|
|
|
4001
4102
|
themeState,
|
|
4002
4103
|
onDraft,
|
|
4003
4104
|
onSubmitField,
|
|
4004
|
-
onSubmitThemeColor
|
|
4105
|
+
onSubmitThemeColor,
|
|
4106
|
+
onToggleReviewType
|
|
4005
4107
|
}) {
|
|
4006
4108
|
if (ui.modal === "options") {
|
|
4007
4109
|
return /* @__PURE__ */ jsx14(
|
|
@@ -4013,7 +4115,8 @@ function Modals({
|
|
|
4013
4115
|
editing: ui.editing,
|
|
4014
4116
|
fieldError: ui.fieldError,
|
|
4015
4117
|
onDraft,
|
|
4016
|
-
onSubmit: onSubmitField
|
|
4118
|
+
onSubmit: onSubmitField,
|
|
4119
|
+
onToggleReviewType
|
|
4017
4120
|
}
|
|
4018
4121
|
);
|
|
4019
4122
|
}
|
|
@@ -5740,6 +5843,10 @@ function handleOptionsModalKey(key, context) {
|
|
|
5740
5843
|
context.setOptions((previous) => {
|
|
5741
5844
|
return { ...previous, [field.key]: !previous[field.key] };
|
|
5742
5845
|
});
|
|
5846
|
+
} else if (field.kind === "multi") {
|
|
5847
|
+
if (key.name === "return" || key.name === "space") {
|
|
5848
|
+
context.dispatchUi({ type: "editStarted" });
|
|
5849
|
+
}
|
|
5743
5850
|
} else if (key.name === "return") {
|
|
5744
5851
|
context.beginEdit(String(context.options[field.key]));
|
|
5745
5852
|
}
|
|
@@ -6387,7 +6494,12 @@ function App({
|
|
|
6387
6494
|
draftRef.current = value2;
|
|
6388
6495
|
},
|
|
6389
6496
|
onSubmitField: commitField,
|
|
6390
|
-
onSubmitThemeColor: commitThemeColor
|
|
6497
|
+
onSubmitThemeColor: commitThemeColor,
|
|
6498
|
+
onToggleReviewType: (type) => {
|
|
6499
|
+
setOptions((previous) => {
|
|
6500
|
+
return { ...previous, reviewTypes: toggleReviewType(previous.reviewTypes, type) };
|
|
6501
|
+
});
|
|
6502
|
+
}
|
|
6391
6503
|
}
|
|
6392
6504
|
)
|
|
6393
6505
|
] });
|
|
@@ -6415,7 +6527,8 @@ function bootstrap() {
|
|
|
6415
6527
|
workHours: values["work-hours"],
|
|
6416
6528
|
tz: values.tz ?? "",
|
|
6417
6529
|
wallClock: values["wall-clock"],
|
|
6418
|
-
includeDrafts: values["include-drafts"]
|
|
6530
|
+
includeDrafts: values["include-drafts"],
|
|
6531
|
+
reviewTypes: values["review-types"] ?? ""
|
|
6419
6532
|
};
|
|
6420
6533
|
for (const field of FIELDS) {
|
|
6421
6534
|
const value2 = initial2[field.key];
|