@git-snitch/renderer 0.0.3
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/build.d.ts +7 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +53 -0
- package/dist/charts.d.ts +106 -0
- package/dist/charts.d.ts.map +1 -0
- package/dist/charts.js +212 -0
- package/dist/custom-templates.d.ts +3 -0
- package/dist/custom-templates.d.ts.map +1 -0
- package/dist/custom-templates.js +1 -0
- package/dist/data.d.ts +24 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +30 -0
- package/dist/empty-state.d.ts +13 -0
- package/dist/empty-state.d.ts.map +1 -0
- package/dist/empty-state.js +9 -0
- package/dist/export.d.ts +15 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +53 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/inline-plugin.d.ts +13 -0
- package/dist/inline-plugin.d.ts.map +1 -0
- package/dist/inline-plugin.js +81 -0
- package/dist/layout.d.ts +43 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +25 -0
- package/dist/remote-urls.d.ts +6 -0
- package/dist/remote-urls.d.ts.map +1 -0
- package/dist/remote-urls.js +82 -0
- package/dist/serialization.d.ts +5 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +46 -0
- package/dist/tables.d.ts +50 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +228 -0
- package/dist/template/report-template.html +135 -0
- package/dist/template.d.ts +21 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +1 -0
- package/dist/theme-toggle.d.ts +2 -0
- package/dist/theme-toggle.d.ts.map +1 -0
- package/dist/theme-toggle.js +9 -0
- package/dist/theme.d.ts +16 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +70 -0
- package/package.json +57 -0
- package/report-template.html +15 -0
- package/src/app.tsx +351 -0
- package/src/build.ts +68 -0
- package/src/charts-route.tsx +158 -0
- package/src/charts.tsx +482 -0
- package/src/custom-template-module.d.ts +5 -0
- package/src/custom-templates.ts +3 -0
- package/src/data.ts +52 -0
- package/src/empty-state.tsx +31 -0
- package/src/export.ts +77 -0
- package/src/index.ts +52 -0
- package/src/inline-plugin.ts +123 -0
- package/src/layout.tsx +152 -0
- package/src/main.tsx +17 -0
- package/src/overview.tsx +253 -0
- package/src/quality-hotspots-routes.tsx +340 -0
- package/src/remote-urls.ts +97 -0
- package/src/repo-routes.tsx +285 -0
- package/src/scan-routes.tsx +393 -0
- package/src/serialization.ts +58 -0
- package/src/styles.css +2 -0
- package/src/tables.tsx +467 -0
- package/src/template.ts +30 -0
- package/src/theme-toggle.tsx +24 -0
- package/src/theme.tsx +108 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +41 -0
package/src/overview.tsx
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
|
|
2
|
+
|
|
3
|
+
import type { CommitRecord, GitHubRepoMeta, RepoReportData, ReportData } from "@git-snitch/core";
|
|
4
|
+
|
|
5
|
+
import { CommitActivityChart, deriveCommitActivityData } from "./charts.js";
|
|
6
|
+
import { EmptyState } from "./empty-state.js";
|
|
7
|
+
import { StatsGrid } from "./layout.js";
|
|
8
|
+
|
|
9
|
+
type StreakSummary =
|
|
10
|
+
| {
|
|
11
|
+
readonly status: "ready";
|
|
12
|
+
readonly current: number;
|
|
13
|
+
readonly longest: number;
|
|
14
|
+
readonly anchorDate: string;
|
|
15
|
+
}
|
|
16
|
+
| { readonly status: "empty" }
|
|
17
|
+
| { readonly status: "insufficient-dates" };
|
|
18
|
+
|
|
19
|
+
function parseCommitDay(commit: CommitRecord) {
|
|
20
|
+
const timestamp = Date.parse(commit.authoredAt);
|
|
21
|
+
|
|
22
|
+
if (Number.isNaN(timestamp)) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Date(timestamp).toISOString().slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function nextUtcDay(day: string) {
|
|
30
|
+
const timestamp = Date.parse(`${day}T00:00:00.000Z`);
|
|
31
|
+
|
|
32
|
+
if (Number.isNaN(timestamp)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new Date(timestamp + 86_400_000).toISOString().slice(0, 10);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function deriveStreakSummary(commits: readonly CommitRecord[]): StreakSummary {
|
|
40
|
+
if (commits.length === 0) {
|
|
41
|
+
return { status: "empty" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const days = [...new Set(commits.map(parseCommitDay).filter((day): day is string => day !== undefined))].sort();
|
|
45
|
+
|
|
46
|
+
if (days.length === 0) {
|
|
47
|
+
return { status: "insufficient-dates" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let longest = 1;
|
|
51
|
+
let active = 1;
|
|
52
|
+
|
|
53
|
+
for (let index = 1; index < days.length; index += 1) {
|
|
54
|
+
const previousDay = days[index - 1];
|
|
55
|
+
const currentDay = days[index];
|
|
56
|
+
|
|
57
|
+
if (previousDay && currentDay && nextUtcDay(previousDay) === currentDay) {
|
|
58
|
+
active += 1;
|
|
59
|
+
} else {
|
|
60
|
+
active = 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
longest = Math.max(longest, active);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let current = 1;
|
|
67
|
+
for (let index = days.length - 1; index > 0; index -= 1) {
|
|
68
|
+
const previousDay = days[index - 1];
|
|
69
|
+
const currentDay = days[index];
|
|
70
|
+
|
|
71
|
+
if (previousDay && currentDay && nextUtcDay(previousDay) === currentDay) {
|
|
72
|
+
current += 1;
|
|
73
|
+
} else {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { status: "ready", current, longest, anchorDate: days[days.length - 1] ?? "" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function totalAdditions(report: RepoReportData) {
|
|
82
|
+
return report.commits.reduce((sum, commit) => sum + commit.files.reduce((fileSum, file) => fileSum + file.additions, 0), 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function totalDeletions(report: RepoReportData) {
|
|
86
|
+
return report.commits.reduce((sum, commit) => sum + commit.files.reduce((fileSum, file) => fileSum + file.deletions, 0), 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function totalLinesOfCode(report: RepoReportData) {
|
|
90
|
+
return report.analysis.languages.reduce((sum, language) => sum + language.lines, 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatNumber(value: number) {
|
|
94
|
+
return new Intl.NumberFormat("en").format(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatCompact(value: number) {
|
|
98
|
+
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function GitHubMetaBar({ meta }: { readonly meta: GitHubRepoMeta }) {
|
|
102
|
+
const items: readonly string[] = [
|
|
103
|
+
...(meta.stars !== undefined && meta.stars > 0 ? [`Stars ${formatCompact(meta.stars)}`] : []),
|
|
104
|
+
...(meta.forks !== undefined && meta.forks > 0 ? [`Forks ${formatCompact(meta.forks)}`] : []),
|
|
105
|
+
...(meta.license !== undefined && meta.license.length > 0 ? [`License ${meta.license}`] : []),
|
|
106
|
+
...(meta.visibility !== undefined ? [meta.visibility === "private" ? "Private" : "Public"] : []),
|
|
107
|
+
...(meta.openIssues !== undefined && meta.openIssues > 0 ? [`Issues ${formatCompact(meta.openIssues)}`] : []),
|
|
108
|
+
...(meta.openPullRequests !== undefined && meta.openPullRequests > 0 ? [`PRs ${formatCompact(meta.openPullRequests)}`] : []),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const topics = meta.topics ?? [];
|
|
112
|
+
|
|
113
|
+
if (items.length === 0 && topics.length === 0 && meta.homepageUrl === undefined) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<section className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
|
119
|
+
{items.map((item) => {
|
|
120
|
+
const spaceIndex = item.indexOf(" ");
|
|
121
|
+
const label = spaceIndex >= 0 ? item.slice(0, spaceIndex) : item;
|
|
122
|
+
const value = spaceIndex >= 0 ? item.slice(spaceIndex + 1) : undefined;
|
|
123
|
+
return (
|
|
124
|
+
<span key={item} className="text-sm text-muted-foreground">
|
|
125
|
+
{label}{value !== undefined ? (<>
|
|
126
|
+
{" "}<strong className="font-medium text-foreground">{value}</strong>
|
|
127
|
+
</>) : null}
|
|
128
|
+
</span>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
{meta.homepageUrl !== undefined && meta.homepageUrl.length > 0 ? (
|
|
132
|
+
<a
|
|
133
|
+
className="text-sm text-muted-foreground hover:text-foreground"
|
|
134
|
+
href={meta.homepageUrl}
|
|
135
|
+
target="_blank"
|
|
136
|
+
rel="noopener noreferrer"
|
|
137
|
+
>
|
|
138
|
+
Homepage
|
|
139
|
+
</a>
|
|
140
|
+
) : null}
|
|
141
|
+
{topics.length > 0 ? (
|
|
142
|
+
<div className="flex flex-wrap gap-1">
|
|
143
|
+
{topics.map((topic) => (
|
|
144
|
+
<span key={topic} className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{topic}</span>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
148
|
+
</section>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildOverviewStats(report: RepoReportData) {
|
|
153
|
+
return [
|
|
154
|
+
{ label: "Total commits", value: formatNumber(report.commits.length), description: "Commits included in this report" },
|
|
155
|
+
{ label: "Contributors", value: formatNumber(report.contributors.length), description: "Unique author identities" },
|
|
156
|
+
{ label: "Additions", value: formatNumber(totalAdditions(report)), description: "Lines added across file changes" },
|
|
157
|
+
{ label: "Deletions", value: formatNumber(totalDeletions(report)), description: "Lines removed across file changes" },
|
|
158
|
+
{ label: "LoC", value: formatNumber(totalLinesOfCode(report)), description: "Detected lines of code" },
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function StreakCard({ streak }: { readonly streak: StreakSummary }) {
|
|
163
|
+
if (streak.status === "empty") {
|
|
164
|
+
return (
|
|
165
|
+
<EmptyState
|
|
166
|
+
title="No streak data yet"
|
|
167
|
+
description="Streaks appear after the repository has at least one dated commit."
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (streak.status === "insufficient-dates") {
|
|
173
|
+
return (
|
|
174
|
+
<EmptyState
|
|
175
|
+
title="Streak data is unavailable"
|
|
176
|
+
description="The commits in this report do not include valid authored dates, so streaks cannot be derived."
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<Card className="h-full overflow-hidden shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5">
|
|
183
|
+
<CardHeader className="space-y-2">
|
|
184
|
+
<CardTitle className="text-base font-semibold tracking-tight text-foreground">Commit streak</CardTitle>
|
|
185
|
+
<p className="text-sm leading-6 text-muted-foreground">Consecutive UTC commit days ending at the latest commit in this report.</p>
|
|
186
|
+
</CardHeader>
|
|
187
|
+
<CardContent className="grid grid-flow-dense gap-4 sm:grid-cols-2">
|
|
188
|
+
<div>
|
|
189
|
+
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Current</p>
|
|
190
|
+
<p className="mt-2 text-4xl font-semibold tracking-tight text-foreground">{streak.current}</p>
|
|
191
|
+
<p className="mt-2 text-xs leading-5 text-muted-foreground">Ending {streak.anchorDate}</p>
|
|
192
|
+
</div>
|
|
193
|
+
<div>
|
|
194
|
+
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Longest</p>
|
|
195
|
+
<p className="mt-2 text-4xl font-semibold tracking-tight text-foreground">{streak.longest}</p>
|
|
196
|
+
<p className="mt-2 text-xs leading-5 text-muted-foreground">Best consecutive-day run</p>
|
|
197
|
+
</div>
|
|
198
|
+
</CardContent>
|
|
199
|
+
</Card>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ChartPreview({ report }: { readonly report: RepoReportData }) {
|
|
204
|
+
const activity = deriveCommitActivityData(report);
|
|
205
|
+
|
|
206
|
+
if (activity.length === 0 || activity.every((point) => point.commits === 0)) {
|
|
207
|
+
return (
|
|
208
|
+
<EmptyState
|
|
209
|
+
title="No activity preview yet"
|
|
210
|
+
description="The mini chart needs at least one cadence point with commits."
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return <CommitActivityChart data={activity} />;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function RepoOverview({ report }: { readonly report: ReportData }) {
|
|
219
|
+
if (report.kind !== "repo") {
|
|
220
|
+
return (
|
|
221
|
+
<EmptyState
|
|
222
|
+
title="Repo overview is unavailable for scan reports"
|
|
223
|
+
description="This route expects a single-repository report. Open the scan overview for multi-repository aggregate evidence."
|
|
224
|
+
/>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const githubMeta = report.repository.github;
|
|
229
|
+
|
|
230
|
+
if (report.commits.length === 0 && report.contributors.length === 0) {
|
|
231
|
+
return (
|
|
232
|
+
<div className="grid gap-6">
|
|
233
|
+
<StatsGrid stats={buildOverviewStats(report)} />
|
|
234
|
+
{githubMeta !== undefined ? <GitHubMetaBar meta={githubMeta} /> : null}
|
|
235
|
+
<EmptyState
|
|
236
|
+
title="This repository has no commit activity yet"
|
|
237
|
+
description="git-snitch found a repository report, but there are no commits or contributors to summarize. Charts and streaks will appear after activity exists."
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div className="grid gap-6">
|
|
245
|
+
<StatsGrid stats={buildOverviewStats(report)} />
|
|
246
|
+
{githubMeta !== undefined ? <GitHubMetaBar meta={githubMeta} /> : null}
|
|
247
|
+
<section aria-label="Repository overview previews" className="grid grid-flow-dense gap-6 lg:grid-cols-2">
|
|
248
|
+
<StreakCard streak={deriveStreakSummary(report.commits)} />
|
|
249
|
+
<ChartPreview report={report} />
|
|
250
|
+
</section>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
|
|
2
|
+
import { cn } from "@git-snitch/ui/lib/utils";
|
|
3
|
+
|
|
4
|
+
import type { FileHotspot, QualitySignal, RepoReportData, ReportData } from "@git-snitch/core";
|
|
5
|
+
|
|
6
|
+
import { EmptyState } from "./empty-state.js";
|
|
7
|
+
import { HotspotsTable } from "./tables.js";
|
|
8
|
+
|
|
9
|
+
type RepoRouteProps = {
|
|
10
|
+
readonly report: ReportData;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type HealthRating = "Strong" | "Watch" | "Strained" | "Unclear";
|
|
14
|
+
|
|
15
|
+
const minimumConclusiveCommits = 3;
|
|
16
|
+
const minimumConclusiveContributors = 2;
|
|
17
|
+
|
|
18
|
+
type QualityMetric = {
|
|
19
|
+
readonly label: string;
|
|
20
|
+
readonly value: string;
|
|
21
|
+
readonly description: string;
|
|
22
|
+
readonly tone: "good" | "watch" | "risk" | "neutral";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function formatNumber(value: number) {
|
|
26
|
+
return new Intl.NumberFormat("en").format(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function repoDataMismatch(title: string) {
|
|
30
|
+
return (
|
|
31
|
+
<EmptyState
|
|
32
|
+
title={title}
|
|
33
|
+
description="This route expects a single-repository report. Scan reports use aggregate scan routes rather than repo-only quality views."
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function repoFilename(report: RepoReportData, suffix: string) {
|
|
39
|
+
const safeName = report.repository.name.trim().replaceAll(/[^a-zA-Z0-9._-]+/g, "-").replaceAll(/^-|-$/g, "");
|
|
40
|
+
return `${safeName.length > 0 ? safeName : "repo"}-${suffix}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function commitChurn(report: RepoReportData) {
|
|
44
|
+
return report.commits.reduce(
|
|
45
|
+
(sum, commit) => sum + commit.files.reduce((fileSum, file) => fileSum + file.additions + file.deletions, 0),
|
|
46
|
+
0,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function averageCommitSize(report: RepoReportData) {
|
|
51
|
+
if (report.commits.length === 0) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Math.round(commitChurn(report) / report.commits.length);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function busFactor(report: RepoReportData) {
|
|
59
|
+
const sortedCommitCounts = report.contributors.map((contributor) => contributor.commitCount).filter((count) => count > 0).sort((left, right) => right - left);
|
|
60
|
+
const totalCommits = sortedCommitCounts.reduce((sum, count) => sum + count, 0);
|
|
61
|
+
|
|
62
|
+
if (totalCommits === 0) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const threshold = totalCommits * 0.8;
|
|
67
|
+
let covered = 0;
|
|
68
|
+
let contributors = 0;
|
|
69
|
+
|
|
70
|
+
for (const count of sortedCommitCounts) {
|
|
71
|
+
covered += count;
|
|
72
|
+
contributors += 1;
|
|
73
|
+
if (covered >= threshold) {
|
|
74
|
+
return contributors;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return contributors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function codeStability(report: RepoReportData) {
|
|
82
|
+
const additions = report.commits.reduce((sum, commit) => sum + commit.files.reduce((fileSum, file) => fileSum + file.additions, 0), 0);
|
|
83
|
+
const deletions = report.commits.reduce((sum, commit) => sum + commit.files.reduce((fileSum, file) => fileSum + file.deletions, 0), 0);
|
|
84
|
+
|
|
85
|
+
if (report.commits.length === 0) {
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (deletions === 0) {
|
|
90
|
+
return additions > 0 ? 2 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Math.round((additions / deletions) * 100) / 100;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function severityPenalty(signal: QualitySignal) {
|
|
97
|
+
if (signal.severity === "critical") {
|
|
98
|
+
return 24;
|
|
99
|
+
}
|
|
100
|
+
if (signal.severity === "warning") {
|
|
101
|
+
return 12;
|
|
102
|
+
}
|
|
103
|
+
return 6;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasConclusiveQualityEvidence(report: RepoReportData) {
|
|
107
|
+
return report.commits.length >= minimumConclusiveCommits && report.contributors.length >= minimumConclusiveContributors;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function deriveHealthScore(report: RepoReportData) {
|
|
111
|
+
if (!hasConclusiveQualityEvidence(report)) {
|
|
112
|
+
return { score: 0, rating: "Unclear" satisfies HealthRating };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const signalPenalty = report.analysis.qualitySignals.reduce((sum, signal) => sum + severityPenalty(signal), 0);
|
|
116
|
+
const busFactorPenalty = report.commits.length > 0 && busFactor(report) <= 1 ? 16 : 0;
|
|
117
|
+
const hotspotPenalty = report.analysis.hotspots.some((hotspot) => hotspot.riskLevel.level === "high") ? 10 : 0;
|
|
118
|
+
const score = Math.max(0, Math.min(100, 100 - signalPenalty - busFactorPenalty - hotspotPenalty));
|
|
119
|
+
|
|
120
|
+
if (score >= 80) {
|
|
121
|
+
return { score, rating: "Strong" satisfies HealthRating };
|
|
122
|
+
}
|
|
123
|
+
if (score >= 55) {
|
|
124
|
+
return { score, rating: "Watch" satisfies HealthRating };
|
|
125
|
+
}
|
|
126
|
+
return { score, rating: "Strained" satisfies HealthRating };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function qualityMetrics(report: RepoReportData): readonly QualityMetric[] {
|
|
130
|
+
const factor = busFactor(report);
|
|
131
|
+
const averageSize = averageCommitSize(report);
|
|
132
|
+
const churn = commitChurn(report);
|
|
133
|
+
const stability = codeStability(report);
|
|
134
|
+
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
label: "Bus factor",
|
|
138
|
+
value: factor === 0 ? "No commits" : formatNumber(factor),
|
|
139
|
+
description: "Contributors covering roughly 80% of observed commits.",
|
|
140
|
+
tone: factor === 0 ? "neutral" : factor <= 1 ? "risk" : factor <= 2 ? "watch" : "good",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
label: "Avg commit size",
|
|
144
|
+
value: `${formatNumber(averageSize)} lines`,
|
|
145
|
+
description: "Mean changed lines per commit from file statistics.",
|
|
146
|
+
tone: averageSize === 0 ? "neutral" : averageSize > 500 ? "risk" : averageSize > 200 ? "watch" : "good",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
label: "Churn",
|
|
150
|
+
value: `${formatNumber(churn)} lines`,
|
|
151
|
+
description: "Total additions and deletions in the selected repo scope.",
|
|
152
|
+
tone: churn === 0 ? "neutral" : churn > 5_000 ? "risk" : churn > 1_000 ? "watch" : "good",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
label: "Stability ratio",
|
|
156
|
+
value: stability.toLocaleString("en"),
|
|
157
|
+
description: "Additions divided by deletions; sparse repos are marked explicitly.",
|
|
158
|
+
tone: report.commits.length === 0 ? "neutral" : stability > 3 || stability < 0.5 ? "watch" : "good",
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function metricToneClass(tone: QualityMetric["tone"]) {
|
|
164
|
+
if (tone === "risk") {
|
|
165
|
+
return "border-red-500/30 bg-red-500/10";
|
|
166
|
+
}
|
|
167
|
+
if (tone === "watch") {
|
|
168
|
+
return "border-amber-500/30 bg-amber-500/10";
|
|
169
|
+
}
|
|
170
|
+
if (tone === "good") {
|
|
171
|
+
return "border-emerald-500/30 bg-emerald-500/10";
|
|
172
|
+
}
|
|
173
|
+
return "border-border/70 bg-card/80";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function HealthScorePanel({ report }: { readonly report: RepoReportData }) {
|
|
177
|
+
const health = deriveHealthScore(report);
|
|
178
|
+
const hasEvidence = health.rating !== "Unclear";
|
|
179
|
+
const narrative = hasEvidence
|
|
180
|
+
? "The score combines observed warning signals, ownership concentration, and high-risk files. Tiny repositories are treated as inconclusive instead of being dressed up as healthy."
|
|
181
|
+
: "There are not enough commits or contributors for a confident health label. Treat this repository as inconclusive until it has a larger evidence trail.";
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<section className="grid grid-flow-dense gap-5 rounded-3xl border border-border/70 bg-card/80 p-6 shadow-sm md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-center">
|
|
185
|
+
<div className="rounded-2xl border border-border/70 bg-background/70 p-6">
|
|
186
|
+
<p className="text-sm font-medium text-muted-foreground">Repository health score</p>
|
|
187
|
+
<div className="mt-3 flex items-end gap-2">
|
|
188
|
+
<span className="text-6xl font-semibold tracking-[-0.08em] text-foreground">{hasEvidence ? health.score : "—"}</span>
|
|
189
|
+
{hasEvidence ? <span className="pb-2 text-lg font-medium text-muted-foreground">/100</span> : null}
|
|
190
|
+
</div>
|
|
191
|
+
<p className="mt-3 text-base font-semibold text-foreground">{health.rating}</p>
|
|
192
|
+
</div>
|
|
193
|
+
<div className="max-w-4xl">
|
|
194
|
+
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl">Quality signals without hiding the evidence trail.</h2>
|
|
195
|
+
<p className="mt-4 text-sm leading-6 text-muted-foreground">{narrative}</p>
|
|
196
|
+
</div>
|
|
197
|
+
</section>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function QualityMetricCards({ report }: { readonly report: RepoReportData }) {
|
|
202
|
+
return (
|
|
203
|
+
<section aria-label="Quality metric cards" className="grid grid-flow-dense gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
204
|
+
{qualityMetrics(report).map((metric) => (
|
|
205
|
+
<Card key={metric.label} className={cn("overflow-hidden shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5", metricToneClass(metric.tone))}>
|
|
206
|
+
<CardHeader>
|
|
207
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">{metric.label}</CardTitle>
|
|
208
|
+
</CardHeader>
|
|
209
|
+
<CardContent>
|
|
210
|
+
<p className="text-3xl font-semibold tracking-tight text-foreground">{metric.value}</p>
|
|
211
|
+
<p className="mt-3 text-xs leading-5 text-muted-foreground">{metric.description}</p>
|
|
212
|
+
</CardContent>
|
|
213
|
+
</Card>
|
|
214
|
+
))}
|
|
215
|
+
</section>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function severityClass(severity: QualitySignal["severity"]) {
|
|
220
|
+
if (severity === "critical") {
|
|
221
|
+
return "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300";
|
|
222
|
+
}
|
|
223
|
+
if (severity === "warning") {
|
|
224
|
+
return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
|
225
|
+
}
|
|
226
|
+
return "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function RecommendationsList({ signals }: { readonly signals: readonly QualitySignal[] }) {
|
|
230
|
+
if (signals.length === 0) {
|
|
231
|
+
return (
|
|
232
|
+
<EmptyState
|
|
233
|
+
title="No quality recommendations yet"
|
|
234
|
+
description="git-snitch did not detect quality risks in the available report data. Empty and tiny repositories may simply not have enough evidence."
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<section className="rounded-2xl border border-border/70 bg-card/80 shadow-sm" aria-label="Quality recommendations">
|
|
241
|
+
<div className="border-b border-border/70 p-5">
|
|
242
|
+
<h2 className="text-2xl font-semibold tracking-tight text-foreground">Recommendations</h2>
|
|
243
|
+
<p className="mt-2 text-sm leading-6 text-muted-foreground">Prioritized actions generated from the report quality signals.</p>
|
|
244
|
+
</div>
|
|
245
|
+
<ol className="divide-y divide-border/70">
|
|
246
|
+
{signals.map((signal) => (
|
|
247
|
+
<li key={signal.id} className="grid gap-3 p-5 md:grid-cols-[auto_1fr] md:items-start">
|
|
248
|
+
<span className={cn("inline-flex w-fit rounded-full border px-2.5 py-1 text-xs font-semibold capitalize", severityClass(signal.severity))}>{signal.severity}</span>
|
|
249
|
+
<div>
|
|
250
|
+
<h3 className="font-semibold text-foreground">{signal.label}</h3>
|
|
251
|
+
<p className="mt-1 text-sm leading-6 text-muted-foreground">{signal.summary}</p>
|
|
252
|
+
</div>
|
|
253
|
+
</li>
|
|
254
|
+
))}
|
|
255
|
+
</ol>
|
|
256
|
+
</section>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function countRisk(hotspots: readonly FileHotspot[], level: FileHotspot["riskLevel"]["level"]) {
|
|
261
|
+
return hotspots.filter((hotspot) => hotspot.riskLevel.level === level).length;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function highestRisk(hotspots: readonly FileHotspot[]) {
|
|
265
|
+
const high = hotspots.find((hotspot) => hotspot.riskLevel.level === "high");
|
|
266
|
+
if (high) {
|
|
267
|
+
return high;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return hotspots.find((hotspot) => hotspot.riskLevel.level === "medium") ?? hotspots[0];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function RiskIndicators({ hotspots }: { readonly hotspots: readonly FileHotspot[] }) {
|
|
274
|
+
const topRisk = highestRisk(hotspots);
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<section aria-label="Hotspot risk indicators" className="grid grid-flow-dense gap-4 md:grid-cols-3">
|
|
278
|
+
<Card className="border-red-500/20 bg-red-500/10 shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5">
|
|
279
|
+
<CardHeader><CardTitle className="text-sm font-medium text-muted-foreground">High risk files</CardTitle></CardHeader>
|
|
280
|
+
<CardContent><p className="text-4xl font-semibold tracking-tight text-foreground">{formatNumber(countRisk(hotspots, "high"))}</p></CardContent>
|
|
281
|
+
</Card>
|
|
282
|
+
<Card className="border-amber-500/20 bg-amber-500/10 shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5">
|
|
283
|
+
<CardHeader><CardTitle className="text-sm font-medium text-muted-foreground">Medium risk files</CardTitle></CardHeader>
|
|
284
|
+
<CardContent><p className="text-4xl font-semibold tracking-tight text-foreground">{formatNumber(countRisk(hotspots, "medium"))}</p></CardContent>
|
|
285
|
+
</Card>
|
|
286
|
+
<Card className="shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5">
|
|
287
|
+
<CardHeader><CardTitle className="text-sm font-medium text-muted-foreground">Top hotspot</CardTitle></CardHeader>
|
|
288
|
+
<CardContent>
|
|
289
|
+
<p className="truncate font-mono text-sm font-semibold text-foreground">{topRisk?.path ?? "No file risk"}</p>
|
|
290
|
+
<p className="mt-2 text-xs text-muted-foreground">{topRisk ? `${formatNumber(topRisk.hotspotScore)} score from churn and contributors` : "No ranked file changes yet."}</p>
|
|
291
|
+
</CardContent>
|
|
292
|
+
</Card>
|
|
293
|
+
</section>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function QualityRoute({ report }: RepoRouteProps) {
|
|
298
|
+
if (report.kind !== "repo") {
|
|
299
|
+
return repoDataMismatch("Quality is unavailable for scan reports");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const isTiny = !hasConclusiveQualityEvidence(report);
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div className="grid gap-6">
|
|
306
|
+
<HealthScorePanel report={report} />
|
|
307
|
+
{isTiny ? (
|
|
308
|
+
<EmptyState
|
|
309
|
+
title="Quality evidence is sparse"
|
|
310
|
+
description={`This repository has too little activity for a confident health narrative. At least ${minimumConclusiveCommits} commits and ${minimumConclusiveContributors} contributors are needed before git-snitch labels quality as strong, watch, or strained.`}
|
|
311
|
+
/>
|
|
312
|
+
) : null}
|
|
313
|
+
<QualityMetricCards report={report} />
|
|
314
|
+
<RecommendationsList signals={report.analysis.qualitySignals} />
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function HotspotsRoute({ report }: RepoRouteProps) {
|
|
320
|
+
if (report.kind !== "repo") {
|
|
321
|
+
return repoDataMismatch("Hotspots are unavailable for scan reports");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const hotspots = report.analysis.hotspots;
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<div className="grid gap-6">
|
|
328
|
+
<section className="rounded-3xl border border-border/70 bg-card/80 p-6 shadow-sm">
|
|
329
|
+
<div className="max-w-4xl">
|
|
330
|
+
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl">Hotspots rank files where churn, frequency, and shared ownership intersect.</h2>
|
|
331
|
+
<p className="mt-4 text-sm leading-6 text-muted-foreground">
|
|
332
|
+
Use this route to find files that deserve review before they become expensive coordination points. Low activity repositories show an explicit empty state instead of a fabricated risk map.
|
|
333
|
+
</p>
|
|
334
|
+
</div>
|
|
335
|
+
</section>
|
|
336
|
+
{hotspots.length > 0 ? <RiskIndicators hotspots={hotspots} /> : null}
|
|
337
|
+
<HotspotsTable hotspots={hotspots} exportFilename={repoFilename(report, "hotspots.csv")} remoteUrl={report.repository.remoteUrl} currentBranch={report.repository.currentBranch} />
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export type GitProvider = "github" | "gitlab" | "bitbucket" | "unknown";
|
|
2
|
+
|
|
3
|
+
const SCP_REMOTE = /^git@([^:]+):(.+?)$/;
|
|
4
|
+
|
|
5
|
+
export function normalizeRemoteToWebUrl(remoteUrl: string): string {
|
|
6
|
+
const trimmed = remoteUrl.replace(/\/+$/, "");
|
|
7
|
+
|
|
8
|
+
const scpMatch = SCP_REMOTE.exec(trimmed);
|
|
9
|
+
if (scpMatch && scpMatch[1] !== undefined && scpMatch[2] !== undefined) {
|
|
10
|
+
return `https://${scpMatch[1]}/${scpMatch[2].replace(/\.git$/, "")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (trimmed.startsWith("ssh://")) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = new URL(trimmed);
|
|
16
|
+
const path = parsed.pathname.replace(/^\/+/, "").replace(/\.git$/, "");
|
|
17
|
+
return `https://${parsed.hostname}/${path}`;
|
|
18
|
+
} catch {
|
|
19
|
+
return trimmed.replace(/\.git$/, "");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return trimmed.replace(/\.git$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function detectProvider(remoteUrl: string | undefined): GitProvider {
|
|
27
|
+
if (remoteUrl === undefined) {
|
|
28
|
+
return "unknown";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const host = extractHost(remoteUrl);
|
|
32
|
+
if (host === undefined) {
|
|
33
|
+
return "unknown";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (host === "github.com" || host.endsWith(".github.com")) {
|
|
37
|
+
return "github";
|
|
38
|
+
}
|
|
39
|
+
if (host === "gitlab.com" || host.endsWith(".gitlab.com")) {
|
|
40
|
+
return "gitlab";
|
|
41
|
+
}
|
|
42
|
+
if (host === "bitbucket.org" || host.endsWith(".bitbucket.org")) {
|
|
43
|
+
return "bitbucket";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return "unknown";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildCommitUrl(remoteUrl: string | undefined, hash: string): string | undefined {
|
|
50
|
+
if (remoteUrl === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const provider = detectProvider(remoteUrl);
|
|
55
|
+
const base = normalizeRemoteToWebUrl(remoteUrl);
|
|
56
|
+
|
|
57
|
+
switch (provider) {
|
|
58
|
+
case "github":
|
|
59
|
+
return `${base}/commit/${hash}`;
|
|
60
|
+
case "gitlab":
|
|
61
|
+
return `${base}/-/commit/${hash}`;
|
|
62
|
+
case "bitbucket":
|
|
63
|
+
return `${base}/commits/${hash}`;
|
|
64
|
+
default:
|
|
65
|
+
return `${base}/commit/${hash}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildFileUrl(remoteUrl: string | undefined, branch: string | undefined, filePath: string): string | undefined {
|
|
70
|
+
if (remoteUrl === undefined || branch === undefined) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const provider = detectProvider(remoteUrl);
|
|
75
|
+
const base = normalizeRemoteToWebUrl(remoteUrl);
|
|
76
|
+
|
|
77
|
+
switch (provider) {
|
|
78
|
+
case "github":
|
|
79
|
+
return `${base}/blob/${branch}/${filePath}`;
|
|
80
|
+
case "gitlab":
|
|
81
|
+
return `${base}/-/blob/${branch}/${filePath}`;
|
|
82
|
+
case "bitbucket":
|
|
83
|
+
return `${base}/src/${branch}/${filePath}`;
|
|
84
|
+
default:
|
|
85
|
+
return `${base}/blob/${branch}/${filePath}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractHost(remoteUrl: string): string | undefined {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = new URL(remoteUrl);
|
|
92
|
+
return parsed.hostname;
|
|
93
|
+
} catch {
|
|
94
|
+
const scpMatch = SCP_REMOTE.exec(remoteUrl);
|
|
95
|
+
return scpMatch?.[1];
|
|
96
|
+
}
|
|
97
|
+
}
|