@d3lm/pr-stats 0.2.10 → 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/dist/tui-app.mjs +72 -18
- package/package.json +2 -2
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",
|
|
@@ -2525,6 +2531,15 @@ function parseTarget(input) {
|
|
|
2525
2531
|
}
|
|
2526
2532
|
return amount;
|
|
2527
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
|
+
}
|
|
2528
2543
|
function parseSizeTarget(input) {
|
|
2529
2544
|
const target = {};
|
|
2530
2545
|
for (const part of input.split(",")) {
|
|
@@ -2651,6 +2666,13 @@ var FIELDS = [
|
|
|
2651
2666
|
kind: "text",
|
|
2652
2667
|
fetch: false
|
|
2653
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
|
+
},
|
|
2654
2676
|
{
|
|
2655
2677
|
key: "sizeTarget",
|
|
2656
2678
|
label: "Size target",
|
|
@@ -2713,6 +2735,10 @@ function validateField(key, value2) {
|
|
|
2713
2735
|
parseTarget(value2);
|
|
2714
2736
|
break;
|
|
2715
2737
|
}
|
|
2738
|
+
case "targetPercentile": {
|
|
2739
|
+
parseTargetPercentile(value2);
|
|
2740
|
+
break;
|
|
2741
|
+
}
|
|
2716
2742
|
case "sizeTarget": {
|
|
2717
2743
|
parseSizeTarget(value2);
|
|
2718
2744
|
break;
|
|
@@ -2741,6 +2767,7 @@ function readSavedOptions() {
|
|
|
2741
2767
|
}
|
|
2742
2768
|
const record = value2;
|
|
2743
2769
|
record.reviewTypes ??= "";
|
|
2770
|
+
record.targetPercentile ??= "";
|
|
2744
2771
|
const options = {};
|
|
2745
2772
|
for (const field of FIELDS) {
|
|
2746
2773
|
const raw = record[field.key];
|
|
@@ -2784,6 +2811,9 @@ function applySavedOptions(values, explicit) {
|
|
|
2784
2811
|
if (!explicit.has("target") && saved2.target !== "") {
|
|
2785
2812
|
values.target = saved2.target;
|
|
2786
2813
|
}
|
|
2814
|
+
if (!explicit.has("target-percentile") && saved2.targetPercentile !== "") {
|
|
2815
|
+
values["target-percentile"] = saved2.targetPercentile;
|
|
2816
|
+
}
|
|
2787
2817
|
if (!explicit.has("size-target") && saved2.sizeTarget !== "") {
|
|
2788
2818
|
values["size-target"] = saved2.sizeTarget;
|
|
2789
2819
|
}
|
|
@@ -2880,6 +2910,7 @@ var EMPTY_PLACEHOLDERS = {
|
|
|
2880
2910
|
repos: "(all accessible)",
|
|
2881
2911
|
user: "(authenticated user)",
|
|
2882
2912
|
target: "(none)",
|
|
2913
|
+
targetPercentile: "(p90)",
|
|
2883
2914
|
sizeTarget: "(none)",
|
|
2884
2915
|
tz: "(system)",
|
|
2885
2916
|
reviewTypes: "(every type)"
|
|
@@ -5170,9 +5201,17 @@ function buildVerdictCard(reviewed) {
|
|
|
5170
5201
|
})
|
|
5171
5202
|
});
|
|
5172
5203
|
}
|
|
5173
|
-
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) {
|
|
5174
5213
|
const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
|
|
5175
|
-
const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
|
|
5214
|
+
const stats = computeReviewStats(results, { targetHours: target?.hours, now: raw.fetchedAt });
|
|
5176
5215
|
const strip = [
|
|
5177
5216
|
countCell(stats.reviewed.length, "reviewed on request"),
|
|
5178
5217
|
countCell(stats.pending.length, "awaiting you", true),
|
|
@@ -5193,9 +5232,9 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5193
5232
|
return { empty: "No reviewed or review-requested PRs found.", ...base, lists: [] };
|
|
5194
5233
|
}
|
|
5195
5234
|
const lists = [];
|
|
5196
|
-
if (
|
|
5235
|
+
if (target !== void 0 && stats.misses.length > 0) {
|
|
5197
5236
|
lists.push({
|
|
5198
|
-
title: `Reviews that missed the <= ${
|
|
5237
|
+
title: `Reviews that missed the <= ${target.label} target`,
|
|
5199
5238
|
rows: toPrRows(stats.misses, stats.misses.map(durationLead))
|
|
5200
5239
|
});
|
|
5201
5240
|
}
|
|
@@ -5211,11 +5250,19 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5211
5250
|
}
|
|
5212
5251
|
const sorted = [...stats.allHours].toSorted((a, b) => a - b);
|
|
5213
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
|
+
};
|
|
5214
5260
|
const headline = [
|
|
5215
5261
|
{ text: "p50 ", fg: theme.muted },
|
|
5216
|
-
{ text: formatDuration(percentile(sorted, 50)), fg:
|
|
5217
|
-
{ text:
|
|
5218
|
-
{ 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)],
|
|
5219
5266
|
{ text: ` ${stats.reviewed.length} of ${total} reviews`, fg: theme.muted }
|
|
5220
5267
|
];
|
|
5221
5268
|
const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
|
|
@@ -5224,15 +5271,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5224
5271
|
(entry) => durationHours(entry.pr.createdAt, entry.requestedAt)
|
|
5225
5272
|
);
|
|
5226
5273
|
let serviceCard = null;
|
|
5227
|
-
if (
|
|
5228
|
-
const inside = stats.allHours.filter((value2) => value2 <=
|
|
5229
|
-
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;
|
|
5230
5277
|
serviceCard = buildGaugeCard({
|
|
5231
5278
|
title: "Service level",
|
|
5232
|
-
subtitle: `reviewed within ${
|
|
5279
|
+
subtitle: `reviewed within ${target.label}`,
|
|
5233
5280
|
rows: [
|
|
5234
|
-
{ label: `inside ${
|
|
5235
|
-
{ 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 },
|
|
5236
5283
|
...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
|
|
5237
5284
|
]
|
|
5238
5285
|
});
|
|
@@ -5254,6 +5301,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5254
5301
|
expanded
|
|
5255
5302
|
}) : null;
|
|
5256
5303
|
const cards = [
|
|
5304
|
+
...serviceCard === null ? [] : [serviceCard],
|
|
5257
5305
|
buildHistogramCard({
|
|
5258
5306
|
title: "Time to review",
|
|
5259
5307
|
subtitle: "elapsed time, request \u2192 review",
|
|
@@ -5306,7 +5354,6 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
5306
5354
|
buildVerdictCard(stats.reviewed),
|
|
5307
5355
|
...pendingCard === null ? [] : [pendingCard],
|
|
5308
5356
|
buildOffHoursCard("reviews submitted, local time", reviewDates),
|
|
5309
|
-
...serviceCard === null ? [] : [serviceCard],
|
|
5310
5357
|
...byRepoCard === null ? [] : [byRepoCard]
|
|
5311
5358
|
];
|
|
5312
5359
|
const distribution = buildDistribution({
|
|
@@ -5760,7 +5807,12 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
|
|
|
5760
5807
|
if (!raw) {
|
|
5761
5808
|
return null;
|
|
5762
5809
|
}
|
|
5763
|
-
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
|
+
};
|
|
5764
5816
|
const sizeTarget = options.sizeTarget === "" ? void 0 : parseSizeTarget(options.sizeTarget);
|
|
5765
5817
|
const pendingRepos = buildPendingRepoOptions(raw);
|
|
5766
5818
|
const openRepos = buildOpenRepoOptions(raw);
|
|
@@ -5774,7 +5826,7 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
|
|
|
5774
5826
|
const reviewScope = resolveScope(scopes.review, reviewRepos);
|
|
5775
5827
|
const sizeScope = resolveScope(scopes.size, sizeRepos);
|
|
5776
5828
|
const commentScope = resolveScope(scopes.comment, commentRepos);
|
|
5777
|
-
const review = reviewScope.view === "detail" ? buildReviewView(raw,
|
|
5829
|
+
const review = reviewScope.view === "detail" ? buildReviewView(raw, reviewTarget, reviewScope.repo, width, expanded.review) : null;
|
|
5778
5830
|
return {
|
|
5779
5831
|
pendingRepos,
|
|
5780
5832
|
openRepos,
|
|
@@ -5801,6 +5853,7 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
|
|
|
5801
5853
|
options.tz,
|
|
5802
5854
|
options.wallClock,
|
|
5803
5855
|
options.target,
|
|
5856
|
+
options.targetPercentile,
|
|
5804
5857
|
options.sizeTarget,
|
|
5805
5858
|
width,
|
|
5806
5859
|
scopes.pending,
|
|
@@ -5999,7 +6052,7 @@ function queueTabOf(context) {
|
|
|
5999
6052
|
}
|
|
6000
6053
|
function handleQueueKey(key, context) {
|
|
6001
6054
|
const { key: tab, view, repos, scope } = queueTabOf(context);
|
|
6002
|
-
if (scope
|
|
6055
|
+
if (scope?.view === "list") {
|
|
6003
6056
|
switch (key.name) {
|
|
6004
6057
|
case "up":
|
|
6005
6058
|
case "k": {
|
|
@@ -6091,7 +6144,7 @@ function statsTabOf(context) {
|
|
|
6091
6144
|
}
|
|
6092
6145
|
function handleStatsKey(key, context) {
|
|
6093
6146
|
const { key: tab, repos, scope, view } = statsTabOf(context);
|
|
6094
|
-
if (scope
|
|
6147
|
+
if (scope?.view === "list") {
|
|
6095
6148
|
switch (key.name) {
|
|
6096
6149
|
case "up":
|
|
6097
6150
|
case "k": {
|
|
@@ -6523,6 +6576,7 @@ function bootstrap() {
|
|
|
6523
6576
|
repos: values.repo.join(","),
|
|
6524
6577
|
user: values.user ?? "",
|
|
6525
6578
|
target: values.target ?? "",
|
|
6579
|
+
targetPercentile: values["target-percentile"] ?? "",
|
|
6526
6580
|
sizeTarget: values["size-target"] ?? "",
|
|
6527
6581
|
workHours: values["work-hours"],
|
|
6528
6582
|
tz: values.tz ?? "",
|
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",
|