Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

codeowners-lint.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.

codeowners-lint.tsxBlame336 lines · 1 contributor
f3e1844Claude1/**
2 * Block J21 — CODEOWNERS validator UI.
3 *
4 * GET /:owner/:repo/codeowners
5 *
6 * Lints the CODEOWNERS file at any of the standard locations (root,
7 * `.github/`, `docs/`) on the default branch, using the pure validator
8 * in `src/lib/codeowners-lint.ts`. Non-destructive — report-only.
9 */
10
11import { Hono } from "hono";
12import { and, eq } from "drizzle-orm";
13import { db } from "../db";
14import {
15 organizations,
16 repositories,
17 teams,
18 users,
19} from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader, RepoNav } from "../views/components";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { getBlob, getDefaultBranch } from "../git/repository";
25import {
26 validateCodeowners,
27 type LintFinding,
28 type LintReport,
29 type OwnerResolver,
30} from "../lib/codeowners-lint";
31
32const codeownersRoutes = new Hono<AuthEnv>();
33
34const CODEOWNERS_PATHS = [
35 "CODEOWNERS",
36 ".github/CODEOWNERS",
37 "docs/CODEOWNERS",
38];
39
40async function resolveRepo(ownerName: string, repoName: string) {
41 try {
42 const [owner] = await db
43 .select()
44 .from(users)
45 .where(eq(users.username, ownerName))
46 .limit(1);
47 if (!owner) return null;
48 const [repo] = await db
49 .select()
50 .from(repositories)
51 .where(
52 and(
53 eq(repositories.ownerId, owner.id),
54 eq(repositories.name, repoName)
55 )
56 )
57 .limit(1);
58 if (!repo) return null;
59 return { owner, repo };
60 } catch {
61 return null;
62 }
63}
64
65async function loadCodeowners(
66 ownerName: string,
67 repoName: string
68): Promise<{ path: string; content: string } | null> {
69 try {
70 const defaultBranch =
71 (await getDefaultBranch(ownerName, repoName)) || "main";
72 for (const path of CODEOWNERS_PATHS) {
73 try {
74 const blob = await getBlob(ownerName, repoName, defaultBranch, path);
75 if (blob && !blob.isBinary) {
76 return { path, content: blob.content };
77 }
78 } catch {
79 // next
80 }
81 }
82 } catch {
83 // Default-branch lookup failed
84 }
85 return null;
86}
87
88function buildResolver(): OwnerResolver {
89 const userCache = new Map<string, boolean>();
90 const teamCache = new Map<string, boolean>();
91 return {
92 async isUser(username: string) {
93 const key = username.toLowerCase();
94 if (userCache.has(key)) return userCache.get(key)!;
95 try {
96 const [row] = await db
97 .select({ id: users.id })
98 .from(users)
99 .where(eq(users.username, username))
100 .limit(1);
101 const ok = !!row;
102 userCache.set(key, ok);
103 return ok;
104 } catch {
105 userCache.set(key, false);
106 return false;
107 }
108 },
109 async isTeam(orgSlug: string, teamSlug: string) {
110 const key = `${orgSlug.toLowerCase()}/${teamSlug.toLowerCase()}`;
111 if (teamCache.has(key)) return teamCache.get(key)!;
112 try {
113 const [org] = await db
114 .select({ id: organizations.id })
115 .from(organizations)
116 .where(eq(organizations.slug, orgSlug))
117 .limit(1);
118 if (!org) {
119 teamCache.set(key, false);
120 return false;
121 }
122 const [team] = await db
123 .select({ id: teams.id })
124 .from(teams)
125 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
126 .limit(1);
127 const ok = !!team;
128 teamCache.set(key, ok);
129 return ok;
130 } catch {
131 teamCache.set(key, false);
132 return false;
133 }
134 },
135 };
136}
137
138codeownersRoutes.get(
139 "/:owner/:repo/codeowners",
140 softAuth,
141 async (c) => {
142 const { owner: ownerName, repo: repoName } = c.req.param();
143 const user = c.get("user");
144
145 const resolved = await resolveRepo(ownerName, repoName);
146 if (!resolved) {
147 return c.html(
148 <Layout title="Not Found" user={user}>
149 <div class="empty-state">
150 <h2>Repository not found</h2>
151 </div>
152 </Layout>,
153 404
154 );
155 }
156
157 const { repo } = resolved;
158 if (repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
159 return c.html(
160 <Layout title="Not Found" user={user}>
161 <div class="empty-state">
162 <h2>Repository not found</h2>
163 </div>
164 </Layout>,
165 404
166 );
167 }
168
169 const located = await loadCodeowners(ownerName, repoName);
170 let report: LintReport | null = null;
171 if (located) {
172 try {
173 report = await validateCodeowners(located.content, buildResolver());
174 } catch {
175 report = null;
176 }
177 }
178
179 return c.html(
180 <Layout
181 title={`CODEOWNERS — ${ownerName}/${repoName}`}
182 user={user}
183 >
184 <RepoHeader owner={ownerName} repo={repoName} />
185 <RepoNav owner={ownerName} repo={repoName} active="code" />
186 <div style="max-width: 960px; margin-top: 16px">
187 <h2 style="margin: 0 0 6px 0">CODEOWNERS</h2>
188 <p style="color: var(--text-muted); margin: 0 0 20px 0">
189 Validates the CODEOWNERS file against known users, teams, and
190 syntax. Searched (in order):{" "}
191 <code>{CODEOWNERS_PATHS.join(" · ")}</code>
192 </p>
193
194 {!located && (
195 <div
196 style="border: 1px dashed var(--border); border-radius: 6px; padding: 24px; text-align: center; color: var(--text-muted)"
197 >
198 <h3 style="margin: 0 0 6px 0; color: var(--text)">
199 No CODEOWNERS file found
200 </h3>
201 <p style="margin: 0 0 12px 0">
202 Add a <code>CODEOWNERS</code> file at one of the standard
203 paths to auto-assign reviewers when pull requests touch
204 matching files.
205 </p>
206 <a
207 href={`/${ownerName}/${repoName}/new?path=${encodeURIComponent(
208 ".github/CODEOWNERS"
209 )}`}
210 class="btn btn-primary"
211 >
212 Create .github/CODEOWNERS
213 </a>
214 </div>
215 )}
216
217 {located && report && (
218 <SummaryCards report={report} path={located.path} />
219 )}
220
221 {located && report && report.findings.length > 0 && (
222 <section style="margin-top: 20px">
223 <h3 style="font-size: 14px; margin: 0 0 8px 0">Findings</h3>
224 <ul style="list-style: none; padding: 0; margin: 0; font-size: 13px">
225 {report.findings.map((f) => (
226 <FindingRow finding={f} />
227 ))}
228 </ul>
229 </section>
230 )}
231
232 {located && report && report.ok && report.findings.length === 0 && (
233 <div
234 style="margin-top: 20px; border: 1px solid #2ea043; border-radius: 6px; padding: 16px; background: rgba(46, 160, 67, 0.08); color: #2ea043"
235 >
236 No issues found. CODEOWNERS is clean.
237 </div>
238 )}
239
240 {located && (
241 <section style="margin-top: 24px">
242 <h3 style="font-size: 14px; margin: 0 0 8px 0">
243 {located.path}
244 </h3>
245 <pre
246 style="border: 1px solid var(--border); border-radius: 6px; padding: 12px; background: var(--bg-secondary); overflow-x: auto; font-size: 12px; line-height: 1.5"
247 >
248 {renderFileWithLineNos(located.content)}
249 </pre>
250 </section>
251 )}
252 </div>
253 </Layout>
254 );
255 }
256);
257
258function SummaryCards(props: { report: LintReport; path: string }) {
259 const r = props.report;
260 return (
261 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 12px">
262 <Card label="Rules" value={r.totalRules} tone="grey" />
263 <Card label="Errors" value={r.errors.length} tone="red" />
264 <Card label="Warnings" value={r.warnings.length} tone="orange" />
265 <Card label="Info" value={r.infos.length} tone="blue" />
266 </div>
267 );
268}
269
270const TONES: Record<string, string> = {
271 red: "#f85149",
272 orange: "#f0883e",
273 blue: "#58a6ff",
274 green: "#2ea043",
275 grey: "var(--text-muted)",
276};
277
278function Card(props: { label: string; value: number; tone: string }) {
279 const colour = TONES[props.tone] || TONES.grey;
280 return (
281 <div style="border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg-secondary)">
282 <div style={`font-size: 20px; font-weight: 600; color: ${colour}; line-height: 1`}>
283 {props.value}
284 </div>
285 <div style="color: var(--text-muted); font-size: 11px; margin-top: 4px">
286 {props.label}
287 </div>
288 </div>
289 );
290}
291
292function FindingRow(props: { finding: LintFinding }) {
293 const f = props.finding;
294 const icon =
295 f.severity === "error" ? "\u2716" : f.severity === "warning" ? "\u26A0" : "\u2139";
296 const tone =
297 f.severity === "error"
298 ? TONES.red
299 : f.severity === "warning"
300 ? TONES.orange
301 : TONES.blue;
302 return (
303 <li style="padding: 8px 10px; border-bottom: 1px solid var(--border); display: flex; gap: 10px; align-items: start">
304 <span
305 style={`color: ${tone}; font-weight: 600; font-size: 13px; min-width: 18px; text-align: center`}
306 aria-hidden="true"
307 >
308 {icon}
309 </span>
310 <div style="flex: 1">
311 <div>
312 <span style="color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px">
313 line {f.line}
314 </span>{" "}
315 <span style={`color: ${tone}; text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em`}>
316 {f.severity}
317 </span>{" "}
318 <span style="color: var(--text-muted); font-size: 11px">
319 {f.code}
320 </span>
321 </div>
322 <div style="margin-top: 2px">{f.message}</div>
323 </div>
324 </li>
325 );
326}
327
328function renderFileWithLineNos(content: string): string {
329 const lines = content.split(/\r?\n/);
330 const width = String(lines.length).length;
331 return lines
332 .map((l, i) => `${String(i + 1).padStart(width)} ${l}`)
333 .join("\n");
334}
335
336export default codeownersRoutes;