CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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"; | |
| bb0f894 | 11 | import { |
| 12 | Avatar, | |
| 13 | Card, | |
| 14 | Flex, | |
| 15 | List, | |
| 16 | ListItem, | |
| 17 | PageHeader, | |
| 18 | Text, | |
| 19 | Tooltip, | |
| 20 | } from "../views/ui"; | |
| 43de941 | 21 | |
| 22 | const contributors = new Hono<AuthEnv>(); | |
| 23 | ||
| 24 | contributors.use("*", softAuth); | |
| 25 | ||
| 26 | interface Contributor { | |
| 27 | name: string; | |
| 28 | email: string; | |
| 29 | commits: number; | |
| 30 | additions: number; | |
| 31 | deletions: number; | |
| 32 | } | |
| 33 | ||
| 34 | contributors.get("/:owner/:repo/contributors", async (c) => { | |
| 35 | const { owner, repo } = c.req.param(); | |
| 36 | const user = c.get("user"); | |
| 37 | ||
| 38 | if (!(await repoExists(owner, repo))) return c.notFound(); | |
| 39 | ||
| 40 | const ref = (await getDefaultBranch(owner, repo)) || "main"; | |
| 41 | const repoDir = getRepoPath(owner, repo); | |
| 42 | ||
| 43 | // Get shortlog for commit counts | |
| 44 | const shortlogProc = Bun.spawn( | |
| 45 | ["git", "shortlog", "-sne", ref], | |
| 46 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 47 | ); | |
| 48 | const shortlogOut = await new Response(shortlogProc.stdout).text(); | |
| 49 | await shortlogProc.exited; | |
| 50 | ||
| 51 | const contribs: Contributor[] = shortlogOut | |
| 52 | .trim() | |
| 53 | .split("\n") | |
| 54 | .filter(Boolean) | |
| 55 | .map((line) => { | |
| 56 | const match = line.trim().match(/^(\d+)\t(.+?)\s+<(.+?)>$/); | |
| 57 | if (!match) return null; | |
| 58 | return { | |
| 59 | name: match[2], | |
| 60 | email: match[3], | |
| 61 | commits: parseInt(match[1], 10), | |
| 62 | additions: 0, | |
| 63 | deletions: 0, | |
| 64 | }; | |
| 65 | }) | |
| 66 | .filter((c): c is Contributor => c !== null) | |
| 67 | .sort((a, b) => b.commits - a.commits); | |
| 68 | ||
| 69 | // Get recent commit activity (last 52 weeks) | |
| 70 | const activityProc = Bun.spawn( | |
| 71 | [ | |
| 72 | "git", | |
| 73 | "log", | |
| 74 | "--format=%aI", | |
| 75 | "--since=1 year ago", | |
| 76 | ref, | |
| 77 | ], | |
| 78 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 79 | ); | |
| 80 | const activityOut = await new Response(activityProc.stdout).text(); | |
| 81 | await activityProc.exited; | |
| 82 | ||
| 83 | // Build weekly commit counts | |
| 84 | const weekCounts: number[] = new Array(52).fill(0); | |
| 85 | const now = Date.now(); | |
| 86 | for (const line of activityOut.trim().split("\n").filter(Boolean)) { | |
| 87 | const date = new Date(line); | |
| 88 | const weeksAgo = Math.floor( | |
| 89 | (now - date.getTime()) / (7 * 24 * 60 * 60 * 1000) | |
| 90 | ); | |
| 91 | if (weeksAgo >= 0 && weeksAgo < 52) { | |
| 92 | weekCounts[51 - weeksAgo]++; | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | const maxWeek = Math.max(...weekCounts, 1); | |
| 97 | ||
| 98 | return c.html( | |
| 99 | <Layout title={`Contributors — ${owner}/${repo}`} user={user}> | |
| 100 | <RepoHeader owner={owner} repo={repo} /> | |
| 101 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| bb0f894 | 102 | <PageHeader title="Contributors" /> |
| 103 | ||
| 104 | <Card style="margin-bottom:24px;padding:16px"> | |
| 105 | <Text size={13} muted>Commit activity — last year</Text> | |
| 106 | <Flex gap={2} align="flex-end" style="height:60px;margin-top:8px"> | |
| 43de941 | 107 | {weekCounts.map((count) => ( |
| bb0f894 | 108 | <Tooltip text={`${count} commits`}> |
| 109 | <div | |
| 110 | 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;`} | |
| 111 | /> | |
| 112 | </Tooltip> | |
| 43de941 | 113 | ))} |
| bb0f894 | 114 | </Flex> |
| 115 | </Card> | |
| 116 | ||
| 117 | <List> | |
| 118 | {contribs.map((contrib) => ( | |
| 119 | <ListItem> | |
| 120 | <Flex align="center" gap={12} style="flex:1"> | |
| 121 | <Avatar name={contrib.name} size={36} /> | |
| 43de941 | 122 | <div> |
| bb0f894 | 123 | <Text size={14} weight={600}> |
| 43de941 | 124 | {contrib.name} |
| bb0f894 | 125 | </Text> |
| 126 | <br /> | |
| 127 | <Text size={12} muted> | |
| 43de941 | 128 | {contrib.email} |
| bb0f894 | 129 | </Text> |
| 43de941 | 130 | </div> |
| bb0f894 | 131 | </Flex> |
| 132 | <div style="text-align:right"> | |
| 133 | <Text size={14} weight={600}> | |
| 43de941 | 134 | {contrib.commits} |
| bb0f894 | 135 | </Text> |
| 136 | <Text size={13} muted> | |
| 137 | {" "}commit{contrib.commits !== 1 ? "s" : ""} | |
| 138 | </Text> | |
| 43de941 | 139 | </div> |
| bb0f894 | 140 | </ListItem> |
| 43de941 | 141 | ))} |
| bb0f894 | 142 | </List> |
| 43de941 | 143 | </Layout> |
| 144 | ); | |
| 145 | }); | |
| 146 | ||
| 147 | export default contributors; |