Skip to content

fix: write CODEOWNERS annotations after glob-based sections - #130

Merged
dduugg merged 2 commits into
rubyatscale:mainfrom
artfuldodger:je-codeowners-annotations-last
Sep 26, 2026
Merged

dduugg merged 2 commits into
rubyatscale:mainfrom
artfuldodger:je-codeowners-annotations-last

Conversation

@artfuldodger

@artfuldodger artfuldodger commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • GitHub applies the last matching CODEOWNERS line. The generator writes the "Annotations at the top of file" section first, so a broader team glob that comes later can override an annotated file.
  • This case passes validate. A team glob excludes the file through unowned_globs, and an annotation assigns it to another team. for-file correctly reports the annotated team. But in the generated CODEOWNERS, the team glob comes after the annotation line, so GitHub requests the wrong team's review. for-file --from-codeowners and the Ruby gem's CODEOWNERS-based lookups (teams_for_files_from_codeowners, and for_file with its default from_codeowners: true) return the wrong team too, because the CODEOWNERS reader also gives the last match priority.
  • The fix writes the annotations section last. Resolution and validation keep their existing mapper order; only the generated file's section order changes.

Why only annotations need to move

In a configuration that passes validate, a file has one owner. The only way a broader pattern can also match it is a team glob that excludes it through unowned_globs, which CODEOWNERS can't express. Each possible owner of that excluded file:

Excluded file owned by Section position today Correct on GitHub?
.codeowner directory after team globs yes
package (package.yml / package.json) after team globs yes
team YML / gem after team globs yes
file annotation before team globs no

Moving annotations to the end fixes the one wrong row. Annotation lines are exact file paths, so writing them last can only affect the file each one names.

Measured

On a large monorepo, I compared the per-file resolver (runner::file_owner_for_file) against CODEOWNERS-derived owners (runner::teams_for_files_from_codeowners) for every owned file (95,776):

CODEOWNERS Mismatches Of which caused by section order
generated by main 2,785 24
generated by this branch 2,761 0
  • Files fixed: all 24 ordering mismatches, with no new mismatches introduced.
  • What remains: every remaining mismatch is a different, pre-existing issue: several teams sharing one github.team handle, which CODEOWNERS can't tell apart when you read ownership back from it. Those don't affect GitHub routing, since the handle is the same, so after this change GitHub review routing matched for-file for every file in that repo.
  • Where to go next: I'm happy to follow up on the shared-handle case separately, for example with a validate warning or by making the reader flag ambiguous handles.

I also bucketed every tracked file (136,621) against both orderings, including the unowned cases:

Outcome main this branch
per-file and CODEOWNERS agree 113,238 113,262
different team 2,967 2,943
tool unowned, CODEOWNERS assigns a team 0 0
tool owned, no CODEOWNERS line 6 6
both unowned 20,410 20,410
  • Scope: exactly 24 files changed, all to agree, and none got worse.
  • Same lines, new order: the generated files contain the same set of lines; only the order differs.
  • Annotations can't reach unowned files: the annotation section only lists config-owned files (project.files is filtered by owned_globs/unowned_globs), which is also when for-file honours an annotation. So writing annotations last can't assign an owner to a file the tool treats as unowned.

Tests

  • New fixture annotation_in_unowned_team_glob. Team Alpha owns ruby/app/**/* but excludes beta_owned.rb through unowned_globs; beta_owned.rb is # @team Beta. New tests:
    • validate accepts the committed fixture CODEOWNERS
    • after generate in a temp copy, crosscheck-owners reports "Success! All files match between CODEOWNERS and for-file command."
    • after generate in a temp copy, for-file --from-codeowners returns Beta
    • With main's generator, all three fail; crosscheck reports ruby/app/services/beta_owned.rb: CODEOWNERS=Alpha fast=Beta.
  • Regenerated fixtures. I regenerated the four fixture CODEOWNERS files that were exact snapshots of main's output, and updated four inline expectations. Each change only moves the annotations block to the end.
  • Every existing fixture. Running generate, then validate and crosscheck-owners, on every upstream fixture with main's binary and with this branch gives identical exit codes and mismatch counts. The only difference is the new fixture, which goes from 1 mismatch to 0.
  • Checks. cargo clippy --all-targets --all-features -- -D warnings, cargo test (166 passed, 0 failed, rebased on current main), and cargo fmt -- --check, all on the pinned 1.97.1 toolchain.

Things to be aware of

  • Every consumer's CODEOWNERS changes on upgrade (worth a release note). The annotations block moves to the end. Plain validate autocorrects it, but CI that runs validate --skip-autocorrect fails until someone regenerates and commits the file. The diff is a pure section move.
  • for-team report order. The for-team report lists sections in CODEOWNERS order, so its "Annotations at the top of file" section now appears last (see test_for_team).
  • Relation to Ownership overrides #69. This doesn't implement the ownership overrides proposed in Ownership overrides #69. The ignored valid_project_with_overrides tests still fail at validate, as they do on main. It does make the generated file agree with the priority order Ownership overrides #69 describes, for the overlaps that are valid today.

🤖 Generated with Claude Code

@dduugg dduugg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for tracking this down — a subtle one, and the write-up made it easy to review.

One ask for the release: every consumer's CODEOWNERS changes on upgrade. Plain bin/codeownership validate autocorrects it, but CI that runs validate --skip-autocorrect will fail until someone regenerates and commits, so it's worth calling out in the release notes.

}

#[test]
fn test_codeowners_file_agrees_with_for_file() -> Result<(), Box<dyn Error>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test and test_for_file_from_codeowners_returns_annotated_owner read the committed fixture CODEOWNERS, which is already in the fixed order, so both pass on main too — only test_validate_accepts_generated_order fails there. Could these two regenerate into a temp copy first, so they exercise the generator? Otherwise it'd help to describe them as fixture-consistency checks.

@dduugg dduugg mentioned this pull request Sep 25, 2026
artfuldodger and others added 2 commits September 25, 2026 13:15
GitHub applies the last matching CODEOWNERS line, but the generator wrote
the file-annotation section first. A file that a team glob excludes via
unowned_globs and an annotation assigns to another team is valid, and
for-file resolves it to the annotated team, yet the broader team glob
came later in CODEOWNERS and won on GitHub (and in --from-codeowners
lookups).

Write the annotations section last. Resolution and validation keep their
existing mapper order; only the generated file changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The crosscheck and for-file tests read the committed fixture CODEOWNERS,
which is already in the fixed order, so they passed without the generator
change. Regenerate into the temp copy first so all three tests fail on the
old order.
@artfuldodger
artfuldodger force-pushed the je-codeowners-annotations-last branch from a04ef70 to 314ba94 Compare September 25, 2026 19:16

@dduugg dduugg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — regenerating into the temp copy closes the gap; all three tests now fail on the old ordering.

@dduugg
dduugg merged commit ae656c4 into rubyatscale:main Sep 26, 2026
11 checks passed
@dduugg dduugg mentioned this pull request Sep 26, 2026
dduugg added a commit that referenced this pull request Sep 26, 2026
A minor bump, because both changes since 0.4.0 are visible to consumers:

  - The generated CODEOWNERS now writes the "Annotations at the top of file"
    section last (#130), so GitHub's last-match-wins routing agrees with
    `for-file`. Every consumer's CODEOWNERS changes on upgrade, so `validate`
    reports it out of date until it's regenerated.
  - `find_file_owners` caches loaded teams for the life of the process (#129),
    so team files added or edited mid-process aren't seen until
    `clear_team_cache()` runs or the process restarts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants