@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 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 === "text",
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((pr) => classifyPr(pr, found.get(prKey(pr.repo, pr.number)) ?? null, user)),
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];
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.10",
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",