Implement Insights page and getContributors function
swadhinbiswas committed 10 months ago
bb60a17
1 parent f402be5
commit bb60a17
3 files changed
+278 -1
Showing 3 changed files+278-1
▼src/lib/git.ts
+38-0
| @@ -1058,6 +1058,44 @@ export async function getFileRawContent( | ||
1058 | 1058 | }); |
1059 | 1059 | } |
1060 | 1060 | |
1061 | + | |
1062 | +/** | |
1063 | + * Get contributors list with commit counts | |
1064 | + */ | |
1065 | +export async function getContributors( | |
1066 | + repoPath: string, | |
1067 | + ref: string = "HEAD" | |
1068 | +): Promise<{ name: string; email: string; commits: number }[]> { | |
1069 | + const git = getGit(repoPath); | |
1070 | + | |
1071 | + try { | |
1072 | + // git shortlog -sne (summary, numbered, email) | |
1073 | + const output = await git.raw(["shortlog", "-sne", ref]); | |
1074 | + | |
1075 | + const contributors: { name: string; email: string; commits: number }[] = []; | |
1076 | + | |
1077 | + output | |
1078 | + .trim() | |
1079 | + .split("\n") | |
1080 | + .filter(Boolean) | |
1081 | + .forEach((line) => { | |
1082 | + const match = line.match(/^\s*(\d+)\s+(.+)\s+<(.+)>$/); | |
1083 | + if (match) { | |
1084 | + contributors.push({ | |
1085 | + commits: parseInt(match[1], 10), | |
1086 | + name: match[2].trim(), | |
1087 | + email: match[3].trim(), | |
1088 | + }); | |
1089 | + } | |
1090 | + }); | |
1091 | + | |
1092 | + return contributors; | |
1093 | + } catch (error) { | |
1094 | + logger.error("Error getting contributors:", error); | |
1095 | + return []; | |
1096 | + } | |
1097 | +} | |
1098 | + | |
1061 | 1099 | export async function installHooks(repoPath: string) { |
1062 | 1100 | const hooksDir = join(repoPath, "hooks"); |
1063 | 1101 | const fs = await import("fs/promises"); |
1064 | 1102 | |
▼src/pages/[owner]/[repo]/blob/[branch]/[...path].astro
+2-1
| @@ -15,7 +15,8 @@ import { | ||
15 | 15 | File as FileIcon, |
16 | 16 | Trash2, |
17 | 17 | Edit, |
18 | - Monitor | |
18 | + Monitor, | |
19 | + Clock | |
19 | 20 | } from "lucide-react"; |
20 | 21 | import { Button } from "@/components/ui/button"; |
21 | 22 | import { createHighlighter } from 'shiki'; |
22 | 23 | |
▼src/pages/[owner]/[repo]/insights/index.astro
+238-0
| @@ -0,0 +1,238 @@ | ||
1 | +--- | |
2 | +import BaseLayout from "@/layouts/BaseLayout.astro"; | |
3 | +import RepoHeader from "@/components/repo/RepoHeader.astro"; | |
4 | +import { getDatabase, schema } from "@/db"; | |
5 | +import { eq, and, sql, desc, gte } from "drizzle-orm"; | |
6 | +import { canReadRepo } from "@/lib/permissions"; | |
7 | +import { getContributors, getCommits } from "@/lib/git"; | |
8 | +import { | |
9 | + GitCommit, | |
10 | + GitPullRequest, | |
11 | + AlertCircle, | |
12 | + Users, | |
13 | + Activity | |
14 | +} from "lucide-react"; | |
15 | + | |
16 | +const { owner: ownerName, repo: repoName } = Astro.params; | |
17 | +const db = getDatabase(); | |
18 | + | |
19 | +// 1. Fetch Repo & Owner | |
20 | +const user = await db.query.users.findFirst({ | |
21 | + where: eq(schema.users.username, ownerName!), | |
22 | +}); | |
23 | + | |
24 | +if (!user) return Astro.redirect("/404"); | |
25 | + | |
26 | +const repoData = await db.query.repositories.findFirst({ | |
27 | + where: and( | |
28 | + eq(schema.repositories.ownerId, user.id), | |
29 | + eq(schema.repositories.name, repoName!) | |
30 | + ), | |
31 | + with: { | |
32 | + owner: true, | |
33 | + }, | |
34 | +}); | |
35 | + | |
36 | +if (!repoData) return Astro.redirect("/404"); | |
37 | + | |
38 | +// Check permissions | |
39 | +const currentUser = Astro.locals.user; | |
40 | +const hasAccess = await canReadRepo(currentUser?.id, repoData); | |
41 | + | |
42 | +if (!hasAccess) { | |
43 | + return Astro.redirect("/404"); | |
44 | +} | |
45 | + | |
46 | +const repo = { | |
47 | + owner: repoData.owner.username, | |
48 | + name: repoData.name, | |
49 | + description: repoData.description, | |
50 | + visibility: repoData.visibility, | |
51 | + defaultBranch: repoData.defaultBranch, | |
52 | + stars: repoData.starCount, | |
53 | + forks: repoData.forkCount, | |
54 | + watchers: repoData.watchCount, | |
55 | + language: repoData.language || "Unknown", | |
56 | + license: repoData.licenseType || "None", | |
57 | + topics: repoData.topics ? JSON.parse(repoData.topics) : [], | |
58 | + updatedAt: repoData.updatedAt, | |
59 | + openPrCount: repoData.openPrCount, | |
60 | + openIssueCount: 0 // Placeholder, fetched below | |
61 | +}; | |
62 | + | |
63 | +// Fetch real issue count | |
64 | +const issueCountResult = await db | |
65 | + .select({ count: sql<number>`count(*)` }) | |
66 | + .from(schema.issues) | |
67 | + .where( | |
68 | + and( | |
69 | + eq(schema.issues.repositoryId, repoData.id), | |
70 | + eq(schema.issues.state, "open") | |
71 | + ) | |
72 | + ) | |
73 | + .get(); | |
74 | + | |
75 | +repo.openIssueCount = issueCountResult?.count || 0; | |
76 | + | |
77 | +// Gather Insights Data | |
78 | + | |
79 | +// 1. Contributors | |
80 | +const contributors = await getContributors(repoData.diskPath); | |
81 | + | |
82 | +// 2. Recent Commits (Pulse - e.g., last month) | |
83 | +// For now, simple last 50 commits | |
84 | +const recentCommits = await getCommits(repoData.diskPath, { limit: 20 }); | |
85 | + | |
86 | +// 3. Issue/PR Stats (Last 30 days) | |
87 | +const thirtyDaysAgo = new Date(); | |
88 | +thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); | |
89 | + | |
90 | +const recentIssues = await db.select({ count: sql<number>`count(*)` }) | |
91 | + .from(schema.issues) | |
92 | + .where(and( | |
93 | + eq(schema.issues.repositoryId, repoData.id), | |
94 | + gte(schema.issues.createdAt, thirtyDaysAgo.toISOString()) | |
95 | + )).get(); | |
96 | + | |
97 | +const recentPRs = await db.select({ count: sql<number>`count(*)` }) | |
98 | + .from(schema.pullRequests) | |
99 | + .where(and( | |
100 | + eq(schema.pullRequests.repositoryId, repoData.id), | |
101 | + gte(schema.pullRequests.createdAt, thirtyDaysAgo.toISOString()) | |
102 | + )).get(); | |
103 | + | |
104 | +const closedIssues = await db.select({ count: sql<number>`count(*)` }) | |
105 | + .from(schema.issues) | |
106 | + .where(and( | |
107 | + eq(schema.issues.repositoryId, repoData.id), | |
108 | + eq(schema.issues.state, "closed"), | |
109 | + gte(schema.issues.updatedAt, thirtyDaysAgo.toISOString()) | |
110 | + )).get(); | |
111 | + | |
112 | +const mergedPRs = await db.select({ count: sql<number>`count(*)` }) | |
113 | + .from(schema.pullRequests) | |
114 | + .where(and( | |
115 | + eq(schema.pullRequests.repositoryId, repoData.id), | |
116 | + eq(schema.pullRequests.state, "merged"), | |
117 | + gte(schema.pullRequests.updatedAt, thirtyDaysAgo.toISOString()) | |
118 | + )).get(); | |
119 | + | |
120 | +--- | |
121 | + | |
122 | +<BaseLayout title={`Insights · ${repo.owner}/${repo.name}`}> | |
123 | + <div class="container py-6"> | |
124 | + <RepoHeader repo={repo} activeTab="insights" /> | |
125 | + | |
126 | + <div class="grid gap-8 md:grid-cols-[240px_1fr]"> | |
127 | + <!-- Sidebar --> | |
128 | + <aside class="space-y-1"> | |
129 | + <a href="#" class="flex items-center gap-2 px-3 py-2 text-sm font-medium bg-muted text-primary rounded-md"> | |
130 | + <Activity class="h-4 w-4" /> | |
131 | + Pulse | |
132 | + </a> | |
133 | + <a href="#" class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted/50 rounded-md"> | |
134 | + <Users class="h-4 w-4" /> | |
135 | + Contributors | |
136 | + </a> | |
137 | + <div class="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase mt-4"> | |
138 | + Graphs | |
139 | + </div> | |
140 | + <span class="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground cursor-not-allowed opacity-60"> | |
141 | + Code Frequency | |
142 | + </span> | |
143 | + <span class="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground cursor-not-allowed opacity-60"> | |
144 | + Commits | |
145 | + </span> | |
146 | + <span class="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground cursor-not-allowed opacity-60"> | |
147 | + Network | |
148 | + </span> | |
149 | + </aside> | |
150 | + | |
151 | + <!-- Content --> | |
152 | + <div class="space-y-8"> | |
153 | + <!-- Pulse Section --> | |
154 | + <section> | |
155 | + <div class="mb-6"> | |
156 | + <h2 class="text-2xl font-bold tracking-tight">Pulse</h2> | |
157 | + <p class="text-muted-foreground"> | |
158 | + Activity overview for the last 30 days. | |
159 | + </p> | |
160 | + </div> | |
161 | + | |
162 | + <div class="grid gap-4 md:grid-cols-2"> | |
163 | + <div class="rounded-lg border bg-card text-card-foreground shadow-sm p-6"> | |
164 | + <div class="flex items-center gap-4"> | |
165 | + <div class="p-2 bg-blue-100 dark:bg-blue-900/20 rounded-full"> | |
166 | + <GitPullRequest class="h-6 w-6 text-blue-600 dark:text-blue-400" /> | |
167 | + </div> | |
168 | + <div> | |
169 | + <p class="text-sm font-medium text-muted-foreground">Active Pull Requests</p> | |
170 | + <div class="flex items-baseline gap-2"> | |
171 | + <h3 class="text-2xl font-bold">{recentPRs?.count || 0}</h3> | |
172 | + <span class="text-sm text-muted-foreground">opened</span> | |
173 | + </div> | |
174 | + <div class="text-sm text-muted-foreground mt-1"> | |
175 | + {mergedPRs?.count || 0} merged | |
176 | + </div> | |
177 | + </div> | |
178 | + </div> | |
179 | + </div> | |
180 | + | |
181 | + <div class="rounded-lg border bg-card text-card-foreground shadow-sm p-6"> | |
182 | + <div class="flex items-center gap-4"> | |
183 | + <div class="p-2 bg-purple-100 dark:bg-purple-900/20 rounded-full"> | |
184 | + <AlertCircle class="h-6 w-6 text-purple-600 dark:text-purple-400" /> | |
185 | + </div> | |
186 | + <div> | |
187 | + <p class="text-sm font-medium text-muted-foreground">Active Issues</p> | |
188 | + <div class="flex items-baseline gap-2"> | |
189 | + <h3 class="text-2xl font-bold">{recentIssues?.count || 0}</h3> | |
190 | + <span class="text-sm text-muted-foreground">opened</span> | |
191 | + </div> | |
192 | + <div class="text-sm text-muted-foreground mt-1"> | |
193 | + {closedIssues?.count || 0} closed | |
194 | + </div> | |
195 | + </div> | |
196 | + </div> | |
197 | + </div> | |
198 | + </div> | |
199 | + </section> | |
200 | + | |
201 | + <!-- Contributors Section --> | |
202 | + <section> | |
203 | + <div class="mb-6 flex items-center justify-between"> | |
204 | + <h2 class="text-xl font-bold tracking-tight">Contributors</h2> | |
205 | + </div> | |
206 | + | |
207 | + <div class="rounded-lg border"> | |
208 | + {contributors.length > 0 ? ( | |
209 | + <div class="divide-y"> | |
210 | + {contributors.map((contributor, i) => ( | |
211 | + <div class="flex items-center justify-between p-4 hover:bg-muted/30"> | |
212 | + <div class="flex items-center gap-4"> | |
213 | + <div class="h-10 w-10 rounded-full bg-muted flex items-center justify-center text-lg font-medium text-muted-foreground"> | |
214 | + {contributor.name[0]?.toUpperCase()} | |
215 | + </div> | |
216 | + <div> | |
217 | + <div class="font-medium">{contributor.name}</div> | |
218 | + <div class="text-sm text-muted-foreground">{contributor.email}</div> | |
219 | + </div> | |
220 | + </div> | |
221 | + <div class="text-right"> | |
222 | + <div class="text-sm font-bold">{contributor.commits}</div> | |
223 | + <div class="text-xs text-muted-foreground">commits</div> | |
224 | + </div> | |
225 | + </div> | |
226 | + ))} | |
227 | + </div> | |
228 | + ) : ( | |
229 | + <div class="p-8 text-center text-muted-foreground"> | |
230 | + No contributor data available. | |
231 | + </div> | |
232 | + )} | |
233 | + </div> | |
234 | + </section> | |
235 | + </div> | |
236 | + </div> | |
237 | + </div> | |
238 | +</BaseLayout> | |
0 | 239 | |