codeowners-util
v1.0.0
Published
Generate GitHub CODEOWNERS files from a typed config
Maintainers
Readme
codeowners-util
GitHub's CODEOWNERS leaves a lot to desire and lacks a lot of features one would want to create fine grained rules about who owns what in a monorepo.
This library lets you define ownership in TypeScript. You describe who owns what and why. The library generates a correct CODEOWNERS file with rules sorted so GitHub's last-match-wins semantics produce the right result.
The mental model
There are two layers to ownership:
own() is the source of truth. It's a direct declaration: these teams own these paths. If two teams both call own() on the same path, they share it. Nothing else in the system can remove an own() declaration.
only() and add() are for cross-cutting patterns. Things like "all locale files should be reviewed by the i18n team." They apply to file patterns anchored at the repository root. only() replaces the owners outright. add() puts reviewers on top of the owners a file already has.
The rules
own()declarations merge. Twoown()calls on the same path? Both teams are co-owners.add()stacks. It puts teams on top of the current owners.only()replaces. It sets the owners outright and discards whatever came before, including a directown()declaration. Useadd()when you want to keep the existing owners.alwaysis unconditional. Teams listed here are appended to every rule. Useful for bot accounts.A pattern starting with
!excludes files from that rule, as it does in.gitignore.Declaration order decides. Entries in
rulesrun in the order you write them, and each one builds on the result of the last. Pattern specificity does not reorder them.own()is the base layer, so its order does not matter — the narrowest declaration wins.
Install
npm install codeowners-utilQuick start
Create a codeowners.config.ts at your repo root:
import { team, own, only, add } from "codeowners-util";
import type { CodeOwnersConfig } from "codeowners-util";
const bot = team("@ci-bot");
const platform = team("@org/platform");
const search = team("@org/search");
const i18n = team("@org/i18n");
const config: CodeOwnersConfig = {
always: [bot],
teams: {
"@org/platform": "Platform & Infrastructure",
"@org/search": "Search Experience",
"@org/i18n": "Internationalization",
},
own: [
own(
platform,
["*", "apps/web", "libs/config"],
"Platform owns the foundation",
),
own(
search,
["libs/search", "libs/search-api"],
"Search team owns search libs",
),
own([search, platform], "apps/web/src/routes/search.ts"),
],
rules: [
only(i18n, "**/locales/**/*.json", "All locale files are reviewed by i18n"),
add(
i18n,
"**/locales/en-US/**/*.json",
"English source strings need both product team and i18n",
),
],
};
export default config;Generate the file:
npx codeowners-utilThis writes .github/CODEOWNERS with rules sorted by specificity so GitHub's last-match-wins semantics produce the right result.
How ownership works
Direct ownership with own()
own(platform, "libs/config");
own(search, ["libs/search", "libs/search-api"]);
own([search, platform], "apps/web/src/routes/search.ts");When multiple own() calls declare the same path, their owners merge:
own(teamA, "libs/core");
own(teamB, "libs/core");
// libs/core is co-owned by bothCross-cutting rules with only() and add()
Rules apply file patterns across the repository, in declaration order.
add adds teams on top of the current owners:
add(i18n, "**/locales/en-US/**/*.json");
// If search owns libs/search:
// libs/search/locales/en-US/**/*.json → @org/search @org/i18nonly replaces the current owners:
only(i18n, "**/locales/**/*.json");
// Every locale file → @org/i18n, whoever owned it beforeonly discards a direct own() declaration too:
own([checkout, platform], "apps/web/config/features/checkout");
only(checkout, "**/features/checkout/**");
// Files inside apps/web/config/features/checkout/ → @org/checkout
// @org/platform is gone, because `only` replaces.
// Use add(checkout, ...) to keep @org/platform.Rules run in the order you declare them, and each rule builds on the one before it:
only(i18n, "**/locales/**/*.json"); // 1. locales → i18n
add(qa, "**/locales/en-US/**/*.json"); // 2. en-US → i18n + qaReversing those two lines gives a different result, because only() would then discard @org/qa.
How match patterns work
Patterns are anchored at the repository root.
**/locales/**/*.json matches locales at any depth
src/**/*.test.ts matches the root src directory only
**/src/**/*.test.ts matches src at any depthA pattern with no glob characters acts as a directory prefix. apps/web matches apps/web and everything below it.
Excluding files with !
A pattern starting with ! removes files from the rule it belongs to. A file must match at least one ordinary pattern, and no ! pattern.
This lets you write a broad default with a narrow carve-out:
own(commerceDev, "*"),
own(airCars, "libs/air"),
rules: [
// Locale files belong to the shared team, except English source strings
only(commerceDev, ["**/locales/**/*.json", "!**/locales/en-US/**"]),
// which stay with the owning team, plus localization
add(localization, "**/locales/en-US/**/*.json"),
];libs/air/locales/fr/air.json goes to @org/commerce-dev. libs/air/locales/en-US/air.json keeps @org/air-cars and gains @org/localization.
Without ! you cannot express this. only() would take the English files too, and no later rule can give a file back an owner that varies per directory.
Two details differ from .gitignore, both deliberately:
Order does not matter. In
.gitignorea later!re-includes something an earlier line excluded. Here every!pattern is an exclusion, wherever it sits in the array, so the two orders below mean the same thing.only(commerceDev, ["**/locales/**/*.json", "!**/locales/en-US/**"]); only(commerceDev, ["!**/locales/en-US/**", "**/locales/**/*.json"]);Only a rule pattern may start with
!. GitHub does not support negation in a CODEOWNERS file, so a line beginning with!is a syntax error there. The marker is stripped before anything is written, and anown()path starting with!is rejected.
A rule needs at least one pattern that is not an exclusion, otherwise it selects nothing. That throws, as does a bare "!".
A ! anywhere other than the first character is literal, so **/!important.json matches a file named !important.json.
The generator reads the real files in your repository, works out the exact owner set for every file, then writes the smallest set of rules that reproduces those owners. It checks the result before writing. If a directory holds no matching file, no rule is emitted for it.
Ordering
The generated file is sorted so that more specific rules appear later and win under GitHub's last-match-wins evaluation:
* @org/platform # catches everything
libs/search @org/search # more specific, wins for libs/search
libs/search/locales/**/*.json @org/i18n # even more specific, wins for locale filesYou do not need to reason about this ordering. The generator verifies the emitted file against the owners it resolved, and fails rather than write a file that disagrees.
Descriptions
Every part of the config supports optional descriptions. They're rendered as comments in the generated file.
Team descriptions
// In the config
const config: CodeOwnersConfig = {
teams: {
"@org/platform": "Platform & Infrastructure",
"@org/search": "Search Experience",
},
// ...
};
// Or inline with team()
const platform = team("@org/platform", "Platform & Infrastructure");Shows up in section headers:
# @org/platform (Platform & Infrastructure), @ci-botOwnership descriptions
own(
platform,
["*", "libs/config"],
"Platform owns the foundation and shared config",
);Rendered above the group:
# Platform owns the foundation and shared config
# @org/platform (Platform & Infrastructure), @ci-bot
* @org/platform @ci-bot
libs/config @org/platform @ci-botRule descriptions
only(
i18n,
"**/locales/**/*.json",
"All locale files are reviewed by i18n, product teams opt in via en-US",
);Rendered below the rule header:
# ── only: **/locales/**/*.json ──
# All locale files are reviewed by i18n, product teams opt in via en-USCLI
codeowners-util [options]
-c, --config <path> Config file (default: codeowners.config.ts)
-o, --output <path> Output file (default: .github/CODEOWNERS)
--check Check if output is up to date (exit 1 if stale)
--stdout Print generated output to stdout
-h, --help Show helpUse --check in CI to keep the CODEOWNERS file in sync:
npx codeowners-util --checkInput validation
A CODEOWNERS line separates fields by spaces, and # starts a comment. A team handle or path that carries whitespace, a #, or a line break can therefore add or truncate rules in the generated file.
team(), own(), only() and add() reject those values, and generate() checks the whole config again. That second check matters when you build a config from data instead of literals:
// Throws, rather than writing "* @attacker" as an extra rule
team("@org/platform\n* @attacker");
own(platform, "apps/web\n* @attacker");
// Also throws, even though it never calls team()
generate({ own: [{ owners: ["@org/ok\n* @attacker"], paths: ["*"] }] });Descriptions become comment lines, so they may contain spaces but not line breaks.
Programmatic API
import { team, own, match, generate, write } from "codeowners-util";team(name, description?)
Creates a typed team handle. The optional description is used in generated comments.
own(owners, paths, description?)
Declares ownership. Accepts a single team or array, and a single path or array.
only(owners, patterns, description?)
Replaces the owners of every file the patterns match. Accepts a single team or an array, and a single pattern or an array. A pattern starting with ! excludes files from the rule.
add(owners, patterns, description?)
Adds owners on top of the owners a file already has. Same argument shape as only().
generate(config, options?)
Returns the generated CODEOWNERS content as a string.
rootDir- root directory for filesystem-aware resolution (default:process.cwd())fs- custom filesystem implementation for testing (default:node:fs)
write(config, options)
Writes the CODEOWNERS file to disk.
write(config, { outputPath: ".github/CODEOWNERS" });
// Check mode, compare without writing
const result = write(config, {
outputPath: ".github/CODEOWNERS",
check: true,
});
console.log(result.upToDate);Config reference
interface CodeOwnersConfig {
always?: Team[];
teams?: Record<string, string>;
/** Base layer. The narrowest declaration wins, so order does not matter. */
own: OwnershipRule[];
/** Applied on top of own(), in declaration order. */
rules?: PolicyRule[];
}Upgrading to 1.0
match() is gone. It was two operations sharing one function, so it is now two functions, and both take the same arguments as own():
// before
match: [
match("**/locales/**/*.json", { only: [i18n] }),
match("**/*.test.ts", { add: [qa], description: "QA reviews tests" }),
];
// after
rules: [
only(i18n, "**/locales/**/*.json"),
add(qa, "**/*.test.ts", "QA reviews tests"),
];The match config key is now rules. Owners come first, patterns second, and both accept a single value or an array. Resolution semantics are unchanged.
License
MIT
