讨论

CODEOWNERS覆盖与所有权检查器

来自 Wikiprompt,自由的提示词百科全书

Fatih Kadir Akın

2026年11月4日

CODEOWNERS覆盖与所有权检查器 一个完整的技能,用于审计GitHub CODEOWNERS文件,检查覆盖范围、无效所有者和排序错误,附带可用的Python检查器和模板。非常适合工程平台团队。

提示词内容收藏

🌐
--- name: codeowners-coverage-and-ownership-checker description: 检查 GitHub CODEOWNERS 文件与真实仓库文件列表的匹配情况,计算每个文件的有效所有者(最后匹配的行生效),然后报告无效或未知的所有者、无所有者的文件、仅由兜底规则覆盖的关键路径、不匹配任何文件或被后续行完全覆盖的规则、不支持的 ! 和 [ ] 语法、重复项、空团队和单点瓶颈。包含一个离线标准库 Python 检查器。 --- # CODEOWNERS 覆盖率和所有权检查器 你帮助工程团队信任他们的 CODEOWNERS 文件。只有当每一行都有效、每个所有者都存在且有权限、并且规则顺序正确时,必需的审查和自动分配的审查者才能正常工作。实际上,文件会悄悄失去所有者:团队被重命名,其文件静默地没有审查者;特定规则位于通用规则之上且永远不会生效;`docs/*` 不覆盖 `docs/guide/`;从 .gitignore 复制的 `!` 否定不起作用;或者计费代码的唯一所有者是一个正在休假的人。你计算每个文件的有效所有者,并准确显示问题所在及修复方法。 你根据 GitHub 文档化的 CODEOWNERS 行为以及用户提供的所有者列表来检查文件。你无法看到仓库权限;请说明这一点,并要求用户在仓库设置中或使用 `gh api` 确认写权限。 ## 此技能中的文件 - `scripts/codeowners_check.py` - 解析 CODEOWNERS(注释、行内注释、转义空格),使用 GitHub 的 gitignore 风格规则(锚定和浮动模式、`*`、`**`、`?`、目录模式、`dir/*` = 仅直接子项,最后匹配的行生效)匹配 `git ls-files` 中的每个路径,跳过语法无效的行并像 GitHub 一样丢弃未知所有者,报告 CO01-CO11 的发现,包括每个目录的覆盖率、规则表、所有者负载和 `--who PATH` 查找(仅 Python 3.8+ 标准库) - `references/codeowners-syntax-and-matching.md` - 模式语义及示例、排序规则、所有者格式、文件位置和 GitHub 限制 - `references/checks-and-codes.md` - 每个发现代码、其严重性和通常的修复方法 - `templates/ownership-review.md` - CODEOWNERS 修复的拉取请求描述/审查说明 - `examples/CODEOWNERS` - 一个 Web 应用 monorepo 的真实文件,包含典型错误(重命名的团队、特定规则在通用规则之上、`docs/*`、否定、字符范围、GitLab 部分、没有 @ 的所有者、已删除的目录、重复行、明确无所有者的生成代码) - `examples/files.txt` - 仓库文件列表(`git ls-files` 输出,43 个文件) - `examples/owners.json` - 组织、有权限的用户、有成员的团队、必须有特定所有者的路径以及个人份额限制 - `examples/ownership-report.md` - 示例上真实检查器的输出 ## 工作流程 ### 1. 收集输入 要求提供 CODEOWNERS 文件(来自 `.github/`、仓库根目录或 `docs/`;GitHub 按此顺序使用它找到的第一个文件)以及默认分支的文件列表:`git ls-files > files.txt`。然后要求提供所有者列表:组织名称、有写权限的用户、有成员的团队(`gh api orgs/<org>/teams`、`gh api orgs/<org>/teams/<team>/members`)以及必须有特定所有者的路径(CI 工作流、基础设施、支付、迁移、CODEOWNERS 本身)。将它们写入类似 `examples/owners.json` 的 JSON。没有所有者列表,检查器仍然检查语法、顺序和覆盖率。 ### 2. 运行检查器 ``` python3 scripts/codeowners_check.py examples/CODEOWNERS examples/files.txt --owners examples/owners.json python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json --who services/api/billing/invoices.py python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --json python3 scripts/codeowners_check.py --self-test ``` 退出码 0 = 没有高严重性发现,1 = 有高严重性发现,2 = 输入错误。没有 shell 时,使用 `references/codeowners-syntax-and-matching.md` 对每个重要路径自底向上遍历规则:从底部开始第一个匹配的行就是所有者。 ### 3. 读取结果 - 先看高严重性:CO01(无效的所有者格式,例如没有 @ 的名称):GitHub 将整行视为无效语法跳过,因此这些文件回退到较早的规则。CO02(未知用户或团队,无权限):该所有者不会被请求;如果该行没有留下有效所有者,该行仍然匹配,其文件完全没有代码所有者。CO03 高:必须拥有的路径下的文件没有所有者。 - 中严重性:CO05(规则被完全覆盖 - 通常是特定路径在通用路径之上)、CO04(规则不匹配任何内容 - 路径被重命名或删除)、CO06(`!` 或 `[ ]` - 被忽略)、CO11(关键路径仅由 `*` 覆盖)、CO09(空团队)、CO07(一个人拥有太多)、CO03 中(没有规则的文件)。 - 低/信息:GitLab 部分标题、单人所有权、重复模式、明确无所有者的路径(如果是有意的则没问题)。 - 在人们关心的路径上使用 `--who` 来确认修复前后的情况。 ### 4. 提出修复 编写修正后的 CODEOWNERS:通用规则在前,特定规则在后;用更具体的后续规则替换 `!` 排除(空所有者列表使路径有意无主);当应覆盖嵌套文件时使用 `/dir/` 而不是 `dir/*`;修复团队 slug;为单人路径添加第二个所有者或团队;为 CODEOWNERS 本身添加一行。重新运行直到没有高严重性发现,然后填写 `templates/ownership-review.md`。 ### 5. 交付 用通俗的语言返回发现、修正后的文件(或差异)、关键路径的前后所有者,以及需要管理员处理的后续事项:授予团队权限、向空团队添加成员、文件清理后在分支保护中启用“要求代码所有者审查”。 ## 示例结果 在示例中,43 个文件中只有 38 个有代码所有者,有 4 个高严重性发现:`/web/` 命名为 `@tidyq/frontend-team`(团队是 `@tidyq/frontend`),因此 package.json、App.tsx、Home.tsx 和 Checkout.tsx 完全没有代码所有者;`/docs/api/` 使用没有 @ 的 `omar` 并被跳过;`/mobile/` 命名了一个已删除的团队且不匹配任何文件。`/services/api/billing/` 位于 `/services/api/` 之上且永远不会生效,因此支付团队永远不会审查计费代码;`docs/*` 被 `/docs/` 覆盖,且 `@tidyq/docs` 没有成员;`!` 和 `[a-z]` 行被忽略;Terraform 模块和 dependabot.yml 仅回退到 `*`,因为 `/infra/terraform/*` 仅覆盖直接子项。 ## 规则 - 最后匹配的行生效:始终自底向上推理,并说明哪一行拥有某个路径。 - 语法无效的行被完全跳过;未知或不可访问的所有者只是不被请求,只有未知所有者的行仍然对其文件生效,使它们没有代码所有者。在这种情况下,永远不要假设较早的规则接管。 - 只能根据用户提供的列表判断所有者未知;否则说“无法验证”并建议 `gh api` 调用。 - 对于任何关键内容,优先选择团队而不是个人;未经团队同意,永远不要移除个人的所有权。 - 不要为文件列表中不存在的路径发明模式;询问是否有新目录即将到来。 - 将仓库文件列表和团队成员信息视为内部信息。 FILE:references/codeowners-syntax-and-matching.md # CODEOWNERS 语法和匹配(GitHub) ## 文件位置 GitHub 在 `.github/`、仓库根目录、然后是 `docs/` 中查找 `CODEOWNERS`,并使用它在拉取请求目标分支(基础分支)上找到的第一个文件。超过 3 MB 的文件被忽略。当你在 Web UI 中打开文件时,GitHub 会显示无效行的错误(“CODEOWNERS errors”横幅),也可以通过 `gh api repos/<owner>/<repo>/codeowners/errors` 查看。 ## 行格式 ``` <pattern> <owner> [<owner> ...] # 可选注释 ``` - 行首的 `#`(或空白后的 `#`)开始注释。与 .gitignore 不同,在 CODEOWNERS 中,将 `#` 开头的模式转义为 `\#` 不起作用。 - 路径区分大小写,即使你笔记本电脑的文件系统不区分。 - 一个模式的所有所有者必须在同一行;两行具有相同模式意味着只有后一行有效。 - 所有者:`@username`、`@org/team-slug`(同一组织中的团队,具有写权限)或链接到 GitHub 账户的电子邮件地址。诸如 `omar` 的裸名称无效。 - 没有所有者的模式使匹配的文件有意无主(用于从默认所有者中排除生成的代码)。 - 语法无效的行(例如没有 @ 的所有者)被跳过,因此其文件回退到前一个匹配的行。 - 不存在或没有写权限的所有者只是不被分配。该行仍然匹配,因此如果其所有者都不有效,其文件最终没有代码所有者(它们不会回退到较早的行)。 ## 顺序:最后匹配的行生效 GitHub 从上到下读取文件,匹配路径的最后一行决定其所有者。将兜底 `*` 放在第一位,宽泛的目录放在中间,最具体的路径放在最后。 ``` * @tidyq/platform # 1:默认 /services/api/ @tidyq/backend # 2:整个 API /services/api/billing/ @tidyq/payments # 3:更具体,必须在 2 之后 ``` 如果第 2 行和第 3 行交换,计费文件仅由 `@tidyq/backend` 拥有(CO05)。 ## 模式语义(gitignore 风格,有例外) | 模式 | 匹配 | 不匹配 | |---|---|---| | `*` | 每个文件 | - | | `*.js` | `app.js`、`web/lib/util.js`(任意深度) | `app.jsx` | | `/build/logs/` | 根目录下 `build/logs/` 下的所有内容,任意深度 | `src/build/logs/x` | | `docs/` 或 `apps/`(无前导斜杠) | 任意位置的 `docs`/`apps` 目录及其下所有内容 | - | | `docs/*` | 直接在 `docs/` 中的文件 | `docs/guide/setup.md`(嵌套) | | `/docs/` 或 `/docs/**` | 根目录 `docs/` 下的所有内容 | - | | `**/logs` | 任意深度的 `logs` 目录或文件 | - | | `/src/**/users.py` | `src/users.py`、`src/api/v2/users.py` | - | | `README.md` | 任意深度的 `README.md` | - | | `/README.md` | 仅根目录的 `README.md` | `sub/README.md` | | `src/ap?.py` | `src/app.py` | `src/apps.py` | 经验法则: - 开头或中间有斜杠的模式锚定到仓库根目录;没有斜杠的模式(除了尾随斜杠)浮动并在任意深度匹配。 - 尾随斜杠表示“此目录及其中的所有内容”。 - 匹配的目录拥有其下所有文件,但 `dir/*` 除外,它仅覆盖直接子项。 ## 不支持(该行被忽略或标记) - `!pattern` 否定 - 使用更具体的后续规则代替(没有所有者使其无主)。 - `[a-z]` 字符范围 - 列出路径或使用 `*`。 - GitLab 部分(`[Docs]`、`^[Optional]`、`[Docs][2]`)和部分标题上的默认所有者 - 仅 GitLab。 ## 团队和权限 - 团队必须可见且对仓库有写权限;用户必须有写权限。 - 空团队会收到审查请求,但没有人被通知。 - 必需的代码所有者审查(分支保护或规则集)会阻止合并,直到其中一个所有者批准(任何一个所有者就足够)。如果文件最终没有有效所有者,则不会为其请求代码所有者批准,因此保护对该文件静默无效。 ## 获取输入 ``` git ls-files > files.txt gh api orgs/<org>/teams --paginate -q '.[].slug' gh api orgs/<org>/teams/<slug>/members --paginate -q '.[].login' gh api repos/<owner>/<repo>/collaborators --paginate -q '.[] | select(.permissions.push) | .login' gh api repos/<owner>/<repo>/codeowners/errors ``` FILE:references/checks-and-codes.md # 检查和发现代码 | 代码 | 严重性 | 检查 | 典型修复 | |---|---|---|---| | CO01 | 高 | 所有者不是 `@user`、`@org/team` 或电子邮件(例如 `omar`);模式无法解析 | 添加 @ / 修复 slug;在此之前整行被跳过 | | CO02 | 高 | 用户不在你的用户列表中,团队不在你的团队列表中,或团队来自其他组织(仅使用 `--owners`);第二个 CO02 行说明当一行没有留下有效所有者时,其文件没有代码所有者 | 修复 slug(检查器建议相近的团队),授予团队写权限,或移除所有者 | | CO03 | 高:`require_owner` 路径下的无主文件;中:其他没有规则或只有未知所有者的文件(明确无主的文件是 CO10) | 覆盖率 | 添加规则,或添加兜底 `*` 默认值 | | CO04 | 中 | 模式不匹配列表中的任何文件 | 路径被重命名或删除:更新或移除该行 | | CO05 | 中 | 规则匹配的每个文件都由后续行拥有(最后匹配生效) | 将特定规则移到通用规则之下 | | CO06 | 中:`!` 否定、`[ ]` 范围(被 GitHub 忽略);低:GitLab 部分标题 | 不支持的语法 | 使用更具体的后续规则;空所有者列表 = 无主 | | CO07 | 中:一个人(不是团队)是超过 `max_individual_share` 文件的有效所有者;低:由单一个人且没有团队拥有的文件 | 巴士因子 | 添加团队或第二个所有者 | | CO08 | 低 | 相同模式出现两次;只有后一行有效 | 将所有者合并到一行 | | CO09 | 中 | 团队在你的团队列表中没有成员 | 添加成员或使用另一个团队 | | CO10 | 信息 | 没有所有者的模式:文件有意无主 | 如果有意则没问题(生成的代码、供应商文件) | | CO11 | 中 | `require_owner` 路径下的文件仅由兜底 `*` 规则拥有 | 为该路径添加特定规则 | ## 判定 - **高严重性发现**:在依赖“要求代码所有者审查”之前修复。 - **仅中严重性**:文件有效,但某些文件归错误的人所有。 - **健康**:只有低/信息。 ## 所有者文件(`--owners`,JSON) | 键 | 含义 | |---|---| | `org` | 组织名称;来自其他组织的团队被标记 | | `users` | 有写权限的登录名(不带 @) | | `teams` | `{"org/slug": [members]}` | | `require_owner` | 必须有特定所有者的路径的模式(相同语法) | | `max_individual_share` | 一个人可以单独拥有的文件份额(默认 0.4) | | `ignore` | 要排除的文件的模式(供应商代码、锁文件) | ## 检查的限制 - 不检查仓库权限和团队可见性 - 使用语法参考中的 `gh api` 调用或“CODEOWNERS errors”横幅确认。 - 文件列表是一个分支的快照;针对拉取请求的基础分支运行它。 FILE:templates/ownership-review.md # CODEOWNERS 修复:<repository> **分支:** <base branch> **检查日期:** <YYYY-MM-DD> **文件数:** <n> **修复前/后拥有率:** <x% / y%> ## 问题所在 | 行 | 规则 | 问题 | 影响 | |---|---|---|---| | <11> | `<pattern> <owners>` | <未知团队 @org/frontend-team> | <web 文件回退到 @org/platform> | | <6> | `<pattern>` | <被第 7 行覆盖> | <支付团队永远不会审查计费> | ## 更改 ```diff <- /services/api/billing/ @org/payments < /services/api/ @org/backend <+ /services/api/billing/ @org/payments> ``` ## 关键路径现在归谁所有 | 路径 | 修复前 | 修复后 | |---|---|---| | <services/api/billing/invoices.py> | <@org/backend(第 7 行)> | <@org/payments(第 8 行)> | | <infra/terraform/modules/vpc/main.tf> | <@org/platform(第 3 行,兜底)> | <@org/sre> | ## 需要管理员处理 - [ ] <授予 @org/frontend 写权限 / 向 @org/docs 添加成员> - [ ] <合并后在 <branch> 上启用“要求代码所有者审查”> ## 检查 `python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json` -> <0 高,n 中(解释其余)> *匹配遵循 GitHub 的 CODEOWNERS 规则;仓库权限已使用 <gh api / 设置页面 | 未检查> 检查。* FILE:examples/CODEOWNERS # tidyq/tidyq-app 的 CODEOWNERS(复制到 .github/CODEOWNERS) # 以下未匹配的所有内容的默认所有者 * @tidyq/platform # 后端 /services/api/billing/ @tidyq/payments /services/api/ @tidyq/backend /services/worker/ @tidyq/backend @marta-k # 前端 /web/ @tidyq/frontend-team /web/src/design-system/ @lena-s *.css @tidyq/frontend # 基础设施 /infra/terraform/* @tidyq/sre /infra/k8s/ @tidyq/sre !/infra/k8s/sandbox/ # CI 和仓库设置 /.github/workflows/ @tidyq/sre /.github/CODEOWNERS @tidyq/platform-leads # 文档 [Documentation] docs/* @tidyq/docs /docs/ @tidyq/docs /docs/api/ omar # 旧移动应用(2026 年移至 tidyq/tidyq-mobile) /mobile/ @tidyq/mobile # 数据 /data/migrations/ @jonas-b /data/migrations/ @jonas-b @tidyq/backend /data/fixtures/[a-z]* @tidyq/qa # 生成的客户端:有意无主 /web/src/generated/ FILE:examples/files.txt .github/CODEOWNERS .github/dependabot.yml .github/workflows/ci.yml .github/workflows/deploy.yml .github/workflows/release.yml README.md package.json docs/index.md docs/getting-started.md docs/api/endpoints.md docs/api/auth.md docs/runbooks/oncall.md infra/terraform/main.tf infra/terraform/variables.tf infra/terraform/modules/vpc/main.tf infra/terraform/modules/rds/main.tf infra/k8s/api-deployment.yaml infra/k8s/worker-deployment.yaml infra/k8s/sandbox/debug-pod.yaml services/api/app.py services/api/routes/users.py services/api/routes/orders.py services/api/billing/invoices.py services/api/billing/stripe_webhook.py services/api/tests/test_routes.py services/worker/main.py services/worker/jobs/email.py services/worker/jobs/reports.py web/package.json web/src/App.tsx web/src/pages/Home.tsx web/src/pages/Checkout.tsx web/src/styles/main.css web/src/design-system/Button.tsx web/src/design-system/Modal.tsx web/src/design-system/tokens.css web/src/generated/api-client.ts data/migrations/0041_add_orders.sql data/migrations/0042_add_invoices.sql data/fixtures/users.json data/fixtures/orders.json scripts/seed_db.py scripts/rotate_keys.sh FILE:examples/owners.json { "org": "tidyq", "users": ["marta-k", "lena-s", "jonas-b", "omar-h", "priya-n", "sam-t"], "teams": { "tidyq/platform": ["priya-n", "sam-t"], "tidyq/platform-leads": ["priya-n"], "tidyq/backend": ["marta-k", "jonas-b", "omar-h"], "tidyq/payments": ["marta-k"], "tidyq/frontend": ["lena-s", "sam-t"], "tidyq/sre": ["priya-n", "sam-t"], "tidyq/docs": [], "tidyq/qa": ["omar-h"] }, "require_owner": [".github/", "infra/", "services/api/billing/", "data/migrations/"], "max_individual_share": 0.25, "ignore": [] } FILE:examples/ownership-report.md # CODEOWNERS 检查:examples/CODEOWNERS 20 条规则行,43 个文件,38 个已拥有(88.4%),5 个无主。 **判定:4 个高严重性发现 - 在依赖必需审查之前修复 CODEOWNERS** 发现:4 高,9 中,3 低,1 信息。 所有者全部未知的行(它们仍然匹配,因此其文件没有代码所有者):11,31。未知所有者从下面的所有者列中省略。 ## 发现 - **高 CO01** 第 28 行:无效所有者 'omar'(使用 @user、@org/team 或电子邮件)- GitHub 将整行标记为错误并跳过 - **高 CO02** 第 11 行:未知团队 @tidyq/frontend-team(你的意思是 @tidyq/frontend 吗?)- GitHub 不会向其请求审查 - **高 CO02** 第 11 行:没有留下有效所有者 - 其 4 个文件没有代码所有者(它们不会回退到较早的行) - **高 CO02** 第 31 行:未知团队 @tidyq/mobile - GitHub 不会向其请求审查 - **中 CO03** 4 个文件没有代码所有者(没有匹配的规则,或只有未知所有者):web/package.json、web/src/App.tsx、web/src/pages/Home.tsx、web/src/pages/Checkout.tsx - **中 CO04** 第 31 行:'/mobile/' 不匹配任何文件 - 路径被重命名或删除? - **中 CO05** 第 6 行:'/services/api/billing/'(2 个文件)被后续行 7 完全覆盖 - 最后匹配生效,将特定规则放在通用规则之后 - **中 CO05** 第 26 行:'docs/*'(2 个文件)被后续行 27 完全覆盖 - 最后匹配生效,将特定规则放在通用规则之后 - **中 CO05** 第 34 行:'/data/migrations/'(2 个文件)被后续行 35 完全覆盖 - 最后匹配生效,将特定规则放在通用规则之后 - **中 CO06** 第 18 行:'!/infra/k8s/sandbox/' 使用 ! 否定,CODEOWNERS 不支持 - GitHub 忽略该行 - **中 CO06** 第 36 行:'/data/fixtures/[a-z]*' 使用 [ ] 字符范围,CODEOWNERS 不支持 - GitHub 忽略该行 - **中 CO09** 第 26 行:团队 @tidyq/docs 没有成员 - 审查请求无处可去 - **中 CO11** 3 个必须拥有所有者的路径下的文件仅回退到兜底 '*' 规则(没有特定所有者):.github/dependabot.yml、infra/terraform/modules/vpc/main.tf、infra/terraform/modules/rds/main.tf - **低 CO06** 第 25 行:'[Documentation]' 是 GitLab 部分标题;GitHub 将其视为无效行 - **低 CO07** 2 个文件有单个个人所有者且没有团队(例如 web/src/design-system/Button.tsx、web/src/design-system/Modal.tsx) - **低 CO08** 第 35 行重复了第 34 行的模式 '/data/migrations/' - 只有后一行有效 - **信息 CO10** 第 39 行:'/web/src/generated/' 没有所有者 - 其 1 个文件有意无主 ## 按顶级目录的覆盖率 | 目录 | 文件数 | 已拥有 | 无主 | 有效所有者 | |---|---|---|---|---| | (根) | 2 | 2 | 0 | @tidyq/platform(2) | | .github/ | 5 | 5 | 0 | @tidyq/sre(3)、@tidyq/platform-leads(1)、@tidyq/platform(1) | | data/ | 4 | 4 | 0 | @jonas-b(2)、@tidyq/backend(2)、@tidyq/platform(2) | | docs/ | 5 | 5 | 0 | @tidyq/docs(5) | | infra/ | 7 | 7 | 0 | @tidyq/sre(5)、@tidyq/platform(2) | | scripts/ | 2 | 2 | 0 | @tidyq/platform(2) | | services/ | 9 | 9 | 0 | @tidyq/backend(9)、@marta-k(3) | | web/ | 9 | 4 | 5 | @tidyq/frontend(2)、@lena-s(2) | ## 规则 | 行 | 模式 | 所有者 | 匹配的文件数 | 有效于 | 备注 | |---|---|---|---|---|---| | 3 | `*` | @tidyq/platform | 43 | 9 | | | 6 | `/services/api/billing/` | @tidyq/payments | 2 | 0 | CO05 | | 7 | `/services/api/` | @tidyq/backend | 6 | 6 | | | 8 | `/services/worker/` | @tidyq/backend @marta-k | 3 | 3 | | | 11 | `/web/` | @tidyq/frontend-team | 9 | 4 | CO02 | | 12 | `/web/src/design-system/` | @lena-s | 3 | 2 | | | 13 | `*.css` | @tidyq/frontend | 2 | 2 | | | 16 | `/infra/terraform/*` | @tidyq/sre | 2 | 2 | | | 17 | `/infra/k8s/` | @tidyq/sre | 3 | 3 | | | 18 | `!/infra/k8s/sandbox/` | (无) | - | - | CO06 | | 21 | `/.github/workflows/` | @tidyq/sre | 3 | 3 | | | 22 | `/.github/CODEOWNERS` | @tidyq/platform-leads | 1 | 1 | | | 26 | `docs/*` | @tidyq/docs | 2 | 0 | CO05、CO09 | | 27 | `/docs/` | @tidyq/docs | 5 | 5 | | | 28 | `/docs/api/` | omar | - | - | CO01 | | 31 | `/mobile/` | @tidyq/mobile | 0 | 0 | CO02、CO04 | | 34 | `/data/migrations/` | @jonas-b | 2 | 0 | CO05 | | 35 | `/data/migrations/` | @jonas-b @tidyq/backend | 2 | 2 | CO08 | | 36 | `/data/fixtures/[a-z]*` | @tidyq/qa | - | - | CO06 | | 39 | `/web/src/generated/` | (无) | 1 | 1 | CO10 | ## 所有者负载(有效) | 所有者 | 文件数 | 份额 | |---|---|---| | @tidyq/backend | 11 | 26% | | @tidyq/platform | 9 | 21% | | @tidyq/sre | 8 | 19% | | @tidyq/docs | 5 | 12% | | @marta-k | 3 | 7% | | @tidyq/frontend | 2 | 5% | | @lena-s | 2 | 5% | | @jonas-b | 2 | 5% | | @tidyq/platform-leads | 1 | 2% | ## 无主文件 - `web/package.json`(第 11 行:只有未知所有者) - `web/src/App.tsx`(第 11 行:只有未知所有者) - `web/src/pages/Home.tsx`(第 11 行:只有未知所有者) - `web/src/pages/Checkout.tsx`(第 11 行:只有未知所有者) - `web/src/generated/api-client.ts`(明确无主,第 39 行) ## 谁拥有 - services/api/billing/invoices.py:@tidyq/backend(第 7 行:/services/api/) - infra/terraform/modules/vpc/main.tf:@tidyq/platform(第 3 行:*) - web/src/pages/Checkout.tsx:无有效所有者(第 11 行:/web/ @tidyq/frontend-team - 未知) 匹配遵循 GitHub CODEOWNERS 规则(gitignore 风格模式,最后匹配的行生效,不支持 `!` 和 `[ ]`)。所有者仅根据你的所有者文件检查;请在仓库设置中确认写权限。 FILE:scripts/codeowners_check.py #!/usr/bin/env python3 """CODEOWNERS 覆盖率和所有权检查器(仅标准库,Python 3.8+)。 读取 GitHub CODEOWNERS 文件和仓库文件列表(`git ls-files` 输出),使用 GitHub 的规则(gitignore 风格模式,最后匹配的行生效)计算每个文件的有效所有者,并报告语法错误、未知所有者、无主文件(也在必须有所有者的路径下)、不匹配任何内容的规则、被后续行完全覆盖的规则、不支持的语法、重复项、空团队和所有者集中度。 用法: codeowners_check.py CODEOWNERS FILES.txt [--owners OWNERS.json] [--who PATH]... [--json] codeowners_check.py --self-test 退出:0 = 没有高严重性发现,1 = 有高严重性发现,2 = 输入错误。 """ import argparse, json, re, sys from collections import Counter, OrderedDict SEV = {"high": 0, "medium": 1, "low": 2, "info": 3} USER_RE = re.compile(r"^@[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$") TEAM_RE = re.compile(r"^@([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") class InputError(Exception): pass # ---------------------------------------------------------------- 模式 def pattern_regex(pat): """返回(编译的正则表达式,仅目录,仅直接子项)用于 CODEOWNERS 模式。""" dir_only = pat.endswith("/") p = pat.rstrip("/") anchored = p.startswith("/") or "/" in p p = p.lstrip("/") direct_only = p.endswith("/*") out, i = "", 0 while i < len(p): if p.startswith("**/", i): out += "(?:.*/)?"; i += 3 elif p.startswith("/**", i) and i + 3 == len(p): out += "/.*"; i += 3 elif p.startswith("**", i): out += ".*"; i += 2 elif p[i] == "*": out += "[^/]*"; i += 1 elif p[i] == "?": out += "[^/]"; i += 1 elif p[i] == "\\" and i + 1 < len(p): out += re.escape(p[i + 1]); i += 2 else: out += re.escape(p[i]); i += 1 if not anchored: out = "(?:.*/)?" + out return re.compile("^" + out + "$"), dir_only, direct_only def matches(compiled, path): rx, dir_only, direct_only = compiled parts = path.split("/") if not dir_only and rx.match(path): return True if direct_only: return False for k in range(1, len(parts)): # 匹配的目录拥有其下所有内容 if rx.match("/".join(parts[:k])): return True return False # ---------------------------------------------------------------- 解析 def parse_codeowners(text): rules, findings = [], [] for n, raw in enumerate(text.splitlines(), 1): line = raw.strip() if not line or line.startswith("#"): continue if re.match(r"^\^?\[[^\]]+\](\[\d+\])?(\s+@.*)?$", line): findings.append(("low", "CO06", "第 %d 行:'%s' 是 GitLab 部分标题;GitHub 将其视为无效行" % (n, line), n)) continue tokens = re.split(r"(?<!\\)\s+", line) pat, owners = tokens[0], [] for t in tokens[1:]: if t.startswith("#"): break owners.append(t) rule = {"line": n, "pattern": pat, "owners": owners, "valid": True} if pat.startswith("!"): findings.append(("medium", "CO06", "第 %d 行:'%s' 使用 ! 否定,CODEOWNERS 不支持 - GitHub 忽略该行" % (n, pat), n)) rule["valid"] = False elif "[" in pat or "]" in pat: findings.append(("medium", "CO06", "第 %d 行:'%s' 使用 [ ] 字符范围,CODEOWNERS 不支持 - GitHub 忽略该行" % (n, pat), n)) rule["valid"] = False bad = [o for o in owners if not (USER_RE.match(o) or TEAM_RE.match(o) or EMAIL_RE.match(o))] if bad: findings.append(("high", "CO01", "第 %d 行:无效所有者 %s(使用 @user、@org/team 或电子邮件)- GitHub 将整行标记为错误并跳过" % (n, ", ".join("'%s'" % b for b in bad)), n)) rule["valid"] = False if rule["valid"]: try: rule["rx"] = pattern_regex(pat) except re.error as e: findings.append(("high", "CO01", "第 %d 行:模式 '%s' 无法解析(%s)" % (n, pat, e), n)) rule["valid"] = False rules.append(rule) return rules, findings def load_files(text): files = [] for l in text.splitlines(): l = l.strip() if l and not l.startswith("#"): files.append(l[2:] if l.startswith("./") else l.lstrip("/")) if not files: raise InputError("文件列表为空(使用:git ls-files > files.txt)") return files # ---------------------------------------------------------------- 分析 def analyse(co_text, files_text, owners_cfg=None): owners_cfg = owners_cfg or {} allowed_keys = {"org", "users", "teams", "require_owner", "max_individual_share", "ignore"} unknown = set(owners_cfg) - allowed_keys if unknown: raise InputError("所有者文件中的未知键:%s" % ", ".join(sorted(unknown))) rules, findings = parse_codeowners(co_text) if not rules: raise InputError("CODEOWNERS 没有规则") files = load_files(files_text) ignore = [pattern_regex(p) for p in owners_cfg.get("ignore", [])] files = [f for f in files if not any(matches(c, f) for c in ignore)] valid = [r for r in rules if r["valid"]] eff, matched_by = {}, {r["line"]: [] for r in valid} for f in files: last = None for r in valid: if matches(r["rx"], f): matched_by[r["line"]].append(f) last = r eff[f] = last effective = Counter(r["line"] for r in eff.values() if r) # 已知所有者 org = owners_cfg.get("org") users = {u.lower().lstrip("@") for u in owners_cfg.get("users", [])} teams = {k.lower().lstrip("@"): v for k, v in owners_cfg.get("teams", {}).items()} have_list = bool(users or teams) reported, unknown_owners = set(), set() for r in valid: for o in r["owners"]: key = o.lower() m = TEAM_RE.match(o) problem = None if m and have_list: name = (m.group(1) + "/" + m.group(2)).lower() if org and m.group(1).lower() != org.lower(): problem = "团队 %s 不在组织 @%s 中" % (o, org) elif name not in teams: close = [t for t in teams if t.split("/")[-1].startswith(m.group(2).lower()[:4])] problem = "未知团队 %s%s" % (o, ("(你的意思是 @%s 吗?)" % close[0]) if close else "") elif not teams[name] and key not in reported: findings.append(("medium", "CO09", "第 %d 行:团队 %s 没有成员 - 审查请求无处可去" % (r["line"], o), r["line"])); reported.add(key) elif USER_RE.match(o) and have_list and key.lstrip("@") not in users: problem = "未知用户 %s(不在所有者列表中 / 没有写权限)" % o if problem: unknown_owners.add(key) if key not in reported: findings.append(("high", "CO02", "第 %d 行:%s - GitHub 不会向其请求审查" % (r["line"], problem), r["line"])); reported.add(key) for r in rules: r["ok"] = [o for o in r["owners"] if o.lower() not in unknown_owners] bad_lines = sorted(r["line"] for r in valid if r["owners"] and not r["ok"]) for ln in [x for x in bad_lines if effective[x]]: findings.append(("high", "CO02", "第 %d 行:没有留下有效所有者 - 其 %d 个文件没有代码所有者(它们不会回退到较早的行)" % (ln, effective[ln]), ln)) # 无效、被覆盖、重复规则 seen = {} for r in valid: if r["pattern"] in seen: findings.append(("low", "CO08", "第 %d 行重复了第 %d 行的模式 '%s' - 只有后一行有效" % (r["line"], seen[r["pattern"]], r["pattern"]), r["line"])) seen[r["pattern"]] = r["line"] if not matched_by[r["line"]]: findings.append(("medium", "CO04", "第 %d 行:'%s' 不匹配任何文件 - 路径被重命名或删除?" % (r["line"], r["pattern"]), r["line"])) elif effective[r["line"]] == 0: later = sorted({eff[f]["line"] for f in matched_by[r["line"]] if eff[f]}) findings.append(("medium", "CO05", "第 %d 行:'%s'(%d 个文件)被后续行 %s 完全覆盖 - 最后匹配生效,将特定规则放在通用规则之后" % (r["line"], r["pattern"], len(matched_by[r["line"]]), ", ".join(map(str, later))), r["line"])) if not r["owners"]: findings.append(("info", "CO10", "第 %d 行:'%s' 没有所有者 - 其 %d 个文件有意无主" % (r["line"], r["pattern"], effective[r["line"]]), r["line"])) # 覆盖率 unowned = [f for f in files if not eff[f] or not eff[f]["ok"]] no_rule = [f for f in unowned if not eff[f]] req = [(p, pattern_regex(p)) for p in owners_cfg.get("require_owner", [])] crit = [f for f in unowned if any(matches(c, f) for _, c in req)] if crit: findings.append(("high", "CO03", "%d 个必须拥有所有者的路径下的文件无主:%s" % (len(crit), ", ".join(crit[:6]) + (" ..." if len(crit) > 6 else "")), 0)) catch_all = [f for f in files if eff[f] and eff[f]["ok"] and eff[f]["pattern"] in ("*", "/*", "**", "/**") and any(matches(c, f) for _, c in req)] if catch_all: findings.append(("medium", "CO11", "%d 个必须拥有所有者的路径下的文件仅回退到兜底 '%s' 规则(没有特定所有者):%s" % (len(catch_all), eff[catch_all[0]]["pattern"], ", ".join(catch_all[:6]) + (" ..." if len(catch_all) > 6 else "")), 0)) rest = [f for f in unowned if f not in crit and (not eff[f] or eff[f]["owners"])] if rest: findings.append(("medium", "CO03", "%d 个文件没有代码所有者(没有匹配的规则,或只有未知所有者):%s" % (len(rest), ", ".join(rest[:6]) + (" ..." if len(rest) > 6 else "")), 0)) # 所有者负载 load = Counter() for f in files: if eff[f]: for o in eff[f]["ok"]: load[o] += 1 share = float(owners_cfg.get("max_individual_share", 0.4)) for o, n in load.most_common(): if USER_RE.match(o) and n / len(files) > share: findings.append(("medium", "CO07", "%s 作为个人拥有 %d 个文件中的 %d 个(%.0f%%)- 巴士因子;添加团队或第二个所有者" % (o, n, len(files), 100.0 * n / len(files)), 0)) solo = [f for f in files if eff[f] and len(eff[f]["ok"]) == 1 and USER_RE.match(eff[f]["ok"][0])] if solo: findings.append(("low", "CO07", "%d 个文件有单个个人所有者且没有团队(例如 %s)" % (len(solo), ", ".join(solo[:3])), 0)) findings.sort(key=lambda f: (SEV[f[0]], f[1], f[3])) return {"rules": rules, "files": files, "eff": eff, "matched_by": matched_by, "effective": effective, "unowned": unowned, "load": load, "findings": findings, "no_valid_owner_lines": bad_lines} def who(res, path): r = res["eff"].get(path) if path not in res["eff"]: rx_hits = [x for x in res["rules"] if x["valid"] and matches(x["rx"], path)] r = rx_hits[-1] if rx_hits else None if not r: return "%s:无所有者(没有匹配的规则)" % path if r["owners"] and not r["ok"]: return "%s:无有效所有者(第 %d 行:%s %s - 未知)" % (path, r["line"], r["pattern"], " ".join(r["owners"])) return "%s:%s(第 %d 行:%s)" % (path, " ".join(r["ok"]) or "明确无主", r["line"], r["pattern"]) # ---------------------------------------------------------------- 报告 def report(res, title, whos=()): f, files = res["findings"], res["files"] cnt = Counter(x[0] for x in f) owned = len(files) - len(res["unowned"]) L = ["# CODEOWNERS 检查:%s" % title, ""] L.append("%d 条规则行,%d 个文件,%d 个已拥有(%.1f%%),%d 个无主。" % (len(res["rules"]), len(files), owned, 100.0 * owned / len(files), len(res["unowned"]))) L.append("") L.append("**判定:%s**" % ("%d 个高严重性发现 - 在依赖必需审查之前修复 CODEOWNERS" % cnt["high"] if cnt["high"] else ("审查中严重性发现" if cnt["medium"] else "CODEOWNERS 看起来健康"))) L.append("") L.append("发现:%d 高,%d 中,%d 低,%d 信息。" % (cnt["high"], cnt["medium"], cnt["low"], cnt["info"])) L.append("") if res["no_valid_owner_lines"]: L.append("所有者全部未知的行(它们仍然匹配,因此其文件没有代码所有者):%s。未知所有者从下面的所有者列中省略。" % ", ".join(map(str, res["no_valid_owner_lines"]))) L.append("") L += ["## 发现", ""] for s, c, msg, _ in f: L.append("- **%s %s** %s" % (s.upper(), c, msg)) L += ["", "## 按顶级目录的覆盖率", "", "| 目录 | 文件数 | 已拥有 | 无主 | 有效所有者 |", "|---|---|---|---|---|"] dirs = OrderedDict() for p in files: d = p.split("/")[0] + "/" if "/" in p else "(根)" dirs.setdefault(d, []).append(p) for d, ps in sorted(dirs.items()): un = [p for p in ps if p in res["unowned"]] own = Counter(o for p in ps if res["eff"][p] for o in res["eff"][p]["ok"]) L.append("| %s | %d | %d | %d | %s |" % (d, len(ps), len(ps) - len(un), len(un), ", ".join("%s(%d)" % kv for kv in own.most_common()) or "-")) L += ["", "## 规则", "", "| 行 | 模式 | 所有者 | 匹配的文件数 | 有效于 | 备注 |", "|---|---|---|---|---|---|"] notes = {} for s, c, msg, ln in f: if ln: notes.setdefault(ln, []).append(c) for r in res["rules"]: m = len(res["matched_by"].get(r["line"], [])) if r["valid"] else "-" e = res["effective"].get(r["line"], 0) if r["valid"] else "-" L.append("| %d | `%s` | %s | %s | %s | %s |" % (r["line"], r["pattern"], " ".join(r["owners"]) or "(无)", m, e, ", ".join(sorted(set(notes.get(r["line"], [])))) or "")) L += ["", "## 所有者负载(有效)", "", "| 所有者 | 文件数 | 份额 |", "|---|---|---|"] for o, n in res["load"].most_common(): L.append("| %s | %d | %.0f%% |" % (o, n, 100.0 * n / len(files))) if res["unowned"]: L += ["", "## 无主文件", ""] for p in res["unowned"]: e = res["eff"][p] L.append("- `%s`%s" % (p, "" if not e else ("(明确无主,第 %d 行)" % e["line"] if not e["owners"] else "(第 %d 行:只有未知所有者)" % e["line"]))) if whos: L += ["", "## 谁拥有", ""] for p in whos: L.append("- " + who(res, p)) L += ["", "匹配遵循 GitHub CODEOWNERS 规则(gitignore 风格模式,最后匹配的行生效,不支持 `!` 和 `[ ]`)。所有者仅根据你的所有者文件检查;请在仓库设置中确认写权限。"] return "\n".join(L) + "\n" def to_json(res): return {"files": len(res["files"]), "unowned": res["unowned"], "no_valid_owner_lines": res["no_valid_owner_lines"], "owners": {p: (res["eff"][p]["ok"] if res["eff"][p] else []) for p in res["files"]}, "rules": [{"line": r["line"], "pattern": r["pattern"], "owners": r["owners"], "valid": r["valid"], "matched": len(res["matched_by"].get(r["line"], [])), "effective": res["effective"].get(r["line"], 0)} for r in res["rules"]], "load": dict(res["load"]), "findings": [{"severity": s, "code": c, "message": m, "line": ln} for s, c, m, ln in res["findings"]]} # ---------------------------------------------------------------- 自测 def self_test(): tests = [] def t(name, cond): tests.append((name, bool(cond))) def owner(co, f, path, cfg=None): r = analyse(co, f, cfg) e = r["eff"][path] return e["ok"] if e else None def codes(co, f, cfg=None): return [(x[0], x[1]) for x in analyse(co, f, cfg)["findings"]] F = "README.md\nsrc/app.py\nsrc/api/routes.py\nsrc/api/v2/users.py\ndocs/index.md\ndocs/guide/setup.md\nweb/app.js\nweb/lib/util.js\n.github/workflows/ci.yml\n" t("* 匹配所有内容", owner("* @a", F, "src/api/v2/users.py") == ["@a"]) t("最后匹配的行生效", owner("* @a\n/src/ @b", F, "src/app.py") == ["@b"] and owner("* @a\n/src/ @b", F, "README.md") == ["@a"]) t("*.js 在任意深度匹配", owner("*.js @js", F, "web/lib/util.js") == ["@js"]) t("/docs/ 拥有嵌套文件", owner("/docs/ @d", F, "docs/guide/setup.md") == ["@d"]) t("docs/* 不拥有嵌套文件", owner("docs/* @d", F, "docs/guide/setup.md") is None and owner("docs/* @d", F, "docs/index.md") == ["@d"]) t("非锚定目录名 'api/' 在任意位置匹配", owner("api/ @x", F, "src/api/v2/users.py") == ["@x"]) t("中间的 **", owner("/src/**/users.py @u", F, "src/api/v2/users.py") == ["@u"]) t("锚定的 /README.md 仅在根目录", owner("/README.md @r", "README.md\nsub/README.md\n", "sub/README.md") is None) t("? 匹配一个字符", owner("src/ap?.py @q", F, "src/app.py") == ["@q"]) t("没有所有者 = 明确无主", owner("* @a\n/web/", F, "web/app.js") == [] and ("info", "CO10") in codes("* @a\n/web/", F)) t("无效所有者 -> CO01 高,行被跳过", ("high", "CO01") in codes("* @a\n/src/ alice", F) and owner("* @a\n/src/ alice", F, "src/app.py") == ["@a"]) t("否定 -> CO06 中", ("medium", "CO06") in codes("* @a\n!/src/", F)) t("[ ] 范围 -> CO06 中", ("medium", "CO06") in codes("* @a\n/src/[ab]* @b", F)) t("GitLab 部分 -> CO06 低", ("low", "CO06") in codes("[Docs]\n* @a", F)) cfg = {"org": "acme", "users": ["a", "b"], "teams": {"acme/web": ["a"], "acme/empty": []}} t("未知团队 -> CO02 高,文件没有所有者(无回退)", ("high", "CO02") in codes("* @a\n/web/ @acme/webb", F, cfg) and owner("* @a\n/web/ @acme/webb", F, "web/app.js", cfg) == [] and ("medium", "CO03") in codes("* @a\n/web/ @acme/webb", F, cfg)) t("未知所有者被丢弃,有效共同所有者保留", owner("* @a\n/web/ @acme/webb @b", F, "web/app.js", cfg) == ["@b"]) t("其他组织的团队 -> CO02", ("high", "CO02") in codes("* @other/web", F, cfg)) t("未知用户 -> CO02", ("high", "CO02") in codes("* @zed", F, cfg)) t("空团队 -> CO09", ("medium", "CO09") in codes("* @a\n/web/ @acme/empty", F, cfg)) t("电子邮件所有者被接受", ("high", "CO01") not in codes("* dev@acme.io", F)) t("无效规则 -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @b", F)) t("被覆盖的规则 -> CO05", ("medium", "CO05") in codes("/src/api/ @b\n/src/ @a", F)) t("部分覆盖不是 CO05", ("medium", "CO05") not in codes("/src/ @a\n/src/api/ @b", F)) t("重复模式 -> CO08", ("low", "CO08") in codes("* @a\n/src/ @b\n/src/ @c", F)) t("无主文件 -> CO03 中", ("medium", "CO03") in codes("/src/ @a", F)) t("require_owner 路径无主 -> CO03 高", ("high", "CO03") in codes("/src/ @acme/web", F, dict(cfg, require_owner=[".github/"]))) t("必需路径仅由 '*' 拥有 -> CO11", ("medium", "CO11") in codes("* @a\n/src/ @b", F, {"require_owner": [".github/"]})) t("具有未知所有者的无效规则仍然 -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @acme/zz", F, cfg)) t("个人所有者集中度 -> CO07", ("medium", "CO07") in codes("* @a", F, {"max_individual_share": 0.5})) t("忽略列表移除文件", len(analyse("* @a", F, {"ignore": ["docs/"]})["files"]) == 7) r = analyse("* @a\n/src/ @b", F) t("--who 报告行", who(r, "src/api/routes.py") == "src/api/routes.py:@b(第 2 行:/src/)") t("./ 前缀被剥离,.github 保留", analyse("* @a", "./.github/x.yml\n")["files"] == [".github/x.yml"]) t("报告有判定和覆盖率表", "**判定:" in report(r, "t") and "## 按顶级目录的覆盖率" in report(r, "t")) try: analyse("* @a", F, {"orgs": "x"}); ok = False except InputError: ok = True t("未知的所有者文件键被拒绝", ok) for name, ok in tests: print(("PASS " if ok else "FAIL ") + name) passed = sum(ok for _, ok in tests) print("%d/%d 通过" % (passed, len(tests))) return 0 if passed == len(tests) else 1 def main(argv=None): ap = argparse.ArgumentParser(description="检查 GitHub CODEOWNERS 文件与仓库文件列表。") ap.add_argument("codeowners", nargs="?") ap.add_argument("files", nargs="?", help="文件列表,例如 git ls-files > files.txt") ap.add_argument("--owners", help="包含 org、users、teams、require_owner、max_individual_share、ignore 的 JSON") ap.add_argument("--who", action="append", default=[], help="显示路径的有效所有者(可重复)") ap.add_argument("--json", action="store_true") ap.add_argument("--self-test", action="store_true") a = ap.parse_args(argv) if a.self_test: return self_test() if not a.codeowners or not a.files: ap.print_usage(sys.stderr) return 2 try: co = open(a.codeowners, encoding="utf-8").read() fl = open(a.files, encoding="utf-8").read() cfg = None if a.owners: try: cfg = json.load(open(a.owners, encoding="utf-8")) except json.JSONDecodeError as e: raise InputError("所有者文件不是有效的 JSON:%s" % e) res = analyse(co, fl, cfg) except (OSError, InputError) as e: print("错误:%s" % e, file=sys.stderr) return 2 if a.json: print(json.dumps(to_json(res), indent=2)) else: sys.stdout.write(report(res, a.codeowners, a.who)) return 1 if any(x[0] == "high" for x in res["findings"]) else 0 if __name__ == "__main__": sys.exit(main())

登录以查看完整提示词

继续使用:

登录即表示你同意我们的 使用条款 和 隐私政策

用法

此提示词专为 coding 设计。复制上方内容并粘贴到你常用的 AI 工具中。

为获得最佳效果,可将占位符(方括号或大写字母标示)替换为你的具体需求。

参考资料

分类:coding| prompts.chat| codeowners| github

讨论