Skip to content

Commit 0e2e596

Browse files
committed
fix(report): surface email CODEOWNERS owners
Align the ownership explorer with CODEOWNERS coverage by including email owners in the report index and HTML UI. Rename the report schema to generic owner terminology and add CLI regression coverage for email-owner parity. Made-with: Cursor
1 parent 4fc6f5c commit 0e2e596

File tree

5 files changed

+174
-82
lines changed

5 files changed

+174
-82
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ See how ownership coverage looks in practice with [this interactive report](http
2626
- Coverage summary: total files, owned, unowned, and percentage
2727
- Directory explorer with filtering, sorting, and drill-down
2828
- Full unowned file list with scope and text filtering
29-
- Ownership explorer with quick chips and file filtering for `@org/team` and `@username` owners
29+
- Ownership explorer with quick chips and file filtering for resolved CODEOWNERS owners, including `@org/team`, `@username`, and email owners
3030
- Matches GitHub `CODEOWNERS` discovery precedence: `.github/`, repository root, then `docs/`
3131
- Detects CODEOWNERS patterns that match no repository paths
3232
- Detects directories with fragile coverage — 100% covered through individual file rules, but new files would lack owners
@@ -204,6 +204,7 @@ The report follows practical `CODEOWNERS` resolution behavior:
204204

205205
- A file is considered **owned** if at least one owner is resolved.
206206
- When `--validate-github-owners` is enabled, `@username` and `@org/team` owners only count if GitHub confirms they exist and have write access to the repository.
207+
- The ownership explorer surfaces the resolved owners for each matched file, including email owners.
207208
- Within a single `CODEOWNERS` file, the **last matching rule wins**.
208209
- A pattern line with no owners acts as an ownerless override only when it is the last matching rule for a path, matching GitHub's documented behavior.
209210
- GitHub only considers `CODEOWNERS` at `.github/CODEOWNERS`, `CODEOWNERS`, and `docs/CODEOWNERS`, using the first file found in that order.
@@ -228,7 +229,7 @@ The generated page includes:
228229
- repository-level ownership metrics and coverage bar
229230
- scoped directory table with coverage bars
230231
- searchable list of unowned files
231-
- ownership explorer for filtering files by `@org/team` or `@username`
232+
- ownership explorer for filtering files by resolved CODEOWNERS owner, including `@org/team`, `@username`, and email owners
232233
- active `CODEOWNERS` file and rule count
233234
- warnings for extra or unsupported `CODEOWNERS` files that GitHub will ignore
234235
- warnings for CODEOWNERS patterns that match no repository paths

lib/report-builder.js

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export function buildReport (repoRoot, files, codeownersDescriptor, options, pro
2525
const directoryStats = new Map()
2626
/** @type {string[]} */
2727
const unownedFiles = []
28-
/** @type {Map<string, { team: string, files: string[] }>} */
29-
const teamOwnership = new Map()
28+
/** @type {Map<string, { owner: string, files: string[] }>} */
29+
const ownerIndex = new Map()
3030

3131
let owned = 0
3232
let unowned = 0
@@ -40,11 +40,11 @@ export function buildReport (repoRoot, files, codeownersDescriptor, options, pro
4040
if (isOwned) {
4141
owned++
4242
for (const owner of fileOwners) {
43-
const ownerEntry = teamOwnership.get(owner.toLowerCase())
43+
const ownerEntry = ownerIndex.get(owner.toLowerCase())
4444
if (ownerEntry) {
4545
ownerEntry.files.push(filePath)
4646
} else {
47-
teamOwnership.set(owner.toLowerCase(), { team: owner, files: [filePath] })
47+
ownerIndex.set(owner.toLowerCase(), { owner, files: [filePath] })
4848
}
4949
}
5050
} else {
@@ -82,18 +82,18 @@ export function buildReport (repoRoot, files, codeownersDescriptor, options, pro
8282

8383
const directories = mapToRows(directoryStats).sort(compareRows)
8484
unownedFiles.sort((first, second) => first.localeCompare(second))
85-
const teamOwnershipRows = Array.from(teamOwnership.values())
85+
const ownerIndexRows = Array.from(ownerIndex.values())
8686
.map((entry) => {
8787
entry.files.sort((first, second) => first.localeCompare(second))
8888
return {
89-
team: entry.team,
89+
owner: entry.owner,
9090
total: entry.files.length,
9191
files: entry.files,
9292
}
9393
})
9494
.sort((first, second) => {
9595
if (first.total !== second.total) return second.total - first.total
96-
return first.team.localeCompare(second.team)
96+
return first.owner.localeCompare(second.owner)
9797
})
9898

9999
return {
@@ -110,7 +110,7 @@ export function buildReport (repoRoot, files, codeownersDescriptor, options, pro
110110
}],
111111
directories,
112112
unownedFiles,
113-
teamOwnership: teamOwnershipRows,
113+
ownerIndex: ownerIndexRows,
114114
codeownersValidationMeta: {
115115
discoveryWarnings: [],
116116
discoveryWarningCount: 0,
@@ -141,7 +141,8 @@ export function buildReport (repoRoot, files, codeownersDescriptor, options, pro
141141
}
142142

143143
/**
144-
* Collect unique CODEOWNERS owner entries (both @org/team teams and @username individuals).
144+
* Collect unique CODEOWNERS owner entries for the report explorer/index.
145+
* This includes GitHub owners (`@org/team`, `@username`) and email owners.
145146
* @param {string[]|undefined} owners
146147
* @returns {string[]}
147148
*/
@@ -151,22 +152,23 @@ function collectOwners (owners) {
151152
/** @type {Map<string, string>} */
152153
const unique = new Map()
153154
for (const owner of owners) {
154-
if (!looksLikeOwner(owner)) continue
155+
if (!looksLikeIndexedOwner(owner)) continue
155156
const normalized = owner.trim()
156157
unique.set(normalized.toLowerCase(), normalized)
157158
}
158159
return Array.from(unique.values())
159160
}
160161

161162
/**
162-
* Determine whether a CODEOWNERS owner token is a recognized `@`-prefixed owner
163-
* (either an `@org/team` or an `@username`).
163+
* Determine whether a CODEOWNERS owner token should be surfaced in the report
164+
* explorer/index.
164165
* @param {unknown} owner
165166
* @returns {boolean}
166167
*/
167-
function looksLikeOwner (owner) {
168+
function looksLikeIndexedOwner (owner) {
168169
if (typeof owner !== 'string') return false
169-
return /^@[^\s]+$/.test(owner.trim())
170+
const normalized = owner.trim()
171+
return /^@[^\s]+$/.test(normalized) || /^[^\s@]+@[^\s@]+$/.test(normalized)
170172
}
171173

172174
/**

lib/report.template.html

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -188,19 +188,19 @@
188188
}
189189
.controls label { color: var(--muted); display: flex; gap: 8px; align-items: center; font-size: 14px; }
190190
.suggestion-muted { color: var(--muted); }
191-
.team-controls {
191+
.owner-controls {
192192
display: grid;
193193
grid-template-columns: minmax(260px, 420px) minmax(280px, 1fr);
194194
gap: 12px;
195195
align-items: center;
196196
}
197-
.team-chip-list {
197+
.owner-chip-list {
198198
display: flex;
199199
flex-wrap: wrap;
200200
gap: 8px;
201201
margin-bottom: 10px;
202202
}
203-
.team-chip {
203+
.owner-chip {
204204
border: 1px solid rgba(139, 92, 246, 0.55);
205205
background: rgba(139, 92, 246, 0.18);
206206
color: #ddd6fe;
@@ -209,15 +209,15 @@
209209
font-size: 12px;
210210
cursor: pointer;
211211
}
212-
.team-chip:hover {
212+
.owner-chip:hover {
213213
background: rgba(139, 92, 246, 0.3);
214214
}
215-
.team-chip.is-active {
215+
.owner-chip.is-active {
216216
border-color: #c4b5fd;
217217
background: rgba(196, 181, 253, 0.34);
218218
color: #f5f3ff;
219219
}
220-
.team-empty {
220+
.owner-empty {
221221
border: 1px dashed var(--border);
222222
border-radius: 10px;
223223
padding: 12px;
@@ -354,7 +354,7 @@
354354
margin-top: 10px;
355355
margin-bottom: 6px;
356356
}
357-
#team-file-count {
357+
#owner-file-count {
358358
margin-top: 10px;
359359
margin-bottom: 6px;
360360
}
@@ -403,7 +403,7 @@
403403
.hero-title { align-items: flex-start; }
404404
.controls input[type="text"] { min-width: 100%; }
405405
.controls select { min-width: 100%; }
406-
.team-controls {
406+
.owner-controls {
407407
grid-template-columns: 1fr;
408408
}
409409
}
@@ -485,14 +485,14 @@ <h2>Unowned Files</h2>
485485
<h2>Ownership Explorer</h2>
486486
<p class="muted">Select an owner to view the files they own</p>
487487
</div>
488-
<div class="team-empty muted" id="team-empty-state"></div>
489-
<div class="controls field-controls team-controls">
490-
<select id="team-select" aria-label="Select owner"></select>
491-
<input id="team-file-filter" type="text" placeholder="Filter selected owner's files..." />
488+
<div class="owner-empty muted" id="owner-empty-state"></div>
489+
<div class="controls field-controls owner-controls">
490+
<select id="owner-select" aria-label="Select owner"></select>
491+
<input id="owner-file-filter" type="text" placeholder="Filter selected owner's files..." />
492492
</div>
493-
<div class="team-chip-list" id="team-chip-list"></div>
494-
<div class="file-list" id="team-file-list"></div>
495-
<p class="muted" id="team-file-count"></p>
493+
<div class="owner-chip-list" id="owner-chip-list"></div>
494+
<div class="file-list" id="owner-file-list"></div>
495+
<p class="muted" id="owner-file-count"></p>
496496
</section>
497497

498498
<section class="panel">
@@ -559,7 +559,7 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
559559
const clamp = value => Math.max(0, Math.min(100, value))
560560
const scopeQueryParam = 'scope'
561561
const directoryRows = report.directories.filter(row => row.path !== '(root)')
562-
const teamOwnershipRows = Array.isArray(report.teamOwnership) ? report.teamOwnership : []
562+
const ownerIndexRows = Array.isArray(report.ownerIndex) ? report.ownerIndex : []
563563
const suggestionRows = Array.isArray(report.directoryTeamSuggestions) ? report.directoryTeamSuggestions : []
564564
const suggestionMeta = report.directoryTeamSuggestionsMeta || { enabled: false, warnings: [] }
565565
const codeownersValidationMeta = report.codeownersValidationMeta || {
@@ -595,7 +595,7 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
595595
let selectedPath = readScopeFromLocation()
596596
let directoryController
597597
let unownedController
598-
let teamController
598+
let ownerController
599599

600600
function setScope (nextPath, options) {
601601
const historyMode = options && options.historyMode ? options.historyMode : 'push'
@@ -605,7 +605,7 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
605605

606606
if (directoryController) directoryController.render()
607607
if (unownedController) unownedController.render()
608-
if (teamController) teamController.render()
608+
if (ownerController) ownerController.render()
609609

610610
if (historyMode !== 'none') {
611611
if (historyMode === 'replace') {
@@ -643,7 +643,7 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
643643
getDirectScopeStats
644644
)
645645
unownedController = setupUnownedFiles(report.unownedFiles, () => selectedPath)
646-
teamController = setupTeamOwnershipExplorer(teamOwnershipRows, () => selectedPath)
646+
ownerController = setupOwnerIndexExplorer(ownerIndexRows, () => selectedPath)
647647
syncScopeToLocation(selectedPath, true)
648648

649649
globalThis.addEventListener('popstate', () => {
@@ -1342,26 +1342,26 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
13421342
return { render }
13431343
}
13441344

1345-
function setupTeamOwnershipExplorer (rows, getScope) {
1346-
const teamSelect = document.getElementById('team-select')
1347-
const fileFilter = document.getElementById('team-file-filter')
1348-
const chipList = document.getElementById('team-chip-list')
1349-
const list = document.getElementById('team-file-list')
1350-
const count = document.getElementById('team-file-count')
1351-
const emptyState = document.getElementById('team-empty-state')
1345+
function setupOwnerIndexExplorer (rows, getScope) {
1346+
const ownerSelect = document.getElementById('owner-select')
1347+
const fileFilter = document.getElementById('owner-file-filter')
1348+
const chipList = document.getElementById('owner-chip-list')
1349+
const list = document.getElementById('owner-file-list')
1350+
const count = document.getElementById('owner-file-count')
1351+
const emptyState = document.getElementById('owner-empty-state')
13521352
const safeRows = rows
1353-
.filter(row => row && typeof row.team === 'string' && Array.isArray(row.files))
1353+
.filter(row => row && typeof row.owner === 'string' && Array.isArray(row.files))
13541354
.map((row) => ({
1355-
team: row.team,
1355+
owner: row.owner,
13561356
total: Number(row.total) || row.files.length,
13571357
files: row.files,
13581358
}))
1359-
const rowByTeam = new Map(safeRows.map(row => [row.team, row]))
1360-
let selectedTeam = safeRows.length > 0 ? safeRows[0].team : ''
1359+
const rowByOwner = new Map(safeRows.map(row => [row.owner, row]))
1360+
let selectedOwner = safeRows.length > 0 ? safeRows[0].owner : ''
13611361

13621362
if (safeRows.length === 0) {
1363-
emptyState.textContent = 'No @-prefixed owners found in resolved CODEOWNERS matches. Ownership entries appear when rules use @org/team or @username.'
1364-
teamSelect.disabled = true
1363+
emptyState.textContent = 'No resolved owners found in CODEOWNERS matches. Ownership entries appear when rules use @org/team, @username, or email owners.'
1364+
ownerSelect.disabled = true
13651365
fileFilter.disabled = true
13661366
chipList.innerHTML = ''
13671367
list.textContent = '(none)'
@@ -1372,28 +1372,28 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
13721372
emptyState.hidden = true
13731373
for (const row of safeRows) {
13741374
const option = document.createElement('option')
1375-
option.value = row.team
1376-
option.textContent = row.team + ' \u2014 ' + fmt.format(row.total) + ' files'
1377-
teamSelect.appendChild(option)
1375+
option.value = row.owner
1376+
option.textContent = row.owner + ' \u2014 ' + fmt.format(row.total) + ' files'
1377+
ownerSelect.appendChild(option)
13781378
}
13791379

1380-
const quickTeams = safeRows.slice(0, 12)
1381-
for (const row of quickTeams) {
1380+
const quickOwners = safeRows.slice(0, 12)
1381+
for (const row of quickOwners) {
13821382
const chip = document.createElement('button')
13831383
chip.type = 'button'
1384-
chip.className = 'team-chip'
1385-
chip.textContent = row.team + ' (' + fmt.format(row.total) + ')'
1386-
chip.setAttribute('data-team', row.team)
1384+
chip.className = 'owner-chip'
1385+
chip.textContent = row.owner + ' (' + fmt.format(row.total) + ')'
1386+
chip.setAttribute('data-owner', row.owner)
13871387
chip.addEventListener('click', () => {
1388-
selectedTeam = row.team
1389-
teamSelect.value = selectedTeam
1388+
selectedOwner = row.owner
1389+
ownerSelect.value = selectedOwner
13901390
render()
13911391
})
13921392
chipList.appendChild(chip)
13931393
}
13941394

1395-
teamSelect.addEventListener('change', () => {
1396-
selectedTeam = teamSelect.value
1395+
ownerSelect.addEventListener('change', () => {
1396+
selectedOwner = ownerSelect.value
13971397
render()
13981398
})
13991399
fileFilter.addEventListener('input', render)
@@ -1402,17 +1402,17 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
14021402
function render () {
14031403
const scope = getScope()
14041404
const query = fileFilter.value.trim().toLowerCase()
1405-
const selectedRow = rowByTeam.get(selectedTeam) || safeRows[0]
1405+
const selectedRow = rowByOwner.get(selectedOwner) || safeRows[0]
14061406
if (!selectedRow) {
14071407
list.textContent = '(none)'
14081408
count.textContent = 'Showing 0 files.'
14091409
return
14101410
}
14111411

1412-
if (teamSelect.value !== selectedRow.team) {
1413-
teamSelect.value = selectedRow.team
1412+
if (ownerSelect.value !== selectedRow.owner) {
1413+
ownerSelect.value = selectedRow.owner
14141414
}
1415-
selectedTeam = selectedRow.team
1415+
selectedOwner = selectedRow.owner
14161416
const scopedFiles = scope
14171417
? selectedRow.files.filter(file => file === scope || file.startsWith(scope + '/'))
14181418
: selectedRow.files
@@ -1422,12 +1422,12 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
14221422
const shown = filteredFiles.slice(0, 6000)
14231423

14241424
list.textContent = shown.join('\n') || '(none)'
1425-
count.textContent = 'Owner: ' + selectedRow.team + ' — scope: ' + (scope || '(root)') +
1425+
count.textContent = 'Owner: ' + selectedRow.owner + ' — scope: ' + (scope || '(root)') +
14261426
' — showing ' + fmt.format(shown.length) + ' of ' + fmt.format(filteredFiles.length) + ' files.'
14271427

1428-
const chips = chipList.querySelectorAll('.team-chip')
1428+
const chips = chipList.querySelectorAll('.owner-chip')
14291429
for (const chip of chips) {
1430-
const isActive = chip.getAttribute('data-team') === selectedRow.team
1430+
const isActive = chip.getAttribute('data-owner') === selectedRow.owner
14311431
chip.classList.toggle('is-active', isActive)
14321432
}
14331433
}

lib/types.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,12 @@
150150
*/
151151

152152
/**
153-
* Ownership summary for a single team.
153+
* Ownership summary for a single resolved CODEOWNERS owner.
154154
* @typedef {{
155-
* team: string,
155+
* owner: string,
156156
* total: number,
157157
* files: string[]
158-
* }} TeamOwnershipRow
158+
* }} OwnerIndexRow
159159
*/
160160

161161
/**
@@ -206,7 +206,7 @@
206206
* codeownersFiles: { path: string, rules: number }[],
207207
* directories: DirectoryRow[],
208208
* unownedFiles: string[],
209-
* teamOwnership: TeamOwnershipRow[],
209+
* ownerIndex: OwnerIndexRow[],
210210
* codeownersValidationMeta: CodeownersValidationMeta,
211211
* directoryTeamSuggestions: TeamSuggestionRow[],
212212
* directoryTeamSuggestionsMeta: TeamSuggestionsMeta

0 commit comments

Comments
 (0)