@d3lm/pr-stats 0.2.6 → 0.2.7
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 +448 -145
- 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.
|
|
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.
|
|
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
|
@@ -726,7 +726,19 @@ function wallParts(instantMs) {
|
|
|
726
726
|
function zonedStamp(date) {
|
|
727
727
|
const parts = wallParts(date.getTime());
|
|
728
728
|
const dayUtcMs = Date.UTC(parts.year, parts.month - 1, parts.day);
|
|
729
|
-
return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour };
|
|
729
|
+
return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour, minute: parts.minute };
|
|
730
|
+
}
|
|
731
|
+
function hasWorkWindows() {
|
|
732
|
+
const covered = timeMode.workWindows.reduce((sum, window) => sum + (window.endMin - window.startMin), 0);
|
|
733
|
+
return covered < 24 * 60;
|
|
734
|
+
}
|
|
735
|
+
function classifyInstant(date) {
|
|
736
|
+
const { weekday, hour, minute } = zonedStamp(date);
|
|
737
|
+
if (weekday === 0 || weekday === 6) {
|
|
738
|
+
return "weekend";
|
|
739
|
+
}
|
|
740
|
+
const minuteOfDay = hour * 60 + minute;
|
|
741
|
+
return timeMode.workWindows.some((window) => minuteOfDay >= window.startMin && minuteOfDay < window.endMin) ? "work" : "after";
|
|
730
742
|
}
|
|
731
743
|
function utcFromWall(wallTargetMs) {
|
|
732
744
|
let guess = wallTargetMs;
|
|
@@ -780,7 +792,8 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
|
|
|
780
792
|
pr: result.pr,
|
|
781
793
|
requestedAt: result.requestedAt,
|
|
782
794
|
reviewedAt: result.reviewedAt,
|
|
783
|
-
hours: durationHours(result.requestedAt, result.reviewedAt)
|
|
795
|
+
hours: durationHours(result.requestedAt, result.reviewedAt),
|
|
796
|
+
verdict: result.verdict
|
|
784
797
|
});
|
|
785
798
|
} else if (result.kind === "pending" && result.pr.state === "open") {
|
|
786
799
|
pending.push({ pr: result.pr, requestedAt: result.requestedAt, hours: durationHours(result.requestedAt, now) });
|
|
@@ -816,7 +829,22 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
|
|
|
816
829
|
}
|
|
817
830
|
const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
|
|
818
831
|
const misses = targetHours === void 0 ? [] : reviewed.filter((result) => result.hours > targetHours).toSorted((a, b) => b.hours - a.hours);
|
|
819
|
-
|
|
832
|
+
const cyclesByPr = /* @__PURE__ */ new Map();
|
|
833
|
+
for (const result of reviewed) {
|
|
834
|
+
const key = `${result.pr.repo}#${result.pr.number}`;
|
|
835
|
+
cyclesByPr.set(key, (cyclesByPr.get(key) ?? 0) + 1);
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
reviewed,
|
|
839
|
+
pending,
|
|
840
|
+
reviewing,
|
|
841
|
+
expired,
|
|
842
|
+
unrequested,
|
|
843
|
+
allHours,
|
|
844
|
+
byRepo,
|
|
845
|
+
misses,
|
|
846
|
+
cycles: [...cyclesByPr.values()]
|
|
847
|
+
};
|
|
820
848
|
}
|
|
821
849
|
function computeSizeStats(sizes, { sizeTarget } = {}) {
|
|
822
850
|
const metrics = [
|
|
@@ -918,6 +946,13 @@ var FILE_BUCKETS = [
|
|
|
918
946
|
{ label: "21-50", max: 51 },
|
|
919
947
|
{ label: "> 50", max: Infinity }
|
|
920
948
|
];
|
|
949
|
+
var CYCLE_BUCKETS = [
|
|
950
|
+
{ label: "1", max: 2 },
|
|
951
|
+
{ label: "2", max: 3 },
|
|
952
|
+
{ label: "3", max: 4 },
|
|
953
|
+
{ label: "4-5", max: 6 },
|
|
954
|
+
{ label: "> 5", max: Infinity }
|
|
955
|
+
];
|
|
921
956
|
var COMMENT_BUCKETS = [
|
|
922
957
|
{ label: "0", max: 1 },
|
|
923
958
|
{ label: "1-2", max: 3 },
|
|
@@ -1094,18 +1129,18 @@ function ChartsPanel({
|
|
|
1094
1129
|
if (view.empty !== null) {
|
|
1095
1130
|
return /* @__PURE__ */ jsx4("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx4("text", { fg: theme.muted, children: view.empty }) });
|
|
1096
1131
|
}
|
|
1097
|
-
const
|
|
1098
|
-
const
|
|
1099
|
-
const
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
return { key:
|
|
1132
|
+
const left = view.cards.filter((_, i) => i % 2 === 0);
|
|
1133
|
+
const right = view.cards.filter((_, i) => i % 2 === 1);
|
|
1134
|
+
const leftWidth = Math.max(0, ...left.map((card) => cardWidth(card)));
|
|
1135
|
+
const rightWidth = Math.max(0, ...right.map((card) => cardWidth(card)));
|
|
1136
|
+
const twoColumns = right.length > 0 && width - 4 >= leftWidth + COLUMN_GAP + rightWidth;
|
|
1137
|
+
const rows = left.map((card, i) => {
|
|
1138
|
+
return { key: card.title, left: card, right: right.at(i) };
|
|
1104
1139
|
});
|
|
1105
1140
|
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:
|
|
1141
|
+
/* @__PURE__ */ jsx4("box", { flexDirection: "column", width: leftWidth, flexShrink: 0, children: /* @__PURE__ */ jsx4(ChartCard, { card: row.left }) }),
|
|
1107
1142
|
/* @__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:
|
|
1143
|
+
] }, row.key)) }) : /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children: view.cards.map((card) => /* @__PURE__ */ jsx4(ChartCard, { card }, card.title)) });
|
|
1109
1144
|
return /* @__PURE__ */ jsxs3("box", { flexGrow: 1, flexDirection: "column", children: [
|
|
1110
1145
|
/* @__PURE__ */ jsx4("box", { height: 1, children: /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.border, children: "\u2500".repeat(width) }) }),
|
|
1111
1146
|
/* @__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 +1181,7 @@ function ChartsPanel({
|
|
|
1146
1181
|
] }),
|
|
1147
1182
|
/* @__PURE__ */ jsx4("box", { flexDirection: "column", marginTop: 1, children: keyed(view.distribution.lines, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) })
|
|
1148
1183
|
] }),
|
|
1149
|
-
view.
|
|
1184
|
+
view.cards.length === 0 ? /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.muted, marginTop: 1, children: view.noCharts }) : cards
|
|
1150
1185
|
]
|
|
1151
1186
|
}
|
|
1152
1187
|
)
|
|
@@ -3468,20 +3503,22 @@ function classifyPr(pr, details, user) {
|
|
|
3468
3503
|
(node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
|
|
3469
3504
|
);
|
|
3470
3505
|
const reviews = details.reviews.nodes.flatMap(
|
|
3471
|
-
(node) => node?.author?.login === user && node.submittedAt ? [new Date(node.submittedAt)] : []
|
|
3506
|
+
(node) => node?.author?.login === user && node.submittedAt ? [{ at: new Date(node.submittedAt), state: node.state }] : []
|
|
3472
3507
|
);
|
|
3473
3508
|
if (requests.length === 0) {
|
|
3474
3509
|
if (reviews.length === 0) {
|
|
3475
3510
|
return [{ kind: "inaccessible", pr }];
|
|
3476
3511
|
}
|
|
3477
|
-
return [
|
|
3512
|
+
return [
|
|
3513
|
+
{ kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.at.getTime()))) }
|
|
3514
|
+
];
|
|
3478
3515
|
}
|
|
3479
3516
|
const events = [
|
|
3480
3517
|
...requests.map((at) => {
|
|
3481
|
-
return { at, isRequest: true };
|
|
3518
|
+
return { at, isRequest: true, state: "" };
|
|
3482
3519
|
}),
|
|
3483
|
-
...reviews.map((at) => {
|
|
3484
|
-
return { at, isRequest: false };
|
|
3520
|
+
...reviews.map(({ at, state }) => {
|
|
3521
|
+
return { at, isRequest: false, state };
|
|
3485
3522
|
})
|
|
3486
3523
|
].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
|
|
3487
3524
|
const results = [];
|
|
@@ -3490,7 +3527,7 @@ function classifyPr(pr, details, user) {
|
|
|
3490
3527
|
if (event.isRequest) {
|
|
3491
3528
|
openedAt ??= event.at;
|
|
3492
3529
|
} else if (openedAt !== null) {
|
|
3493
|
-
results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at });
|
|
3530
|
+
results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state });
|
|
3494
3531
|
openedAt = null;
|
|
3495
3532
|
}
|
|
3496
3533
|
}
|
|
@@ -3619,6 +3656,12 @@ function loadSnapshot(options) {
|
|
|
3619
3656
|
if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
|
|
3620
3657
|
return null;
|
|
3621
3658
|
}
|
|
3659
|
+
const hasMissingVerdict = data.reviewResults.some((result) => {
|
|
3660
|
+
return result.kind === "reviewed" && result.verdict === void 0;
|
|
3661
|
+
});
|
|
3662
|
+
if (hasMissingVerdict) {
|
|
3663
|
+
return null;
|
|
3664
|
+
}
|
|
3622
3665
|
if (sinceIso === data.sinceIso) {
|
|
3623
3666
|
return data;
|
|
3624
3667
|
}
|
|
@@ -4002,6 +4045,137 @@ function hbar(fraction, width, color) {
|
|
|
4002
4045
|
return line;
|
|
4003
4046
|
}
|
|
4004
4047
|
|
|
4048
|
+
// src/tui/views/charts/bars.ts
|
|
4049
|
+
var BAR_WIDTH2 = 24;
|
|
4050
|
+
var MAX_BARS = 8;
|
|
4051
|
+
function buildBarsCard({ title, subtitle, rows, format }) {
|
|
4052
|
+
const shown = rows.slice(0, MAX_BARS);
|
|
4053
|
+
const max = Math.max(...shown.map((row) => row.value), 0);
|
|
4054
|
+
const labelWidth = Math.max(...shown.map((row) => row.label.length));
|
|
4055
|
+
const valueWidth = Math.max(...shown.map((row) => format(row.value).length));
|
|
4056
|
+
const lines = shown.map((row) => {
|
|
4057
|
+
const line = [{ text: `${row.label.padEnd(labelWidth)} `, fg: theme.muted }];
|
|
4058
|
+
if (row.value <= 0 || max <= 0) {
|
|
4059
|
+
line.push({ text: " ".repeat(BAR_WIDTH2) });
|
|
4060
|
+
} else {
|
|
4061
|
+
line.push(...hbar(row.value / max, BAR_WIDTH2, row.value === max ? theme.accent : theme.chartBar));
|
|
4062
|
+
}
|
|
4063
|
+
line.push(
|
|
4064
|
+
{ text: ` ${format(row.value).padStart(valueWidth)}`, fg: theme.text },
|
|
4065
|
+
{ text: ` ${row.detail}`, fg: theme.dim }
|
|
4066
|
+
);
|
|
4067
|
+
return line;
|
|
4068
|
+
});
|
|
4069
|
+
if (rows.length > shown.length) {
|
|
4070
|
+
lines.push([{ text: `+ ${rows.length - shown.length} more`, fg: theme.dim }]);
|
|
4071
|
+
}
|
|
4072
|
+
return { title, subtitle, lines };
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
// src/tui/views/charts/weeks.ts
|
|
4076
|
+
var DAY_MS = 864e5;
|
|
4077
|
+
var WEEK_MS = 7 * DAY_MS;
|
|
4078
|
+
function mondayOf(dayUtcMs) {
|
|
4079
|
+
return dayUtcMs - (new Date(dayUtcMs).getUTCDay() + 6) % 7 * DAY_MS;
|
|
4080
|
+
}
|
|
4081
|
+
function dateLabel(ms) {
|
|
4082
|
+
return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
4083
|
+
}
|
|
4084
|
+
function weekAxisRow(width, prefix, points, columnOf, mondayOfPoint) {
|
|
4085
|
+
const cells = blankCells(width);
|
|
4086
|
+
const every = Math.ceil(points / 4);
|
|
4087
|
+
let lastEnd = -2;
|
|
4088
|
+
for (let point = 0; point < points; point += every) {
|
|
4089
|
+
const label = dateLabel(mondayOfPoint(point));
|
|
4090
|
+
const at = prefix + columnOf(point);
|
|
4091
|
+
if (at >= lastEnd + 2 && at + label.length <= width) {
|
|
4092
|
+
placeText(cells, at, label, theme.dim);
|
|
4093
|
+
lastEnd = at + label.length;
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
return mergeCells(cells);
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
// src/tui/views/charts/cumulative.ts
|
|
4100
|
+
var CUM_WIDTH = 36;
|
|
4101
|
+
var CUM_HEIGHT = 8;
|
|
4102
|
+
function weekOf(date) {
|
|
4103
|
+
return mondayOf(zonedStamp(date).dayUtcMs);
|
|
4104
|
+
}
|
|
4105
|
+
function buildCumulativeCard({ title, series, legend }) {
|
|
4106
|
+
const subtitle = series.flatMap((entry, i) => [
|
|
4107
|
+
{ text: `${i === 0 ? "" : " "}\u2500\u2500`, fg: entry.color },
|
|
4108
|
+
{ text: ` ${entry.label}`, fg: theme.muted }
|
|
4109
|
+
]);
|
|
4110
|
+
subtitle.push({ text: `, ${legend}`, fg: theme.muted });
|
|
4111
|
+
const mondays = series.flatMap((entry) => entry.dates.map((date) => weekOf(date)));
|
|
4112
|
+
const first = Math.min(...mondays);
|
|
4113
|
+
const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
|
|
4114
|
+
if (weekCount < 2) {
|
|
4115
|
+
return { title, subtitle, lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]] };
|
|
4116
|
+
}
|
|
4117
|
+
const totals = series.map((entry) => {
|
|
4118
|
+
const weekly = Array.from({ length: weekCount }, () => 0);
|
|
4119
|
+
for (const date of entry.dates) {
|
|
4120
|
+
weekly[(weekOf(date) - first) / WEEK_MS] += 1;
|
|
4121
|
+
}
|
|
4122
|
+
let running = 0;
|
|
4123
|
+
return weekly.map((count2) => running += count2);
|
|
4124
|
+
});
|
|
4125
|
+
const maxY = Math.max(...totals.map((cumulative) => cumulative.at(-1) ?? 0), 1);
|
|
4126
|
+
const grid = Array.from(
|
|
4127
|
+
{ length: CUM_HEIGHT },
|
|
4128
|
+
() => Array.from({ length: CUM_WIDTH }, () => null)
|
|
4129
|
+
);
|
|
4130
|
+
for (const [i, cumulative] of totals.entries()) {
|
|
4131
|
+
const rows = Array.from({ length: CUM_WIDTH }, (_, x) => {
|
|
4132
|
+
const weekPos = x / (CUM_WIDTH - 1) * (weekCount - 1);
|
|
4133
|
+
const week = Math.floor(weekPos);
|
|
4134
|
+
const nextWeek = Math.min(week + 1, weekCount - 1);
|
|
4135
|
+
const value2 = cumulative[week] + (cumulative[nextWeek] - cumulative[week]) * (weekPos - week);
|
|
4136
|
+
return CUM_HEIGHT - 1 - Math.round(value2 / maxY * (CUM_HEIGHT - 1));
|
|
4137
|
+
});
|
|
4138
|
+
const color = series[i].color;
|
|
4139
|
+
for (let x = 0; x < CUM_WIDTH; x++) {
|
|
4140
|
+
const here = rows[x];
|
|
4141
|
+
const next = rows[Math.min(x + 1, CUM_WIDTH - 1)];
|
|
4142
|
+
if (here === next) {
|
|
4143
|
+
grid[here][x] = { ch: "\u2500", fg: color };
|
|
4144
|
+
continue;
|
|
4145
|
+
}
|
|
4146
|
+
grid[next][x] = { ch: next < here ? "\u256D" : "\u2570", fg: color };
|
|
4147
|
+
grid[here][x] = { ch: next < here ? "\u256F" : "\u256E", fg: color };
|
|
4148
|
+
for (let row = Math.min(here, next) + 1; row < Math.max(here, next); row++) {
|
|
4149
|
+
grid[row][x] = { ch: "\u2502", fg: color };
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
const topLabel = String(maxY);
|
|
4154
|
+
const yWidth = topLabel.length;
|
|
4155
|
+
const lines = grid.map((row, i) => {
|
|
4156
|
+
const label = i === 0 ? topLabel : i === CUM_HEIGHT - 1 ? "0" : "";
|
|
4157
|
+
const line = [
|
|
4158
|
+
{ text: `${label.padStart(yWidth)} `, fg: theme.muted },
|
|
4159
|
+
{ text: label === "" ? "\u2502" : "\u2524", fg: theme.dim }
|
|
4160
|
+
];
|
|
4161
|
+
for (const cell of row) {
|
|
4162
|
+
line.push(cell === null ? { text: " " } : { text: cell.ch, fg: cell.fg });
|
|
4163
|
+
}
|
|
4164
|
+
return line;
|
|
4165
|
+
});
|
|
4166
|
+
lines.push(
|
|
4167
|
+
[{ text: `${" ".repeat(yWidth)} \u2514${"\u2500".repeat(CUM_WIDTH)}`, fg: theme.dim }],
|
|
4168
|
+
weekAxisRow(
|
|
4169
|
+
yWidth + 2 + CUM_WIDTH,
|
|
4170
|
+
yWidth + 2,
|
|
4171
|
+
weekCount,
|
|
4172
|
+
(week) => Math.round(week / (weekCount - 1) * (CUM_WIDTH - 1)),
|
|
4173
|
+
(week) => first + week * WEEK_MS
|
|
4174
|
+
)
|
|
4175
|
+
);
|
|
4176
|
+
return { title, subtitle, lines };
|
|
4177
|
+
}
|
|
4178
|
+
|
|
4005
4179
|
// src/tui/views/charts/distribution.ts
|
|
4006
4180
|
var DURATION_TICKS = [5 / 60, 0.25, 0.5, 1, 2, 4, 8, 24, 48, 96, 168, 336, 720, 2160];
|
|
4007
4181
|
var COUNT_TICKS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1e3, 2500, 5e3, 1e4, 25e3, 5e4, 1e5];
|
|
@@ -4322,39 +4496,20 @@ function buildSpreadCard(title, metrics, format) {
|
|
|
4322
4496
|
|
|
4323
4497
|
// src/tui/views/charts/trend.ts
|
|
4324
4498
|
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
4499
|
var TREND_POINTS = 40;
|
|
4352
4500
|
var TREND_HEIGHT = 6;
|
|
4353
4501
|
function axisSplit(line) {
|
|
4354
4502
|
const positions = [line.indexOf("\u2524"), line.indexOf("\u253C")].filter((at) => at >= 0);
|
|
4355
4503
|
return positions.length === 0 ? 0 : Math.min(...positions) + 1;
|
|
4356
4504
|
}
|
|
4357
|
-
function buildTrendCard({
|
|
4505
|
+
function buildTrendCard({
|
|
4506
|
+
title,
|
|
4507
|
+
entries,
|
|
4508
|
+
format,
|
|
4509
|
+
floor = 1,
|
|
4510
|
+
scale = "log",
|
|
4511
|
+
valueLabel = "median"
|
|
4512
|
+
}) {
|
|
4358
4513
|
const byWeek = /* @__PURE__ */ new Map();
|
|
4359
4514
|
for (const entry of entries) {
|
|
4360
4515
|
const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
|
|
@@ -4365,10 +4520,11 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4365
4520
|
const mondays = [...byWeek.keys()];
|
|
4366
4521
|
const first = Math.min(...mondays);
|
|
4367
4522
|
const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
|
|
4523
|
+
const scaleSuffix = scale === "log" ? ", log scale" : "";
|
|
4368
4524
|
if (weekCount < 2) {
|
|
4369
4525
|
return {
|
|
4370
4526
|
title,
|
|
4371
|
-
subtitle:
|
|
4527
|
+
subtitle: `weekly ${valueLabel}${scaleSuffix}`,
|
|
4372
4528
|
lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]]
|
|
4373
4529
|
};
|
|
4374
4530
|
}
|
|
@@ -4386,9 +4542,11 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4386
4542
|
medians.push(medians.at(-1) ?? 0);
|
|
4387
4543
|
}
|
|
4388
4544
|
}
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
medians[i]
|
|
4545
|
+
if (scale === "log") {
|
|
4546
|
+
const firstKnown = medians.find((value2) => value2 > 0) ?? 0;
|
|
4547
|
+
for (let i = 0; i < medians.length && medians[i] === 0; i++) {
|
|
4548
|
+
medians[i] = firstKnown;
|
|
4549
|
+
}
|
|
4392
4550
|
}
|
|
4393
4551
|
const chunk = Math.ceil(weekCount / TREND_POINTS);
|
|
4394
4552
|
const points = [];
|
|
@@ -4398,23 +4556,23 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4398
4556
|
}
|
|
4399
4557
|
const stretch = Math.max(1, Math.floor(TREND_POINTS / points.length));
|
|
4400
4558
|
const series = points.flatMap((value2) => Array.from({ length: stretch }, () => value2));
|
|
4401
|
-
const
|
|
4402
|
-
let lo = Math.min(...
|
|
4403
|
-
let hi = Math.max(...
|
|
4559
|
+
const plotted = scale === "log" ? series.map((value2) => Math.log2(Math.max(value2, floor))) : series;
|
|
4560
|
+
let lo = Math.min(...plotted);
|
|
4561
|
+
let hi = Math.max(...plotted);
|
|
4404
4562
|
if (hi - lo < 1e-9) {
|
|
4405
4563
|
lo -= 1;
|
|
4406
4564
|
hi += 1;
|
|
4407
4565
|
}
|
|
4408
|
-
const chart = asciichart.plot(
|
|
4566
|
+
const chart = asciichart.plot(plotted, {
|
|
4409
4567
|
height: TREND_HEIGHT,
|
|
4410
4568
|
min: lo,
|
|
4411
4569
|
max: hi,
|
|
4412
|
-
format: (x) => format(2 ** x).padStart(6)
|
|
4570
|
+
format: (x) => format(scale === "log" ? 2 ** x : x).padStart(6)
|
|
4413
4571
|
});
|
|
4414
4572
|
const ratio = TREND_HEIGHT / (hi - lo);
|
|
4415
4573
|
const min2 = Math.round(lo * ratio);
|
|
4416
4574
|
const rows = Math.abs(Math.round(hi * ratio) - min2);
|
|
4417
|
-
const lastRow = Math.min(rows, Math.max(0, rows - (Math.round((
|
|
4575
|
+
const lastRow = Math.min(rows, Math.max(0, rows - (Math.round((plotted.at(-1) ?? 0) * ratio) - min2)));
|
|
4418
4576
|
const raw = chart.split("\n");
|
|
4419
4577
|
const lines = raw.map((line, i) => {
|
|
4420
4578
|
const split = axisSplit(line);
|
|
@@ -4437,7 +4595,7 @@ function buildTrendCard({ title, entries, format, floor }) {
|
|
|
4437
4595
|
(point) => first + point * chunk * WEEK_MS
|
|
4438
4596
|
)
|
|
4439
4597
|
);
|
|
4440
|
-
const subtitle = chunk === 1 ?
|
|
4598
|
+
const subtitle = chunk === 1 ? `weekly ${valueLabel}${scaleSuffix}` : `${valueLabel} per ${chunk} weeks${scaleSuffix}`;
|
|
4441
4599
|
return { title, subtitle, lines };
|
|
4442
4600
|
}
|
|
4443
4601
|
|
|
@@ -4505,6 +4663,79 @@ function countCell(count2, label, dimWhenZero = false) {
|
|
|
4505
4663
|
{ text: ` ${label}`, fg: dim2 ? theme.dim : theme.muted }
|
|
4506
4664
|
];
|
|
4507
4665
|
}
|
|
4666
|
+
function mondayNoon(monday) {
|
|
4667
|
+
return new Date(monday + 12 * 36e5);
|
|
4668
|
+
}
|
|
4669
|
+
function weeklySums(entries) {
|
|
4670
|
+
const byWeek = /* @__PURE__ */ new Map();
|
|
4671
|
+
for (const entry of entries) {
|
|
4672
|
+
const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
|
|
4673
|
+
byWeek.set(monday, (byWeek.get(monday) ?? 0) + entry.value);
|
|
4674
|
+
}
|
|
4675
|
+
const mondays = [...byWeek.keys()];
|
|
4676
|
+
const first = Math.min(...mondays);
|
|
4677
|
+
const last = Math.max(...mondays);
|
|
4678
|
+
const result = [];
|
|
4679
|
+
for (let monday = first; monday <= last; monday += WEEK_MS) {
|
|
4680
|
+
result.push({ date: mondayNoon(monday), value: byWeek.get(monday) ?? 0 });
|
|
4681
|
+
}
|
|
4682
|
+
return result;
|
|
4683
|
+
}
|
|
4684
|
+
function mergeRateEntries(stats) {
|
|
4685
|
+
const byWeek = /* @__PURE__ */ new Map();
|
|
4686
|
+
const add = (createdAt, merged) => {
|
|
4687
|
+
const monday = mondayOf(zonedStamp(createdAt).dayUtcMs);
|
|
4688
|
+
const counts = byWeek.get(monday) ?? { merged: 0, concluded: 0 };
|
|
4689
|
+
counts.concluded += 1;
|
|
4690
|
+
counts.merged += merged ? 1 : 0;
|
|
4691
|
+
byWeek.set(monday, counts);
|
|
4692
|
+
};
|
|
4693
|
+
for (const result of stats.merged) {
|
|
4694
|
+
add(result.entry.pr.createdAt, true);
|
|
4695
|
+
}
|
|
4696
|
+
for (const result of stats.closed) {
|
|
4697
|
+
add(result.entry.pr.createdAt, false);
|
|
4698
|
+
}
|
|
4699
|
+
return [...byWeek.entries()].map(([monday, counts]) => {
|
|
4700
|
+
return { date: mondayNoon(monday), value: counts.merged / counts.concluded * 100 };
|
|
4701
|
+
});
|
|
4702
|
+
}
|
|
4703
|
+
function buildOffHoursCard(subtitle, dates) {
|
|
4704
|
+
const counts = { work: 0, after: 0, weekend: 0 };
|
|
4705
|
+
for (const date of dates) {
|
|
4706
|
+
counts[classifyInstant(date)] += 1;
|
|
4707
|
+
}
|
|
4708
|
+
const rows = hasWorkWindows() ? [
|
|
4709
|
+
{ label: "work hours", count: counts.work, color: theme.accent },
|
|
4710
|
+
{ label: "after hours", count: counts.after, color: theme.warn },
|
|
4711
|
+
{ label: "weekend", count: counts.weekend, color: theme.chartDim }
|
|
4712
|
+
] : [
|
|
4713
|
+
{ label: "weekday", count: counts.work + counts.after, color: theme.accent },
|
|
4714
|
+
{ label: "weekend", count: counts.weekend, color: theme.chartDim }
|
|
4715
|
+
];
|
|
4716
|
+
return buildGaugeCard({ title: "Off-hours share", subtitle, rows });
|
|
4717
|
+
}
|
|
4718
|
+
function buildVerdictCard(reviewed) {
|
|
4719
|
+
const countOf = (state) => reviewed.filter((entry) => entry.verdict === state).length;
|
|
4720
|
+
const approved = countOf("APPROVED");
|
|
4721
|
+
const changes = countOf("CHANGES_REQUESTED");
|
|
4722
|
+
const commented = countOf("COMMENTED");
|
|
4723
|
+
const other = reviewed.length - approved - changes - commented;
|
|
4724
|
+
const counts = [
|
|
4725
|
+
{ label: "approved", count: approved },
|
|
4726
|
+
{ label: "changes requested", count: changes },
|
|
4727
|
+
{ label: "commented", count: commented },
|
|
4728
|
+
...other > 0 ? [{ label: "other", count: other }] : []
|
|
4729
|
+
];
|
|
4730
|
+
const max = Math.max(...counts.map((row) => row.count));
|
|
4731
|
+
return buildGaugeCard({
|
|
4732
|
+
title: "Review verdicts",
|
|
4733
|
+
subtitle: "how your requested reviews concluded",
|
|
4734
|
+
rows: counts.map((row) => {
|
|
4735
|
+
return { ...row, color: row.count === max ? theme.accent : theme.chartBar };
|
|
4736
|
+
})
|
|
4737
|
+
});
|
|
4738
|
+
}
|
|
4508
4739
|
function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100) {
|
|
4509
4740
|
const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
|
|
4510
4741
|
const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
|
|
@@ -4519,8 +4750,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4519
4750
|
headline: null,
|
|
4520
4751
|
distributionTitle: "Review time distribution",
|
|
4521
4752
|
noCharts: "No completed reviews to chart.",
|
|
4522
|
-
|
|
4523
|
-
right: [],
|
|
4753
|
+
cards: [],
|
|
4524
4754
|
distribution: null
|
|
4525
4755
|
};
|
|
4526
4756
|
if (results.length === 0) {
|
|
@@ -4533,8 +4763,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4533
4763
|
rows: toPrRows(stats.misses, stats.misses.map(durationLead))
|
|
4534
4764
|
});
|
|
4535
4765
|
}
|
|
4766
|
+
const pendingCard = stats.pending.length === 0 ? null : buildHistogramCard({
|
|
4767
|
+
title: "Pending request age",
|
|
4768
|
+
subtitle: "how long open requests have waited",
|
|
4769
|
+
values: stats.pending.map((entry) => entry.hours),
|
|
4770
|
+
buckets: currentBuckets(),
|
|
4771
|
+
format: formatDuration
|
|
4772
|
+
});
|
|
4536
4773
|
if (stats.reviewed.length === 0) {
|
|
4537
|
-
return { empty: null, ...base, lists };
|
|
4774
|
+
return { empty: null, ...base, cards: pendingCard === null ? [] : [pendingCard], lists };
|
|
4538
4775
|
}
|
|
4539
4776
|
const sorted = [...stats.allHours].toSorted((a, b) => a - b);
|
|
4540
4777
|
const total = raw.reviewResults.filter((result) => result.kind === "reviewed").length;
|
|
@@ -4547,7 +4784,39 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4547
4784
|
];
|
|
4548
4785
|
const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
|
|
4549
4786
|
const reviewDates = stats.reviewed.map((entry) => entry.reviewedAt);
|
|
4550
|
-
const
|
|
4787
|
+
const requestAges = [...stats.reviewed, ...stats.pending].map(
|
|
4788
|
+
(entry) => durationHours(entry.pr.createdAt, entry.requestedAt)
|
|
4789
|
+
);
|
|
4790
|
+
let serviceCard = null;
|
|
4791
|
+
if (targetHours !== void 0 && targetLabel !== void 0) {
|
|
4792
|
+
const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
|
|
4793
|
+
const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
|
|
4794
|
+
serviceCard = buildGaugeCard({
|
|
4795
|
+
title: "Service level",
|
|
4796
|
+
subtitle: `reviewed within ${targetLabel}`,
|
|
4797
|
+
rows: [
|
|
4798
|
+
{ label: `inside ${targetLabel}`, count: inside, color: theme.accent },
|
|
4799
|
+
{ label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
|
|
4800
|
+
...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
|
|
4801
|
+
]
|
|
4802
|
+
});
|
|
4803
|
+
}
|
|
4804
|
+
const byRepoCard = repo === null && stats.byRepo.length > 1 ? buildBarsCard({
|
|
4805
|
+
title: "Review time by repo",
|
|
4806
|
+
subtitle: "median review time, slowest first",
|
|
4807
|
+
rows: stats.byRepo.map(([name, hours]) => {
|
|
4808
|
+
return {
|
|
4809
|
+
label: name,
|
|
4810
|
+
value: percentile(
|
|
4811
|
+
hours.toSorted((a, b) => a - b),
|
|
4812
|
+
50
|
|
4813
|
+
),
|
|
4814
|
+
detail: `n=${hours.length}`
|
|
4815
|
+
};
|
|
4816
|
+
}).toSorted((a, b) => b.value - a.value),
|
|
4817
|
+
format: formatDuration
|
|
4818
|
+
}) : null;
|
|
4819
|
+
const cards = [
|
|
4551
4820
|
buildHistogramCard({
|
|
4552
4821
|
title: "Time to review",
|
|
4553
4822
|
subtitle: "elapsed time, request \u2192 review",
|
|
@@ -4555,6 +4824,14 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4555
4824
|
buckets: currentBuckets(),
|
|
4556
4825
|
format: formatDuration
|
|
4557
4826
|
}),
|
|
4827
|
+
buildTrendCard({
|
|
4828
|
+
title: "Review time trend",
|
|
4829
|
+
entries: stats.reviewed.map((entry) => {
|
|
4830
|
+
return { date: entry.reviewedAt, value: entry.hours };
|
|
4831
|
+
}),
|
|
4832
|
+
format: formatDuration,
|
|
4833
|
+
floor: 1 / 60
|
|
4834
|
+
}),
|
|
4558
4835
|
buildHeatmapCard({
|
|
4559
4836
|
title: "When you review",
|
|
4560
4837
|
subtitle: "reviews submitted, weekday \xD7 hour, local time",
|
|
@@ -4564,34 +4841,28 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4564
4841
|
{ label: "req", dates: requestDates, muted: true }
|
|
4565
4842
|
],
|
|
4566
4843
|
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
4844
|
}),
|
|
4578
|
-
buildVolumeCard("Reviews completed per week", reviewDates)
|
|
4845
|
+
buildVolumeCard("Reviews completed per week", reviewDates),
|
|
4846
|
+
buildHistogramCard({
|
|
4847
|
+
title: "Review cycles per PR",
|
|
4848
|
+
subtitle: "completed request \u2192 review rounds per PR",
|
|
4849
|
+
values: stats.cycles,
|
|
4850
|
+
buckets: CYCLE_BUCKETS,
|
|
4851
|
+
format: count
|
|
4852
|
+
}),
|
|
4853
|
+
buildHistogramCard({
|
|
4854
|
+
title: "PR age at request",
|
|
4855
|
+
subtitle: "elapsed time, PR created \u2192 review requested",
|
|
4856
|
+
values: requestAges,
|
|
4857
|
+
buckets: currentBuckets(),
|
|
4858
|
+
format: formatDuration
|
|
4859
|
+
}),
|
|
4860
|
+
buildVerdictCard(stats.reviewed),
|
|
4861
|
+
...pendingCard === null ? [] : [pendingCard],
|
|
4862
|
+
buildOffHoursCard("reviews submitted, local time", reviewDates),
|
|
4863
|
+
...serviceCard === null ? [] : [serviceCard],
|
|
4864
|
+
...byRepoCard === null ? [] : [byRepoCard]
|
|
4579
4865
|
];
|
|
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
4866
|
const distribution = buildDistribution({
|
|
4596
4867
|
values: stats.allHours,
|
|
4597
4868
|
width: Math.max(width - 3, 40),
|
|
@@ -4599,7 +4870,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
|
|
|
4599
4870
|
ticks: DURATION_TICKS,
|
|
4600
4871
|
flat: (count2, value2) => `all ${count2} ${count2 === 1 ? "review" : "reviews"} took ${value2}`
|
|
4601
4872
|
});
|
|
4602
|
-
return { empty: null, ...base, headline,
|
|
4873
|
+
return { empty: null, ...base, headline, cards, distribution, lists };
|
|
4603
4874
|
}
|
|
4604
4875
|
function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
4605
4876
|
const base = {
|
|
@@ -4607,8 +4878,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4607
4878
|
headline: null,
|
|
4608
4879
|
distributionTitle: "PR size distribution",
|
|
4609
4880
|
noCharts: "No authored PRs to chart.",
|
|
4610
|
-
|
|
4611
|
-
right: [],
|
|
4881
|
+
cards: [],
|
|
4612
4882
|
distribution: null,
|
|
4613
4883
|
lists: []
|
|
4614
4884
|
};
|
|
@@ -4639,7 +4909,15 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4639
4909
|
{ text: `${count(percentile(sorted, 90))} lines`, fg: theme.accent },
|
|
4640
4910
|
{ text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
|
|
4641
4911
|
];
|
|
4642
|
-
const
|
|
4912
|
+
const targetCard = stats.met !== void 0 && stats.targetLabel !== void 0 ? buildGaugeCard({
|
|
4913
|
+
title: "Size target",
|
|
4914
|
+
subtitle: `authored within ${stats.targetLabel}`,
|
|
4915
|
+
rows: [
|
|
4916
|
+
{ label: "inside target", count: stats.met, color: theme.accent },
|
|
4917
|
+
{ label: "over target", count: sizes.length - stats.met, color: theme.chartDim }
|
|
4918
|
+
]
|
|
4919
|
+
}) : null;
|
|
4920
|
+
const cards = [
|
|
4643
4921
|
buildHistogramCard({
|
|
4644
4922
|
title: "PR size",
|
|
4645
4923
|
subtitle: "total lines changed per authored PR",
|
|
@@ -4647,6 +4925,14 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4647
4925
|
buckets: LINE_BUCKETS,
|
|
4648
4926
|
format: count
|
|
4649
4927
|
}),
|
|
4928
|
+
buildTrendCard({
|
|
4929
|
+
title: "PR size trend",
|
|
4930
|
+
entries: sizes.map((size) => {
|
|
4931
|
+
return { date: size.pr.createdAt, value: size.total };
|
|
4932
|
+
}),
|
|
4933
|
+
format: count,
|
|
4934
|
+
floor: 1
|
|
4935
|
+
}),
|
|
4650
4936
|
buildHistogramCard({
|
|
4651
4937
|
title: "Files touched",
|
|
4652
4938
|
subtitle: "files changed per authored PR",
|
|
@@ -4654,38 +4940,28 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4654
4940
|
buckets: FILE_BUCKETS,
|
|
4655
4941
|
format: count
|
|
4656
4942
|
}),
|
|
4943
|
+
buildTrendCard({
|
|
4944
|
+
title: "Net lines trend",
|
|
4945
|
+
entries: weeklySums(
|
|
4946
|
+
sizes.map((size) => {
|
|
4947
|
+
return { date: size.pr.createdAt, value: size.additions - size.deletions };
|
|
4948
|
+
})
|
|
4949
|
+
),
|
|
4950
|
+
format: netCount,
|
|
4951
|
+
scale: "linear",
|
|
4952
|
+
valueLabel: "net lines"
|
|
4953
|
+
}),
|
|
4657
4954
|
buildHeatmapCard({
|
|
4658
4955
|
title: "When you open PRs",
|
|
4659
4956
|
subtitle: "PRs opened, weekday \xD7 hour, local time",
|
|
4660
4957
|
grid: created,
|
|
4661
4958
|
columns: [{ label: "opened", dates: created }],
|
|
4662
4959
|
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
4960
|
}),
|
|
4674
4961
|
buildVolumeCard("PRs opened per week", created),
|
|
4962
|
+
...targetCard === null ? [] : [targetCard],
|
|
4675
4963
|
buildSpreadCard("Size spread", stats.metrics, count)
|
|
4676
4964
|
];
|
|
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
4965
|
const distribution = buildDistribution({
|
|
4690
4966
|
values: totals,
|
|
4691
4967
|
width: Math.max(width - 3, 40),
|
|
@@ -4703,7 +4979,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
|
|
|
4703
4979
|
)
|
|
4704
4980
|
});
|
|
4705
4981
|
}
|
|
4706
|
-
return { empty: null, ...base, strip, headline,
|
|
4982
|
+
return { empty: null, ...base, strip, headline, cards, distribution, lists };
|
|
4707
4983
|
}
|
|
4708
4984
|
function buildCommentView(raw, repo = null, width = 100) {
|
|
4709
4985
|
const base = {
|
|
@@ -4711,8 +4987,7 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4711
4987
|
headline: null,
|
|
4712
4988
|
distributionTitle: "Comments per PR distribution",
|
|
4713
4989
|
noCharts: "No authored PRs to chart.",
|
|
4714
|
-
|
|
4715
|
-
right: [],
|
|
4990
|
+
cards: [],
|
|
4716
4991
|
distribution: null,
|
|
4717
4992
|
lists: []
|
|
4718
4993
|
};
|
|
@@ -4742,7 +5017,7 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4742
5017
|
{ text: `${count(percentile(sorted, 90))} comments`, fg: theme.accent },
|
|
4743
5018
|
{ text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
|
|
4744
5019
|
];
|
|
4745
|
-
const
|
|
5020
|
+
const cards = [
|
|
4746
5021
|
buildHistogramCard({
|
|
4747
5022
|
title: "Comments per PR",
|
|
4748
5023
|
subtitle: "discussion plus review comments per authored PR",
|
|
@@ -4750,6 +5025,14 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4750
5025
|
buckets: COMMENT_BUCKETS,
|
|
4751
5026
|
format: count
|
|
4752
5027
|
}),
|
|
5028
|
+
buildTrendCard({
|
|
5029
|
+
title: "Comment trend",
|
|
5030
|
+
entries: sizes.map((size) => {
|
|
5031
|
+
return { date: size.pr.createdAt, value: size.comments.total };
|
|
5032
|
+
}),
|
|
5033
|
+
format: count,
|
|
5034
|
+
floor: 1
|
|
5035
|
+
}),
|
|
4753
5036
|
buildScatterCard({
|
|
4754
5037
|
title: "Comments vs size",
|
|
4755
5038
|
subtitle: "comments against lines changed, log scale",
|
|
@@ -4759,18 +5042,8 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4759
5042
|
formatX: count,
|
|
4760
5043
|
formatY: count
|
|
4761
5044
|
}),
|
|
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
5045
|
buildVolumeCard("Comments received per week", created, stats.totals),
|
|
5046
|
+
buildSpreadCard("Comment spread", stats.metrics, count),
|
|
4774
5047
|
buildGaugeCard({
|
|
4775
5048
|
title: "Feedback rate",
|
|
4776
5049
|
subtitle: "authored PRs that received comments",
|
|
@@ -4800,7 +5073,7 @@ function buildCommentView(raw, repo = null, width = 100) {
|
|
|
4800
5073
|
)
|
|
4801
5074
|
});
|
|
4802
5075
|
}
|
|
4803
|
-
return { empty: null, ...base, strip, headline,
|
|
5076
|
+
return { empty: null, ...base, strip, headline, cards, distribution, lists };
|
|
4804
5077
|
}
|
|
4805
5078
|
function buildMergedView(raw, repo = null, width = 100) {
|
|
4806
5079
|
const base = {
|
|
@@ -4808,8 +5081,7 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4808
5081
|
headline: null,
|
|
4809
5082
|
distributionTitle: "Time to merge distribution",
|
|
4810
5083
|
noCharts: "No merged PRs to chart.",
|
|
4811
|
-
|
|
4812
|
-
right: [],
|
|
5084
|
+
cards: [],
|
|
4813
5085
|
distribution: null,
|
|
4814
5086
|
lists: []
|
|
4815
5087
|
};
|
|
@@ -4864,7 +5136,7 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4864
5136
|
];
|
|
4865
5137
|
const mergeDates = stats.merged.map((result) => result.mergedAt);
|
|
4866
5138
|
const created = sizes.map((size) => size.pr.createdAt);
|
|
4867
|
-
const
|
|
5139
|
+
const cards = [
|
|
4868
5140
|
buildHistogramCard({
|
|
4869
5141
|
title: "Time to merge",
|
|
4870
5142
|
subtitle: "elapsed time, created \u2192 merged",
|
|
@@ -4872,6 +5144,14 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4872
5144
|
buckets: currentBuckets(),
|
|
4873
5145
|
format: formatDuration
|
|
4874
5146
|
}),
|
|
5147
|
+
buildTrendCard({
|
|
5148
|
+
title: "Time to merge trend",
|
|
5149
|
+
entries: stats.merged.map((result) => {
|
|
5150
|
+
return { date: result.mergedAt, value: result.hours };
|
|
5151
|
+
}),
|
|
5152
|
+
format: formatDuration,
|
|
5153
|
+
floor: 1 / 60
|
|
5154
|
+
}),
|
|
4875
5155
|
buildHeatmapCard({
|
|
4876
5156
|
title: "When your PRs merge",
|
|
4877
5157
|
subtitle: "PRs merged, weekday \xD7 hour, local time",
|
|
@@ -4882,6 +5162,38 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4882
5162
|
],
|
|
4883
5163
|
legend: "PRs merged in that hour"
|
|
4884
5164
|
}),
|
|
5165
|
+
buildTrendCard({
|
|
5166
|
+
title: "Merge rate trend",
|
|
5167
|
+
entries: mergeRateEntries(stats),
|
|
5168
|
+
format: (value2) => `${Math.round(value2)}%`,
|
|
5169
|
+
scale: "linear",
|
|
5170
|
+
valueLabel: "merge rate"
|
|
5171
|
+
}),
|
|
5172
|
+
buildScatterCard({
|
|
5173
|
+
title: "Merge time vs size",
|
|
5174
|
+
subtitle: "time to merge against lines changed, log scale",
|
|
5175
|
+
points: stats.merged.map((result) => {
|
|
5176
|
+
return { x: result.entry.total, y: result.hours };
|
|
5177
|
+
}),
|
|
5178
|
+
formatX: count,
|
|
5179
|
+
formatY: formatDuration
|
|
5180
|
+
}),
|
|
5181
|
+
buildCumulativeCard({
|
|
5182
|
+
title: "Created vs merged",
|
|
5183
|
+
series: [
|
|
5184
|
+
/**
|
|
5185
|
+
* The created line stays a neutral gray because every theme preset
|
|
5186
|
+
* keeps chartLine and accent in one hue family, which made the two
|
|
5187
|
+
* lines indistinguishable. The gray also survives theme changes,
|
|
5188
|
+
* since the presets only rotate the hue-carrying colors.
|
|
5189
|
+
*/
|
|
5190
|
+
{ label: "created", dates: created, color: theme.muted },
|
|
5191
|
+
{ label: "merged", dates: mergeDates, color: theme.accent }
|
|
5192
|
+
],
|
|
5193
|
+
legend: "cumulative PRs by week"
|
|
5194
|
+
}),
|
|
5195
|
+
buildVolumeCard("PRs created per week", created),
|
|
5196
|
+
buildVolumeCard("PRs merged per week", mergeDates),
|
|
4885
5197
|
buildGaugeCard({
|
|
4886
5198
|
title: "Outcomes",
|
|
4887
5199
|
subtitle: "where your authored PRs ended up",
|
|
@@ -4892,18 +5204,6 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4892
5204
|
]
|
|
4893
5205
|
})
|
|
4894
5206
|
];
|
|
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
|
-
}),
|
|
4904
|
-
buildVolumeCard("PRs merged per week", mergeDates),
|
|
4905
|
-
buildVolumeCard("PRs created per week", created)
|
|
4906
|
-
];
|
|
4907
5207
|
const distribution = buildDistribution({
|
|
4908
5208
|
values: stats.allHours,
|
|
4909
5209
|
width: Math.max(width - 3, 40),
|
|
@@ -4911,11 +5211,14 @@ function buildMergedView(raw, repo = null, width = 100) {
|
|
|
4911
5211
|
ticks: DURATION_TICKS,
|
|
4912
5212
|
flat: (n, value2) => `all ${n} merged ${n === 1 ? "PR" : "PRs"} took ${value2}`
|
|
4913
5213
|
});
|
|
4914
|
-
return { empty: null, ...base, strip, headline,
|
|
5214
|
+
return { empty: null, ...base, strip, headline, cards, distribution, lists };
|
|
4915
5215
|
}
|
|
4916
5216
|
function count(value2) {
|
|
4917
5217
|
return formatCount(Math.round(value2));
|
|
4918
5218
|
}
|
|
5219
|
+
function netCount(value2) {
|
|
5220
|
+
return value2 < 0 ? `-${count(-value2)}` : `+${count(value2)}`;
|
|
5221
|
+
}
|
|
4919
5222
|
|
|
4920
5223
|
// src/tui/hooks/useViewModel.ts
|
|
4921
5224
|
function resolveScope(scope, repos) {
|