@d3lm/pr-stats 0.2.4 → 0.2.6
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 +2 -2
- package/dist/tui-app.mjs +219 -116
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pr-stats
|
|
2
2
|
|
|
3
|
-
An interactive terminal UI, built with [OpenTUI](https://github.com/anomalyco/opentui) and React, that shows statistics for a GitHub user, e.g., time to review, size of authored PRs, or comments received on them. It works with any repository your GitHub login can see. It has a queue tab for the open PRs awaiting your review, a Your PRs tab that splits into your open PRs and a merged-and-closed report, chart tabs for the review-time, size, and comments reports, and a live options modal.
|
|
3
|
+
An interactive terminal UI, built with [OpenTUI](https://github.com/anomalyco/opentui) and React, that shows statistics for a GitHub user, e.g., time to review, size of authored PRs, or comments received on them. It works with any repository your GitHub login can see. It has a queue tab for the open PRs on your reviewing plate, split into those awaiting your review and those you already reviewed, a Your PRs tab that splits into your open PRs and a merged-and-closed report, chart tabs for the review-time, size, and comments reports, and a live options modal.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -25,7 +25,7 @@ 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. It opens on a tab
|
|
28
|
+
Without flags, it looks at PRs from the last 90 days across all repositories you can access. It opens on a tab with two lists, the open PRs awaiting your review with how long each has been waiting, and below them 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 Your PRs tab next to it 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 charts how your authored PRs got created, merged, and closed, telling a merge apart from a close without one, with time-to-merge percentiles, a histogram and trend, a merge-time heatmap, weekly created and merged volumes, an outcome gauge, and the most recently merged and closed PRs. The remaining tabs hold the time-to-review report, the PR size report, and the comments report with a histogram of comments per PR, a scatter of comments against PR size, and the most commented PRs. 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.
|
|
29
29
|
|
|
30
30
|
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
31
|
|
package/dist/tui-app.mjs
CHANGED
|
@@ -787,6 +787,24 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
|
|
|
787
787
|
}
|
|
788
788
|
}
|
|
789
789
|
pending.sort((a, b) => a.requestedAt.getTime() - b.requestedAt.getTime());
|
|
790
|
+
const pendingKeys = new Set(pending.map((entry) => `${entry.pr.repo}#${entry.pr.number}`));
|
|
791
|
+
const latestReviews = /* @__PURE__ */ new Map();
|
|
792
|
+
for (const result of results) {
|
|
793
|
+
if (result.kind !== "reviewed" && result.kind !== "unrequested" || result.pr.state !== "open") {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const key = `${result.pr.repo}#${result.pr.number}`;
|
|
797
|
+
if (pendingKeys.has(key)) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const latest = latestReviews.get(key);
|
|
801
|
+
if (latest === void 0 || result.reviewedAt > latest.reviewedAt) {
|
|
802
|
+
latestReviews.set(key, { pr: result.pr, reviewedAt: result.reviewedAt });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const reviewing = [...latestReviews.values()].map(({ pr, reviewedAt }) => {
|
|
806
|
+
return { pr, reviewedAt, hours: durationHours(reviewedAt, now) };
|
|
807
|
+
}).toSorted((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
|
|
790
808
|
const expired = results.filter((result) => result.kind === "pending" && result.pr.state !== "open");
|
|
791
809
|
const unrequested = results.filter((result) => result.kind === "unrequested");
|
|
792
810
|
const allHours = reviewed.map((result) => result.hours);
|
|
@@ -798,7 +816,7 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
|
|
|
798
816
|
}
|
|
799
817
|
const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
|
|
800
818
|
const misses = targetHours === void 0 ? [] : reviewed.filter((result) => result.hours > targetHours).toSorted((a, b) => b.hours - a.hours);
|
|
801
|
-
return { reviewed, pending, expired, unrequested, allHours, byRepo, misses };
|
|
819
|
+
return { reviewed, pending, reviewing, expired, unrequested, allHours, byRepo, misses };
|
|
802
820
|
}
|
|
803
821
|
function computeSizeStats(sizes, { sizeTarget } = {}) {
|
|
804
822
|
const metrics = [
|
|
@@ -948,7 +966,7 @@ function toPrRows(entries, leads) {
|
|
|
948
966
|
|
|
949
967
|
// src/tui/views/queue.ts
|
|
950
968
|
function queueRows(view) {
|
|
951
|
-
return view.lists.flatMap((list) => list.rows);
|
|
969
|
+
return view.sections.flatMap((section) => [...section.rows, ...section.lists.flatMap((list) => list.rows)]);
|
|
952
970
|
}
|
|
953
971
|
function groupedLists(entries, rowsOf2) {
|
|
954
972
|
const groups = /* @__PURE__ */ new Map();
|
|
@@ -962,23 +980,26 @@ function groupedLists(entries, rowsOf2) {
|
|
|
962
980
|
});
|
|
963
981
|
}
|
|
964
982
|
function buildPendingReviewView(raw, repo = null, grouped = false) {
|
|
965
|
-
const
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
}
|
|
983
|
+
const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
|
|
984
|
+
const awaiting = repo === null ? stats.pending : stats.pending.filter((entry) => entry.pr.repo === repo);
|
|
985
|
+
const reviewing = repo === null ? stats.reviewing : stats.reviewing.filter((entry) => entry.pr.repo === repo);
|
|
986
|
+
if (awaiting.length === 0 && reviewing.length === 0) {
|
|
987
|
+
return { empty: "No PRs are awaiting your review, and none you reviewed are still open.", sections: [] };
|
|
988
|
+
}
|
|
989
|
+
const split = repo === null && grouped;
|
|
990
|
+
const sectionOf = (title, entries) => split ? { title, rows: [], lists: groupedLists(entries, rowsOf) } : { title, rows: rowsOf(entries), lists: [] };
|
|
973
991
|
return {
|
|
974
992
|
empty: null,
|
|
975
|
-
|
|
993
|
+
sections: [
|
|
994
|
+
...awaiting.length === 0 ? [] : [sectionOf(`Awaiting your review (n=${awaiting.length})`, awaiting)],
|
|
995
|
+
...reviewing.length === 0 ? [] : [sectionOf(`Reviewing (n=${reviewing.length})`, reviewing)]
|
|
996
|
+
]
|
|
976
997
|
};
|
|
977
998
|
}
|
|
978
999
|
function buildOpenAuthoredView(raw, repo = null, grouped = false) {
|
|
979
1000
|
const open = raw.sizes.filter((entry) => entry.pr.state === "open" && (repo === null || entry.pr.repo === repo)).toSorted((a, b) => a.pr.createdAt.getTime() - b.pr.createdAt.getTime());
|
|
980
1001
|
if (open.length === 0) {
|
|
981
|
-
return { empty: "No open authored PRs found.",
|
|
1002
|
+
return { empty: "No open authored PRs found.", sections: [] };
|
|
982
1003
|
}
|
|
983
1004
|
const rowsOf2 = (group) => {
|
|
984
1005
|
const ages = group.map((entry) => durationLead({ hours: durationHours(entry.pr.createdAt, raw.fetchedAt) }));
|
|
@@ -990,13 +1011,11 @@ function buildOpenAuthoredView(raw, repo = null, grouped = false) {
|
|
|
990
1011
|
)
|
|
991
1012
|
);
|
|
992
1013
|
};
|
|
1014
|
+
const title = `Your open authored PRs (n=${open.length})`;
|
|
993
1015
|
if (repo === null && grouped) {
|
|
994
|
-
return { empty: null, lists: groupedLists(open, rowsOf2) };
|
|
1016
|
+
return { empty: null, sections: [{ title, rows: [], lists: groupedLists(open, rowsOf2) }] };
|
|
995
1017
|
}
|
|
996
|
-
return {
|
|
997
|
-
empty: null,
|
|
998
|
-
lists: [{ title: `Your open authored PRs (n=${open.length})`, rows: rowsOf2(open) }]
|
|
999
|
-
};
|
|
1018
|
+
return { empty: null, sections: [{ title, rows: rowsOf2(open), lists: [] }] };
|
|
1000
1019
|
}
|
|
1001
1020
|
function rowsOf(group) {
|
|
1002
1021
|
return toPrRows(
|
|
@@ -1178,7 +1197,10 @@ function Placeholder({
|
|
|
1178
1197
|
}
|
|
1179
1198
|
function LoadProgress({ load }) {
|
|
1180
1199
|
if (!load.total) {
|
|
1181
|
-
return /* @__PURE__ */
|
|
1200
|
+
return /* @__PURE__ */ jsxs4("box", { flexDirection: "row", columnGap: 1, children: [
|
|
1201
|
+
/* @__PURE__ */ jsx5("text", { fg: theme.muted, children: loadLabel(load) }),
|
|
1202
|
+
/* @__PURE__ */ jsx5(Spinner, {})
|
|
1203
|
+
] });
|
|
1182
1204
|
}
|
|
1183
1205
|
const done = load.done ?? 0;
|
|
1184
1206
|
const cells = Math.min(done / load.total, 1) * BAR_WIDTH;
|
|
@@ -1196,7 +1218,7 @@ function LoadProgress({ load }) {
|
|
|
1196
1218
|
] });
|
|
1197
1219
|
}
|
|
1198
1220
|
function loadLabel(load) {
|
|
1199
|
-
return load.phase === "search" ? "searching PRs
|
|
1221
|
+
return load.phase === "search" ? "searching PRs" : "fetching PR details";
|
|
1200
1222
|
}
|
|
1201
1223
|
|
|
1202
1224
|
// src/tui/components/QueuePanel.tsx
|
|
@@ -1207,7 +1229,7 @@ function QueuePanel({
|
|
|
1207
1229
|
heading,
|
|
1208
1230
|
warning,
|
|
1209
1231
|
empty,
|
|
1210
|
-
|
|
1232
|
+
sections,
|
|
1211
1233
|
cursor,
|
|
1212
1234
|
onRefClick
|
|
1213
1235
|
}) {
|
|
@@ -1234,10 +1256,57 @@ function QueuePanel({
|
|
|
1234
1256
|
}
|
|
1235
1257
|
const offsets = [];
|
|
1236
1258
|
let total = 0;
|
|
1237
|
-
for (const
|
|
1238
|
-
|
|
1239
|
-
total +=
|
|
1240
|
-
|
|
1259
|
+
for (const section of sections) {
|
|
1260
|
+
const entry = { rows: total, lists: [] };
|
|
1261
|
+
total += section.rows.length;
|
|
1262
|
+
for (const list of section.lists) {
|
|
1263
|
+
entry.lists.push(total);
|
|
1264
|
+
total += list.rows.length;
|
|
1265
|
+
}
|
|
1266
|
+
offsets.push(entry);
|
|
1267
|
+
}
|
|
1268
|
+
const renderRow = (row, index, indent) => {
|
|
1269
|
+
const isSelected = index === cursor;
|
|
1270
|
+
const bg = isSelected ? theme.selectedBg : void 0;
|
|
1271
|
+
const refStart = indent.length + 2 + row.lead.length + 2;
|
|
1272
|
+
return /* @__PURE__ */ jsxs5(
|
|
1273
|
+
"text",
|
|
1274
|
+
{
|
|
1275
|
+
id: `queue-row-${index}`,
|
|
1276
|
+
wrapMode: "none",
|
|
1277
|
+
onMouseDown: onRefClick === null ? void 0 : (event) => {
|
|
1278
|
+
lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
|
|
1279
|
+
},
|
|
1280
|
+
onMouseUp: onRefClick === null ? void 0 : (event) => {
|
|
1281
|
+
const down = lastDown.current;
|
|
1282
|
+
lastDown.current = null;
|
|
1283
|
+
if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
|
|
1287
|
+
if (local >= refStart && local < refStart + row.ref.length) {
|
|
1288
|
+
onRefClick(row);
|
|
1289
|
+
}
|
|
1290
|
+
},
|
|
1291
|
+
children: [
|
|
1292
|
+
/* @__PURE__ */ jsxs5("span", { fg: theme.accent, children: [
|
|
1293
|
+
indent,
|
|
1294
|
+
isSelected ? "\u25B8 " : " "
|
|
1295
|
+
] }),
|
|
1296
|
+
/* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
|
|
1297
|
+
row.lead,
|
|
1298
|
+
" "
|
|
1299
|
+
] }),
|
|
1300
|
+
onRefClick === null ? /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }) : /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: row.ref }),
|
|
1301
|
+
/* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
|
|
1302
|
+
" ",
|
|
1303
|
+
row.title
|
|
1304
|
+
] })
|
|
1305
|
+
]
|
|
1306
|
+
},
|
|
1307
|
+
row.url
|
|
1308
|
+
);
|
|
1309
|
+
};
|
|
1241
1310
|
return /* @__PURE__ */ jsxs5("box", { flexGrow: 1, flexDirection: "column", children: [
|
|
1242
1311
|
header,
|
|
1243
1312
|
/* @__PURE__ */ jsxs5(
|
|
@@ -1250,49 +1319,19 @@ function QueuePanel({
|
|
|
1250
1319
|
verticalScrollbarOptions: overlayScrollbar,
|
|
1251
1320
|
children: [
|
|
1252
1321
|
warning !== null && /* @__PURE__ */ jsx6("text", { wrapMode: "word", fg: theme.warn, marginTop: 1, children: warning }),
|
|
1253
|
-
|
|
1254
|
-
/* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children:
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
|
|
1267
|
-
},
|
|
1268
|
-
onMouseUp: onRefClick === null ? void 0 : (event) => {
|
|
1269
|
-
const down = lastDown.current;
|
|
1270
|
-
lastDown.current = null;
|
|
1271
|
-
if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
|
|
1275
|
-
if (local >= refStart && local < refStart + row.ref.length) {
|
|
1276
|
-
onRefClick(row);
|
|
1277
|
-
}
|
|
1278
|
-
},
|
|
1279
|
-
children: [
|
|
1280
|
-
/* @__PURE__ */ jsx6("span", { fg: theme.accent, children: isSelected ? "\u25B8 " : " " }),
|
|
1281
|
-
/* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
|
|
1282
|
-
row.lead,
|
|
1283
|
-
" "
|
|
1284
|
-
] }),
|
|
1285
|
-
onRefClick === null ? /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }) : /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: row.ref }),
|
|
1286
|
-
/* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
|
|
1287
|
-
" ",
|
|
1288
|
-
row.title
|
|
1289
|
-
] })
|
|
1290
|
-
]
|
|
1291
|
-
},
|
|
1292
|
-
row.url
|
|
1293
|
-
);
|
|
1294
|
-
})
|
|
1295
|
-
] }, list.title))
|
|
1322
|
+
sections.map((section, sectionIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: 1, children: [
|
|
1323
|
+
/* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children: section.title }),
|
|
1324
|
+
section.rows.map((row, rowIndex) => renderRow(row, offsets[sectionIndex].rows + rowIndex, "")),
|
|
1325
|
+
section.lists.map((list, listIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: listIndex > 0 ? 1 : 0, children: [
|
|
1326
|
+
/* @__PURE__ */ jsxs5("text", { wrapMode: "none", fg: theme.accent, children: [
|
|
1327
|
+
" ",
|
|
1328
|
+
list.title
|
|
1329
|
+
] }),
|
|
1330
|
+
list.rows.map(
|
|
1331
|
+
(row, rowIndex) => renderRow(row, offsets[sectionIndex].lists[listIndex] + rowIndex, " ")
|
|
1332
|
+
)
|
|
1333
|
+
] }, list.title))
|
|
1334
|
+
] }, section.title))
|
|
1296
1335
|
]
|
|
1297
1336
|
}
|
|
1298
1337
|
)
|
|
@@ -1479,7 +1518,7 @@ function QueueTab({
|
|
|
1479
1518
|
heading: repos.length > 0 ? scope.repo ?? (grouped ? "All repos \xB7 grouped by repo" : "All repos") : null,
|
|
1480
1519
|
warning,
|
|
1481
1520
|
empty: view.empty,
|
|
1482
|
-
|
|
1521
|
+
sections: view.sections,
|
|
1483
1522
|
cursor: Math.min(rowCursor, queueRows(view).length - 1),
|
|
1484
1523
|
onRefClick
|
|
1485
1524
|
}
|
|
@@ -2990,6 +3029,62 @@ function ColorRow({
|
|
|
2990
3029
|
] }) });
|
|
2991
3030
|
}
|
|
2992
3031
|
|
|
3032
|
+
// src/tui/components/Modals.tsx
|
|
3033
|
+
import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
|
|
3034
|
+
function Modals({
|
|
3035
|
+
ui,
|
|
3036
|
+
options,
|
|
3037
|
+
saved: saved2,
|
|
3038
|
+
noCache: noCache2,
|
|
3039
|
+
copyLinks: copyLinks2,
|
|
3040
|
+
themeState,
|
|
3041
|
+
onDraft,
|
|
3042
|
+
onSubmitField,
|
|
3043
|
+
onSubmitThemeColor
|
|
3044
|
+
}) {
|
|
3045
|
+
if (ui.modal === "options") {
|
|
3046
|
+
return /* @__PURE__ */ jsx14(
|
|
3047
|
+
OptionsModal,
|
|
3048
|
+
{
|
|
3049
|
+
options,
|
|
3050
|
+
saved: saved2,
|
|
3051
|
+
selected: ui.selectedField,
|
|
3052
|
+
editing: ui.editing,
|
|
3053
|
+
fieldError: ui.fieldError,
|
|
3054
|
+
onDraft,
|
|
3055
|
+
onSubmit: onSubmitField
|
|
3056
|
+
}
|
|
3057
|
+
);
|
|
3058
|
+
}
|
|
3059
|
+
if (ui.modal === "settings") {
|
|
3060
|
+
return /* @__PURE__ */ jsx14(
|
|
3061
|
+
SettingsModal,
|
|
3062
|
+
{
|
|
3063
|
+
selected: ui.selectedSetting,
|
|
3064
|
+
cacheAction: ui.cacheAction,
|
|
3065
|
+
noCache: noCache2,
|
|
3066
|
+
copyLinks: copyLinks2,
|
|
3067
|
+
preset: themeState.preset
|
|
3068
|
+
}
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
3071
|
+
if (ui.modal === "theme") {
|
|
3072
|
+
return /* @__PURE__ */ jsx14(
|
|
3073
|
+
ThemeModal,
|
|
3074
|
+
{
|
|
3075
|
+
selected: ui.selectedThemeColor,
|
|
3076
|
+
editing: ui.editing,
|
|
3077
|
+
error: ui.themeColorError,
|
|
3078
|
+
cacheAction: ui.cacheAction,
|
|
3079
|
+
overrides: themeState.preset === "custom" ? themeState.overrides : {},
|
|
3080
|
+
onDraft,
|
|
3081
|
+
onSubmit: onSubmitThemeColor
|
|
3082
|
+
}
|
|
3083
|
+
);
|
|
3084
|
+
}
|
|
3085
|
+
return null;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
2993
3088
|
// src/tui/hooks/useDeferredLoading.ts
|
|
2994
3089
|
import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
|
|
2995
3090
|
function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
|
|
@@ -3376,7 +3471,10 @@ function classifyPr(pr, details, user) {
|
|
|
3376
3471
|
(node) => node?.author?.login === user && node.submittedAt ? [new Date(node.submittedAt)] : []
|
|
3377
3472
|
);
|
|
3378
3473
|
if (requests.length === 0) {
|
|
3379
|
-
|
|
3474
|
+
if (reviews.length === 0) {
|
|
3475
|
+
return [{ kind: "inaccessible", pr }];
|
|
3476
|
+
}
|
|
3477
|
+
return [{ kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.getTime()))) }];
|
|
3380
3478
|
}
|
|
3381
3479
|
const events = [
|
|
3382
3480
|
...requests.map((at) => {
|
|
@@ -3489,6 +3587,9 @@ function reviveRawData(data) {
|
|
|
3489
3587
|
if (result.kind === "reviewed") {
|
|
3490
3588
|
return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
|
|
3491
3589
|
}
|
|
3590
|
+
if (result.kind === "unrequested") {
|
|
3591
|
+
return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
|
|
3592
|
+
}
|
|
3492
3593
|
return { ...result, pr };
|
|
3493
3594
|
}),
|
|
3494
3595
|
sizes: data.sizes.map((entry) => {
|
|
@@ -3515,6 +3616,9 @@ function loadSnapshot(options) {
|
|
|
3515
3616
|
return null;
|
|
3516
3617
|
}
|
|
3517
3618
|
const data = reviveRawData(stored.data);
|
|
3619
|
+
if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
|
|
3620
|
+
return null;
|
|
3621
|
+
}
|
|
3518
3622
|
if (sinceIso === data.sinceIso) {
|
|
3519
3623
|
return data;
|
|
3520
3624
|
}
|
|
@@ -3723,24 +3827,42 @@ function buildSizeRepoOptions(raw) {
|
|
|
3723
3827
|
})
|
|
3724
3828
|
];
|
|
3725
3829
|
}
|
|
3726
|
-
function pendingDetail(
|
|
3727
|
-
|
|
3830
|
+
function pendingDetail(counts) {
|
|
3831
|
+
const awaiting = `${counts.awaiting} ${counts.awaiting === 1 ? "PR" : "PRs"} awaiting your review`;
|
|
3832
|
+
return awaiting + (counts.reviewing > 0 ? `, ${counts.reviewing} reviewing` : "");
|
|
3728
3833
|
}
|
|
3729
3834
|
function buildPendingRepoOptions(raw) {
|
|
3730
|
-
const
|
|
3835
|
+
const countsByRepo = /* @__PURE__ */ new Map();
|
|
3836
|
+
const countsOf = (repo) => {
|
|
3837
|
+
const counts = countsByRepo.get(repo) ?? { awaiting: 0, reviewing: 0 };
|
|
3838
|
+
countsByRepo.set(repo, counts);
|
|
3839
|
+
return counts;
|
|
3840
|
+
};
|
|
3731
3841
|
for (const result of raw.reviewResults) {
|
|
3732
|
-
|
|
3733
|
-
countByRepo.set(result.pr.repo, (countByRepo.get(result.pr.repo) ?? 0) + pending);
|
|
3842
|
+
countsOf(result.pr.repo);
|
|
3734
3843
|
}
|
|
3735
|
-
if (
|
|
3844
|
+
if (countsByRepo.size < 2) {
|
|
3736
3845
|
return [];
|
|
3737
3846
|
}
|
|
3738
|
-
const
|
|
3739
|
-
const
|
|
3847
|
+
const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
|
|
3848
|
+
for (const entry of stats.pending) {
|
|
3849
|
+
countsOf(entry.pr.repo).awaiting += 1;
|
|
3850
|
+
}
|
|
3851
|
+
for (const entry of stats.reviewing) {
|
|
3852
|
+
countsOf(entry.pr.repo).reviewing += 1;
|
|
3853
|
+
}
|
|
3854
|
+
const entries = [...countsByRepo.entries()].toSorted(
|
|
3855
|
+
(a, b) => b[1].awaiting - a[1].awaiting || b[1].reviewing - a[1].reviewing || a[0].localeCompare(b[0])
|
|
3856
|
+
);
|
|
3857
|
+
const totals = { awaiting: 0, reviewing: 0 };
|
|
3858
|
+
for (const [, counts] of entries) {
|
|
3859
|
+
totals.awaiting += counts.awaiting;
|
|
3860
|
+
totals.reviewing += counts.reviewing;
|
|
3861
|
+
}
|
|
3740
3862
|
return [
|
|
3741
|
-
{ repo: null, label: "All repos", detail: pendingDetail(
|
|
3742
|
-
...entries.map(([repo,
|
|
3743
|
-
return { repo, label: repo, detail: pendingDetail(
|
|
3863
|
+
{ repo: null, label: "All repos", detail: pendingDetail(totals) },
|
|
3864
|
+
...entries.map(([repo, counts]) => {
|
|
3865
|
+
return { repo, label: repo, detail: pendingDetail(counts) };
|
|
3744
3866
|
})
|
|
3745
3867
|
];
|
|
3746
3868
|
}
|
|
@@ -4008,7 +4130,10 @@ function buildHeatmapCard({ title, subtitle, grid, columns, legend }) {
|
|
|
4008
4130
|
});
|
|
4009
4131
|
const columnWidths = columns.map((column) => column.label.length + 2);
|
|
4010
4132
|
const header = [
|
|
4011
|
-
{
|
|
4133
|
+
{
|
|
4134
|
+
text: ` ${["00 03", "06 09", "12 15", "18 21"].map((label) => label.padEnd(12)).join(" ")}`,
|
|
4135
|
+
fg: theme.dim
|
|
4136
|
+
},
|
|
4012
4137
|
{ text: columns.map((column, i) => column.label.padStart(columnWidths[i])).join(""), fg: theme.dim }
|
|
4013
4138
|
];
|
|
4014
4139
|
const lines = [header];
|
|
@@ -5345,7 +5470,7 @@ function createClipboardCopier(renderer2) {
|
|
|
5345
5470
|
}
|
|
5346
5471
|
|
|
5347
5472
|
// src/tui/App.tsx
|
|
5348
|
-
import { jsx as
|
|
5473
|
+
import { jsx as jsx15, jsxs as jsxs13 } from "@opentui/react/jsx-runtime";
|
|
5349
5474
|
function App({
|
|
5350
5475
|
initial: initial2,
|
|
5351
5476
|
initialSaved = null,
|
|
@@ -5467,9 +5592,9 @@ function App({
|
|
|
5467
5592
|
});
|
|
5468
5593
|
const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
|
|
5469
5594
|
return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
|
|
5470
|
-
/* @__PURE__ */
|
|
5471
|
-
/* @__PURE__ */
|
|
5472
|
-
/* @__PURE__ */
|
|
5595
|
+
/* @__PURE__ */ jsx15(Header, { options, raw, error, spinning: showLoad }),
|
|
5596
|
+
/* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
|
|
5597
|
+
/* @__PURE__ */ jsx15(
|
|
5473
5598
|
MainPanel,
|
|
5474
5599
|
{
|
|
5475
5600
|
views,
|
|
@@ -5488,7 +5613,7 @@ function App({
|
|
|
5488
5613
|
onRefClick: copyLinks2 ? copyRow : null
|
|
5489
5614
|
}
|
|
5490
5615
|
),
|
|
5491
|
-
/* @__PURE__ */
|
|
5616
|
+
/* @__PURE__ */ jsx15(
|
|
5492
5617
|
Footer,
|
|
5493
5618
|
{
|
|
5494
5619
|
width,
|
|
@@ -5503,42 +5628,20 @@ function App({
|
|
|
5503
5628
|
stale
|
|
5504
5629
|
}
|
|
5505
5630
|
),
|
|
5506
|
-
|
|
5507
|
-
|
|
5631
|
+
/* @__PURE__ */ jsx15(
|
|
5632
|
+
Modals,
|
|
5508
5633
|
{
|
|
5634
|
+
ui,
|
|
5509
5635
|
options,
|
|
5510
5636
|
saved: saved2,
|
|
5511
|
-
selected: ui.selectedField,
|
|
5512
|
-
editing: ui.editing,
|
|
5513
|
-
fieldError: ui.fieldError,
|
|
5514
|
-
onDraft: (value2) => {
|
|
5515
|
-
draftRef.current = value2;
|
|
5516
|
-
},
|
|
5517
|
-
onSubmit: commitField
|
|
5518
|
-
}
|
|
5519
|
-
),
|
|
5520
|
-
ui.modal === "settings" && /* @__PURE__ */ jsx14(
|
|
5521
|
-
SettingsModal,
|
|
5522
|
-
{
|
|
5523
|
-
selected: ui.selectedSetting,
|
|
5524
|
-
cacheAction: ui.cacheAction,
|
|
5525
5637
|
noCache: noCache2,
|
|
5526
5638
|
copyLinks: copyLinks2,
|
|
5527
|
-
|
|
5528
|
-
}
|
|
5529
|
-
),
|
|
5530
|
-
ui.modal === "theme" && /* @__PURE__ */ jsx14(
|
|
5531
|
-
ThemeModal,
|
|
5532
|
-
{
|
|
5533
|
-
selected: ui.selectedThemeColor,
|
|
5534
|
-
editing: ui.editing,
|
|
5535
|
-
error: ui.themeColorError,
|
|
5536
|
-
cacheAction: ui.cacheAction,
|
|
5537
|
-
overrides: themeState.preset === "custom" ? themeState.overrides : {},
|
|
5639
|
+
themeState,
|
|
5538
5640
|
onDraft: (value2) => {
|
|
5539
5641
|
draftRef.current = value2;
|
|
5540
5642
|
},
|
|
5541
|
-
|
|
5643
|
+
onSubmitField: commitField,
|
|
5644
|
+
onSubmitThemeColor: commitThemeColor
|
|
5542
5645
|
}
|
|
5543
5646
|
)
|
|
5544
5647
|
] });
|
|
@@ -5584,7 +5687,7 @@ function bootstrap() {
|
|
|
5584
5687
|
}
|
|
5585
5688
|
|
|
5586
5689
|
// src/tui/main.tsx
|
|
5587
|
-
import { jsx as
|
|
5690
|
+
import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
|
|
5588
5691
|
var { initial, saved, noCache, copyLinks, theme: theme2 } = bootstrap();
|
|
5589
5692
|
var exitSignals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGBUS"];
|
|
5590
5693
|
var renderer = await createCliRenderer({ exitOnCtrlC: true, exitSignals });
|
|
@@ -5595,7 +5698,7 @@ for (const signal of ["SIGTERM", "SIGHUP"]) {
|
|
|
5595
5698
|
});
|
|
5596
5699
|
}
|
|
5597
5700
|
createRoot(renderer).render(
|
|
5598
|
-
/* @__PURE__ */
|
|
5701
|
+
/* @__PURE__ */ jsx16(
|
|
5599
5702
|
App,
|
|
5600
5703
|
{
|
|
5601
5704
|
initial,
|