Commitf3e1844unknown_key
feat(BLOCK-J): J21 CODEOWNERS validator
feat(BLOCK-J): J21 CODEOWNERS validator Serves /:owner/:repo/codeowners with a lint report anchored to 1-indexed line numbers. Pure lexCodeowners + classifyOwnerToken + isPlausiblePattern + async validateCodeowners(resolver) in src/lib/codeowners-lint.ts (28 tests). Resolver memoises users + teams per request. Stable error/warning/info code taxonomy (unknown_user, duplicate_pattern, missing_catchall, etc.). Non-destructive — report only.
5 files changed+958−0f3e1844e9804f12c98ee13b5fd7e17949dd97b21
5 changed files+958−0
ModifiedBUILD_BIBLE.md+4−0View fileUnifiedSplit
@@ -142,6 +142,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
142142| Repository pulse / activity summary | ✅ | J18 — `GET /:owner/:repo/pulse[?window=1d\|7d\|30d\|90d]` renders GitHub-parity Pulse: commit activity, PR opened/merged/closed/active, issue opened/closed/active, top contributors, recent merges + closes. `src/lib/repo-pulse.ts` is pure-first (`parseWindow`, `windowStart`, `summariseCommits` grouping by lowercased email with count-desc/name-asc sort, `summarisePrs`, `summariseIssues`, `buildPulseReport` one-shot). softAuth read-only; degrades to an empty-state report on DB/git failure. Linked from the Insights page header. |
143143| Atom / RSS feeds | ✅ | J19 — `GET /:owner/:repo/{commits,releases,issues}.atom` serve Atom 1.0 feeds (up to 50 entries each). Pure renderer in `src/lib/atom-feed.ts` — `renderAtomFeed` emits XML-declaration-prefixed `<feed>` with required `<id>/<title>/<updated>/<link rel=self>`, per-entry `<id>/<title>/<link>/<updated>/<published>/<author>/<summary>`. All five XML metacharacters escaped. `toIsoUtc` normalises Date + ISO inputs, falls back to epoch on junk. Cache headers `public, max-age=60, stale-while-revalidate=300`; `application/atom+xml; charset=utf-8` content-type. Private repos 404 (as empty feed doc) for non-owner viewers. |
144144| Stale issue detector | ✅ | J20 — `GET /:owner/:repo/issues/stale[?period=30d\|60d\|90d\|180d]` lists every open issue whose `updated_at` is older than the selected threshold, sorted oldest-first with per-issue `daysSinceUpdate` + comment count + age-colour (>=90d amber, >=180d red). Four age-bucket cards show the distribution (`30-60`, `60-90`, `90-180`, `180+`). Pure `filterStale` / `bucketByStaleness` / `buildStaleReport` in `src/lib/stale-issues.ts` accept both `Date` and ISO inputs and gracefully drop unparseable timestamps. softAuth read-only; private repos 404 for non-owner viewers. Linked from the Issues toolbar via a "Stale" button. Non-destructive — we only surface staleness, never auto-close. |
145| CODEOWNERS validator | ✅ | J21 — `GET /:owner/:repo/codeowners` lints the CODEOWNERS file (standard locations: root / `.github/` / `docs/`) on the default branch against known users + teams and surface-level syntax. Pure `lexCodeowners` (line-aware, CRLF tolerant, inline-comment strip, detects pattern-with-no-owners), `classifyOwnerToken` (user / team / email / invalid), `isPlausiblePattern` (bracket balance, whitespace), `validateCodeowners` (async, takes `OwnerResolver`) in `src/lib/codeowners-lint.ts`. Findings are anchored to 1-indexed line numbers with an `error\|warning\|info` severity + stable `code` taxonomy (`empty_file\|no_owners\|empty_pattern\|bad_pattern_syntax\|bad_owner_format\|unknown_user\|unknown_team\|duplicate_pattern\|duplicate_owner\|missing_catchall`). UI shows four summary cards (rules / errors / warnings / infos), a findings list, and the file body with line numbers. Resolver memoises per-request. Non-destructive — report only. |
145146| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
146147| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
147148| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
@@ -306,6 +307,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
306307- **J14** — Issue dependencies / blocked-by relationships → ✅ shipped. `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (`blocker_issue_id`, `blocked_issue_id`, CHECK `issue_dep_no_self`, unique on the pair, indexes on both sides). `src/lib/issue-dependencies.ts` exposes pure `wouldCreateCycle(edges, blocker, blocked)` (BFS following forward blocks edges) + `summariseBlockers` plus DB helpers: `addDependency` (`{ok, reason}` taxonomy rejecting self / cross_repo / exists / cycle / not_found / error), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Issue detail page in `src/routes/issues.tsx` now renders a "Dependencies" panel above the body showing Blocked-by + Blocks lists, per-row dismiss buttons (owner/author only), and a `#number`-form to add a new blocker. Two POST routes mirror the J11 pattern: `/:o/:r/issues/:n/dependencies` (add) and `/:o/:r/issues/:n/dependencies/:which/:otherId/remove`. Permission gated by `user.id === owner.id || user.id === issue.authorId`. 14 new tests covering cycle detection (direct + transitive + diamond + deep chains), summariseBlockers counts, and route-auth smokes. Total suite 867/867.
307308- **J15** — Deterministic release-notes generator → ✅ shipped. `src/lib/release-notes.ts` ships zero-IO helpers: `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` PR capture; aliases `feature`/`bugfix`/`doc`/`tests`; Merge-commit detection for pull requests and branches), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes` which re-renders the new-release form with notes pre-filled (preserves tag/name/target/draft/prerelease fields) plus a notice banner on missing commits or resolve failure. AI-disabled repos now fall through to the deterministic renderer on publish instead of writing empty notes. 30 new tests. Total suite 897/897.
308309- **J16** — PR auto-merge when checks pass → ✅ shipped. `drizzle/0037_pr_auto_merge.sql` adds `pr_auto_merge` (unique on `pull_request_id`, merge-method text, commit-title/message overrides, last_status + last_checked_at + notified_ready columns for the status-hook watcher). `src/lib/pr-auto-merge.ts` is the pure state machine + DB accessor: `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks}) → {action: wait|merge|skip, reason}` covers all nine paths (not_enabled / pr_closed / pr_draft / no_checks / checks_pending / checks_failed / checks_passed). `src/lib/pr-auto-merge-trigger.ts` is called fire-and-forget from the commit-status POST — resolves each opted-in PR's head branch, matches against the incoming SHA, runs `combinedStatus` + the pure decider, and on first green transition posts a PR comment + PR-author notification. PR detail page gets an `AutoMergePanel` with live status colour (green/yellow/red), merge-method selector (merge/squash/rebase), and disable button. 16 new tests. Total suite 913/913.
310- **J21** — CODEOWNERS validator → ✅ shipped. `src/lib/codeowners-lint.ts` lints a CODEOWNERS file with a pure-first API — `lexCodeowners` preserves 1-indexed line numbers while stripping comments, `classifyOwnerToken` (GitHub-parity regexes for user / team / email / invalid), `isPlausiblePattern` (unbalanced brackets / whitespace), async `validateCodeowners(content, resolver)` taking an `OwnerResolver` so the core stays IO-free. Report emits findings sorted by line then severity plus derived `errors/warnings/infos/ok`. Stable code taxonomy: `empty_file`, `no_owners`, `empty_pattern`, `bad_pattern_syntax`, `bad_owner_format`, `unknown_user`, `unknown_team`, `duplicate_pattern`, `duplicate_owner`, `missing_catchall`. `src/routes/codeowners-lint.tsx` serves `GET /:owner/:repo/codeowners` — softAuth, private repos 404. Walks the three standard paths on the default branch, builds a per-request memoising resolver against `users.username` + `organizations.slug` + `teams.slug`, renders four summary cards + findings list + line-numbered file body, or an empty-state with "Create .github/CODEOWNERS" CTA. 28 new tests covering lexer (blank/comment/inline-comment/CRLF/team tokens), owner classifier (user/team/email/invalid edges), pattern plausibility, validator (empty file, clean catchall, pattern-no-owners, unknown user, unknown team, email passthrough, bad format, duplicate patterns, duplicate owners, ordering, ok-false on error, info-only still ok, bracket-unbalanced), `__internal` parity, route 404. Total suite 1035/1035.
309311- **J20** — Stale issue detector → ✅ shipped. `src/lib/stale-issues.ts` is a pure filter/bucketer — `STALE_PERIODS = ['30d','60d','90d','180d']`, `parsePeriod` with default-on-garbage, `filterStale(issues, now, thresholdDays)` (open-only, parseable-dates-only, oldest-first), `bucketByStaleness` (30-60 / 60-90 / 90-180 / 180+ with inclusive lower bounds), `buildStaleReport` one-shot builder. Accepts both Date and ISO inputs; drops unparseable timestamps silently. `src/routes/stale-issues.tsx` serves `GET /:owner/:repo/issues/stale[?period=…]` (softAuth, read-only), fetches open issues (2000 cap) with a grouped comment count, and renders four bucket cards + an age-coloured table (amber >=90d, red >=180d). Route is mounted BEFORE `issueRoutes` so the static `/issues/stale` path wins over `/issues/:number`. `resolveRepo` wrapped in try/catch — DB failure 404s, never 500. Issues-list toolbar gets a new "Stale" button. Non-destructive: surfaces staleness only; no auto-close. 22 new tests covering period parsing, filter threshold semantics + boundary inclusion + oldest-first sort, bad-date tolerance, bucket boundaries, `buildStaleReport`, `__internal` parity, route smokes. Total suite 1007/1007.
310312- **J19** — Atom / RSS feeds for commits, releases, issues → ✅ shipped. `src/lib/atom-feed.ts` is a pure Atom 1.0 renderer — `escapeXml` (five XML metacharacters), `toIsoUtc` (Date + ISO, epoch fallback), `renderAtomFeed` (XML-decl + `<feed xmlns>` + feed metadata + entries with `<id>/<title>/<link>/<updated>/<published>/<author>/<summary>/<content>`), `ATOM_CONTENT_TYPE`. `pickFeedUpdated` auto-derives from newest entry when not set explicitly. `src/routes/feeds.ts` serves three feeds — `/:owner/:repo/{commits,releases,issues}.atom`, each capped at 50 entries. `softAuth`; private repos receive an empty-feed response so external readers stay non-crashing. Cache: `public, max-age=60, stale-while-revalidate=300`. Commits sourced from `listCommits` on the default branch; releases filter out drafts; issues span both open + closed, newest-first. 20 new tests covering escape edge cases, toIsoUtc, feed structure, well-formedness with zero entries, metacharacter safety, newest-entry auto-update, explicit updatedAt respect, route content-type + cache headers. Total suite 985/985.
311313- **J18** — Repository pulse / activity summary → ✅ shipped. `src/lib/repo-pulse.ts` rolls already-fetched commits + PR + issue rows into time-windowed buckets: `parseWindow` + `windowStart` (1d / 7d / 30d / 90d), `summariseCommits` (in-window filter, email-lowercased author-grouping, count-desc/name-asc sort, first/last SHA), `summarisePrs` (opened/mergedCount/closed/active + openedList + mergedList), `summariseIssues` (opened/closed/active + openedList + closedList), `buildPulseReport` one-shot. `src/routes/pulse.tsx` serves `GET /:owner/:repo/pulse[?window=…]` (softAuth, read-only); fetches up to 500 commits via `listCommits` + 1000 PRs + 1000 issues in parallel; renders eight KPI cards, top-10 contributors, recent merges + newly-opened/closed issues. `resolveRepo` wrapped in try/catch so DB failure falls through to 404, mirroring the J12 community page's never-500 guarantee. Insights page header links to Pulse. 23 new tests covering window parsing, date-edge filters, author grouping, PR/issue bucketing with Date + ISO inputs, bad-date tolerance, `buildPulseReport` + `__internal` re-exports + route smokes. Total suite 965/965.
@@ -518,6 +520,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
518520- `src/routes/feeds.ts` (Block J19) — serves `GET /:owner/:repo/{commits,releases,issues}.atom`. softAuth. Up to 50 entries per feed. `resolveRepo` wrapped in try/catch; private repos 404 (as empty-feed doc) for non-owner viewers. Every handler returns `application/atom+xml; charset=utf-8` with `Cache-Control: public, max-age=60, stale-while-revalidate=300`. Commits source: `listCommits` on default branch. Releases source: `releases` table minus drafts. Issues source: `issues` table newest-first.
519521- `src/lib/stale-issues.ts` (Block J20) — pure stale-issue filter + bucketer. Exports `STALE_PERIODS = ['30d','60d','90d','180d']`, `DEFAULT_STALE_PERIOD='60d'`, `periodDays`, `parsePeriod`. Pure: `filterStale(issues, now, thresholdDays)` drops non-open states + unparseable `updatedAt`, returns oldest-first with `daysSinceUpdate` and carried-through `commentCount`; `bucketByStaleness` splits into `30-60` / `60-90` / `90-180` / `180+` (inclusive at lower bound); `buildStaleReport` one-shot builder emits `{period, thresholdDays, now: ISO, total, issues, buckets}`. Accepts Date + ISO; `__internal` re-exports.
520522- `src/routes/stale-issues.tsx` (Block J20) — serves `GET /:owner/:repo/issues/stale[?period=…]`. softAuth. Fetches open issues only (2000 cap) plus a grouped `count(issue_comments.id)` per issue number, calls `buildStaleReport`. `resolveRepo` wrapped in try/catch so DB failure returns 404, not 500. **Must be mounted BEFORE `issueRoutes`** so the static `/issues/stale` path wins over `/issues/:number` (which would `parseInt("stale")` into NaN). Renders four bucket cards (auto-hides buckets below the threshold), an age-coloured table (>=90d amber, >=180d red), or an empty-state card. Non-destructive: we surface staleness, never auto-close.
523- `src/lib/codeowners-lint.ts` (Block J21) — pure CODEOWNERS linter. Exports `lexCodeowners(content)` (line-aware, CRLF + inline-comment tolerant, classifies each non-blank/non-comment line as rule or malformed), `classifyOwnerToken(token, hadAt) → 'user'\|'team'\|'email'\|'invalid'` (GitHub-parity regexes: username `[A-Za-z0-9][A-Za-z0-9-]{0,38}`, team `org/team`, RFC-loose email), `isPlausiblePattern` (bracket-balance + no-whitespace), async `validateCodeowners(content, resolver)` returning `LintReport` with `findings` sorted by line then severity, plus derived `errors/warnings/infos/ok`. Never throws. `__internal` re-exports regexes + helpers.
524- `src/routes/codeowners-lint.tsx` (Block J21) — serves `GET /:owner/:repo/codeowners`. softAuth; private repos 404 for non-owner viewers. Walks the three standard paths (`CODEOWNERS`, `.github/CODEOWNERS`, `docs/CODEOWNERS`) on the default branch, builds a per-request memoising resolver over `users.username` + `organizations.slug` + `teams.slug`, calls `validateCodeowners`. Renders four summary cards (rules / errors / warnings / info), a findings list with line + severity + code + message, and the file body with line numbers. Gracefully empty-states when no CODEOWNERS file is found, with a one-click link to create `.github/CODEOWNERS` in the web editor.
521525
522526### 4.7 Views (locked contracts)
523527- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/codeowners-lint.test.ts+269−0View fileUnifiedSplit
@@ -0,0 +1,269 @@
1/**
2 * Block J21 — CODEOWNERS validator. Pure lexer + validator + route smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 lexCodeowners,
9 classifyOwnerToken,
10 isPlausiblePattern,
11 validateCodeowners,
12 __internal,
13 type OwnerResolver,
14} from "../lib/codeowners-lint";
15
16const allKnownResolver: OwnerResolver = {
17 isUser: () => true,
18 isTeam: () => true,
19};
20
21const noneKnownResolver: OwnerResolver = {
22 isUser: () => false,
23 isTeam: () => false,
24};
25
26function resolver(known: {
27 users?: string[];
28 teams?: string[];
29}): OwnerResolver {
30 const u = new Set((known.users || []).map((s) => s.toLowerCase()));
31 const t = new Set((known.teams || []).map((s) => s.toLowerCase()));
32 return {
33 isUser: (x) => u.has(x.toLowerCase()),
34 isTeam: (o, team) => t.has(`${o}/${team}`.toLowerCase()),
35 };
36}
37
38describe("codeowners-lint — lexCodeowners", () => {
39 it("ignores blank + comment lines", () => {
40 const { rules, totalLines } = lexCodeowners(
41 "# comment\n\n# another\n* @alice\n"
42 );
43 expect(rules).toHaveLength(1);
44 expect(rules[0].line).toBe(4);
45 expect(rules[0].pattern).toBe("*");
46 expect(rules[0].owners).toEqual(["alice"]);
47 expect(totalLines).toBeGreaterThan(0);
48 });
49
50 it("strips the leading @ from owner tokens", () => {
51 const { rules } = lexCodeowners("* @alice @bob\n");
52 expect(rules[0].owners).toEqual(["alice", "bob"]);
53 });
54
55 it("detects malformed lines with no owners", () => {
56 const { malformedLines } = lexCodeowners("*\nfoo/*\n");
57 expect(malformedLines.map((m) => m.line)).toEqual([1, 2]);
58 });
59
60 it("preserves line numbers with CRLF", () => {
61 const { rules } = lexCodeowners("# comment\r\n\r\n* @a\r\n");
62 expect(rules[0].line).toBe(3);
63 });
64
65 it("drops inline trailing comments", () => {
66 const { rules } = lexCodeowners("src/api/** @bob # backend lead");
67 expect(rules[0].owners).toEqual(["bob"]);
68 });
69
70 it("accepts team-style owner tokens", () => {
71 const { rules } = lexCodeowners("docs/ @acme/docs-team");
72 expect(rules[0].owners).toEqual(["acme/docs-team"]);
73 });
74});
75
76describe("codeowners-lint — classifyOwnerToken", () => {
77 it("classifies @user tokens as user", () => {
78 expect(classifyOwnerToken("alice", true)).toBe("user");
79 expect(classifyOwnerToken("a", true)).toBe("user");
80 expect(classifyOwnerToken("alice-bob", true)).toBe("user");
81 });
82
83 it("classifies @org/team tokens as team", () => {
84 expect(classifyOwnerToken("acme/backend", true)).toBe("team");
85 expect(classifyOwnerToken("acme/docs-team_2", true)).toBe("team");
86 });
87
88 it("classifies plain emails as email", () => {
89 expect(classifyOwnerToken("a@b.com", false)).toBe("email");
90 expect(classifyOwnerToken("a.b+c@mail.example.io", false)).toBe("email");
91 });
92
93 it("rejects bogus tokens", () => {
94 expect(classifyOwnerToken("", true)).toBe("invalid");
95 expect(classifyOwnerToken("-alice", true)).toBe("invalid"); // leading dash
96 expect(classifyOwnerToken("alice/bob/carol", true)).toBe("invalid"); // two slashes
97 expect(classifyOwnerToken("alice!", true)).toBe("invalid");
98 expect(classifyOwnerToken("plain", false)).toBe("invalid"); // missing @
99 });
100});
101
102describe("codeowners-lint — isPlausiblePattern", () => {
103 it("accepts normal glob patterns", () => {
104 expect(isPlausiblePattern("*")).toBe(true);
105 expect(isPlausiblePattern("/docs/**")).toBe(true);
106 expect(isPlausiblePattern("src/**/*.ts")).toBe(true);
107 expect(isPlausiblePattern("[abc].md")).toBe(true);
108 });
109
110 it("rejects empty, whitespace, unbalanced brackets", () => {
111 expect(isPlausiblePattern("")).toBe(false);
112 expect(isPlausiblePattern("foo bar")).toBe(false);
113 expect(isPlausiblePattern("[abc")).toBe(false);
114 expect(isPlausiblePattern("abc]")).toBe(false);
115 });
116});
117
118describe("codeowners-lint — validateCodeowners", () => {
119 it("flags empty files as a warning", async () => {
120 const r = await validateCodeowners("", allKnownResolver);
121 expect(r.ok).toBe(true);
122 expect(r.warnings).toHaveLength(1);
123 expect(r.warnings[0].code).toBe("empty_file");
124 });
125
126 it("returns ok + a single info for a valid-but-missing-catchall file", async () => {
127 const r = await validateCodeowners(
128 "/docs/ @alice\n",
129 resolver({ users: ["alice"] })
130 );
131 expect(r.errors).toHaveLength(0);
132 expect(r.infos.some((f) => f.code === "missing_catchall")).toBe(true);
133 expect(r.ok).toBe(true);
134 });
135
136 it("clean file with catchall yields zero findings", async () => {
137 const r = await validateCodeowners(
138 "* @alice\n",
139 resolver({ users: ["alice"] })
140 );
141 expect(r.findings).toEqual([]);
142 expect(r.ok).toBe(true);
143 });
144
145 it("catches pattern-with-no-owners", async () => {
146 const r = await validateCodeowners(
147 "* @alice\n/docs/\n",
148 resolver({ users: ["alice"] })
149 );
150 const noOwners = r.findings.find((f) => f.code === "no_owners");
151 expect(noOwners).toBeTruthy();
152 expect(noOwners!.line).toBe(2);
153 expect(r.ok).toBe(false);
154 });
155
156 it("reports unknown users", async () => {
157 const r = await validateCodeowners(
158 "* @ghost\n",
159 noneKnownResolver
160 );
161 const unknown = r.findings.find((f) => f.code === "unknown_user");
162 expect(unknown).toBeTruthy();
163 expect(unknown!.token).toBe("ghost");
164 expect(r.ok).toBe(false);
165 });
166
167 it("reports unknown teams", async () => {
168 const r = await validateCodeowners(
169 "* @acme/missing\n",
170 resolver({})
171 );
172 const unknown = r.findings.find((f) => f.code === "unknown_team");
173 expect(unknown).toBeTruthy();
174 expect(unknown!.token).toBe("acme/missing");
175 });
176
177 it("accepts plain email owners without DB lookup", async () => {
178 const r = await validateCodeowners(
179 "* ops@example.com\n",
180 noneKnownResolver
181 );
182 const errors = r.errors.filter(
183 (f) =>
184 f.code === "unknown_user" ||
185 f.code === "unknown_team" ||
186 f.code === "bad_owner_format"
187 );
188 expect(errors).toHaveLength(0);
189 expect(r.ok).toBe(true);
190 });
191
192 it("flags bad owner formats", async () => {
193 const r = await validateCodeowners(
194 "* @-bad-user\n",
195 allKnownResolver
196 );
197 const bad = r.findings.find((f) => f.code === "bad_owner_format");
198 expect(bad).toBeTruthy();
199 });
200
201 it("flags duplicate patterns as warnings", async () => {
202 const r = await validateCodeowners(
203 "* @alice\n* @bob\n",
204 resolver({ users: ["alice", "bob"] })
205 );
206 const dup = r.findings.find((f) => f.code === "duplicate_pattern");
207 expect(dup).toBeTruthy();
208 expect(dup!.severity).toBe("warning");
209 });
210
211 it("flags duplicate owners on the same rule as warnings", async () => {
212 const r = await validateCodeowners(
213 "* @alice @alice\n",
214 resolver({ users: ["alice"] })
215 );
216 const dup = r.findings.find((f) => f.code === "duplicate_owner");
217 expect(dup).toBeTruthy();
218 expect(dup!.severity).toBe("warning");
219 });
220
221 it("orders findings by line number", async () => {
222 const r = await validateCodeowners(
223 "* @alice @alice\n/bad\n/docs/ @ghost\n",
224 resolver({ users: ["alice"] })
225 );
226 const lines = r.findings.map((f) => f.line);
227 const sorted = [...lines].sort((a, b) => a - b);
228 expect(lines).toEqual(sorted);
229 });
230
231 it("returns ok=false when any error is present", async () => {
232 const r = await validateCodeowners("/docs/\n", resolver({}));
233 expect(r.ok).toBe(false);
234 });
235
236 it("returns ok=true when only warnings/infos exist", async () => {
237 const r = await validateCodeowners(
238 "/docs/ @alice\n",
239 resolver({ users: ["alice"] })
240 );
241 expect(r.warnings.length + r.infos.length).toBeGreaterThan(0);
242 expect(r.ok).toBe(true);
243 });
244
245 it("flags bad pattern syntax with unbalanced brackets", async () => {
246 const r = await validateCodeowners(
247 "[abc @alice\n",
248 resolver({ users: ["alice"] })
249 );
250 const bad = r.findings.find((f) => f.code === "bad_pattern_syntax");
251 expect(bad).toBeTruthy();
252 });
253});
254
255describe("codeowners-lint — __internal parity", () => {
256 it("re-exports the helpers", () => {
257 expect(__internal.lexCodeowners).toBe(lexCodeowners);
258 expect(__internal.classifyOwnerToken).toBe(classifyOwnerToken);
259 expect(__internal.isPlausiblePattern).toBe(isPlausiblePattern);
260 expect(__internal.validateCodeowners).toBe(validateCodeowners);
261 });
262});
263
264describe("codeowners-lint — routes", () => {
265 it("GET /:o/:r/codeowners returns 404 for unknown repos", async () => {
266 const res = await app.request("/alice/nope/codeowners");
267 expect(res.status).toBe(404);
268 });
269});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -81,6 +81,7 @@ import pinnedReposRoutes from "./routes/pinned-repos";
8181import pulseRoutes from "./routes/pulse";
8282import feedRoutes from "./routes/feeds";
8383import staleIssuesRoutes from "./routes/stale-issues";
84import codeownersLintRoutes from "./routes/codeowners-lint";
8485import webRoutes from "./routes/web";
8586
8687const app = new Hono();
@@ -287,6 +288,9 @@ app.route("/", pulseRoutes);
287288// Atom feeds — /:owner/:repo/{commits,releases,issues}.atom (Block J19)
288289app.route("/", feedRoutes);
289290
291// CODEOWNERS validator — /:owner/:repo/codeowners (Block J21)
292app.route("/", codeownersLintRoutes);
293
290294// Insights + milestones
291295app.route("/", insightsRoutes);
292296
Addedsrc/lib/codeowners-lint.ts+345−0View fileUnifiedSplit
@@ -0,0 +1,345 @@
1/**
2 * Block J21 — CODEOWNERS validator.
3 *
4 * Pure lint layer for CODEOWNERS files. Splits input line-by-line,
5 * classifies each line (comment / blank / rule / malformed), and returns
6 * a typed report with errors + warnings anchored to 1-indexed line
7 * numbers so the UI can point the user at the exact problem.
8 *
9 * Validation is *local* (structure + obviously-bogus tokens) except for
10 * `unknownUser` / `unknownTeam`, which require a caller-supplied
11 * resolver so this module stays IO-free and easy to unit-test.
12 */
13
14export type LintSeverity = "error" | "warning" | "info";
15
16export type LintCode =
17 | "empty_pattern"
18 | "no_owners"
19 | "bad_owner_format"
20 | "unknown_user"
21 | "unknown_team"
22 | "duplicate_pattern"
23 | "duplicate_owner"
24 | "missing_catchall"
25 | "empty_file"
26 | "bad_pattern_syntax";
27
28export interface LintFinding {
29 line: number;
30 code: LintCode;
31 severity: LintSeverity;
32 message: string;
33 /** Optional pattern + offending token for the UI to highlight. */
34 pattern?: string;
35 token?: string;
36}
37
38export interface LexedRule {
39 line: number;
40 /** The raw line text (without trailing `\n`). */
41 raw: string;
42 pattern: string;
43 /** Owner tokens **without** leading `@`, in source order. */
44 owners: string[];
45}
46
47export interface LintReport {
48 totalLines: number;
49 totalRules: number;
50 rules: LexedRule[];
51 findings: LintFinding[];
52 errors: LintFinding[];
53 warnings: LintFinding[];
54 infos: LintFinding[];
55 /** Convenience: true iff `errors.length === 0`. */
56 ok: boolean;
57}
58
59/**
60 * Resolver contract — the route fulfils this from the DB / org model so
61 * the pure lint logic remains free of external dependencies.
62 */
63export interface OwnerResolver {
64 isUser: (username: string) => boolean | Promise<boolean>;
65 isTeam: (org: string, team: string) => boolean | Promise<boolean>;
66}
67
68// ---------------------------------------------------------------------------
69// Lexer
70// ---------------------------------------------------------------------------
71
72/**
73 * Split content into per-line records. Comment and blank lines are kept
74 * in the return only so callers can display the original file alongside
75 * findings — they produce no findings themselves.
76 */
77export function lexCodeowners(content: string): {
78 rules: LexedRule[];
79 malformedLines: Array<{ line: number; raw: string }>;
80 totalLines: number;
81} {
82 const rules: LexedRule[] = [];
83 const malformed: Array<{ line: number; raw: string }> = [];
84 const lines = content.split(/\r?\n/);
85 lines.forEach((raw, idx) => {
86 const lineNo = idx + 1;
87 const trimmed = raw.replace(/#.*$/, "").trim();
88 if (!trimmed) return; // blank or pure comment
89 const parts = trimmed.split(/\s+/);
90 if (parts.length === 1) {
91 // A pattern with zero owners — malformed rule.
92 malformed.push({ line: lineNo, raw });
93 return;
94 }
95 const pattern = parts[0];
96 const owners = parts
97 .slice(1)
98 .map((o) => o.replace(/^@/, "").trim())
99 .filter(Boolean);
100 rules.push({ line: lineNo, raw, pattern, owners });
101 });
102 return { rules, malformedLines: malformed, totalLines: lines.length };
103}
104
105// ---------------------------------------------------------------------------
106// Owner-token classification
107// ---------------------------------------------------------------------------
108
109export type OwnerTokenKind = "user" | "team" | "email" | "invalid";
110
111const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
112const USER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
113const TEAM_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
114
115export function classifyOwnerToken(
116 tokenWithoutAt: string,
117 hadAt: boolean
118): OwnerTokenKind {
119 if (!tokenWithoutAt) return "invalid";
120 if (!hadAt && EMAIL_RE.test(tokenWithoutAt)) return "email";
121 if (!hadAt) return "invalid"; // non-@ tokens must be emails
122 if (tokenWithoutAt.includes("/")) {
123 return TEAM_RE.test(tokenWithoutAt) ? "team" : "invalid";
124 }
125 return USER_RE.test(tokenWithoutAt) ? "user" : "invalid";
126}
127
128// ---------------------------------------------------------------------------
129// Pattern sanity check
130// ---------------------------------------------------------------------------
131
132/**
133 * GitHub's CODEOWNERS patterns are a restricted subset of gitignore-style
134 * globs. This catches the obviously-broken cases without trying to be a
135 * full parser — we delegate actual matching to the existing parser.
136 */
137export function isPlausiblePattern(pat: string): boolean {
138 if (!pat) return false;
139 if (/\s/.test(pat)) return false;
140 // No unmatched brackets.
141 let depth = 0;
142 for (const ch of pat) {
143 if (ch === "[") depth++;
144 if (ch === "]") depth--;
145 if (depth < 0) return false;
146 }
147 if (depth !== 0) return false;
148 return true;
149}
150
151// ---------------------------------------------------------------------------
152// Validator
153// ---------------------------------------------------------------------------
154
155export async function validateCodeowners(
156 content: string,
157 resolver: OwnerResolver
158): Promise<LintReport> {
159 const lex = lexCodeowners(content);
160 const findings: LintFinding[] = [];
161
162 // 1. Empty-file case.
163 if (content.trim().length === 0) {
164 findings.push({
165 line: 1,
166 code: "empty_file",
167 severity: "warning",
168 message:
169 "CODEOWNERS file is empty — no ownership rules are being enforced.",
170 });
171 return assembleReport(lex.rules, findings, lex.totalLines);
172 }
173
174 // 2. Pattern-with-no-owners lines.
175 for (const m of lex.malformedLines) {
176 findings.push({
177 line: m.line,
178 code: "no_owners",
179 severity: "error",
180 message:
181 "Rule declares a pattern but no owners. Each rule must list at least one @user, @org/team, or email.",
182 });
183 }
184
185 // 3. Per-rule checks.
186 const seenPatterns = new Map<string, number>();
187 for (const rule of lex.rules) {
188 // a) Empty / malformed pattern.
189 if (!rule.pattern) {
190 findings.push({
191 line: rule.line,
192 code: "empty_pattern",
193 severity: "error",
194 message: "Rule has no pattern.",
195 });
196 continue;
197 }
198 if (!isPlausiblePattern(rule.pattern)) {
199 findings.push({
200 line: rule.line,
201 code: "bad_pattern_syntax",
202 severity: "error",
203 message: `Pattern \"${rule.pattern}\" looks malformed (unbalanced brackets or whitespace).`,
204 pattern: rule.pattern,
205 });
206 }
207
208 // b) Duplicate pattern.
209 if (seenPatterns.has(rule.pattern)) {
210 findings.push({
211 line: rule.line,
212 code: "duplicate_pattern",
213 severity: "warning",
214 message: `Pattern \"${rule.pattern}\" was already declared on line ${seenPatterns.get(
215 rule.pattern
216 )}. The later rule wins.`,
217 pattern: rule.pattern,
218 });
219 } else {
220 seenPatterns.set(rule.pattern, rule.line);
221 }
222
223 // c) Per-owner checks.
224 const seenOwners = new Set<string>();
225 // Re-parse the raw line to learn which owner tokens had an `@`.
226 const rawOwners = rule.raw
227 .replace(/#.*$/, "")
228 .trim()
229 .split(/\s+/)
230 .slice(1);
231 for (let i = 0; i < rule.owners.length; i++) {
232 const owner = rule.owners[i];
233 const rawToken = rawOwners[i] ?? owner;
234 const hadAt = rawToken.startsWith("@");
235 if (seenOwners.has(owner)) {
236 findings.push({
237 line: rule.line,
238 code: "duplicate_owner",
239 severity: "warning",
240 message: `Owner \"${owner}\" listed more than once on this rule.`,
241 pattern: rule.pattern,
242 token: owner,
243 });
244 continue;
245 }
246 seenOwners.add(owner);
247 const kind = classifyOwnerToken(owner, hadAt);
248 if (kind === "invalid") {
249 findings.push({
250 line: rule.line,
251 code: "bad_owner_format",
252 severity: "error",
253 message: `Owner \"${rawToken}\" isn't a valid @user, @org/team, or email.`,
254 pattern: rule.pattern,
255 token: rawToken,
256 });
257 continue;
258 }
259 if (kind === "user") {
260 const ok = await resolver.isUser(owner);
261 if (!ok) {
262 findings.push({
263 line: rule.line,
264 code: "unknown_user",
265 severity: "error",
266 message: `User @${owner} does not exist on this instance.`,
267 pattern: rule.pattern,
268 token: owner,
269 });
270 }
271 } else if (kind === "team") {
272 const [orgSlug, teamSlug] = owner.split("/");
273 const ok = await resolver.isTeam(orgSlug, teamSlug);
274 if (!ok) {
275 findings.push({
276 line: rule.line,
277 code: "unknown_team",
278 severity: "error",
279 message: `Team @${owner} does not exist on this instance.`,
280 pattern: rule.pattern,
281 token: owner,
282 });
283 }
284 }
285 // `email` is accepted as-is — we have no way to reach out and verify.
286 }
287 }
288
289 // 4. Missing catch-all `*` rule. Informational only.
290 const hasCatchAll = lex.rules.some(
291 (r) => r.pattern === "*" || r.pattern === "/*"
292 );
293 if (!hasCatchAll && lex.rules.length > 0) {
294 findings.push({
295 line: 1,
296 code: "missing_catchall",
297 severity: "info",
298 message:
299 "No catch-all rule (`* @owner`). Files outside any pattern will have no reviewer auto-assigned.",
300 });
301 }
302
303 return assembleReport(lex.rules, findings, lex.totalLines);
304}
305
306function assembleReport(
307 rules: LexedRule[],
308 findings: LintFinding[],
309 totalLines: number
310): LintReport {
311 // Sort findings by line, then severity (errors first).
312 const severityRank: Record<LintSeverity, number> = {
313 error: 0,
314 warning: 1,
315 info: 2,
316 };
317 findings.sort((a, b) => {
318 if (a.line !== b.line) return a.line - b.line;
319 return severityRank[a.severity] - severityRank[b.severity];
320 });
321 const errors = findings.filter((f) => f.severity === "error");
322 const warnings = findings.filter((f) => f.severity === "warning");
323 const infos = findings.filter((f) => f.severity === "info");
324 return {
325 totalLines,
326 totalRules: rules.length,
327 rules,
328 findings,
329 errors,
330 warnings,
331 infos,
332 ok: errors.length === 0,
333 };
334}
335
336export const __internal = {
337 EMAIL_RE,
338 USER_RE,
339 TEAM_RE,
340 lexCodeowners,
341 classifyOwnerToken,
342 isPlausiblePattern,
343 validateCodeowners,
344 assembleReport,
345};
Addedsrc/routes/codeowners-lint.tsx+336−0View fileUnifiedSplit
@@ -0,0 +1,336 @@
1/**
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;
0337