Blame · Line-by-line history
contributors.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 43de941 | 1 | /** |
| 2 | * Contributors page — who contributed to this repo, commit counts. | |
| 3 | */ | |
| 4 | ||
| 5 | import { Hono } from "hono"; | |
| 6 | import { Layout } from "../views/layout"; | |
| 7 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 8 | import { getRepoPath, repoExists, getDefaultBranch } from "../git/repository"; | |
| 9 | import { softAuth } from "../middleware/auth"; | |
| 10 | import type { AuthEnv } from "../middleware/auth"; | |
| 11 | ||
| 12 | const contributors = new Hono<AuthEnv>(); | |
| 13 | ||
| 14 | contributors.use("*", softAuth); | |
| 15 | ||
| 16 | interface Contributor { | |
| 17 | name: string; | |
| 18 | email: string; | |
| 19 | commits: number; | |
| 20 | additions: number; | |
| 21 | deletions: number; | |
| 22 | } | |
| 23 | ||
| 24 | contributors.get("/:owner/:repo/contributors", async (c) => { | |
| 25 | const { owner, repo } = c.req.param(); | |
| 26 | const user = c.get("user"); | |
| 27 | ||
| 28 | if (!(await repoExists(owner, repo))) return c.notFound(); | |
| 29 | ||
| 30 | const ref = (await getDefaultBranch(owner, repo)) || "main"; | |
| 31 | const repoDir = getRepoPath(owner, repo); | |
| 32 | ||
| 33 | // Get shortlog for commit counts | |
| 34 | const shortlogProc = Bun.spawn( | |
| 35 | ["git", "shortlog", "-sne", ref], | |
| 36 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 37 | ); | |
| 38 | const shortlogOut = await new Response(shortlogProc.stdout).text(); | |
| 39 | await shortlogProc.exited; | |
| 40 | ||
| 41 | const contribs: Contributor[] = shortlogOut | |
| 42 | .trim() | |
| 43 | .split("\n") | |
| 44 | .filter(Boolean) | |
| 45 | .map((line) => { | |
| 46 | const match = line.trim().match(/^(\d+)\t(.+?)\s+<(.+?)>$/); | |
| 47 | if (!match) return null; | |
| 48 | return { | |
| 49 | name: match[2], | |
| 50 | email: match[3], | |
| 51 | commits: parseInt(match[1], 10), | |
| 52 | additions: 0, | |
| 53 | deletions: 0, | |
| 54 | }; | |
| 55 | }) | |
| 56 | .filter((c): c is Contributor => c !== null) | |
| 57 | .sort((a, b) => b.commits - a.commits); | |
| 58 | ||
| 59 | // Get recent commit activity (last 52 weeks) | |
| 60 | const activityProc = Bun.spawn( | |
| 61 | [ | |
| 62 | "git", | |
| 63 | "log", | |
| 64 | "--format=%aI", | |
| 65 | "--since=1 year ago", | |
| 66 | ref, | |
| 67 | ], | |
| 68 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 69 | ); | |
| 70 | const activityOut = await new Response(activityProc.stdout).text(); | |
| 71 | await activityProc.exited; | |
| 72 | ||
| 73 | // Build weekly commit counts | |
| 74 | const weekCounts: number[] = new Array(52).fill(0); | |
| 75 | const now = Date.now(); | |
| 76 | for (const line of activityOut.trim().split("\n").filter(Boolean)) { | |
| 77 | const date = new Date(line); | |
| 78 | const weeksAgo = Math.floor( | |
| 79 | (now - date.getTime()) / (7 * 24 * 60 * 60 * 1000) | |
| 80 | ); | |
| 81 | if (weeksAgo >= 0 && weeksAgo < 52) { | |
| 82 | weekCounts[51 - weeksAgo]++; | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | const maxWeek = Math.max(...weekCounts, 1); | |
| 87 | ||
| 88 | return c.html( | |
| 89 | <Layout title={`Contributors — ${owner}/${repo}`} user={user}> | |
| 90 | <RepoHeader owner={owner} repo={repo} /> | |
| 91 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 92 | <h2 style="margin-bottom: 16px">Contributors</h2> | |
| 93 | ||
| 94 | <div | |
| 95 | style="margin-bottom: 24px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px" | |
| 96 | > | |
| 97 | <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 8px"> | |
| 98 | Commit activity — last year | |
| 99 | </div> | |
| 100 | <div style="display: flex; gap: 2px; align-items: flex-end; height: 60px"> | |
| 101 | {weekCounts.map((count) => ( | |
| 102 | <div | |
| 103 | style={`flex: 1; background: var(--green); opacity: ${count === 0 ? "0.1" : Math.max(0.3, count / maxWeek).toFixed(2)}; height: ${count === 0 ? "2px" : Math.max(4, (count / maxWeek) * 60).toFixed(0) + "px"}; border-radius: 1px;`} | |
| 104 | title={`${count} commits`} | |
| 105 | /> | |
| 106 | ))} | |
| 107 | </div> | |
| 108 | </div> | |
| 109 | ||
| 110 | <div class="issue-list"> | |
| 111 | {contribs.map((contrib, i) => ( | |
| 112 | <div class="issue-item"> | |
| 113 | <div style="display: flex; align-items: center; gap: 12px; flex: 1"> | |
| 114 | <div class="user-avatar" style="width: 36px; height: 36px; font-size: 16px; flex-shrink: 0"> | |
| 115 | {contrib.name[0].toUpperCase()} | |
| 116 | </div> | |
| 117 | <div> | |
| 118 | <div style="font-weight: 600; font-size: 14px"> | |
| 119 | {contrib.name} | |
| 120 | </div> | |
| 121 | <div style="font-size: 12px; color: var(--text-muted)"> | |
| 122 | {contrib.email} | |
| 123 | </div> | |
| 124 | </div> | |
| 125 | </div> | |
| 126 | <div style="text-align: right"> | |
| 127 | <span style="font-weight: 600; font-size: 14px"> | |
| 128 | {contrib.commits} | |
| 129 | </span> | |
| 130 | <span style="color: var(--text-muted); font-size: 13px"> | |
| 131 | {" "} | |
| 132 | commit{contrib.commits !== 1 ? "s" : ""} | |
| 133 | </span> | |
| 134 | </div> | |
| 135 | </div> | |
| 136 | ))} | |
| 137 | </div> | |
| 138 | </Layout> | |
| 139 | ); | |
| 140 | }); | |
| 141 | ||
| 142 | export default contributors; |