@0xbigboss/gh-pulse-core 1.0.0
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/dist/cache.cjs +312 -0
- package/dist/cache.d.cts +58 -0
- package/dist/cache.d.ts +58 -0
- package/dist/cache.js +303 -0
- package/dist/config.cjs +182 -0
- package/dist/config.d.cts +248 -0
- package/dist/config.d.ts +248 -0
- package/dist/config.js +172 -0
- package/dist/filters.cjs +16 -0
- package/dist/filters.d.cts +3 -0
- package/dist/filters.d.ts +3 -0
- package/dist/filters.js +12 -0
- package/dist/github.cjs +240 -0
- package/dist/github.d.cts +46 -0
- package/dist/github.d.ts +46 -0
- package/dist/github.js +235 -0
- package/dist/index.cjs +28 -0
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/reports/context.cjs +8 -0
- package/dist/reports/context.d.cts +7 -0
- package/dist/reports/context.d.ts +7 -0
- package/dist/reports/context.js +5 -0
- package/dist/reports/exec.cjs +160 -0
- package/dist/reports/exec.d.cts +6 -0
- package/dist/reports/exec.d.ts +6 -0
- package/dist/reports/exec.js +157 -0
- package/dist/reports/index.cjs +21 -0
- package/dist/reports/index.d.cts +5 -0
- package/dist/reports/index.d.ts +5 -0
- package/dist/reports/index.js +5 -0
- package/dist/reports/meta.cjs +15 -0
- package/dist/reports/meta.d.cts +12 -0
- package/dist/reports/meta.d.ts +12 -0
- package/dist/reports/meta.js +12 -0
- package/dist/reports/personal.cjs +90 -0
- package/dist/reports/personal.d.cts +8 -0
- package/dist/reports/personal.d.ts +8 -0
- package/dist/reports/personal.js +87 -0
- package/dist/reports/team.cjs +127 -0
- package/dist/reports/team.d.cts +6 -0
- package/dist/reports/team.d.ts +6 -0
- package/dist/reports/team.js +124 -0
- package/dist/reports/types.cjs +2 -0
- package/dist/reports/types.d.cts +144 -0
- package/dist/reports/types.d.ts +144 -0
- package/dist/reports/types.js +1 -0
- package/dist/reports/utils.cjs +71 -0
- package/dist/reports/utils.d.cts +6 -0
- package/dist/reports/utils.d.ts +6 -0
- package/dist/reports/utils.js +65 -0
- package/dist/repos.cjs +102 -0
- package/dist/repos.d.cts +12 -0
- package/dist/repos.d.ts +12 -0
- package/dist/repos.js +96 -0
- package/dist/sync.cjs +360 -0
- package/dist/sync.d.cts +24 -0
- package/dist/sync.d.ts +24 -0
- package/dist/sync.js +357 -0
- package/dist/team.cjs +45 -0
- package/dist/team.d.cts +10 -0
- package/dist/team.d.ts +10 -0
- package/dist/team.js +42 -0
- package/dist/time.cjs +153 -0
- package/dist/time.d.cts +13 -0
- package/dist/time.d.ts +13 -0
- package/dist/time.js +145 -0
- package/dist/types.cjs +2 -0
- package/dist/types.d.cts +133 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.js +1 -0
- package/package.json +29 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildTeamReport = buildTeamReport;
|
|
4
|
+
const utils_1 = require("./utils.cjs");
|
|
5
|
+
const context_1 = require("./context.cjs");
|
|
6
|
+
const filters_1 = require("../filters.cjs");
|
|
7
|
+
function buildTeamReport(cache, options) {
|
|
8
|
+
const { pullRequests, commits } = (0, context_1.loadReportData)(cache, options.repos);
|
|
9
|
+
const now = options.meta.generated_at;
|
|
10
|
+
const exclude = options.excludeAuthors;
|
|
11
|
+
const teamSet = new Set(options.teamMembers);
|
|
12
|
+
const prLookup = buildPullRequestLookup(pullRequests);
|
|
13
|
+
const events = cache
|
|
14
|
+
.getEvents({ since: options.timeRange.start, until: options.timeRange.end })
|
|
15
|
+
.filter((event) => options.repos.includes(event.repo));
|
|
16
|
+
const prEvents = events.filter((event) => 'pr_number' in event);
|
|
17
|
+
const reviewEvents = events.filter((event) => event.type === 'review_submitted');
|
|
18
|
+
const opened = prEvents.filter((event) => event.type === 'pr_opened' &&
|
|
19
|
+
teamHasAuthor(teamSet, event.author) &&
|
|
20
|
+
!(0, filters_1.isExcludedAuthor)(event.author, exclude, options.excludeBots));
|
|
21
|
+
const merged = prEvents.filter((event) => event.type === 'pr_merged' &&
|
|
22
|
+
prAuthorInTeam(prLookup, teamSet, event.repo, event.pr_number, exclude, options.excludeBots));
|
|
23
|
+
const closed = prEvents.filter((event) => event.type === 'pr_closed' &&
|
|
24
|
+
prAuthorInTeam(prLookup, teamSet, event.repo, event.pr_number, exclude, options.excludeBots));
|
|
25
|
+
const mergedPrs = merged
|
|
26
|
+
.map((event) => prLookup.get(`${event.repo}#${event.pr_number}`))
|
|
27
|
+
.filter((pr) => Boolean(pr))
|
|
28
|
+
.filter((pr) => options.includeDrafts || !pr.draft);
|
|
29
|
+
const cycleTimes = mergedPrs.map((pr) => {
|
|
30
|
+
const prEventsForPr = cache.getEventsForPr(pr.repo, pr.number);
|
|
31
|
+
const metrics = (0, utils_1.computeCycleTime)(pr, prEventsForPr);
|
|
32
|
+
return { pr: (0, utils_1.buildPullRequestSummary)(pr, now), metrics };
|
|
33
|
+
});
|
|
34
|
+
const cycleTimeValues = cycleTimes
|
|
35
|
+
.map((entry) => entry.metrics?.total_time)
|
|
36
|
+
.filter((value) => typeof value === 'number' && value > 0);
|
|
37
|
+
const reviewValues = cycleTimes
|
|
38
|
+
.map((entry) => entry.metrics?.review_time)
|
|
39
|
+
.filter((value) => typeof value === 'number' && value >= 0);
|
|
40
|
+
const reviewEngagement = mergedPrs.map((pr) => {
|
|
41
|
+
const prEventsForPr = cache.getEventsForPr(pr.repo, pr.number);
|
|
42
|
+
const metrics = (0, utils_1.computeReviewEngagement)(pr, prEventsForPr);
|
|
43
|
+
return { pr: (0, utils_1.buildPullRequestSummary)(pr, now), metrics };
|
|
44
|
+
});
|
|
45
|
+
const memberBreakdown = buildMemberBreakdown(options.teamMembers, pullRequests, commits, reviewEvents, exclude, options.excludeBots, options.timeRange.start, options.timeRange.end);
|
|
46
|
+
const blockers = findBlockers(pullRequests, options.includeDrafts, exclude, options.excludeBots, options.thresholds, now);
|
|
47
|
+
return {
|
|
48
|
+
type: 'team',
|
|
49
|
+
meta: options.meta,
|
|
50
|
+
team_size: options.teamMembers.length,
|
|
51
|
+
velocity: {
|
|
52
|
+
prs_opened: opened.length,
|
|
53
|
+
prs_merged: merged.length,
|
|
54
|
+
prs_closed: closed.length,
|
|
55
|
+
cycle_time_p50: (0, utils_1.percentile)(cycleTimeValues, 50),
|
|
56
|
+
cycle_time_p90: (0, utils_1.percentile)(cycleTimeValues, 90),
|
|
57
|
+
review_turnaround_p50: (0, utils_1.percentile)(reviewValues, 50),
|
|
58
|
+
review_turnaround_p90: (0, utils_1.percentile)(reviewValues, 90),
|
|
59
|
+
},
|
|
60
|
+
members: memberBreakdown,
|
|
61
|
+
blockers,
|
|
62
|
+
cycle_times: cycleTimes,
|
|
63
|
+
review_engagement: reviewEngagement,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function buildMemberBreakdown(teamMembers, pullRequests, commits, reviewEvents, exclude, excludeBots, since, until) {
|
|
67
|
+
return [...teamMembers]
|
|
68
|
+
.filter((member) => !(0, filters_1.isExcludedAuthor)(member, exclude, excludeBots))
|
|
69
|
+
.map((member) => {
|
|
70
|
+
const authored = pullRequests.filter((pr) => pr.author === member &&
|
|
71
|
+
pr.created_at >= since &&
|
|
72
|
+
pr.created_at <= until &&
|
|
73
|
+
!(0, filters_1.isExcludedAuthor)(pr.author, exclude, excludeBots));
|
|
74
|
+
const reviewed = reviewEvents.filter((event) => event.type === 'review_submitted' &&
|
|
75
|
+
event.reviewer === member &&
|
|
76
|
+
!(0, filters_1.isExcludedAuthor)(event.reviewer, exclude, excludeBots));
|
|
77
|
+
const memberCommits = commits.filter((commit) => commit.author === member &&
|
|
78
|
+
commit.committed_at >= since &&
|
|
79
|
+
commit.committed_at <= until &&
|
|
80
|
+
!(0, filters_1.isExcludedAuthor)(commit.author, exclude, excludeBots));
|
|
81
|
+
const linesChanged = memberCommits.reduce((total, commit) => total + commit.additions + commit.deletions, 0);
|
|
82
|
+
return {
|
|
83
|
+
user: member,
|
|
84
|
+
prs_authored: authored.length,
|
|
85
|
+
prs_reviewed: reviewed.length,
|
|
86
|
+
commits: memberCommits.length,
|
|
87
|
+
lines_changed: linesChanged,
|
|
88
|
+
};
|
|
89
|
+
})
|
|
90
|
+
.toSorted((a, b) => a.user.localeCompare(b.user));
|
|
91
|
+
}
|
|
92
|
+
function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresholds, now) {
|
|
93
|
+
return pullRequests
|
|
94
|
+
.filter((pr) => pr.state === 'open')
|
|
95
|
+
.filter((pr) => includeDrafts || !pr.draft)
|
|
96
|
+
.filter((pr) => !(0, filters_1.isExcludedAuthor)(pr.author, exclude, excludeBots))
|
|
97
|
+
.flatMap((pr) => {
|
|
98
|
+
const blockers = [];
|
|
99
|
+
const summary = (0, utils_1.buildPullRequestSummary)(pr, now);
|
|
100
|
+
const staleDays = diffDays(pr.updated_at, now);
|
|
101
|
+
const stuckDays = diffDays(pr.created_at, now);
|
|
102
|
+
if (staleDays >= thresholds.stale_days) {
|
|
103
|
+
blockers.push({ pr: summary, status: 'stale', days: staleDays });
|
|
104
|
+
}
|
|
105
|
+
if (stuckDays >= thresholds.stuck_days) {
|
|
106
|
+
blockers.push({ pr: summary, status: 'stuck', days: stuckDays });
|
|
107
|
+
}
|
|
108
|
+
return blockers;
|
|
109
|
+
})
|
|
110
|
+
.toSorted((a, b) => b.days - a.days);
|
|
111
|
+
}
|
|
112
|
+
function diffDays(start, end) {
|
|
113
|
+
return Math.floor((end - start) / (24 * 60 * 60 * 1000));
|
|
114
|
+
}
|
|
115
|
+
function teamHasAuthor(team, author) {
|
|
116
|
+
return team.has(author);
|
|
117
|
+
}
|
|
118
|
+
function prAuthorInTeam(lookup, team, repo, number, exclude, excludeBots) {
|
|
119
|
+
const pr = lookup.get(`${repo}#${number}`);
|
|
120
|
+
if (!pr || (0, filters_1.isExcludedAuthor)(pr.author, exclude, excludeBots)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return teamHasAuthor(team, pr.author);
|
|
124
|
+
}
|
|
125
|
+
function buildPullRequestLookup(pullRequests) {
|
|
126
|
+
return new Map(pullRequests.map((pr) => [`${pr.repo}#${pr.number}`, pr]));
|
|
127
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Cache } from "../cache.cjs";
|
|
2
|
+
import type { ReportInputs, TeamReport } from "./types.cjs";
|
|
3
|
+
export interface TeamReportOptions extends ReportInputs {
|
|
4
|
+
meta: TeamReport['meta'];
|
|
5
|
+
}
|
|
6
|
+
export declare function buildTeamReport(cache: Cache, options: TeamReportOptions): TeamReport;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Cache } from "../cache.js";
|
|
2
|
+
import type { ReportInputs, TeamReport } from "./types.js";
|
|
3
|
+
export interface TeamReportOptions extends ReportInputs {
|
|
4
|
+
meta: TeamReport['meta'];
|
|
5
|
+
}
|
|
6
|
+
export declare function buildTeamReport(cache: Cache, options: TeamReportOptions): TeamReport;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { buildPullRequestSummary, computeCycleTime, computeReviewEngagement, percentile, } from "./utils.js";
|
|
2
|
+
import { loadReportData } from "./context.js";
|
|
3
|
+
import { isExcludedAuthor } from "../filters.js";
|
|
4
|
+
export function buildTeamReport(cache, options) {
|
|
5
|
+
const { pullRequests, commits } = loadReportData(cache, options.repos);
|
|
6
|
+
const now = options.meta.generated_at;
|
|
7
|
+
const exclude = options.excludeAuthors;
|
|
8
|
+
const teamSet = new Set(options.teamMembers);
|
|
9
|
+
const prLookup = buildPullRequestLookup(pullRequests);
|
|
10
|
+
const events = cache
|
|
11
|
+
.getEvents({ since: options.timeRange.start, until: options.timeRange.end })
|
|
12
|
+
.filter((event) => options.repos.includes(event.repo));
|
|
13
|
+
const prEvents = events.filter((event) => 'pr_number' in event);
|
|
14
|
+
const reviewEvents = events.filter((event) => event.type === 'review_submitted');
|
|
15
|
+
const opened = prEvents.filter((event) => event.type === 'pr_opened' &&
|
|
16
|
+
teamHasAuthor(teamSet, event.author) &&
|
|
17
|
+
!isExcludedAuthor(event.author, exclude, options.excludeBots));
|
|
18
|
+
const merged = prEvents.filter((event) => event.type === 'pr_merged' &&
|
|
19
|
+
prAuthorInTeam(prLookup, teamSet, event.repo, event.pr_number, exclude, options.excludeBots));
|
|
20
|
+
const closed = prEvents.filter((event) => event.type === 'pr_closed' &&
|
|
21
|
+
prAuthorInTeam(prLookup, teamSet, event.repo, event.pr_number, exclude, options.excludeBots));
|
|
22
|
+
const mergedPrs = merged
|
|
23
|
+
.map((event) => prLookup.get(`${event.repo}#${event.pr_number}`))
|
|
24
|
+
.filter((pr) => Boolean(pr))
|
|
25
|
+
.filter((pr) => options.includeDrafts || !pr.draft);
|
|
26
|
+
const cycleTimes = mergedPrs.map((pr) => {
|
|
27
|
+
const prEventsForPr = cache.getEventsForPr(pr.repo, pr.number);
|
|
28
|
+
const metrics = computeCycleTime(pr, prEventsForPr);
|
|
29
|
+
return { pr: buildPullRequestSummary(pr, now), metrics };
|
|
30
|
+
});
|
|
31
|
+
const cycleTimeValues = cycleTimes
|
|
32
|
+
.map((entry) => entry.metrics?.total_time)
|
|
33
|
+
.filter((value) => typeof value === 'number' && value > 0);
|
|
34
|
+
const reviewValues = cycleTimes
|
|
35
|
+
.map((entry) => entry.metrics?.review_time)
|
|
36
|
+
.filter((value) => typeof value === 'number' && value >= 0);
|
|
37
|
+
const reviewEngagement = mergedPrs.map((pr) => {
|
|
38
|
+
const prEventsForPr = cache.getEventsForPr(pr.repo, pr.number);
|
|
39
|
+
const metrics = computeReviewEngagement(pr, prEventsForPr);
|
|
40
|
+
return { pr: buildPullRequestSummary(pr, now), metrics };
|
|
41
|
+
});
|
|
42
|
+
const memberBreakdown = buildMemberBreakdown(options.teamMembers, pullRequests, commits, reviewEvents, exclude, options.excludeBots, options.timeRange.start, options.timeRange.end);
|
|
43
|
+
const blockers = findBlockers(pullRequests, options.includeDrafts, exclude, options.excludeBots, options.thresholds, now);
|
|
44
|
+
return {
|
|
45
|
+
type: 'team',
|
|
46
|
+
meta: options.meta,
|
|
47
|
+
team_size: options.teamMembers.length,
|
|
48
|
+
velocity: {
|
|
49
|
+
prs_opened: opened.length,
|
|
50
|
+
prs_merged: merged.length,
|
|
51
|
+
prs_closed: closed.length,
|
|
52
|
+
cycle_time_p50: percentile(cycleTimeValues, 50),
|
|
53
|
+
cycle_time_p90: percentile(cycleTimeValues, 90),
|
|
54
|
+
review_turnaround_p50: percentile(reviewValues, 50),
|
|
55
|
+
review_turnaround_p90: percentile(reviewValues, 90),
|
|
56
|
+
},
|
|
57
|
+
members: memberBreakdown,
|
|
58
|
+
blockers,
|
|
59
|
+
cycle_times: cycleTimes,
|
|
60
|
+
review_engagement: reviewEngagement,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function buildMemberBreakdown(teamMembers, pullRequests, commits, reviewEvents, exclude, excludeBots, since, until) {
|
|
64
|
+
return [...teamMembers]
|
|
65
|
+
.filter((member) => !isExcludedAuthor(member, exclude, excludeBots))
|
|
66
|
+
.map((member) => {
|
|
67
|
+
const authored = pullRequests.filter((pr) => pr.author === member &&
|
|
68
|
+
pr.created_at >= since &&
|
|
69
|
+
pr.created_at <= until &&
|
|
70
|
+
!isExcludedAuthor(pr.author, exclude, excludeBots));
|
|
71
|
+
const reviewed = reviewEvents.filter((event) => event.type === 'review_submitted' &&
|
|
72
|
+
event.reviewer === member &&
|
|
73
|
+
!isExcludedAuthor(event.reviewer, exclude, excludeBots));
|
|
74
|
+
const memberCommits = commits.filter((commit) => commit.author === member &&
|
|
75
|
+
commit.committed_at >= since &&
|
|
76
|
+
commit.committed_at <= until &&
|
|
77
|
+
!isExcludedAuthor(commit.author, exclude, excludeBots));
|
|
78
|
+
const linesChanged = memberCommits.reduce((total, commit) => total + commit.additions + commit.deletions, 0);
|
|
79
|
+
return {
|
|
80
|
+
user: member,
|
|
81
|
+
prs_authored: authored.length,
|
|
82
|
+
prs_reviewed: reviewed.length,
|
|
83
|
+
commits: memberCommits.length,
|
|
84
|
+
lines_changed: linesChanged,
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
.toSorted((a, b) => a.user.localeCompare(b.user));
|
|
88
|
+
}
|
|
89
|
+
function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresholds, now) {
|
|
90
|
+
return pullRequests
|
|
91
|
+
.filter((pr) => pr.state === 'open')
|
|
92
|
+
.filter((pr) => includeDrafts || !pr.draft)
|
|
93
|
+
.filter((pr) => !isExcludedAuthor(pr.author, exclude, excludeBots))
|
|
94
|
+
.flatMap((pr) => {
|
|
95
|
+
const blockers = [];
|
|
96
|
+
const summary = buildPullRequestSummary(pr, now);
|
|
97
|
+
const staleDays = diffDays(pr.updated_at, now);
|
|
98
|
+
const stuckDays = diffDays(pr.created_at, now);
|
|
99
|
+
if (staleDays >= thresholds.stale_days) {
|
|
100
|
+
blockers.push({ pr: summary, status: 'stale', days: staleDays });
|
|
101
|
+
}
|
|
102
|
+
if (stuckDays >= thresholds.stuck_days) {
|
|
103
|
+
blockers.push({ pr: summary, status: 'stuck', days: stuckDays });
|
|
104
|
+
}
|
|
105
|
+
return blockers;
|
|
106
|
+
})
|
|
107
|
+
.toSorted((a, b) => b.days - a.days);
|
|
108
|
+
}
|
|
109
|
+
function diffDays(start, end) {
|
|
110
|
+
return Math.floor((end - start) / (24 * 60 * 60 * 1000));
|
|
111
|
+
}
|
|
112
|
+
function teamHasAuthor(team, author) {
|
|
113
|
+
return team.has(author);
|
|
114
|
+
}
|
|
115
|
+
function prAuthorInTeam(lookup, team, repo, number, exclude, excludeBots) {
|
|
116
|
+
const pr = lookup.get(`${repo}#${number}`);
|
|
117
|
+
if (!pr || isExcludedAuthor(pr.author, exclude, excludeBots)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return teamHasAuthor(team, pr.author);
|
|
121
|
+
}
|
|
122
|
+
function buildPullRequestLookup(pullRequests) {
|
|
123
|
+
return new Map(pullRequests.map((pr) => [`${pr.repo}#${pr.number}`, pr]));
|
|
124
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Commit, CycleTimeMetrics, GitHubUsername, PullRequest, RepoFullName, ReviewEngagement, Timestamp } from "../types.cjs";
|
|
2
|
+
import type { CacheFreshness } from "../cache.cjs";
|
|
3
|
+
export type ReportAudience = 'personal' | 'individual' | 'team' | 'exec';
|
|
4
|
+
export interface ReportMeta {
|
|
5
|
+
audience: ReportAudience;
|
|
6
|
+
period_label: string;
|
|
7
|
+
start: Timestamp;
|
|
8
|
+
end: Timestamp;
|
|
9
|
+
generated_at: Timestamp;
|
|
10
|
+
repos: RepoFullName[];
|
|
11
|
+
data_freshness: CacheFreshness;
|
|
12
|
+
}
|
|
13
|
+
export interface PullRequestSummary {
|
|
14
|
+
repo: RepoFullName;
|
|
15
|
+
number: number;
|
|
16
|
+
title: string;
|
|
17
|
+
author: GitHubUsername;
|
|
18
|
+
state: string;
|
|
19
|
+
draft: boolean;
|
|
20
|
+
updated_at: Timestamp;
|
|
21
|
+
created_at: Timestamp;
|
|
22
|
+
age_days: number;
|
|
23
|
+
url?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface CommitSummary {
|
|
26
|
+
repo: RepoFullName;
|
|
27
|
+
sha: string;
|
|
28
|
+
author: GitHubUsername;
|
|
29
|
+
committed_at: Timestamp;
|
|
30
|
+
message: string;
|
|
31
|
+
additions: number;
|
|
32
|
+
deletions: number;
|
|
33
|
+
files_changed: number;
|
|
34
|
+
}
|
|
35
|
+
export interface ReviewDebtSection {
|
|
36
|
+
requested: PullRequestSummary[];
|
|
37
|
+
team_prs: PullRequestSummary[];
|
|
38
|
+
}
|
|
39
|
+
export interface OpenWorkSection {
|
|
40
|
+
open_prs: PullRequestSummary[];
|
|
41
|
+
}
|
|
42
|
+
export interface PersonalSummarySection {
|
|
43
|
+
prs_opened: number;
|
|
44
|
+
prs_merged: number;
|
|
45
|
+
prs_closed: number;
|
|
46
|
+
commits: number;
|
|
47
|
+
repos_touched: number;
|
|
48
|
+
lines_changed: number;
|
|
49
|
+
}
|
|
50
|
+
export interface PersonalReport {
|
|
51
|
+
type: 'personal' | 'individual';
|
|
52
|
+
user: GitHubUsername;
|
|
53
|
+
meta: ReportMeta;
|
|
54
|
+
summary: PersonalSummarySection;
|
|
55
|
+
review_debt: ReviewDebtSection;
|
|
56
|
+
open_work: OpenWorkSection;
|
|
57
|
+
authored_prs: PullRequestSummary[];
|
|
58
|
+
commits_detail: CommitSummary[];
|
|
59
|
+
}
|
|
60
|
+
export interface TeamVelocitySection {
|
|
61
|
+
prs_opened: number;
|
|
62
|
+
prs_merged: number;
|
|
63
|
+
prs_closed: number;
|
|
64
|
+
cycle_time_p50: number | null;
|
|
65
|
+
cycle_time_p90: number | null;
|
|
66
|
+
review_turnaround_p50: number | null;
|
|
67
|
+
review_turnaround_p90: number | null;
|
|
68
|
+
}
|
|
69
|
+
export interface TeamMemberBreakdown {
|
|
70
|
+
user: GitHubUsername;
|
|
71
|
+
prs_authored: number;
|
|
72
|
+
prs_reviewed: number;
|
|
73
|
+
commits: number;
|
|
74
|
+
lines_changed: number;
|
|
75
|
+
}
|
|
76
|
+
export interface BlockerEntry {
|
|
77
|
+
pr: PullRequestSummary;
|
|
78
|
+
status: 'stale' | 'stuck';
|
|
79
|
+
days: number;
|
|
80
|
+
}
|
|
81
|
+
export interface TeamReport {
|
|
82
|
+
type: 'team';
|
|
83
|
+
meta: ReportMeta;
|
|
84
|
+
team_size: number;
|
|
85
|
+
velocity: TeamVelocitySection;
|
|
86
|
+
members: TeamMemberBreakdown[];
|
|
87
|
+
blockers: BlockerEntry[];
|
|
88
|
+
cycle_times: Array<{
|
|
89
|
+
pr: PullRequestSummary;
|
|
90
|
+
metrics: CycleTimeMetrics | null;
|
|
91
|
+
}>;
|
|
92
|
+
review_engagement: Array<{
|
|
93
|
+
pr: PullRequestSummary;
|
|
94
|
+
metrics: ReviewEngagement | null;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
export interface TrendPoint {
|
|
98
|
+
bucket_start: Timestamp;
|
|
99
|
+
bucket_end: Timestamp;
|
|
100
|
+
prs_merged: number;
|
|
101
|
+
cycle_time_p50: number | null;
|
|
102
|
+
}
|
|
103
|
+
export interface RepoHealthEntry {
|
|
104
|
+
repo: RepoFullName;
|
|
105
|
+
status: 'active' | 'stale' | 'dormant';
|
|
106
|
+
last_activity_at: Timestamp | null;
|
|
107
|
+
}
|
|
108
|
+
export interface HighlightEntry {
|
|
109
|
+
pr: PullRequestSummary;
|
|
110
|
+
reasons: string[];
|
|
111
|
+
}
|
|
112
|
+
export interface ExecReport {
|
|
113
|
+
type: 'exec';
|
|
114
|
+
meta: ReportMeta;
|
|
115
|
+
velocity_trends: TrendPoint[];
|
|
116
|
+
cycle_time_trends: TrendPoint[];
|
|
117
|
+
repo_health: RepoHealthEntry[];
|
|
118
|
+
highlights: HighlightEntry[];
|
|
119
|
+
team_breakdown: TeamMemberBreakdown[];
|
|
120
|
+
}
|
|
121
|
+
export type Report = PersonalReport | TeamReport | ExecReport;
|
|
122
|
+
export interface ReportInputs {
|
|
123
|
+
repos: RepoFullName[];
|
|
124
|
+
timeRange: {
|
|
125
|
+
start: Timestamp;
|
|
126
|
+
end: Timestamp;
|
|
127
|
+
label: string;
|
|
128
|
+
};
|
|
129
|
+
includeDrafts: boolean;
|
|
130
|
+
blockersOnly: boolean;
|
|
131
|
+
excludeAuthors: Set<GitHubUsername>;
|
|
132
|
+
excludeBots: boolean;
|
|
133
|
+
teamMembers: GitHubUsername[];
|
|
134
|
+
subjectUser?: GitHubUsername;
|
|
135
|
+
thresholds: {
|
|
136
|
+
stale_days: number;
|
|
137
|
+
stuck_days: number;
|
|
138
|
+
large_pr_lines: number;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export interface ReportDataContext {
|
|
142
|
+
pullRequests: PullRequest[];
|
|
143
|
+
commits: Commit[];
|
|
144
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Commit, CycleTimeMetrics, GitHubUsername, PullRequest, RepoFullName, ReviewEngagement, Timestamp } from "../types.js";
|
|
2
|
+
import type { CacheFreshness } from "../cache.js";
|
|
3
|
+
export type ReportAudience = 'personal' | 'individual' | 'team' | 'exec';
|
|
4
|
+
export interface ReportMeta {
|
|
5
|
+
audience: ReportAudience;
|
|
6
|
+
period_label: string;
|
|
7
|
+
start: Timestamp;
|
|
8
|
+
end: Timestamp;
|
|
9
|
+
generated_at: Timestamp;
|
|
10
|
+
repos: RepoFullName[];
|
|
11
|
+
data_freshness: CacheFreshness;
|
|
12
|
+
}
|
|
13
|
+
export interface PullRequestSummary {
|
|
14
|
+
repo: RepoFullName;
|
|
15
|
+
number: number;
|
|
16
|
+
title: string;
|
|
17
|
+
author: GitHubUsername;
|
|
18
|
+
state: string;
|
|
19
|
+
draft: boolean;
|
|
20
|
+
updated_at: Timestamp;
|
|
21
|
+
created_at: Timestamp;
|
|
22
|
+
age_days: number;
|
|
23
|
+
url?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface CommitSummary {
|
|
26
|
+
repo: RepoFullName;
|
|
27
|
+
sha: string;
|
|
28
|
+
author: GitHubUsername;
|
|
29
|
+
committed_at: Timestamp;
|
|
30
|
+
message: string;
|
|
31
|
+
additions: number;
|
|
32
|
+
deletions: number;
|
|
33
|
+
files_changed: number;
|
|
34
|
+
}
|
|
35
|
+
export interface ReviewDebtSection {
|
|
36
|
+
requested: PullRequestSummary[];
|
|
37
|
+
team_prs: PullRequestSummary[];
|
|
38
|
+
}
|
|
39
|
+
export interface OpenWorkSection {
|
|
40
|
+
open_prs: PullRequestSummary[];
|
|
41
|
+
}
|
|
42
|
+
export interface PersonalSummarySection {
|
|
43
|
+
prs_opened: number;
|
|
44
|
+
prs_merged: number;
|
|
45
|
+
prs_closed: number;
|
|
46
|
+
commits: number;
|
|
47
|
+
repos_touched: number;
|
|
48
|
+
lines_changed: number;
|
|
49
|
+
}
|
|
50
|
+
export interface PersonalReport {
|
|
51
|
+
type: 'personal' | 'individual';
|
|
52
|
+
user: GitHubUsername;
|
|
53
|
+
meta: ReportMeta;
|
|
54
|
+
summary: PersonalSummarySection;
|
|
55
|
+
review_debt: ReviewDebtSection;
|
|
56
|
+
open_work: OpenWorkSection;
|
|
57
|
+
authored_prs: PullRequestSummary[];
|
|
58
|
+
commits_detail: CommitSummary[];
|
|
59
|
+
}
|
|
60
|
+
export interface TeamVelocitySection {
|
|
61
|
+
prs_opened: number;
|
|
62
|
+
prs_merged: number;
|
|
63
|
+
prs_closed: number;
|
|
64
|
+
cycle_time_p50: number | null;
|
|
65
|
+
cycle_time_p90: number | null;
|
|
66
|
+
review_turnaround_p50: number | null;
|
|
67
|
+
review_turnaround_p90: number | null;
|
|
68
|
+
}
|
|
69
|
+
export interface TeamMemberBreakdown {
|
|
70
|
+
user: GitHubUsername;
|
|
71
|
+
prs_authored: number;
|
|
72
|
+
prs_reviewed: number;
|
|
73
|
+
commits: number;
|
|
74
|
+
lines_changed: number;
|
|
75
|
+
}
|
|
76
|
+
export interface BlockerEntry {
|
|
77
|
+
pr: PullRequestSummary;
|
|
78
|
+
status: 'stale' | 'stuck';
|
|
79
|
+
days: number;
|
|
80
|
+
}
|
|
81
|
+
export interface TeamReport {
|
|
82
|
+
type: 'team';
|
|
83
|
+
meta: ReportMeta;
|
|
84
|
+
team_size: number;
|
|
85
|
+
velocity: TeamVelocitySection;
|
|
86
|
+
members: TeamMemberBreakdown[];
|
|
87
|
+
blockers: BlockerEntry[];
|
|
88
|
+
cycle_times: Array<{
|
|
89
|
+
pr: PullRequestSummary;
|
|
90
|
+
metrics: CycleTimeMetrics | null;
|
|
91
|
+
}>;
|
|
92
|
+
review_engagement: Array<{
|
|
93
|
+
pr: PullRequestSummary;
|
|
94
|
+
metrics: ReviewEngagement | null;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
export interface TrendPoint {
|
|
98
|
+
bucket_start: Timestamp;
|
|
99
|
+
bucket_end: Timestamp;
|
|
100
|
+
prs_merged: number;
|
|
101
|
+
cycle_time_p50: number | null;
|
|
102
|
+
}
|
|
103
|
+
export interface RepoHealthEntry {
|
|
104
|
+
repo: RepoFullName;
|
|
105
|
+
status: 'active' | 'stale' | 'dormant';
|
|
106
|
+
last_activity_at: Timestamp | null;
|
|
107
|
+
}
|
|
108
|
+
export interface HighlightEntry {
|
|
109
|
+
pr: PullRequestSummary;
|
|
110
|
+
reasons: string[];
|
|
111
|
+
}
|
|
112
|
+
export interface ExecReport {
|
|
113
|
+
type: 'exec';
|
|
114
|
+
meta: ReportMeta;
|
|
115
|
+
velocity_trends: TrendPoint[];
|
|
116
|
+
cycle_time_trends: TrendPoint[];
|
|
117
|
+
repo_health: RepoHealthEntry[];
|
|
118
|
+
highlights: HighlightEntry[];
|
|
119
|
+
team_breakdown: TeamMemberBreakdown[];
|
|
120
|
+
}
|
|
121
|
+
export type Report = PersonalReport | TeamReport | ExecReport;
|
|
122
|
+
export interface ReportInputs {
|
|
123
|
+
repos: RepoFullName[];
|
|
124
|
+
timeRange: {
|
|
125
|
+
start: Timestamp;
|
|
126
|
+
end: Timestamp;
|
|
127
|
+
label: string;
|
|
128
|
+
};
|
|
129
|
+
includeDrafts: boolean;
|
|
130
|
+
blockersOnly: boolean;
|
|
131
|
+
excludeAuthors: Set<GitHubUsername>;
|
|
132
|
+
excludeBots: boolean;
|
|
133
|
+
teamMembers: GitHubUsername[];
|
|
134
|
+
subjectUser?: GitHubUsername;
|
|
135
|
+
thresholds: {
|
|
136
|
+
stale_days: number;
|
|
137
|
+
stuck_days: number;
|
|
138
|
+
large_pr_lines: number;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export interface ReportDataContext {
|
|
142
|
+
pullRequests: PullRequest[];
|
|
143
|
+
commits: Commit[];
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildPullRequestSummary = buildPullRequestSummary;
|
|
4
|
+
exports.computeCycleTime = computeCycleTime;
|
|
5
|
+
exports.computeReviewEngagement = computeReviewEngagement;
|
|
6
|
+
exports.percentile = percentile;
|
|
7
|
+
const time_1 = require("../time.cjs");
|
|
8
|
+
function buildPullRequestSummary(pr, now) {
|
|
9
|
+
return {
|
|
10
|
+
repo: pr.repo,
|
|
11
|
+
number: pr.number,
|
|
12
|
+
title: pr.title,
|
|
13
|
+
author: pr.author,
|
|
14
|
+
state: pr.state,
|
|
15
|
+
draft: pr.draft,
|
|
16
|
+
updated_at: pr.updated_at,
|
|
17
|
+
created_at: pr.created_at,
|
|
18
|
+
age_days: (0, time_1.diffInDays)(pr.created_at, now),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function computeCycleTime(pr, events) {
|
|
22
|
+
if (pr.merged_at === null) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const openedAt = minTimestamp(events.filter((event) => event.type === 'pr_opened').map((e) => e.opened_at));
|
|
26
|
+
const readyAt = minTimestamp(events.filter((event) => event.type === 'pr_ready').map((e) => e.ready_at));
|
|
27
|
+
const firstReviewAt = minTimestamp(events.filter((event) => event.type === 'review_submitted').map((event) => event.submitted_at));
|
|
28
|
+
const openTime = openedAt ?? pr.created_at;
|
|
29
|
+
const readyTime = readyAt ?? openTime;
|
|
30
|
+
const reviewStart = firstReviewAt ?? null;
|
|
31
|
+
const draftTime = readyAt ? readyTime - openTime : null;
|
|
32
|
+
const reviewTime = reviewStart ? reviewStart - readyTime : 0;
|
|
33
|
+
const mergeTime = reviewStart ? pr.merged_at - reviewStart : 0;
|
|
34
|
+
const totalTime = pr.merged_at - openTime;
|
|
35
|
+
return {
|
|
36
|
+
draft_time: draftTime,
|
|
37
|
+
review_time: reviewTime,
|
|
38
|
+
merge_time: mergeTime,
|
|
39
|
+
total_time: totalTime,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function computeReviewEngagement(pr, events) {
|
|
43
|
+
const comments = events.filter((event) => event.type === 'comment_added');
|
|
44
|
+
const reviews = events.filter((event) => event.type === 'review_submitted');
|
|
45
|
+
if (comments.length === 0 && reviews.length === 0) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const firstComment = minTimestamp(comments.map((event) => event.commented_at));
|
|
49
|
+
const firstReview = minTimestamp(reviews.map((event) => event.submitted_at));
|
|
50
|
+
return {
|
|
51
|
+
time_to_first_comment: firstComment ? firstComment - pr.created_at : null,
|
|
52
|
+
time_to_first_review: firstReview ? firstReview - pr.created_at : null,
|
|
53
|
+
review_count: reviews.length,
|
|
54
|
+
comment_count: comments.length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function percentile(values, pct) {
|
|
58
|
+
if (values.length === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const sorted = [...values].toSorted((a, b) => a - b);
|
|
62
|
+
const index = Math.floor((pct / 100) * (sorted.length - 1));
|
|
63
|
+
return sorted[index] ?? null;
|
|
64
|
+
}
|
|
65
|
+
function minTimestamp(values) {
|
|
66
|
+
const filtered = values.filter((value) => typeof value === 'number');
|
|
67
|
+
if (filtered.length === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return Math.min(...filtered);
|
|
71
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CycleTimeMetrics, Event, PullRequest, ReviewEngagement, Timestamp } from "../types.cjs";
|
|
2
|
+
import type { PullRequestSummary } from "./types.cjs";
|
|
3
|
+
export declare function buildPullRequestSummary(pr: PullRequest, now: Timestamp): PullRequestSummary;
|
|
4
|
+
export declare function computeCycleTime(pr: PullRequest, events: Event[]): CycleTimeMetrics | null;
|
|
5
|
+
export declare function computeReviewEngagement(pr: PullRequest, events: Event[]): ReviewEngagement | null;
|
|
6
|
+
export declare function percentile(values: number[], pct: number): number | null;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CycleTimeMetrics, Event, PullRequest, ReviewEngagement, Timestamp } from "../types.js";
|
|
2
|
+
import type { PullRequestSummary } from "./types.js";
|
|
3
|
+
export declare function buildPullRequestSummary(pr: PullRequest, now: Timestamp): PullRequestSummary;
|
|
4
|
+
export declare function computeCycleTime(pr: PullRequest, events: Event[]): CycleTimeMetrics | null;
|
|
5
|
+
export declare function computeReviewEngagement(pr: PullRequest, events: Event[]): ReviewEngagement | null;
|
|
6
|
+
export declare function percentile(values: number[], pct: number): number | null;
|