@forgeportal/plugin-github-insights 1.3.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/.turbo/turbo-build.log +4 -0
- package/LICENSE +21 -0
- package/dist/GitHubInsightsTab.d.ts +8 -0
- package/dist/GitHubInsightsTab.d.ts.map +1 -0
- package/dist/GitHubInsightsTab.js +111 -0
- package/dist/GitHubInsightsTab.js.map +1 -0
- package/dist/__tests__/api-client.test.d.ts +2 -0
- package/dist/__tests__/api-client.test.d.ts.map +1 -0
- package/dist/__tests__/api-client.test.js +159 -0
- package/dist/__tests__/api-client.test.js.map +1 -0
- package/dist/api-client.d.ts +34 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +85 -0
- package/dist/api-client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/routes.d.ts +14 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +115 -0
- package/dist/routes.js.map +1 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +11 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +17 -0
- package/dist/ui.js.map +1 -0
- package/forgeportal-plugin.json +23 -0
- package/package.json +50 -0
- package/src/GitHubInsightsTab.tsx +421 -0
- package/src/__tests__/api-client.test.ts +192 -0
- package/src/api-client.ts +120 -0
- package/src/index.ts +42 -0
- package/src/routes.ts +151 -0
- package/src/types.ts +86 -0
- package/src/ui.ts +18 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useApi } from '@forgeportal/plugin-sdk/react';
|
|
3
|
+
import type { Entity } from '@forgeportal/plugin-sdk';
|
|
4
|
+
import type { GHRepo, GHPR, GHCommit, GHContributor } from './types.js';
|
|
5
|
+
import { parseGitHubUrl } from './api-client.js';
|
|
6
|
+
|
|
7
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface OverviewResponse {
|
|
10
|
+
data: { repo: GHRepo; openPRCount: number; latestCommit: GHCommit | null };
|
|
11
|
+
}
|
|
12
|
+
interface PRsResponse { data: GHPR[] }
|
|
13
|
+
interface CommitsResponse { data: GHCommit[] }
|
|
14
|
+
interface ContributorsResponse { data: GHContributor[] }
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extracts GitHub owner/repo from:
|
|
20
|
+
* 1. entity.annotations['forgeportal.dev/github-repo'] (explicit override)
|
|
21
|
+
* 2. entity.links — first link pointing to github.com
|
|
22
|
+
* 3. entity.spec.scmUrl — if it's a github.com URL
|
|
23
|
+
*/
|
|
24
|
+
function extractGitHubRef(entity: Entity): { owner: string; repo: string } | null {
|
|
25
|
+
// 1. Explicit annotation: forgeportal.dev/github-repo = owner/repo
|
|
26
|
+
const annotationRepo = entity.annotations?.['forgeportal.dev/github-repo'];
|
|
27
|
+
if (annotationRepo) {
|
|
28
|
+
const parts = annotationRepo.replace(/^https?:\/\/github\.com\//, '').split('/');
|
|
29
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
30
|
+
return { owner: parts[0], repo: parts[1] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Links — first github.com URL
|
|
35
|
+
for (const link of entity.links ?? []) {
|
|
36
|
+
const ref = parseGitHubUrl(link.url);
|
|
37
|
+
if (ref) return ref;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3. spec.scmUrl
|
|
41
|
+
const scmUrl = entity.spec?.['scmUrl'];
|
|
42
|
+
if (typeof scmUrl === 'string') {
|
|
43
|
+
const ref = parseGitHubUrl(scmUrl);
|
|
44
|
+
if (ref) return ref;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function relativeTime(isoDate: string): string {
|
|
51
|
+
const diff = Date.now() - new Date(isoDate).getTime();
|
|
52
|
+
const minutes = Math.floor(diff / 60_000);
|
|
53
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
54
|
+
const hours = Math.floor(minutes / 60);
|
|
55
|
+
if (hours < 24) return `${hours}h ago`;
|
|
56
|
+
const days = Math.floor(hours / 24);
|
|
57
|
+
if (days < 30) return `${days}d ago`;
|
|
58
|
+
return new Date(isoDate).toLocaleDateString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function truncateSha(sha: string): string {
|
|
62
|
+
return sha.slice(0, 7);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function truncateMessage(msg: string, max = 72): string {
|
|
66
|
+
const first = msg.split('\n')[0] ?? msg;
|
|
67
|
+
return first.length > max ? `${first.slice(0, max)}…` : first;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Sub-components ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function SectionTitle({ children, count }: { children: React.ReactNode; count?: number }): React.ReactElement {
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex items-center gap-2 mb-3">
|
|
75
|
+
<h3 className="text-sm font-semibold text-gray-700">{children}</h3>
|
|
76
|
+
{count !== undefined && (
|
|
77
|
+
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">{count}</span>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function PRStatusBadge({ draft, labels }: { draft: boolean; labels: { name: string; color: string }[] }): React.ReactElement {
|
|
84
|
+
if (draft) {
|
|
85
|
+
return <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">Draft</span>;
|
|
86
|
+
}
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-wrap gap-1">
|
|
89
|
+
{labels.map((l) => (
|
|
90
|
+
<span
|
|
91
|
+
key={l.name}
|
|
92
|
+
className="rounded-full px-2 py-0.5 text-xs font-medium"
|
|
93
|
+
style={{ backgroundColor: `#${l.color}22`, color: `#${l.color}` }}
|
|
94
|
+
>
|
|
95
|
+
{l.name}
|
|
96
|
+
</span>
|
|
97
|
+
))}
|
|
98
|
+
{labels.length === 0 && (
|
|
99
|
+
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">Open</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Tab views ────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
type ActiveView = 'overview' | 'prs' | 'commits' | 'contributors';
|
|
108
|
+
|
|
109
|
+
// ─── Main Tab ────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
interface GitHubInsightsTabProps { entity: Entity }
|
|
112
|
+
|
|
113
|
+
export function GitHubInsightsTab({ entity }: GitHubInsightsTabProps): React.ReactElement {
|
|
114
|
+
const [activeView, setActiveView] = useState<ActiveView>('overview');
|
|
115
|
+
|
|
116
|
+
const ghRef = extractGitHubRef(entity);
|
|
117
|
+
|
|
118
|
+
// Not configured state
|
|
119
|
+
if (!ghRef) {
|
|
120
|
+
return (
|
|
121
|
+
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-8 text-center">
|
|
122
|
+
<p className="text-sm font-medium text-gray-700 mb-1">No GitHub repository linked</p>
|
|
123
|
+
<p className="text-xs text-gray-500 mb-4">
|
|
124
|
+
Add a GitHub link to your{' '}
|
|
125
|
+
<code className="rounded bg-gray-100 px-1 py-0.5">entity.yaml</code>{' '}
|
|
126
|
+
or set the annotation{' '}
|
|
127
|
+
<code className="rounded bg-gray-100 px-1 py-0.5">forgeportal.dev/github-repo</code>.
|
|
128
|
+
</p>
|
|
129
|
+
<pre className="mx-auto max-w-md rounded bg-gray-800 p-3 text-left text-xs text-green-300">
|
|
130
|
+
{`metadata:\n links:\n - title: GitHub\n url: https://github.com/owner/repo`}
|
|
131
|
+
</pre>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { owner, repo } = ghRef;
|
|
137
|
+
const baseUrl = `/api/v1/plugins/github-insights/entities/${entity.id}`;
|
|
138
|
+
const q = `owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
|
139
|
+
|
|
140
|
+
const { data: overviewData, isPending: overviewLoading, error: overviewError } =
|
|
141
|
+
useApi<OverviewResponse>(`${baseUrl}/overview?${q}`, { staleTime: 60_000 });
|
|
142
|
+
|
|
143
|
+
const { data: prsData, isPending: prsLoading } =
|
|
144
|
+
useApi<PRsResponse>(`${baseUrl}/prs?${q}`, {
|
|
145
|
+
enabled: activeView === 'prs',
|
|
146
|
+
staleTime: 60_000,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const { data: commitsData, isPending: commitsLoading } =
|
|
150
|
+
useApi<CommitsResponse>(`${baseUrl}/commits?${q}`, {
|
|
151
|
+
enabled: activeView === 'commits',
|
|
152
|
+
staleTime: 60_000,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const { data: contributorsData, isPending: contributorsLoading } =
|
|
156
|
+
useApi<ContributorsResponse>(`${baseUrl}/contributors?${q}`, {
|
|
157
|
+
enabled: activeView === 'contributors',
|
|
158
|
+
staleTime: 300_000,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const overview = overviewData?.data;
|
|
162
|
+
const prs = prsData?.data ?? [];
|
|
163
|
+
const commits = commitsData?.data ?? [];
|
|
164
|
+
const contributors = contributorsData?.data ?? [];
|
|
165
|
+
|
|
166
|
+
const views: { id: ActiveView; label: string }[] = [
|
|
167
|
+
{ id: 'overview', label: 'Overview' },
|
|
168
|
+
{ id: 'prs', label: `PRs${overview ? ` (${overview.openPRCount})` : ''}` },
|
|
169
|
+
{ id: 'commits', label: 'Commits' },
|
|
170
|
+
{ id: 'contributors', label: 'Contributors' },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className="space-y-4">
|
|
175
|
+
{/* Repo header */}
|
|
176
|
+
<div className="flex items-center justify-between">
|
|
177
|
+
<a
|
|
178
|
+
href={`https://github.com/${owner}/${repo}`}
|
|
179
|
+
target="_blank"
|
|
180
|
+
rel="noreferrer"
|
|
181
|
+
className="flex items-center gap-2 text-sm font-medium text-indigo-600 hover:underline"
|
|
182
|
+
>
|
|
183
|
+
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
|
184
|
+
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.387.6.113.82-.258.82-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.84 1.237 1.84 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.3 1.23A11.51 11.51 0 0 1 12 5.803c1.02.005 2.046.138 3.006.404 2.291-1.553 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.91 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .322.218.694.825.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
|
|
185
|
+
</svg>
|
|
186
|
+
{owner}/{repo}
|
|
187
|
+
</a>
|
|
188
|
+
{overview && (
|
|
189
|
+
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
190
|
+
<span>⭐ {overview.repo.stargazers_count.toLocaleString()}</span>
|
|
191
|
+
<span>🍴 {overview.repo.forks_count.toLocaleString()}</span>
|
|
192
|
+
{overview.repo.language && <span>🔵 {overview.repo.language}</span>}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Sub-nav */}
|
|
198
|
+
<div className="flex gap-1 border-b border-gray-200">
|
|
199
|
+
{views.map((v) => (
|
|
200
|
+
<button
|
|
201
|
+
key={v.id}
|
|
202
|
+
onClick={() => setActiveView(v.id)}
|
|
203
|
+
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
204
|
+
activeView === v.id
|
|
205
|
+
? 'border-b-2 border-indigo-500 text-indigo-600 -mb-px'
|
|
206
|
+
: 'text-gray-500 hover:text-gray-700'
|
|
207
|
+
}`}
|
|
208
|
+
>
|
|
209
|
+
{v.label}
|
|
210
|
+
</button>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Error */}
|
|
215
|
+
{overviewError && (
|
|
216
|
+
<div className="rounded-md bg-amber-50 border border-amber-200 p-3 text-xs text-amber-800">
|
|
217
|
+
<span className="font-medium">Limited access: </span>
|
|
218
|
+
{overviewError instanceof Error ? overviewError.message : 'GitHub API error'}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{/* ── Overview ─────────────────────────────────────────────────────────── */}
|
|
223
|
+
{activeView === 'overview' && (
|
|
224
|
+
<div className="space-y-4">
|
|
225
|
+
{overviewLoading && (
|
|
226
|
+
<p className="text-xs text-gray-400 animate-pulse">Loading repository overview…</p>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{overview && (
|
|
230
|
+
<>
|
|
231
|
+
{/* Repo stats cards */}
|
|
232
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
233
|
+
{[
|
|
234
|
+
{ label: 'Open PRs', value: overview.openPRCount, icon: '🔀' },
|
|
235
|
+
{ label: 'Open Issues', value: overview.repo.open_issues_count, icon: '🐛' },
|
|
236
|
+
{ label: 'Stars', value: overview.repo.stargazers_count, icon: '⭐' },
|
|
237
|
+
{ label: 'Forks', value: overview.repo.forks_count, icon: '🍴' },
|
|
238
|
+
].map((stat) => (
|
|
239
|
+
<div key={stat.label} className="rounded-lg border border-gray-200 bg-white p-3">
|
|
240
|
+
<p className="text-xs text-gray-500">{stat.icon} {stat.label}</p>
|
|
241
|
+
<p className="mt-1 text-lg font-semibold text-gray-800">
|
|
242
|
+
{stat.value.toLocaleString()}
|
|
243
|
+
</p>
|
|
244
|
+
</div>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Repo details */}
|
|
249
|
+
{overview.repo.description && (
|
|
250
|
+
<p className="text-xs text-gray-600 italic">{overview.repo.description}</p>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{/* Latest commit */}
|
|
254
|
+
{overview.latestCommit && (
|
|
255
|
+
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
256
|
+
<p className="text-xs font-medium text-gray-500 mb-2">Latest Commit</p>
|
|
257
|
+
<div className="flex items-start gap-3">
|
|
258
|
+
{overview.latestCommit.author && (
|
|
259
|
+
<img
|
|
260
|
+
src={overview.latestCommit.author.avatar_url}
|
|
261
|
+
alt={overview.latestCommit.author.login}
|
|
262
|
+
className="h-6 w-6 rounded-full"
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
<div className="min-w-0 flex-1">
|
|
266
|
+
<p className="text-xs text-gray-800 font-medium truncate">
|
|
267
|
+
{truncateMessage(overview.latestCommit.commit.message)}
|
|
268
|
+
</p>
|
|
269
|
+
<p className="text-xs text-gray-500 mt-0.5">
|
|
270
|
+
<a
|
|
271
|
+
href={overview.latestCommit.html_url}
|
|
272
|
+
target="_blank"
|
|
273
|
+
rel="noreferrer"
|
|
274
|
+
className="font-mono text-indigo-600 hover:underline"
|
|
275
|
+
>
|
|
276
|
+
{truncateSha(overview.latestCommit.sha)}
|
|
277
|
+
</a>
|
|
278
|
+
{' · '}
|
|
279
|
+
{overview.latestCommit.commit.author.name}
|
|
280
|
+
{' · '}
|
|
281
|
+
{relativeTime(overview.latestCommit.commit.author.date)}
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{/* ── Pull Requests ────────────────────────────────────────────────────── */}
|
|
293
|
+
{activeView === 'prs' && (
|
|
294
|
+
<div>
|
|
295
|
+
<SectionTitle count={prs.length}>Open Pull Requests</SectionTitle>
|
|
296
|
+
{prsLoading && <p className="text-xs text-gray-400 animate-pulse">Loading PRs…</p>}
|
|
297
|
+
{!prsLoading && prs.length === 0 && (
|
|
298
|
+
<p className="text-xs text-gray-400">No open pull requests. 🎉</p>
|
|
299
|
+
)}
|
|
300
|
+
<div className="space-y-2">
|
|
301
|
+
{prs.map((pr) => (
|
|
302
|
+
<div key={pr.number} className="rounded-lg border border-gray-200 bg-white p-3 hover:bg-gray-50">
|
|
303
|
+
<div className="flex items-start gap-2">
|
|
304
|
+
<img src={pr.user.avatar_url} alt={pr.user.login} className="h-5 w-5 rounded-full mt-0.5 flex-shrink-0" />
|
|
305
|
+
<div className="min-w-0 flex-1">
|
|
306
|
+
<a
|
|
307
|
+
href={pr.html_url}
|
|
308
|
+
target="_blank"
|
|
309
|
+
rel="noreferrer"
|
|
310
|
+
className="text-xs font-medium text-gray-800 hover:text-indigo-600 hover:underline line-clamp-1"
|
|
311
|
+
>
|
|
312
|
+
{pr.title}
|
|
313
|
+
</a>
|
|
314
|
+
<div className="flex items-center gap-2 mt-1">
|
|
315
|
+
<span className="text-xs text-gray-400">
|
|
316
|
+
#{pr.number} · {pr.user.login} · {relativeTime(pr.created_at)}
|
|
317
|
+
</span>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
<PRStatusBadge draft={pr.draft} labels={pr.labels} />
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
|
|
328
|
+
{/* ── Commits ──────────────────────────────────────────────────────────── */}
|
|
329
|
+
{activeView === 'commits' && (
|
|
330
|
+
<div>
|
|
331
|
+
<SectionTitle count={commits.length}>Recent Commits</SectionTitle>
|
|
332
|
+
{commitsLoading && <p className="text-xs text-gray-400 animate-pulse">Loading commits…</p>}
|
|
333
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
334
|
+
<table className="min-w-full text-xs">
|
|
335
|
+
<thead>
|
|
336
|
+
<tr className="border-b border-gray-200 bg-gray-50">
|
|
337
|
+
<th className="px-3 py-2 text-left font-medium text-gray-600">SHA</th>
|
|
338
|
+
<th className="px-3 py-2 text-left font-medium text-gray-600">Message</th>
|
|
339
|
+
<th className="px-3 py-2 text-left font-medium text-gray-600">Author</th>
|
|
340
|
+
<th className="px-3 py-2 text-left font-medium text-gray-600">Date</th>
|
|
341
|
+
</tr>
|
|
342
|
+
</thead>
|
|
343
|
+
<tbody className="divide-y divide-gray-100 bg-white">
|
|
344
|
+
{commits.map((c) => (
|
|
345
|
+
<tr key={c.sha} className="hover:bg-gray-50">
|
|
346
|
+
<td className="px-3 py-2">
|
|
347
|
+
<a href={c.html_url} target="_blank" rel="noreferrer" className="font-mono text-indigo-600 hover:underline">
|
|
348
|
+
{truncateSha(c.sha)}
|
|
349
|
+
</a>
|
|
350
|
+
</td>
|
|
351
|
+
<td className="px-3 py-2 max-w-xs">
|
|
352
|
+
<p className="truncate text-gray-800">{truncateMessage(c.commit.message, 60)}</p>
|
|
353
|
+
</td>
|
|
354
|
+
<td className="px-3 py-2">
|
|
355
|
+
<div className="flex items-center gap-1">
|
|
356
|
+
{c.author && (
|
|
357
|
+
<img src={c.author.avatar_url} alt={c.author.login} className="h-4 w-4 rounded-full" />
|
|
358
|
+
)}
|
|
359
|
+
<span className="text-gray-600">{c.commit.author.name}</span>
|
|
360
|
+
</div>
|
|
361
|
+
</td>
|
|
362
|
+
<td className="px-3 py-2 text-gray-500 whitespace-nowrap">
|
|
363
|
+
{relativeTime(c.commit.author.date)}
|
|
364
|
+
</td>
|
|
365
|
+
</tr>
|
|
366
|
+
))}
|
|
367
|
+
{!commitsLoading && commits.length === 0 && (
|
|
368
|
+
<tr>
|
|
369
|
+
<td colSpan={4} className="py-6 text-center text-gray-400">No commits found.</td>
|
|
370
|
+
</tr>
|
|
371
|
+
)}
|
|
372
|
+
</tbody>
|
|
373
|
+
</table>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
{/* ── Contributors ─────────────────────────────────────────────────────── */}
|
|
379
|
+
{activeView === 'contributors' && (
|
|
380
|
+
<div>
|
|
381
|
+
<SectionTitle count={contributors.length}>Top Contributors</SectionTitle>
|
|
382
|
+
{contributorsLoading && <p className="text-xs text-gray-400 animate-pulse">Loading contributors…</p>}
|
|
383
|
+
{contributors.length > 0 && (() => {
|
|
384
|
+
const max = contributors[0]?.contributions ?? 1;
|
|
385
|
+
return (
|
|
386
|
+
<div className="space-y-3">
|
|
387
|
+
{contributors.map((c) => (
|
|
388
|
+
<div key={c.login} className="flex items-center gap-3">
|
|
389
|
+
<img src={c.avatar_url} alt={c.login} className="h-7 w-7 rounded-full flex-shrink-0" />
|
|
390
|
+
<div className="flex-1 min-w-0">
|
|
391
|
+
<div className="flex items-center justify-between mb-1">
|
|
392
|
+
<a
|
|
393
|
+
href={c.html_url}
|
|
394
|
+
target="_blank"
|
|
395
|
+
rel="noreferrer"
|
|
396
|
+
className="text-xs font-medium text-gray-800 hover:text-indigo-600 hover:underline"
|
|
397
|
+
>
|
|
398
|
+
{c.login}
|
|
399
|
+
</a>
|
|
400
|
+
<span className="text-xs text-gray-500">{c.contributions} commits</span>
|
|
401
|
+
</div>
|
|
402
|
+
<div className="h-1.5 w-full rounded-full bg-gray-100">
|
|
403
|
+
<div
|
|
404
|
+
className="h-1.5 rounded-full bg-indigo-500"
|
|
405
|
+
style={{ width: `${Math.round((c.contributions / max) * 100)}%` }}
|
|
406
|
+
/>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
))}
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
})()}
|
|
414
|
+
{!contributorsLoading && contributors.length === 0 && (
|
|
415
|
+
<p className="text-xs text-gray-400">No contributors data available.</p>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { GitHubInsightsClient, parseGitHubUrl } from '../api-client.js';
|
|
3
|
+
import type { GitHubInsightsConfig } from '../types.js';
|
|
4
|
+
|
|
5
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const CONFIG: GitHubInsightsConfig = {
|
|
8
|
+
token: 'ghp_test_token',
|
|
9
|
+
cacheTTLSeconds: 0, // disable cache for tests
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const REPO_FIXTURE = {
|
|
13
|
+
full_name: 'acme/payments-api',
|
|
14
|
+
description: 'The payments API',
|
|
15
|
+
default_branch: 'main',
|
|
16
|
+
stargazers_count: 42,
|
|
17
|
+
forks_count: 7,
|
|
18
|
+
open_issues_count: 3,
|
|
19
|
+
language: 'TypeScript',
|
|
20
|
+
html_url: 'https://github.com/acme/payments-api',
|
|
21
|
+
pushed_at: '2026-02-20T10:00:00Z',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const PR_FIXTURE = [{
|
|
25
|
+
number: 99,
|
|
26
|
+
title: 'feat: add webhook support',
|
|
27
|
+
html_url: 'https://github.com/acme/payments-api/pull/99',
|
|
28
|
+
state: 'open',
|
|
29
|
+
user: { login: 'alice', avatar_url: 'https://avatars.githubusercontent.com/alice' },
|
|
30
|
+
created_at: '2026-02-18T09:00:00Z',
|
|
31
|
+
updated_at: '2026-02-19T14:00:00Z',
|
|
32
|
+
labels: [],
|
|
33
|
+
}];
|
|
34
|
+
|
|
35
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function mockFetch(body: unknown, status = 200): ReturnType<typeof vi.fn> {
|
|
38
|
+
const mock = vi.fn().mockResolvedValue({
|
|
39
|
+
ok: status >= 200 && status < 300,
|
|
40
|
+
status,
|
|
41
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
42
|
+
headers: { get: () => null },
|
|
43
|
+
json: () => Promise.resolve(body),
|
|
44
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
45
|
+
});
|
|
46
|
+
vi.stubGlobal('fetch', mock);
|
|
47
|
+
return mock;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
51
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
52
|
+
|
|
53
|
+
// ── parseGitHubUrl ────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe('parseGitHubUrl', () => {
|
|
56
|
+
it('parses a standard HTTPS GitHub URL', () => {
|
|
57
|
+
expect(parseGitHubUrl('https://github.com/acme/payments-api')).toEqual({
|
|
58
|
+
owner: 'acme',
|
|
59
|
+
repo: 'payments-api',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('strips .git suffix', () => {
|
|
64
|
+
expect(parseGitHubUrl('https://github.com/acme/payments-api.git')).toEqual({
|
|
65
|
+
owner: 'acme',
|
|
66
|
+
repo: 'payments-api',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('parses URL without protocol prefix', () => {
|
|
71
|
+
expect(parseGitHubUrl('github.com/acme/my-repo')).toEqual({
|
|
72
|
+
owner: 'acme',
|
|
73
|
+
repo: 'my-repo',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns null for non-GitHub URLs', () => {
|
|
78
|
+
expect(parseGitHubUrl('https://gitlab.com/acme/repo')).toBeNull();
|
|
79
|
+
expect(parseGitHubUrl('https://bitbucket.org/acme/repo')).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns null for invalid URLs', () => {
|
|
83
|
+
expect(parseGitHubUrl('not-a-url')).toBeNull();
|
|
84
|
+
expect(parseGitHubUrl('')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns null when owner or repo is missing', () => {
|
|
88
|
+
expect(parseGitHubUrl('https://github.com/acme')).toBeNull();
|
|
89
|
+
expect(parseGitHubUrl('https://github.com/')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── GitHubInsightsClient.getRepo ──────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('GitHubInsightsClient.getRepo', () => {
|
|
96
|
+
const client = new GitHubInsightsClient(CONFIG);
|
|
97
|
+
|
|
98
|
+
it('fetches repository metadata', async () => {
|
|
99
|
+
mockFetch(REPO_FIXTURE);
|
|
100
|
+
const repo = await client.getRepo('acme', 'payments-api');
|
|
101
|
+
expect(repo.full_name).toBe('acme/payments-api');
|
|
102
|
+
expect(repo.stargazers_count).toBe(42);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('sends the Authorization header', async () => {
|
|
106
|
+
const fetchMock = mockFetch(REPO_FIXTURE);
|
|
107
|
+
await client.getRepo('acme', 'payments-api');
|
|
108
|
+
const [, init] = fetchMock.mock.calls[0] as [string, Record<string, Record<string, string>>];
|
|
109
|
+
expect(init.headers['Authorization']).toBe('Bearer ghp_test_token');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('sends Accept: application/vnd.github+json', async () => {
|
|
113
|
+
const fetchMock = mockFetch(REPO_FIXTURE);
|
|
114
|
+
await client.getRepo('acme', 'payments-api');
|
|
115
|
+
const [, init] = fetchMock.mock.calls[0] as [string, Record<string, Record<string, string>>];
|
|
116
|
+
expect(init.headers['Accept']).toBe('application/vnd.github+json');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws "not found" on 404', async () => {
|
|
120
|
+
mockFetch({ message: 'Not Found' }, 404);
|
|
121
|
+
await expect(client.getRepo('acme', 'ghost-repo')).rejects.toThrow('not found');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('throws rate limit error on 403', async () => {
|
|
125
|
+
const mock = vi.fn().mockResolvedValue({
|
|
126
|
+
ok: false, status: 403,
|
|
127
|
+
headers: { get: (h: string) => h === 'Retry-After' ? '60' : null },
|
|
128
|
+
json: () => Promise.resolve({}),
|
|
129
|
+
text: () => Promise.resolve('rate limit'),
|
|
130
|
+
});
|
|
131
|
+
vi.stubGlobal('fetch', mock);
|
|
132
|
+
await expect(client.getRepo('acme', 'payments-api')).rejects.toThrow(/rate limit/i);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('throws rate limit error on 429', async () => {
|
|
136
|
+
const mock = vi.fn().mockResolvedValue({
|
|
137
|
+
ok: false, status: 429,
|
|
138
|
+
headers: { get: () => null },
|
|
139
|
+
json: () => Promise.resolve({}),
|
|
140
|
+
text: () => Promise.resolve('too many'),
|
|
141
|
+
});
|
|
142
|
+
vi.stubGlobal('fetch', mock);
|
|
143
|
+
await expect(client.getRepo('acme', 'payments-api')).rejects.toThrow(/rate limit/i);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── GitHubInsightsClient.getOpenPRs ───────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe('GitHubInsightsClient.getOpenPRs', () => {
|
|
150
|
+
const client = new GitHubInsightsClient(CONFIG);
|
|
151
|
+
|
|
152
|
+
it('returns open pull requests', async () => {
|
|
153
|
+
mockFetch(PR_FIXTURE);
|
|
154
|
+
const prs = await client.getOpenPRs('acme', 'payments-api');
|
|
155
|
+
expect(prs).toHaveLength(1);
|
|
156
|
+
expect(prs[0]?.number).toBe(99);
|
|
157
|
+
expect(prs[0]?.title).toBe('feat: add webhook support');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('requests the correct query parameters', async () => {
|
|
161
|
+
const fetchMock = mockFetch(PR_FIXTURE);
|
|
162
|
+
await client.getOpenPRs('acme', 'payments-api');
|
|
163
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
164
|
+
expect(url).toContain('state=open');
|
|
165
|
+
expect(url).toContain('per_page=25');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── TTL cache ─────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe('GitHubInsightsClient TTL cache', () => {
|
|
172
|
+
it('serves cached data on second request when TTL > 0', async () => {
|
|
173
|
+
const cachedClient = new GitHubInsightsClient({ ...CONFIG, cacheTTLSeconds: 300 });
|
|
174
|
+
const fetchMock = mockFetch(REPO_FIXTURE);
|
|
175
|
+
|
|
176
|
+
await cachedClient.getRepo('acme', 'payments-api');
|
|
177
|
+
await cachedClient.getRepo('acme', 'payments-api');
|
|
178
|
+
|
|
179
|
+
// fetch should only be called once — second call is from cache
|
|
180
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('bypasses cache when TTL is 0', async () => {
|
|
184
|
+
const noCache = new GitHubInsightsClient({ ...CONFIG, cacheTTLSeconds: 0 });
|
|
185
|
+
const fetchMock = mockFetch(REPO_FIXTURE);
|
|
186
|
+
|
|
187
|
+
await noCache.getRepo('acme', 'payments-api');
|
|
188
|
+
await noCache.getRepo('acme', 'payments-api');
|
|
189
|
+
|
|
190
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
191
|
+
});
|
|
192
|
+
});
|