@d3lm/pr-stats 0.2.6 → 0.2.8
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 +9 -1
- package/dist/tui-app.mjs +573 -166
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,15 @@ You can also run it without installing through `npx @d3lm/pr-stats`. The bunx eq
|
|
|
25
25
|
pr-stats
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Without flags, it looks at PRs from the last 90 days across all repositories you can access.
|
|
28
|
+
Without flags, it looks at PRs from the last 90 days across all repositories you can access. The tabs hold these views.
|
|
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.
|
|
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 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 either of them.
|
|
32
|
+
- The time-to-review report pairs its histogram, trend, heatmap, and weekly volume with 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
|
+
- 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.
|
|
34
|
+
- The comments report holds a histogram of comments per PR, a scatter of comments against PR size, and the most commented PRs.
|
|
35
|
+
|
|
36
|
+
When the data spans multiple repos, every tab opens on a repo picker that drills into one repo or the aggregate across all of them, and on the two queue lists the `g` key groups the aggregate list by repo. Comparison cards like the reviewer leaderboard cap themselves at eight rows and fold the rest into an overflow line, and the `x` key lifts the cap on the open stats tab and restores it. The footer names the key whenever the tab has a capped card.
|
|
29
37
|
|
|
30
38
|
Node gates the FFI that OpenTUI renders through behind the `--experimental-ffi` flag, and the launcher re-executes itself with that flag when it is missing, so a plain `pr-stats` works without extra flags on both runtimes.
|
|
31
39
|
|
package/dist/tui-app.mjs
CHANGED
|
@@ -146,7 +146,7 @@ import { join as join2 } from "node:path";
|
|
|
146
146
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
147
147
|
import { homedir } from "node:os";
|
|
148
148
|
import { dirname, join } from "node:path";
|
|
149
|
-
var VERSION =
|
|
149
|
+
var VERSION = 3;
|
|
150
150
|
var enabled = false;
|
|
151
151
|
function configureCache(on) {
|
|
152
152
|
enabled = on;
|
|
@@ -621,10 +621,12 @@ function hintsFor(modal, editing, tab, authoredTab, views, copyLinks2) {
|
|
|
621
621
|
if (scope?.view === "list") {
|
|
622
622
|
return `\u2191/\u2193 select \xB7 enter open \xB7 ${toggle}\u2190/\u2192 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
|
|
623
623
|
}
|
|
624
|
+
const view = views === null ? null : tab === 1 ? views.merged : tab === 2 ? views.review : tab === 3 ? views.size : views.comments;
|
|
625
|
+
const expand = view?.expandable ? view.expanded ? "x collapse \xB7 " : "x expand \xB7 " : "";
|
|
624
626
|
if (scope !== null && repos.length > 0) {
|
|
625
|
-
return `${toggle}esc back \xB7 j/k scroll \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
|
|
627
|
+
return `${toggle}${expand}esc back \xB7 j/k scroll \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
|
|
626
628
|
}
|
|
627
|
-
return `${toggle}1-5 tabs \xB7 j/k scroll \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
|
|
629
|
+
return `${toggle}${expand}1-5 tabs \xB7 j/k scroll \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
|
|
628
630
|
}
|
|
629
631
|
|
|
630
632
|
// src/tui/components/Spinner.tsx
|
|
@@ -726,7 +728,19 @@ function wallParts(instantMs) {
|
|
|
726
728
|
function zonedStamp(date) {
|
|
727
729
|
const parts = wallParts(date.getTime());
|
|
728
730
|
const dayUtcMs = Date.UTC(parts.year, parts.month - 1, parts.day);
|
|
729
|
-
return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour };
|
|
731
|
+
return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour, minute: parts.minute };
|
|
732
|
+
}
|
|
733
|
+
function hasWorkWindows() {
|
|
734
|
+
const covered = timeMode.workWindows.reduce((sum, window) => sum + (window.endMin - window.startMin), 0);
|
|
735
|
+
return covered < 24 * 60;
|
|
736
|
+
}
|
|
737
|
+
function classifyInstant(date) {
|
|
738
|
+
const { weekday, hour, minute } = zonedStamp(date);
|
|
739
|
+
if (weekday === 0 || weekday === 6) {
|
|
740
|
+
return "weekend";
|
|
741
|
+
}
|
|
742
|
+
const minuteOfDay = hour * 60 + minute;
|
|
743
|
+
return timeMode.workWindows.some((window) => minuteOfDay >= window.startMin && minuteOfDay < window.endMin) ? "work" : "after";
|
|
730
744
|
}
|
|
731
745
|
function utcFromWall(wallTargetMs) {
|
|
732
746
|
let guess = wallTargetMs;
|
|
@@ -780,7 +794,8 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
|
|
|
780
794
|
pr: result.pr,
|
|
781
795
|
requestedAt: result.requestedAt,
|
|
782
796
|
reviewedAt: result.reviewedAt,
|
|
783
|
-
hours: durationHours(result.requestedAt, result.reviewedAt)
|
|
797
|
+
hours: durationHours(result.requestedAt, result.reviewedAt),
|
|
798
|
+
verdict: result.verdict
|
|
784
799
|
});
|
|
785
800
|
} else if (result.kind === "pending" && result.pr.state === "open") {
|
|
786
801
|
pending.push({ pr: result.pr, requestedAt: result.requestedAt, hours: durationHours(result.requestedAt, now) });
|
|
@@ -816,7 +831,22 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
|
|
|
816
831
|
}
|
|
817
832
|
const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
|
|
818
833
|
const misses = targetHours === void 0 ? [] : reviewed.filter((result) => result.hours > targetHours).toSorted((a, b) => b.hours - a.hours);
|
|
819
|
-
|
|
834
|
+
const cyclesByPr = /* @__PURE__ */ new Map();
|
|
835
|
+
for (const result of reviewed) {
|
|
836
|
+
const key = `${result.pr.repo}#${result.pr.number}`;
|
|
837
|
+
cyclesByPr.set(key, (cyclesByPr.get(key) ?? 0) + 1);
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
reviewed,
|
|
841
|
+
pending,
|
|
842
|
+
reviewing,
|
|
843
|
+
expired,
|
|
844
|
+
unrequested,
|
|
845
|
+
allHours,
|
|
846
|
+
byRepo,
|
|
847
|
+
misses,
|
|
848
|
+
cycles: [...cyclesByPr.values()]
|
|
849
|
+
};
|
|
820
850
|
}
|
|
821
851
|
function computeSizeStats(sizes, { sizeTarget } = {}) {
|
|
822
852
|
const metrics = [
|
|
@@ -855,6 +885,37 @@ function computeMergeStats(sizes) {
|
|
|
855
885
|
closed.sort((a, b) => b.closedAt.getTime() - a.closedAt.getTime());
|
|
856
886
|
return { merged, closed, open, allHours: merged.map((result) => result.hours) };
|
|
857
887
|
}
|
|
888
|
+
function computeReviewerStats(sizes, author) {
|
|
889
|
+
const byLogin = /* @__PURE__ */ new Map();
|
|
890
|
+
let mergedReviewed = 0;
|
|
891
|
+
let mergedUnreviewed = 0;
|
|
892
|
+
for (const entry of sizes) {
|
|
893
|
+
const others = entry.reviewers.filter((login) => login !== author);
|
|
894
|
+
for (const login of others) {
|
|
895
|
+
const counts = byLogin.get(login) ?? { prs: 0, reviews: 0 };
|
|
896
|
+
counts.reviews += 1;
|
|
897
|
+
byLogin.set(login, counts);
|
|
898
|
+
}
|
|
899
|
+
const distinct = new Set(others);
|
|
900
|
+
for (const login of distinct) {
|
|
901
|
+
const counts = byLogin.get(login);
|
|
902
|
+
if (counts !== void 0) {
|
|
903
|
+
counts.prs += 1;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (entry.mergedAt !== null) {
|
|
907
|
+
if (others.length > 0) {
|
|
908
|
+
mergedReviewed += 1;
|
|
909
|
+
} else {
|
|
910
|
+
mergedUnreviewed += 1;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const leaderboard = [...byLogin.entries()].map(([login, counts]) => {
|
|
915
|
+
return { login, ...counts };
|
|
916
|
+
}).toSorted((a, b) => b.prs - a.prs || b.reviews - a.reviews || a.login.localeCompare(b.login));
|
|
917
|
+
return { leaderboard, mergedReviewed, mergedUnreviewed };
|
|
918
|
+
}
|
|
858
919
|
function computeCommentStats(sizes) {
|
|
859
920
|
const metrics = [
|
|
860
921
|
{ label: "discussion comments", values: sizes.map((size) => size.comments.discussion) },
|
|
@@ -918,6 +979,13 @@ var FILE_BUCKETS = [
|
|
|
918
979
|
{ label: "21-50", max: 51 },
|
|
919
980
|
{ label: "> 50", max: Infinity }
|
|
920
981
|
];
|
|
982
|
+
var CYCLE_BUCKETS = [
|
|
983
|
+
{ label: "1", max: 2 },
|
|
984
|
+
{ label: "2", max: 3 },
|
|
985
|
+
{ label: "3", max: 4 },
|
|
986
|
+
{ label: "4-5", max: 6 },
|
|
987
|
+
{ label: "> 5", max: Infinity }
|
|
988
|
+
];
|
|
921
989
|
var COMMENT_BUCKETS = [
|
|
922
990
|
{ label: "0", max: 1 },
|
|
923
991
|
{ label: "1-2", max: 3 },
|
|
@@ -1051,9 +1119,24 @@ function useScrollbarSettle(scrollRef, mounted = true) {
|
|
|
1051
1119
|
renderer2.off(CliRenderEvents.FRAME, release);
|
|
1052
1120
|
scrollRef.current?.verticalScrollBar.resetVisibilityControl();
|
|
1053
1121
|
};
|
|
1122
|
+
const resyncThumb = () => {
|
|
1123
|
+
const bar = scrollRef.current?.verticalScrollBar;
|
|
1124
|
+
if (!bar) {
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const height = bar.viewportSize;
|
|
1128
|
+
const wanted = Math.max(1, height);
|
|
1129
|
+
if (bar.scrollSize < wanted || bar.slider.viewPortSize === wanted) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
bar.viewportSize = 0;
|
|
1133
|
+
bar.viewportSize = height;
|
|
1134
|
+
};
|
|
1054
1135
|
renderer2.on(CliRenderEvents.FRAME, release);
|
|
1136
|
+
renderer2.on(CliRenderEvents.FRAME, resyncThumb);
|
|
1055
1137
|
return () => {
|
|
1056
1138
|
renderer2.off(CliRenderEvents.FRAME, release);
|
|
1139
|
+
renderer2.off(CliRenderEvents.FRAME, resyncThumb);
|
|
1057
1140
|
};
|
|
1058
1141
|
}, [scrollRef, renderer2, mounted]);
|
|
1059
1142
|
}
|
|
@@ -1094,18 +1177,18 @@ function ChartsPanel({
|
|
|
1094
1177
|
if (view.empty !== null) {
|
|
1095
1178
|
return /* @__PURE__ */ jsx4("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx4("text", { fg: theme.muted, children: view.empty }) });
|
|
1096
1179
|
}
|
|
1097
|
-
const
|
|
1098
|
-
const
|
|
1099
|
-
const
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
return { key:
|
|
1180
|
+
const left = view.cards.filter((_, i) => i % 2 === 0);
|
|
1181
|
+
const right = view.cards.filter((_, i) => i % 2 === 1);
|
|
1182
|
+
const leftWidth = Math.max(0, ...left.map((card) => cardWidth(card)));
|
|
1183
|
+
const rightWidth = Math.max(0, ...right.map((card) => cardWidth(card)));
|
|
1184
|
+
const twoColumns = right.length > 0 && width - 4 >= leftWidth + COLUMN_GAP + rightWidth;
|
|
1185
|
+
const rows = left.map((card, i) => {
|
|
1186
|
+
return { key: card.title, left: card, right: right.at(i) };
|
|
1104
1187
|
});
|
|
1105
1188
|
const cards = twoColumns ? /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children: rows.map((row) => /* @__PURE__ */ jsxs3("box", { flexDirection: "row", alignItems: "flex-start", columnGap: COLUMN_GAP, children: [
|
|
1106
|
-
/* @__PURE__ */ jsx4("box", { flexDirection: "column", width: leftWidth, flexShrink: 0, children:
|
|
1189
|
+
/* @__PURE__ */ jsx4("box", { flexDirection: "column", width: leftWidth, flexShrink: 0, children: /* @__PURE__ */ jsx4(ChartCard, { card: row.left }) }),
|
|
1107
1190
|
/* @__PURE__ */ jsx4("box", { flexDirection: "column", width: rightWidth, flexShrink: 0, children: row.right !== void 0 && /* @__PURE__ */ jsx4(ChartCard, { card: row.right }) })
|
|
1108
|
-
] }, row.key)) }) : /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children:
|
|
1191
|
+
] }, row.key)) }) : /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children: view.cards.map((card) => /* @__PURE__ */ jsx4(ChartCard, { card }, card.title)) });
|
|
1109
1192
|
return /* @__PURE__ */ jsxs3("box", { flexGrow: 1, flexDirection: "column", children: [
|
|
1110
1193
|
/* @__PURE__ */ jsx4("box", { height: 1, children: /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.border, children: "\u2500".repeat(width) }) }),
|
|
1111
1194
|
/* @__PURE__ */ jsx4("box", { height: 1, paddingLeft: 1, paddingRight: 2, flexDirection: "row", justifyContent: "space-between", children: keyed(view.strip, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) }),
|
|
@@ -1146,7 +1229,7 @@ function ChartsPanel({
|
|
|
1146
1229
|
] }),
|
|
1147
1230
|
/* @__PURE__ */ jsx4("box", { flexDirection: "column", marginTop: 1, children: keyed(view.distribution.lines, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) })
|
|
1148
1231
|
] }),
|
|
1149
|
-
view.
|
|
1232
|
+
view.cards.length === 0 ? /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.muted, marginTop: 1, children: view.noCharts }) : cards
|
|
1150
1233
|
]
|
|
1151
1234
|
}
|
|
1152
1235
|
)
|
|
@@ -1393,7 +1476,8 @@ var initialBrowseState = {
|
|
|
1393
1476
|
},
|
|
1394
1477
|
repoCursors: { pending: 0, open: 0, review: 0, size: 0, comment: 0, merged: 0 },
|
|
1395
1478
|
rowCursors: { pending: 0, open: 0 },
|
|
1396
|
-
grouped: { pending: false, open: false }
|
|
1479
|
+
grouped: { pending: false, open: false },
|
|
1480
|
+
expanded: { review: false, size: false, comment: false, merged: false }
|
|
1397
1481
|
};
|
|
1398
1482
|
function dropVanishedRepo(scope, repos) {
|
|
1399
1483
|
if (scope.view === "detail" && scope.repo !== null && !repos.some((option) => option.repo === scope.repo)) {
|
|
@@ -1442,6 +1526,9 @@ function browseReducer(state, action) {
|
|
|
1442
1526
|
case "groupingToggled": {
|
|
1443
1527
|
return { ...state, grouped: { ...state.grouped, [action.tab]: !state.grouped[action.tab] } };
|
|
1444
1528
|
}
|
|
1529
|
+
case "expandToggled": {
|
|
1530
|
+
return { ...state, expanded: { ...state.expanded, [action.tab]: !state.expanded[action.tab] } };
|
|
1531
|
+
}
|
|
1445
1532
|
case "dataLoaded": {
|
|
1446
1533
|
return {
|
|
1447
1534
|
...state,
|
|
@@ -3367,6 +3454,7 @@ async function fetchPrSizes(prs) {
|
|
|
3367
3454
|
}
|
|
3368
3455
|
reviews(first: 100) {
|
|
3369
3456
|
nodes {
|
|
3457
|
+
author { login }
|
|
3370
3458
|
comments {
|
|
3371
3459
|
totalCount
|
|
3372
3460
|
}
|
|
@@ -3468,20 +3556,22 @@ function classifyPr(pr, details, user) {
|
|
|
3468
3556
|
(node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
|
|
3469
3557
|
);
|
|
3470
3558
|
const reviews = details.reviews.nodes.flatMap(
|
|
3471
|
-
(node) => node?.author?.login === user && node.submittedAt ? [new Date(node.submittedAt)] : []
|
|
3559
|
+
(node) => node?.author?.login === user && node.submittedAt ? [{ at: new Date(node.submittedAt), state: node.state }] : []
|
|
3472
3560
|
);
|
|
3473
3561
|
if (requests.length === 0) {
|
|
3474
3562
|
if (reviews.length === 0) {
|
|
3475
3563
|
return [{ kind: "inaccessible", pr }];
|
|
3476
3564
|
}
|
|
3477
|
-
return [
|
|
3565
|
+
return [
|
|
3566
|
+
{ kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.at.getTime()))) }
|
|
3567
|
+
];
|
|
3478
3568
|
}
|
|
3479
3569
|
const events = [
|
|
3480
3570
|
...requests.map((at) => {
|
|
3481
|
-
return { at, isRequest: true };
|
|
3571
|
+
return { at, isRequest: true, state: "" };
|
|
3482
3572
|
}),
|
|
3483
|
-
...reviews.map((at) => {
|
|
3484
|
-
return { at, isRequest: false };
|
|
3573
|
+
...reviews.map(({ at, state }) => {
|
|
3574
|
+
return { at, isRequest: false, state };
|
|
3485
3575
|
})
|
|
3486
3576
|
].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
|
|
3487
3577
|
const results = [];
|
|
@@ -3490,7 +3580,7 @@ function classifyPr(pr, details, user) {
|
|
|
3490
3580
|
if (event.isRequest) {
|
|
3491
3581
|
openedAt ??= event.at;
|
|
3492
3582
|
} else if (openedAt !== null) {
|
|
3493
|
-
results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at });
|
|
3583
|
+
results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state });
|
|
3494
3584
|
openedAt = null;
|
|
3495
3585
|
}
|
|
3496
3586
|
}
|
|
@@ -3559,6 +3649,7 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
|
|
|
3559
3649
|
if (details) {
|
|
3560
3650
|
const discussion = details.comments.totalCount;
|
|
3561
3651
|
const review = details.reviews.nodes.reduce((sum, node) => sum + (node?.comments.totalCount ?? 0), 0);
|
|
3652
|
+
const reviewers = details.reviews.nodes.flatMap((node) => node?.author == null ? [] : [node.author.login]);
|
|
3562
3653
|
sizes.push({
|
|
3563
3654
|
pr,
|
|
3564
3655
|
files: details.changedFiles,
|
|
@@ -3567,7 +3658,8 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
|
|
|
3567
3658
|
total: details.additions + details.deletions,
|
|
3568
3659
|
mergedAt: details.mergedAt === null ? null : new Date(details.mergedAt),
|
|
3569
3660
|
closedAt: details.closedAt === null ? null : new Date(details.closedAt),
|
|
3570
|
-
comments: { discussion, review, total: discussion + review }
|
|
3661
|
+
comments: { discussion, review, total: discussion + review },
|
|
3662
|
+
reviewers
|
|
3571
3663
|
});
|
|
3572
3664
|
}
|
|
3573
3665
|
}
|
|
@@ -3619,6 +3711,12 @@ function loadSnapshot(options) {
|
|
|
3619
3711
|
if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
|
|
3620
3712
|
return null;
|
|
3621
3713
|
}
|
|
3714
|
+
const hasMissingVerdict = data.reviewResults.some((result) => {
|
|
3715
|
+
return result.kind === "reviewed" && result.verdict === void 0;
|
|
3716
|
+
});
|
|
3717
|
+
if (hasMissingVerdict) {
|
|
3718
|
+
return null;
|
|
3719
|
+
}
|
|
3622
3720
|
if (sinceIso === data.sinceIso) {
|
|
3623
3721
|
return data;
|
|
3624
3722
|
}
|
|
@@ -4002,6 +4100,137 @@ function hbar(fraction, width, color) {
|
|
|
4002
4100
|
return line;
|
|
4003
4101
|
}
|
|
4004
4102
|
|
|
4103
|
+
// src/tui/views/charts/bars.ts
|
|
4104
|
+
var BAR_WIDTH2 = 24;
|
|
4105
|
+
var MAX_BARS = 8;
|
|
4106
|
+
function buildBarsCard({ title, subtitle, rows, format, expanded = false }) {
|
|
4107
|
+
const shown = expanded ? rows : rows.slice(0, MAX_BARS);
|
|
4108
|
+
const max = Math.max(...shown.map((row) => row.value), 0);
|
|
4109
|
+
const labelWidth = Math.max(...shown.map((row) => row.label.length));
|
|
4110
|
+
const valueWidth = Math.max(...shown.map((row) => format(row.value).length));
|
|
4111
|
+
const lines = shown.map((row) => {
|
|
4112
|
+
const line = [{ text: `${row.label.padEnd(labelWidth)} `, fg: theme.muted }];
|
|
4113
|
+
if (row.value <= 0 || max <= 0) {
|
|
4114
|
+
line.push({ text: " ".repeat(BAR_WIDTH2) });
|
|
4115
|
+
} else {
|
|
4116
|
+
line.push(...hbar(row.value / max, BAR_WIDTH2, row.value === max ? theme.accent : theme.chartBar));
|
|
4117
|
+
}
|
|
4118
|
+
line.push(
|
|
4119
|
+
{ text: ` ${format(row.value).padStart(valueWidth)}`, fg: theme.text },
|
|
4120
|
+
{ text: ` ${row.detail}`, fg: theme.dim }
|
|
4121
|
+
);
|
|
4122
|
+
return line;
|
|
4123
|
+
});
|
|
4124
|
+
if (rows.length > shown.length) {
|
|
4125
|
+
lines.push([{ text: `+ ${rows.length - shown.length} more \xB7 x expands`, fg: theme.dim }]);
|
|
4126
|
+
}
|
|
4127
|
+
return { title, subtitle, lines };
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
// src/tui/views/charts/weeks.ts
|
|
4131
|
+
var DAY_MS = 864e5;
|
|
4132
|
+
var WEEK_MS = 7 * DAY_MS;
|
|
4133
|
+
function mondayOf(dayUtcMs) {
|
|
4134
|
+
return dayUtcMs - (new Date(dayUtcMs).getUTCDay() + 6) % 7 * DAY_MS;
|
|
4135
|
+
}
|
|
4136
|
+
function dateLabel(ms) {
|
|
4137
|
+
return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
4138
|
+
}
|
|
4139
|
+
function weekAxisRow(width, prefix, points, columnOf, mondayOfPoint) {
|
|
4140
|
+
const cells = blankCells(width);
|
|
4141
|
+
const every = Math.ceil(points / 4);
|
|
4142
|
+
let lastEnd = -2;
|
|
4143
|
+
for (let point = 0; point < points; point += every) {
|
|
4144
|
+
const label = dateLabel(mondayOfPoint(point));
|
|
4145
|
+
const at = prefix + columnOf(point);
|
|
4146
|
+
if (at >= lastEnd + 2 && at + label.length <= width) {
|
|
4147
|
+
placeText(cells, at, label, theme.dim);
|
|
4148
|
+
lastEnd = at + label.length;
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
return mergeCells(cells);
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
// src/tui/views/charts/cumulative.ts
|
|
4155
|
+
var CUM_WIDTH = 36;
|
|
4156
|
+
var CUM_HEIGHT = 8;
|
|
4157
|
+
function weekOf(date) {
|
|
4158
|
+
return mondayOf(zonedStamp(date).dayUtcMs);
|
|
4159
|
+
}
|
|
4160
|
+
function buildCumulativeCard({ title, series, legend }) {
|
|
4161
|
+
const subtitle = series.flatMap((entry, i) => [
|
|
4162
|
+
{ text: `${i === 0 ? "" : " "}\u2500\u2500`, fg: entry.color },
|
|
4163
|
+
{ text: ` ${entry.label}`, fg: theme.muted }
|
|
4164
|
+
]);
|
|
4165
|
+
subtitle.push({ text: `, ${legend}`, fg: theme.muted });
|
|
4166
|
+
const mondays = series.flatMap((entry) => entry.dates.map((date) => weekOf(date)));
|
|
4167
|
+
const first = Math.min(...mondays);
|
|
4168
|
+
const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
|
|
4169
|
+
if (weekCount < 2) {
|
|
4170
|
+
return { title, subtitle, lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]] };
|
|
4171
|
+
}
|
|
4172
|
+
const totals = series.map((entry) => {
|
|
4173
|
+
const weekly = Array.from({ length: weekCount }, () => 0);
|
|
4174
|
+
for (const date of entry.dates) {
|
|
4175
|
+
weekly[(weekOf(date) - first) / WEEK_MS] += 1;
|
|
4176
|
+
}
|
|
4177
|
+
let running = 0;
|
|
4178
|
+
return weekly.map((count2) => running += count2);
|
|
4179
|
+
});
|
|
4180
|
+
const maxY = Math.max(...totals.map((cumulative) => cumulative.at(-1) ?? 0), 1);
|
|
4181
|
+
const grid = Array.from(
|
|
4182
|
+
{ length: CUM_HEIGHT },
|
|
4183
|
+
() => Array.from({ length: CUM_WIDTH }, () => null)
|
|
4184
|
+
);
|
|
4185
|
+
for (const [i, cumulative] of totals.entries()) {
|
|
4186
|
+
const rows = Array.from({ length: CUM_WIDTH }, (_, x) => {
|
|
4187
|
+
const weekPos = x / (CUM_WIDTH - 1) * (weekCount - 1);
|
|
4188
|
+
const week = Math.floor(weekPos);
|
|
4189
|
+
const nextWeek = Math.min(week + 1, weekCount - 1);
|
|
4190
|
+
const value2 = cumulative[week] + (cumulative[nextWeek] - cumulative[week]) * (weekPos - week);
|
|
4191
|
+
return CUM_HEIGHT - 1 - Math.round(value2 / maxY * (CUM_HEIGHT - 1));
|
|
4192
|
+
});
|
|
4193
|
+
const color = series[i].color;
|
|
4194
|
+
for (let x = 0; x < CUM_WIDTH; x++) {
|
|
4195
|
+
const here = rows[x];
|
|
4196
|
+
const next = rows[Math.min(x + 1, CUM_WIDTH - 1)];
|
|
4197
|
+
if (here === next) {
|
|
4198
|
+
grid[here][x] = { ch: "\u2500", fg: color };
|
|
4199
|
+
continue;
|
|
4200
|
+
}
|
|
4201
|
+
grid[next][x] = { ch: next < here ? "\u256D" : "\u2570", fg: color };
|
|
4202
|
+
grid[here][x] = { ch: next < here ? "\u256F" : "\u256E", fg: color };
|
|
4203
|
+
for (let row = Math.min(here, next) + 1; row < Math.max(here, next); row++) {
|
|
4204
|
+
grid[row][x] = { ch: "\u2502", fg: color };
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
const topLabel = String(maxY);
|
|
4209
|
+
const yWidth = topLabel.length;
|
|
4210
|
+
const lines = grid.map((row, i) => {
|
|
4211
|
+
const label = i === 0 ? topLabel : i === CUM_HEIGHT - 1 ? "0" : "";
|
|
4212
|
+
const line = [
|
|
4213
|
+
{ text: `${label.padStart(yWidth)} `, fg: theme.muted },
|
|
4214
|
+
{ text: label === "" ? "\u2502" : "\u2524", fg: theme.dim }
|
|
4215
|
+
];
|
|
4216
|
+
for (const cell of row) {
|
|
4217
|
+
line.push(cell === null ? { text: " " } : { text: cell.ch, fg: cell.fg });
|
|
4218
|
+
}
|
|
4219
|
+
return line;
|
|
4220
|
+
});
|
|
4221
|
+
lines.push(
|
|
4222
|
+
[{ text: `${" ".repeat(yWidth)} \u2514${"\u2500".repeat(CUM_WIDTH)}`, fg: theme.dim }],
|
|
4223
|
+
weekAxisRow(
|
|
4224
|
+
yWidth + 2 + CUM_WIDTH,
|
|
4225
|
+
yWidth + 2,
|
|
4226
|
+
weekCount,
|
|
4227
|
+
(week) => Math.round(week / (weekCount - 1) * (CUM_WIDTH - 1)),
|
|
4228
|
+
(week) => first + week * WEEK_MS
|
|
4229
|
+
)
|
|
4230
|
+
);
|
|
4231
|
+
return { title, subtitle, lines };
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4005
4234
|
// src/tui/views/charts/distribution.ts
|
|
4006
4235
|
var DURATION_TICKS = [5 / 60, 0.25, 0.5, 1, 2, 4, 8, 24, 48, 96, 168, 336, 720, 2160];
|
|
4007
4236
|
var COUNT_TICKS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1e3, 2500, 5e3, 1e4, 25e3, 5e4, 1e5];
|
|
@@ -4322,39 +4551,20 @@ function buildSpreadCard(title, metrics, format) {
|
|
|
4322
4551
|
|
|
4323
4552
|
// src/tui/views/charts/trend.ts
|
|
4324
4553
|
var asciichart = __toESM(require_asciichart(), 1);
|
|
4325
|
-
|
|
4326
|
-
// src/tui/views/charts/weeks.ts
|
|
4327
|
-
var DAY_MS = 864e5;
|
|
4328
|
-
var WEEK_MS = 7 * DAY_MS;
|
|
4329
|
-
function mondayOf(dayUtcMs) {
|
|
4330
|
-
return dayUtcMs - (new Date(dayUtcMs).getUTCDay() + 6) % 7 * DAY_MS;
|
|
4331
|
-
}
|
|
4332
|
-
function dateLabel(ms) {
|
|
4333
|
-
return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
4334
|
-
}
|
|
4335
|
-
function weekAxisRow(width, prefix, points, columnOf, mondayOfPoint) {
|
|
4336
|
-
const cells = blankCells(width);
|
|
4337
|
-
const every = Math.ceil(points / 4);
|
|
4338
|
-
let lastEnd = -2;
|
|
4339
|
-
for (let point = 0; point < points; point += every) {
|
|
4340
|
-
const label = dateLabel(mondayOfPoint(point));
|
|
4341
|
-
const at = prefix + columnOf(point);
|
|
4342
|
-
if (at >= lastEnd + 2 && at + label.length <= width) {
|
|
4343
|
-
placeText(cells, at, label, theme.dim);
|
|
4344
|
-
lastEnd = at + label.length;
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4347
|
-
return mergeCells(cells);
|
|
4348
|
-
}
|
|
4349
|
-
|
|
4350
|
-
// src/tui/views/charts/trend.ts
|
|
4351
4554
|
var TREND_POINTS = 40;
|
|
4352
4555
|
var TREND_HEIGHT = 6;
|
|
4353
4556
|
function axisSplit(line) {
|
|
4354
4557
|
const positions = [line.indexOf("\u2524"), line.indexOf("\u253C")].filter((at) => at >= 0);
|
|
4355
4558
|
return positions.length === 0 ? 0 : Math.min(...positions) + 1;
|
|
4356
4559
|
}
|
|
4357
|
-
function buildTrendCard({
|
|
4560
|
+
function buildTrendCard({
|
|
4561
|
+
title,
|
|
4562
|
+
entries,
|
|
4563
|
+
format,
|
|
4564
|
+
floor = 1,
|
|
4565
|
+
scale = "log",
|
|
4566
|
+
valueLabel = "median"
|
|
4567
|
+
}) {
|
|
4358
4568
|
const byWeek = /* @__PURE__ */ new Map();
|
|
4359
4569
|
for (const entry of entries) {
|
|
4360
4570
|
const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
|
|
@@ -4365,10 +4575,11 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4365
4575
|
const mondays = [...byWeek.keys()];
|
|
4366
4576
|
const first = Math.min(...mondays);
|
|
4367
4577
|
const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
|
|
4578
|
+
const scaleSuffix = scale === "log" ? ", log scale" : "";
|
|
4368
4579
|
if (weekCount < 2) {
|
|
4369
4580
|
return {
|
|
4370
4581
|
title,
|
|
4371
|
-
subtitle:
|
|
4582
|
+
subtitle: `weekly ${valueLabel}${scaleSuffix}`,
|
|
4372
4583
|
lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]]
|
|
4373
4584
|
};
|
|
4374
4585
|
}
|
|
@@ -4386,9 +4597,11 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4386
4597
|
medians.push(medians.at(-1) ?? 0);
|
|
4387
4598
|
}
|
|
4388
4599
|
}
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
medians[i]
|
|
4600
|
+
if (scale === "log") {
|
|
4601
|
+
const firstKnown = medians.find((value2) => value2 > 0) ?? 0;
|
|
4602
|
+
for (let i = 0; i < medians.length && medians[i] === 0; i++) {
|
|
4603
|
+
medians[i] = firstKnown;
|
|
4604
|
+
}
|
|
4392
4605
|
}
|
|
4393
4606
|
const chunk = Math.ceil(weekCount / TREND_POINTS);
|
|
4394
4607
|
const points = [];
|
|
@@ -4398,23 +4611,23 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4398
4611
|
}
|
|
4399
4612
|
const stretch = Math.max(1, Math.floor(TREND_POINTS / points.length));
|
|
4400
4613
|
const series = points.flatMap((value2) => Array.from({ length: stretch }, () => value2));
|
|
4401
|
-
const
|
|
4402
|
-
let lo = Math.min(...
|
|
4403
|
-
let hi = Math.max(...
|
|
4614
|
+
const plotted = scale === "log" ? series.map((value2) => Math.log2(Math.max(value2, floor))) : series;
|
|
4615
|
+
let lo = Math.min(...plotted);
|
|
4616
|
+
let hi = Math.max(...plotted);
|
|
4404
4617
|
if (hi - lo < 1e-9) {
|
|
4405
4618
|
lo -= 1;
|
|
4406
4619
|
hi += 1;
|
|
4407
4620
|
}
|
|
4408
|
-
const chart = asciichart.plot(
|
|
4621
|
+
const chart = asciichart.plot(plotted, {
|
|
4409
4622
|
height: TREND_HEIGHT,
|
|
4410
4623
|
min: lo,
|
|
4411
4624
|
max: hi,
|
|
4412
|
-
format: (x) => format(2 ** x).padStart(6)
|
|
4625
|
+
format: (x) => format(scale === "log" ? 2 ** x : x).padStart(6)
|
|
4413
4626
|
});
|
|
4414
4627
|
const ratio = TREND_HEIGHT / (hi - lo);
|
|
4415
4628
|
const min2 = Math.round(lo * ratio);
|
|
4416
4629
|
const rows = Math.abs(Math.round(hi * ratio) - min2);
|
|
4417
|
-
const lastRow = Math.min(rows, Math.max(0, rows - (Math.round((
|
|
4630
|
+
const lastRow = Math.min(rows, Math.max(0, rows - (Math.round((plotted.at(-1) ?? 0) * ratio) - min2)));
|
|
4418
4631
|
const raw = chart.split("\n");
|
|
4419
4632
|
const lines = raw.map((line, i) => {
|
|
4420
4633
|
const split = axisSplit(line);
|
|
@@ -4437,7 +4650,7 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4437
4650
|
(point) => first + point * chunk * WEEK_MS
|
|
4438
4651
|
)
|
|
4439
4652
|
);
|
|
4440
|
-
const subtitle = chunk === 1 ?
|
|
4653
|
+
const subtitle = chunk === 1 ? `weekly ${valueLabel}${scaleSuffix}` : `${valueLabel} per ${chunk} weeks${scaleSuffix}`;
|
|
4441
4654
|
return { title, subtitle, lines };
|
|
4442
4655
|
}
|
|
4443
4656
|
|
|
@@ -4505,7 +4718,80 @@ function countCell(count2, label, dimWhenZero = false) {
|
|
|
4505
4718
|
{ text: ` ${label}`, fg: dim2 ? theme.dim : theme.muted }
|
|
4506
4719
|
];
|
|
4507
4720
|
}
|
|
4508
|
-
function
|
|
4721
|
+
function mondayNoon(monday) {
|
|
4722
|
+
return new Date(monday + 12 * 36e5);
|
|
4723
|
+
}
|
|
4724
|
+
function weeklySums(entries) {
|
|
4725
|
+
const byWeek = /* @__PURE__ */ new Map();
|
|
4726
|
+
for (const entry of entries) {
|
|
4727
|
+
const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
|
|
4728
|
+
byWeek.set(monday, (byWeek.get(monday) ?? 0) + entry.value);
|
|
4729
|
+
}
|
|
4730
|
+
const mondays = [...byWeek.keys()];
|
|
4731
|
+
const first = Math.min(...mondays);
|
|
4732
|
+
const last = Math.max(...mondays);
|
|
4733
|
+
const result = [];
|
|
4734
|
+
for (let monday = first; monday <= last; monday += WEEK_MS) {
|
|
4735
|
+
result.push({ date: mondayNoon(monday), value: byWeek.get(monday) ?? 0 });
|
|
4736
|
+
}
|
|
4737
|
+
return result;
|
|
4738
|
+
}
|
|
4739
|
+
function mergeRateEntries(stats) {
|
|
4740
|
+
const byWeek = /* @__PURE__ */ new Map();
|
|
4741
|
+
const add = (createdAt, merged) => {
|
|
4742
|
+
const monday = mondayOf(zonedStamp(createdAt).dayUtcMs);
|
|
4743
|
+
const counts = byWeek.get(monday) ?? { merged: 0, concluded: 0 };
|
|
4744
|
+
counts.concluded += 1;
|
|
4745
|
+
counts.merged += merged ? 1 : 0;
|
|
4746
|
+
byWeek.set(monday, counts);
|
|
4747
|
+
};
|
|
4748
|
+
for (const result of stats.merged) {
|
|
4749
|
+
add(result.entry.pr.createdAt, true);
|
|
4750
|
+
}
|
|
4751
|
+
for (const result of stats.closed) {
|
|
4752
|
+
add(result.entry.pr.createdAt, false);
|
|
4753
|
+
}
|
|
4754
|
+
return [...byWeek.entries()].map(([monday, counts]) => {
|
|
4755
|
+
return { date: mondayNoon(monday), value: counts.merged / counts.concluded * 100 };
|
|
4756
|
+
});
|
|
4757
|
+
}
|
|
4758
|
+
function buildOffHoursCard(subtitle, dates) {
|
|
4759
|
+
const counts = { work: 0, after: 0, weekend: 0 };
|
|
4760
|
+
for (const date of dates) {
|
|
4761
|
+
counts[classifyInstant(date)] += 1;
|
|
4762
|
+
}
|
|
4763
|
+
const rows = hasWorkWindows() ? [
|
|
4764
|
+
{ label: "work hours", count: counts.work, color: theme.accent },
|
|
4765
|
+
{ label: "after hours", count: counts.after, color: theme.warn },
|
|
4766
|
+
{ label: "weekend", count: counts.weekend, color: theme.chartDim }
|
|
4767
|
+
] : [
|
|
4768
|
+
{ label: "weekday", count: counts.work + counts.after, color: theme.accent },
|
|
4769
|
+
{ label: "weekend", count: counts.weekend, color: theme.chartDim }
|
|
4770
|
+
];
|
|
4771
|
+
return buildGaugeCard({ title: "Off-hours share", subtitle, rows });
|
|
4772
|
+
}
|
|
4773
|
+
function buildVerdictCard(reviewed) {
|
|
4774
|
+
const countOf = (state) => reviewed.filter((entry) => entry.verdict === state).length;
|
|
4775
|
+
const approved = countOf("APPROVED");
|
|
4776
|
+
const changes = countOf("CHANGES_REQUESTED");
|
|
4777
|
+
const commented = countOf("COMMENTED");
|
|
4778
|
+
const other = reviewed.length - approved - changes - commented;
|
|
4779
|
+
const counts = [
|
|
4780
|
+
{ label: "approved", count: approved },
|
|
4781
|
+
{ label: "changes requested", count: changes },
|
|
4782
|
+
{ label: "commented", count: commented },
|
|
4783
|
+
...other > 0 ? [{ label: "other", count: other }] : []
|
|
4784
|
+
];
|
|
4785
|
+
const max = Math.max(...counts.map((row) => row.count));
|
|
4786
|
+
return buildGaugeCard({
|
|
4787
|
+
title: "Review verdicts",
|
|
4788
|
+
subtitle: "how your requested reviews concluded",
|
|
4789
|
+
rows: counts.map((row) => {
|
|
4790
|
+
return { ...row, color: row.count === max ? theme.accent : theme.chartBar };
|
|
4791
|
+
})
|
|
4792
|
+
});
|
|
4793
|
+
}
|
|
4794
|
+
function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100, expanded = false) {
|
|
4509
4795
|
const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
|
|
4510
4796
|
const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
|
|
4511
4797
|
const strip = [
|
|
@@ -4519,9 +4805,10 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4519
4805
|
headline: null,
|
|
4520
4806
|
distributionTitle: "Review time distribution",
|
|
4521
4807
|
noCharts: "No completed reviews to chart.",
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4808
|
+
cards: [],
|
|
4809
|
+
distribution: null,
|
|
4810
|
+
expandable: false,
|
|
4811
|
+
expanded
|
|
4525
4812
|
};
|
|
4526
4813
|
if (results.length === 0) {
|
|
4527
4814
|
return { empty: "No reviewed or review-requested PRs found.", ...base, lists: [] };
|
|
@@ -4533,8 +4820,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4533
4820
|
rows: toPrRows(stats.misses, stats.misses.map(durationLead))
|
|
4534
4821
|
});
|
|
4535
4822
|
}
|
|
4823
|
+
const pendingCard = stats.pending.length === 0 ? null : buildHistogramCard({
|
|
4824
|
+
title: "Pending request age",
|
|
4825
|
+
subtitle: "how long open requests have waited",
|
|
4826
|
+
values: stats.pending.map((entry) => entry.hours),
|
|
4827
|
+
buckets: currentBuckets(),
|
|
4828
|
+
format: formatDuration
|
|
4829
|
+
});
|
|
4536
4830
|
if (stats.reviewed.length === 0) {
|
|
4537
|
-
return { empty: null, ...base, lists };
|
|
4831
|
+
return { empty: null, ...base, cards: pendingCard === null ? [] : [pendingCard], lists };
|
|
4538
4832
|
}
|
|
4539
4833
|
const sorted = [...stats.allHours].toSorted((a, b) => a - b);
|
|
4540
4834
|
const total = raw.reviewResults.filter((result) => result.kind === "reviewed").length;
|
|
@@ -4547,7 +4841,40 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4547
4841
|
];
|
|
4548
4842
|
const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
|
|
4549
4843
|
const reviewDates = stats.reviewed.map((entry) => entry.reviewedAt);
|
|
4550
|
-
const
|
|
4844
|
+
const requestAges = [...stats.reviewed, ...stats.pending].map(
|
|
4845
|
+
(entry) => durationHours(entry.pr.createdAt, entry.requestedAt)
|
|
4846
|
+
);
|
|
4847
|
+
let serviceCard = null;
|
|
4848
|
+
if (targetHours !== void 0 && targetLabel !== void 0) {
|
|
4849
|
+
const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
|
|
4850
|
+
const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
|
|
4851
|
+
serviceCard = buildGaugeCard({
|
|
4852
|
+
title: "Service level",
|
|
4853
|
+
subtitle: `reviewed within ${targetLabel}`,
|
|
4854
|
+
rows: [
|
|
4855
|
+
{ label: `inside ${targetLabel}`, count: inside, color: theme.accent },
|
|
4856
|
+
{ label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
|
|
4857
|
+
...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
|
|
4858
|
+
]
|
|
4859
|
+
});
|
|
4860
|
+
}
|
|
4861
|
+
const byRepoCard = repo === null && stats.byRepo.length > 1 ? buildBarsCard({
|
|
4862
|
+
title: "Review time by repo",
|
|
4863
|
+
subtitle: "median review time, slowest first",
|
|
4864
|
+
rows: stats.byRepo.map(([name, hours]) => {
|
|
4865
|
+
return {
|
|
4866
|
+
label: name,
|
|
4867
|
+
value: percentile(
|
|
4868
|
+
hours.toSorted((a, b) => a - b),
|
|
4869
|
+
50
|
|
4870
|
+
),
|
|
4871
|
+
detail: `n=${hours.length}`
|
|
4872
|
+
};
|
|
4873
|
+
}).toSorted((a, b) => b.value - a.value),
|
|
4874
|
+
format: formatDuration,
|
|
4875
|
+
expanded
|
|
4876
|
+
}) : null;
|
|
4877
|
+
const cards = [
|
|
4551
4878
|
buildHistogramCard({
|
|
4552
4879
|
title: "Time to review",
|
|
4553
4880
|
subtitle: "elapsed time, request \u2192 review",
|
|
@@ -4555,6 +4882,14 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4555
4882
|
buckets: currentBuckets(),
|
|
4556
4883
|
format: formatDuration
|
|
4557
4884
|
}),
|
|
4885
|
+
buildTrendCard({
|
|
4886
|
+
title: "Review time trend",
|
|
4887
|
+
entries: stats.reviewed.map((entry) => {
|
|
4888
|
+
return { date: entry.reviewedAt, value: entry.hours };
|
|
4889
|
+
}),
|
|
4890
|
+
format: formatDuration,
|
|
4891
|
+
floor: 1 / 60
|
|
4892
|
+
}),
|
|
4558
4893
|
buildHeatmapCard({
|
|
4559
4894
|
title: "When you review",
|
|
4560
4895
|
subtitle: "reviews submitted, weekday \xD7 hour, local time",
|
|
@@ -4564,34 +4899,28 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4564
4899
|
{ label: "req", dates: requestDates, muted: true }
|
|
4565
4900
|
],
|
|
4566
4901
|
legend: "reviews in that hour"
|
|
4567
|
-
})
|
|
4568
|
-
];
|
|
4569
|
-
const right = [
|
|
4570
|
-
buildTrendCard({
|
|
4571
|
-
title: "Review time trend",
|
|
4572
|
-
entries: stats.reviewed.map((entry) => {
|
|
4573
|
-
return { date: entry.reviewedAt, value: entry.hours };
|
|
4574
|
-
}),
|
|
4575
|
-
format: formatDuration,
|
|
4576
|
-
floor: 1 / 60
|
|
4577
4902
|
}),
|
|
4578
|
-
buildVolumeCard("Reviews completed per week", reviewDates)
|
|
4903
|
+
buildVolumeCard("Reviews completed per week", reviewDates),
|
|
4904
|
+
buildHistogramCard({
|
|
4905
|
+
title: "Review cycles per PR",
|
|
4906
|
+
subtitle: "completed request \u2192 review rounds per PR",
|
|
4907
|
+
values: stats.cycles,
|
|
4908
|
+
buckets: CYCLE_BUCKETS,
|
|
4909
|
+
format: count
|
|
4910
|
+
}),
|
|
4911
|
+
buildHistogramCard({
|
|
4912
|
+
title: "PR age at request",
|
|
4913
|
+
subtitle: "elapsed time, PR created \u2192 review requested",
|
|
4914
|
+
values: requestAges,
|
|
4915
|
+
buckets: currentBuckets(),
|
|
4916
|
+
format: formatDuration
|
|
4917
|
+
}),
|
|
4918
|
+
buildVerdictCard(stats.reviewed),
|
|
4919
|
+
...pendingCard === null ? [] : [pendingCard],
|
|
4920
|
+
buildOffHoursCard("reviews submitted, local time", reviewDates),
|
|
4921
|
+
...serviceCard === null ? [] : [serviceCard],
|
|
4922
|
+
...byRepoCard === null ? [] : [byRepoCard]
|
|
4579
4923
|
];
|
|
4580
|
-
if (targetHours !== void 0 && targetLabel !== void 0) {
|
|
4581
|
-
const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
|
|
4582
|
-
const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
|
|
4583
|
-
left.push(
|
|
4584
|
-
buildGaugeCard({
|
|
4585
|
-
title: "Service level",
|
|
4586
|
-
subtitle: `reviewed within ${targetLabel}`,
|
|
4587
|
-
rows: [
|
|
4588
|
-
{ label: `inside ${targetLabel}`, count: inside, color: theme.accent },
|
|
4589
|
-
{ label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
|
|
4590
|
-
...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
|
|
4591
|
-
]
|
|
4592
|
-
})
|
|
4593
|
-
);
|
|
4594
|
-
}
|
|
4595
4924
|
const distribution = buildDistribution({
|
|
4596
4925
|
values: stats.allHours,
|
|
4597
4926
|
width: Math.max(width - 3, 40),
|
|
@@ -4599,7 +4928,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4599
4928
|
ticks: DURATION_TICKS,
|
|
4600
4929
|
flat: (count2, value2) => `all ${count2} ${count2 === 1 ? "review" : "reviews"} took ${value2}`
|
|
4601
4930
|
});
|
|
4602
|
-
return {
|
|
4931
|
+
return {
|
|
4932
|
+
empty: null,
|
|
4933
|
+
...base,
|
|
4934
|
+
headline,
|
|
4935
|
+
cards,
|
|
4936
|
+
distribution,
|
|
4937
|
+
lists,
|
|
4938
|
+
expandable: byRepoCard !== null && stats.byRepo.length > MAX_BARS
|
|
4939
|
+
};
|
|
4603
4940
|
}
|
|
4604
4941
|
function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
4605
4942
|
const base = {
|
|
@@ -4607,10 +4944,11 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4607
4944
|
headline: null,
|
|
4608
4945
|
distributionTitle: "PR size distribution",
|
|
4609
4946
|
noCharts: "No authored PRs to chart.",
|
|
4610
|
-
|
|
4611
|
-
right: [],
|
|
4947
|
+
cards: [],
|
|
4612
4948
|
distribution: null,
|
|
4613
|
-
lists: []
|
|
4949
|
+
lists: [],
|
|
4950
|
+
expandable: false,
|
|
4951
|
+
expanded: false
|
|
4614
4952
|
};
|
|
4615
4953
|
if (raw.authoredTotal === 0) {
|
|
4616
4954
|
return { empty: "No authored PRs found.", ...base };
|
|
@@ -4639,7 +4977,15 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4639
4977
|
{ text: `${count(percentile(sorted, 90))} lines`, fg: theme.accent },
|
|
4640
4978
|
{ text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
|
|
4641
4979
|
];
|
|
4642
|
-
const
|
|
4980
|
+
const targetCard = stats.met !== void 0 && stats.targetLabel !== void 0 ? buildGaugeCard({
|
|
4981
|
+
title: "Size target",
|
|
4982
|
+
subtitle: `authored within ${stats.targetLabel}`,
|
|
4983
|
+
rows: [
|
|
4984
|
+
{ label: "inside target", count: stats.met, color: theme.accent },
|
|
4985
|
+
{ label: "over target", count: sizes.length - stats.met, color: theme.chartDim }
|
|
4986
|
+
]
|
|
4987
|
+
}) : null;
|
|
4988
|
+
const cards = [
|
|
4643
4989
|
buildHistogramCard({
|
|
4644
4990
|
title: "PR size",
|
|
4645
4991
|
subtitle: "total lines changed per authored PR",
|
|
@@ -4647,6 +4993,14 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4647
4993
|
buckets: LINE_BUCKETS,
|
|
4648
4994
|
format: count
|
|
4649
4995
|
}),
|
|
4996
|
+
buildTrendCard({
|
|
4997
|
+
title: "PR size trend",
|
|
4998
|
+
entries: sizes.map((size) => {
|
|
4999
|
+
return { date: size.pr.createdAt, value: size.total };
|
|
5000
|
+
}),
|
|
5001
|
+
format: count,
|
|
5002
|
+
floor: 1
|
|
5003
|
+
}),
|
|
4650
5004
|
buildHistogramCard({
|
|
4651
5005
|
title: "Files touched",
|
|
4652
5006
|
subtitle: "files changed per authored PR",
|
|
@@ -4654,38 +5008,28 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4654
5008
|
buckets: FILE_BUCKETS,
|
|
4655
5009
|
format: count
|
|
4656
5010
|
}),
|
|
5011
|
+
buildTrendCard({
|
|
5012
|
+
title: "Net lines trend",
|
|
5013
|
+
entries: weeklySums(
|
|
5014
|
+
sizes.map((size) => {
|
|
5015
|
+
return { date: size.pr.createdAt, value: size.additions - size.deletions };
|
|
5016
|
+
})
|
|
5017
|
+
),
|
|
5018
|
+
format: netCount,
|
|
5019
|
+
scale: "linear",
|
|
5020
|
+
valueLabel: "net lines"
|
|
5021
|
+
}),
|
|
4657
5022
|
buildHeatmapCard({
|
|
4658
5023
|
title: "When you open PRs",
|
|
4659
5024
|
subtitle: "PRs opened, weekday \xD7 hour, local time",
|
|
4660
5025
|
grid: created,
|
|
4661
5026
|
columns: [{ label: "opened", dates: created }],
|
|
4662
5027
|
legend: "PRs opened in that hour"
|
|
4663
|
-
})
|
|
4664
|
-
];
|
|
4665
|
-
const right = [
|
|
4666
|
-
buildTrendCard({
|
|
4667
|
-
title: "PR size trend",
|
|
4668
|
-
entries: sizes.map((size) => {
|
|
4669
|
-
return { date: size.pr.createdAt, value: size.total };
|
|
4670
|
-
}),
|
|
4671
|
-
format: count,
|
|
4672
|
-
floor: 1
|
|
4673
5028
|
}),
|
|
4674
5029
|
buildVolumeCard("PRs opened per week", created),
|
|
5030
|
+
...targetCard === null ? [] : [targetCard],
|
|
4675
5031
|
buildSpreadCard("Size spread", stats.metrics, count)
|
|
4676
5032
|
];
|
|
4677
|
-
if (stats.met !== void 0 && stats.targetLabel !== void 0) {
|
|
4678
|
-
left.push(
|
|
4679
|
-
buildGaugeCard({
|
|
4680
|
-
title: "Size target",
|
|
4681
|
-
subtitle: `authored within ${stats.targetLabel}`,
|
|
4682
|
-
rows: [
|
|
4683
|
-
{ label: "inside target", count: stats.met, color: theme.accent },
|
|
4684
|
-
{ label: "over target", count: sizes.length - stats.met, color: theme.chartDim }
|
|
4685
|
-
]
|
|
4686
|
-
})
|
|
4687
|
-
);
|
|
4688
|
-
}
|
|
4689
5033
|
const distribution = buildDistribution({
|
|
4690
5034
|
values: totals,
|
|
4691
5035
|
width: Math.max(width - 3, 40),
|
|
@@ -4703,7 +5047,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4703
5047
|
)
|
|
4704
5048
|
});
|
|
4705
5049
|
}
|
|
4706
|
-
return { empty: null, ...base, strip, headline,
|
|
5050
|
+
return { empty: null, ...base, strip, headline, cards, distribution, lists };
|
|
4707
5051
|
}
|
|
4708
5052
|
function buildCommentView(raw, repo = null, width = 100) {
|
|
4709
5053
|
const base = {
|
|
@@ -4711,10 +5055,11 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4711
5055
|
headline: null,
|
|
4712
5056
|
distributionTitle: "Comments per PR distribution",
|
|
4713
5057
|
noCharts: "No authored PRs to chart.",
|
|
4714
|
-
|
|
4715
|
-
right: [],
|
|
5058
|
+
cards: [],
|
|
4716
5059
|
distribution: null,
|
|
4717
|
-
lists: []
|
|
5060
|
+
lists: [],
|
|
5061
|
+
expandable: false,
|
|
5062
|
+
expanded: false
|
|
4718
5063
|
};
|
|
4719
5064
|
if (raw.authoredTotal === 0) {
|
|
4720
5065
|
return { empty: "No authored PRs found.", ...base };
|
|
@@ -4742,7 +5087,7 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4742
5087
|
{ text: `${count(percentile(sorted, 90))} comments`, fg: theme.accent },
|
|
4743
5088
|
{ text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
|
|
4744
5089
|
];
|
|
4745
|
-
const
|
|
5090
|
+
const cards = [
|
|
4746
5091
|
buildHistogramCard({
|
|
4747
5092
|
title: "Comments per PR",
|
|
4748
5093
|
subtitle: "discussion plus review comments per authored PR",
|
|
@@ -4750,6 +5095,14 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4750
5095
|
buckets: COMMENT_BUCKETS,
|
|
4751
5096
|
format: count
|
|
4752
5097
|
}),
|
|
5098
|
+
buildTrendCard({
|
|
5099
|
+
title: "Comment trend",
|
|
5100
|
+
entries: sizes.map((size) => {
|
|
5101
|
+
return { date: size.pr.createdAt, value: size.comments.total };
|
|
5102
|
+
}),
|
|
5103
|
+
format: count,
|
|
5104
|
+
floor: 1
|
|
5105
|
+
}),
|
|
4753
5106
|
buildScatterCard({
|
|
4754
5107
|
title: "Comments vs size",
|
|
4755
5108
|
subtitle: "comments against lines changed, log scale",
|
|
@@ -4759,18 +5112,8 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4759
5112
|
formatX: count,
|
|
4760
5113
|
formatY: count
|
|
4761
5114
|
}),
|
|
4762
|
-
buildSpreadCard("Comment spread", stats.metrics, count)
|
|
4763
|
-
];
|
|
4764
|
-
const right = [
|
|
4765
|
-
buildTrendCard({
|
|
4766
|
-
title: "Comment trend",
|
|
4767
|
-
entries: sizes.map((size) => {
|
|
4768
|
-
return { date: size.pr.createdAt, value: size.comments.total };
|
|
4769
|
-
}),
|
|
4770
|
-
format: count,
|
|
4771
|
-
floor: 1
|
|
4772
|
-
}),
|
|
4773
5115
|
buildVolumeCard("Comments received per week", created, stats.totals),
|
|
5116
|
+
buildSpreadCard("Comment spread", stats.metrics, count),
|
|
4774
5117
|
buildGaugeCard({
|
|
4775
5118
|
title: "Feedback rate",
|
|
4776
5119
|
subtitle: "authored PRs that received comments",
|
|
@@ -4800,18 +5143,19 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4800
5143
|
)
|
|
4801
5144
|
});
|
|
4802
5145
|
}
|
|
4803
|
-
return { empty: null, ...base, strip, headline,
|
|
5146
|
+
return { empty: null, ...base, strip, headline, cards, distribution, lists };
|
|
4804
5147
|
}
|
|
4805
|
-
function buildMergedView(raw, repo = null, width = 100) {
|
|
5148
|
+
function buildMergedView(raw, repo = null, width = 100, expanded = false) {
|
|
4806
5149
|
const base = {
|
|
4807
5150
|
strip: [],
|
|
4808
5151
|
headline: null,
|
|
4809
5152
|
distributionTitle: "Time to merge distribution",
|
|
4810
5153
|
noCharts: "No merged PRs to chart.",
|
|
4811
|
-
|
|
4812
|
-
right: [],
|
|
5154
|
+
cards: [],
|
|
4813
5155
|
distribution: null,
|
|
4814
|
-
lists: []
|
|
5156
|
+
lists: [],
|
|
5157
|
+
expandable: false,
|
|
5158
|
+
expanded
|
|
4815
5159
|
};
|
|
4816
5160
|
if (raw.authoredTotal === 0) {
|
|
4817
5161
|
return { empty: "No authored PRs found.", ...base };
|
|
@@ -4821,6 +5165,7 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4821
5165
|
return { empty: "No accessible authored PRs to analyze.", ...base };
|
|
4822
5166
|
}
|
|
4823
5167
|
const stats = computeMergeStats(sizes);
|
|
5168
|
+
const reviewers = computeReviewerStats(sizes, raw.user);
|
|
4824
5169
|
const strip = [
|
|
4825
5170
|
countCell(sizes.length, "PRs created"),
|
|
4826
5171
|
countCell(stats.merged.length, "merged"),
|
|
@@ -4851,8 +5196,22 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4851
5196
|
)
|
|
4852
5197
|
});
|
|
4853
5198
|
}
|
|
5199
|
+
const reviewerCard = reviewers.leaderboard.length === 0 ? null : buildBarsCard({
|
|
5200
|
+
title: "Who reviews your PRs",
|
|
5201
|
+
subtitle: "distinct PRs reviewed per person",
|
|
5202
|
+
rows: reviewers.leaderboard.map((row) => {
|
|
5203
|
+
return {
|
|
5204
|
+
label: row.login,
|
|
5205
|
+
value: row.prs,
|
|
5206
|
+
detail: row.reviews === 1 ? "1 review" : `${row.reviews} reviews`
|
|
5207
|
+
};
|
|
5208
|
+
}),
|
|
5209
|
+
format: count,
|
|
5210
|
+
expanded
|
|
5211
|
+
});
|
|
5212
|
+
const expandable = reviewers.leaderboard.length > MAX_BARS;
|
|
4854
5213
|
if (stats.merged.length === 0) {
|
|
4855
|
-
return { empty: null, ...base, strip, lists };
|
|
5214
|
+
return { empty: null, ...base, strip, cards: reviewerCard === null ? [] : [reviewerCard], lists, expandable };
|
|
4856
5215
|
}
|
|
4857
5216
|
const sorted = [...stats.allHours].toSorted((a, b) => a - b);
|
|
4858
5217
|
const headline = [
|
|
@@ -4864,7 +5223,7 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4864
5223
|
];
|
|
4865
5224
|
const mergeDates = stats.merged.map((result) => result.mergedAt);
|
|
4866
5225
|
const created = sizes.map((size) => size.pr.createdAt);
|
|
4867
|
-
const
|
|
5226
|
+
const cards = [
|
|
4868
5227
|
buildHistogramCard({
|
|
4869
5228
|
title: "Time to merge",
|
|
4870
5229
|
subtitle: "elapsed time, created \u2192 merged",
|
|
@@ -4872,6 +5231,14 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4872
5231
|
buckets: currentBuckets(),
|
|
4873
5232
|
format: formatDuration
|
|
4874
5233
|
}),
|
|
5234
|
+
buildTrendCard({
|
|
5235
|
+
title: "Time to merge trend",
|
|
5236
|
+
entries: stats.merged.map((result) => {
|
|
5237
|
+
return { date: result.mergedAt, value: result.hours };
|
|
5238
|
+
}),
|
|
5239
|
+
format: formatDuration,
|
|
5240
|
+
floor: 1 / 60
|
|
5241
|
+
}),
|
|
4875
5242
|
buildHeatmapCard({
|
|
4876
5243
|
title: "When your PRs merge",
|
|
4877
5244
|
subtitle: "PRs merged, weekday \xD7 hour, local time",
|
|
@@ -4882,6 +5249,38 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4882
5249
|
],
|
|
4883
5250
|
legend: "PRs merged in that hour"
|
|
4884
5251
|
}),
|
|
5252
|
+
buildTrendCard({
|
|
5253
|
+
title: "Merge rate trend",
|
|
5254
|
+
entries: mergeRateEntries(stats),
|
|
5255
|
+
format: (value2) => `${Math.round(value2)}%`,
|
|
5256
|
+
scale: "linear",
|
|
5257
|
+
valueLabel: "merge rate"
|
|
5258
|
+
}),
|
|
5259
|
+
buildScatterCard({
|
|
5260
|
+
title: "Merge time vs size",
|
|
5261
|
+
subtitle: "time to merge against lines changed, log scale",
|
|
5262
|
+
points: stats.merged.map((result) => {
|
|
5263
|
+
return { x: result.entry.total, y: result.hours };
|
|
5264
|
+
}),
|
|
5265
|
+
formatX: count,
|
|
5266
|
+
formatY: formatDuration
|
|
5267
|
+
}),
|
|
5268
|
+
buildCumulativeCard({
|
|
5269
|
+
title: "Created vs merged",
|
|
5270
|
+
series: [
|
|
5271
|
+
/**
|
|
5272
|
+
* The created line stays a neutral gray because every theme preset
|
|
5273
|
+
* keeps chartLine and accent in one hue family, which made the two
|
|
5274
|
+
* lines indistinguishable. The gray also survives theme changes,
|
|
5275
|
+
* since the presets only rotate the hue-carrying colors.
|
|
5276
|
+
*/
|
|
5277
|
+
{ label: "created", dates: created, color: theme.muted },
|
|
5278
|
+
{ label: "merged", dates: mergeDates, color: theme.accent }
|
|
5279
|
+
],
|
|
5280
|
+
legend: "cumulative PRs by week"
|
|
5281
|
+
}),
|
|
5282
|
+
buildVolumeCard("PRs created per week", created),
|
|
5283
|
+
buildVolumeCard("PRs merged per week", mergeDates),
|
|
4885
5284
|
buildGaugeCard({
|
|
4886
5285
|
title: "Outcomes",
|
|
4887
5286
|
subtitle: "where your authored PRs ended up",
|
|
@@ -4890,19 +5289,16 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4890
5289
|
{ label: "closed unmerged", count: stats.closed.length, color: theme.warn },
|
|
4891
5290
|
...stats.open.length > 0 ? [{ label: "still open", count: stats.open.length, color: theme.chartDim }] : []
|
|
4892
5291
|
]
|
|
4893
|
-
})
|
|
4894
|
-
];
|
|
4895
|
-
const right = [
|
|
4896
|
-
buildTrendCard({
|
|
4897
|
-
title: "Time to merge trend",
|
|
4898
|
-
entries: stats.merged.map((result) => {
|
|
4899
|
-
return { date: result.mergedAt, value: result.hours };
|
|
4900
|
-
}),
|
|
4901
|
-
format: formatDuration,
|
|
4902
|
-
floor: 1 / 60
|
|
4903
5292
|
}),
|
|
4904
|
-
|
|
4905
|
-
|
|
5293
|
+
buildGaugeCard({
|
|
5294
|
+
title: "Review coverage",
|
|
5295
|
+
subtitle: "merged PRs that received a review",
|
|
5296
|
+
rows: [
|
|
5297
|
+
{ label: "reviewed", count: reviewers.mergedReviewed, color: theme.accent },
|
|
5298
|
+
{ label: "merged unreviewed", count: reviewers.mergedUnreviewed, color: theme.warn }
|
|
5299
|
+
]
|
|
5300
|
+
}),
|
|
5301
|
+
...reviewerCard === null ? [] : [reviewerCard]
|
|
4906
5302
|
];
|
|
4907
5303
|
const distribution = buildDistribution({
|
|
4908
5304
|
values: stats.allHours,
|
|
@@ -4911,11 +5307,14 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4911
5307
|
ticks: DURATION_TICKS,
|
|
4912
5308
|
flat: (n, value2) => `all ${n} merged ${n === 1 ? "PR" : "PRs"} took ${value2}`
|
|
4913
5309
|
});
|
|
4914
|
-
return { empty: null, ...base, strip, headline,
|
|
5310
|
+
return { empty: null, ...base, strip, headline, cards, distribution, lists, expandable };
|
|
4915
5311
|
}
|
|
4916
5312
|
function count(value2) {
|
|
4917
5313
|
return formatCount(Math.round(value2));
|
|
4918
5314
|
}
|
|
5315
|
+
function netCount(value2) {
|
|
5316
|
+
return value2 < 0 ? `-${count(-value2)}` : `+${count(value2)}`;
|
|
5317
|
+
}
|
|
4919
5318
|
|
|
4920
5319
|
// src/tui/hooks/useViewModel.ts
|
|
4921
5320
|
function resolveScope(scope, repos) {
|
|
@@ -4924,7 +5323,7 @@ function resolveScope(scope, repos) {
|
|
|
4924
5323
|
}
|
|
4925
5324
|
return dropVanishedRepo(scope, repos);
|
|
4926
5325
|
}
|
|
4927
|
-
function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
|
|
5326
|
+
function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoch) {
|
|
4928
5327
|
return useMemo(() => {
|
|
4929
5328
|
void themeEpoch;
|
|
4930
5329
|
configureTimeMode({
|
|
@@ -4950,7 +5349,7 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
|
|
|
4950
5349
|
const reviewScope = resolveScope(scopes.review, reviewRepos);
|
|
4951
5350
|
const sizeScope = resolveScope(scopes.size, sizeRepos);
|
|
4952
5351
|
const commentScope = resolveScope(scopes.comment, commentRepos);
|
|
4953
|
-
const review = reviewScope.view === "detail" ? buildReviewView(raw, targetHours, targetLabelOf(options.target), reviewScope.repo, width) : null;
|
|
5352
|
+
const review = reviewScope.view === "detail" ? buildReviewView(raw, targetHours, targetLabelOf(options.target), reviewScope.repo, width, expanded.review) : null;
|
|
4954
5353
|
return {
|
|
4955
5354
|
pendingRepos,
|
|
4956
5355
|
openRepos,
|
|
@@ -4966,7 +5365,7 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
|
|
|
4966
5365
|
commentScope,
|
|
4967
5366
|
pending: pendingScope.view === "detail" ? buildPendingReviewView(raw, pendingScope.repo, grouping.pending) : null,
|
|
4968
5367
|
open: openScope.view === "detail" ? buildOpenAuthoredView(raw, openScope.repo, grouping.open) : null,
|
|
4969
|
-
merged: mergedScope.view === "detail" ? buildMergedView(raw, mergedScope.repo, width) : null,
|
|
5368
|
+
merged: mergedScope.view === "detail" ? buildMergedView(raw, mergedScope.repo, width, expanded.merged) : null,
|
|
4970
5369
|
review,
|
|
4971
5370
|
size: sizeScope.view === "detail" ? buildSizeView(raw, sizeTarget, sizeScope.repo, width) : null,
|
|
4972
5371
|
comments: commentScope.view === "detail" ? buildCommentView(raw, commentScope.repo, width) : null
|
|
@@ -4987,6 +5386,8 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
|
|
|
4987
5386
|
scopes.comment,
|
|
4988
5387
|
grouping.pending,
|
|
4989
5388
|
grouping.open,
|
|
5389
|
+
expanded.review,
|
|
5390
|
+
expanded.merged,
|
|
4990
5391
|
themeEpoch
|
|
4991
5392
|
]);
|
|
4992
5393
|
}
|
|
@@ -5216,31 +5617,35 @@ function statsTabOf(context) {
|
|
|
5216
5617
|
return {
|
|
5217
5618
|
key: "merged",
|
|
5218
5619
|
repos: views?.mergedRepos ?? [],
|
|
5219
|
-
scope: views?.mergedScope ?? null
|
|
5620
|
+
scope: views?.mergedScope ?? null,
|
|
5621
|
+
view: views?.merged ?? null
|
|
5220
5622
|
};
|
|
5221
5623
|
}
|
|
5222
5624
|
if (context.browse.tab === 2) {
|
|
5223
5625
|
return {
|
|
5224
5626
|
key: "review",
|
|
5225
5627
|
repos: views?.reviewRepos ?? [],
|
|
5226
|
-
scope: views?.reviewScope ?? null
|
|
5628
|
+
scope: views?.reviewScope ?? null,
|
|
5629
|
+
view: views?.review ?? null
|
|
5227
5630
|
};
|
|
5228
5631
|
}
|
|
5229
5632
|
if (context.browse.tab === 3) {
|
|
5230
5633
|
return {
|
|
5231
5634
|
key: "size",
|
|
5232
5635
|
repos: views?.sizeRepos ?? [],
|
|
5233
|
-
scope: views?.sizeScope ?? null
|
|
5636
|
+
scope: views?.sizeScope ?? null,
|
|
5637
|
+
view: views?.size ?? null
|
|
5234
5638
|
};
|
|
5235
5639
|
}
|
|
5236
5640
|
return {
|
|
5237
5641
|
key: "comment",
|
|
5238
5642
|
repos: views?.commentRepos ?? [],
|
|
5239
|
-
scope: views?.commentScope ?? null
|
|
5643
|
+
scope: views?.commentScope ?? null,
|
|
5644
|
+
view: views?.comments ?? null
|
|
5240
5645
|
};
|
|
5241
5646
|
}
|
|
5242
5647
|
function handleStatsKey(key, context) {
|
|
5243
|
-
const { key: tab, repos, scope } = statsTabOf(context);
|
|
5648
|
+
const { key: tab, repos, scope, view } = statsTabOf(context);
|
|
5244
5649
|
if (scope !== null && scope.view === "list") {
|
|
5245
5650
|
switch (key.name) {
|
|
5246
5651
|
case "up":
|
|
@@ -5261,6 +5666,8 @@ function handleStatsKey(key, context) {
|
|
|
5261
5666
|
}
|
|
5262
5667
|
} else if ((key.name === "escape" || key.name === "backspace") && repos.length > 0) {
|
|
5263
5668
|
context.dispatchBrowse({ type: "pickerReturned", tab });
|
|
5669
|
+
} else if (key.name === "x" && view?.expandable === true) {
|
|
5670
|
+
context.dispatchBrowse({ type: "expandToggled", tab });
|
|
5264
5671
|
} else if (key.name === "j") {
|
|
5265
5672
|
context.scrollBy(context.browse.tab, 2);
|
|
5266
5673
|
} else if (key.name === "k") {
|
|
@@ -5526,7 +5933,7 @@ function App({
|
|
|
5526
5933
|
}
|
|
5527
5934
|
});
|
|
5528
5935
|
});
|
|
5529
|
-
const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, themeState);
|
|
5936
|
+
const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, browse.expanded, themeState);
|
|
5530
5937
|
const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
|
|
5531
5938
|
const draftRef = useRef5("");
|
|
5532
5939
|
const commitField = () => {
|