토론

코드오너스 커버리지 및 소유권 체커

Wikiprompt, 무료 프롬프트 백과사전에서

Fatih Kadir Akın

2026년 11월 4일

코드오너스 커버리지 및 소유권 체커 GitHub CODEOWNERS 파일의 커버리지, 잘못된 소유자, 순서 오류를 감사하는 완전한 스킬로, 작동하는 Python 체커와 템플릿을 포함합니다. 엔지니어링 플랫폼 팀에 적합합니다.

프롬프트 내용저장

🌐
--- name: codeowners-coverage-and-ownership-checker description: GitHub CODEOWNERS 파일을 실제 저장소 파일 목록과 대조하여 확인합니다 - 모든 파일의 소유자를 계산하고(마지막으로 일치하는 줄이 우선), 잘못되었거나 알 수 없는 소유자, 소유자가 없는 파일, 포괄 규칙에만 의존하는 중요 경로, 어떤 것과도 일치하지 않거나 이후 줄에 완전히 덮어쓰여진 규칙, 지원되지 않는 ! 및 [ ] 구문, 중복, 빈 팀 및 1인 병목 현상을 보고합니다. 오프라인 stdlib Python 검사기를 포함합니다. --- # CODEOWNERS 적용 범위 및 소유권 검사기 엔지니어링 팀이 CODEOWNERS 파일을 신뢰할 수 있도록 돕습니다. 필수 리뷰 및 자동 지정 리뷰어는 모든 줄이 유효하고, 모든 소유자가 존재하며 액세스 권한이 있고, 규칙이 올바른 순서로 있을 때만 작동합니다. 실제로 파일은 조용히 소유자를 잃습니다: 팀 이름이 변경되어 해당 파일에 리뷰어가 자동으로 지정되지 않거나, 특정 규칙이 일반 규칙 위에 있어 적용되지 않거나, `docs/*`가 `docs/guide/`를 포함하지 않거나, .gitignore에서 복사된 `!` 부정이 아무 효과가 없거나, 결제 코드의 유일한 소유자가 휴가 중인 한 명인 경우입니다. 모든 파일의 실제 소유자를 계산하고 정확히 무엇이 잘못되었는지, 어떻게 수정하는지 보여줍니다. 파일을 GitHub의 문서화된 CODEOWNERS 동작 및 사용자가 제공한 소유자 목록과 대조합니다. 저장소 권한은 볼 수 없으므로 이를 명시하고 사용자에게 저장소 설정 또는 `gh api`를 통해 쓰기 액세스를 확인하도록 요청하세요. ## 이 스킬의 파일 - `scripts/codeowners_check.py` - CODEOWNERS를 파싱하고(주석, 인라인 주석, 이스케이프된 공백), `git ls-files`의 모든 경로를 GitHub의 gitignore 스타일 규칙(앵커 및 플로팅 패턴, `*`, `**`, `?`, 디렉토리 패턴, `dir/*` = 직계 하위만, 마지막 일치 줄이 우선)과 매칭하고, 잘못된 구문이 있는 줄을 건너뛰고 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` - 일반적인 실수(이름 변경된 팀, 일반 규칙 위의 특정 규칙, `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 = 잘못된 입력. 셸이 없으면 `references/codeowners-syntax-and-matching.md`로 각 중요 경로에 대해 규칙을 아래에서 위로 살펴보세요: 아래에서 일치하는 첫 번째 줄이 소유자입니다. ### 3. 결과 읽기 - 높음 우선: CO01 (@ 없는 이름과 같은 잘못된 소유자 형식): GitHub는 전체 줄을 잘못된 구문으로 건너뛰므로 해당 파일은 이전 규칙으로 대체됩니다. CO02 (알 수 없는 사용자 또는 팀, 액세스 없음): 해당 소유자는 요청되지 않습니다. 줄에 유효한 소유자가 남아 있지 않으면 줄은 여전히 일치하며 해당 파일은 코드 소유자가 전혀 없게 됩니다. CO03 높음: 소유해야 하는 경로 아래의 파일에 소유자가 없습니다. - 중간: CO05 (완전히 덮어쓰여진 규칙 - 일반적으로 일반 규칙 위의 특정 경로), CO04 (아무것도 일치하지 않는 규칙 - 이름 변경 또는 삭제된 경로), CO06 (`!` 또는 `[ ]` - 무시됨), CO11 (중요 경로가 `*`로만 덮임), CO09 (빈 팀), CO07 (한 명이 너무 많이 소유), CO03 중간 (규칙이 없는 파일). - 낮음/정보: GitLab 섹션 헤더, 단일 개인 소유권, 중복 패턴, 명시적으로 소유되지 않은 경로 (의도된 경우 괜찮음). - 사람들이 관심을 갖는 경로에 `--who`를 사용하여 수정 전후를 확인하세요. ### 4. 수정 제안 수정된 CODEOWNERS를 작성하세요: 일반 규칙을 먼저, 특정 규칙을 나중에; `!` 제외를 더 구체적인 이후 규칙으로 대체 (빈 소유자 목록은 경로를 의도적으로 소유하지 않게 만듦); 중첩 파일을 포함해야 할 때 `dir/*` 대신 `/dir/` 사용; 팀 슬러그 수정; 1인 경로에 두 번째 소유자 또는 팀 추가; CODEOWNERS 자체에 대한 줄 추가. 높은 결과가 없을 때까지 다시 실행한 다음 `templates/ownership-review.md`를 작성하세요. ### 5. 전달 결과를 평이한 언어로, 수정된 파일(또는 diff), 주요 경로의 전/후 소유자, 그리고 관리자가 필요한 후속 조치(팀 액세스 권한 부여, 빈 팀에 구성원 추가, 파일이 깨끗해지면 브랜치 보호에서 "Code Owners의 리뷰 요구" 활성화)를 반환하세요. ## 예제 결과 예제에서는 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`를 찾고, 풀 리퀘스트가 대상으로 하는 브랜치(기본 브랜치)에서 찾은 첫 번째 파일을 사용합니다. 3MB가 넘는 파일은 무시됩니다. GitHub는 웹 UI에서 파일을 열 때 잘못된 줄에 대한 오류를 표시하고("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`); 패턴을 파싱할 수 없음 | @ 추가 / 슬러그 수정; 그때까지 전체 줄이 건너뜀 | | CO02 | 높음 | 사용자가 사용자 목록에 없음, 팀이 팀 목록에 없음 또는 다른 조직의 팀 (`--owners`로만); 두 번째 CO02 줄은 줄에 유효한 소유자가 남아 있지 않을 때 해당 파일이 코드 소유자를 얻지 못함을 나타냄 | 슬러그 수정 (검사기가 유사한 팀 제안), 팀에 쓰기 액세스 권한 부여 또는 소유자 제거 | | CO03 | 높음: `require_owner` 경로 아래의 소유되지 않은 파일; 중간: 규칙이 없거나 알 수 없는 소유자만 있는 다른 파일 (명시적으로 소유되지 않은 파일은 CO10) | 적용 범위 | 규칙 추가 또는 포괄 `*` 기본값 | | CO04 | 중간 | 패턴이 목록의 어떤 파일과도 일치하지 않음 | 경로 이름 변경 또는 삭제: 줄 업데이트 또는 제거 | | CO05 | 중간 | 규칙이 일치하는 모든 파일이 이후 줄에 의해 소유됨 (마지막 일치 우선) | 특정 규칙을 일반 규칙 아래로 이동 | | CO06 | 중간: `!` 부정, `[ ]` 범위 (GitHub에서 무시됨); 낮음: GitLab 섹션 헤더 | 지원되지 않는 구문 | 더 구체적인 이후 규칙 사용; 빈 소유자 목록 = 소유되지 않음 | | CO07 | 중간: 한 개인(팀 아님)이 `max_individual_share`보다 많은 파일의 실제 소유자; 낮음: 단일 개인이 소유하고 팀이 없는 파일 | 버스 팩터 | 팀 또는 두 번째 소유자 추가 | | CO08 | 낮음 | 같은 패턴이 두 번 나타남; 나중 줄만 계산 | 소유자를 한 줄로 병합 | | CO09 | 중간 | 팀 목록에 구성원이 없는 팀 | 구성원 추가 또는 다른 팀 사용 | | CO10 | 정보 | 소유자가 없는 패턴: 의도적으로 소유되지 않은 파일 | 의도된 경우 괜찮음 (생성 코드, 벤더 파일) | | CO11 | 중간 | `require_owner` 경로 아래의 파일이 포괄 `*` 규칙에 의해서만 소유됨 | 해당 경로에 대한 특정 규칙 추가 | ## 판정 - **높은 결과**: "Code Owners의 리뷰 요구"에 의존하기 전에 수정. - **중간만**: 파일은 작동하지만 일부 파일이 잘못된 사람에게 전달됨. - **건강함**: 낮음/정보만. ## 소유자 파일 (`--owners`, JSON) | 키 | 의미 | |---|---| | `org` | 조직 이름; 다른 조직의 팀은 플래그 지정됨 | | `users` | 쓰기 액세스 권한이 있는 로그인 (@ 없이) | | `teams` | `{"org/slug": [구성원]}` | | `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> | <웹 파일이 @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>에서 "Code Owners의 리뷰 요구" 활성화> ## 확인 `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", "line %d: '%s' is a GitLab section header; GitHub treats it as an invalid line" % (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", "line %d: '%s' uses ! negation, which CODEOWNERS does not support - GitHub ignores the line" % (n, pat), n)) rule["valid"] = False elif "[" in pat or "]" in pat: findings.append(("medium", "CO06", "line %d: '%s' uses [ ] character ranges, which CODEOWNERS does not support - GitHub ignores the line" % (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", "line %d: invalid owner %s (use @user, @org/team or an email) - GitHub flags the whole line as an error and skips it" % (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", "line %d: pattern '%s' cannot be parsed (%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("file list is empty (use: 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("unknown key(s) in owners file: %s" % ", ".join(sorted(unknown))) rules, findings = parse_codeowners(co_text) if not rules: raise InputError("CODEOWNERS has no rules") 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 = "team %s is not in org @%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 = "unknown team %s%s" % (o, (" (did you mean @%s?)" % close[0]) if close else "") elif not teams[name] and key not in reported: findings.append(("medium", "CO09", "line %d: team %s has no members - review requests go nowhere" % (r["line"], o), r["line"])); reported.add(key) elif USER_RE.match(o) and have_list and key.lstrip("@") not in users: problem = "unknown user %s (not in the owners list / no write access)" % o if problem: unknown_owners.add(key) if key not in reported: findings.append(("high", "CO02", "line %d: %s - GitHub will not request reviews from it" % (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", "line %d: no valid owner left - its %d file(s) get no code owner (they do not fall back to earlier lines)" % (ln, effective[ln]), ln)) # 죽은, 덮어써진, 중복 규칙 seen = {} for r in valid: if r["pattern"] in seen: findings.append(("low", "CO08", "line %d repeats the pattern '%s' of line %d - only the later line counts" % (r["line"], r["pattern"], seen[r["pattern"]]), r["line"])) seen[r["pattern"]] = r["line"] if not matched_by[r["line"]]: findings.append(("medium", "CO04", "line %d: '%s' matches no file - path renamed or removed?" % (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", "line %d: '%s' (%d file(s)) is fully overridden by later line(s) %s - last match wins, put specific rules after general ones" % (r["line"], r["pattern"], len(matched_by[r["line"]]), ", ".join(map(str, later))), r["line"])) if not r["owners"]: findings.append(("info", "CO10", "line %d: '%s' has no owners - its %d file(s) are intentionally unowned" % (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 file(s) under paths that must have an owner are unowned: %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 file(s) under paths that must have an owner only fall back to the catch-all '%s' rule (no specific owner): %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 file(s) have no code owner (no matching rule, or only unknown owners): %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 is the owner of %d of %d files (%.0f%%) as an individual - bus factor; add a team or a second owner" % (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 file(s) have a single individual owner and no team (e.g. %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: no owner (no matching rule)" % path if r["owners"] and not r["ok"]: return "%s: no valid owner (line %d: %s %s - unknown)" % (path, r["line"], r["pattern"], " ".join(r["owners"])) return "%s: %s (line %d: %s)" % (path, " ".join(r["ok"]) or "explicitly unowned", 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 check: %s" % title, ""] L.append("%d rule line(s), %d file(s), %d owned (%.1f%%), %d unowned." % (len(res["rules"]), len(files), owned, 100.0 * owned / len(files), len(res["unowned"]))) L.append("") L.append("**Verdict: %s**" % ("%d high finding(s) - fix CODEOWNERS before relying on required reviews" % cnt["high"] if cnt["high"] else ("review the medium findings" if cnt["medium"] else "CODEOWNERS looks healthy"))) L.append("") L.append("Findings: %d high, %d medium, %d low, %d info." % (cnt["high"], cnt["medium"], cnt["low"], cnt["info"])) L.append("") if res["no_valid_owner_lines"]: L.append("Lines whose owners are all unknown (they still match, so their files get no code owner): %s. Unknown owners are left out of the owner columns below." % ", ".join(map(str, res["no_valid_owner_lines"]))) L.append("") L += ["## Findings", ""] for s, c, msg, _ in f: L.append("- **%s %s** %s" % (s.upper(), c, msg)) L += ["", "## Coverage by top-level directory", "", "| Directory | Files | Owned | Unowned | Effective owners |", "|---|---|---|---|---|"] dirs = OrderedDict() for p in files: d = p.split("/")[0] + "/" if "/" in p else "(root)" 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 += ["", "## Rules", "", "| Line | Pattern | Owners | Files matched | Effective for | Note |", "|---|---|---|---|---|---|"] 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 "(none)", m, e, ", ".join(sorted(set(notes.get(r["line"], [])))) or "")) L += ["", "## Owner load (effective)", "", "| Owner | Files | Share |", "|---|---|---|"] for o, n in res["load"].most_common(): L.append("| %s | %d | %.0f%% |" % (o, n, 100.0 * n / len(files))) if res["unowned"]: L += ["", "## Unowned files", ""] for p in res["unowned"]: e = res["eff"][p] L.append("- `%s`%s" % (p, "" if not e else (" (explicitly unowned, line %d)" % e["line"] if not e["owners"] else " (line %d: only unknown owners)" % e["line"]))) if whos: L += ["", "## Who owns", ""] for p in whos: L.append("- " + who(res, p)) L += ["", "Matching follows GitHub CODEOWNERS rules (gitignore-style patterns, last matching line wins, `!` and `[ ]` unsupported). Owners are checked against your owners file only; confirm write access in the repository settings."] 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("* matches everything", owner("* @a", F, "src/api/v2/users.py") == ["@a"]) t("last matching line wins", owner("* @a\n/src/ @b", F, "src/app.py") == ["@b"] and owner("* @a\n/src/ @b", F, "README.md") == ["@a"]) t("*.js matches at any depth", owner("*.js @js", F, "web/lib/util.js") == ["@js"]) t("/docs/ owns nested files", owner("/docs/ @d", F, "docs/guide/setup.md") == ["@d"]) t("docs/* does not own nested files", owner("docs/* @d", F, "docs/guide/setup.md") is None and owner("docs/* @d", F, "docs/index.md") == ["@d"]) t("unanchored dir name 'api/' matches anywhere", owner("api/ @x", F, "src/api/v2/users.py") == ["@x"]) t("** in the middle", owner("/src/**/users.py @u", F, "src/api/v2/users.py") == ["@u"]) t("anchored /README.md only at root", owner("/README.md @r", "README.md\nsub/README.md\n", "sub/README.md") is None) t("? matches one character", owner("src/ap?.py @q", F, "src/app.py") == ["@q"]) t("no owners = explicitly unowned", owner("* @a\n/web/", F, "web/app.js") == [] and ("info", "CO10") in codes("* @a\n/web/", F)) t("invalid owner -> CO01 high, line skipped", ("high", "CO01") in codes("* @a\n/src/ alice", F) and owner("* @a\n/src/ alice", F, "src/app.py") == ["@a"]) t("negation -> CO06 medium", ("medium", "CO06") in codes("* @a\n!/src/", F)) t("[ ] range -> CO06 medium", ("medium", "CO06") in codes("* @a\n/src/[ab]* @b", F)) t("GitLab section -> CO06 low", ("low", "CO06") in codes("[Docs]\n* @a", F)) cfg = {"org": "acme", "users": ["a", "b"], "teams": {"acme/web": ["a"], "acme/empty": []}} t("unknown team -> CO02 high, files get no owner (no fallback)", ("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("unknown owner dropped, valid co-owner kept", owner("* @a\n/web/ @acme/webb @b", F, "web/app.js", cfg) == ["@b"]) t("team of other org -> CO02", ("high", "CO02") in codes("* @other/web", F, cfg)) t("unknown user -> CO02", ("high", "CO02") in codes("* @zed", F, cfg)) t("empty team -> CO09", ("medium", "CO09") in codes("* @a\n/web/ @acme/empty", F, cfg)) t("email owner accepted", ("high", "CO01") not in codes("* dev@acme.io", F)) t("dead rule -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @b", F)) t("shadowed rule -> CO05", ("medium", "CO05") in codes("/src/api/ @b\n/src/ @a", F)) t("partially overridden is not CO05", ("medium", "CO05") not in codes("/src/ @a\n/src/api/ @b", F)) t("duplicate pattern -> CO08", ("low", "CO08") in codes("* @a\n/src/ @b\n/src/ @c", F)) t("unowned file -> CO03 medium", ("medium", "CO03") in codes("/src/ @a", F)) t("require_owner path unowned -> CO03 high", ("high", "CO03") in codes("/src/ @acme/web", F, dict(cfg, require_owner=[".github/"]))) t("required path only owned by '*' -> CO11", ("medium", "CO11") in codes("* @a\n/src/ @b", F, {"require_owner": [".github/"]})) t("dead rule with unknown owner still -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @acme/zz", F, cfg)) t("individual owner concentration -> CO07", ("medium", "CO07") in codes("* @a", F, {"max_individual_share": 0.5})) t("ignore list removes files", len(analyse("* @a", F, {"ignore": ["docs/"]})["files"]) == 7) r = analyse("* @a\n/src/ @b", F) t("--who reports line", who(r, "src/api/routes.py") == "src/api/routes.py: @b (line 2: /src/)") t("./ prefix stripped, .github kept", analyse("* @a", "./.github/x.yml\n")["files"] == [".github/x.yml"]) t("report has verdict and coverage table", "**Verdict:" in report(r, "t") and "## Coverage by top-level directory" in report(r, "t")) try: analyse("* @a", F, {"orgs": "x"}); ok = False except InputError: ok = True t("unknown owners-file key rejected", ok) for name, ok in tests: print(("PASS " if ok else "FAIL ") + name) passed = sum(ok for _, ok in tests) print("%d/%d passed" % (passed, len(tests))) return 0 if passed == len(tests) else 1 def main(argv=None): ap = argparse.ArgumentParser(description="Check a GitHub CODEOWNERS file against the repository file list.") ap.add_argument("codeowners", nargs="?") ap.add_argument("files", nargs="?", help="file list, e.g. git ls-files > files.txt") ap.add_argument("--owners", help="JSON with org, users, teams, require_owner, max_individual_share, ignore") ap.add_argument("--who", action="append", default=[], help="show the effective owner of a path (repeatable)") 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("owners file is not valid JSON: %s" % e) res = analyse(co, fl, cfg) except (OSError, InputError) as e: print("error: %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())

전체 프롬프트를 보려면 로그인하세요

Continue with:

By logging in, you agree to our Terms of Use and Privacy Policy

사용법

이 프롬프트는 coding와 함께 사용하도록 설계되었습니다. 위의 프롬프트 내용을 복사하여 원하는 AI 도구에 붙여넣으세요.

최상의 결과를 얻으려면 자리 표시자(대괄호 또는 대문자로 표시)를 특정 요구 사항으로 사용자 지정할 수 있습니다.

참고 자료

분류:coding| prompts.chat| codeowners| github

토론