├── .github ├── CODEOWNERS ├── linters │ ├── .isort.cfg │ ├── trivy.yaml │ ├── .shellcheckrc │ ├── .jscpd.json │ ├── .flake8 │ ├── .markdown-lint.yml │ ├── .mypy.ini │ ├── .textlintrc │ ├── zizmor.yaml │ ├── .yaml-lint.yml │ └── .python-lint ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── docker-ci.yml │ ├── pr-title.yml │ ├── auto-labeler.yml │ ├── stale.yaml │ ├── super-linter.yaml │ ├── python-ci.yml │ ├── contributors_report.yaml │ ├── scorecard.yml │ ├── copilot-setup-steps.yml │ └── release.yml ├── pull_request_template.md ├── dependabot.yml ├── copilot-instructions.md └── release-drafter.yml ├── requirements.txt ├── .coveragerc ├── requirements-test.txt ├── .vscode └── settings.json ├── action.yml ├── .env-example ├── .dockerignore ├── Makefile ├── Dockerfile ├── LICENSE ├── markdown_writer.py ├── auth.py ├── test_env_get_bool.py ├── .gitignore ├── test_auth.py ├── test_markdown_writer.py ├── CONTRIBUTING.md ├── env.py ├── test_env.py ├── cleanowners.py ├── test_cleanowners.py └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/ospo-github-actions 2 | -------------------------------------------------------------------------------- /.github/linters/.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | github3.py==4.0.1 2 | python-dotenv==1.2.1 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # omit test files 4 | test_*.py -------------------------------------------------------------------------------- /.github/linters/trivy.yaml: -------------------------------------------------------------------------------- 1 | scan: 2 | skip-dirs: 3 | - .mypy_cache 4 | -------------------------------------------------------------------------------- /.github/linters/.shellcheckrc: -------------------------------------------------------------------------------- 1 | # Don't suggest [ -n "$VAR" ] over [ ! -z "$VAR" ] 2 | disable=SC2129 3 | -------------------------------------------------------------------------------- /.github/linters/.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": 25, 3 | "ignore": ["test*"], 4 | "absolute": true 5 | } 6 | -------------------------------------------------------------------------------- /.github/linters/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = venv,.venv,.git,__pycache__ 3 | extend-ignore = C901 4 | max-line-length = 150 5 | statistics = True 6 | -------------------------------------------------------------------------------- /.github/linters/.markdown-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # line length 3 | MD013: false 4 | # singe h1 5 | MD025: false 6 | # duplicate headers 7 | MD024: false 8 | -------------------------------------------------------------------------------- /.github/linters/.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | disable_error_code = attr-defined, import-not-found 3 | 4 | [mypy-github3.*] 5 | ignore_missing_imports = True 6 | -------------------------------------------------------------------------------- /.github/linters/.textlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "filters": { 3 | "comments": true 4 | }, 5 | "rules": { 6 | "terminology": { 7 | "severity": "warning" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | black==25.12.0 2 | flake8==7.3.0 3 | mypy==1.19.0 4 | mypy-extensions==1.1.0 5 | pylint==4.0.4 6 | pytest==9.0.2 7 | pytest-cov==7.0.0 8 | types-requests==2.32.4.20250913 9 | -------------------------------------------------------------------------------- /.github/linters/zizmor.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | dangerous-triggers: # to allow pull_request_target for auto-labelling fork pull requests 3 | ignore: 4 | - auto-labeler.yml 5 | - pr-title.yml 6 | - release.yml 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": ["."], 3 | "python.testing.unittestEnabled": false, 4 | "python.testing.pytestEnabled": true, 5 | "[python]": { 6 | "editor.defaultFormatter": "ms-python.black-formatter" 7 | }, 8 | "python.formatting.provider": "none" 9 | } 10 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Cleanowners action" 3 | author: "github" 4 | description: "A GitHub Action to suggest removal of non-organization members from CODEOWNERS files." 5 | runs: 6 | using: "docker" 7 | image: "docker://ghcr.io/github/cleanowners:v1" 8 | branding: 9 | icon: "bell" 10 | color: "orange" 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Ask a question 5 | url: https://github.com/github/cleanowners/discussions/new 6 | about: Ask a question or start a discussion 7 | - name: GitHub OSPO GitHub Action Overall Issue 8 | url: https://github.com/github/github-ospo/issues/new 9 | about: File issue for multiple GitHub OSPO GitHub Actions 10 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | DRY_RUN = "false" # true or false 2 | EXEMPT_REPOS = "" # comma separated list of repositories to exempt 3 | GH_ENTERPRISE_URL = "" 4 | GH_TOKEN = "" 5 | ORGANIZATION = "" 6 | REPOSITORY = "" # comma separated list of repositories in the format org/repo 7 | 8 | # GITHUB APP 9 | GH_APP_ID = "" 10 | GH_INSTALLATION_ID = "" 11 | GH_PRIVATE_KEY = "" 12 | GITHUB_APP_ENTERPRISE_ONLY = "" 13 | 14 | # OPTIONAL SETTINGS 15 | BODY = "" 16 | COMMIT_MESSAGE = "" 17 | TITLE = "" 18 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Application specific files 2 | test_*.py 3 | 4 | # Python 5 | *.pyc 6 | __pycache__/ 7 | *.pyo 8 | *.pyd 9 | 10 | # Common 11 | *.md 12 | docker-compose.yml 13 | Dockerfile* 14 | .env* 15 | Makefile 16 | 17 | # Logs 18 | logs 19 | *.log 20 | 21 | # IDEs 22 | .vscode/ 23 | .idea/ 24 | 25 | # Dependency directories 26 | node_modules/ 27 | .venv/ 28 | 29 | ## Cache directories 30 | .parcel-cache 31 | 32 | # git 33 | .git 34 | .gitattributes 35 | .gitignore 36 | .github/ 37 | -------------------------------------------------------------------------------- /.github/workflows/docker-ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Docker Image CI 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v6.0.1 19 | with: 20 | persist-credentials: false 21 | - name: Build the Docker image 22 | run: docker build . --file Dockerfile --platform linux/amd64 23 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | ## Reference: https://github.com/amannn/action-semantic-pull-request 2 | --- 3 | name: "Lint PR Title" 4 | on: 5 | pull_request_target: 6 | types: [opened, reopened, edited, synchronize] 7 | permissions: 8 | contents: read 9 | jobs: 10 | main: 11 | permissions: 12 | contents: read 13 | pull-requests: read 14 | statuses: write 15 | uses: github/ospo-reusable-workflows/.github/workflows/pr-title.yaml@26eec20abba5ae806698592c79628f6906da372c 16 | secrets: 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/auto-labeler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Auto Labeler 3 | on: 4 | # pull_request_target event is required for autolabeler to support all PRs including forks 5 | pull_request_target: 6 | types: [opened, reopened, edited, synchronize] 7 | permissions: 8 | contents: read 9 | jobs: 10 | main: 11 | permissions: 12 | contents: read 13 | pull-requests: write 14 | uses: github/ospo-reusable-workflows/.github/workflows/auto-labeler.yaml@26eec20abba5ae806698592c79628f6906da372c 15 | with: 16 | config-name: release-drafter.yml 17 | secrets: 18 | github-token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | pytest -v --cov=. --cov-config=.coveragerc --cov-fail-under=80 --cov-report term-missing 4 | 5 | .PHONY: clean 6 | clean: 7 | rm -rf .pytest_cache .coverage __pycache__ 8 | 9 | .PHONY: lint 10 | lint: 11 | # stop the build if there are Python syntax errors or undefined names 12 | flake8 . --config=.github/linters/.flake8 --count --select=E9,F63,F7,F82 --show-source 13 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 14 | flake8 . --config=.github/linters/.flake8 --count --exit-zero --max-complexity=15 --max-line-length=150 15 | isort --settings-file=.github/linters/.isort.cfg . 16 | pylint --rcfile=.github/linters/.python-lint --fail-under=9.0 *.py 17 | mypy --config-file=.github/linters/.mypy.ini *.py 18 | black . 19 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Close stale issues" 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | issues: write 15 | pull-requests: read 16 | steps: 17 | - uses: actions/stale@v10.1.1 18 | with: 19 | stale-issue-message: "This issue is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days." 20 | close-issue-message: "This issue was closed because it has been stalled for 35 days with no activity." 21 | days-before-stale: 21 22 | days-before-close: 14 23 | days-before-pr-close: -1 24 | exempt-issue-labels: keep 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #checkov:skip=CKV_DOCKER_2 2 | #checkov:skip=CKV_DOCKER_3 3 | #trivy:ignore:AVD-DS-0002 4 | FROM python:3.14.0-slim@sha256:0aecac02dc3d4c5dbb024b753af084cafe41f5416e02193f1ce345d671ec966e 5 | LABEL org.opencontainers.image.source https://github.com/github/cleanowners 6 | 7 | WORKDIR /action/workspace 8 | COPY requirements.txt *.py /action/workspace/ 9 | 10 | RUN python3 -m pip install --no-cache-dir -r requirements.txt \ 11 | && apt-get -y update \ 12 | && apt-get -y install --no-install-recommends git=1:2.47.3-0+deb13u1 \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | # Add a simple healthcheck to satisfy container scanners 16 | HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ 17 | CMD python3 -c "import os,sys; sys.exit(0 if os.path.exists('/action/workspace/cleanowners.py') else 1)" 18 | 19 | CMD ["/action/workspace/cleanowners.py"] 20 | ENTRYPOINT ["python3", "-u"] 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | 11 | 12 | ## Proposed Changes 13 | 14 | 15 | 16 | ## Readiness Checklist 17 | 18 | ### Author/Contributor 19 | 20 | - [ ] If documentation is needed for this change, has that been included in this pull request 21 | - [ ] run `make lint` and fix any issues that you have introduced 22 | - [ ] run `make test` and ensure you have test coverage for the lines you are introducing 23 | - [ ] If publishing new data to the public (scorecards, security scan results, code quality results, live dashboards, etc.), please request review from `@jeffrey-luszcz` 24 | 25 | ### Reviewer 26 | 27 | - [ ] Label as either `fix`, `documentation`, `enhancement`, `infrastructure`, `maintenance` or `breaking` 28 | -------------------------------------------------------------------------------- /.github/workflows/super-linter.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint Code Base 3 | 4 | on: 5 | pull_request: 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | name: Lint Code Base 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | packages: read 19 | statuses: write 20 | 21 | steps: 22 | - name: Checkout Code 23 | uses: actions/checkout@v6.0.1 24 | with: 25 | fetch-depth: 0 26 | persist-credentials: false 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.txt -r requirements-test.txt 31 | - name: Lint Code Base 32 | uses: super-linter/super-linter@502f4fe48a81a392756e173e39a861f8c8efe056 33 | env: 34 | DEFAULT_BRANCH: main 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | GITHUB_ACTIONS_COMMAND_ARGS: -shellcheck= 37 | VALIDATE_BIOME_FORMAT: false 38 | VALIDATE_BIOME_LINT: false 39 | VALIDATE_PYTHON_RUFF_FORMAT: false 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | description: Suggest an idea for this project 4 | labels: 5 | - enhancement 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Is your feature request related to a problem? 10 | description: A clear and concise description of what the problem is. Please describe. 11 | placeholder: | 12 | Ex. I'm always frustrated when [...] 13 | validations: 14 | required: false 15 | 16 | - type: textarea 17 | attributes: 18 | label: Describe the solution you'd like 19 | description: A clear and concise description of what you want to happen. 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | attributes: 25 | label: Describe alternatives you've considered 26 | description: A clear and concise description of any alternative solutions or features you've considered. 27 | validations: 28 | required: false 29 | 30 | - type: textarea 31 | attributes: 32 | label: Additional context 33 | description: Add any other context or screenshots about the feature request here. 34 | validations: 35 | required: false 36 | -------------------------------------------------------------------------------- /.github/workflows/python-ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 3 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 4 | 5 | name: Python package 6 | 7 | on: 8 | push: 9 | branches: [main] 10 | pull_request: 11 | branches: [main] 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | python-version: [3.11, 3.12] 22 | 23 | steps: 24 | - uses: actions/checkout@v6.0.1 25 | with: 26 | persist-credentials: false 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v6.1.0 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install -r requirements.txt -r requirements-test.txt 35 | - name: Lint with flake8 and pylint 36 | run: | 37 | make lint 38 | - name: Test with pytest 39 | run: | 40 | make test 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | cooldown: 9 | default-days: 7 10 | commit-message: 11 | prefix: "chore(deps)" 12 | labels: ["python", "dependencies"] 13 | groups: 14 | dependencies: 15 | applies-to: version-updates 16 | update-types: 17 | - "minor" 18 | - "patch" 19 | - package-ecosystem: "github-actions" 20 | directory: "/" 21 | schedule: 22 | interval: "weekly" 23 | cooldown: 24 | default-days: 7 25 | commit-message: 26 | prefix: "chore(deps)" 27 | labels: ["github_actions", "dependencies"] 28 | groups: 29 | dependencies: 30 | applies-to: version-updates 31 | update-types: 32 | - "minor" 33 | - "patch" 34 | - package-ecosystem: "docker" 35 | directory: "/" 36 | schedule: 37 | interval: "weekly" 38 | cooldown: 39 | default-days: 7 40 | commit-message: 41 | prefix: "chore(deps)" 42 | labels: ["docker", "dependencies"] 43 | groups: 44 | dependencies: 45 | applies-to: version-updates 46 | update-types: 47 | - "minor" 48 | - "patch" 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | description: Create a report to help us improve 4 | labels: 5 | - bug 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Describe the bug 10 | description: A clear and concise description of what the bug is. 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | attributes: 16 | label: To Reproduce 17 | description: Steps to reproduce the behavior 18 | placeholder: | 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | attributes: 28 | label: Expected behavior 29 | description: A clear and concise description of what you expected to happen. 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | attributes: 35 | label: Screenshots 36 | description: If applicable, add screenshots to help explain your problem. 37 | validations: 38 | required: false 39 | 40 | - type: textarea 41 | attributes: 42 | label: Additional context 43 | description: Add any other context about the problem here. 44 | validations: 45 | required: false 46 | -------------------------------------------------------------------------------- /markdown_writer.py: -------------------------------------------------------------------------------- 1 | """Write the results to a markdown file""" 2 | 3 | 4 | def write_to_markdown( 5 | users_count, 6 | pull_count, 7 | no_codeowners_count, 8 | codeowners_count, 9 | repo_and_users_to_remove, 10 | repos_missing_codeowners, 11 | ): 12 | """Write the results to a markdown file""" 13 | with open("report.md", "w", encoding="utf-8") as file: 14 | file.write( 15 | "# Cleanowners Report\n\n" 16 | "## Overall Stats\n" 17 | f"{users_count} Users to Remove\n" 18 | f"{pull_count} Pull Requests created\n" 19 | f"{no_codeowners_count} Repositories with no CODEOWNERS file\n" 20 | f"{codeowners_count} Repositories with CODEOWNERS file\n" 21 | ) 22 | if repo_and_users_to_remove: 23 | file.write("## Repositories and Users to Remove\n") 24 | for repo, users in repo_and_users_to_remove.items(): 25 | file.write(f"{repo}\n") 26 | for user in users: 27 | file.write(f"- {user}\n") 28 | file.write("\n") 29 | if repos_missing_codeowners: 30 | file.write("## Repositories Missing CODEOWNERS\n") 31 | for repo in repos_missing_codeowners: 32 | file.write(f"- {repo}\n") 33 | file.write("\n") 34 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot Instructions 2 | 3 | This is a GitHub Action that is designed to help keep `CODEOWNERS` files current by removing users that are no longer a part of the organization. This is helpful for companies that are looking to remove outdated information in the `CODEOWNERS` file. This action can be paired with other `CODEOWNERS` related actions to suggest new owners or lint `CODEOWNERS` files to ensure accuracy. 4 | 5 | ## Code Standards 6 | 7 | ### Required Before Each Commit 8 | 9 | - Run `make lint` before committing any changes to ensure proper code linting and formatting. 10 | 11 | ### Development Flow 12 | 13 | - Lint: `make lint` 14 | - Test: `make test` 15 | 16 | ## Repository Structure 17 | 18 | - `Makefile`: Contains commands for linting, testing, and other tasks 19 | - `requirements.txt`: Python dependencies for the project 20 | - `requirements-test.txt`: Python dependencies for testing 21 | - `README.md`: Project documentation and setup instructions 22 | - `setup.py`: Python package setup configuration 23 | - `test_*.py`: Python test files matching the naming convention for test discovery 24 | 25 | ## Key Guidelines 26 | 27 | 1. Follow Python best practices and idiomatic patterns 28 | 2. Maintain existing code structure and organization 29 | 3. Write unit tests for new functionality. 30 | 4. Document changes to environment variables in the `README.md` file. 31 | -------------------------------------------------------------------------------- /.github/workflows/contributors_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Monthly contributor report 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "3 2 1 * *" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | contributor_report: 13 | name: contributor report 14 | runs-on: ubuntu-latest 15 | permissions: 16 | issues: write 17 | 18 | steps: 19 | - name: Get dates for last month 20 | shell: bash 21 | run: | 22 | # Calculate the first day of the previous month 23 | start_date=$(date -d "last month" +%Y-%m-01) 24 | 25 | # Calculate the last day of the previous month 26 | end_date=$(date -d "$start_date +1 month -1 day" +%Y-%m-%d) 27 | 28 | #Set an environment variable with the date range 29 | echo "START_DATE=$start_date" >> "$GITHUB_ENV" 30 | echo "END_DATE=$end_date" >> "$GITHUB_ENV" 31 | 32 | - name: Run contributor action 33 | uses: github/contributors@e345de71bbd056a34a70709afd4f4bf0a270cc1a 34 | env: 35 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | START_DATE: ${{ env.START_DATE }} 37 | END_DATE: ${{ env.END_DATE }} 38 | REPOSITORY: github/cleanowners 39 | SPONSOR_INFO: "true" 40 | 41 | - name: Create issue 42 | uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 43 | with: 44 | title: Monthly contributor report 45 | token: ${{ secrets.GITHUB_TOKEN }} 46 | content-filepath: ./contributors.md 47 | assignees: zkoppert 48 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Scorecard supply-chain security 3 | on: 4 | workflow_dispatch: 5 | # For Branch-Protection check (for repo branch protection or rules). 6 | # Only the default branch is supported. See 7 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 8 | branch_protection_rule: 9 | # To guarantee Maintained check is occasionally updated. See 10 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 11 | schedule: 12 | - cron: "29 11 * * 6" 13 | push: 14 | branches: [main] 15 | 16 | permissions: read-all 17 | 18 | jobs: 19 | analysis: 20 | name: Merge to Main Scorecard analysis 21 | runs-on: ubuntu-latest 22 | permissions: 23 | security-events: write 24 | id-token: write 25 | 26 | steps: 27 | - name: "Checkout code" 28 | uses: actions/checkout@v6.0.1 29 | with: 30 | persist-credentials: false 31 | 32 | - name: "Run analysis" 33 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a 34 | with: 35 | results_file: results.sarif 36 | results_format: sarif 37 | publish_results: true 38 | - name: "Upload artifact" 39 | uses: actions/upload-artifact@v5.0.0 40 | with: 41 | name: SARIF file 42 | path: results.sarif 43 | retention-days: 5 44 | - name: "Upload to code-scanning" 45 | uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 46 | with: 47 | sarif_file: results.sarif 48 | -------------------------------------------------------------------------------- /.github/workflows/copilot-setup-steps.yml: -------------------------------------------------------------------------------- 1 | name: "Copilot Setup Steps" 2 | 3 | # Automatically run the setup steps when they are changed to allow for easy validation, and 4 | # allow manual testing through the repository's "Actions" tab 5 | on: 6 | workflow_dispatch: 7 | push: 8 | paths: 9 | - .github/workflows/copilot-setup-steps.yml 10 | pull_request: 11 | paths: 12 | - .github/workflows/copilot-setup-steps.yml 13 | 14 | # Set the permissions to the lowest permissions possible needed for your steps. 15 | # Copilot will be given its own token for its operations. 16 | permissions: 17 | # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. 18 | contents: read 19 | 20 | jobs: 21 | # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. 22 | copilot-setup-steps: 23 | runs-on: ubuntu-latest 24 | 25 | # You can define any steps you want, and they will run before the agent starts. 26 | # If you do not check out your code, Copilot will do this for you. 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v6.0.1 30 | with: 31 | persist-credentials: false 32 | 33 | - name: Set up Python 34 | uses: actions/setup-python@v6.1.0 35 | with: 36 | python-version: 3.12 37 | 38 | - name: Install dependencies 39 | run: | 40 | pip install -r requirements.txt -r requirements-test.txt 41 | -------------------------------------------------------------------------------- /.github/linters/.yaml-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ########################################### 3 | # These are the rules used for # 4 | # linting all the yaml files in the stack # 5 | # NOTE: # 6 | # You can disable line with: # 7 | # # yamllint disable-line # 8 | ########################################### 9 | rules: 10 | braces: 11 | level: warning 12 | min-spaces-inside: 0 13 | max-spaces-inside: 0 14 | min-spaces-inside-empty: 1 15 | max-spaces-inside-empty: 5 16 | brackets: 17 | level: warning 18 | min-spaces-inside: 0 19 | max-spaces-inside: 0 20 | min-spaces-inside-empty: 1 21 | max-spaces-inside-empty: 5 22 | colons: 23 | level: warning 24 | max-spaces-before: 0 25 | max-spaces-after: 1 26 | commas: 27 | level: warning 28 | max-spaces-before: 0 29 | min-spaces-after: 1 30 | max-spaces-after: 1 31 | comments: disable 32 | comments-indentation: disable 33 | document-end: disable 34 | document-start: 35 | level: warning 36 | present: true 37 | empty-lines: 38 | level: warning 39 | max: 2 40 | max-start: 0 41 | max-end: 0 42 | hyphens: 43 | level: warning 44 | max-spaces-after: 1 45 | indentation: 46 | level: warning 47 | spaces: consistent 48 | indent-sequences: true 49 | check-multi-line-strings: false 50 | key-duplicates: enable 51 | line-length: 52 | level: warning 53 | max: 1024 54 | allow-non-breakable-words: true 55 | allow-non-breakable-inline-mappings: true 56 | new-line-at-end-of-file: disable 57 | new-lines: 58 | type: unix 59 | trailing-spaces: disable 60 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: "v$RESOLVED_VERSION" 3 | tag-template: "v$RESOLVED_VERSION" 4 | template: | 5 | # Changelog 6 | $CHANGES 7 | 8 | See details of [all code changes](https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION) since previous release 9 | 10 | categories: 11 | - title: "🚀 Features" 12 | labels: 13 | - "feature" 14 | - "enhancement" 15 | - title: "🐛 Bug Fixes" 16 | labels: 17 | - "fix" 18 | - "bugfix" 19 | - "bug" 20 | - title: "🧰 Maintenance" 21 | labels: 22 | - "infrastructure" 23 | - "automation" 24 | - "documentation" 25 | - "dependencies" 26 | - "maintenance" 27 | - "revert" 28 | - title: "🏎 Performance" 29 | label: "performance" 30 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 31 | version-resolver: 32 | major: 33 | labels: 34 | - "breaking" 35 | - "major" 36 | minor: 37 | labels: 38 | - "enhancement" 39 | - "feature" 40 | - "minor" 41 | patch: 42 | labels: 43 | - "documentation" 44 | - "fix" 45 | - "maintenance" 46 | - "patch" 47 | default: patch 48 | autolabeler: 49 | - label: "automation" 50 | title: 51 | - "/^(build|ci|perf|refactor|test).*/i" 52 | - label: "enhancement" 53 | title: 54 | - "/^(style).*/i" 55 | - label: "documentation" 56 | title: 57 | - "/^(docs).*/i" 58 | - label: "feature" 59 | title: 60 | - "/^(feat).*/i" 61 | - label: "fix" 62 | title: 63 | - "/^(fix).*/i" 64 | - label: "infrastructure" 65 | title: 66 | - "/^(infrastructure).*/i" 67 | - label: "maintenance" 68 | title: 69 | - "/^(chore|maintenance).*/i" 70 | - label: "revert" 71 | title: 72 | - "/^(revert).*/i" 73 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | workflow_dispatch: 5 | pull_request_target: 6 | types: [closed] 7 | branches: [main] 8 | permissions: 9 | contents: read 10 | jobs: 11 | release: 12 | permissions: 13 | contents: write 14 | pull-requests: read 15 | uses: github/ospo-reusable-workflows/.github/workflows/release.yaml@26eec20abba5ae806698592c79628f6906da372c 16 | with: 17 | publish: true 18 | release-config-name: release-drafter.yml 19 | secrets: 20 | github-token: ${{ secrets.GITHUB_TOKEN }} 21 | release_image: 22 | needs: release 23 | permissions: 24 | contents: read 25 | packages: write 26 | id-token: write 27 | attestations: write 28 | uses: github/ospo-reusable-workflows/.github/workflows/release-image.yaml@26eec20abba5ae806698592c79628f6906da372c 29 | with: 30 | image-name: ${{ github.repository }} 31 | full-tag: ${{ needs.release.outputs.full-tag }} 32 | short-tag: ${{ needs.release.outputs.short-tag }} 33 | secrets: 34 | github-token: ${{ secrets.GITHUB_TOKEN }} 35 | image-registry: ghcr.io 36 | image-registry-username: ${{ github.actor }} 37 | image-registry-password: ${{ secrets.GITHUB_TOKEN }} 38 | release_discussion: 39 | needs: release 40 | permissions: 41 | contents: read 42 | discussions: write 43 | uses: github/ospo-reusable-workflows/.github/workflows/release-discussion.yaml@26eec20abba5ae806698592c79628f6906da372c 44 | with: 45 | full-tag: ${{ needs.release.outputs.full-tag }} 46 | body: ${{ needs.release.outputs.body }} 47 | secrets: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | discussion-repository-id: ${{ secrets.RELEASE_DISCUSSION_REPOSITORY_ID }} 50 | discussion-category-id: ${{ secrets.RELEASE_DISCUSSION_CATEGORY_ID }} 51 | -------------------------------------------------------------------------------- /auth.py: -------------------------------------------------------------------------------- 1 | """This is the module that contains functions related to authenticating to GitHub with a personal access token.""" 2 | 3 | import github3 4 | 5 | 6 | def auth_to_github( 7 | token: str, 8 | gh_app_id: int | None, 9 | gh_app_installation_id: int | None, 10 | gh_app_private_key_bytes: bytes, 11 | ghe: str, 12 | gh_app_enterprise_only: bool, 13 | ) -> github3.GitHub: 14 | """ 15 | Connect to GitHub.com or GitHub Enterprise, depending on env variables. 16 | 17 | Args: 18 | token (str): the GitHub personal access token 19 | gh_app_id (int | None): the GitHub App ID 20 | gh_app_installation_id (int | None): the GitHub App Installation ID 21 | gh_app_private_key_bytes (bytes): the GitHub App Private Key 22 | ghe (str): the GitHub Enterprise URL 23 | gh_app_enterprise_only (bool): Set this to true if the GH APP is created on GHE and needs to communicate with GHE api only 24 | 25 | Returns: 26 | github3.GitHub: the GitHub connection object 27 | """ 28 | if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: 29 | if ghe and gh_app_enterprise_only: 30 | gh = github3.github.GitHubEnterprise(url=ghe) 31 | else: 32 | gh = github3.github.GitHub() 33 | gh.login_as_app_installation( 34 | gh_app_private_key_bytes, gh_app_id, gh_app_installation_id 35 | ) 36 | github_connection = gh 37 | elif ghe and token: 38 | github_connection = github3.github.GitHubEnterprise(url=ghe, token=token) 39 | elif token: 40 | github_connection = github3.login(token=token) 41 | else: 42 | raise ValueError( 43 | "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set" 44 | ) 45 | 46 | if not github_connection: 47 | raise ValueError("Unable to authenticate to GitHub") 48 | return github_connection # type: ignore 49 | -------------------------------------------------------------------------------- /test_env_get_bool.py: -------------------------------------------------------------------------------- 1 | """Test the get_bool_env_var function""" 2 | 3 | import os 4 | import unittest 5 | from unittest.mock import patch 6 | 7 | from env import get_bool_env_var 8 | 9 | 10 | class TestEnv(unittest.TestCase): 11 | """Test the get_bool_env_var function""" 12 | 13 | @patch.dict( 14 | os.environ, 15 | { 16 | "TEST_BOOL": "true", 17 | }, 18 | clear=True, 19 | ) 20 | def test_get_bool_env_var_that_exists_and_is_true(self): 21 | """Test that gets a boolean environment variable that exists and is true""" 22 | result = get_bool_env_var("TEST_BOOL", False) 23 | self.assertTrue(result) 24 | 25 | @patch.dict( 26 | os.environ, 27 | { 28 | "TEST_BOOL": "false", 29 | }, 30 | clear=True, 31 | ) 32 | def test_get_bool_env_var_that_exists_and_is_false(self): 33 | """Test that gets a boolean environment variable that exists and is false""" 34 | result = get_bool_env_var("TEST_BOOL", False) 35 | self.assertFalse(result) 36 | 37 | @patch.dict( 38 | os.environ, 39 | { 40 | "TEST_BOOL": "nope", 41 | }, 42 | clear=True, 43 | ) 44 | def test_get_bool_env_var_that_exists_and_is_false_due_to_invalid_value(self): 45 | """Test that gets a boolean environment variable that exists and is false 46 | due to an invalid value 47 | """ 48 | result = get_bool_env_var("TEST_BOOL", False) 49 | self.assertFalse(result) 50 | 51 | @patch.dict( 52 | os.environ, 53 | { 54 | "TEST_BOOL": "false", 55 | }, 56 | clear=True, 57 | ) 58 | def test_get_bool_env_var_that_does_not_exist_and_default_value_returns_true(self): 59 | """Test that gets a boolean environment variable that does not exist 60 | and default value returns: true 61 | """ 62 | result = get_bool_env_var("DOES_NOT_EXIST", True) 63 | self.assertTrue(result) 64 | 65 | @patch.dict( 66 | os.environ, 67 | { 68 | "TEST_BOOL": "true", 69 | }, 70 | clear=True, 71 | ) 72 | def test_get_bool_env_var_that_does_not_exist_and_default_value_returns_false(self): 73 | """Test that gets a boolean environment variable that does not exist 74 | and default value returns: false 75 | """ 76 | result = get_bool_env_var("DOES_NOT_EXIST", False) 77 | self.assertFalse(result) 78 | 79 | 80 | if __name__ == "__main__": 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # asdf 7 | .tool-versions 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # Cython debug symbols 141 | cython_debug/ 142 | 143 | # Mac 144 | .DS_Store 145 | -------------------------------------------------------------------------------- /test_auth.py: -------------------------------------------------------------------------------- 1 | """Test cases for the auth module.""" 2 | 3 | import unittest 4 | from unittest.mock import MagicMock, patch 5 | 6 | import auth 7 | 8 | 9 | class TestAuth(unittest.TestCase): 10 | """ 11 | Test case for the auth module. 12 | """ 13 | 14 | @patch("github3.login") 15 | def test_auth_to_github_with_token(self, mock_login): 16 | """ 17 | Test the auth_to_github function when the token is provided. 18 | """ 19 | mock_login.return_value = "Authenticated to GitHub.com" 20 | 21 | result = auth.auth_to_github("token", "", "", b"", "", False) 22 | 23 | self.assertEqual(result, "Authenticated to GitHub.com") 24 | 25 | def test_auth_to_github_without_token(self): 26 | """ 27 | Test the auth_to_github function when the token is not provided. 28 | Expect a ValueError to be raised. 29 | """ 30 | with self.assertRaises(ValueError) as context_manager: 31 | auth.auth_to_github("", "", "", b"", "", False) 32 | the_exception = context_manager.exception 33 | self.assertEqual( 34 | str(the_exception), 35 | "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set", 36 | ) 37 | 38 | @patch("github3.github.GitHubEnterprise") 39 | def test_auth_to_github_with_ghe(self, mock_ghe): 40 | """ 41 | Test the auth_to_github function when the GitHub Enterprise URL is provided. 42 | """ 43 | mock_ghe.return_value = "Authenticated to GitHub Enterprise" 44 | result = auth.auth_to_github( 45 | "token", "", "", b"", "https://github.example.com", False 46 | ) 47 | 48 | self.assertEqual(result, "Authenticated to GitHub Enterprise") 49 | 50 | @patch("github3.github.GitHubEnterprise") 51 | def test_auth_to_github_with_ghe_and_ghe_app(self, mock_ghe): 52 | """ 53 | Test the auth_to_github function when the GitHub Enterprise URL is provided and the app was created in GitHub Enterprise URL. 54 | """ 55 | mock = mock_ghe.return_value 56 | mock.login_as_app_installation = MagicMock(return_value=True) 57 | result = auth.auth_to_github( 58 | "", "123", "123", b"123", "https://github.example.com", True 59 | ) 60 | mock.login_as_app_installation.assert_called_once() 61 | self.assertEqual(result, mock) 62 | 63 | @patch("github3.github.GitHub") 64 | def test_auth_to_github_with_app(self, mock_gh): 65 | """ 66 | Test the auth_to_github function when app credentials are provided 67 | """ 68 | mock = mock_gh.return_value 69 | mock.login_as_app_installation = MagicMock(return_value=True) 70 | result = auth.auth_to_github( 71 | "", "123", "123", b"123", "https://github.example.com", False 72 | ) 73 | mock.login_as_app_installation.assert_called_once() 74 | self.assertEqual(result, mock) 75 | 76 | @patch("github3.login") 77 | def test_auth_to_github_invalid_credentials(self, mock_login): 78 | """ 79 | Test the auth_to_github function raises correct ValueError 80 | when credentials are present but incorrect. 81 | """ 82 | mock_login.return_value = None 83 | with self.assertRaises(ValueError) as context_manager: 84 | auth.auth_to_github("not_a_valid_token", "", "", b"", "", False) 85 | 86 | the_exception = context_manager.exception 87 | self.assertEqual( 88 | str(the_exception), 89 | "Unable to authenticate to GitHub", 90 | ) 91 | 92 | 93 | if __name__ == "__main__": 94 | unittest.main() 95 | -------------------------------------------------------------------------------- /test_markdown_writer.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the write_to_markdown function in markdown_writer.py""" 2 | 3 | import unittest 4 | from unittest.mock import call, mock_open, patch 5 | 6 | from markdown_writer import write_to_markdown 7 | 8 | 9 | class TestWriteToMarkdown(unittest.TestCase): 10 | """Test the write_to_markdown function""" 11 | 12 | def test_write_with_all_counts_and_no_users_to_remove(self): 13 | """Test that the function writes the correct markdown when there are no users to remove""" 14 | mock_file = mock_open() 15 | with patch("builtins.open", mock_file): 16 | write_to_markdown(0, 0, 2, 3, {}, []) 17 | mock_file().write.assert_called_once_with( 18 | "# Cleanowners Report\n\n" 19 | "## Overall Stats\n" 20 | "0 Users to Remove\n" 21 | "0 Pull Requests created\n" 22 | "2 Repositories with no CODEOWNERS file\n" 23 | "3 Repositories with CODEOWNERS file\n" 24 | ) 25 | 26 | def test_write_with_repos_and_users_with_users_to_remove(self): 27 | """Test that the function writes the correct markdown when there are users to remove""" 28 | mock_file = mock_open() 29 | repo_users = {"repo1": ["user1", "user2"], "repo2": ["user3"]} 30 | with patch("builtins.open", mock_file): 31 | write_to_markdown(1, 2, 3, 4, repo_users, []) 32 | calls = [ 33 | call( 34 | "# Cleanowners Report\n\n" 35 | "## Overall Stats\n" 36 | "1 Users to Remove\n" 37 | "2 Pull Requests created\n" 38 | "3 Repositories with no CODEOWNERS file\n" 39 | "4 Repositories with CODEOWNERS file\n" 40 | ), 41 | call("## Repositories and Users to Remove\n"), 42 | call("repo1\n"), 43 | call("- user1\n"), 44 | call("- user2\n"), 45 | call("\n"), 46 | call("repo2\n"), 47 | call("- user3\n"), 48 | call("\n"), 49 | ] 50 | mock_file().write.assert_has_calls(calls, any_order=False) 51 | 52 | def test_write_with_repos_missing_codeowners(self): 53 | """Test that the function writes the correct markdown when there are repos missing CODEOWNERS""" 54 | mock_file = mock_open() 55 | repos_missing_codeowners = ["repo1", "repo2"] 56 | with patch("builtins.open", mock_file): 57 | write_to_markdown(0, 0, 2, 0, {}, repos_missing_codeowners) 58 | calls = [ 59 | call( 60 | "# Cleanowners Report\n\n" 61 | "## Overall Stats\n" 62 | "0 Users to Remove\n" 63 | "0 Pull Requests created\n" 64 | "2 Repositories with no CODEOWNERS file\n" 65 | "0 Repositories with CODEOWNERS file\n" 66 | ), 67 | call("## Repositories Missing CODEOWNERS\n"), 68 | call("- repo1\n"), 69 | call("- repo2\n"), 70 | call("\n"), 71 | ] 72 | mock_file().write.assert_has_calls(calls, any_order=False) 73 | 74 | def test_write_with_empty_inputs(self): 75 | """Test that the function writes the correct markdown when all inputs are 0""" 76 | mock_file = mock_open() 77 | with patch("builtins.open", mock_file): 78 | write_to_markdown(0, 0, 0, 0, {}, []) 79 | mock_file().write.assert_called_once_with( 80 | "# Cleanowners Report\n\n" 81 | "## Overall Stats\n" 82 | "0 Users to Remove\n" 83 | "0 Pull Requests created\n" 84 | "0 Repositories with no CODEOWNERS file\n" 85 | "0 Repositories with CODEOWNERS file\n" 86 | ) 87 | 88 | 89 | if __name__ == "__main__": 90 | unittest.main() 91 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Contributing to cleanowners 5 | 6 | First off, thanks for taking the time to contribute! :heart: 7 | 8 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us project owners and smooth out the experience for all involved. The team looks forward to your contributions. :tada: 9 | 10 | 11 | 12 | ## Table of Contents 13 | 14 | - [I Have a Question](#i-have-a-question) 15 | - [I Want To Contribute](#i-want-to-contribute) 16 | - [Reporting Bugs](#reporting-bugs) 17 | - [Suggesting Enhancements](#suggesting-enhancements) 18 | - [Releases](#releases) 19 | 20 | ## I Have a Question 21 | 22 | Before you ask a question, it is best to search for existing [Issues](https://github.com/github/cleanowners/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. 23 | 24 | If you then still feel the need to ask a question and need clarification, we recommend the following: 25 | 26 | - Open an [Issue](https://github.com/github/cleanowners/issues/new). 27 | - Provide as much context as you can about what you're running into. 28 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 29 | 30 | We will then take care of the issue as soon as possible. 31 | 32 | ## I Want To Contribute 33 | 34 | ### Legal Notice 35 | 36 | When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 37 | 38 | ## Reporting Bugs 39 | 40 | 41 | 42 | ### Before Submitting a Bug Report 43 | 44 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 45 | 46 | - Make sure that you are using the latest version. 47 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the documentation. If you are looking for support, you might want to check [this section](#i-have-a-question)). 48 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/github/cleanowners/issues). 49 | - Collect information about the bug: 50 | - Stack trace (Traceback) 51 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 52 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 53 | - Possibly your input and the output 54 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 55 | 56 | 57 | 58 | ### How Do I Submit a Good Bug Report? 59 | 60 | Please submit a bug report using our [GitHub Issues template](https://github.com/github/cleanowners/issues/new?template=bug_report.yml). 61 | 62 | ## Suggesting Enhancements 63 | 64 | This section guides you through submitting an enhancement suggestion for cleanowners, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 65 | 66 | 67 | 68 | ### Before Submitting an Enhancement 69 | 70 | - Make sure that you are using the latest version. 71 | - Read the documentation carefully and find out if the functionality is already covered, maybe by an individual configuration. 72 | - Perform a [search](https://github.com/github/cleanowners/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 73 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature or to develop the feature yourself and contribute it to the project. 74 | 75 | 76 | 77 | ### How Do I Submit a Good Enhancement Suggestion? 78 | 79 | Please submit an enhancement suggestion using our [GitHub Issues template](https://github.com/github/cleanowners/issues/new?template=feature_request.yml). 80 | 81 | ### Pull Request Standards 82 | 83 | We are using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) to standardize our pull request titles. This allows us to automatically generate labels and changelogs and follow semantic versioning. Please follow the commit message format when creating a pull request. What pull request title prefixes are expected are in the [pull_request_template.md](.github/pull_request_template.md) that is shown when creating a pull request. 84 | 85 | ## Releases 86 | 87 | Releases are automated if a pull request is labelled with our [semver related labels](.github/release-drafter.yml) or with the `vuln` or `release` labels. 88 | 89 | You can also manually initiate a release you can do so through the GitHub Actions UI. If you have permissions to do so, you can navigate to the [Actions tab](https://github.com/github/cleanowners/actions/workflows/release.yml) and select the `Run workflow` button. This will allow you to select the branch to release from and the version to release. 90 | -------------------------------------------------------------------------------- /env.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sets up the environment variables for the action. 3 | """ 4 | 5 | import os 6 | from os.path import dirname, join 7 | 8 | from dotenv import load_dotenv 9 | 10 | 11 | def get_bool_env_var(env_var_name: str, default: bool = False) -> bool: 12 | """Get a boolean environment variable. 13 | 14 | Args: 15 | env_var_name: The name of the environment variable to retrieve. 16 | default: The default value to return if the environment variable is not set. 17 | 18 | Returns: 19 | The value of the environment variable as a boolean. 20 | """ 21 | ev = os.environ.get(env_var_name, "") 22 | if ev == "" and default: 23 | return default 24 | return ev.strip().lower() == "true" 25 | 26 | 27 | def get_int_env_var(env_var_name: str) -> int | None: 28 | """Get an integer environment variable. 29 | 30 | Args: 31 | env_var_name: The name of the environment variable to retrieve. 32 | 33 | Returns: 34 | The value of the environment variable as an integer or None. 35 | """ 36 | env_var = os.environ.get(env_var_name) 37 | if env_var is None or not env_var.strip(): 38 | return None 39 | try: 40 | return int(env_var) 41 | except ValueError: 42 | return None 43 | 44 | 45 | def get_env_vars( 46 | test: bool = False, 47 | ) -> tuple[ 48 | str | None, 49 | list[str], 50 | int | None, 51 | int | None, 52 | bytes, 53 | bool, 54 | str | None, 55 | str, 56 | list[str], 57 | bool, 58 | str, 59 | str, 60 | str, 61 | bool, 62 | ]: 63 | """ 64 | Get the environment variables for use in the action. 65 | 66 | Args: 67 | test (bool): Whether or not to load the environment variables from a .env file (default: False) 68 | 69 | Returns: 70 | organization (str | None): The organization to search for repositories in 71 | repository_list (list[str]): A list of repositories to search for 72 | gh_app_id (int | None): The GitHub App ID to use for authentication 73 | gh_app_installation_id (int | None): The GitHub App Installation ID to use for authentication 74 | gh_app_private_key_bytes (bytes): The GitHub App Private Key as bytes to use for authentication 75 | gh_app_enterprise_only (bool): Set this to true if the GH APP is created on GHE and needs to communicate with GHE api only 76 | token (str | None): The GitHub token to use for authentication 77 | ghe (str): The GitHub Enterprise URL to use for authentication 78 | exempt_repositories_list (list[str]): A list of repositories to exempt from the action 79 | dry_run (bool): Whether or not to actually open issues/pull requests 80 | title (str): The title to use for the pull request 81 | body (str): The body to use for the pull request 82 | message (str): Commit message to use 83 | issue_report (bool): Whether or not to create an issue report with the results 84 | 85 | """ 86 | if not test: 87 | # Load from .env file if it exists 88 | dotenv_path = join(dirname(__file__), ".env") 89 | load_dotenv(dotenv_path) 90 | 91 | organization = os.getenv("ORGANIZATION") 92 | repositories_str = os.getenv("REPOSITORY") 93 | # Either organization or repository must be set 94 | if not organization and not repositories_str: 95 | raise ValueError( 96 | "ORGANIZATION and REPOSITORY environment variables were not set. Please set one" 97 | ) 98 | 99 | if repositories_str and repositories_str.find("/") == 0: 100 | raise ValueError( 101 | "REPOSITORY environment variable was not set correctly. Please set it to a comma separated list of repositories in the format org/repo" 102 | ) 103 | 104 | # Separate repositories_str into a list based on the comma separator 105 | repositories_list = [] 106 | if repositories_str: 107 | repositories_list = [ 108 | repository.strip() for repository in repositories_str.split(",") 109 | ] 110 | 111 | gh_app_id = get_int_env_var("GH_APP_ID") 112 | gh_app_private_key_bytes = os.environ.get("GH_APP_PRIVATE_KEY", "").encode("utf8") 113 | gh_app_installation_id = get_int_env_var("GH_APP_INSTALLATION_ID") 114 | gh_app_enterprise_only = get_bool_env_var("GITHUB_APP_ENTERPRISE_ONLY") 115 | 116 | if gh_app_id and (not gh_app_private_key_bytes or not gh_app_installation_id): 117 | raise ValueError( 118 | "GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set" 119 | ) 120 | 121 | token = os.getenv("GH_TOKEN") 122 | if ( 123 | not gh_app_id 124 | and not gh_app_private_key_bytes 125 | and not gh_app_installation_id 126 | and not token 127 | ): 128 | raise ValueError("GH_TOKEN environment variable not set") 129 | 130 | ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip() 131 | 132 | exempt_repos = os.getenv("EXEMPT_REPOS") 133 | exempt_repositories_list = [] 134 | if exempt_repos: 135 | exempt_repositories_list = [ 136 | repository.strip() for repository in exempt_repos.split(",") 137 | ] 138 | 139 | dry_run = get_bool_env_var("DRY_RUN") 140 | 141 | title = os.getenv("TITLE") 142 | # make sure that title is a string with less than 70 characters 143 | if title: 144 | if len(title) > 70: 145 | raise ValueError( 146 | "TITLE environment variable is too long. Max 70 characters" 147 | ) 148 | else: 149 | title = "Clean up CODEOWNERS file" 150 | 151 | body = os.getenv("BODY") 152 | # make sure that body is a string with less than 65536 characters 153 | if body: 154 | if len(body) > 65536: 155 | raise ValueError( 156 | "BODY environment variable is too long. Max 65536 characters" 157 | ) 158 | else: 159 | body = "Consider these updates to the CODEOWNERS file to remove users no longer in this organization." 160 | 161 | commit_message = os.getenv("COMMIT_MESSAGE") 162 | if commit_message: 163 | if len(commit_message) > 65536: 164 | raise ValueError( 165 | "COMMIT_MESSAGE environment variable is too long. Max 65536 characters" 166 | ) 167 | else: 168 | commit_message = ( 169 | "Remove users no longer in this organization from CODEOWNERS file" 170 | ) 171 | 172 | issue_report = get_bool_env_var("ISSUE_REPORT") 173 | 174 | return ( 175 | organization, 176 | repositories_list, 177 | gh_app_id, 178 | gh_app_installation_id, 179 | gh_app_private_key_bytes, 180 | gh_app_enterprise_only, 181 | token, 182 | ghe, 183 | exempt_repositories_list, 184 | dry_run, 185 | title, 186 | body, 187 | commit_message, 188 | issue_report, 189 | ) 190 | -------------------------------------------------------------------------------- /test_env.py: -------------------------------------------------------------------------------- 1 | """Test the get_env_vars function""" 2 | 3 | import os 4 | import unittest 5 | from unittest.mock import patch 6 | 7 | from env import get_env_vars 8 | 9 | BODY = "Consider these updates to the CODEOWNERS file to remove users no longer in this organization." 10 | COMMIT_MESSAGE = "Remove users no longer in this organization from CODEOWNERS file" 11 | ORGANIZATION = "Organization01" 12 | TITLE = "Clean up CODEOWNERS file" 13 | TOKEN = "Token01" 14 | 15 | 16 | class TestEnv(unittest.TestCase): 17 | """Test the get_env_vars function""" 18 | 19 | def setUp(self): 20 | env_keys = [ 21 | "BODY", 22 | "COMMIT_MESSAGE", 23 | "DRY_RUN", 24 | "EXEMPT_REPOS", 25 | "GH_APP_ID", 26 | "GH_ENTERPRISE_URL", 27 | "GH_APP_INSTALLATION_ID", 28 | "GH_APP_PRIVATE_KEY", 29 | "GH_TOKEN", 30 | "ORGANIZATION", 31 | "REPOSITORY", 32 | "TITLE", 33 | "ISSUE_REPORT", 34 | ] 35 | for key in env_keys: 36 | if key in os.environ: 37 | del os.environ[key] 38 | 39 | @patch.dict( 40 | os.environ, 41 | { 42 | "BODY": BODY, 43 | "COMMIT_MESSAGE": COMMIT_MESSAGE, 44 | "DRY_RUN": "false", 45 | "EXEMPT_REPOS": "repo4,repo5", 46 | "GH_APP_ID": "", 47 | "GH_ENTERPRISE_URL": "", 48 | "GH_APP_INSTALLATION_ID": "", 49 | "GH_APP_PRIVATE_KEY": "", 50 | "GH_TOKEN": TOKEN, 51 | "ORGANIZATION": ORGANIZATION, 52 | "REPOSITORY": "org/repo1,org2/repo2", 53 | "TITLE": TITLE, 54 | }, 55 | ) 56 | def test_get_env_vars_with_org(self): 57 | """Test that all environment variables are set correctly using an organization""" 58 | expected_result = ( 59 | ORGANIZATION, 60 | ["org/repo1", "org2/repo2"], 61 | None, 62 | None, 63 | b"", 64 | False, 65 | TOKEN, 66 | "", 67 | ["repo4", "repo5"], 68 | False, 69 | TITLE, 70 | BODY, 71 | COMMIT_MESSAGE, 72 | False, 73 | ) 74 | result = get_env_vars(True) 75 | self.assertEqual(result, expected_result) 76 | 77 | @patch.dict( 78 | os.environ, 79 | { 80 | "BODY": BODY, 81 | "COMMIT_MESSAGE": COMMIT_MESSAGE, 82 | "DRY_RUN": "true", 83 | "EXEMPT_REPOS": "repo4,repo5", 84 | "GH_APP_ID": "12345", 85 | "GH_ENTERPRISE_URL": "", 86 | "GH_APP_INSTALLATION_ID": "678910", 87 | "GH_APP_PRIVATE_KEY": "hello", 88 | "GH_TOKEN": "", 89 | "ORGANIZATION": "", 90 | "REPOSITORY": "org/repo1,org2/repo2", 91 | "TITLE": TITLE, 92 | }, 93 | clear=True, 94 | ) 95 | def test_get_env_vars_with_github_app_and_repos(self): 96 | """Test that all environment variables are set correctly using a list of repositories""" 97 | expected_result = ( 98 | "", 99 | ["org/repo1", "org2/repo2"], 100 | 12345, 101 | 678910, 102 | b"hello", 103 | False, 104 | "", 105 | "", 106 | ["repo4", "repo5"], 107 | True, 108 | TITLE, 109 | BODY, 110 | COMMIT_MESSAGE, 111 | False, 112 | ) 113 | result = get_env_vars(True) 114 | self.assertEqual(result, expected_result) 115 | 116 | @patch.dict( 117 | os.environ, 118 | { 119 | "ORGANIZATION": "my_organization", 120 | "GH_APP_ID": "12345", 121 | "GH_APP_INSTALLATION_ID": "", 122 | "GH_APP_PRIVATE_KEY": "", 123 | "GH_TOKEN": "", 124 | }, 125 | clear=True, 126 | ) 127 | def test_get_env_vars_auth_with_github_app_installation_missing_inputs(self): 128 | """Test that an error is raised there are missing inputs for the gh app""" 129 | with self.assertRaises(ValueError) as context_manager: 130 | get_env_vars(True) 131 | the_exception = context_manager.exception 132 | self.assertEqual( 133 | str(the_exception), 134 | "GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set", 135 | ) 136 | 137 | @patch.dict( 138 | os.environ, 139 | { 140 | "BODY": BODY, 141 | "COMMIT_MESSAGE": COMMIT_MESSAGE, 142 | "DRY_RUN": "true", 143 | "EXEMPT_REPOS": "repo4,repo5", 144 | "GH_APP_ID": "", 145 | "GH_ENTERPRISE_URL": "", 146 | "GH_APP_INSTALLATION_ID": "", 147 | "GH_APP_PRIVATE_KEY": "", 148 | "GH_TOKEN": TOKEN, 149 | "ORGANIZATION": "", 150 | "REPOSITORY": "org/repo1,org2/repo2", 151 | "TITLE": TITLE, 152 | }, 153 | clear=True, 154 | ) 155 | def test_get_env_vars_with_token_and_repos(self): 156 | """Test that all environment variables are set correctly using a list of repositories""" 157 | expected_result = ( 158 | "", 159 | ["org/repo1", "org2/repo2"], 160 | None, 161 | None, 162 | b"", 163 | False, 164 | TOKEN, 165 | "", 166 | ["repo4", "repo5"], 167 | True, 168 | TITLE, 169 | BODY, 170 | COMMIT_MESSAGE, 171 | False, 172 | ) 173 | result = get_env_vars(True) 174 | self.assertEqual(result, expected_result) 175 | 176 | @patch.dict( 177 | os.environ, 178 | { 179 | "BODY": BODY, 180 | "COMMIT_MESSAGE": COMMIT_MESSAGE, 181 | "GH_APP_ID": "", 182 | "GH_APP_INSTALLATION_ID": "", 183 | "GH_APP_PRIVATE_KEY": "", 184 | "GH_TOKEN": TOKEN, 185 | "ORGANIZATION": ORGANIZATION, 186 | "TITLE": TITLE, 187 | "ISSUE_REPORT": "true", 188 | }, 189 | ) 190 | def test_get_env_vars_optional_values(self): 191 | """Test that optional values are set to their default values if not provided""" 192 | expected_result = ( 193 | ORGANIZATION, 194 | [], 195 | None, 196 | None, 197 | b"", 198 | False, 199 | TOKEN, 200 | "", 201 | [], 202 | False, 203 | TITLE, 204 | BODY, 205 | COMMIT_MESSAGE, 206 | True, 207 | ) 208 | result = get_env_vars(True) 209 | self.assertEqual(result, expected_result) 210 | 211 | @patch.dict(os.environ, {}) 212 | def test_get_env_vars_missing_org_or_repo(self): 213 | """Test that an error is raised if required environment variables are not set""" 214 | with self.assertRaises(ValueError): 215 | get_env_vars(True) 216 | 217 | @patch.dict( 218 | os.environ, 219 | { 220 | "ORGANIZATION": ORGANIZATION, 221 | }, 222 | clear=True, 223 | ) 224 | def test_get_env_vars_missing_token(self): 225 | """Test that an error is raised if required environment variables are not set""" 226 | with self.assertRaises(ValueError): 227 | get_env_vars(True) 228 | 229 | @patch.dict( 230 | os.environ, 231 | { 232 | "GH_APP_ID": "", 233 | "GH_APP_INSTALLATION_ID": "", 234 | "GH_APP_PRIVATE_KEY": "", 235 | "GH_TOKEN": TOKEN, 236 | "ORGANIZATION": ORGANIZATION, 237 | }, 238 | clear=True, 239 | ) 240 | def test_get_env_vars_with_repos_no_dry_run(self): 241 | """Test that all environment variables are set correctly when DRY_RUN is false""" 242 | expected_result = ( 243 | ORGANIZATION, 244 | [], 245 | None, 246 | None, 247 | b"", 248 | False, 249 | TOKEN, 250 | "", 251 | [], 252 | False, 253 | "Clean up CODEOWNERS file", 254 | "Consider these updates to the CODEOWNERS file to remove users no longer in this organization.", 255 | "Remove users no longer in this organization from CODEOWNERS file", 256 | False, 257 | ) 258 | result = get_env_vars(True) 259 | self.assertEqual(result, expected_result) 260 | 261 | 262 | if __name__ == "__main__": 263 | unittest.main() 264 | -------------------------------------------------------------------------------- /cleanowners.py: -------------------------------------------------------------------------------- 1 | """A GitHub Action to suggest removal of non-organization members from CODEOWNERS files.""" 2 | 3 | import uuid 4 | 5 | import auth 6 | import env 7 | import github3 8 | from markdown_writer import write_to_markdown 9 | 10 | 11 | def get_org(github_connection, organization): 12 | """Get the organization object""" 13 | try: 14 | return github_connection.organization(organization) 15 | except github3.exceptions.NotFoundError: 16 | print(f"Organization {organization} not found") 17 | return None 18 | 19 | 20 | def main(): # pragma: no cover 21 | """Run the main program""" 22 | 23 | # Get the environment variables 24 | ( 25 | organization, 26 | repository_list, 27 | gh_app_id, 28 | gh_app_installation_id, 29 | gh_app_private_key_bytes, 30 | gh_app_enterprise_only, 31 | token, 32 | ghe, 33 | exempt_repositories_list, 34 | dry_run, 35 | title, 36 | body, 37 | commit_message, 38 | issue_report, 39 | ) = env.get_env_vars() 40 | 41 | # Auth to GitHub.com or GHE 42 | github_connection = auth.auth_to_github( 43 | token, 44 | gh_app_id, 45 | gh_app_installation_id, 46 | gh_app_private_key_bytes, 47 | ghe, 48 | gh_app_enterprise_only, 49 | ) 50 | 51 | pull_count = 0 52 | eligble_for_pr_count = 0 53 | no_codeowners_count = 0 54 | codeowners_count = 0 55 | users_count = 0 56 | 57 | if organization and not repository_list: 58 | gh_org = get_org(github_connection, organization) 59 | if not gh_org: 60 | raise ValueError( 61 | f"""Organization {organization} is not an organization and 62 | REPOSITORY environment variable was not set. 63 | Please set valid ORGANIZATION or set REPOSITORY environment 64 | variable 65 | """ 66 | ) 67 | 68 | # Get the repositories from the organization or list of repositories 69 | repos = get_repos_iterator(organization, repository_list, github_connection) 70 | 71 | repo_and_users_to_remove = {} 72 | repos_missing_codeowners = [] 73 | for repo in repos: 74 | # Check if the repository is in the exempt_repositories_list 75 | if repo.full_name in exempt_repositories_list: 76 | print(f"Skipping {repo.full_name} as it is in the exempt_repositories_list") 77 | continue 78 | 79 | # Check to see if repository is archived 80 | if repo.archived: 81 | print(f"Skipping {repo.full_name} as it is archived") 82 | continue 83 | 84 | # Check to see if repository has a CODEOWNERS file 85 | file_changed = False 86 | codeowners_file_contents, codeowners_filepath = get_codeowners_file(repo) 87 | 88 | if not codeowners_file_contents: 89 | print(f"Skipping {repo.full_name} as it does not have a CODEOWNERS file") 90 | no_codeowners_count += 1 91 | repos_missing_codeowners.append(repo) 92 | continue 93 | 94 | codeowners_count += 1 95 | 96 | if codeowners_file_contents.content is None: 97 | # This is a large file so we need to get the sha and download based off the sha 98 | codeowners_file_contents = repo.blob( 99 | repo.file_contents(codeowners_filepath).sha 100 | ).decode_content() 101 | 102 | # Extract the usernames from the CODEOWNERS file 103 | usernames = get_usernames_from_codeowners(codeowners_file_contents) 104 | 105 | usernames_to_remove = [] 106 | codeowners_file_contents_new = None 107 | for username in usernames: 108 | org = organization if organization else repo.owner.login 109 | gh_org = get_org(github_connection, org) 110 | if not gh_org: 111 | print(f"Owner {org} of repo {repo} is not an organization.") 112 | break 113 | 114 | # Check to see if the username is a member of the organization 115 | if not gh_org.is_member(username): 116 | print( 117 | f"\t{username} is not a member of {org}. Suggest removing them from {repo.full_name}" 118 | ) 119 | users_count += 1 120 | usernames_to_remove.append(username) 121 | if not dry_run: 122 | # Remove that username from the codeowners_file_contents 123 | file_changed = True 124 | bytes_username = f"@{username}".encode("ASCII") 125 | codeowners_file_contents_new = ( 126 | codeowners_file_contents.decoded.replace(bytes_username, b"") 127 | ) 128 | 129 | # Store the repo and users to remove for reporting later 130 | if usernames_to_remove: 131 | repo_and_users_to_remove[repo] = usernames_to_remove 132 | 133 | # Update the CODEOWNERS file if usernames were removed 134 | if file_changed: 135 | eligble_for_pr_count += 1 136 | new_usernames = get_usernames_from_codeowners(codeowners_file_contents_new) 137 | if len(new_usernames) == 0: 138 | print( 139 | f"\twarning: All usernames removed from CODEOWNERS in {repo.full_name}." 140 | ) 141 | try: 142 | pull = commit_changes( 143 | title, 144 | body, 145 | repo, 146 | codeowners_file_contents_new, 147 | commit_message, 148 | codeowners_filepath, 149 | ) 150 | pull_count += 1 151 | print(f"\tCreated pull request {pull.html_url}") 152 | except github3.exceptions.NotFoundError: 153 | print("\tFailed to create pull request. Check write permissions.") 154 | continue 155 | 156 | # Report the statistics from this run 157 | print_stats( 158 | pull_count=pull_count, 159 | eligble_for_pr_count=eligble_for_pr_count, 160 | no_codeowners_count=no_codeowners_count, 161 | codeowners_count=codeowners_count, 162 | users_count=users_count, 163 | ) 164 | 165 | if issue_report: 166 | write_to_markdown( 167 | users_count, 168 | pull_count, 169 | no_codeowners_count, 170 | codeowners_count, 171 | repo_and_users_to_remove, 172 | repos_missing_codeowners, 173 | ) 174 | 175 | 176 | def get_codeowners_file(repo): 177 | """ 178 | Get the CODEOWNERS file from the repository and return 179 | the file contents and file path or None if it doesn't exist 180 | """ 181 | codeowners_file_contents = None 182 | codeowners_filepath = None 183 | try: 184 | if ( 185 | repo.file_contents(".github/CODEOWNERS") 186 | and repo.file_contents(".github/CODEOWNERS").size > 0 187 | ): 188 | codeowners_file_contents = repo.file_contents(".github/CODEOWNERS") 189 | codeowners_filepath = ".github/CODEOWNERS" 190 | except github3.exceptions.NotFoundError: 191 | pass 192 | try: 193 | if ( 194 | repo.file_contents("CODEOWNERS") 195 | and repo.file_contents("CODEOWNERS").size > 0 196 | ): 197 | codeowners_file_contents = repo.file_contents("CODEOWNERS") 198 | codeowners_filepath = "CODEOWNERS" 199 | except github3.exceptions.NotFoundError: 200 | pass 201 | try: 202 | if ( 203 | repo.file_contents("docs/CODEOWNERS") 204 | and repo.file_contents("docs/CODEOWNERS").size > 0 205 | ): 206 | codeowners_file_contents = repo.file_contents("docs/CODEOWNERS") 207 | codeowners_filepath = "docs/CODEOWNERS" 208 | except github3.exceptions.NotFoundError: 209 | pass 210 | return codeowners_file_contents, codeowners_filepath 211 | 212 | 213 | def print_stats( 214 | pull_count, eligble_for_pr_count, no_codeowners_count, codeowners_count, users_count 215 | ): 216 | """Print the statistics from this run to the terminal output""" 217 | print(f"Found {users_count} users to remove") 218 | print(f"Created {pull_count} pull requests successfully") 219 | print(f"Skipped {no_codeowners_count} repositories without a CODEOWNERS file") 220 | print(f"Processed {codeowners_count} repositories with a CODEOWNERS file") 221 | if eligble_for_pr_count == 0: 222 | print("No pull requests were needed") 223 | else: 224 | print( 225 | f"{round((pull_count / eligble_for_pr_count) * 100, 2)}% of eligible repositories had pull requests created" 226 | ) 227 | if codeowners_count + no_codeowners_count == 0: 228 | print("No repositories were processed") 229 | else: 230 | print( 231 | f"{round((codeowners_count / (codeowners_count + no_codeowners_count)) * 100, 2)}% of repositories had CODEOWNERS files" 232 | ) 233 | 234 | 235 | def get_repos_iterator(organization, repository_list, github_connection): 236 | """Get the repositories from the organization or list of repositories""" 237 | repos = [] 238 | if organization and not repository_list: 239 | repos = github_connection.organization(organization).repositories() 240 | else: 241 | # Get the repositories from the repository_list 242 | for full_repo_path in repository_list: 243 | org = full_repo_path.split("/")[0] 244 | repo = full_repo_path.split("/")[1] 245 | repos.append(github_connection.repository(org, repo)) 246 | 247 | return repos 248 | 249 | 250 | def get_usernames_from_codeowners(codeowners_file_contents, ignore_teams=True): 251 | """Extract the usernames from the CODEOWNERS file""" 252 | usernames = [] 253 | for line in codeowners_file_contents.decoded.splitlines(): 254 | if line: 255 | line = line.decode() 256 | # skip comments 257 | if line.lstrip().startswith("#"): 258 | continue 259 | # skip empty lines 260 | if not line.strip(): 261 | continue 262 | # Identify handles 263 | if "@" in line: 264 | handles = line.split("@")[1:] 265 | for handle in handles: 266 | handle = handle.split()[0] 267 | # Identify team handles by the presence of a slash. 268 | # Ignore teams because non-org members cannot be in a team. 269 | if ignore_teams and "/" not in handle: 270 | usernames.append(handle) 271 | elif not ignore_teams: 272 | usernames.append(handle) 273 | return usernames 274 | 275 | 276 | def commit_changes( 277 | title, 278 | body, 279 | repo, 280 | codeowners_file_contents_new, 281 | commit_message, 282 | codeowners_filepath, 283 | ): 284 | """Commit the changes to the repo and open a pull request and return the pull request object""" 285 | default_branch = repo.default_branch 286 | # Get latest commit sha from default branch 287 | default_branch_commit = repo.ref(f"heads/{default_branch}").object.sha 288 | front_matter = "refs/heads/" 289 | branch_name = f"codeowners-{str(uuid.uuid4())}" 290 | repo.create_ref(front_matter + branch_name, default_branch_commit) 291 | repo.file_contents(codeowners_filepath).update( 292 | message=commit_message, 293 | content=codeowners_file_contents_new, 294 | branch=branch_name, 295 | ) 296 | 297 | pull = repo.create_pull( 298 | title=title, body=body, head=branch_name, base=repo.default_branch 299 | ) 300 | return pull 301 | 302 | 303 | if __name__ == "__main__": # pragma: no cover 304 | main() 305 | -------------------------------------------------------------------------------- /test_cleanowners.py: -------------------------------------------------------------------------------- 1 | """Test the functions in the cleanowners module.""" 2 | 3 | import unittest 4 | import uuid 5 | from io import StringIO 6 | from unittest.mock import MagicMock, patch 7 | 8 | import github3 9 | from cleanowners import ( 10 | commit_changes, 11 | get_codeowners_file, 12 | get_org, 13 | get_repos_iterator, 14 | get_usernames_from_codeowners, 15 | print_stats, 16 | ) 17 | 18 | 19 | class TestCommitChanges(unittest.TestCase): 20 | """Test the commit_changes function in cleanowners.py""" 21 | 22 | @patch("uuid.uuid4") 23 | def test_commit_changes(self, mock_uuid): 24 | """Test the commit_changes function.""" 25 | mock_uuid.return_value = uuid.UUID( 26 | "12345678123456781234567812345678" 27 | ) # Mock UUID generation 28 | mock_repo = MagicMock() # Mock repo object 29 | mock_repo.default_branch = "main" 30 | mock_repo.ref.return_value.object.sha = "abc123" # Mock SHA for latest commit 31 | mock_repo.create_ref.return_value = True 32 | mock_repo.file_contents.return_value = MagicMock() 33 | mock_repo.file_contents.update.return_value = True 34 | mock_repo.create_pull.return_value = "MockPullRequest" 35 | 36 | title = "Test Title" 37 | body = "Test Body" 38 | dependabot_file = "testing!" 39 | branch_name = "codeowners-12345678-1234-5678-1234-567812345678" 40 | commit_message = "Test commit message" 41 | result = commit_changes( 42 | title, 43 | body, 44 | mock_repo, 45 | dependabot_file, 46 | commit_message, 47 | "CODEOWNERS", 48 | ) 49 | 50 | # Assert that the methods were called with the correct arguments 51 | mock_repo.create_ref.assert_called_once_with( 52 | f"refs/heads/{branch_name}", "abc123" 53 | ) 54 | mock_repo.file_contents.assert_called_once_with("CODEOWNERS") 55 | mock_repo.create_pull.assert_called_once_with( 56 | title=title, 57 | body=body, 58 | head=branch_name, 59 | base="main", 60 | ) 61 | 62 | # Assert that the function returned the expected result 63 | self.assertEqual(result, "MockPullRequest") 64 | 65 | 66 | class TestGetUsernamesFromCodeowners(unittest.TestCase): 67 | """Test the get_usernames_from_codeowners function in cleanowners.py""" 68 | 69 | def test_get_usernames_from_codeowners_ignore_teams(self): 70 | """Test the get_usernames_from_codeowners function.""" 71 | codeowners_file_contents = MagicMock() 72 | codeowners_file_contents.decoded = """ 73 | # Comment 74 | @user1 75 | @user2 76 | @org/team 77 | # Another comment 78 | @user3 @user4 79 | """.encode( 80 | "ASCII" 81 | ) 82 | expected_usernames = ["user1", "user2", "user3", "user4"] 83 | 84 | result = get_usernames_from_codeowners(codeowners_file_contents) 85 | 86 | self.assertEqual(result, expected_usernames) 87 | 88 | def test_get_usernames_from_codeowners_with_teams(self): 89 | """Test the get_usernames_from_codeowners function.""" 90 | codeowners_file_contents = MagicMock() 91 | codeowners_file_contents.decoded = """ 92 | # Comment 93 | @user1 94 | @user2 95 | @org/team 96 | # Another comment 97 | @user3 @user4 98 | """.encode( 99 | "ASCII" 100 | ) 101 | expected_usernames = ["user1", "user2", "org/team", "user3", "user4"] 102 | 103 | result = get_usernames_from_codeowners(codeowners_file_contents, False) 104 | 105 | self.assertEqual(result, expected_usernames) 106 | 107 | 108 | class TestGetOrganization(unittest.TestCase): 109 | """Test the get_org function in cleanowners.py""" 110 | 111 | @patch("github3.login") 112 | def test_get_organization_succeeds(self, mock_github): 113 | """Test the organization is valid.""" 114 | organization = "my_organization" 115 | github_connection = mock_github.return_value 116 | 117 | mock_organization = MagicMock() 118 | github_connection.organization.return_value = mock_organization 119 | 120 | result = get_org(github_connection, organization) 121 | 122 | github_connection.organization.assert_called_once_with(organization) 123 | self.assertEqual(result, mock_organization) 124 | 125 | @patch("github3.login") 126 | def test_get_organization_fails(self, mock_github): 127 | """Test the organization is not valid.""" 128 | organization = "my_organization" 129 | github_connection = mock_github.return_value 130 | 131 | github_connection.organization.side_effect = github3.exceptions.NotFoundError( 132 | resp=MagicMock(status_code=404) 133 | ) 134 | result = get_org(github_connection, organization) 135 | 136 | github_connection.organization.assert_called_once_with(organization) 137 | self.assertIsNone(result) 138 | 139 | 140 | class TestGetReposIterator(unittest.TestCase): 141 | """Test the get_repos_iterator function in evergreen.py""" 142 | 143 | @patch("github3.login") 144 | def test_get_repos_iterator_with_organization(self, mock_github): 145 | """Test the get_repos_iterator function with an organization""" 146 | organization = "my_organization" 147 | repository_list = [] 148 | github_connection = mock_github.return_value 149 | 150 | mock_organization = MagicMock() 151 | mock_repositories = MagicMock() 152 | mock_organization.repositories.return_value = mock_repositories 153 | github_connection.organization.return_value = mock_organization 154 | 155 | result = get_repos_iterator(organization, repository_list, github_connection) 156 | 157 | # Assert that the organization method was called with the correct argument 158 | github_connection.organization.assert_called_once_with(organization) 159 | 160 | # Assert that the repositories method was called on the organization object 161 | mock_organization.repositories.assert_called_once() 162 | 163 | # Assert that the function returned the expected result 164 | self.assertEqual(result, mock_repositories) 165 | 166 | @patch("github3.login") 167 | def test_get_repos_iterator_with_repository_list(self, mock_github): 168 | """Test the get_repos_iterator function with a repository list""" 169 | organization = None 170 | repository_list = ["org/repo1", "org2/repo2"] 171 | github_connection = mock_github.return_value 172 | 173 | mock_repository = MagicMock() 174 | mock_repository_list = [mock_repository, mock_repository] 175 | github_connection.repository.side_effect = mock_repository_list 176 | 177 | result = get_repos_iterator(organization, repository_list, github_connection) 178 | 179 | # Assert that the repository method was called with the correct arguments for each repository in the list 180 | expected_calls = [ 181 | unittest.mock.call("org", "repo1"), 182 | unittest.mock.call("org2", "repo2"), 183 | ] 184 | github_connection.repository.assert_has_calls(expected_calls) 185 | 186 | # Assert that the function returned the expected result 187 | self.assertEqual(result, mock_repository_list) 188 | 189 | 190 | class TestPrintStats(unittest.TestCase): 191 | """Test the print_stats function in cleanowners.py""" 192 | 193 | @patch("sys.stdout", new_callable=StringIO) 194 | def test_print_stats_all_counts(self, mock_stdout): 195 | """Test the print_stats function with all counts.""" 196 | print_stats(5, 10, 2, 3, 4) 197 | expected_output = ( 198 | "Found 4 users to remove\n" 199 | "Created 5 pull requests successfully\n" 200 | "Skipped 2 repositories without a CODEOWNERS file\n" 201 | "Processed 3 repositories with a CODEOWNERS file\n" 202 | "50.0% of eligible repositories had pull requests created\n" 203 | "60.0% of repositories had CODEOWNERS files\n" 204 | ) 205 | self.assertEqual(mock_stdout.getvalue(), expected_output) 206 | 207 | @patch("sys.stdout", new_callable=StringIO) 208 | def test_print_stats_no_pull_requests_needed(self, mock_stdout): 209 | """Test the print_stats function with no pull requests needed.""" 210 | print_stats(0, 0, 2, 3, 4) 211 | expected_output = ( 212 | "Found 4 users to remove\n" 213 | "Created 0 pull requests successfully\n" 214 | "Skipped 2 repositories without a CODEOWNERS file\n" 215 | "Processed 3 repositories with a CODEOWNERS file\n" 216 | "No pull requests were needed\n" 217 | "60.0% of repositories had CODEOWNERS files\n" 218 | ) 219 | self.assertEqual(mock_stdout.getvalue(), expected_output) 220 | 221 | @patch("sys.stdout", new_callable=StringIO) 222 | def test_print_stats_no_repositories_processed(self, mock_stdout): 223 | """Test the print_stats function with no repositories processed.""" 224 | print_stats(0, 0, 0, 0, 0) 225 | expected_output = ( 226 | "Found 0 users to remove\n" 227 | "Created 0 pull requests successfully\n" 228 | "Skipped 0 repositories without a CODEOWNERS file\n" 229 | "Processed 0 repositories with a CODEOWNERS file\n" 230 | "No pull requests were needed\n" 231 | "No repositories were processed\n" 232 | ) 233 | self.assertEqual(mock_stdout.getvalue(), expected_output) 234 | 235 | 236 | class TestGetCodeownersFile(unittest.TestCase): 237 | """Test the get_codeowners_file function in cleanowners.py""" 238 | 239 | def setUp(self): 240 | self.repo = MagicMock() 241 | 242 | def test_codeowners_in_github_folder(self): 243 | """Test that a CODEOWNERS file in the .github folder is considered valid.""" 244 | self.repo.file_contents.side_effect = lambda path: ( 245 | MagicMock(size=1) if path == ".github/CODEOWNERS" else None 246 | ) 247 | contents, path = get_codeowners_file(self.repo) 248 | self.assertIsNotNone(contents) 249 | self.assertEqual(path, ".github/CODEOWNERS") 250 | 251 | def test_codeowners_in_root(self): 252 | """Test that a CODEOWNERS file in the root is considered valid.""" 253 | self.repo.file_contents.side_effect = lambda path: ( 254 | MagicMock(size=1) if path == "CODEOWNERS" else None 255 | ) 256 | contents, path = get_codeowners_file(self.repo) 257 | self.assertIsNotNone(contents) 258 | self.assertEqual(path, "CODEOWNERS") 259 | 260 | def test_codeowners_in_docs_folder(self): 261 | """Test that a CODEOWNERS file in a docs folder is considered valid.""" 262 | self.repo.file_contents.side_effect = lambda path: ( 263 | MagicMock(size=1) if path == "docs/CODEOWNERS" else None 264 | ) 265 | contents, path = get_codeowners_file(self.repo) 266 | self.assertIsNotNone(contents) 267 | self.assertEqual(path, "docs/CODEOWNERS") 268 | 269 | def test_codeowners_not_found(self): 270 | """Test that a missing CODEOWNERS file is not considered valid because it doesn't exist.""" 271 | self.repo.file_contents.side_effect = lambda path: None 272 | contents, path = get_codeowners_file(self.repo) 273 | self.assertIsNone(contents) 274 | self.assertIsNone(path) 275 | 276 | def test_codeowners_empty_file(self): 277 | """Test that an empty CODEOWNERS file is not considered valid because it is empty.""" 278 | self.repo.file_contents.side_effect = lambda path: MagicMock(size=0) 279 | contents, path = get_codeowners_file(self.repo) 280 | self.assertIsNone(contents) 281 | self.assertIsNone(path) 282 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cleanowners action 2 | 3 | [![CodeQL](https://github.com/github/cleanowners/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/github/cleanowners/actions/workflows/github-code-scanning/codeql) [![Lint Code Base](https://github.com/github/cleanowners/actions/workflows/super-linter.yaml/badge.svg)](https://github.com/github/cleanowners/actions/workflows/super-linter.yaml) [![Python package](https://github.com/github/cleanowners/actions/workflows/python-ci.yml/badge.svg)](https://github.com/github/cleanowners/actions/workflows/python-ci.yml) [![Docker Image CI](https://github.com/github/cleanowners/actions/workflows/docker-ci.yml/badge.svg)](https://github.com/github/cleanowners/actions/workflows/docker-ci.yml)[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/github/cleanowners/badge)](https://scorecard.dev/viewer/?uri=github.com/github/cleanowners) 4 | 5 | Cleanowners is a GitHub Action that is designed to help keep `CODEOWNERS` files current by removing users that are no longer a part of the organization. This is helpful for companies that are looking to remove outdated information in the `CODEOWNERS` file. This action can be paired with other `CODEOWNERS` related actions to suggest new owners or lint `CODEOWNERS` files to ensure accuracy. 6 | 7 | This action was developed by the GitHub OSPO for our own use and developed in a way that we could open source it that it might be useful to you as well! If you want to know more about how we use it, reach out in an issue in this repository. 8 | 9 | ## Support 10 | 11 | If you need support using this project or have questions about it, please [open up an issue in this repository](https://github.com/github/cleanowners/issues). Requests made directly to GitHub staff or support team will be redirected here to open an issue. GitHub SLA's and support/services contracts do not apply to this repository. 12 | 13 | ### OSPO GitHub Actions as a Whole 14 | 15 | All feedback regarding our GitHub Actions, as a whole, should be communicated through [issues on our github-ospo repository](https://github.com/github/github-ospo/issues/new). 16 | 17 | ## Use as a GitHub Action 18 | 19 | 1. Create a repository to host this GitHub Action or select an existing repository. 20 | 1. Select a best fit workflow file from the [examples below](#example-workflows). 21 | 1. Copy that example into your repository (from step 1) and into the proper directory for GitHub Actions: `.github/workflows/` directory with the file extension `.yml` (ie. `.github/workflows/cleanowners.yml`) 22 | 1. Edit the values (`ORGANIZATION`, `EXEMPT_REPOS`) from the sample workflow with your information. 23 | 1. Also edit the value for `GH_ENTERPRISE_URL` if you are using a GitHub Server and not using github.com. For github.com users, don't put anything in here. 24 | 1. Update the value of `GH_TOKEN`. Do this by creating a [GitHub API token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with permissions to read the repository/organization and write issues or pull requests. Then take the value of the API token you just created, and [create a repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) where the name of the secret is `GH_TOKEN` and the value of the secret the API token. It just needs to match between when you create the secret name and when you refer to it in the workflow file. 25 | 1. Commit the workflow file to the default branch (often `master` or `main`) 26 | 1. Wait for the action to trigger based on the `schedule` entry or manually trigger the workflow as shown in the [documentation](https://docs.github.com/en/actions/using-workflows/manually-running-a-workflow). 27 | 28 | ### Configuration 29 | 30 | Below are the allowed configuration options: 31 | 32 | #### Authentication 33 | 34 | This action can be configured to authenticate with GitHub App Installation or Personal Access Token (PAT). If all configuration options are provided, the GitHub App Installation configuration has precedence. You can choose one of the following methods to authenticate: 35 | 36 | ##### GitHub App Installation 37 | 38 | | field | required | default | description | 39 | | ---------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 40 | | `GH_APP_ID` | True | `""` | GitHub Application ID. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | 41 | | `GH_APP_INSTALLATION_ID` | True | `""` | GitHub Application Installation ID. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | 42 | | `GH_APP_PRIVATE_KEY` | True | `""` | GitHub Application Private Key. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | 43 | | `GITHUB_APP_ENTERPRISE_ONLY` | False | `false` | Set this input to `true` if your app is created in GHE and communicates with GHE. | 44 | 45 | ##### Personal Access Token (PAT) 46 | 47 | | field | required | default | description | 48 | | ---------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------- | 49 | | `GH_TOKEN` | True | `""` | The GitHub Token used to scan the repository. Must have read access to all repository you are interested in scanning. | 50 | 51 | #### Other Configuration Options 52 | 53 | | field | required | default | description | 54 | | ------------------- | ----------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 55 | | `GH_ENTERPRISE_URL` | False | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. | 56 | | `ORGANIZATION` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the GitHub organization which you want this action to work from. ie. github.com/github would be `github` | 57 | | `REPOSITORY` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the repository and organization which you want this action to work from. ie. `github/cleanowners` or a comma separated list of multiple repositories `github/cleanowners,super-linter/super-linter` | 58 | | `EXEMPT_REPOS` | False | "" | These repositories will be exempt from this action. ex: If my org is set to `github` then I might want to exempt a few of the repos but get the rest by setting `EXEMPT_REPOS` to `github/cleanowners,github/contributors` | 59 | | `DRY_RUN` | False | False | If set to true, this action will not create any pull requests. It will only log the repositories that could have the `CODEOWNERS` file updated. This is useful for testing or discovering the scope of this issue in your organization. | 60 | | `ISSUE_REPORT` | False | False | If set to true, this action will create an issue in the repository with the report on the repositories that had users removed from the `CODEOWNERS` file. | 61 | 62 | ### Example workflows 63 | 64 | #### Basic 65 | 66 | ```yaml 67 | --- 68 | name: Weekly codeowners cleanup 69 | on: 70 | workflow_dispatch: 71 | schedule: 72 | - cron: "3 2 1 * *" 73 | 74 | permissions: 75 | contents: read 76 | 77 | jobs: 78 | cleanowners: 79 | name: cleanowners 80 | runs-on: ubuntu-latest 81 | permissions: 82 | issues: write 83 | 84 | steps: 85 | - name: Run cleanowners action 86 | uses: github/cleanowners@v1 87 | env: 88 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 89 | ORGANIZATION: 90 | ``` 91 | 92 | #### Advanced 93 | 94 | ```yaml 95 | --- 96 | name: Weekly codeowners cleanup 97 | on: 98 | workflow_dispatch: 99 | schedule: 100 | - cron: "3 2 1 * *" 101 | 102 | permissions: 103 | contents: read 104 | 105 | jobs: 106 | cleanowners: 107 | name: cleanowners 108 | runs-on: ubuntu-latest 109 | permissions: 110 | issues: write 111 | 112 | steps: 113 | - name: Run cleanowners action 114 | uses: github/cleanowners@v1 115 | env: 116 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 117 | ORGANIZATION: 118 | EXEMPT_REPOS: "org_name/repo_name_1, org_name/repo_name_2" 119 | ISSUE_REPORT: true 120 | - name: Create issue 121 | uses: peter-evans/create-issue-from-file@v5 122 | with: 123 | title: Cleanowners Report 124 | content-filepath: ./report.md 125 | assignees: 126 | token: ${{ secrets.GITHUB_TOKEN }} 127 | ``` 128 | 129 | ### Authenticating with a GitHub App and Installation 130 | 131 | You can authenticate as a GitHub App Installation by providing additional environment variables. If `GH_TOKEN` is set alongside these GitHub App Installation variables, the `GH_TOKEN` will be ignored and not used. 132 | 133 | ```yaml 134 | --- 135 | name: Weekly codeowners cleanup via GitHub App 136 | on: 137 | workflow_dispatch: 138 | schedule: 139 | - cron: "3 2 1 * *" 140 | 141 | permissions: 142 | contents: read 143 | 144 | jobs: 145 | cleanowners: 146 | name: cleanowners 147 | runs-on: ubuntu-latest 148 | permissions: 149 | issues: write 150 | 151 | steps: 152 | - name: Run cleanowners action 153 | uses: github/cleanowners@v1 154 | env: 155 | GH_APP_ID: ${{ secrets.GH_APP_ID }} 156 | GH_APP_INSTALLATION_ID: ${{ secrets.GH_APP_INSTALLATION_ID }} 157 | GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} 158 | ORGANIZATION: 159 | EXEMPT_REPOS: "org_name/repo_name_1, org_name/repo_name_2" 160 | ``` 161 | 162 | ## Local usage without Docker 163 | 164 | 1. Make sure you have at least Python3.11 installed 165 | 1. Copy `.env-example` to `.env` 166 | 1. Fill out the `.env` file with a _token_ from a user that has access to the organization (listed below). Tokens should have at least write:org and write:repository access. 167 | 1. Fill out the `.env` file with the configuration parameters you want to use 168 | 1. `pip3 install -r requirements.txt` 169 | 1. Run `python3 ./cleanowners.py`, which will output everything in the terminal 170 | 171 | ## License 172 | 173 | [MIT](LICENSE) 174 | 175 | ## More OSPO Tools 176 | 177 | Looking for more resources for your open source program office (OSPO)? Check out the [`github-ospo`](https://github.com/github/github-ospo) repository for a variety of tools designed to support your needs. 178 | -------------------------------------------------------------------------------- /.github/linters/.python-lint: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=yes 88 | 89 | # Minimum Python version to use for version dependent checks. Will default to 90 | # the version used to run pylint. 91 | py-version=3.11 92 | 93 | # Discover python modules and packages in the file system subtree. 94 | recursive=no 95 | 96 | # Add paths to the list of the source roots. Supports globbing patterns. The 97 | # source root is an absolute path or a path relative to the current working 98 | # directory used to determine a package namespace for modules located under the 99 | # source root. 100 | source-roots= 101 | 102 | # When enabled, pylint would attempt to guess common misconfiguration and emit 103 | # user-friendly hints instead of false-positive error messages. 104 | suggestion-mode=yes 105 | 106 | # Allow loading of arbitrary C extensions. Extensions are imported into the 107 | # active Python interpreter and may run arbitrary code. 108 | unsafe-load-any-extension=no 109 | 110 | # In verbose mode, extra non-checker-related info will be displayed. 111 | #verbose= 112 | 113 | 114 | [BASIC] 115 | 116 | # Naming style matching correct argument names. 117 | argument-naming-style=snake_case 118 | 119 | # Regular expression matching correct argument names. Overrides argument- 120 | # naming-style. If left empty, argument names will be checked with the set 121 | # naming style. 122 | #argument-rgx= 123 | 124 | # Naming style matching correct attribute names. 125 | attr-naming-style=snake_case 126 | 127 | # Regular expression matching correct attribute names. Overrides attr-naming- 128 | # style. If left empty, attribute names will be checked with the set naming 129 | # style. 130 | #attr-rgx= 131 | 132 | # Bad variable names which should always be refused, separated by a comma. 133 | bad-names=foo, 134 | bar, 135 | baz, 136 | toto, 137 | tutu, 138 | tata 139 | 140 | # Bad variable names regexes, separated by a comma. If names match any regex, 141 | # they will always be refused 142 | bad-names-rgxs= 143 | 144 | # Naming style matching correct class attribute names. 145 | class-attribute-naming-style=any 146 | 147 | # Regular expression matching correct class attribute names. Overrides class- 148 | # attribute-naming-style. If left empty, class attribute names will be checked 149 | # with the set naming style. 150 | #class-attribute-rgx= 151 | 152 | # Naming style matching correct class constant names. 153 | class-const-naming-style=UPPER_CASE 154 | 155 | # Regular expression matching correct class constant names. Overrides class- 156 | # const-naming-style. If left empty, class constant names will be checked with 157 | # the set naming style. 158 | #class-const-rgx= 159 | 160 | # Naming style matching correct class names. 161 | class-naming-style=PascalCase 162 | 163 | # Regular expression matching correct class names. Overrides class-naming- 164 | # style. If left empty, class names will be checked with the set naming style. 165 | #class-rgx= 166 | 167 | # Naming style matching correct constant names. 168 | const-naming-style=UPPER_CASE 169 | 170 | # Regular expression matching correct constant names. Overrides const-naming- 171 | # style. If left empty, constant names will be checked with the set naming 172 | # style. 173 | #const-rgx= 174 | 175 | # Minimum line length for functions/classes that require docstrings, shorter 176 | # ones are exempt. 177 | docstring-min-length=-1 178 | 179 | # Naming style matching correct function names. 180 | function-naming-style=snake_case 181 | 182 | # Regular expression matching correct function names. Overrides function- 183 | # naming-style. If left empty, function names will be checked with the set 184 | # naming style. 185 | #function-rgx= 186 | 187 | # Good variable names which should always be accepted, separated by a comma. 188 | good-names=i, 189 | j, 190 | k, 191 | ex, 192 | Run, 193 | _ 194 | 195 | # Good variable names regexes, separated by a comma. If names match any regex, 196 | # they will always be accepted 197 | good-names-rgxs= 198 | 199 | # Include a hint for the correct naming format with invalid-name. 200 | include-naming-hint=no 201 | 202 | # Naming style matching correct inline iteration names. 203 | inlinevar-naming-style=any 204 | 205 | # Regular expression matching correct inline iteration names. Overrides 206 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 207 | # with the set naming style. 208 | #inlinevar-rgx= 209 | 210 | # Naming style matching correct method names. 211 | method-naming-style=snake_case 212 | 213 | # Regular expression matching correct method names. Overrides method-naming- 214 | # style. If left empty, method names will be checked with the set naming style. 215 | #method-rgx= 216 | 217 | # Naming style matching correct module names. 218 | module-naming-style=snake_case 219 | 220 | # Regular expression matching correct module names. Overrides module-naming- 221 | # style. If left empty, module names will be checked with the set naming style. 222 | #module-rgx= 223 | 224 | # Colon-delimited sets of names that determine each other's naming style when 225 | # the name regexes allow several styles. 226 | name-group= 227 | 228 | # Regular expression which should only match function or class names that do 229 | # not require a docstring. 230 | no-docstring-rgx=^_ 231 | 232 | # List of decorators that produce properties, such as abc.abstractproperty. Add 233 | # to this list to register other decorators that produce valid properties. 234 | # These decorators are taken in consideration only for invalid-name. 235 | property-classes=abc.abstractproperty 236 | 237 | # Regular expression matching correct type alias names. If left empty, type 238 | # alias names will be checked with the set naming style. 239 | #typealias-rgx= 240 | 241 | # Regular expression matching correct type variable names. If left empty, type 242 | # variable names will be checked with the set naming style. 243 | #typevar-rgx= 244 | 245 | # Naming style matching correct variable names. 246 | variable-naming-style=snake_case 247 | 248 | # Regular expression matching correct variable names. Overrides variable- 249 | # naming-style. If left empty, variable names will be checked with the set 250 | # naming style. 251 | #variable-rgx= 252 | 253 | 254 | [CLASSES] 255 | 256 | # Warn about protected attribute access inside special methods 257 | check-protected-access-in-special-methods=no 258 | 259 | # List of method names used to declare (i.e. assign) instance attributes. 260 | defining-attr-methods=__init__, 261 | __new__, 262 | setUp, 263 | asyncSetUp, 264 | __post_init__ 265 | 266 | # List of member names, which should be excluded from the protected access 267 | # warning. 268 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 269 | 270 | # List of valid names for the first argument in a class method. 271 | valid-classmethod-first-arg=cls 272 | 273 | # List of valid names for the first argument in a metaclass class method. 274 | valid-metaclass-classmethod-first-arg=mcs 275 | 276 | 277 | [DESIGN] 278 | 279 | # List of regular expressions of class ancestor names to ignore when counting 280 | # public methods (see R0903) 281 | exclude-too-few-public-methods= 282 | 283 | # List of qualified class names to ignore when counting class parents (see 284 | # R0901) 285 | ignored-parents= 286 | 287 | # Maximum number of arguments for function / method. 288 | max-args=5 289 | 290 | # Maximum number of attributes for a class (see R0902). 291 | max-attributes=7 292 | 293 | # Maximum number of boolean expressions in an if statement (see R0916). 294 | max-bool-expr=5 295 | 296 | # Maximum number of branch for function / method body. 297 | max-branches=12 298 | 299 | # Maximum number of locals for function / method body. 300 | max-locals=15 301 | 302 | # Maximum number of parents for a class (see R0901). 303 | max-parents=7 304 | 305 | # Maximum number of public methods for a class (see R0904). 306 | max-public-methods=20 307 | 308 | # Maximum number of return / yield for function / method body. 309 | max-returns=6 310 | 311 | # Maximum number of statements in function / method body. 312 | max-statements=50 313 | 314 | # Minimum number of public methods for a class (see R0903). 315 | min-public-methods=2 316 | 317 | 318 | [EXCEPTIONS] 319 | 320 | # Exceptions that will emit a warning when caught. 321 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 322 | 323 | 324 | [FORMAT] 325 | 326 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 327 | expected-line-ending-format= 328 | 329 | # Regexp for a line that is allowed to be longer than the limit. 330 | ignore-long-lines=^\s*(# )??$ 331 | 332 | # Number of spaces of indent required inside a hanging or continued line. 333 | indent-after-paren=4 334 | 335 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 336 | # tab). 337 | indent-string=' ' 338 | 339 | # Maximum number of characters on a single line. 340 | max-line-length=100 341 | 342 | # Maximum number of lines in a module. 343 | max-module-lines=1000 344 | 345 | # Allow the body of a class to be on the same line as the declaration if body 346 | # contains single statement. 347 | single-line-class-stmt=no 348 | 349 | # Allow the body of an if to be on the same line as the test if there is no 350 | # else. 351 | single-line-if-stmt=no 352 | 353 | 354 | [IMPORTS] 355 | 356 | # List of modules that can be imported at any level, not just the top level 357 | # one. 358 | allow-any-import-level= 359 | 360 | # Allow explicit reexports by alias from a package __init__. 361 | allow-reexport-from-package=no 362 | 363 | # Allow wildcard imports from modules that define __all__. 364 | allow-wildcard-with-all=no 365 | 366 | # Deprecated modules which should not be used, separated by a comma. 367 | deprecated-modules= 368 | 369 | # Output a graph (.gv or any supported image format) of external dependencies 370 | # to the given file (report RP0402 must not be disabled). 371 | ext-import-graph= 372 | 373 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 374 | # external) dependencies to the given file (report RP0402 must not be 375 | # disabled). 376 | import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of internal dependencies 379 | # to the given file (report RP0402 must not be disabled). 380 | int-import-graph= 381 | 382 | # Force import order to recognize a module as part of the standard 383 | # compatibility libraries. 384 | known-standard-library= 385 | 386 | # Force import order to recognize a module as part of a third party library. 387 | known-third-party=enchant 388 | 389 | # Couples of modules and preferred modules, separated by a comma. 390 | preferred-modules= 391 | 392 | 393 | [LOGGING] 394 | 395 | # The type of string formatting that logging methods do. `old` means using % 396 | # formatting, `new` is for `{}` formatting. 397 | logging-format-style=old 398 | 399 | # Logging modules to check that the string format arguments are in logging 400 | # function parameter format. 401 | logging-modules=logging 402 | 403 | 404 | [MESSAGES CONTROL] 405 | 406 | # Only show warnings with the listed confidence levels. Leave empty to show 407 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 408 | # UNDEFINED. 409 | confidence=HIGH, 410 | CONTROL_FLOW, 411 | INFERENCE, 412 | INFERENCE_FAILURE, 413 | UNDEFINED 414 | 415 | # Disable the message, report, category or checker with the given id(s). You 416 | # can either give multiple identifiers separated by comma (,) or put this 417 | # option multiple times (only on the command line, not in the configuration 418 | # file where it should appear only once). You can also use "--disable=all" to 419 | # disable everything first and then re-enable specific checks. For example, if 420 | # you want to run only the similarities checker, you can use "--disable=all 421 | # --enable=similarities". If you want to run only the classes checker, but have 422 | # no Warning level messages displayed, use "--disable=all --enable=classes 423 | # --disable=W". 424 | disable=bad-inline-option, 425 | deprecated-pragma, 426 | duplicate-code, 427 | locally-disabled, 428 | file-ignored, 429 | import-error, 430 | line-too-long, 431 | raw-checker-failed, 432 | suppressed-message, 433 | too-many-arguments, 434 | too-many-branches, 435 | too-many-locals, 436 | too-many-positional-arguments, 437 | too-many-statements, 438 | useless-suppression, 439 | use-symbolic-message-instead, 440 | use-implicit-booleaness-not-comparison-to-string, 441 | use-implicit-booleaness-not-comparison-to-zero, 442 | wrong-import-order 443 | 444 | # Enable the message, report, category or checker with the given id(s). You can 445 | # either give multiple identifier separated by comma (,) or put this option 446 | # multiple time (only on the command line, not in the configuration file where 447 | # it should appear only once). See also the "--disable" option for examples. 448 | enable= 449 | 450 | 451 | [METHOD_ARGS] 452 | 453 | # List of qualified names (i.e., library.method) which require a timeout 454 | # parameter e.g. 'requests.api.get,requests.api.post' 455 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 456 | 457 | 458 | [MISCELLANEOUS] 459 | 460 | # List of note tags to take in consideration, separated by a comma. 461 | notes=FIXME, 462 | XXX, 463 | TODO 464 | 465 | # Regular expression of note tags to take in consideration. 466 | notes-rgx= 467 | 468 | 469 | [REFACTORING] 470 | 471 | # Maximum number of nested blocks for function / method body 472 | max-nested-blocks=5 473 | 474 | # Complete name of functions that never returns. When checking for 475 | # inconsistent-return-statements if a never returning function is called then 476 | # it will be considered as an explicit return statement and no message will be 477 | # printed. 478 | never-returning-functions=sys.exit,argparse.parse_error 479 | 480 | 481 | [REPORTS] 482 | 483 | # Python expression which should return a score less than or equal to 10. You 484 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 485 | # 'convention', and 'info' which contain the number of messages in each 486 | # category, as well as 'statement' which is the total number of statements 487 | # analyzed. This score is used by the global evaluation report (RP0004). 488 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 489 | 490 | # Template used to display messages. This is a python new-style format string 491 | # used to format the message information. See doc for all details. 492 | msg-template= 493 | 494 | # Set the output format. Available formats are: text, parseable, colorized, 495 | # json2 (improved json format), json (old json format) and msvs (visual 496 | # studio). You can also give a reporter class, e.g. 497 | # mypackage.mymodule.MyReporterClass. 498 | #output-format= 499 | 500 | # Tells whether to display a full report or only the messages. 501 | reports=no 502 | 503 | # Activate the evaluation score. 504 | score=yes 505 | 506 | 507 | [SIMILARITIES] 508 | 509 | # Comments are removed from the similarity computation 510 | ignore-comments=yes 511 | 512 | # Docstrings are removed from the similarity computation 513 | ignore-docstrings=yes 514 | 515 | # Imports are removed from the similarity computation 516 | ignore-imports=yes 517 | 518 | # Signatures are removed from the similarity computation 519 | ignore-signatures=yes 520 | 521 | # Minimum lines number of a similarity. 522 | min-similarity-lines=4 523 | 524 | 525 | [SPELLING] 526 | 527 | # Limits count of emitted suggestions for spelling mistakes. 528 | max-spelling-suggestions=4 529 | 530 | # Spelling dictionary name. No available dictionaries : You need to install 531 | # both the python package and the system dependency for enchant to work. 532 | spelling-dict= 533 | 534 | # List of comma separated words that should be considered directives if they 535 | # appear at the beginning of a comment and should not be checked. 536 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 537 | 538 | # List of comma separated words that should not be checked. 539 | spelling-ignore-words= 540 | 541 | # A path to a file that contains the private dictionary; one word per line. 542 | spelling-private-dict-file= 543 | 544 | # Tells whether to store unknown words to the private dictionary (see the 545 | # --spelling-private-dict-file option) instead of raising a message. 546 | spelling-store-unknown-words=no 547 | 548 | 549 | [STRING] 550 | 551 | # This flag controls whether inconsistent-quotes generates a warning when the 552 | # character used as a quote delimiter is used inconsistently within a module. 553 | check-quote-consistency=no 554 | 555 | # This flag controls whether the implicit-str-concat should generate a warning 556 | # on implicit string concatenation in sequences defined over several lines. 557 | check-str-concat-over-line-jumps=no 558 | 559 | 560 | [TYPECHECK] 561 | 562 | # List of decorators that produce context managers, such as 563 | # contextlib.contextmanager. Add to this list to register other decorators that 564 | # produce valid context managers. 565 | contextmanager-decorators=contextlib.contextmanager 566 | 567 | # List of members which are set dynamically and missed by pylint inference 568 | # system, and so shouldn't trigger E1101 when accessed. Python regular 569 | # expressions are accepted. 570 | generated-members= 571 | 572 | # Tells whether to warn about missing members when the owner of the attribute 573 | # is inferred to be None. 574 | ignore-none=yes 575 | 576 | # This flag controls whether pylint should warn about no-member and similar 577 | # checks whenever an opaque object is returned when inferring. The inference 578 | # can return multiple potential results while evaluating a Python object, but 579 | # some branches might not be evaluated, which results in partial inference. In 580 | # that case, it might be useful to still emit no-member and other checks for 581 | # the rest of the inferred objects. 582 | ignore-on-opaque-inference=yes 583 | 584 | # List of symbolic message names to ignore for Mixin members. 585 | ignored-checks-for-mixins=no-member, 586 | not-async-context-manager, 587 | not-context-manager, 588 | attribute-defined-outside-init 589 | 590 | # List of class names for which member attributes should not be checked (useful 591 | # for classes with dynamically set attributes). This supports the use of 592 | # qualified names. 593 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 594 | 595 | # Show a hint with possible names when a member name was not found. The aspect 596 | # of finding the hint is based on edit distance. 597 | missing-member-hint=yes 598 | 599 | # The minimum edit distance a name should have in order to be considered a 600 | # similar match for a missing member name. 601 | missing-member-hint-distance=1 602 | 603 | # The total number of similar names that should be taken in consideration when 604 | # showing a hint for a missing member. 605 | missing-member-max-choices=1 606 | 607 | # Regex pattern to define which classes are considered mixins. 608 | mixin-class-rgx=.*[Mm]ixin 609 | 610 | # List of decorators that change the signature of a decorated function. 611 | signature-mutators= 612 | 613 | 614 | [VARIABLES] 615 | 616 | # List of additional names supposed to be defined in builtins. Remember that 617 | # you should avoid defining new builtins when possible. 618 | additional-builtins= 619 | 620 | # Tells whether unused global variables should be treated as a violation. 621 | allow-global-unused-variables=yes 622 | 623 | # List of names allowed to shadow builtins 624 | allowed-redefined-builtins= 625 | 626 | # List of strings which can identify a callback function by name. A callback 627 | # name must start or end with one of those strings. 628 | callbacks=cb_, 629 | _cb 630 | 631 | # A regular expression matching the name of dummy variables (i.e. expected to 632 | # not be used). 633 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 634 | 635 | # Argument names that match this expression will be ignored. 636 | ignored-argument-names=_.*|^ignored_|^unused_ 637 | 638 | # Tells whether we should check for unused import in __init__ files. 639 | init-import=no 640 | 641 | # List of qualified module names which can have objects that can redefine 642 | # builtins. 643 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 644 | --------------------------------------------------------------------------------