@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 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 === "text",
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((pr) => classifyPr(pr, found.get(prKey(pr.repo, pr.number)) ?? null, user)),
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 buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100, expanded = false) {
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 (targetHours !== void 0 && stats.misses.length > 0) {
5235
+ if (target !== void 0 && stats.misses.length > 0) {
5094
5236
  lists.push({
5095
- title: `Reviews that missed the <= ${targetLabel} target`,
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: theme.accent },
5114
- { text: " p90 ", fg: theme.muted },
5115
- { text: formatDuration(percentile(sorted, 90)), fg: theme.accent },
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 (targetHours !== void 0 && targetLabel !== void 0) {
5125
- const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
5126
- const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
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 ${targetLabel}`,
5279
+ subtitle: `reviewed within ${target.label}`,
5130
5280
  rows: [
5131
- { label: `inside ${targetLabel}`, count: inside, color: theme.accent },
5132
- { label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
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 targetHours = options.target === "" ? void 0 : parseTarget(options.target);
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, targetHours, targetLabelOf(options.target), reviewScope.repo, width, expanded.review) : null;
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 !== null && scope.view === "list") {
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 !== null && scope.view === "list") {
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.9",
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.1.7",
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",