diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a186b26 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy this file to .env before running local live operations. +# Never commit .env or paste tokens into issues, logs, or pull requests. + +# Target repository in owner/repository format. +GITHUB_REPOSITORY=owner/repository + +# Required for GitHub Projects v2 creation and synchronization. +# Create a personal access token (classic) with `repo` and `project` scopes. +# Leave empty when you only use dry-run or repository-scoped GitHub Actions. +PROJECT_SETUP_PAT= + +# Optional path to the local setup configuration. +PROJECT_SETUP_CONFIG=project_setup.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 244c7ea..76743b2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,8 @@ -# Optional fallback ownership for governance files +# Fallback ownership for project setup and repository automation files /.github/ @v-Kaefer +/project_setup/ @v-Kaefer /config/ @v-Kaefer /docs/repo/ @v-Kaefer -/scripts/github/ @v-Kaefer +/scripts/validation/ @v-Kaefer +/Makefile @v-Kaefer +/pyproject.toml @v-Kaefer diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ef8a754..bbbfe61 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,26 +1,26 @@ ## Linked Issue -- Closes # +- Closes # ## Milestone -- MS0 +- ## Summary -- +- ## How to test - Test type: automated | smoke | manual -- Steps: describe the commands, manual flow, or verification evidence +- Steps: ## Evidence -- [ ] Screenshot/GIF attached (when applicable) -- [ ] Log/output attached (when applicable) -- [ ] Manual checklist executed (when applicable) +- [ ] Screenshot/GIF attached when applicable +- [ ] Log/output attached when applicable +- [ ] Manual checklist executed when applicable ## Known risks -- +- ## DoD checklist - [ ] Scope implemented as defined - [ ] Tests executed and documented -- [ ] Evidence attached +- [ ] Evidence attached when applicable - [ ] No known critical breakage introduced diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 164b66b..e769b0b 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -3,32 +3,33 @@ name: Auto label on: issues: types: [opened, edited, reopened] - pull_request: + pull_request_target: types: [opened, edited, reopened, synchronize] permissions: contents: read issues: write - pull-requests: read + +concurrency: + group: auto-label-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true jobs: auto-label: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 + - name: Checkout trusted repository automation + uses: actions/checkout@v6 with: - python-version: '3.11' + ref: ${{ github.event.pull_request.base.sha || github.sha }} + persist-credentials: false - - name: Install governance tool - run: pip install -e . --quiet + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" - name: Apply inferred labels env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_EVENT_PATH: ${{ github.event_path }} - run: python -m governance_bootstrap auto-label apply + GITHUB_TOKEN: ${{ github.token }} + run: python -m project_setup auto-label apply --live diff --git a/.github/workflows/branch-naming.yml b/.github/workflows/branch-naming.yml deleted file mode 100644 index 26252e2..0000000 --- a/.github/workflows/branch-naming.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Branch naming validation - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -permissions: - contents: read - -jobs: - validate-branch-name: - runs-on: ubuntu-latest - steps: - - name: Validate head branch pattern - shell: bash - run: | - BRANCH="${{ github.head_ref }}" - echo "Checking branch: $BRANCH" - if [[ ! "$BRANCH" =~ ^(feat|fix|docs|refactor|test|hotfix|milestone|task|copilot)\/[a-z0-9._/-]+$ ]]; then - echo "Invalid branch naming. Use e.g. feat/repo-governance-bootstrap or milestone/m1-setup" >&2 - exit 1 - fi diff --git a/.github/workflows/governance-bootstrap.yml b/.github/workflows/governance-bootstrap.yml deleted file mode 100644 index b70ff7f..0000000 --- a/.github/workflows/governance-bootstrap.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Governance bootstrap (manual) - -on: - workflow_dispatch: - inputs: - run_labels_sync: - description: "Sync labels from config/project/labels.json" - required: true - default: true - type: boolean - run_milestones_sync: - description: "Sync milestones from config/project/milestones.json" - required: true - default: true - type: boolean - run_issue_generation: - description: "Generate stories/tasks from config/stories/backlog-manifest.json" - required: true - default: true - type: boolean - run_project_creation: - description: "Create GitHub Project v2 from project-definition.json" - required: true - default: false - type: boolean - dry_run: - description: "Dry-run issue generation" - required: true - default: true - type: boolean - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - bootstrap: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install governance tool - run: pip install -e . --quiet - - - name: Sync labels - if: ${{ inputs.run_labels_sync }} - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap labels sync --dry-run - else - python -m governance_bootstrap labels sync - fi - - - name: Sync milestones - if: ${{ inputs.run_milestones_sync }} - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap milestones sync --dry-run - else - python -m governance_bootstrap milestones sync - fi - - - name: Create project v2 - if: ${{ inputs.run_project_creation }} - env: - GH_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap project create --dry-run - else - python -m governance_bootstrap project create - fi - - - name: Generate issues and tasks - if: ${{ inputs.run_issue_generation }} - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GH_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap issues generate --dry-run - else - python -m governance_bootstrap issues generate --link-subissues - fi diff --git a/.github/workflows/main-source-branch.yml b/.github/workflows/main-source-branch.yml index 713af9a..9156671 100644 --- a/.github/workflows/main-source-branch.yml +++ b/.github/workflows/main-source-branch.yml @@ -8,12 +8,16 @@ on: permissions: contents: read +concurrency: + group: main-source-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: validate-main-source: name: validate-main-source runs-on: ubuntu-latest steps: - - name: Ensure PR to main comes from develop + - name: Ensure PR to main comes from develop or hotfix shell: bash env: BASE_REF: ${{ github.event.pull_request.base.ref }} @@ -21,13 +25,18 @@ jobs: HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} EXPECTED_REPO: ${{ github.repository }} run: | + set -euo pipefail echo "Base: $BASE_REF" echo "Head: $HEAD_REPO:$HEAD_REF" if [[ "$BASE_REF" != "main" ]]; then - echo "This workflow only validates PRs targeting main." + echo "This workflow only validates PRs targeting main." >&2 + exit 1 + fi + if [[ "$HEAD_REPO" != "$EXPECTED_REPO" ]]; then + echo "PRs targeting main must come from the same repository." >&2 exit 1 fi - if [[ "$HEAD_REPO" != "$EXPECTED_REPO" || "$HEAD_REF" != "develop" ]]; then - echo "PRs targeting main must come from $EXPECTED_REPO:develop." >&2 + if [[ "$HEAD_REF" != "develop" && ! "$HEAD_REF" =~ ^hotfix/[a-z0-9._/-]+$ ]]; then + echo "PRs targeting main must come from develop or hotfix/*." >&2 exit 1 fi diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 3e59904..268e5f1 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -1,32 +1,46 @@ name: PR metadata validation on: - pull_request: - types: [opened, synchronize, reopened, edited] + pull_request_target: + types: [opened, synchronize, reopened, edited, ready_for_review] permissions: contents: read + issues: write + +concurrency: + group: pr-metadata-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: - validate-pr-body: - # Draft PRs are works-in-progress; skip validation until they are marked ready. + validate-pr: if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - - name: Ensure required PR sections and issue link + - name: Checkout trusted base commit + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Validate branch name and pull request metadata env: PR_BODY: ${{ github.event.pull_request.body }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ github.token }} run: | - required=("Linked Issue" "Summary" "How to test") - for section in "${required[@]}"; do - if ! echo "$PR_BODY" | grep -qF "## $section"; then - echo "Missing required PR section: ## $section" >&2 - exit 1 - fi - done - # Accept a real issue number (Closes/Fixes/Resolves #NNN) or an explicit N/A - # when there is genuinely no linked issue. - if ! echo "$PR_BODY" | grep -qE 'Closes #[0-9]+|Fixes #[0-9]+|Resolves #[0-9]+|Closes #[Nn][/\\]?[Aa]|#N/A|#n/a'; then - echo "PR body must reference a linked issue (e.g. Closes #123) or mark it as not applicable (Closes #N/A)" >&2 - exit 1 - fi + set -euo pipefail + python scripts/validation/validate_pr_body.py \ + --branch "$HEAD_REF" \ + --base-branch "$BASE_REF" \ + --repo "$REPOSITORY" \ + --pr-number "$PR_NUMBER" \ + --comment diff --git a/.github/workflows/project-setup.yml b/.github/workflows/project-setup.yml new file mode 100644 index 0000000..653f47f --- /dev/null +++ b/.github/workflows/project-setup.yml @@ -0,0 +1,112 @@ +name: Project setup + +on: + workflow_dispatch: + inputs: + run_labels_sync: + description: "Synchronize labels" + required: true + default: true + type: boolean + run_milestones_sync: + description: "Synchronize milestones" + required: true + default: true + type: boolean + run_issue_generation: + description: "Generate backlog issues and tasks" + required: true + default: false + type: boolean + run_project_creation: + description: "Create a GitHub Project v2 (requires PROJECT_SETUP_PAT for live runs)" + required: true + default: false + type: boolean + dry_run: + description: "Plan changes without writing to GitHub" + required: true + default: true + type: boolean + +permissions: + contents: read + issues: write + +concurrency: + group: project-setup-${{ github.repository }} + cancel-in-progress: false + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Validate embedded setup package and configuration + env: + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + python -m compileall -q project_setup + python -m project_setup doctor --config project_setup.json + + - name: Require PAT for live Project v2 creation + if: ${{ inputs.run_project_creation && !inputs.dry_run }} + env: + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + if [ -z "${PROJECT_SETUP_PAT:-}" ]; then + echo "::error title=PROJECT_SETUP_PAT is required::GitHub's repository-scoped token cannot create or synchronize Projects v2." + echo "Create a personal access token (classic):" + echo " GitHub profile picture > Settings > Developer settings" + echo " Personal access tokens > Tokens (classic) > Generate new token (classic)" + echo " Select scopes: repo and project" + echo "Save it as the repository Actions secret PROJECT_SETUP_PAT, then run this workflow again." + exit 1 + fi + echo "PROJECT_SETUP_PAT is configured for the requested Project v2 operation." + + - name: Apply project setup + env: + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + args="--config project_setup.json" + if [ "${{ inputs.dry_run }}" = "true" ]; then + args="$args --dry-run" + else + args="$args --live" + fi + if [ "${{ inputs.run_labels_sync }}" = "true" ]; then + args="$args --run-labels" + else + args="$args --skip-labels" + fi + if [ "${{ inputs.run_milestones_sync }}" = "true" ]; then + args="$args --run-milestones" + else + args="$args --skip-milestones" + fi + if [ "${{ inputs.run_issue_generation }}" = "true" ]; then + args="$args --run-issue-generation" + else + args="$args --skip-issue-generation" + fi + if [ "${{ inputs.run_project_creation }}" = "true" ]; then + args="$args --run-project-creation" + else + args="$args --skip-project-creation" + fi + python -m project_setup apply $args diff --git a/.github/workflows/repo-quality.yml b/.github/workflows/repo-quality.yml new file mode 100644 index 0000000..4828d38 --- /dev/null +++ b/.github/workflows/repo-quality.yml @@ -0,0 +1,32 @@ +name: Repository quality + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main, develop] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: repo-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Run repository checks + run: make check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 141b66a..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Tests - -on: - push: - branches: ["**"] - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -jobs: - pytest: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install package and test dependencies - run: pip install -e ".[dev]" - - - name: Run tests - run: python -m pytest tests/ -v diff --git a/.gitignore b/.gitignore index 505e9ed..89454e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,19 @@ -# Python __pycache__/ *.py[cod] -*.egg-info/ -dist/ -build/ -.eggs/ -*.egg +*$py.class .venv/ venv/ env/ - -# pytest -.pytest_cache/ - -# local env files .env -.env.local +.env.* +!.env.example +build/ +dist/ +*.egg +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c91427 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 v-Kaefer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c85bd24 --- /dev/null +++ b/Makefile @@ -0,0 +1,153 @@ +ifeq ($(OS),Windows_NT) +PYTHON ?= python +DETECTED_OS := Windows +else +PYTHON ?= python3 +DETECTED_OS := POSIX +endif + +PIP ?= $(PYTHON) -m pip +TARGET ?= +REPO ?= +PROFILE ?= core +PROJECT_TYPE ?= +CONFIG ?= project_setup.json +PROJECT_NUMBER ?= +OWNER ?= +FORCE ?= 0 +LIVE ?= 0 + +WORKDIR := $(if $(strip $(TARGET)),$(TARGET),.) +FORCE_FLAG := $(if $(filter 1 true yes on,$(FORCE)),--force,) +OWNER_FLAG := $(if $(strip $(OWNER)),--owner "$(OWNER)",) +PROJECT_TYPE_FLAG := $(if $(strip $(PROJECT_TYPE)),--project-type "$(PROJECT_TYPE)",) +EXECUTION_FLAG := $(if $(filter 1 true yes on,$(LIVE)),--live,--dry-run) + +.PHONY: help install dev-install compile test quality check doctor discover init init-dry plan apply setup setup-live labels milestones issues project-create project-sync clean clean-generated + +define require_value +$(if $(strip $($(1))),,$(error ERROR: $(1) is required. Fix: $(2))) +endef + +help: + @echo "GitHub Project Setup" + @echo "Detected environment: $(DETECTED_OS); Python command: $(PYTHON)" + @echo "" + @echo "First-time local setup:" + @echo " 1. Copy .env.example to .env" + @echo " 2. Add PROJECT_SETUP_PAT when Project v2 operations are needed" + @echo " 3. Run make doctor" + @echo " 4. Run make check" + @echo "" + @echo "Development:" + @echo " make install Install the CLI" + @echo " make dev-install Install in editable mode" + @echo " make check Validate committed files, compile and run tests" + @echo " make doctor Inspect OS, .env, gh auth and configuration" + @echo " make clean Remove local Python/build artifacts" + @echo "" + @echo "Repository analysis and setup:" + @echo " make discover TARGET=../project REPO=owner/repo" + @echo " make discover TARGET=../project REPO=owner/repo PROJECT_TYPE=python" + @echo " make init-dry TARGET=../project Preview copied automation files" + @echo " make init TARGET=../project Copy core automation files" + @echo " make init TARGET=../project FORCE=1 Replace existing managed files" + @echo " make plan TARGET=../project REPO=owner/repo" + @echo " make apply TARGET=../project REPO=owner/repo Dry-run by default" + @echo " make apply TARGET=../project REPO=owner/repo LIVE=1 Apply changes" + @echo " make setup TARGET=../project REPO=owner/repo Init + dry-run" + @echo " make setup-live TARGET=../project REPO=owner/repo Init + live apply" + @echo "" + @echo "Individual operations (dry-run by default):" + @echo " make labels REPO=owner/repo" + @echo " make milestones REPO=owner/repo" + @echo " make issues REPO=owner/repo" + @echo " make project-create REPO=owner/repo" + @echo " make project-sync REPO=owner/repo PROJECT_NUMBER=1" + @echo " Add LIVE=1 only after reviewing the dry-run output." + +install: + @echo "==> Installing project_setup" + $(PIP) install . + +dev-install: + @echo "==> Installing project_setup in editable mode" + $(PIP) install -e . + +quality: + @echo "==> [1/3] Validating repository structure and committed files" + $(PYTHON) scripts/validation/repo_quality.py + +compile: + @echo "==> [2/3] Compiling Python sources" + $(PYTHON) -m compileall -q project_setup scripts tests + @$(MAKE) --no-print-directory clean-generated + @echo "Python compilation passed. Generated cache files were removed." + +test: + @echo "==> [3/3] Running unit tests" + $(PYTHON) -B -m unittest discover -s tests -p "test_*.py" -v + +check: quality compile test + @echo "All repository checks passed. No GitHub API changes were made." + +doctor: + @echo "==> Inspecting local setup (read-only)" + $(PYTHON) -m project_setup doctor --config "$(CONFIG)" + +discover: + $(call require_value,TARGET,use TARGET=../my-project) + $(call require_value,REPO,use REPO=owner/repository) + $(PYTHON) -m project_setup discover --repo "$(REPO)" --config "$(CONFIG)" --root "$(TARGET)" $(PROJECT_TYPE_FLAG) --auto + +init: + $(call require_value,TARGET,use TARGET=../my-project) + $(PYTHON) -m project_setup init --target "$(TARGET)" --profile "$(PROFILE)" $(FORCE_FLAG) --live + +init-dry: + $(call require_value,TARGET,use TARGET=../my-project) + $(PYTHON) -m project_setup init --target "$(TARGET)" --profile "$(PROFILE)" $(FORCE_FLAG) --dry-run + +plan: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup apply --repo "$(REPO)" --config "$(CONFIG)" --dry-run + +apply: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup apply --repo "$(REPO)" --config "$(CONFIG)" $(EXECUTION_FLAG) + +setup: init plan + +setup-live: + $(call require_value,TARGET,use TARGET=../my-project) + $(call require_value,REPO,use REPO=owner/repository) + $(MAKE) --no-print-directory init TARGET="$(TARGET)" PROFILE="$(PROFILE)" FORCE="$(FORCE)" + $(MAKE) --no-print-directory apply TARGET="$(TARGET)" REPO="$(REPO)" CONFIG="$(CONFIG)" LIVE=1 + +labels: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup labels sync --repo "$(REPO)" --file config/project/labels.json $(EXECUTION_FLAG) + +milestones: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup milestones sync --repo "$(REPO)" --file config/project/milestones.json $(EXECUTION_FLAG) + +issues: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup issues generate --repo "$(REPO)" --file config/stories/backlog-manifest.json $(EXECUTION_FLAG) + +project-create: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project create --repo "$(REPO)" --file config/project/project-definition.json $(EXECUTION_FLAG) + +project-sync: + $(call require_value,REPO,use REPO=owner/repository) + $(call require_value,PROJECT_NUMBER,use PROJECT_NUMBER=1) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project sync --repo "$(REPO)" --project-number "$(PROJECT_NUMBER)" $(OWNER_FLAG) --file config/project/project-definition.json $(EXECUTION_FLAG) + +clean-generated: + @$(PYTHON) -c "from pathlib import Path; import shutil; [shutil.rmtree(path, ignore_errors=True) for path in list(Path('.').rglob('__pycache__'))]; [path.unlink(missing_ok=True) for pattern in ('*.pyc','*.pyo') for path in list(Path('.').rglob(pattern))]" + +clean: clean-generated + @$(PYTHON) -c "from pathlib import Path; import shutil; [shutil.rmtree(Path(name), ignore_errors=True) for name in ('build','dist','.pytest_cache','.mypy_cache')]; [shutil.rmtree(path, ignore_errors=True) for path in list(Path('.').glob('*.egg-info'))]" + @echo "Local Python and build artifacts removed." diff --git a/README.md b/README.md index 9740616..6ef05b8 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,629 @@ -# GitHub Project Automation +[![Repository quality](https://github.com/v-Kaefer/Github-Project-Automation/actions/workflows/repo-quality.yml/badge.svg?branch=develop)](https://github.com/v-Kaefer/Github-Project-Automation/actions/workflows/repo-quality.yml) +[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![Status: beta](https://img.shields.io/badge/status-beta-orange.svg)](https://github.com/v-Kaefer/Github-Project-Automation) -A reusable governance bootstrap toolkit for any GitHub project. -It syncs labels, milestones, and a Project v2 board, generates issues/tasks from a backlog manifest, and auto-labels issues and PRs — all driven by JSON config files you drop into your repo. +# GitHub Project Setup ---- + -## How it works +`project_setup` is a self-contained toolkit for installing and operating GitHub repository automation. It provides a Makefile and Python CLI for labels, milestones, issues, sub-issues, pull-request guardrails, repository discovery, and GitHub Projects v2. -The toolkit has two parts: +[Go directly to setup](#setup) · [Leia em português](#português) -| Part | What it is | -|---|---| -| `governance_bootstrap` | Generic Python CLI — never needs editing | -| `config/` + `governance.bootstrap.json` | Your project's data — edit these for every new project | +## Main capabilities ---- +- Manual operation through Make targets. +- Safe dry-run defaults for every mutating CLI command. +- Explicit `--live` or `LIVE=1` confirmation before writes. +- Guided repository discovery through the Python CLI. +- Embedded workflows, templates, manifests, and validation scripts. +- Native Windows detection in the Makefile. +- Local `.env` loading without external dependencies. +- Standard `github.token` for repository-scoped Actions operations. +- Explicit `PROJECT_SETUP_PAT` requirement for GitHub Projects v2. +- Actionable diagnostics with a `Fix:` instruction for validation errors. + + + +## Setup -## Quick start +### 1. Requirements -### 1. Copy config files into your repo +- Python 3.11 or newer; +- Git; +- GNU Make for the Makefile interface; +- permission to modify the target GitHub repository; +- a personal access token only for live GitHub Projects v2 operations. +The Makefile detects `OS=Windows_NT`. It uses `python` on Windows and `python3` on Linux, macOS, Git Bash, and WSL. The command can still be overridden: + +```powershell +make PYTHON=py check ``` -governance.bootstrap.json -config/project/labels.json -config/project/milestones.json -config/project/project-definition.json -config/stories/backlog-manifest.json -.github/workflows/governance-bootstrap.yml -.github/workflows/auto-label.yml + +The Python CLI can be used without Make: + +```bash +python -m project_setup --help ``` -### 2. Edit the config files for your project +### 2. Validate the tool -- **`labels.json`** — label names, colors, descriptions. -- **`milestones.json`** — milestone titles and due dates. -- **`project-definition.json`** — board name, custom fields, options and views. -- **`backlog-manifest.json`** — milestones, user stories and tasks. -- **`governance.bootstrap.json`** — points to the above files; set `dryRun`, `runLabels`, etc. +```bash +make check +``` -### 3. Add a repository secret +The command runs three local stages: -Create a secret named `GOVERNANCE_PAT` with a PAT that has: -- `repo` (issues) -- `project` (Project v2) -- `read:org` (if the repo belongs to an org) +1. validate required and committed files, JSON, script references, and package metadata; +2. compile Python sources; +3. run unit tests. -### 4. Run (GitHub Actions — recommended) +It does not call the GitHub API. Local `__pycache__` files are removed automatically and are not reported as committed files. A generated artifact only fails the check when `git ls-files` confirms it is tracked. -1. Go to **Actions → Governance bootstrap (manual) → Run workflow**. -2. Run with `dry_run = true` first to preview. -3. Run with `dry_run = false` to apply. +Typical corrective output: ---- +```text +ERROR: Generated Python artifact is committed: project_setup/__pycache__/module.pyc + Fix: Run `git rm --cached -- project_setup/__pycache__/module.pyc` and then `make clean`. +``` + +### 3. Create the local environment -## Local CLI +PowerShell: -Install the package: +```powershell +Copy-Item .env.example .env +``` + +Linux, macOS, Git Bash, or WSL: ```bash -pip install -e . +cp .env.example .env +``` + +Set the repository: + +```dotenv +GITHUB_REPOSITORY=owner/repository ``` -Guided wizard (checks auth, detects project type, shows recommended command): +The CLI loads `.env` automatically from the current working directory. Existing process environment variables take precedence. + +Run the read-only diagnostic: ```bash -export GH_TOKEN= -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json +make doctor ``` -Run directly: +`make doctor` reports the operating system, Python executable, `.env`, configuration files, token source, GitHub CLI installation, and `gh auth` status. It never prints token values and does not write to GitHub. + +### 4. Authentication + +#### Repository-scoped GitHub Actions operations + +GitHub automatically creates `github.token` for each job. The workflows expose it to Python as: + +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` + +Do not create a custom secret named `GITHUB_TOKEN`. The standard token is used for operations inside the repository, subject to the workflow `permissions` block: + +- labels; +- milestones; +- issues and tasks; +- sub-issues in the same repository; +- PR validation comments; +- inferred labels. + +#### Local GitHub CLI authentication + +The CLI can fall back to an authenticated GitHub CLI session: ```bash -# Dry-run (safe preview) -python -m governance_bootstrap bootstrap --repo owner/repo --dry-run +gh auth login +gh auth status +``` + +`make doctor` distinguishes a missing CLI, valid authentication, invalid authentication, and environment-token authentication. An invalid `gh` session does not block execution when a valid token is available in `.env`. + +#### GitHub Projects v2 + +The repository-scoped token cannot access Projects v2. Live Project creation and synchronization require `PROJECT_SETUP_PAT`. + +For the current GraphQL implementation, create a **personal access token (classic)**: + +1. Click your GitHub profile picture. +2. Open **Settings**. +3. Open **Developer settings**. +4. Open **Personal access tokens**. +5. Open **Tokens (classic)**. +6. Select **Generate new token** → **Generate new token (classic)**. +7. Define a descriptive name and expiration. +8. Select the scopes: + - `repo`; + - `project`. +9. Generate the token and copy it immediately. -# Apply -python -m governance_bootstrap bootstrap --repo owner/repo --no-dry-run +Official GitHub documentation: + +- [Managing personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [Automating Projects using Actions](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) + +For local use, save the PAT in `.env`: + +```dotenv +PROJECT_SETUP_PAT=ghp_your_token_here ``` -Individual commands: +Never commit `.env`. + +For the manual Actions workflow, save the PAT as a repository secret: + +1. Open the target repository. +2. Open **Settings**. +3. Open **Secrets and variables** → **Actions**. +4. Select **New repository secret**. +5. Name it `PROJECT_SETUP_PAT`. +6. Paste and save the token. + +A live workflow that requests Project v2 without the secret stops before applying changes and prints the required configuration path and scopes. + +> GitHub recommends a GitHub App for long-lived organization automation. The PAT workflow remains the simplest initial setup for individual users. + +### 5. Discover and install + +Inspect a target repository: ```bash -python -m governance_bootstrap labels sync --repo owner/repo -python -m governance_bootstrap milestones sync --repo owner/repo -python -m governance_bootstrap project create --repo owner/repo -python -m governance_bootstrap issues generate --repo owner/repo --link-subissues -python -m governance_bootstrap auto-label apply --repo owner/repo +make discover TARGET=../my-project REPO=owner/my-project ``` ---- +Non-interactive discovery always recommends a dry-run command, even when `project_setup.json` contains an old `dryRun=false` value. -## Repository layout +Preview installed files: +```bash +make init-dry TARGET=../my-project PROFILE=core ``` -governance_bootstrap/ # Generic CLI tool (Python package) -config/ - project/ - labels.json # Label definitions - milestones.json # Milestone list and dates - project-definition.json # Project v2 board name, fields and views - stories/ - backlog-manifest.json # Phases, user stories and tasks - phases/ - phase-review-policy.json -governance.bootstrap.json # Bootstrap entry point (paths + defaults) -.github/workflows/ - governance-bootstrap.yml # Manual dispatch workflow - auto-label.yml # Auto-labels issues and PRs on create/edit - branch-naming.yml # Validates branch name pattern - main-source-branch.yml # Ensures PRs to main come from develop - pr-metadata.yml # Validates required PR sections + +The installation dry-run does not create the target directory. + +Install the core automation: + +```bash +make init TARGET=../my-project PROFILE=core ``` +The installer includes `Makefile` and `.env.example`. Existing files are preserved. If the target already has either file, review and merge the template manually. + +Combined install and remote dry-run: + +```bash +make setup TARGET=../my-project REPO=owner/my-project +``` + +`make setup` installs missing files, then executes the configured API phase in dry-run mode. It does not write remote GitHub changes. + +### 6. Customize + +Review at least: + +- `.env.example` and the untracked `.env`; +- `project_setup.json`; +- `config/project/labels.json`; +- `config/project/milestones.json`; +- `config/project/project-definition.json`; +- `config/stories/backlog-manifest.json`; +- `.github/workflows/project-setup.yml`; +- `.github/workflows/main-source-branch.yml`; +- `.github/pull_request_template.md`. + +Issue generation and Project creation are disabled by default. + +### 7. Diagnose and plan + +```bash +make doctor +make plan TARGET=../my-project REPO=owner/repository +``` + +`make plan` is always a dry-run. Review the complete output before a live operation. + +### 8. Apply the complete configured setup + +`make apply` is also a dry-run unless live execution is explicitly enabled: + +```bash +make apply TARGET=../my-project REPO=owner/repository +``` + +Apply changes only after reviewing the plan: + +```bash +make apply TARGET=../my-project REPO=owner/repository LIVE=1 +``` + +The CLI equivalent is: + +```bash +python -m project_setup apply --repo owner/repository --live +``` + +If live Project creation is enabled, `PROJECT_SETUP_PAT` is mandatory. + +### 9. Run individual modules manually + +Individual Make targets are dry-run by default: + +```bash +make labels TARGET=../my-project REPO=owner/repository +make milestones TARGET=../my-project REPO=owner/repository +make issues TARGET=../my-project REPO=owner/repository +make project-create TARGET=../my-project REPO=owner/repository +make project-sync TARGET=../my-project REPO=owner/repository PROJECT_NUMBER=1 +``` + +After reviewing the output, add `LIVE=1` explicitly: + +```bash +make labels TARGET=../my-project REPO=owner/repository LIVE=1 +make milestones TARGET=../my-project REPO=owner/repository LIVE=1 +make issues TARGET=../my-project REPO=owner/repository LIVE=1 +make project-create TARGET=../my-project REPO=owner/repository LIVE=1 +make project-sync TARGET=../my-project REPO=owner/repository PROJECT_NUMBER=1 LIVE=1 +``` + +Project v2 live commands require `PROJECT_SETUP_PAT` in the target `.env`. + +Without a PAT, `project-sync` dry-run produces an offline preview of the local Project definition and clearly states that remote fields, items, and issues were not queried. + +### 10. Run manually in GitHub Actions + +In the target repository: + +**Actions** → **Project setup** → **Run workflow** + +The workflow defaults to dry-run. Labels, milestones, issue generation, and Project creation are separate inputs. A live Project v2 run requires the `PROJECT_SETUP_PAT` Actions secret. + +## Makefile reference + +| Target | Purpose | +| --- | --- | +| `make help` | Show detected platform, setup steps, and commands. | +| `make check` | Validate committed files, compile, and test. | +| `make doctor` | Inspect OS, `.env`, tokens, `gh auth`, and configuration without API writes. | +| `make discover TARGET=... REPO=...` | Detect the target stack and recommend safe setup options. | +| `make init-dry TARGET=...` | Preview installed files without creating the target directory. | +| `make init TARGET=...` | Install missing files while preserving existing files. | +| `make setup TARGET=... REPO=...` | Install missing files and run the configured remote phase in dry-run mode. | +| `make plan TARGET=... REPO=...` | Preview the complete configured API phase. | +| `make apply TARGET=... REPO=...` | Preview the configured API phase; dry-run remains the default. | +| `make apply TARGET=... REPO=... LIVE=1` | Apply the complete configured API phase explicitly. | +| `make ...` | Preview one module. | +| `make ... LIVE=1` | Apply one module explicitly. | +| `make clean` | Remove local Python and build artifacts. | + +## Security model + +- Dry-run is the default for all mutating CLI commands. +- Existing target files are preserved. +- Installation dry-runs do not create directories. +- Project v2 never silently falls back to `github.token`. +- GitHub HTTP requests have a finite timeout. +- Automatic retries are limited to idempotent read requests; mutations are not replayed after transport failures. +- Workflows use minimum repository permissions. +- Privileged `pull_request_target` workflows check out trusted base-branch automation. +- Read-only `pull_request` test workflows may test the proposed PR content. +- Untrusted branch names are passed through environment variables, not interpolated into shell source. +- Tokens are never printed by diagnostics. + +## Current limitations + +- Issue generation is not idempotent yet and must not be repeated without reviewing existing issues. +- Project v2 views remain a manual configuration step. +- Rulesets and branch protection are not created automatically. +- The package is embedded in target repositories instead of being installed from PyPI. +- Milestone synchronization intentionally inspects at most the first 100 existing milestones. +- A summarized `make preview` with a configurable example limit is planned but not implemented yet. + --- -## Authentication + + +# Configuração de Projetos no GitHub + +O `project_setup` é uma ferramenta autocontida para instalar e operar automações de repositórios no GitHub. Ela oferece Makefile e CLI Python para labels, milestones, issues, sub-issues, validações de pull request, descoberta do repositório e GitHub Projects v2. + +[Ir diretamente para a configuração](#configuração) · [Read in English](#english) + +## Principais recursos + +- Execução manual por Makefile. +- Dry-run seguro em todos os comandos mutáveis. +- Confirmação explícita por `--live` ou `LIVE=1` antes de qualquer escrita. +- Descoberta guiada pela CLI Python. +- Workflows, templates, manifests e validadores incorporados. +- Identificação nativa do Windows pelo Makefile. +- Carregamento automático de `.env`, sem dependências externas. +- `github.token` padrão para operações do próprio repositório. +- `PROJECT_SETUP_PAT` explícita para GitHub Projects v2. +- Erros com instruções `Fix:`. + + -The CLI reads the token from `GITHUB_TOKEN` or `GH_TOKEN`. -If neither is set it falls back to `gh auth token` (if `gh` is installed). +## Configuração + +### 1. Requisitos + +- Python 3.11 ou superior; +- Git; +- GNU Make para usar o Makefile; +- permissão para modificar o repositório-alvo; +- personal access token somente para operações reais de Project v2. + +O Makefile detecta `OS=Windows_NT`. No Windows, usa `python`; em Linux, macOS, Git Bash e WSL, usa `python3`. É possível sobrescrever: + +```powershell +make PYTHON=py check +``` + +Sem Make: + +```bash +python -m project_setup --help +``` + +### 2. Validar a ferramenta + +```bash +make check +``` + +O comando: + +1. valida arquivos obrigatórios e commitados, JSON, referências de scripts e metadados do pacote; +2. compila os fontes Python; +3. executa os testes unitários. + +Ele não chama a API do GitHub. Os `__pycache__` locais são removidos automaticamente e não são confundidos com arquivos versionados. Um artefato gerado somente causa falha quando `git ls-files` confirma que ele está commitado. + +### 3. Criar o ambiente local + +PowerShell: + +```powershell +Copy-Item .env.example .env +``` + +Linux, macOS, Git Bash ou WSL: + +```bash +cp .env.example .env +``` + +Defina o repositório: + +```dotenv +GITHUB_REPOSITORY=owner/repositorio +``` + +A CLI carrega automaticamente o `.env` do diretório atual. Variáveis já presentes no processo têm prioridade. + +Execute: + +```bash +make doctor +``` + +O `doctor` informa sistema operacional, executável Python, `.env`, configuração, origem do token, instalação da GitHub CLI e situação do `gh auth`. Ele não exibe tokens e não altera o GitHub. + +### 4. Autenticação + +#### Operações do repositório no Actions + +O GitHub fornece automaticamente `github.token`. Os workflows o passam ao Python assim: + +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` + +Não crie um secret personalizado chamado `GITHUB_TOKEN`. O token padrão atende, conforme o bloco `permissions`: + +- labels; +- milestones; +- issues e tasks; +- sub-issues no mesmo repositório; +- comentários de validação em PRs; +- labels inferidas. + +#### Autenticação local da GitHub CLI + +```bash +gh auth login +gh auth status +``` + +O `make doctor` diferencia CLI ausente, autenticação válida, autenticação inválida e tokens definidos no ambiente. Um `gh auth` inválido não bloqueia o uso quando existe outro token válido no `.env`. + +#### GitHub Projects v2 + +O token padrão do repositório não acessa Projects v2. Criação e sincronização reais exigem `PROJECT_SETUP_PAT`. + +Para a implementação GraphQL atual, crie um **personal access token classic**: + +1. clique na foto de perfil; +2. abra **Settings**; +3. abra **Developer settings**; +4. abra **Personal access tokens**; +5. abra **Tokens (classic)**; +6. selecione **Generate new token** → **Generate new token (classic)**; +7. defina nome e validade; +8. marque os escopos `repo` e `project`; +9. gere e copie o token imediatamente. + +Documentação oficial: + +- [Gerenciar personal access tokens](https://docs.github.com/pt/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [Automatizar Projects usando Actions](https://docs.github.com/pt/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) + +Para execução local, salve no `.env`: + +```dotenv +PROJECT_SETUP_PAT=ghp_seu_token_aqui +``` + +Nunca versione o `.env`. + +Para Actions, crie o secret: + +**Repositório** → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** + +Nome: + +```text +PROJECT_SETUP_PAT +``` + +Uma execução real de Project v2 sem esse secret para antes de aplicar alterações e mostra o caminho e os escopos necessários. + +> Para automações permanentes em organizações, o GitHub recomenda uma GitHub App. A PAT é mantida como o caminho inicial mais simples. + +### 5. Descobrir e instalar + +```bash +make discover TARGET=../meu-projeto REPO=owner/meu-projeto +make init-dry TARGET=../meu-projeto PROFILE=core +make init TARGET=../meu-projeto PROFILE=core +``` + +A descoberta não interativa sempre recomenda dry-run. O dry-run de instalação não cria o diretório-alvo. + +O instalador inclui `Makefile` e `.env.example`. Arquivos existentes são preservados e devem ser mesclados manualmente. + +Fluxo combinado de instalação e simulação remota: + +```bash +make setup TARGET=../meu-projeto REPO=owner/meu-projeto +``` + +Esse comando instala arquivos ausentes e executa o plano remoto em dry-run, sem alterar a API do GitHub. + +### 6. Personalizar + +Revise: + +- `.env.example` e o `.env` não versionado; +- `project_setup.json`; +- manifests em `config/project` e `config/stories`; +- workflows e templates em `.github/`. + +Geração de issues e criação de Project ficam desativadas por padrão. + +### 7. Diagnosticar e planejar + +```bash +make doctor +make plan TARGET=../meu-projeto REPO=owner/repositorio +``` + +O `make plan` sempre usa dry-run. + +### 8. Aplicar a configuração completa + +Sem `LIVE=1`, o comando continua sendo uma simulação: + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio +``` + +A escrita exige confirmação explícita: + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +``` + +Equivalente pela CLI: + +```bash +python -m project_setup apply --repo owner/repositorio --live +``` + +Se a configuração habilitar criação de Project v2, `PROJECT_SETUP_PAT` será obrigatória. + +### 9. Executar módulos individualmente + +Dry-run padrão: + +```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio +make milestones TARGET=../meu-projeto REPO=owner/repositorio +make issues TARGET=../meu-projeto REPO=owner/repositorio +make project-create TARGET=../meu-projeto REPO=owner/repositorio +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 +``` + +Após revisar, adicione `LIVE=1`: + +```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make milestones TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make issues TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-create TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 LIVE=1 +``` +Os comandos reais de Project v2 exigem `PROJECT_SETUP_PAT` no `.env` do alvo. + +Sem PAT, o dry-run de `project-sync` apresenta apenas a definição local e informa claramente que fields, items e issues remotos não foram consultados. + +### 10. Executar manualmente no Actions + +No repositório-alvo: + +**Actions** → **Project setup** → **Run workflow** + +O workflow inicia em dry-run. Labels, milestones, geração de issues e criação de Project são entradas separadas. Project v2 real exige o secret `PROJECT_SETUP_PAT`. + +## Referência do Makefile + +| Alvo | Finalidade | +| --- | --- | +| `make help` | Mostrar plataforma detectada, sequência inicial e comandos. | +| `make check` | Validar arquivos commitados, compilar e testar. | +| `make doctor` | Verificar SO, `.env`, tokens, `gh auth` e configuração sem escrita na API. | +| `make discover TARGET=... REPO=...` | Detectar a stack e recomendar opções seguras. | +| `make init-dry TARGET=...` | Simular arquivos instalados sem criar o diretório-alvo. | +| `make init TARGET=...` | Instalar arquivos ausentes preservando existentes. | +| `make setup TARGET=... REPO=...` | Instalar arquivos ausentes e executar o plano remoto em dry-run. | +| `make plan TARGET=... REPO=...` | Simular a fase completa da API. | +| `make apply TARGET=... REPO=...` | Simular a fase configurada; dry-run permanece o padrão. | +| `make apply TARGET=... REPO=... LIVE=1` | Aplicar explicitamente a fase configurada. | +| `make ...` | Simular um módulo. | +| `make ... LIVE=1` | Aplicar explicitamente um módulo. | +| `make clean` | Remover caches Python e artefatos locais. | + +## Segurança + +- Dry-run é o padrão em todos os comandos mutáveis. +- Arquivos existentes são preservados. +- Dry-run de instalação não cria diretórios. +- Project v2 não usa fallback silencioso para `github.token`. +- Chamadas HTTP possuem timeout finito. +- Retentativas automáticas ficam restritas a leituras idempotentes; mutações não são repetidas após falhas de transporte. +- Workflows usam permissões mínimas. +- Workflows privilegiados com `pull_request_target` usam automação confiável da branch-base. +- Workflows de teste somente leitura com `pull_request` podem validar o conteúdo proposto no PR. +- Nomes de branches passam por variáveis de ambiente, sem interpolação direta no shell. +- Diagnósticos nunca imprimem tokens. + +## Limitações atuais + +- A geração de issues ainda não é idempotente e não deve ser repetida sem revisar as issues existentes. +- Views de Project v2 continuam manuais. +- Rulesets e branch protection ainda não são criados. +- O pacote é incorporado nos repositórios-alvo, sem publicação no PyPI. +- A sincronização de milestones consulta intencionalmente no máximo os primeiros 100 milestones existentes. +- Um `make preview` resumido, com limite configurável de exemplos, está planejado, mas ainda não foi implementado. diff --git a/TESTE_REAL_RELATORIO.md b/TESTE_REAL_RELATORIO.md new file mode 100644 index 0000000..c4fe9ee --- /dev/null +++ b/TESTE_REAL_RELATORIO.md @@ -0,0 +1,64 @@ +# Relatório do teste real + +Repositório testado: `v-Kaefer/Github-Project-Automation` + +## Resultado + +O fluxo principal foi concluído com sucesso: + +- Project criado: [Project Delivery Board #6](https://github.com/users/v-Kaefer/projects/6) +- Issue criada: [#7 — US-00](https://github.com/v-Kaefer/Github-Project-Automation/issues/7) +- Tasks criadas: [#8 — T-00.1](https://github.com/v-Kaefer/Github-Project-Automation/issues/8) e [#9 — T-00.2](https://github.com/v-Kaefer/Github-Project-Automation/issues/9) + +Esse teste valida o caminho principal com configuração válida, PAT válida e conectividade liberada. Ele não substitui testes de repetição, idempotência, manifests inválidos ou falhas intermediárias. + +## O que deu errado inicialmente + +### 1. A autenticação local do `gh` estava inválida + +O comando `gh auth status` informou que o token da conta `v-Kaefer` estava inválido. Isso impediu a validação usando o cliente `gh`, mas não afetou a execução posterior pelo token configurado no `.env`. + +**Status após correção:** o `make doctor` agora informa separadamente se a GitHub CLI está instalada, se `gh auth` é válido e qual fonte de token está disponível, sem exibir credenciais. Um `gh auth` inválido não bloqueia o uso de uma PAT válida no `.env`. + +### 2. O Makefile não funcionou diretamente no shell padrão do Windows + +A primeira execução de `make` falhou por dois motivos de portabilidade: + +- o Makefile usava `python3`, que não estava disponível com esse nome no Windows; +- as regras de validação usavam o comando Unix `test`, que não existe no `cmd.exe`. + +**Status após correção:** o Makefile detecta `OS=Windows_NT`, usa `python` no Windows e eliminou a dependência do comando Unix `test`. Git Bash ou WSL deixam de ser requisitos para os alvos básicos. + +### 3. A semântica de dry-run não era uniforme + +A primeira tentativa misturou a interface do comando agregado com a dos subcomandos individuais. Alguns caminhos aceitavam `--no-dry-run`; outros executavam alterações reais apenas pela ausência de `--dry-run`. + +**Status após correção:** todos os comandos mutáveis usam dry-run por padrão. A execução real exige `--live`, o alias compatível `--no-dry-run`, ou `LIVE=1` pelo Makefile. + +### 4. A sandbox bloqueou a conexão com o GitHub + +A execução recebeu `WinError 10013`, indicando bloqueio de rede pela sandbox. Após autorizar a conexão externa, as operações foram concluídas. + +Esse bloqueio pertenceu exclusivamente ao ambiente local de teste. Ele não foi classificado como falha do Makefile nem como problema da lógica de automação. + +## Comandos atuais no Windows + +Dry-run: + +```powershell +make project-create REPO=v-Kaefer/Github-Project-Automation +make issues REPO=v-Kaefer/Github-Project-Automation +``` + +Execução real explícita: + +```powershell +make project-create REPO=v-Kaefer/Github-Project-Automation LIVE=1 +make issues REPO=v-Kaefer/Github-Project-Automation LIVE=1 +``` + +Não é mais necessário definir manualmente `SHELL` ou `PYTHON` em uma instalação padrão do Windows com `python` disponível no `PATH`. + +## Conclusão + +O fluxo principal de criação foi validado com sucesso. Os problemas de portabilidade do Makefile, diagnóstico do `gh` e confirmação de execução real foram corrigidos posteriormente. A restrição de rede permaneceu registrada apenas como característica da sandbox utilizada no teste. diff --git a/config/project/labels.json b/config/project/labels.json index aca438c..4e29ae9 100644 --- a/config/project/labels.json +++ b/config/project/labels.json @@ -1,26 +1,23 @@ [ - {"name":"type:user-story","color":"1D76DB","description":"📘 User story item"}, - {"name":"type:task","color":"0E8A16","description":"🛠️ Implementation task"}, - {"name":"type:bug","color":"D73A4A","description":"🐞 Bug report"}, - {"name":"type:repo","color":"5319E7","description":"🏗️ Repository/governance work"}, - {"name":"type:stretch","color":"FBCA04","description":"🌟 Stretch goal item"}, + {"name":"type:user-story","color":"1D76DB","description":"User story"}, + {"name":"type:task","color":"0E8A16","description":"Implementation task"}, + {"name":"type:bug","color":"D73A4A","description":"Bug or regression"}, + {"name":"type:repo","color":"5319E7","description":"Repository or automation work"}, + {"name":"type:stretch","color":"FBCA04","description":"Optional stretch goal"}, - {"name":"priority:critical","color":"B60205","description":"🔥 Critical priority"}, - {"name":"priority:high","color":"D93F0B","description":"⬆️ High priority"}, - {"name":"priority:medium","color":"FBCA04","description":"➡️ Medium priority"}, - {"name":"priority:low","color":"0E8A16","description":"⬇️ Low priority"}, + {"name":"priority:critical","color":"B60205","description":"Critical priority"}, + {"name":"priority:high","color":"D93F0B","description":"High priority"}, + {"name":"priority:medium","color":"FBCA04","description":"Medium priority"}, + {"name":"priority:low","color":"0E8A16","description":"Low priority"}, - {"name":"status:backlog","color":"C5DEF5","description":"📥 Backlog"}, - {"name":"status:ready","color":"C5DEF5","description":"✅ Ready"}, - {"name":"status:in-progress","color":"C5DEF5","description":"🚧 In progress"}, - {"name":"status:review-milestone","color":"C5DEF5","description":"👀 Review task->milestone"}, - {"name":"status:review-develop","color":"C5DEF5","description":"🧪 Review milestone->develop"}, - {"name":"status:review-main","color":"C5DEF5","description":"��️ Review develop->main"}, - {"name":"status:qa-manual","color":"C5DEF5","description":"🧫 Manual QA"}, - {"name":"status:done","color":"0E8A16","description":"🏁 Done"}, - {"name":"status:blocked","color":"B60205","description":"⛔ Blocked"}, + {"name":"status:backlog","color":"C5DEF5","description":"Backlog"}, + {"name":"status:ready","color":"BFDADC","description":"Ready for implementation"}, + {"name":"status:in-progress","color":"FEF2C0","description":"In progress"}, + {"name":"status:in-review","color":"D4C5F9","description":"In review"}, + {"name":"status:done","color":"0E8A16","description":"Done"}, + {"name":"status:blocked","color":"B60205","description":"Blocked"}, - {"name":"test:automated","color":"0366D6","description":"🤖 Automated test"}, - {"name":"test:smoke","color":"0366D6","description":"💨 Smoke test"}, - {"name":"test:manual","color":"0366D6","description":"🧍 Manual test"} + {"name":"test:automated","color":"0366D6","description":"Automated tests"}, + {"name":"test:smoke","color":"0366D6","description":"Smoke test"}, + {"name":"test:manual","color":"0366D6","description":"Manual validation"} ] diff --git a/config/project/milestones.json b/config/project/milestones.json index 81402a6..96ec12f 100644 --- a/config/project/milestones.json +++ b/config/project/milestones.json @@ -1,6 +1,10 @@ [ - {"title": "M0", "description": "Project setup and bootstrap", "due_on": "2026-01-31T00:00:00Z"}, - {"title": "M1", "description": "First deliverable", "due_on": "2026-02-28T00:00:00Z"}, - {"title": "M2", "description": "Second deliverable", "due_on": "2026-03-31T00:00:00Z"}, - {"title": "M3", "description": "Final delivery", "due_on": "2026-04-30T00:00:00Z"} + { + "title": "M0", + "description": "Repository setup, conventions and automation" + }, + { + "title": "M1", + "description": "First planned product increment" + } ] diff --git a/config/project/project-definition.json b/config/project/project-definition.json index 2e6ca4c..ddc2687 100644 --- a/config/project/project-definition.json +++ b/config/project/project-definition.json @@ -1,19 +1,40 @@ { - "name": "My Project Board", - "description": "Operational board for milestones, user stories, tasks, review layers and validation.", + "name": "Project Delivery Board", + "description": "Generic delivery board for stories, tasks, bugs and repository work.", + "phaseMilestoneMap": { + "M0": "Setup", + "M1": "Delivery" + }, "fields": [ - {"name": "Milestone", "type": "single_select", "options": ["M0","M1","M2","M3"]}, - {"name": "Item Type", "type": "single_select", "options": ["user-story","task","bug","repo","stretch"]}, - {"name": "Priority", "type": "single_select", "options": ["critical","high","medium","low"]}, - {"name": "Status", "type": "single_select", "options": ["backlog","ready","in-progress","review","done","blocked"]}, - {"name": "Test Type", "type": "single_select", "options": ["automated","smoke","manual"]}, - {"name": "DoD Status", "type": "single_select", "options": ["not-started","partial","ready","done"]}, - {"name": "Responsible", "type": "text"} + { + "name": "Phase", + "type": "single_select", + "options": ["Setup", "Delivery", "Maintenance"] + }, + { + "name": "Item Type", + "type": "single_select", + "options": ["user-story", "task", "bug", "repo", "stretch"] + }, + { + "name": "Priority", + "type": "single_select", + "options": ["critical", "high", "medium", "low"] + }, + { + "name": "Status", + "type": "single_select", + "options": ["backlog", "ready", "in-progress", "in-review", "done", "blocked"] + }, + { + "name": "Test Type", + "type": "single_select", + "options": ["automated", "smoke", "manual"] + }, + { + "name": "Milestone", + "type": "text" + } ], - "views": [ - "Board by status", - "Roadmap by milestone", - "Current milestone", - "In review" - ] + "views": ["Backlog", "Current delivery", "Bugs", "Done"] } diff --git a/config/stories/backlog-manifest.json b/config/stories/backlog-manifest.json index e0525ed..6de9128 100644 --- a/config/stories/backlog-manifest.json +++ b/config/stories/backlog-manifest.json @@ -1,43 +1,23 @@ { "version": "1.0.0", - "repository": "owner/repo", + "repository": "owner/repository", "defaultIssueLabels": ["status:backlog"], - "milestones": [ + "phases": [ { + "phaseId": "setup", "milestone": "M0", "stories": [ { "storyId": "US-00", - "title": "US-00 | Set up repository, project board and initial backlog", - "labels": ["type:user-story", "priority:critical", "test:manual"], - "body": "As a team, we want an operational repository structure so we can run the project with consistent traceability and review.", - "acceptanceCriteria": "- Milestones created\n- Labels synced\n- Project board configured\n- Initial backlog published", - "testStrategy": "- Manual verification of milestones, labels and board", - "dod": "- All setup steps completed and visible in the repo", + "title": "US-00 | Configure repository automation", + "body": "## Context\nEstablish repository conventions, templates and automated checks.", + "labels": ["type:user-story", "priority:high", "test:manual"], + "acceptanceCriteria": "- Required workflows are installed.\n- Dry-run completes successfully.\n- Repository-specific values replace the examples.", + "testStrategy": "- Run `make check`.\n- Run `make plan REPO=owner/repository`.", + "dod": "- Configuration reviewed.\n- Secrets and variables documented.\n- No placeholder repository values remain.", "tasks": [ - "T-00.1 | Create milestones", - "T-00.2 | Create GitHub Project v2 with fields and views", - "T-00.3 | Sync base labels", - "T-00.4 | Create issue and PR templates", - "T-00.5 | Publish initial backlog" - ] - } - ] - }, - { - "milestone": "M1", - "stories": [ - { - "storyId": "US-01", - "title": "US-01 | Example user story for milestone 1", - "labels": ["type:user-story", "priority:high", "test:automated"], - "body": "As a user, I want an example feature so I can understand the story format.", - "acceptanceCriteria": "- Acceptance criterion 1\n- Acceptance criterion 2", - "testStrategy": "- automated", - "dod": "- Implementation complete\n- Tests passing\n- Reviewed and merged", - "tasks": [ - "T-01.1 | Example task 1", - "T-01.2 | Example task 2" + "T-00.1 | Customize labels and milestones", + "T-00.2 | Review workflows and branch policy" ] } ] diff --git a/docs/DOCUMENTATION-GUIDE.md b/docs/DOCUMENTATION-GUIDE.md index 962c085..db60949 100644 --- a/docs/DOCUMENTATION-GUIDE.md +++ b/docs/DOCUMENTATION-GUIDE.md @@ -1,117 +1,57 @@ # Documentation Guide -This guide maps every documentation artifact in this repository to the config files and CLI commands that drive it. -When you add or change a doc, update the corresponding config. When you change a config, update the corresponding doc. - ---- - -## Config ↔ Doc alignment - -| Config file | Purpose | Corresponding doc(s) | -|-------------|---------|----------------------| -| `config/project/milestones.json` | Milestone titles and due dates | `docs/milestones/MN-.md` (one per milestone) | -| `config/project/labels.json` | Label names, colors, descriptions | `docs/repo/project-board-policy.md` (status baseline) | -| `config/project/project-definition.json` | Board name, custom fields, views | `docs/repo/project-board-policy.md` | -| `config/stories/backlog-manifest.json` | Milestones → user stories → tasks | `docs/stories/story-index.md`, `docs/milestones/MN-.md` | -| `config/phases/phase-review-policy.json` | Review layers and responsible pairs per milestone | `docs/repo/review-policy.md` | -| `governance.bootstrap.json` | Entry point — file paths and default flags | `docs/repo/governance-shared-tool.md`, `docs/repo/governance-bootstrap-runbook.pt-BR.md` | - ---- - -## Workflows ↔ Docs - -| Workflow | What it does | Where to configure it | -|----------|--------------|-----------------------| -| `governance-bootstrap.yml` | Syncs labels, milestones, project board and issues | `governance.bootstrap.json` + `config/` | -| `auto-label.yml` | Infers and applies labels to issues and PRs | `governance_bootstrap/auto_label.py` | -| `pr-metadata.yml` | Validates PR body has required sections and an issue link | Inline bash check in the workflow | -| `branch-naming.yml` | Enforces branch naming convention | `docs/repo/branching-policy.md` | -| `main-source-branch.yml` | Ensures PRs to `main` come from `develop` | `docs/repo/branching-policy.md` | - ---- - -## How to create documentation for a new milestone - -1. **Define the milestone** in `config/project/milestones.json`: - ```json - {"title": "MN", "description": "Short description", "due_on": "YYYY-MM-DDT00:00:00Z"} - ``` - -2. **Add the milestone's stories and tasks** in `config/stories/backlog-manifest.json`: - ```json - { - "milestone": "MN", - "stories": [ - { - "storyId": "US-NN", - "title": "US-NN | Story title", - "labels": ["type:user-story", "priority:high", "test:automated"], - "body": "As a ..., I want ... so that ...", - "acceptanceCriteria": "- Criterion 1\n- Criterion 2", - "testStrategy": "- automated", - "dod": "- Implementation complete\n- Tests passing\n- Reviewed and merged", - "tasks": ["T-NN.1 | Task title"] - } - ] - } - ``` - -3. **Copy the milestone template** to `docs/milestones/MN-.md`: - ```bash - cp docs/milestones/MILESTONE-TEMPLATE.md docs/milestones/MN-my-feature.md - ``` - Fill in the objective, scope, stories table, risks and exit criteria. - -4. **Update the story index** at `docs/stories/story-index.md`: - ```markdown - - Milestone MN: US-NN, US-NN+1, ... - ``` - -5. **Assign responsible pairs** in `config/phases/phase-review-policy.json`: - ```json - "milestoneResponsiblePairs": { - "MN": ["@username1", "@username2"] - } - ``` - -6. **Run the bootstrap** to apply labels, milestones and generate issues: - ```bash - python -m governance_bootstrap bootstrap --repo owner/repo --dry-run - # review output, then: - python -m governance_bootstrap bootstrap --repo owner/repo --no-dry-run - ``` - ---- +This guide maps the reusable configuration, workflows and operational documentation maintained by GitHub Project Setup. + +## Config and documentation alignment + +| Configuration | Purpose | Documentation | +| --- | --- | --- | +| `project_setup.json` | File paths and safe execution defaults | `docs/repo/project-setup-shared-tool.md`, `docs/repo/project-setup-runbook.pt-BR.md` | +| `config/project/labels.json` | Label names, colors and descriptions | `docs/repo/project-board-policy.md` | +| `config/project/milestones.json` | Milestone definitions | `docs/milestones/MILESTONE-TEMPLATE.md` | +| `config/project/project-definition.json` | Project v2 fields and options | `docs/repo/project-board-policy.md` | +| `config/stories/backlog-manifest.json` | Phases, stories and tasks | milestone and story documentation in the target repository | + +## Workflows and sources + +| Workflow | Purpose | Source of behavior | +| --- | --- | --- | +| `.github/workflows/project-setup.yml` | Manual dry-run or live setup | `project_setup/cli.py`, `project_setup/runner.py`, `project_setup.json` | +| `.github/workflows/auto-label.yml` | Infer labels for issues and PRs | `project_setup/auto_label.py` | +| `.github/workflows/pr-metadata.yml` | Validate branch names and PR metadata | `project_setup/pr_validation.py` | +| `.github/workflows/main-source-branch.yml` | Restrict PR sources targeting `main` | `.github/workflows/main-source-branch.yml`, `docs/repo/branching-policy.md` | +| `.github/workflows/repo-quality.yml` | Validate this tool repository | `Makefile`, `scripts/validation/repo_quality.py`, `tests/` | + +## Adding a milestone template + +1. Add the milestone to `config/project/milestones.json`. +2. Add its phase mapping and required options to `config/project/project-definition.json`. +3. Add stories under `phases` in `config/stories/backlog-manifest.json`. +4. Copy and complete `docs/milestones/MILESTONE-TEMPLATE.md` in the target repository when milestone documentation is useful. +5. Run a dry-run: + +```bash +python -m project_setup apply --repo owner/repository --dry-run +``` -## Folder structure reference +6. Apply only after reviewing the proposed changes: +```bash +python -m project_setup apply --repo owner/repository --live ``` -docs/ - milestones/ - MILESTONE-TEMPLATE.md # Copy this for each milestone - MN-.md # One per milestone (you create these) - phases/ - README.md # Guide for milestone docs (this folder is the old "phases" home) - stories/ - README.md - story-index.md # All user stories grouped by milestone - repo/ - branching-policy.md # Branch naming and merge layer conventions - review-policy.md # PR review rules per merge layer - dod-policy.md # Definition of Done per item type and milestone - project-board-policy.md # Required board fields and status values - testing-policy.md # Test type priority order and validation strategy - handoff-policy.md # What to register when ownership changes - governance-shared-tool.md # How to use this toolkit in another repo - governance-bootstrap-runbook.pt-BR.md # Step-by-step runbook -config/ - project/ - labels.json # All labels — must match project-board-policy.md - milestones.json # Milestone titles and dates — must match docs/milestones/ - project-definition.json # Board fields — must match project-board-policy.md - stories/ - backlog-manifest.json # Milestones → stories → tasks — must match docs/milestones/ - phases/ - phase-review-policy.json # Review layers and responsible pairs — must match review-policy.md -governance.bootstrap.json # Entry point: file paths and CLI defaults + +## Repository structure + +```text +project_setup/ Reusable Python package +project_setup.json File paths and execution defaults +config/project/ Labels, milestones and Project v2 definition +config/stories/ Backlog manifest +.github/workflows/ Generic active workflows +docs/repo/ Operational policies and runbooks +scripts/validation/ Cross-platform validation entrypoints +tests/ Unit and installation tests +Makefile Human and AI-oriented command interface ``` + +When a configuration contract changes, update its loader, tests, README and relevant runbook in the same pull request. diff --git a/docs/repo/branching-policy.md b/docs/repo/branching-policy.md index fa6f198..f9391d4 100644 --- a/docs/repo/branching-policy.md +++ b/docs/repo/branching-policy.md @@ -1,16 +1,26 @@ # Branching Policy (EN) -## Main branches -- `main`: stable macro delivery -- `develop`: integration branch -- `milestone/`: active milestone branch -- `feat//` or `task//`: implementation branch +## Default branches -## Merge layers -1. task -> milestone -2. milestone -> develop -3. develop -> main +- `main`: stable delivery branch. +- `develop`: optional integration branch. +- `phase/`: optional phase branch for staged delivery. +- implementation branches: `feat/`, `fix/`, `task/`, `docs/`, `refactor/`, `test/`, `chore/`, `ci/`, `hotfix/`, or `release/`. + +## Default merge layers + +1. implementation branch -> `develop` or a phase branch; +2. phase branch -> `develop`; +3. `develop` -> `main`; +4. `hotfix/*` -> `main` when explicitly allowed. + +Repositories that use trunk-based development should adapt `.github/workflows/main-source-branch.yml` instead of copying this model unchanged. ## Naming -- Feature convention default: `feat/` -- Current bootstrap branch: `feat/repo-governance-bootstrap` + +Use lowercase branch paths with hyphens, dots, underscores, or nested scopes, for example: + +- `feat/project-setup`; +- `task/setup/customize-labels`; +- `fix/project-sync-pagination`; +- `hotfix/workflow-permissions`. diff --git a/docs/repo/branching-policy.pt-BR.md b/docs/repo/branching-policy.pt-BR.md index aed1359..3a04ffe 100644 --- a/docs/repo/branching-policy.pt-BR.md +++ b/docs/repo/branching-policy.pt-BR.md @@ -1,16 +1,26 @@ # Política de Branches (PT-BR) -## Branches principais -- `main`: entrega macro estável -- `develop`: branch de integração -- `milestone/`: branch do milestone ativo -- `feat//` ou `task//`: branch de implementação - -## Camadas de merge -1. task -> milestone -2. milestone -> develop -3. develop -> main - -## Convenção -- Convenção padrão para feature: `feat/` -- Branch atual de bootstrap: `feat/repo-governance-bootstrap` +## Branches padrão + +- `main`: branch de entrega estável. +- `develop`: branch de integração opcional. +- `phase/`: branch opcional para entregas por fase. +- branches de implementação: `feat/`, `fix/`, `task/`, `docs/`, `refactor/`, `test/`, `chore/`, `ci/`, `hotfix/` ou `release/`. + +## Camadas padrão de merge + +1. branch de implementação -> `develop` ou branch de fase; +2. branch de fase -> `develop`; +3. `develop` -> `main`; +4. `hotfix/*` -> `main` quando explicitamente permitido. + +Projetos que utilizam trunk-based development devem adaptar `.github/workflows/main-source-branch.yml`, em vez de copiar este modelo sem alterações. + +## Nomenclatura + +Use caminhos em letras minúsculas, com hífens, pontos, underscores ou escopos aninhados, por exemplo: + +- `feat/project-setup`; +- `task/setup/customize-labels`; +- `fix/project-sync-pagination`; +- `hotfix/workflow-permissions`. diff --git a/docs/repo/governance-bootstrap-runbook.pt-BR.md b/docs/repo/governance-bootstrap-runbook.pt-BR.md deleted file mode 100644 index 542be8e..0000000 --- a/docs/repo/governance-bootstrap-runbook.pt-BR.md +++ /dev/null @@ -1,51 +0,0 @@ -# Runbook — Governance Bootstrap - -## 1) Required permissions -To create/edit Project, labels, issues and sub-issues automatically, use an account with: -- **Admin** access to the repository -- Permission for **Projects** (Project v2) -- Token with scopes: `repo`, `project` and `read:org` (if the repo is in an org) - -## 2) How to grant admin access on GitHub -1. Repository → **Settings** → **Collaborators and teams**. -2. Add the user/account that will run the automations. -3. Set role to **Admin**. -4. Under **Settings → Actions → General**, enable: - - `Read and write permissions` for the `GITHUB_TOKEN`; - - creation and approval of PRs by GitHub Actions (if desired). -5. To create **Project v2**, authenticate `gh` with a PAT that includes the `project` scope (plus `repo`). - -## 3) How to run - -### Option A — Manual workflow (recommended) -1. Push this branch. -2. GitHub → **Actions** → `Governance bootstrap (manual)` → **Run workflow**. -3. Run with `dry_run=true` first to preview. -4. Run with `dry_run=false` to apply labels, milestones, issues/tasks/sub-issues and Project. - -### Option B — Local CLI -> Security: avoid putting your PAT directly in shell history. Prefer loading via a local unversioned env file, secret manager, or interactive prompt. - -```bash -export GH_TOKEN= -export GITHUB_REPOSITORY=owner/repo - -# Preview changes -python -m governance_bootstrap bootstrap --repo owner/repo --dry-run - -# Apply -python -m governance_bootstrap bootstrap --repo owner/repo --no-dry-run -``` - -Or use the guided `discover` wizard: - -```bash -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json -``` - -## 4) Notes -- To reuse in another project, copy and adapt the manifests in `config/project`, `config/stories` and `governance.bootstrap.json`. -- The expected workflow secret is `GOVERNANCE_PAT`. -- The `discover` command checks auth status, detects project type, and prints the recommended bootstrap command. -- Milestone responsible pairs are `TBD` in `config/phases/phase-review-policy.json` — fill them in for your team. - diff --git a/docs/repo/governance-bootstrap.workflow-template.yml b/docs/repo/governance-bootstrap.workflow-template.yml deleted file mode 100644 index 485d735..0000000 --- a/docs/repo/governance-bootstrap.workflow-template.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Governance bootstrap (manual) - -on: - workflow_dispatch: - inputs: - dry_run: - description: "Run without writing to GitHub" - required: true - default: true - type: boolean - run_project_creation: - description: "Create GitHub Project v2" - required: true - default: false - type: boolean - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - bootstrap: - runs-on: ubuntu-latest - steps: - - name: Checkout consumer repo - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install governance CLI - run: | - python -m pip install "git+https://github.com/OWNER/github-governance-bootstrap.git@v0.1.0" - - - name: Run bootstrap - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GH_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - args="--config governance.bootstrap.json" - if [ "${{ inputs.dry_run }}" = "true" ]; then - args="$args --dry-run" - else - args="$args --no-dry-run --link-subissues" - fi - if [ "${{ inputs.run_project_creation }}" = "true" ]; then - args="$args --run-project-creation" - fi - governance bootstrap $args diff --git a/docs/repo/governance-shared-tool.md b/docs/repo/governance-shared-tool.md deleted file mode 100644 index f27dded..0000000 --- a/docs/repo/governance-shared-tool.md +++ /dev/null @@ -1,49 +0,0 @@ -# Shared Governance Tool - -This repository carries the reusable bootstrap engine as the Python package `governance_bootstrap`. - -## What Is Generic -- GitHub label sync from `config/project/labels.json`. -- GitHub milestone sync from `config/project/milestones.json`. -- GitHub Project v2 creation and issue sync from `config/project/project-definition.json`. -- Issue/task generation from `config/stories/backlog-manifest.json`. -- Auto-label and issue milestone helpers. -- `discover` wizard that checks auth, detects project type, and prints the recommended bootstrap command. - -## What Stays Project-Specific -- Label names and colors. -- Milestone names and dates. -- Project board name, fields, options and views. -- Backlog milestones, user stories, tasks and default labels. -- The target repository passed with `--repo owner/repo`. - -## Consumer Setup -1. Copy `governance.bootstrap.json`, `config/project`, `config/stories` and `.github/workflows/governance-bootstrap.yml` into the consumer repo. -2. Add a repository secret named `GOVERNANCE_PAT`. -3. Give the token access to `repo` issues and Project v2 operations (`project`, and `read:org` for orgs). -4. Run the manual workflow with `dry_run=true` to preview changes. -5. Run again with `dry_run=false` when the dry-run output looks correct. - -## Local Usage - -Check auth and get a recommended command interactively: - -```bash -export GH_TOKEN= -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json -``` - -Run discovery in non-interactive (auto) mode: - -```bash -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json --auto -``` - -Run bootstrap directly (dry-run first): - -```bash -python -m governance_bootstrap bootstrap --repo owner/repo --config governance.bootstrap.json --dry-run -# When output looks correct: -python -m governance_bootstrap bootstrap --repo owner/repo --config governance.bootstrap.json --no-dry-run -``` - diff --git a/docs/repo/project-board-policy.md b/docs/repo/project-board-policy.md index ae7ef80..0f00467 100644 --- a/docs/repo/project-board-policy.md +++ b/docs/repo/project-board-policy.md @@ -1,21 +1,31 @@ # Project Board Policy (EN) -## Required fields -- Milestone +## Managed fields + +- Phase - Item Type -- Status - Priority +- Status - Test Type -- DoD Status -- Responsible +- Milestone ## Status baseline + - backlog - ready - in-progress -- review-milestone -- review-develop -- review-main -- qa-manual +- in-review - done - blocked + +## Item types + +- user-story +- task +- bug +- repo +- stretch + +The canonical options are defined in `config/project/project-definition.json`. Labels that synchronize into the board must use the same lowercase values after their prefix, for example `status:in-review` and `type:task`. + +Project views listed in the definition are recommendations and currently require manual configuration. diff --git a/docs/repo/project-board-policy.pt-BR.md b/docs/repo/project-board-policy.pt-BR.md index 6eaf5a4..ac0ee13 100644 --- a/docs/repo/project-board-policy.pt-BR.md +++ b/docs/repo/project-board-policy.pt-BR.md @@ -1,21 +1,31 @@ # Política do Project Board (PT-BR) -## Campos obrigatórios -- Milestone +## Campos gerenciados + +- Phase - Item Type -- Status - Priority +- Status - Test Type -- DoD Status -- Responsible +- Milestone ## Status base + - backlog - ready - in-progress -- review-milestone -- review-develop -- review-main -- qa-manual +- in-review - done - blocked + +## Tipos de item + +- user-story +- task +- bug +- repo +- stretch + +As opções canônicas estão em `config/project/project-definition.json`. Labels sincronizadas com o board devem utilizar o mesmo valor em letras minúsculas após o prefixo, por exemplo `status:in-review` e `type:task`. + +As views listadas na definição são recomendações e ainda precisam ser configuradas manualmente. diff --git a/docs/repo/project-setup-runbook.pt-BR.md b/docs/repo/project-setup-runbook.pt-BR.md new file mode 100644 index 0000000..3246549 --- /dev/null +++ b/docs/repo/project-setup-runbook.pt-BR.md @@ -0,0 +1,255 @@ +# Runbook — GitHub Project Setup + +## Objetivo + +Instalar e operar as automações em outro repositório sem sobrescrever arquivos existentes e sem executar alterações remotas antes de uma revisão. + +## 1. Validar a ferramenta + +```bash +make check +``` + +O comando: + +1. valida somente arquivos commitados e configurações; +2. compila os fontes Python; +3. remove caches gerados; +4. executa os testes. + +Caches locais em `__pycache__` não são tratados como arquivos versionados. Quando houver uma falha real, a saída apresenta uma instrução `Fix:`. + +### Windows + +O Makefile detecta `OS=Windows_NT`, usa `python` por padrão e não depende do comando Unix `test`. Em Linux, macOS, Git Bash e WSL, o padrão permanece `python3`. + +A detecção pode ser conferida com: + +```bash +make help +make doctor +``` + +O comando Python ainda pode ser sobrescrito explicitamente: + +```powershell +make PYTHON=py check +``` + +## 2. Criar o ambiente local + +PowerShell: + +```powershell +Copy-Item .env.example .env +``` + +Linux, macOS, Git Bash ou WSL: + +```bash +cp .env.example .env +``` + +Defina o repositório: + +```dotenv +GITHUB_REPOSITORY=owner/repositorio +``` + +A CLI carrega `.env` automaticamente e nunca imprime os valores dos tokens. + +## 3. Configurar autenticação + +### Operações do repositório + +No GitHub Actions, labels, milestones, issues, sub-issues e comentários usam o token padrão: + +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` + +Não crie um secret chamado `GITHUB_TOKEN`. + +Para execução local sem Project v2, também é possível usar uma sessão autenticada do GitHub CLI: + +```bash +gh auth login +gh auth status +``` + +O `make doctor` diferencia: + +- GitHub CLI ausente; +- GitHub CLI instalada com autenticação válida; +- GitHub CLI instalada com autenticação inválida; +- token recebido por `GITHUB_TOKEN`, `GH_TOKEN`, `PROJECT_SETUP_PAT` ou `gh`. + +Uma autenticação inválida do `gh` não bloqueia a ferramenta quando existe outro token válido no `.env`. + +### GitHub Projects v2 + +Project v2 não pode usar o token padrão do repositório. Crie um personal access token classic: + +1. foto de perfil; +2. **Settings**; +3. **Developer settings**; +4. **Personal access tokens**; +5. **Tokens (classic)**; +6. **Generate new token (classic)**; +7. selecione os escopos `repo` e `project`; +8. gere e copie o token. + +Para execução local, salve no `.env`: + +```dotenv +PROJECT_SETUP_PAT=ghp_seu_token +``` + +Para Actions, salve como secret do repositório: + +**Settings** → **Secrets and variables** → **Actions** → **New repository secret** + +Nome: + +```text +PROJECT_SETUP_PAT +``` + +Uma execução real de Project v2 sem essa PAT termina antes das alterações e informa o caminho de configuração. + +## 4. Executar o diagnóstico + +```bash +make doctor +``` + +O diagnóstico verifica: + +- sistema operacional e executável Python; +- presença do `.env`; +- repositório configurado; +- disponibilidade e origem da autenticação; +- estado de `gh auth`; +- presença específica de `PROJECT_SETUP_PAT`; +- validade de `project_setup.json`; +- existência dos manifests referenciados. + +Ele não aplica alterações no GitHub. + +## 5. Inspecionar o repositório-alvo + +```bash +make discover TARGET=../meu-projeto REPO=owner/repositorio +``` + +O modo automático sempre recomenda dry-run. Uma aplicação real pela descoberta exige confirmação explícita. + +## 6. Simular a instalação + +```bash +make init-dry TARGET=../meu-projeto PROFILE=core +``` + +O dry-run não cria o diretório-alvo. + +## 7. Instalar os arquivos + +```bash +make init TARGET=../meu-projeto +``` + +O instalador também leva `Makefile` e `.env.example`. Arquivos existentes são preservados e devem ser mesclados manualmente. A substituição consciente exige `FORCE=1`. + +### Fluxo combinado + +```bash +make setup TARGET=../meu-projeto REPO=owner/repositorio +``` + +Esse comando executa a instalação real dos arquivos ausentes e, em seguida, o plano remoto em dry-run. Ele não aplica alterações na API do GitHub. + +## 8. Personalizar + +Revise: + +- `.env.example` e `.env`; +- `project_setup.json`; +- `config/project/labels.json`; +- `config/project/milestones.json`; +- `config/project/project-definition.json`; +- `config/stories/backlog-manifest.json`; +- workflows e templates em `.github/`. + +Mantenha `runIssueGeneration` e `runProjectCreation` desativados até concluir a personalização. + +## 9. Revisar o plano completo + +```bash +make plan TARGET=../meu-projeto REPO=owner/repositorio +``` + +O plano é sempre dry-run. + +## 10. Aplicar a configuração completa + +Sem `LIVE=1`, `make apply` continua em dry-run: + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio +``` + +A escrita exige confirmação explícita: + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +``` + +Também existe o atalho combinado explícito: + +```bash +make setup-live TARGET=../meu-projeto REPO=owner/repositorio +``` + +## 11. Executar módulos individualmente + +Primeiro execute em dry-run: + +```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio +make milestones TARGET=../meu-projeto REPO=owner/repositorio +make issues TARGET=../meu-projeto REPO=owner/repositorio +make project-create TARGET=../meu-projeto REPO=owner/repositorio +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 +``` + +Após revisar a saída, habilite a escrita explicitamente: + +```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make milestones TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make issues TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-create TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 LIVE=1 +``` + +Operações reais de Project v2 exigem `PROJECT_SETUP_PAT` no `.env` do repositório-alvo. + +Sem PAT, `project-sync` em dry-run produz um preview offline da definição local e informa que a comparação remota não foi executada. + +## 12. Execução manual no Actions + +No repositório-alvo: + +**Actions** → **Project setup** → **Run workflow** + +Comece com `dry_run=true`. A criação real de Project v2 exige o secret `PROJECT_SETUP_PAT`. + +## Recuperação + +1. execute `make doctor`; +2. siga cada instrução `Fix:`; +3. execute `make check`; +4. repita `make plan`; +5. aplique somente após revisar a saída. + +Labels e milestones são sincronizados de forma idempotente. A geração de issues ainda não é idempotente e não deve ser repetida sem revisar as issues existentes. diff --git a/docs/repo/project-setup-shared-tool.md b/docs/repo/project-setup-shared-tool.md new file mode 100644 index 0000000..2a27b7c --- /dev/null +++ b/docs/repo/project-setup-shared-tool.md @@ -0,0 +1,88 @@ +# Project Setup Shared Tool + +## Package + +The reusable engine is the Python package `project_setup`. + +Entrypoints: + +```bash +project-setup --help +project_setup --help +python -m project_setup --help +``` + +## Distribution model + +The installer embeds the package and managed automation files directly in the target repository. It also installs `Makefile` and `.env.example` when they do not already exist. + +Preview installation: + +```bash +python -m project_setup init --target ../target-repository --dry-run +``` + +Install files explicitly: + +```bash +python -m project_setup init --target ../target-repository --live +``` + +Existing files are preserved unless `--force` is explicitly selected. Existing Makefiles and environment templates should be reviewed and merged manually. Installation dry-runs do not create the target directory. + +## Local environment + +The CLI automatically loads `.env` from the current working directory without replacing variables that are already present in the process environment. + +```bash +cp .env.example .env +make doctor +``` + +`make doctor` validates the operating system, Python executable, local files, token source, and GitHub CLI authentication without writing to GitHub. + +## Configuration + +`project_setup.json` points to four manifests and selects which API modules participate: + +- labels; +- milestones; +- Project v2 definition; +- backlog stories and tasks. + +Every mutating CLI command remains dry-run by default. A live operation requires `--live`, the compatible `--no-dry-run` alias, or `LIVE=1` through Make. + +## Discovery + +The `discover` command detects common Python, Node.js, Go, Java, Rust and .NET markers. It reports authentication status and prints a safely quoted recommended `apply` command before any write operation. Non-interactive discovery enforces dry-run in the recommended command. + +## Authentication boundary + +Repository-scoped Actions operations use the standard token: + +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` + +No user-created `GITHUB_TOKEN` secret is needed. This token covers labels, milestones, issues, sub-issues, and PR comments within the repository, subject to the workflow `permissions` block. + +Local commands may also use `GITHUB_TOKEN`, `GH_TOKEN`, or a valid `gh auth` session. Diagnostics distinguish an unavailable CLI from an invalid CLI session and never print credentials. + +GitHub Projects v2 are owned by a user or organization rather than a repository. Live Project creation or synchronization therefore requires `PROJECT_SETUP_PAT`. + +For the current GraphQL implementation: + +1. create a personal access token classic; +2. select `repo` and `project` scopes; +3. save it as `PROJECT_SETUP_PAT` in the local `.env`; +4. save the same credential as the `PROJECT_SETUP_PAT` Actions secret when manual workflows must operate on Projects v2. + +The CLI and workflow fail before a live Project v2 operation if the explicit PAT is missing. They do not silently use `github.token` for that operation. A Project sync dry-run without the PAT falls back to an offline preview and clearly states that remote data was not queried. + +## Request safety + +GitHub API requests use a finite timeout. Automatic retries are limited to idempotent reads; mutation requests are not replayed after transport failures because a lost response could otherwise duplicate an issue or Project. + +## Automation boundaries + +The tool automates repository files, labels, milestones, issues, sub-issues and Project v2 fields/items. Branch protection, rulesets and Project views remain outside the automated core. diff --git a/docs/repo/project-setup.workflow-template.yml b/docs/repo/project-setup.workflow-template.yml new file mode 100644 index 0000000..653f47f --- /dev/null +++ b/docs/repo/project-setup.workflow-template.yml @@ -0,0 +1,112 @@ +name: Project setup + +on: + workflow_dispatch: + inputs: + run_labels_sync: + description: "Synchronize labels" + required: true + default: true + type: boolean + run_milestones_sync: + description: "Synchronize milestones" + required: true + default: true + type: boolean + run_issue_generation: + description: "Generate backlog issues and tasks" + required: true + default: false + type: boolean + run_project_creation: + description: "Create a GitHub Project v2 (requires PROJECT_SETUP_PAT for live runs)" + required: true + default: false + type: boolean + dry_run: + description: "Plan changes without writing to GitHub" + required: true + default: true + type: boolean + +permissions: + contents: read + issues: write + +concurrency: + group: project-setup-${{ github.repository }} + cancel-in-progress: false + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Validate embedded setup package and configuration + env: + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + python -m compileall -q project_setup + python -m project_setup doctor --config project_setup.json + + - name: Require PAT for live Project v2 creation + if: ${{ inputs.run_project_creation && !inputs.dry_run }} + env: + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + if [ -z "${PROJECT_SETUP_PAT:-}" ]; then + echo "::error title=PROJECT_SETUP_PAT is required::GitHub's repository-scoped token cannot create or synchronize Projects v2." + echo "Create a personal access token (classic):" + echo " GitHub profile picture > Settings > Developer settings" + echo " Personal access tokens > Tokens (classic) > Generate new token (classic)" + echo " Select scopes: repo and project" + echo "Save it as the repository Actions secret PROJECT_SETUP_PAT, then run this workflow again." + exit 1 + fi + echo "PROJECT_SETUP_PAT is configured for the requested Project v2 operation." + + - name: Apply project setup + env: + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + args="--config project_setup.json" + if [ "${{ inputs.dry_run }}" = "true" ]; then + args="$args --dry-run" + else + args="$args --live" + fi + if [ "${{ inputs.run_labels_sync }}" = "true" ]; then + args="$args --run-labels" + else + args="$args --skip-labels" + fi + if [ "${{ inputs.run_milestones_sync }}" = "true" ]; then + args="$args --run-milestones" + else + args="$args --skip-milestones" + fi + if [ "${{ inputs.run_issue_generation }}" = "true" ]; then + args="$args --run-issue-generation" + else + args="$args --skip-issue-generation" + fi + if [ "${{ inputs.run_project_creation }}" = "true" ]; then + args="$args --run-project-creation" + else + args="$args --skip-project-creation" + fi + python -m project_setup apply $args diff --git a/docs/repo/script-reference-contract.md b/docs/repo/script-reference-contract.md new file mode 100644 index 0000000..8c25f09 --- /dev/null +++ b/docs/repo/script-reference-contract.md @@ -0,0 +1,12 @@ +# Validation script reference contract + +The reusable setup currently distributes two validation scripts. + +| Script | Invoked by | Distributed by | +| --- | --- | --- | +| `scripts/validation/repo_quality.py` | `Makefile` (`quality` / `check`) | `project_setup/installer.py` | +| `scripts/validation/validate_pr_body.py` | `.github/workflows/pr-metadata.yml` | `project_setup/installer.py` | + +`repo_quality.py` validates this contract on every `make check` run. A new `.py`, `.sh`, or `.ps1` file under `scripts/` must be registered with its caller before the quality gate can pass. + +The legacy namespace list is assembled at runtime. This prevents the validator from reporting its own deny-list definitions as obsolete references while still scanning the complete file. diff --git a/docs/repo/script-reference-contract.pt-BR.md b/docs/repo/script-reference-contract.pt-BR.md new file mode 100644 index 0000000..c8d8c10 --- /dev/null +++ b/docs/repo/script-reference-contract.pt-BR.md @@ -0,0 +1,12 @@ +# Contrato de referências dos scripts de validação + +A configuração reutilizável distribui atualmente dois scripts de validação. + +| Script | Chamado por | Distribuído por | +| --- | --- | --- | +| `scripts/validation/repo_quality.py` | `Makefile` (`quality` / `check`) | `project_setup/installer.py` | +| `scripts/validation/validate_pr_body.py` | `.github/workflows/pr-metadata.yml` | `project_setup/installer.py` | + +O `repo_quality.py` valida esse contrato em cada execução de `make check`. Um novo arquivo `.py`, `.sh` ou `.ps1` dentro de `scripts/` precisa ser registrado com seu chamador antes que o quality gate possa passar. + +A lista de namespaces legados é montada em tempo de execução. Isso evita que o validador interprete a própria lista de bloqueio como referência obsoleta, sem deixar de inspecionar o arquivo completo. diff --git a/governance_bootstrap/__init__.py b/governance_bootstrap/__init__.py deleted file mode 100644 index d73763e..0000000 --- a/governance_bootstrap/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Reusable GitHub governance bootstrap tooling.""" - -__all__ = ["__version__"] -__version__ = "0.1.0" diff --git a/governance_bootstrap/cli.py b/governance_bootstrap/cli.py deleted file mode 100644 index 367a1c8..0000000 --- a/governance_bootstrap/cli.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -import argparse -import os - -from .bootstrap import load_bootstrap_config, run_bootstrap -from .auto_label import apply_auto_labels -from .discovery import cmd_discover -from .github import GitHubClient, get_token, require_client -from .issue_milestones import sync_issue_milestones -from .issues import generate_issues -from .labels import sync_labels -from .milestones import sync_milestones -from .project import create_project, sync_project - - -def repo_arg(value: str | None) -> str: - repo = value or os.getenv("GITHUB_REPOSITORY") - if not repo: - raise SystemExit("Missing --repo and GITHUB_REPOSITORY") - return repo - - -def optional_client() -> GitHubClient | None: - token = get_token() - return GitHubClient(token) if token else None - - -def cmd_labels_sync(args) -> int: - client = GitHubClient("") if args.dry_run else require_client() - sync_labels(client, repo_arg(args.repo), args.file, dry_run=args.dry_run) - return 0 - - -def cmd_milestones_sync(args) -> int: - client = GitHubClient("") if args.dry_run else require_client() - sync_milestones(client, repo_arg(args.repo), args.file, dry_run=args.dry_run) - return 0 - - -def cmd_issues_generate(args) -> int: - generate_issues(repo_arg(args.repo), args.file, dry_run=args.dry_run, link_subissues=args.link_subissues) - return 0 - - -def cmd_project_create(args) -> int: - client = GitHubClient("") if args.dry_run else require_client() - create_project(client, repo_arg(args.repo), args.file, dry_run=args.dry_run) - return 0 - - -def cmd_project_sync(args) -> int: - sync_project( - require_client(), - repo_arg(args.repo), - args.file, - args.project_number, - owner=args.owner, - issue_state=args.issue_state, - link_subissue_items=args.link_subissues, - only_link_subissues=args.only_link_subissues, - dry_run=args.dry_run, - ) - return 0 - - -def cmd_issue_milestones_sync(args) -> int: - sync_issue_milestones(require_client(), repo_arg(args.repo), clear_not_planned=args.clear_not_planned, dry_run=args.dry_run) - return 0 - - -def cmd_auto_label_apply(args) -> int: - event_path = args.event_path or os.getenv("GITHUB_EVENT_PATH") - if not event_path: - print("Missing --event-path or GITHUB_EVENT_PATH") - return 1 - return apply_auto_labels(repo_arg(args.repo), event_path, args.labels_file, optional_client(), dry_run=args.dry_run) - - -def cmd_bootstrap(args) -> int: - config = load_bootstrap_config(args.config) - defaults = config.get("defaults", {}) - dry_run = args.dry_run if args.dry_run is not None else defaults.get("dryRun", True) - repo = repo_arg(args.repo) - client = GitHubClient("") if dry_run else require_client() - - run_labels = args.run_labels if args.run_labels is not None else defaults.get("runLabels", True) - run_milestones = args.run_milestones if args.run_milestones is not None else defaults.get("runMilestones", True) - run_project_creation = args.run_project_creation if args.run_project_creation is not None else defaults.get("runProjectCreation", False) - run_issue_generation = args.run_issue_generation if args.run_issue_generation is not None else defaults.get("runIssueGeneration", True) - link_subissues = args.link_subissues if args.link_subissues is not None else defaults.get("linkSubissues", False) - - run_bootstrap( - client, - repo, - config, - dry_run=dry_run, - run_labels=run_labels, - run_milestones=run_milestones, - run_project_creation=run_project_creation, - run_issue_generation=run_issue_generation, - link_subissues=link_subissues, - ) - return 0 - - -def add_bool_pair(parser: argparse.ArgumentParser, name: str, dest: str, help_text: str) -> None: - group = parser.add_mutually_exclusive_group() - group.add_argument(f"--{name}", dest=dest, action="store_true", default=None, help=help_text) - group.add_argument(f"--skip-{name.removeprefix('run-')}", dest=dest, action="store_false") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="governance", description="Reusable GitHub governance bootstrap CLI") - sub = parser.add_subparsers(dest="command", required=True) - - labels = sub.add_parser("labels") - labels_sub = labels.add_subparsers(dest="labels_command", required=True) - labels_sync = labels_sub.add_parser("sync") - labels_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - labels_sync.add_argument("--file", default="config/project/labels.json") - labels_sync.add_argument("--dry-run", action="store_true") - labels_sync.set_defaults(func=cmd_labels_sync) - - milestones = sub.add_parser("milestones") - milestones_sub = milestones.add_subparsers(dest="milestones_command", required=True) - milestones_sync = milestones_sub.add_parser("sync") - milestones_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - milestones_sync.add_argument("--file", default="config/project/milestones.json") - milestones_sync.add_argument("--dry-run", action="store_true") - milestones_sync.set_defaults(func=cmd_milestones_sync) - - issues = sub.add_parser("issues") - issues_sub = issues.add_subparsers(dest="issues_command", required=True) - issues_generate = issues_sub.add_parser("generate") - issues_generate.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - issues_generate.add_argument("--file", default="config/stories/backlog-manifest.json") - issues_generate.add_argument("--dry-run", action="store_true") - issues_generate.add_argument("--link-subissues", action="store_true") - issues_generate.set_defaults(func=cmd_issues_generate) - - project = sub.add_parser("project") - project_sub = project.add_subparsers(dest="project_command", required=True) - project_create = project_sub.add_parser("create") - project_create.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - project_create.add_argument("--file", default="config/project/project-definition.json") - project_create.add_argument("--dry-run", action="store_true") - project_create.set_defaults(func=cmd_project_create) - - project_sync = project_sub.add_parser("sync") - project_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - project_sync.add_argument("--owner") - project_sync.add_argument("--file", default="config/project/project-definition.json") - project_sync.add_argument("--project-number", type=int, required=True) - project_sync.add_argument("--issue-state", default="open", choices=["open", "closed", "all"]) - project_sync.add_argument("--link-subissues", action="store_true") - project_sync.add_argument("--only-link-subissues", action="store_true") - project_sync.add_argument("--dry-run", action="store_true") - project_sync.set_defaults(func=cmd_project_sync) - - issue_milestones = sub.add_parser("issue-milestones") - issue_milestones_sub = issue_milestones.add_subparsers(dest="issue_milestones_command", required=True) - issue_milestones_sync = issue_milestones_sub.add_parser("sync") - issue_milestones_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - issue_milestones_sync.add_argument("--clear-not-planned", action="store_true") - issue_milestones_sync.add_argument("--dry-run", action="store_true") - issue_milestones_sync.set_defaults(func=cmd_issue_milestones_sync) - - auto_label = sub.add_parser("auto-label") - auto_label_sub = auto_label.add_subparsers(dest="auto_label_command", required=True) - auto_label_apply = auto_label_sub.add_parser("apply") - auto_label_apply.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - auto_label_apply.add_argument("--event-path", default=os.getenv("GITHUB_EVENT_PATH")) - auto_label_apply.add_argument("--labels-file", default="config/project/labels.json") - auto_label_apply.add_argument("--dry-run", action="store_true") - auto_label_apply.set_defaults(func=cmd_auto_label_apply) - - discover = sub.add_parser("discover") - discover.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - discover.add_argument("--config", default="governance.bootstrap.json") - discover.add_argument("--root", default=".") - discover.add_argument("--project-type") - discover.add_argument("--auto", action="store_true") - discover.add_argument("--apply", action="store_true") - discover.set_defaults(func=cmd_discover) - - bootstrap = sub.add_parser("bootstrap") - bootstrap.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - bootstrap.add_argument("--config", default="governance.bootstrap.json") - dry_run_group = bootstrap.add_mutually_exclusive_group() - dry_run_group.add_argument("--dry-run", dest="dry_run", action="store_true", default=None) - dry_run_group.add_argument("--no-dry-run", dest="dry_run", action="store_false") - add_bool_pair(bootstrap, "run-labels", "run_labels", "Run labels sync") - add_bool_pair(bootstrap, "run-milestones", "run_milestones", "Run milestones sync") - add_bool_pair(bootstrap, "run-project-creation", "run_project_creation", "Create project v2") - add_bool_pair(bootstrap, "run-issue-generation", "run_issue_generation", "Generate issues/tasks") - link_group = bootstrap.add_mutually_exclusive_group() - link_group.add_argument("--link-subissues", dest="link_subissues", action="store_true", default=None) - link_group.add_argument("--no-link-subissues", dest="link_subissues", action="store_false") - bootstrap.set_defaults(func=cmd_bootstrap) - - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - return args.func(args) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/governance_bootstrap/discovery.py b/governance_bootstrap/discovery.py deleted file mode 100644 index 9fe435c..0000000 --- a/governance_bootstrap/discovery.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -import os -import shutil -import subprocess -import sys - -from .bootstrap import load_bootstrap_config, run_bootstrap -from .github import GitHubClient, get_token, require_client - - -SUPPORTED_PROJECT_TYPES = ["python", "node", "go", "java", "rust", "dotnet", "generic"] -PROJECT_MARKERS = { - "python": ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], - "node": ["package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock"], - "go": ["go.mod"], - "java": ["pom.xml", "build.gradle", "build.gradle.kts"], - "rust": ["Cargo.toml"], - "dotnet": ["*.csproj", "*.sln"], -} - - -@dataclass(frozen=True) -class AuthStatus: - configured: bool - source: str - detail: str - - -@dataclass(frozen=True) -class ProjectMatch: - project_type: str - markers: tuple[str, ...] - - -def detect_auth_status() -> AuthStatus: - token = get_token() - if token: - return AuthStatus(True, "environment", "GITHUB_TOKEN or GH_TOKEN is set") - - gh = shutil.which("gh") - if gh: - result = subprocess.run([gh, "auth", "status", "--hostname", "github.com"], capture_output=True, text=True, timeout=10) - if result.returncode == 0: - return AuthStatus(True, "gh", "gh auth status succeeded") - detail = (result.stderr or result.stdout or "gh auth status failed").strip() - return AuthStatus(False, "gh", detail) - - return AuthStatus(False, "missing", "No GITHUB_TOKEN/GH_TOKEN and gh CLI not found") - - -def _collect_markers(root: Path, patterns: list[str]) -> list[str]: - markers: list[str] = [] - for pattern in patterns: - if "*" in pattern: - markers.extend(sorted(str(path.relative_to(root)) for path in root.glob(pattern) if path.is_file())) - continue - candidate = root / pattern - if candidate.exists(): - markers.append(pattern) - return markers - - -def detect_project_matches(root: str | os.PathLike[str]) -> list[ProjectMatch]: - root_path = Path(root) - if not root_path.exists(): - raise FileNotFoundError(f"Project root does not exist: {root_path}") - - matches: list[ProjectMatch] = [] - for project_type in ("python", "node", "go", "java", "rust", "dotnet"): - markers = _collect_markers(root_path, PROJECT_MARKERS[project_type]) - if markers: - matches.append(ProjectMatch(project_type, tuple(markers))) - - if matches: - return matches - return [ProjectMatch("generic", tuple())] - - -def resolve_project_match(root: str | os.PathLike[str], override: str | None = None) -> ProjectMatch: - if override: - if override not in SUPPORTED_PROJECT_TYPES: - raise ValueError(f"Unsupported project type override: {override}") - matches = detect_project_matches(root) - markers = matches[0].markers if matches and matches[0].project_type == override else tuple() - return ProjectMatch(override, markers) - - matches = detect_project_matches(root) - if len(matches) == 1: - return matches[0] - - if not sys.stdin.isatty(): - return matches[0] - - print("Multiple project types detected:") - for index, match in enumerate(matches, start=1): - print(f" {index}. {match.project_type} ({', '.join(match.markers)})") - print(" 0. generic") - - while True: - choice = input("Choose project type [1]: ").strip() - if choice in {"", "1"}: - return matches[0] - if choice == "0": - return ProjectMatch("generic", tuple()) - if choice.isdigit(): - index = int(choice) - if 1 <= index <= len(matches): - return matches[index - 1] - print("Invalid choice, try again.") - - -def _prompt_bool(question: str, default: bool) -> bool: - suffix = "[Y/n]" if default else "[y/N]" - while True: - answer = input(f"{question} {suffix} ").strip().lower() - if not answer: - return default - if answer in {"y", "yes", "true", "1"}: - return True - if answer in {"n", "no", "false", "0"}: - return False - print("Please answer yes or no.") - - -def _prompt_confirm(message: str) -> bool: - while True: - answer = input(f"{message} [y/N] ").strip().lower() - if not answer: - return False - if answer in {"y", "yes", "true", "1"}: - return True - if answer in {"n", "no", "false", "0"}: - return False - print("Please answer yes or no.") - - -def build_bootstrap_command(repo: str, config_path: str, dry_run: bool, run_labels: bool, run_milestones: bool, run_project_creation: bool, run_issue_generation: bool, link_subissues: bool) -> str: - parts = [ - "python -m governance_bootstrap bootstrap", - f"--repo {repo}", - f"--config {config_path}", - ] - parts.append("--dry-run" if dry_run else "--no-dry-run") - parts.append("--run-labels" if run_labels else "--skip-labels") - parts.append("--run-milestones" if run_milestones else "--skip-milestones") - parts.append("--run-project-creation" if run_project_creation else "--skip-project-creation") - parts.append("--run-issue-generation" if run_issue_generation else "--skip-issue-generation") - parts.append("--link-subissues" if link_subissues else "--no-link-subissues") - return " ".join(parts) - - -def cmd_discover(args) -> int: - config = load_bootstrap_config(args.config) - repo = args.repo or os.getenv("GITHUB_REPOSITORY") - if not repo: - print("Missing --repo and GITHUB_REPOSITORY") - return 1 - - auth = detect_auth_status() - print("==> GitHub auth") - if auth.configured: - print(f"Configured: yes ({auth.source})") - else: - print(f"Configured: no ({auth.source})") - print(auth.detail) - pat_name = config.get("workflowVar", "GOVERNANCE_PAT") - print(f"Expected workflow secret: {pat_name}") - return 1 - - print("==> Project detection") - try: - project = resolve_project_match(args.root, args.project_type) - except (FileNotFoundError, ValueError) as exc: - print(str(exc)) - return 1 - if project.project_type == "generic": - print("Detected project type: generic") - print("No common project markers found.") - else: - print(f"Detected project type: {project.project_type}") - if project.markers: - print(f"Markers: {', '.join(project.markers)}") - - defaults = config.get("defaults", {}) - interactive = sys.stdin.isatty() and not args.auto - dry_run = defaults.get("dryRun", True) - run_labels = defaults.get("runLabels", True) - run_milestones = defaults.get("runMilestones", True) - run_project_creation = defaults.get("runProjectCreation", False) - run_issue_generation = defaults.get("runIssueGeneration", True) - link_subissues = defaults.get("linkSubissues", False) - - print("==> Bootstrap options") - if interactive: - dry_run = _prompt_bool("Run in dry-run mode?", dry_run) - run_labels = _prompt_bool("Sync labels?", run_labels) - run_milestones = _prompt_bool("Sync milestones?", run_milestones) - run_project_creation = _prompt_bool("Create GitHub Project v2?", run_project_creation) - run_issue_generation = _prompt_bool("Generate issues/tasks?", run_issue_generation) - link_subissues = _prompt_bool("Link sub-issues when generating tasks?", link_subissues) - else: - print("Using config defaults (non-interactive).") - - print(f"Dry-run: {'yes' if dry_run else 'no'}") - print(f"Sync labels: {'yes' if run_labels else 'no'}") - print(f"Sync milestones: {'yes' if run_milestones else 'no'}") - print(f"Create project: {'yes' if run_project_creation else 'no'}") - print(f"Generate issues: {'yes' if run_issue_generation else 'no'}") - print(f"Link sub-issues: {'yes' if link_subissues else 'no'}") - - print("==> Recommended command") - command = build_bootstrap_command(repo, args.config, dry_run, run_labels, run_milestones, run_project_creation, run_issue_generation, link_subissues) - print(command) - - if args.apply: - if not sys.stdin.isatty(): - print("Confirmation required to run the selected command interactively.") - return 1 - if not _prompt_confirm("Run the selected bootstrap command now?"): - print("Aborted.") - return 1 - client = GitHubClient("") if dry_run else require_client() - run_bootstrap( - client, - repo, - config, - dry_run=dry_run, - run_labels=run_labels, - run_milestones=run_milestones, - run_project_creation=run_project_creation, - run_issue_generation=run_issue_generation, - link_subissues=link_subissues, - ) - - return 0 diff --git a/governance_bootstrap/github.py b/governance_bootstrap/github.py deleted file mode 100644 index 02a371c..0000000 --- a/governance_bootstrap/github.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import time -import urllib.error -import urllib.parse -import urllib.request - - -API_BASE = "https://api.github.com" -GRAPHQL_URL = f"{API_BASE}/graphql" -API_VERSION = "2022-11-28" -RETRYABLE_HTTP_STATUS = {502, 503, 504} - - -class GitHubRequestError(RuntimeError): - def __init__(self, method: str, url: str, status: int, details: str): - super().__init__(f"GitHub API request failed ({method} {url}) status={status}: {details}") - self.method = method - self.url = url - self.status = status - self.details = details - - -def get_token() -> str | None: - token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") - if token: - return token - - gh = shutil.which("gh") - if not gh: - return None - - result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True) - if result.returncode != 0: - return None - token = result.stdout.strip() - return token or None - - -def split_repo(repo: str) -> tuple[str, str]: - if "/" not in repo: - raise ValueError("repository must use owner/name format") - return repo.split("/", 1) - - -class GitHubClient: - def __init__(self, token: str): - self.token = token - - def request_json(self, method: str, url: str, payload=None, accept: str = "application/vnd.github+json"): - headers = { - "Accept": accept, - "Authorization": f"Bearer {self.token}", - "X-GitHub-Api-Version": API_VERSION, - "Content-Type": "application/json", - } - data = json.dumps(payload).encode("utf-8") if payload is not None else None - for attempt in range(1, 6): - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req) as res: - body = res.read().decode("utf-8") - return json.loads(body) if body else {} - except urllib.error.HTTPError as exc: - details = exc.read().decode("utf-8", errors="replace") - if exc.code in RETRYABLE_HTTP_STATUS and attempt < 5: - wait_seconds = attempt * 2 - print(f"warning: HTTP {exc.code} from GitHub; retrying in {wait_seconds}s") - time.sleep(wait_seconds) - continue - raise GitHubRequestError(method, url, exc.code, details) from exc - except urllib.error.URLError as exc: - if attempt < 5: - wait_seconds = attempt * 2 - print(f"warning: GitHub request failed; retrying in {wait_seconds}s: {exc.reason}") - time.sleep(wait_seconds) - continue - raise - - def paginated(self, url: str): - items = [] - page = 1 - while True: - sep = "&" if "?" in url else "?" - batch = self.request_json("GET", f"{url}{sep}per_page=100&page={page}") - items.extend(batch) - if len(batch) < 100: - return items - page += 1 - - def graphql(self, query: str, variables: dict | None = None): - data = self.request_json("POST", GRAPHQL_URL, {"query": query, "variables": variables or {}}) - if data.get("errors"): - raise RuntimeError(f"GraphQL error: {json.dumps(data['errors'], ensure_ascii=False)}") - return data["data"] - - -def require_client() -> GitHubClient: - token = get_token() - if not token: - raise SystemExit("Missing GITHUB_TOKEN or GH_TOKEN") - return GitHubClient(token) diff --git a/governance_bootstrap/issue_milestones.py b/governance_bootstrap/issue_milestones.py deleted file mode 100644 index 9b7bae7..0000000 --- a/governance_bootstrap/issue_milestones.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import re - -from .github import API_BASE, GitHubClient, split_repo - - -def milestone_from_body(body: str) -> str | None: - match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", body or "") - return match.group(1) if match else None - - -def parent_issue_number_from_body(body: str) -> int | None: - match = re.search(r"Parent story:.*\(#(\d+)\)", body or "") - return int(match.group(1)) if match else None - - -def sync_issue_milestones(client: GitHubClient, repo: str, clear_not_planned: bool = False, dry_run: bool = False) -> None: - owner, name = split_repo(repo) - repo_base = f"{API_BASE}/repos/{owner}/{name}" - - milestones = client.paginated(f"{repo_base}/milestones?state=all") - milestone_by_title = {milestone["title"]: milestone for milestone in milestones} - issues = [ - issue - for issue in client.paginated(f"{repo_base}/issues?state=all&sort=created&direction=asc") - if "pull_request" not in issue - ] - - explicit_milestone_by_issue = {} - for issue in issues: - milestone = milestone_from_body(issue.get("body") or "") - if milestone: - explicit_milestone_by_issue[issue["number"]] = milestone - - updated = 0 - cleared = 0 - already_correct = 0 - unmapped = [] - - for issue in issues: - issue_number = issue["number"] - current = issue.get("milestone") - current_title = current["title"] if current else None - - if clear_not_planned and issue.get("state") == "closed" and issue.get("state_reason") == "not_planned": - if current_title: - if dry_run: - print(f"[DRY-RUN] Would clear milestone from not-planned issue #{issue_number}: {current_title}") - else: - client.request_json("PATCH", f"{repo_base}/issues/{issue_number}", {"milestone": None}) - print(f"cleared #{issue_number}: {current_title}") - cleared += 1 - else: - already_correct += 1 - continue - - target = explicit_milestone_by_issue.get(issue_number) - if not target: - parent_number = parent_issue_number_from_body(issue.get("body") or "") - if parent_number: - target = explicit_milestone_by_issue.get(parent_number) - - if not target: - unmapped.append((issue_number, issue["title"])) - continue - - milestone = milestone_by_title.get(target) - if not milestone: - raise RuntimeError(f"Milestone '{target}' referenced by issue #{issue_number} does not exist") - - if current_title == target: - already_correct += 1 - continue - - if dry_run: - print(f"[DRY-RUN] Would set issue #{issue_number}: {current_title or 'none'} -> {target}") - else: - client.request_json("PATCH", f"{repo_base}/issues/{issue_number}", {"milestone": milestone["number"]}) - print(f"updated #{issue_number}: {current_title or 'none'} -> {target}") - updated += 1 - - print(f"issues_checked={len(issues)}") - print(f"updated={updated}") - print(f"cleared_not_planned={cleared}") - print(f"already_correct={already_correct}") - print(f"unmapped={len(unmapped)}") - for issue_number, title in unmapped: - print(f"unmapped #{issue_number}: {title}") diff --git a/governance_bootstrap/issues.py b/governance_bootstrap/issues.py deleted file mode 100644 index 26f21dc..0000000 --- a/governance_bootstrap/issues.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess - - -def run_gh(cmd: list[str]) -> str: - result = subprocess.run(cmd, text=True, capture_output=True) - if result.returncode != 0: - raise RuntimeError(f"GitHub command failed. stderr:\n{result.stderr}") - return result.stdout.strip() - - -def create_issue(repo: str, title: str, body: str, labels: list[str]) -> int: - cmd = ["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body] - for label in labels: - cmd += ["--label", label] - url = run_gh(cmd) - parts = url.rstrip("/").split("/") - if len(parts) < 2 or not parts[-1].isdigit(): - raise RuntimeError(f"Unexpected gh issue create output: {url}") - return int(parts[-1]) - - -def issue_node_id(repo: str, number: int) -> str: - owner, name = repo.split("/", 1) - return run_gh([ - "gh", "api", "graphql", - "-f", "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){issue(number:$number){id}}}", - "-f", f"owner={owner}", - "-f", f"repo={name}", - "-F", f"number={number}", - "--jq", ".data.repository.issue.id", - ]) - - -def add_sub_issue(repo: str, parent_number: int, child_number: int) -> None: - parent_id = issue_node_id(repo, parent_number) - child_id = issue_node_id(repo, child_number) - run_gh([ - "gh", "api", "graphql", - "-f", "query=mutation($parent:ID!,$child:ID!){addSubIssue(input:{issueId:$parent,subIssueId:$child}){clientMutationId}}", - "-f", f"parent={parent_id}", - "-f", f"child={child_id}", - ]) - - -def load_backlog(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - if "milestones" not in data: - raise ValueError("backlog manifest must contain milestones") - return data - - -def generate_issues(repo: str, manifest: str, dry_run: bool = False, link_subissues: bool = False) -> None: - if not repo: - repo = os.getenv("GITHUB_REPOSITORY", "") - if not repo: - raise SystemExit("Missing --repo and GITHUB_REPOSITORY") - - data = load_backlog(manifest) - for milestone_entry in data["milestones"]: - for story in milestone_entry["stories"]: - story_labels = list(dict.fromkeys(story["labels"] + data.get("defaultIssueLabels", []))) - story_body = ( - f"{story['body']}\n\n" - f"## Acceptance criteria\n{story.get('acceptanceCriteria', '- TBD')}\n\n" - f"## Test strategy\n{story.get('testStrategy', '- TBD')}\n\n" - f"## Definition of Done\n{story.get('dod', '- TBD')}\n\n" - f"- Milestone: {milestone_entry['milestone']}\n" - f"- Item type: user-story\n" - ) - if dry_run: - print(f"[DRY-RUN] Story: {story['title']} labels={story_labels}") - story_num = 0 - else: - story_num = create_issue(repo, story["title"], story_body, story_labels) - print(f"Created story #{story_num}: {story['title']}") - - for task_title in story.get("tasks", []): - task_labels = ["type:task", "status:backlog"] - task_body = ( - f"Parent story: {story['storyId']}" - + (f" (#{story_num})" if story_num else "") - + "\n\n" - + "## Technical scope\n- TBD\n\n" - + "## Completion criteria\n- TBD\n\n" - + "## Test strategy\n- TBD\n\n" - + "## Expected evidence\n- TBD\n\n" - + "## Definition of Done\n- TBD\n\n" - + "- Item type: task/sub-issue\n" - ) - if dry_run: - print(f"[DRY-RUN] Task: {task_title} labels={task_labels}") - continue - task_num = create_issue(repo, task_title, task_body, task_labels) - print(f" Created task #{task_num}: {task_title}") - if link_subissues: - add_sub_issue(repo, story_num, task_num) - print(f" Linked #{task_num} as sub-issue of #{story_num}") diff --git a/governance_bootstrap/project.py b/governance_bootstrap/project.py deleted file mode 100644 index 584c5e4..0000000 --- a/governance_bootstrap/project.py +++ /dev/null @@ -1,410 +0,0 @@ -from __future__ import annotations - -import json -import re - -from .github import API_BASE, GitHubClient, split_repo - - -def load_project_definition(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - definition = json.load(f) - if "name" not in definition: - raise ValueError("project definition must contain name") - return definition - - -def owner_node(client: GitHubClient, owner: str) -> str: - query_user = "query($login:String!){user(login:$login){id}}" - data = client.graphql(query_user, {"login": owner}) - user = data.get("user") - if user and user.get("id"): - return user["id"] - query_org = "query($login:String!){organization(login:$login){id}}" - data = client.graphql(query_org, {"login": owner}) - org = data.get("organization") - if org and org.get("id"): - return org["id"] - raise RuntimeError(f"Owner not found: {owner}") - - -def create_project(client: GitHubClient, repo: str, definition_file: str, dry_run: bool = False) -> None: - definition = load_project_definition(definition_file) - owner = split_repo(repo)[0] - if dry_run: - print(f"[DRY-RUN] Would create project: {definition['name']}") - print("[DRY-RUN] Fields to configure:") - for field in definition.get("fields", []): - print(f"- {field['name']} ({field['type']})") - return - - oid = owner_node(client, owner) - mutation = """ - mutation($owner:ID!, $title:String!) { - createProjectV2(input:{ownerId:$owner,title:$title}) { - projectV2 { id url } - } - } - """ - data = client.graphql(mutation, {"owner": oid, "title": definition["name"]}) - project = data["createProjectV2"]["projectV2"] - print(json.dumps(project, ensure_ascii=False)) - print(f"Project created. Configure custom fields and views using {definition_file}.") - - -def find_project(client: GitHubClient, owner: str, project_number: int) -> tuple[dict, str]: - query_user = """ - query($login:String!, $number:Int!) { - user(login:$login) { projectV2(number:$number) { id title url } } - } - """ - data = client.graphql(query_user, {"login": owner, "number": project_number}) - user = data.get("user") - if user and user.get("projectV2"): - return user["projectV2"], "user" - - query_org = """ - query($login:String!, $number:Int!) { - organization(login:$login) { projectV2(number:$number) { id title url } } - } - """ - data = client.graphql(query_org, {"login": owner, "number": project_number}) - org = data.get("organization") - if org and org.get("projectV2"): - return org["projectV2"], "org" - raise RuntimeError(f"Project v2 #{project_number} not found for owner '{owner}'") - - -def list_project_fields(client: GitHubClient, project_id: str) -> list[dict]: - query = """ - query($project:ID!, $cursor:String) { - node(id:$project) { - ... on ProjectV2 { - fields(first:100, after:$cursor) { - pageInfo { hasNextPage endCursor } - nodes { - __typename - ... on ProjectV2Field { id name dataType } - ... on ProjectV2SingleSelectField { id name dataType options { id name } } - ... on ProjectV2IterationField { id name dataType } - } - } - } - } - } - """ - fields = [] - cursor = None - while True: - data = client.graphql(query, {"project": project_id, "cursor": cursor}) - page = data["node"]["fields"] - fields.extend(page["nodes"]) - if not page["pageInfo"]["hasNextPage"]: - return fields - cursor = page["pageInfo"]["endCursor"] - - -def create_text_field(client: GitHubClient, project_id: str, name: str) -> dict: - mutation = """ - mutation($project:ID!, $name:String!) { - createProjectV2Field(input:{projectId:$project, name:$name, dataType:TEXT}) { - projectV2Field { ... on ProjectV2Field { id name dataType } } - } - } - """ - data = client.graphql(mutation, {"project": project_id, "name": name}) - return data["createProjectV2Field"]["projectV2Field"] - - -def create_single_select_field(client: GitHubClient, project_id: str, name: str, options: list[str]) -> dict: - mutation = """ - mutation($project:ID!, $name:String!, $options:[ProjectV2SingleSelectFieldOptionInput!]!) { - createProjectV2Field(input:{projectId:$project, name:$name, dataType:SINGLE_SELECT, singleSelectOptions:$options}) { - projectV2Field { ... on ProjectV2SingleSelectField { id name dataType options { id name } } } - } - } - """ - option_payload = [{"name": opt, "color": "GRAY", "description": ""} for opt in options] - data = client.graphql(mutation, {"project": project_id, "name": name, "options": option_payload}) - return data["createProjectV2Field"]["projectV2Field"] - - -def ensure_fields(client: GitHubClient, project_id: str, definition: dict, dry_run: bool = False) -> dict: - existing = list_project_fields(client, project_id) - by_name = {f["name"]: f for f in existing if f and f.get("name")} - for field in definition.get("fields", []): - name = field["name"] - if name in by_name: - continue - if dry_run: - print(f"[DRY-RUN] Would create field: {name} ({field['type']})") - continue - if field["type"] == "text": - created = create_text_field(client, project_id, name) - elif field["type"] == "single_select": - created = create_single_select_field(client, project_id, name, field.get("options", [])) - else: - print(f"Skipping unsupported field type: {field['type']} ({name})") - continue - print(f"created field: {name}") - by_name[name] = created - if dry_run: - return by_name - return {f["name"]: f for f in list_project_fields(client, project_id) if f and f.get("name")} - - -def list_repo_issues(client: GitHubClient, repo: str, state: str = "open") -> list[dict]: - owner, name = split_repo(repo) - issues = client.paginated(f"{API_BASE}/repos/{owner}/{name}/issues?state={state}&sort=created&direction=asc") - return [issue for issue in issues if "pull_request" not in issue] - - -def list_project_items(client: GitHubClient, project_id: str) -> dict: - query = """ - query($project:ID!, $cursor:String) { - node(id:$project) { - ... on ProjectV2 { - items(first:100, after:$cursor) { - pageInfo { hasNextPage endCursor } - nodes { - id - content { __typename ... on Issue { id number } } - } - } - } - } - } - """ - content_to_item = {} - cursor = None - while True: - data = client.graphql(query, {"project": project_id, "cursor": cursor}) - page = data["node"]["items"] - for item in page["nodes"]: - content = item.get("content") - if content and content.get("__typename") == "Issue": - content_to_item[content["id"]] = item["id"] - if not page["pageInfo"]["hasNextPage"]: - return content_to_item - cursor = page["pageInfo"]["endCursor"] - - -def issue_node_id(client: GitHubClient, repo: str, number: int) -> str: - owner, name = split_repo(repo) - query = """ - query($owner:String!, $repo:String!, $number:Int!) { - repository(owner:$owner, name:$repo) { issue(number:$number) { id } } - } - """ - data = client.graphql(query, {"owner": owner, "repo": name, "number": number}) - issue = data["repository"]["issue"] - if not issue: - raise RuntimeError(f"Issue #{number} not found in {repo}") - return issue["id"] - - -def add_issue_to_project(client: GitHubClient, project_id: str, issue_id: str) -> str: - mutation = """ - mutation($project:ID!, $content:ID!) { - addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } } - } - """ - data = client.graphql(mutation, {"project": project_id, "content": issue_id}) - return data["addProjectV2ItemById"]["item"]["id"] - - -def add_sub_issue(client: GitHubClient, parent_id: str, child_id: str) -> None: - mutation = """ - mutation($parent:ID!, $child:ID!) { - addSubIssue(input:{issueId:$parent, subIssueId:$child}) { clientMutationId } - } - """ - client.graphql(mutation, {"parent": parent_id, "child": child_id}) - - -def update_item_position(client: GitHubClient, project_id: str, item_id: str, after_id: str | None) -> None: - mutation = """ - mutation($project:ID!, $item:ID!, $after:ID) { - updateProjectV2ItemPosition(input:{projectId:$project, itemId:$item, afterId:$after}) { clientMutationId } - } - """ - client.graphql(mutation, {"project": project_id, "item": item_id, "after": after_id}) - - -def update_single_select(client: GitHubClient, project_id: str, item_id: str, field_id: str, option_id: str) -> None: - mutation = """ - mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) { - updateProjectV2ItemFieldValue(input:{projectId:$project, itemId:$item, fieldId:$field, value:{singleSelectOptionId:$option}}) { - projectV2Item { id } - } - } - """ - client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "option": option_id}) - - -def update_text(client: GitHubClient, project_id: str, item_id: str, field_id: str, text_value: str) -> None: - mutation = """ - mutation($project:ID!, $item:ID!, $field:ID!, $text:String!) { - updateProjectV2ItemFieldValue(input:{projectId:$project, itemId:$item, fieldId:$field, value:{text:$text}}) { - projectV2Item { id } - } - } - """ - client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "text": text_value}) - - -def milestone_from_body(body: str) -> str | None: - match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", body or "") - return match.group(1) if match else None - - -def milestone_from_issue(issue: dict) -> str | None: - milestone = issue.get("milestone") - if milestone and milestone.get("title"): - return milestone["title"] - return milestone_from_body(issue.get("body", "") or "") - - -def parent_issue_number_from_body(body: str) -> int | None: - match = re.search(r"Parent story:.*\(#(\d+)\)", body or "") - return int(match.group(1)) if match else None - - -def label_value(labels: list, prefix: str) -> str | None: - for label in labels: - name = label["name"] if isinstance(label, dict) else str(label) - if name.startswith(prefix): - return name.split(":", 1)[1] - return None - - -def option_id(field: dict, option_name: str) -> str | None: - for opt in field.get("options", []): - if opt["name"] == option_name: - return opt["id"] - return None - - -def sync_issue_fields(client: GitHubClient, project_id: str, item_id: str, issue: dict, fields: dict, definition: dict, dry_run: bool = False) -> None: - labels = issue.get("labels", []) - milestone = milestone_from_issue(issue) - mappings = { - "Item Type": label_value(labels, "type:"), - "Priority": label_value(labels, "priority:"), - "Status": label_value(labels, "status:"), - "Test Type": label_value(labels, "test:"), - "Milestone": milestone, - } - - for field_name, field_value in mappings.items(): - if not field_value: - continue - field = fields.get(field_name) - if not field: - continue - if field.get("dataType") == "SINGLE_SELECT": - oid = option_id(field, field_value) - if not oid: - print(f"warning: option '{field_value}' not found for field '{field_name}'") - continue - if dry_run: - print(f"[DRY-RUN] Would set {field_name}={field_value} on issue #{issue['number']}") - else: - update_single_select(client, project_id, item_id, field["id"], oid) - elif field.get("dataType") == "TEXT": - if dry_run: - print(f"[DRY-RUN] Would set {field_name}={field_value} on issue #{issue['number']}") - else: - update_text(client, project_id, item_id, field["id"], field_value) - - -def reorder_project_items(client: GitHubClient, project_id: str, issues: list[dict], current_items: dict, dry_run: bool = False) -> None: - previous_item_id = None - for issue in issues: - item_id = current_items.get(issue["node_id"]) - if not item_id: - continue - if dry_run: - print(f"[DRY-RUN] Would position issue #{issue['number']} after {previous_item_id or 'top'}") - else: - update_item_position(client, project_id, item_id, previous_item_id) - previous_item_id = item_id - - -def link_subissues(client: GitHubClient, repo: str, issues: list[dict], dry_run: bool = False) -> None: - node_ids_by_number = {issue["number"]: issue.get("node_id") for issue in issues} - linked = 0 - skipped = 0 - - for issue in issues: - parent_number = parent_issue_number_from_body(issue.get("body", "") or "") - if parent_number is None: - continue - - parent_id = node_ids_by_number.get(parent_number) - if not parent_id: - parent_id = issue_node_id(client, repo, parent_number) - node_ids_by_number[parent_number] = parent_id - - if dry_run: - print(f"[DRY-RUN] Would link issue #{issue['number']} as sub-issue of #{parent_number}") - continue - - try: - add_sub_issue(client, parent_id, issue["node_id"]) - print(f"Linked issue #{issue['number']} as sub-issue of #{parent_number}") - linked += 1 - except RuntimeError as exc: - error_text = str(exc).lower() - if ( - "already" in error_text - or "exists" in error_text - or "duplicate sub-issues" in error_text - or "may only have one parent" in error_text - ): - print(f"Sub-issue link already exists: #{issue['number']} -> #{parent_number}") - skipped += 1 - continue - raise - - print(f"Sub-issue linking finished: linked={linked}, already_present={skipped}") - - -def sync_project(client: GitHubClient, repo: str, definition_file: str, project_number: int, owner: str | None = None, issue_state: str = "open", link_subissue_items: bool = False, only_link_subissues: bool = False, dry_run: bool = False) -> None: - definition = load_project_definition(definition_file) - project_owner = owner or split_repo(repo)[0] - issues = list_repo_issues(client, repo, state=issue_state) - for issue in issues: - issue["node_id"] = issue_node_id(client, repo, issue["number"]) - - if only_link_subissues: - link_subissues(client, repo, issues, dry_run=dry_run) - return - - project, owner_type = find_project(client, project_owner, project_number) - print(f"Project found: {project['title']} ({project['url']}) owner_type={owner_type}") - fields = ensure_fields(client, project["id"], definition, dry_run=dry_run) - current_items = list_project_items(client, project["id"]) - - for issue in issues: - issue_id = issue["node_id"] - item_id = current_items.get(issue_id) - if not item_id: - if dry_run: - print(f"[DRY-RUN] Would add issue #{issue['number']} to project") - item_id = f"dry-run-item-{issue['number']}" - else: - item_id = add_issue_to_project(client, project["id"], issue_id) - current_items[issue_id] = item_id - print(f"Added issue #{issue['number']} to project") - else: - print(f"Issue #{issue['number']} already in project") - sync_issue_fields(client, project["id"], item_id, issue, fields, definition, dry_run=dry_run) - - reorder_project_items(client, project["id"], issues, current_items, dry_run=dry_run) - if link_subissue_items: - link_subissues(client, repo, issues, dry_run=dry_run) - if definition.get("views"): - print("Note: project views are listed in project-definition.json but are not automated by this script.") - for view in definition["views"]: - print(f"- create manually if needed: {view}") diff --git a/governance.bootstrap.json b/project_setup.json similarity index 80% rename from governance.bootstrap.json rename to project_setup.json index 57ece50..6e42fe1 100644 --- a/governance.bootstrap.json +++ b/project_setup.json @@ -1,15 +1,16 @@ { + "version": "0.2.1", "labelsFile": "config/project/labels.json", "milestonesFile": "config/project/milestones.json", "projectDefinitionFile": "config/project/project-definition.json", "backlogManifestFile": "config/stories/backlog-manifest.json", - "workflowVar": "GOVERNANCE_PAT", + "secretName": "PROJECT_SETUP_PAT", "defaults": { "dryRun": true, "runLabels": true, "runMilestones": true, "runProjectCreation": false, - "runIssueGeneration": true, + "runIssueGeneration": false, "linkSubissues": true } } diff --git a/project_setup/__init__.py b/project_setup/__init__.py new file mode 100644 index 0000000..b9c76b7 --- /dev/null +++ b/project_setup/__init__.py @@ -0,0 +1,4 @@ +"""Reusable GitHub project setup and repository automation tooling.""" + +__all__ = ["__version__"] +__version__ = "0.2.1" diff --git a/governance_bootstrap/__main__.py b/project_setup/__main__.py similarity index 100% rename from governance_bootstrap/__main__.py rename to project_setup/__main__.py diff --git a/governance_bootstrap/auto_label.py b/project_setup/auto_label.py similarity index 62% rename from governance_bootstrap/auto_label.py rename to project_setup/auto_label.py index 50ca765..6f019e7 100644 --- a/governance_bootstrap/auto_label.py +++ b/project_setup/auto_label.py @@ -3,20 +3,20 @@ import json import re -from .github import API_BASE, GitHubRequestError, GitHubClient +from .github import API_BASE, GitHubClient, GitHubRequestError LABEL_PREFIXES = ("type:", "priority:", "test:") def load_event(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) + with open(path, "r", encoding="utf-8") as file: + return json.load(file) def load_allowed_labels(path: str) -> set[str]: - with open(path, "r", encoding="utf-8") as f: - return {item["name"] for item in json.load(f)} + with open(path, "r", encoding="utf-8") as file: + return {item["name"] for item in json.load(file)} def label_names(item: dict) -> set[str]: @@ -24,11 +24,11 @@ def label_names(item: dict) -> set[str]: def find_test_label(text: str) -> str | None: - patterns = [ + patterns = ( r"Test strategy\s*\n+\s*(automated|smoke|manual)\b", r"Expected test type\s*\n+\s*(automated|smoke|manual)\b", r"Test type:\s*(automated|smoke|manual)\b", - ] + ) for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: @@ -56,92 +56,76 @@ def linked_issue_number(text: str) -> int | None: return int(match.group(1)) if match else None -def labels_from_linked_issue(client: GitHubClient, repo: str, number: int) -> set[str]: - issue = client.request_json("GET", f"{API_BASE}/repos/{repo}/issues/{number}") - return {name for name in label_names(issue) if name.startswith(LABEL_PREFIXES)} - - -def branch_type_label(branch: str) -> str | None: - prefix = branch.split("/", 1)[0].lower() - if prefix in {"fix", "hotfix"}: - return "type:bug" - if prefix in {"docs", "refactor", "test"}: - return "type:repo" - return None - - def infer_issue_labels(issue: dict) -> set[str]: current = label_names(issue) body = issue.get("body") or "" - labels = set() - + labels: set[str] = set() type_label = next((label for label in current if label.startswith("type:")), None) labels.add(type_label or title_type_label(issue.get("title", "")) or "") - - priority_label = find_priority_label(body) - if priority_label: - labels.add(priority_label) - - test_label = find_test_label(body) - if test_label: + if priority := find_priority_label(body): + labels.add(priority) + if test_label := find_test_label(body): labels.add(test_label) - if not any(label.startswith("status:") for label in current): labels.add("status:backlog") - return {label for label in labels if label} -def infer_pr_labels(repo: str, pr: dict, client: GitHubClient | None) -> set[str]: - body = pr.get("body") or "" - labels = set() - - number = linked_issue_number(body) - if number and client: +def infer_pr_labels(repo: str, pull_request: dict, client: GitHubClient | None) -> set[str]: + body = pull_request.get("body") or "" + current = label_names(pull_request) + labels: set[str] = set() + linked_number = linked_issue_number(body) + if linked_number and client: try: - labels.update(labels_from_linked_issue(client, repo, number)) + issue = client.get_issue(repo, linked_number) + labels.update(name for name in label_names(issue) if name.startswith(LABEL_PREFIXES)) except GitHubRequestError as exc: - print(f"warning: could not read linked issue #{number}: {exc}") - - test_label = find_test_label(body) - if test_label: + print(f"warning: could not read linked issue #{linked_number}: {exc}") + if test_label := find_test_label(body): labels.add(test_label) - - if not any(label.startswith("type:") for label in labels): - type_label = branch_type_label(pr.get("head", {}).get("ref", "")) - if type_label: - labels.add(type_label) - + if not any(label.startswith("type:") for label in labels | current): + prefix = pull_request.get("head", {}).get("ref", "").split("/", 1)[0].lower() + if prefix in {"fix", "hotfix"}: + labels.add("type:bug") + elif prefix in {"docs", "refactor", "test", "chore"}: + labels.add("type:repo") return labels def event_target(event: dict) -> tuple[str, dict, int]: if "issue" in event and "pull_request" not in event["issue"]: - return "issue", event["issue"], event["issue"]["number"] + return "issue", event["issue"], int(event["issue"]["number"]) if "pull_request" in event: - return "pull_request", event["pull_request"], event["pull_request"]["number"] + return "pull_request", event["pull_request"], int(event["pull_request"]["number"]) raise RuntimeError("Unsupported event payload: expected issue or pull_request") -def apply_auto_labels(repo: str, event_path: str, labels_file: str, client: GitHubClient | None, dry_run: bool = False) -> int: +def apply_auto_labels( + repo: str, + event_path: str, + labels_file: str, + client: GitHubClient | None, + dry_run: bool = False, +) -> int: event = load_event(event_path) allowed = load_allowed_labels(labels_file) target_type, item, number = event_target(event) current = label_names(item) - inferred = infer_issue_labels(item) if target_type == "issue" else infer_pr_labels(repo, item, client) labels = sorted(label for label in inferred if label in allowed and label not in current) if not labels: print(f"No labels to add for {target_type} #{number}") return 0 - print(f"Labels to add to {target_type} #{number}: {', '.join(labels)}") if dry_run: return 0 if not client: - print("Missing GITHUB_TOKEN or GH_TOKEN") + print( + "Missing GitHub token.\n" + "Fix: set PROJECT_SETUP_PAT in .env, set GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`." + ) return 1 - try: client.request_json("POST", f"{API_BASE}/repos/{repo}/issues/{number}/labels", {"labels": labels}) except GitHubRequestError as exc: diff --git a/project_setup/cli.py b/project_setup/cli.py new file mode 100644 index 0000000..67fc6ff --- /dev/null +++ b/project_setup/cli.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import platform +import sys + +from .auto_label import apply_auto_labels +from .discovery import SUPPORTED_PROJECT_TYPES, run_discovery +from .github import ( + GitHubClient, + GitHubRequestError, + get_gh_auth_status, + get_project_pat, + get_token, + get_token_source, + load_env_file, + require_client, + require_project_client, +) +from .installer import PROFILE_FILES, install_repository +from .issue_milestones import sync_issue_milestones +from .issues import generate_issues +from .labels import sync_labels +from .milestones import sync_milestones +from .project import create_project, load_project_definition, sync_project +from .pr_validation import upsert_validation_comment, validate_pull_request +from .runner import load_project_setup_config, run_project_setup + + +def repo_arg(value: str | None) -> str: + repository = value or os.getenv("GITHUB_REPOSITORY") + if not repository: + raise SystemExit( + "Missing target repository.\n" + "Fix: pass `--repo owner/repository`, use `REPO=owner/repository` with Make, " + "or set GITHUB_REPOSITORY in .env." + ) + return repository + + +def optional_client() -> GitHubClient | None: + return GitHubClient(token) if (token := get_token()) else None + + +def cmd_init(args: argparse.Namespace) -> int: + install_repository( + args.target, + source=args.source, + profile=args.profile, + force=args.force, + dry_run=args.dry_run, + ) + return 0 + + +def cmd_doctor(args: argparse.Namespace) -> int: + config_path = Path(args.config) + environment_path = load_env_file() + project_pat = get_project_pat() + github_token, token_source = get_token_source() + gh_status = get_gh_auth_status() + failures = 0 + + print("==> Environment") + print("python_module=project_setup") + print(f"operating_system={platform.system()} {platform.release()}") + print(f"python_executable={sys.executable}") + print(f"working_directory={Path.cwd()}") + print(f"env_file={environment_path or (Path.cwd() / '.env')} exists={'yes' if environment_path else 'no'}") + print(f"github_repository={os.getenv('GITHUB_REPOSITORY') or 'missing'}") + print(f"github_token={'configured' if github_token else 'missing'} source={token_source}") + print(f"project_setup_pat={'configured' if project_pat else 'missing'}") + print(f"gh_cli={'installed' if gh_status.installed else 'missing'}") + print(f"gh_auth={'valid' if gh_status.authenticated else 'invalid' if gh_status.installed else 'not-installed'}") + print(f"gh_auth_detail={gh_status.detail}") + + if os.name == "nt": + print("INFO: Windows detected. The Makefile selects `python` by default and does not require Unix `test` commands.") + if not environment_path: + print("WARNING: .env was not found.") + print(" Fix: copy .env.example to .env and fill only the values required for your workflow.") + if gh_status.installed and not gh_status.authenticated: + print("WARNING: GitHub CLI is installed but its authentication is invalid.") + print(" Fix: run `gh auth login`, or continue with a valid token configured in .env.") + if not github_token: + print("WARNING: no GitHub authentication is available for live repository operations.") + print(" Fix: set PROJECT_SETUP_PAT in .env, set GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`.") + if not project_pat: + print("INFO: PROJECT_SETUP_PAT is required only for GitHub Projects v2 creation or synchronization.") + print(" Setup: Settings > Developer settings > Personal access tokens > Tokens (classic).") + print(" Required scopes: repo and project. Save the token as PROJECT_SETUP_PAT in .env.") + + print("==> Configuration") + print(f"config={config_path.resolve()}") + print(f"config_exists={'yes' if config_path.is_file() else 'no'}") + if not config_path.is_file(): + print(f"ERROR: configuration file is missing: {config_path}") + print(" Fix: restore project_setup.json or pass --config .") + return 1 + + try: + config = load_project_setup_config(str(config_path)) + except (OSError, ValueError) as exc: + print(f"ERROR: configuration could not be loaded: {exc}") + print(" Fix: correct project_setup.json and run `make doctor` again.") + return 1 + + for key in ("labelsFile", "milestonesFile", "projectDefinitionFile", "backlogManifestFile"): + path = Path(config[key]) + exists = path.is_file() + print(f"{key}={path} exists={'yes' if exists else 'no'}") + if not exists: + failures += 1 + print(f" ERROR: referenced file is missing: {path}") + print(f" Fix: create the file or update `{key}` in {config_path}.") + + defaults = config.get("defaults", {}) + if defaults.get("runProjectCreation", False) and not project_pat: + failures += 1 + print("ERROR: runProjectCreation is enabled but PROJECT_SETUP_PAT is missing.") + print(" Fix: configure PROJECT_SETUP_PAT in .env or disable runProjectCreation until the token is ready.") + + if failures: + print(f"Doctor found {failures} blocking problem(s). No GitHub API changes were made.") + return 1 + print("Doctor completed. Local files are valid; no GitHub API changes were made.") + return 0 + + +def cmd_labels_sync(args: argparse.Namespace) -> int: + sync_labels(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + return 0 + + +def cmd_milestones_sync(args: argparse.Namespace) -> int: + sync_milestones(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + return 0 + + +def cmd_issues_generate(args: argparse.Namespace) -> int: + generate_issues( + None if args.dry_run else require_client(), + repo_arg(args.repo), + args.file, + args.dry_run, + args.link_subissues, + ) + return 0 + + +def cmd_project_create(args: argparse.Namespace) -> int: + client = GitHubClient("") if args.dry_run else require_project_client() + create_project(client, repo_arg(args.repo), args.file, args.dry_run) + return 0 + + +def cmd_project_sync(args: argparse.Namespace) -> int: + repository = repo_arg(args.repo) + if args.dry_run and not get_project_pat(): + definition = load_project_definition(args.file) + target_owner = args.owner or repository.split("/", 1)[0] + print("[DRY-RUN] Offline Project v2 preview; PROJECT_SETUP_PAT is not configured.") + print(f"- owner: {target_owner}") + print(f"- project number: {args.project_number}") + print(f"- repository: {repository}") + print(f"- issue state: {args.issue_state}") + for field in definition.get("fields", []): + print(f"- configured field: {field['name']} ({field['type']})") + print("Remote Project fields, items, and issues were not queried.") + print("Fix: configure PROJECT_SETUP_PAT to run a remote dry-run comparison.") + return 0 + sync_project( + require_project_client(), + repository, + args.file, + args.project_number, + owner=args.owner, + issue_state=args.issue_state, + dry_run=args.dry_run, + ) + return 0 + + +def cmd_issue_milestones_sync(args: argparse.Namespace) -> int: + sync_issue_milestones(require_client(), repo_arg(args.repo), args.clear_not_planned, args.dry_run) + return 0 + + +def cmd_auto_label_apply(args: argparse.Namespace) -> int: + event_path = args.event_path or os.getenv("GITHUB_EVENT_PATH") + if not event_path: + raise SystemExit( + "Missing GitHub event payload.\n" + "Fix: pass --event-path locally. GitHub Actions provides GITHUB_EVENT_PATH automatically." + ) + return apply_auto_labels(repo_arg(args.repo), event_path, args.labels_file, optional_client(), args.dry_run) + + +def cmd_validate_pr(args: argparse.Namespace) -> int: + body = args.body + if args.body_file: + body = Path(args.body_file).read_text(encoding="utf-8") + if body is None: + body = os.getenv("PR_BODY", "") + findings = validate_pull_request(args.branch, body, args.base_branch) + for finding in findings: + print(f"{finding.section}: {finding.problem}") + print(f" Fix: {finding.fix}") + if args.comment: + repository = repo_arg(args.repo) + if not args.pr_number: + raise SystemExit("--comment requires --pr-number") + upsert_validation_comment(require_client(), repository, args.pr_number, findings) + return 1 if findings else 0 + + +def cmd_apply(args: argparse.Namespace) -> int: + config = load_project_setup_config(args.config) + defaults = config.get("defaults", {}) + values = { + "dry_run": args.dry_run, + "run_labels": args.run_labels if args.run_labels is not None else defaults.get("runLabels", True), + "run_milestones": args.run_milestones if args.run_milestones is not None else defaults.get("runMilestones", True), + "run_project_creation": args.run_project_creation if args.run_project_creation is not None else defaults.get("runProjectCreation", False), + "run_issue_generation": args.run_issue_generation if args.run_issue_generation is not None else defaults.get("runIssueGeneration", False), + "link_subissues": args.link_subissues if args.link_subissues is not None else defaults.get("linkSubissues", False), + } + if values["dry_run"]: + client = GitHubClient("") + elif values["run_project_creation"]: + client = require_project_client() + else: + client = require_client() + run_project_setup(client, repo_arg(args.repo), config, **values) + return 0 + + +def add_bool_pair(parser: argparse.ArgumentParser, name: str, destination: str, help_text: str) -> None: + group = parser.add_mutually_exclusive_group() + group.add_argument(f"--{name}", dest=destination, action="store_true", default=None, help=help_text) + group.add_argument(f"--skip-{name.removeprefix('run-')}", dest=destination, action="store_false") + + +def add_execution_mode(parser: argparse.ArgumentParser, *, default_dry_run: bool = True) -> None: + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--dry-run", dest="dry_run", action="store_true", help="Preview without writing changes") + mode.add_argument( + "--live", + "--no-dry-run", + dest="dry_run", + action="store_false", + help="Apply changes; --no-dry-run is kept as a compatibility alias", + ) + parser.set_defaults(dry_run=default_dry_run) + + +def add_apply_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) + parser.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) + add_execution_mode(parser) + add_bool_pair(parser, "run-labels", "run_labels", "Synchronize labels") + add_bool_pair(parser, "run-milestones", "run_milestones", "Synchronize milestones") + add_bool_pair(parser, "run-project-creation", "run_project_creation", "Create Project v2") + add_bool_pair(parser, "run-issue-generation", "run_issue_generation", "Generate issues and tasks") + links = parser.add_mutually_exclusive_group() + links.add_argument("--link-subissues", dest="link_subissues", action="store_true", default=None) + links.add_argument("--no-link-subissues", dest="link_subissues", action="store_false") + parser.set_defaults(func=cmd_apply) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="project-setup", description="Set up and automate GitHub repositories") + subcommands = parser.add_subparsers(dest="command", required=True) + + init = subcommands.add_parser("init", help="Copy project setup tooling into a target repository") + init.add_argument("--target", required=True) + init.add_argument("--source") + init.add_argument("--profile", choices=sorted(PROFILE_FILES), default="core") + init.add_argument("--force", action="store_true") + add_execution_mode(init) + init.set_defaults(func=cmd_init) + + discover = subcommands.add_parser("discover", help="Inspect a repository and recommend setup options") + discover.add_argument("--repo") + discover.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) + discover.add_argument("--root", default=".") + discover.add_argument("--project-type", choices=SUPPORTED_PROJECT_TYPES) + discover.add_argument("--auto", action="store_true", help="Use configuration defaults without prompts") + discover.add_argument("--apply", action="store_true", help="Apply the selected setup after review") + discover.add_argument("--yes", action="store_true", help="Confirm --apply in non-interactive environments") + discover.set_defaults(func=run_discovery) + + doctor = subcommands.add_parser("doctor", help="Check local project setup prerequisites") + doctor.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) + doctor.set_defaults(func=cmd_doctor) + + labels = subcommands.add_parser("labels") + labels_sub = labels.add_subparsers(dest="labels_command", required=True) + labels_sync = labels_sub.add_parser("sync") + labels_sync.add_argument("--repo") + labels_sync.add_argument("--file", default="config/project/labels.json") + add_execution_mode(labels_sync) + labels_sync.set_defaults(func=cmd_labels_sync) + + milestones = subcommands.add_parser("milestones") + milestones_sub = milestones.add_subparsers(dest="milestones_command", required=True) + milestones_sync = milestones_sub.add_parser("sync") + milestones_sync.add_argument("--repo") + milestones_sync.add_argument("--file", default="config/project/milestones.json") + add_execution_mode(milestones_sync) + milestones_sync.set_defaults(func=cmd_milestones_sync) + + issues = subcommands.add_parser("issues") + issues_sub = issues.add_subparsers(dest="issues_command", required=True) + issues_generate = issues_sub.add_parser("generate") + issues_generate.add_argument("--repo") + issues_generate.add_argument("--file", default="config/stories/backlog-manifest.json") + issues_generate.add_argument("--link-subissues", action="store_true") + add_execution_mode(issues_generate) + issues_generate.set_defaults(func=cmd_issues_generate) + + project = subcommands.add_parser("project") + project_sub = project.add_subparsers(dest="project_command", required=True) + project_create = project_sub.add_parser("create") + project_create.add_argument("--repo") + project_create.add_argument("--file", default="config/project/project-definition.json") + add_execution_mode(project_create) + project_create.set_defaults(func=cmd_project_create) + project_sync = project_sub.add_parser("sync") + project_sync.add_argument("--repo") + project_sync.add_argument("--owner") + project_sync.add_argument("--file", default="config/project/project-definition.json") + project_sync.add_argument("--project-number", type=int, required=True) + project_sync.add_argument("--issue-state", choices=("open", "closed", "all"), default="open") + add_execution_mode(project_sync) + project_sync.set_defaults(func=cmd_project_sync) + + issue_milestones = subcommands.add_parser("issue-milestones") + issue_milestones_sub = issue_milestones.add_subparsers(dest="issue_milestones_command", required=True) + issue_milestones_sync = issue_milestones_sub.add_parser("sync") + issue_milestones_sync.add_argument("--repo") + issue_milestones_sync.add_argument("--clear-not-planned", action="store_true") + add_execution_mode(issue_milestones_sync) + issue_milestones_sync.set_defaults(func=cmd_issue_milestones_sync) + + auto_label = subcommands.add_parser("auto-label") + auto_label_sub = auto_label.add_subparsers(dest="auto_label_command", required=True) + auto_label_apply = auto_label_sub.add_parser("apply") + auto_label_apply.add_argument("--repo") + auto_label_apply.add_argument("--event-path") + auto_label_apply.add_argument("--labels-file", default="config/project/labels.json") + add_execution_mode(auto_label_apply) + auto_label_apply.set_defaults(func=cmd_auto_label_apply) + + validate_pr = subcommands.add_parser("validate-pr") + validate_pr.add_argument("--branch") + validate_pr.add_argument("--base-branch") + validate_pr.add_argument("--body") + validate_pr.add_argument("--body-file") + validate_pr.add_argument("--repo") + validate_pr.add_argument("--pr-number", type=int) + validate_pr.add_argument("--comment", action="store_true") + validate_pr.set_defaults(func=cmd_validate_pr) + + apply = subcommands.add_parser("apply", help="Apply configured repository setup") + add_apply_arguments(apply) + return parser + + +def main(argv: list[str] | None = None) -> int: + try: + load_env_file() + args = build_parser().parse_args(argv) + return int(args.func(args)) + except ValueError as exc: + raise SystemExit(f"Configuration error: {exc}\nFix the referenced file and run the command again.") from exc + except OSError as exc: + raise SystemExit(f"File error: {exc}") from exc + except GitHubRequestError as exc: + raise SystemExit(f"GitHub API error: {exc}") from exc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/project_setup/discovery.py b/project_setup/discovery.py new file mode 100644 index 0000000..c54b558 --- /dev/null +++ b/project_setup/discovery.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import os +import shlex +import subprocess +import sys + +from .github import ( + GitHubClient, + get_gh_auth_status, + get_token_source, + require_client, + require_project_client, +) +from .runner import load_project_setup_config, run_project_setup + + +SUPPORTED_PROJECT_TYPES = ("python", "node", "go", "java", "rust", "dotnet", "generic") +PROJECT_MARKERS = { + "python": ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile"), + "node": ("package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock"), + "go": ("go.mod",), + "java": ("pom.xml", "build.gradle", "build.gradle.kts"), + "rust": ("Cargo.toml",), + "dotnet": ("*.csproj", "*.sln"), +} + + +@dataclass(frozen=True) +class AuthStatus: + configured: bool + source: str + detail: str + + +@dataclass(frozen=True) +class ProjectMatch: + project_type: str + markers: tuple[str, ...] + + +def detect_auth_status() -> AuthStatus: + token, source = get_token_source() + if token: + return AuthStatus(True, source, f"A GitHub token is available from {source}") + gh = get_gh_auth_status() + if gh.installed: + return AuthStatus(False, "gh", gh.detail) + return AuthStatus(False, "missing", "No environment token is configured and GitHub CLI was not found") + + +def _collect_markers(root: Path, patterns: tuple[str, ...]) -> tuple[str, ...]: + markers: list[str] = [] + for pattern in patterns: + if "*" in pattern: + markers.extend(str(path.relative_to(root)) for path in sorted(root.glob(pattern)) if path.is_file()) + elif (root / pattern).is_file(): + markers.append(pattern) + return tuple(markers) + + +def detect_project_matches(root: str | os.PathLike[str]) -> list[ProjectMatch]: + root_path = Path(root) + if not root_path.exists(): + raise FileNotFoundError(f"Project root does not exist: {root_path}") + matches = [ + ProjectMatch(project_type, markers) + for project_type in SUPPORTED_PROJECT_TYPES[:-1] + if (markers := _collect_markers(root_path, PROJECT_MARKERS[project_type])) + ] + return matches or [ProjectMatch("generic", tuple())] + + +def resolve_project_match(root: str | os.PathLike[str], override: str | None = None) -> ProjectMatch: + if override: + if override not in SUPPORTED_PROJECT_TYPES: + raise ValueError(f"Unsupported project type: {override}") + match = next((item for item in detect_project_matches(root) if item.project_type == override), None) + return match or ProjectMatch(override, tuple()) + matches = detect_project_matches(root) + if len(matches) == 1 or not sys.stdin.isatty(): + return matches[0] + print("Multiple project types detected:") + for index, match in enumerate(matches, start=1): + print(f" {index}. {match.project_type} ({', '.join(match.markers)})") + print(" 0. generic") + while True: + choice = input("Choose project type [1]: ").strip() + if choice in {"", "1"}: + return matches[0] + if choice == "0": + return ProjectMatch("generic", tuple()) + if choice.isdigit() and 1 <= int(choice) <= len(matches): + return matches[int(choice) - 1] + print("Invalid choice, try again.") + + +def _prompt_bool(question: str, default: bool) -> bool: + suffix = "[Y/n]" if default else "[y/N]" + while True: + answer = input(f"{question} {suffix} ").strip().lower() + if not answer: + return default + if answer in {"y", "yes", "true", "1"}: + return True + if answer in {"n", "no", "false", "0"}: + return False + print("Please answer yes or no.") + + +def build_apply_command( + repo: str, + config_path: str, + dry_run: bool, + run_labels: bool, + run_milestones: bool, + run_project_creation: bool, + run_issue_generation: bool, + link_subissues: bool, +) -> str: + parts = [ + "python", + "-m", + "project_setup", + "apply", + "--repo", + repo, + "--config", + config_path, + "--dry-run" if dry_run else "--live", + "--run-labels" if run_labels else "--skip-labels", + "--run-milestones" if run_milestones else "--skip-milestones", + "--run-project-creation" if run_project_creation else "--skip-project-creation", + "--run-issue-generation" if run_issue_generation else "--skip-issue-generation", + "--link-subissues" if link_subissues else "--no-link-subissues", + ] + return subprocess.list2cmdline(parts) if os.name == "nt" else shlex.join(parts) + + +def run_discovery(args) -> int: + config = load_project_setup_config(args.config) + repo = args.repo or os.getenv("GITHUB_REPOSITORY") + if not repo: + print("Missing --repo and GITHUB_REPOSITORY") + print("Fix: pass --repo owner/repository or set GITHUB_REPOSITORY in .env.") + return 1 + + auth = detect_auth_status() + print("==> GitHub auth") + print(f"Configured: {'yes' if auth.configured else 'no'} ({auth.source})") + print(f"Detail: {auth.detail}") + if not auth.configured: + print(f"Expected workflow secret: {config.get('secretName', 'PROJECT_SETUP_PAT')}") + print("Fix: set a supported token in .env or repair the GitHub CLI session with `gh auth login`.") + return 1 + + print("==> Project detection") + try: + project = resolve_project_match(args.root, args.project_type) + except (FileNotFoundError, ValueError) as exc: + print(str(exc)) + return 1 + print(f"Detected project type: {project.project_type}") + if project.markers: + print(f"Markers: {', '.join(project.markers)}") + + defaults = config.get("defaults", {}) + values = { + "dry_run": True, + "run_labels": defaults.get("runLabels", True), + "run_milestones": defaults.get("runMilestones", True), + "run_project_creation": defaults.get("runProjectCreation", False), + "run_issue_generation": defaults.get("runIssueGeneration", False), + "link_subissues": defaults.get("linkSubissues", False), + } + interactive = sys.stdin.isatty() and not args.auto + if interactive: + values["dry_run"] = _prompt_bool("Run in dry-run mode?", True) + values["run_labels"] = _prompt_bool("Sync labels?", values["run_labels"]) + values["run_milestones"] = _prompt_bool("Sync milestones?", values["run_milestones"]) + values["run_project_creation"] = _prompt_bool("Create Project v2?", values["run_project_creation"]) + values["run_issue_generation"] = _prompt_bool("Generate issues and tasks?", values["run_issue_generation"]) + values["link_subissues"] = _prompt_bool("Link generated tasks as sub-issues?", values["link_subissues"]) + else: + print("Using configuration modules with dry-run enforced for non-interactive discovery.") + + print("==> Recommended command") + print(build_apply_command(repo, args.config, **values)) + if not args.apply: + return 0 + if not args.yes: + if not sys.stdin.isatty() or not _prompt_bool("Run the selected setup now?", False): + print("Confirmation required; no changes were applied.") + return 1 + if values["dry_run"]: + client = GitHubClient("") + elif values["run_project_creation"]: + client = require_project_client() + else: + client = require_client() + run_project_setup(client, repo, config, **values) + return 0 diff --git a/project_setup/github.py b/project_setup/github.py new file mode 100644 index 0000000..6aef7f5 --- /dev/null +++ b/project_setup/github.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import time +from typing import Any +import urllib.error +import urllib.parse +import urllib.request + + +API_BASE = "https://api.github.com" +GRAPHQL_URL = f"{API_BASE}/graphql" +API_VERSION = "2022-11-28" +RETRYABLE_HTTP_STATUS = {429, 502, 503, 504} +IDEMPOTENT_METHODS = {"GET", "HEAD"} +HTTP_TIMEOUT_SECONDS = 30 +ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class GitHubRequestError(RuntimeError): + def __init__(self, method: str, url: str, status: int, details: str): + super().__init__(f"GitHub API request failed ({method} {url}) status={status}: {details}") + self.method = method + self.url = url + self.status = status + self.details = details + + +@dataclass(frozen=True) +class GhAuthStatus: + installed: bool + authenticated: bool + detail: str + + +def load_env_file(path: str | os.PathLike[str] | None = None) -> Path | None: + """Load a simple dotenv file without overriding existing environment variables.""" + configured_path = path or os.getenv("PROJECT_SETUP_ENV_FILE", ".env") + candidate = Path(configured_path).expanduser() + if not candidate.is_absolute(): + candidate = Path.cwd() / candidate + if not candidate.is_file(): + return None + + for line_number, raw_line in enumerate(candidate.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + if "=" not in line: + raise ValueError( + f"Invalid environment entry in {candidate} at line {line_number}: expected NAME=value" + ) + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if not ENV_KEY.fullmatch(key): + raise ValueError( + f"Invalid environment variable name '{key}' in {candidate} at line {line_number}" + ) + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + os.environ.setdefault(key, value) + return candidate.resolve() + + +def _compact_detail(text: str, fallback: str) -> str: + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[0] if lines else fallback + + +def get_gh_auth_status() -> GhAuthStatus: + gh = shutil.which("gh") + if not gh: + return GhAuthStatus(False, False, "GitHub CLI is not installed or is not available on PATH") + try: + result = subprocess.run( + [gh, "auth", "status"], + capture_output=True, + text=True, + timeout=10, + ) + except subprocess.TimeoutExpired: + return GhAuthStatus(True, False, "`gh auth status` timed out after 10 seconds") + except OSError as exc: + return GhAuthStatus(True, False, f"Could not execute `gh auth status`: {exc}") + detail = _compact_detail( + result.stdout if result.returncode == 0 else result.stderr, + "GitHub CLI authentication is valid" if result.returncode == 0 else "GitHub CLI authentication is invalid", + ) + return GhAuthStatus(True, result.returncode == 0, detail) + + +def get_token_source() -> tuple[str | None, str]: + load_env_file() + for variable in ("GITHUB_TOKEN", "GH_TOKEN", "PROJECT_SETUP_PAT"): + token = os.environ.get(variable) + if token and token.strip(): + return token.strip(), variable + gh = shutil.which("gh") + if not gh: + return None, "missing" + try: + result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True, timeout=10) + except (OSError, subprocess.TimeoutExpired): + return None, "gh-invalid" + token = result.stdout.strip() if result.returncode == 0 else "" + return (token, "gh") if token else (None, "gh-invalid") + + +def get_token() -> str | None: + return get_token_source()[0] + + +def get_project_pat() -> str | None: + load_env_file() + token = os.environ.get("PROJECT_SETUP_PAT") + return token.strip() if token and token.strip() else None + + +def split_repo(repo: str) -> tuple[str, str]: + if "/" not in repo: + raise ValueError("repository must use owner/name format") + owner, name = repo.split("/", 1) + if not owner or not name: + raise ValueError("repository must use owner/name format") + return owner, name + + +def _validate_github_api_url(url: str) -> None: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != "api.github.com": + raise ValueError(f"Unsupported GitHub API URL: {url}") + + +class GitHubClient: + def __init__(self, token: str): + self.token = token.strip() + + def _headers(self, accept: str = "application/vnd.github+json") -> dict[str, str]: + headers = { + "Accept": accept, + "X-GitHub-Api-Version": API_VERSION, + "Content-Type": "application/json", + "User-Agent": "github-project-setup", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def request_json(self, method: str, url: str, payload: Any = None, accept: str = "application/vnd.github+json") -> Any: + normalized_method = method.upper() + _validate_github_api_url(url) + data = json.dumps(payload).encode("utf-8") if payload is not None else None + retry_allowed = normalized_method in IDEMPOTENT_METHODS + for attempt in range(1, 6): + request = urllib.request.Request( + url, + data=data, + headers=self._headers(accept), + method=normalized_method, + ) + try: + with urllib.request.urlopen( # noqa: S310 - URL is restricted to https://api.github.com above. + request, + timeout=HTTP_TIMEOUT_SECONDS, + ) as response: + body = response.read().decode("utf-8") + return json.loads(body) if body else {} + except urllib.error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + if retry_allowed and exc.code in RETRYABLE_HTTP_STATUS and attempt < 5: + retry_after = exc.headers.get("Retry-After") + wait_seconds = int(retry_after) if retry_after and retry_after.isdigit() else attempt * 2 + print(f"warning: GitHub returned HTTP {exc.code}; retrying read in {wait_seconds}s") + time.sleep(wait_seconds) + continue + raise GitHubRequestError(normalized_method, url, exc.code, details) from exc + except urllib.error.URLError as exc: + if retry_allowed and attempt < 5: + wait_seconds = attempt * 2 + print(f"warning: GitHub read failed; retrying in {wait_seconds}s: {exc.reason}") + time.sleep(wait_seconds) + continue + raise GitHubRequestError(normalized_method, url, 0, str(exc.reason)) from exc + raise RuntimeError("GitHub request exhausted retries") + + def paginated(self, url: str) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + page = 1 + while True: + separator = "&" if "?" in url else "?" + batch = self.request_json("GET", f"{url}{separator}per_page=100&page={page}") + if not isinstance(batch, list): + raise RuntimeError(f"Expected a list from paginated GitHub endpoint: {url}") + items.extend(batch) + if len(batch) < 100: + return items + page += 1 + + def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + response = self.request_json("POST", GRAPHQL_URL, {"query": query, "variables": variables or {}}) + if response.get("errors"): + raise RuntimeError(f"GraphQL error: {json.dumps(response['errors'], ensure_ascii=False)}") + return response["data"] + + def get_issue(self, repo: str, number: int) -> dict[str, Any]: + return self.request_json("GET", f"{API_BASE}/repos/{repo}/issues/{number}") + + def create_issue(self, repo: str, title: str, body: str, labels: list[str]) -> dict[str, Any]: + return self.request_json( + "POST", + f"{API_BASE}/repos/{repo}/issues", + {"title": title, "body": body, "labels": labels}, + ) + + def update_issue(self, repo: str, number: int, payload: dict[str, Any]) -> dict[str, Any]: + return self.request_json("PATCH", f"{API_BASE}/repos/{repo}/issues/{number}", payload) + + def list_issue_comments(self, repo: str, number: int) -> list[dict[str, Any]]: + return self.paginated(f"{API_BASE}/repos/{repo}/issues/{number}/comments") + + def create_issue_comment(self, repo: str, number: int, body: str) -> dict[str, Any]: + return self.request_json("POST", f"{API_BASE}/repos/{repo}/issues/{number}/comments", {"body": body}) + + def update_issue_comment(self, repo: str, comment_id: int, body: str) -> dict[str, Any]: + return self.request_json("PATCH", f"{API_BASE}/repos/{repo}/issues/comments/{comment_id}", {"body": body}) + + def delete_issue_comment(self, repo: str, comment_id: int) -> dict[str, Any]: + return self.request_json("DELETE", f"{API_BASE}/repos/{repo}/issues/comments/{comment_id}") + + +def require_client() -> GitHubClient: + token, source = get_token_source() + if not token: + gh = get_gh_auth_status() + gh_guidance = ( + f"GitHub CLI status: {gh.detail}.\n" if gh.installed else "GitHub CLI is not installed.\n" + ) + raise SystemExit( + "No GitHub token is available.\n" + f"{gh_guidance}" + "Fix: copy .env.example to .env and set PROJECT_SETUP_PAT, set GITHUB_TOKEN/GH_TOKEN, " + "or repair the CLI session with `gh auth login`." + ) + if source == "gh-invalid": + raise SystemExit("GitHub CLI authentication is invalid. Fix: run `gh auth login` and retry.") + return GitHubClient(token) + + +def require_project_client() -> GitHubClient: + token = get_project_pat() + if not token: + raise SystemExit( + "GitHub Projects v2 requires PROJECT_SETUP_PAT.\n" + "Fix:\n" + " 1. GitHub profile picture > Settings > Developer settings.\n" + " 2. Personal access tokens > Tokens (classic) > Generate new token (classic).\n" + " 3. Select the `repo` and `project` scopes.\n" + " 4. Copy .env.example to .env and set PROJECT_SETUP_PAT=.\n" + " 5. Run `make doctor` before retrying.\n" + "The repository-scoped github.token cannot create or synchronize Projects v2." + ) + return GitHubClient(token) diff --git a/project_setup/installer.py b/project_setup/installer.py new file mode 100644 index 0000000..2bcd74f --- /dev/null +++ b/project_setup/installer.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import shutil + + +CORE_TEMPLATE_FILES = ( + ".env.example", + "Makefile", + ".github/ISSUE_TEMPLATE/bug-report.yml", + ".github/ISSUE_TEMPLATE/task-sub-issue.yml", + ".github/ISSUE_TEMPLATE/user-story.yml", + ".github/ISSUE_TEMPLATE/config.yml", + ".github/pull_request_template.md", + ".github/workflows/auto-label.yml", + ".github/workflows/main-source-branch.yml", + ".github/workflows/pr-metadata.yml", + ".github/workflows/project-setup.yml", + "config/project/labels.json", + "config/project/milestones.json", + "config/project/project-definition.json", + "config/stories/backlog-manifest.json", + "project_setup.json", + "scripts/validation/repo_quality.py", + "scripts/validation/validate_pr_body.py", +) + +PROFILE_FILES = { + "core": (), +} + + +@dataclass(frozen=True) +class InstallResult: + copied: tuple[str, ...] + skipped: tuple[str, ...] + + +def source_root_from_package() -> Path: + return Path(__file__).resolve().parents[1] + + +def package_files(source_root: Path) -> tuple[tuple[str, str], ...]: + package_root = source_root / "project_setup" + return tuple( + (relative, relative) + for relative in ( + str(path.relative_to(source_root)).replace("\\", "/") + for path in sorted(package_root.glob("*.py")) + ) + ) + + +def template_files(source_root: Path, profile: str) -> tuple[tuple[str, str], ...]: + if profile not in PROFILE_FILES: + raise ValueError(f"Unknown profile '{profile}'. Available profiles: {', '.join(PROFILE_FILES)}") + core = tuple((path, path) for path in CORE_TEMPLATE_FILES) + return (*core, *package_files(source_root), *PROFILE_FILES[profile]) + + +def install_repository( + target: str | Path, + *, + source: str | Path | None = None, + profile: str = "core", + force: bool = False, + dry_run: bool = False, +) -> InstallResult: + source_root = Path(source).resolve() if source else source_root_from_package() + target_root = Path(target).resolve() + if target_root.exists() and not target_root.is_dir(): + raise ValueError(f"Target is not a directory: {target_root}") + if not dry_run: + target_root.mkdir(parents=True, exist_ok=True) + + copied: list[str] = [] + skipped: list[str] = [] + for source_relative, destination_relative in template_files(source_root, profile): + source_path = source_root / source_relative + destination = target_root / destination_relative + if not source_path.is_file(): + raise FileNotFoundError( + f"Project setup template is missing: {source_path}. " + "Restore the source file before retrying the installation." + ) + if destination.exists() and not force: + skipped.append(destination_relative) + print(f"skipped existing: {destination_relative}") + if destination_relative in {"Makefile", ".env.example"}: + print( + f" Review the installed template manually before merging it into the existing {destination_relative}. " + "Use --force only after reviewing the differences." + ) + continue + if dry_run: + copied.append(destination_relative) + print(f"[DRY-RUN] Would copy: {destination_relative}") + continue + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, destination) + copied.append(destination_relative) + print(f"copied: {destination_relative}") + + print(f"Project setup installation finished: copied={len(copied)}, skipped={len(skipped)}") + if ".env.example" in copied: + print("Next: copy .env.example to .env, configure only required values, and run `make doctor`.") + return InstallResult(tuple(copied), tuple(skipped)) diff --git a/project_setup/issue_milestones.py b/project_setup/issue_milestones.py new file mode 100644 index 0000000..f80829a --- /dev/null +++ b/project_setup/issue_milestones.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import re + +from .github import API_BASE, GitHubClient, split_repo + + +def milestone_from_body(body: str) -> str | None: + match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", body or "") + return match.group(1) if match else None + + +def parent_issue_number_from_body(body: str) -> int | None: + match = re.search(r"Parent story:.*\(#(\d+)\)", body or "") + return int(match.group(1)) if match else None + + +def sync_issue_milestones( + client: GitHubClient, + repo: str, + clear_not_planned: bool = False, + dry_run: bool = False, +) -> None: + owner, name = split_repo(repo) + base = f"{API_BASE}/repos/{owner}/{name}" + milestones = client.paginated(f"{base}/milestones?state=all") + milestones_by_title = {item["title"]: item for item in milestones} + issues = [ + item + for item in client.paginated(f"{base}/issues?state=all&sort=created&direction=asc") + if "pull_request" not in item + ] + explicit = { + issue["number"]: title + for issue in issues + if (title := milestone_from_body(issue.get("body") or "")) + } + missing = sorted({title for title in explicit.values() if title not in milestones_by_title}) + if missing: + raise ValueError( + f"Milestones referenced by issue bodies do not exist: {', '.join(missing)}. " + "Create the milestones or correct the issue bodies before retrying." + ) + + updated = cleared = unchanged = 0 + unmapped: list[tuple[int, str]] = [] + + for issue in issues: + number = int(issue["number"]) + current = issue.get("milestone") + current_title = current["title"] if current else None + if clear_not_planned and issue.get("state") == "closed" and issue.get("state_reason") == "not_planned": + if current_title: + if dry_run: + print(f"[DRY-RUN] Would clear milestone from issue #{number}") + else: + client.update_issue(repo, number, {"milestone": None}) + cleared += 1 + else: + unchanged += 1 + continue + + target = explicit.get(number) + if not target and (parent := parent_issue_number_from_body(issue.get("body") or "")): + target = explicit.get(parent) + if not target: + unmapped.append((number, issue["title"])) + continue + milestone = milestones_by_title[target] + if current_title == target: + unchanged += 1 + continue + if dry_run: + print(f"[DRY-RUN] Would set issue #{number}: {current_title or 'none'} -> {target}") + else: + client.update_issue(repo, number, {"milestone": milestone["number"]}) + updated += 1 + + print(f"issues_checked={len(issues)}") + print(f"updated={updated}") + print(f"cleared_not_planned={cleared}") + print(f"already_correct={unchanged}") + print(f"unmapped={len(unmapped)}") + for number, title in unmapped: + print(f"unmapped #{number}: {title}") diff --git a/project_setup/issues.py b/project_setup/issues.py new file mode 100644 index 0000000..e9df7ab --- /dev/null +++ b/project_setup/issues.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json + +from .github import GitHubClient + + +def load_backlog(path: str) -> dict: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + if "phases" not in data or not isinstance(data["phases"], list): + raise ValueError("backlog manifest must contain a phases list") + return data + + +def task_title(task: str | dict) -> str: + if isinstance(task, str): + return task + title = task.get("title") + if not title: + raise ValueError("task objects must define title") + return str(title) + + +def issue_node_id(client: GitHubClient, repo: str, number: int) -> str: + owner, name = repo.split("/", 1) + query = """ + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$number) { id } } + } + """ + data = client.graphql(query, {"owner": owner, "repo": name, "number": number}) + issue = data["repository"]["issue"] + if not issue: + raise RuntimeError(f"Issue #{number} was not found in {repo}") + return issue["id"] + + +def add_sub_issue(client: GitHubClient, repo: str, parent_number: int, child_number: int) -> None: + mutation = """ + mutation($parent:ID!, $child:ID!) { + addSubIssue(input:{issueId:$parent, subIssueId:$child}) { clientMutationId } + } + """ + client.graphql( + mutation, + { + "parent": issue_node_id(client, repo, parent_number), + "child": issue_node_id(client, repo, child_number), + }, + ) + + +def generate_issues( + client: GitHubClient | None, + repo: str, + manifest: str, + dry_run: bool = False, + link_subissues: bool = False, +) -> None: + data = load_backlog(manifest) + default_labels = data.get("defaultIssueLabels", []) + for phase in data["phases"]: + milestone = phase.get("milestone", "") + for story in phase.get("stories", []): + story_labels = list(dict.fromkeys([*story.get("labels", []), *default_labels])) + story_body = "\n\n".join( + [ + story.get("body", "## Context\n- Describe the expected outcome."), + f"## Acceptance criteria\n{story.get('acceptanceCriteria', '- Define acceptance criteria.')}", + f"## Test strategy\n{story.get('testStrategy', '- Define the test strategy.')}", + f"## Definition of Done\n{story.get('dod', '- Define the completion criteria.')}", + f"- Milestone: {milestone}\n- Item type: user-story", + ] + ) + if dry_run: + print(f"[DRY-RUN] Story: {story['title']} labels={story_labels}") + story_number = None + else: + if not client: + raise RuntimeError("A GitHub client is required outside dry-run mode") + created_story = client.create_issue(repo, story["title"], story_body, story_labels) + story_number = int(created_story["number"]) + print(f"Created story #{story_number}: {story['title']}") + + for task in story.get("tasks", []): + title = task_title(task) + task_labels = ["type:task", "status:backlog"] + parent_reference = f"{story.get('storyId', 'US-XX')}" + if story_number: + parent_reference += f" (#{story_number})" + task_body = "\n\n".join( + [ + f"Parent story: {parent_reference}", + "## Technical scope\n- Define the implementation scope.", + "## Completion criteria\n- Define objective completion criteria.", + "## Test strategy\n- Define automated, smoke, or manual validation.", + "## Expected evidence\n- Attach relevant evidence.", + "## Definition of Done\n- Scope implemented and validated.", + "- Item type: task/sub-issue", + ] + ) + if dry_run: + print(f"[DRY-RUN] Task: {title} labels={task_labels}") + continue + assert client is not None and story_number is not None + created_task = client.create_issue(repo, title, task_body, task_labels) + task_number = int(created_task["number"]) + print(f" Created task #{task_number}: {title}") + if link_subissues: + add_sub_issue(client, repo, story_number, task_number) + print(f" Linked #{task_number} as sub-issue of #{story_number}") diff --git a/governance_bootstrap/labels.py b/project_setup/labels.py similarity index 60% rename from governance_bootstrap/labels.py rename to project_setup/labels.py index 41a3aa5..2133ec9 100644 --- a/governance_bootstrap/labels.py +++ b/project_setup/labels.py @@ -3,25 +3,24 @@ import json import urllib.parse -from .github import API_BASE, GitHubRequestError, GitHubClient, split_repo +from .github import API_BASE, GitHubClient, GitHubRequestError, split_repo def load_labels(path: str) -> list[dict]: - with open(path, "r", encoding="utf-8") as f: - labels = json.load(f) + with open(path, "r", encoding="utf-8") as file: + labels = json.load(file) if not isinstance(labels, list): raise ValueError("labels manifest must be a JSON list") for label in labels: - for key in ("name", "color"): - if key not in label: - raise ValueError(f"label is missing required key: {key}") + if not label.get("name") or not label.get("color"): + raise ValueError("each label must define name and color") return labels def sync_labels(client: GitHubClient, repo: str, labels_file: str, dry_run: bool = False) -> None: owner, name = split_repo(repo) labels = load_labels(labels_file) - base = f"{API_BASE}/repos/{owner}/{name}/labels" + endpoint = f"{API_BASE}/repos/{owner}/{name}/labels" if dry_run: print(f"[DRY-RUN] Would sync {len(labels)} labels to {repo}") @@ -31,11 +30,11 @@ def sync_labels(client: GitHubClient, repo: str, labels_file: str, dry_run: bool for label in labels: try: - client.request_json("POST", base, label) + client.request_json("POST", endpoint, label) print(f"created: {label['name']}") except GitHubRequestError as exc: if exc.status != 422: raise - patch_url = f"{base}/{urllib.parse.quote(label['name'])}" - client.request_json("PATCH", patch_url, label) + encoded_name = urllib.parse.quote(label["name"], safe="") + client.request_json("PATCH", f"{endpoint}/{encoded_name}", label) print(f"updated: {label['name']}") diff --git a/governance_bootstrap/milestones.py b/project_setup/milestones.py similarity index 61% rename from governance_bootstrap/milestones.py rename to project_setup/milestones.py index aaf4d0f..b57c7eb 100644 --- a/governance_bootstrap/milestones.py +++ b/project_setup/milestones.py @@ -5,31 +5,35 @@ from .github import API_BASE, GitHubClient, split_repo +MILESTONE_LOOKUP_LIMIT = 100 + + def load_milestones(path: str) -> list[dict]: - with open(path, "r", encoding="utf-8") as f: - milestones = json.load(f) + with open(path, "r", encoding="utf-8") as file: + milestones = json.load(file) if not isinstance(milestones, list): raise ValueError("milestones manifest must be a JSON list") for milestone in milestones: - if "title" not in milestone: - raise ValueError("milestone is missing required key: title") + if not isinstance(milestone, dict): + raise ValueError("each milestone must be a JSON object") + if not milestone.get("title"): + raise ValueError("each milestone must define a title") return milestones def sync_milestones(client: GitHubClient, repo: str, milestones_file: str, dry_run: bool = False) -> None: owner, name = split_repo(repo) milestones = load_milestones(milestones_file) + endpoint = f"{API_BASE}/repos/{owner}/{name}/milestones" if dry_run: print(f"[DRY-RUN] Would sync {len(milestones)} milestones to {repo}") for milestone in milestones: - print(f"- {milestone['title']} ({milestone.get('due_on', 'no-due-date')})") + print(f"- {milestone['title']} ({milestone.get('due_on', 'no due date')})") return - base = f"{API_BASE}/repos/{owner}/{name}/milestones" - existing = client.request_json("GET", f"{base}?state=all&per_page=100") + existing = client.request_json("GET", f"{endpoint}?state=all&per_page={MILESTONE_LOOKUP_LIMIT}") existing_by_title = {item["title"]: item for item in existing} - for milestone in milestones: payload = { "title": milestone["title"], @@ -37,12 +41,10 @@ def sync_milestones(client: GitHubClient, repo: str, milestones_file: str, dry_r } if milestone.get("due_on"): payload["due_on"] = milestone["due_on"] - current = existing_by_title.get(milestone["title"]) if current: - client.request_json("PATCH", f"{base}/{current['number']}", payload) + client.request_json("PATCH", f"{endpoint}/{current['number']}", payload) print(f"updated: {milestone['title']}") - continue - - client.request_json("POST", base, payload) - print(f"created: {milestone['title']}") + else: + client.request_json("POST", endpoint, payload) + print(f"created: {milestone['title']}") diff --git a/project_setup/pr_validation.py b/project_setup/pr_validation.py new file mode 100644 index 0000000..84df04d --- /dev/null +++ b/project_setup/pr_validation.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +import unicodedata + +from .github import GitHubClient + + +VALIDATION_MARKER = "" +BRANCH_PATTERN = re.compile(r"^(feat|fix|docs|refactor|test|hotfix|phase|task|chore|ci|release)/[a-z0-9._/-]+$") +REQUIRED_SECTIONS = ( + ("linked issue", "Linked Issue"), + ("milestone", "Milestone"), + ("summary", "Summary"), + ("how to test", "How to test"), + ("known risks", "Known risks"), + ("dod checklist", "DoD checklist"), +) +ANGLE_PLACEHOLDER = re.compile(r"^<[A-Za-z][^<>]*>$") +KEYWORD_PLACEHOLDER = re.compile(r"\b(todo|tbd|placeholder|describe|fill in|replace)\b", re.IGNORECASE) + + +@dataclass(frozen=True) +class ValidationFinding: + section: str + problem: str + fix: str + + +def normalize_header(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + normalized = "".join(character for character in normalized if not unicodedata.combining(character)) + return " ".join(normalized.strip().lower().split()) + + +def sections_from_body(body: str) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {} + current: str | None = None + for line in (body or "").splitlines(): + match = re.match(r"^##\s+(.+?)\s*$", line) + if match: + current = normalize_header(match.group(1)) + sections.setdefault(current, []) + elif current: + sections[current].append(line) + return sections + + +def meaningful(lines: list[str]) -> bool: + for line in lines: + stripped = line.strip().lstrip("-* ").strip() + if stripped and not ANGLE_PLACEHOLDER.fullmatch(stripped) and not KEYWORD_PLACEHOLDER.search(stripped): + return True + return False + + +def validate_branch(branch: str | None, base_branch: str | None = None) -> list[ValidationFinding]: + normalized = (branch or "").strip() + if normalized == "develop" and (base_branch or "").strip() == "main": + return [] + if BRANCH_PATTERN.fullmatch(normalized.casefold()): + return [] + return [ + ValidationFinding( + "Branch name", + f"Invalid branch name: `{normalized or '(missing)'}`.", + "Use a supported prefix such as `feat/`, `fix/`, `docs/`, `task/`, `chore/`, `hotfix/`, or `release/`.", + ) + ] + + +def validate_body(body: str | None) -> list[ValidationFinding]: + if not (body or "").strip(): + return [ValidationFinding("PR body", "The pull request body is empty.", "Fill the repository pull request template.")] + sections = sections_from_body(body or "") + findings: list[ValidationFinding] = [] + for key, label in REQUIRED_SECTIONS: + lines = sections.get(key) + if lines is None: + findings.append(ValidationFinding(label, "Required section is missing.", f"Add `## {label}`.")) + elif not meaningful(lines): + findings.append(ValidationFinding(label, "Section is empty or contains only placeholders.", "Replace placeholders with concrete information.")) + linked = "\n".join(sections.get("linked issue", [])) + if linked and not re.search(r"\b(closes|fixes|resolves)\s+#\d+\b", linked, re.IGNORECASE): + findings.append(ValidationFinding("Linked Issue", "No closing issue reference was found.", "Use `Closes #123`, `Fixes #123`, or `Resolves #123`.")) + return findings + + +def validate_pull_request(branch: str | None, body: str | None, base_branch: str | None = None) -> list[ValidationFinding]: + return [*validate_branch(branch, base_branch), *validate_body(body)] + + +def render_comment(findings: list[ValidationFinding]) -> str: + lines = [VALIDATION_MARKER, "## Project setup PR validation", ""] + if not findings: + lines.append("All configured pull request checks passed.") + return "\n".join(lines) + lines.append("The pull request still needs attention:") + for finding in findings: + lines.extend(["", f"### {finding.section}", f"- Problem: {finding.problem}", f"- Fix: {finding.fix}"]) + return "\n".join(lines) + + +def upsert_validation_comment(client: GitHubClient, repo: str, pr_number: int, findings: list[ValidationFinding]) -> str | None: + existing = next( + (comment for comment in client.list_issue_comments(repo, pr_number) if VALIDATION_MARKER in (comment.get("body") or "")), + None, + ) + if not findings: + if existing: + client.delete_issue_comment(repo, int(existing["id"])) + return "deleted" + return None + body = render_comment(findings) + if existing: + client.update_issue_comment(repo, int(existing["id"]), body) + return "updated" + client.create_issue_comment(repo, pr_number, body) + return "created" diff --git a/project_setup/project.py b/project_setup/project.py new file mode 100644 index 0000000..585a0bd --- /dev/null +++ b/project_setup/project.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import json +import re + +from .github import API_BASE, GitHubClient, split_repo + + +def load_project_definition(path: str) -> dict: + with open(path, "r", encoding="utf-8") as file: + definition = json.load(file) + if not definition.get("name"): + raise ValueError("project definition must contain name") + return definition + + +def owner_node(client: GitHubClient, owner: str) -> str: + user_query = "query($login:String!){user(login:$login){id}}" + user = client.graphql(user_query, {"login": owner}).get("user") + if user and user.get("id"): + return user["id"] + org_query = "query($login:String!){organization(login:$login){id}}" + organization = client.graphql(org_query, {"login": owner}).get("organization") + if organization and organization.get("id"): + return organization["id"] + raise RuntimeError(f"Owner not found: {owner}") + + +def create_project(client: GitHubClient, repo: str, definition_file: str, dry_run: bool = False) -> None: + definition = load_project_definition(definition_file) + if dry_run: + print(f"[DRY-RUN] Would create Project v2: {definition['name']}") + for field in definition.get("fields", []): + print(f"- field: {field['name']} ({field['type']})") + return + mutation = """ + mutation($owner:ID!, $title:String!) { + createProjectV2(input:{ownerId:$owner,title:$title}) { projectV2 { id number title url } } + } + """ + project = client.graphql( + mutation, + {"owner": owner_node(client, split_repo(repo)[0]), "title": definition["name"]}, + )["createProjectV2"]["projectV2"] + print(json.dumps(project, ensure_ascii=False)) + + +def find_project(client: GitHubClient, owner: str, project_number: int) -> dict: + query = """ + query($login:String!, $number:Int!) { + user(login:$login) { projectV2(number:$number) { id title url } } + organization(login:$login) { projectV2(number:$number) { id title url } } + } + """ + data = client.graphql(query, {"login": owner, "number": project_number}) + for owner_type in ("user", "organization"): + node = data.get(owner_type) + if node and node.get("projectV2"): + return node["projectV2"] + raise RuntimeError(f"Project v2 #{project_number} not found for '{owner}'") + + +def list_project_fields(client: GitHubClient, project_id: str) -> list[dict]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + fields(first:100, after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { id name dataType options { id name } } + } + } + } + } + } + """ + fields: list[dict] = [] + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["fields"] + fields.extend(field for field in page["nodes"] if field) + if not page["pageInfo"]["hasNextPage"]: + return fields + cursor = page["pageInfo"]["endCursor"] + + +def create_field(client: GitHubClient, project_id: str, field: dict) -> None: + field_type = field.get("type") + if field_type == "text": + mutation = """ + mutation($project:ID!, $name:String!) { + createProjectV2Field(input:{projectId:$project,name:$name,dataType:TEXT}) { + projectV2Field { ... on ProjectV2Field { id } } + } + } + """ + client.graphql(mutation, {"project": project_id, "name": field["name"]}) + return + if field_type == "single_select": + mutation = """ + mutation($project:ID!, $name:String!, $options:[ProjectV2SingleSelectFieldOptionInput!]!) { + createProjectV2Field(input:{projectId:$project,name:$name,dataType:SINGLE_SELECT,singleSelectOptions:$options}) { + projectV2Field { ... on ProjectV2SingleSelectField { id } } + } + } + """ + options = [ + {"name": option, "color": "GRAY", "description": ""} + for option in field.get("options", []) + ] + client.graphql(mutation, {"project": project_id, "name": field["name"], "options": options}) + return + raise ValueError(f"Unsupported project field type: {field_type}") + + +def ensure_fields(client: GitHubClient, project_id: str, definition: dict, dry_run: bool = False) -> dict[str, dict]: + existing = {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} + for field in definition.get("fields", []): + if field["name"] in existing: + continue + if dry_run: + print(f"[DRY-RUN] Would create field: {field['name']} ({field['type']})") + else: + create_field(client, project_id, field) + print(f"created field: {field['name']}") + if dry_run: + return existing + return {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} + + +def list_repo_issues(client: GitHubClient, repo: str, state: str = "open") -> list[dict]: + issues = client.paginated(f"{API_BASE}/repos/{repo}/issues?state={state}&sort=created&direction=asc") + return [issue for issue in issues if "pull_request" not in issue] + + +def issue_node_id(client: GitHubClient, repo: str, number: int) -> str: + owner, name = split_repo(repo) + query = """ + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner,name:$repo) { issue(number:$number) { id } } + } + """ + issue = client.graphql(query, {"owner": owner, "repo": name, "number": number})["repository"]["issue"] + if not issue: + raise RuntimeError(f"Issue #{number} not found in {repo}") + return issue["id"] + + +def list_project_items(client: GitHubClient, project_id: str) -> dict[str, str]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + items(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { id content { __typename ... on Issue { id } } } + } + } + } + } + """ + result: dict[str, str] = {} + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["items"] + for item in page["nodes"]: + content = item.get("content") + if content and content.get("__typename") == "Issue": + result[content["id"]] = item["id"] + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + +def add_issue_to_project(client: GitHubClient, project_id: str, issue_id: str) -> str: + mutation = """ + mutation($project:ID!, $content:ID!) { + addProjectV2ItemById(input:{projectId:$project,contentId:$content}) { item { id } } + } + """ + return client.graphql(mutation, {"project": project_id, "content": issue_id})["addProjectV2ItemById"]["item"]["id"] + + +def update_single_select(client: GitHubClient, project_id: str, item_id: str, field_id: str, option_id: str) -> None: + mutation = """ + mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) { + updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{singleSelectOptionId:$option}}) { + projectV2Item { id } + } + } + """ + client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "option": option_id}) + + +def update_text(client: GitHubClient, project_id: str, item_id: str, field_id: str, value: str) -> None: + mutation = """ + mutation($project:ID!, $item:ID!, $field:ID!, $value:String!) { + updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{text:$value}}) { + projectV2Item { id } + } + } + """ + client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "value": value}) + + +def label_value(labels: list, prefix: str) -> str | None: + for label in labels: + name = label["name"] if isinstance(label, dict) else str(label) + if name.startswith(prefix): + return name.split(":", 1)[1] + return None + + +def milestone_from_issue(issue: dict) -> str | None: + milestone = issue.get("milestone") + if milestone and milestone.get("title"): + return milestone["title"] + match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", issue.get("body") or "") + return match.group(1) if match else None + + +def option_id(field: dict, desired: str) -> str | None: + normalized = re.sub(r"[^a-z0-9]+", "", desired.lower()) + for option in field.get("options", []): + if re.sub(r"[^a-z0-9]+", "", option["name"].lower()) == normalized: + return option["id"] + return None + + +def sync_issue_fields( + client: GitHubClient, + project_id: str, + item_id: str, + issue: dict, + fields: dict[str, dict], + definition: dict, + dry_run: bool = False, +) -> None: + milestone = milestone_from_issue(issue) + values = { + "Phase": definition.get("phaseMilestoneMap", {}).get(milestone), + "Item Type": label_value(issue.get("labels", []), "type:"), + "Priority": label_value(issue.get("labels", []), "priority:"), + "Status": label_value(issue.get("labels", []), "status:"), + "Test Type": label_value(issue.get("labels", []), "test:"), + "Milestone": milestone, + } + for field_name, value in values.items(): + field = fields.get(field_name) + if not field or not value: + continue + if dry_run: + print(f"[DRY-RUN] Would set {field_name}={value} on issue #{issue['number']}") + elif field.get("dataType") == "SINGLE_SELECT": + if selected := option_id(field, value): + update_single_select(client, project_id, item_id, field["id"], selected) + else: + print(f"warning: option '{value}' not found for field '{field_name}'") + elif field.get("dataType") == "TEXT": + update_text(client, project_id, item_id, field["id"], value) + + +def sync_project( + client: GitHubClient, + repo: str, + definition_file: str, + project_number: int, + owner: str | None = None, + issue_state: str = "open", + dry_run: bool = False, +) -> None: + definition = load_project_definition(definition_file) + project = find_project(client, owner or split_repo(repo)[0], project_number) + print(f"Project found: {project['title']} ({project['url']})") + fields = ensure_fields(client, project["id"], definition, dry_run=dry_run) + current_items = list_project_items(client, project["id"]) + for issue in list_repo_issues(client, repo, issue_state): + node_id = issue.get("node_id") or issue_node_id(client, repo, int(issue["number"])) + item_id = current_items.get(node_id) + if not item_id: + if dry_run: + print(f"[DRY-RUN] Would add issue #{issue['number']} to project") + item_id = f"dry-run-{issue['number']}" + else: + item_id = add_issue_to_project(client, project["id"], node_id) + current_items[node_id] = item_id + print(f"Added issue #{issue['number']} to project") + sync_issue_fields(client, project["id"], item_id, issue, fields, definition, dry_run=dry_run) diff --git a/governance_bootstrap/bootstrap.py b/project_setup/runner.py similarity index 50% rename from governance_bootstrap/bootstrap.py rename to project_setup/runner.py index 4a5d979..d111c62 100644 --- a/governance_bootstrap/bootstrap.py +++ b/project_setup/runner.py @@ -3,19 +3,23 @@ import json from .github import GitHubClient -from .issue_milestones import sync_issue_milestones from .issues import generate_issues from .labels import sync_labels from .milestones import sync_milestones -from .project import create_project, sync_project +from .project import create_project -def load_bootstrap_config(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) +def load_project_setup_config(path: str) -> dict: + with open(path, "r", encoding="utf-8") as file: + config = json.load(file) + required = ("labelsFile", "milestonesFile", "projectDefinitionFile", "backlogManifestFile") + missing = [key for key in required if key not in config] + if missing: + raise ValueError(f"project setup config is missing: {', '.join(missing)}") + return config -def run_bootstrap( +def run_project_setup( client: GitHubClient, repo: str, config: dict, @@ -34,11 +38,15 @@ def run_bootstrap( print("==> Sync milestones") sync_milestones(client, repo, config["milestonesFile"], dry_run=dry_run) if run_project_creation: - print("==> Create project v2") + print("==> Create Project v2") create_project(client, repo, config["projectDefinitionFile"], dry_run=dry_run) if run_issue_generation: - print("==> Generate issues/tasks") - generate_issues(repo, config["backlogManifestFile"], dry_run=dry_run, link_subissues=link_subissues and not dry_run) - - print("Governance bootstrap finished.") - + print("==> Generate issues and tasks") + generate_issues( + None if dry_run else client, + repo, + config["backlogManifestFile"], + dry_run=dry_run, + link_subissues=link_subissues and not dry_run, + ) + print("Project setup finished.") diff --git a/pyproject.toml b/pyproject.toml index 5fdbb1d..e0b8fc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,19 +3,26 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] -name = "github-governance-bootstrap" -version = "0.1.0" -description = "Reusable GitHub governance bootstrap CLI for labels, milestones, projects, issues and sub-issues." +name = "github-project-setup" +version = "0.2.1" +description = "Reusable CLI for setting up GitHub repository workflows, labels, milestones, issues and Projects." readme = "README.md" requires-python = ">=3.11" dependencies = [] +keywords = ["github", "automation", "repository", "project-management", "github-actions"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", +] [project.optional-dependencies] dev = ["pytest>=8"] [project.scripts] -governance = "governance_bootstrap.cli:main" +project-setup = "project_setup.cli:main" +project_setup = "project_setup.cli:main" [tool.setuptools.packages.find] where = ["."] -include = ["governance_bootstrap*"] +include = ["project_setup*"] diff --git a/scripts/validation/repo_quality.py b/scripts/validation/repo_quality.py new file mode 100644 index 0000000..38fc0b4 --- /dev/null +++ b/scripts/validation/repo_quality.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tomllib + + +SELF = Path(__file__).resolve() +ROOT = SELF.parents[2] +REQUIRED_PATHS = ( + ".env.example", + "Makefile", + "README.md", + "LICENSE", + "pyproject.toml", + "project_setup.json", + "project_setup/__init__.py", + "project_setup/__main__.py", + "project_setup/cli.py", + "project_setup/discovery.py", + "project_setup/runner.py", + "project_setup/installer.py", + "project_setup/github.py", + ".github/workflows/project-setup.yml", + ".github/workflows/auto-label.yml", + ".github/workflows/pr-metadata.yml", + ".github/workflows/repo-quality.yml", + "scripts/validation/repo_quality.py", + "scripts/validation/validate_pr_body.py", + "tests/test_project_setup.py", + "tests/test_script_references.py", +) + +# Build legacy names at runtime so this validation file does not contain the exact +# forbidden strings it is responsible for finding. +LEGACY_NAMESPACE = "governance" +FORBIDDEN_REFERENCES = ( + f"{LEGACY_NAMESPACE}_bootstrap", + f"{LEGACY_NAMESPACE}_bootstarp", + f"{LEGACY_NAMESPACE}.bootstrap.json", + f"{LEGACY_NAMESPACE}-bootstrap", +) + +SCRIPT_REFERENCES = { + "scripts/validation/repo_quality.py": ( + "Makefile", + ), + "scripts/validation/validate_pr_body.py": ( + ".github/workflows/pr-metadata.yml", + ), +} +INSTALLER_MANIFEST = "project_setup/installer.py" +SCRIPT_SUFFIXES = {".py", ".sh", ".ps1"} +TEXT_SUFFIXES = {".py", ".md", ".yml", ".yaml", ".json", ".toml", ".txt", ".sh", ".ps1", ".example"} + + +def fail(message: str, failures: list[str], fix: str | None = None) -> None: + failures.append(message) + print(f"ERROR: {message}", file=sys.stderr) + if fix: + print(f" Fix: {fix}", file=sys.stderr) + + +def tracked_files(failures: list[str]) -> list[Path]: + try: + result = subprocess.run( + ["git", "ls-files", "-z"], + cwd=ROOT, + capture_output=True, + check=False, + ) + except FileNotFoundError: + fail( + "Git is not installed or is not available on PATH.", + failures, + "Install Git, open a new terminal, and run `make check` again.", + ) + return [] + + if result.returncode != 0: + details = result.stderr.decode("utf-8", errors="replace").strip() or "unknown Git error" + fail( + f"Could not inspect committed files: {details}", + failures, + "Run this command from a Git working tree and confirm that `git status` succeeds.", + ) + return [] + + names = result.stdout.decode("utf-8", errors="surrogateescape").split("\0") + return [ROOT / name for name in names if name] + + +def read_text(relative_path: str, failures: list[str]) -> str | None: + path = ROOT / relative_path + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + fail( + f"Script reference owner is missing: {relative_path}", + failures, + "Restore the file or remove its script-reference contract from repo_quality.py.", + ) + except UnicodeDecodeError: + fail( + f"Script reference owner is not valid UTF-8 text: {relative_path}", + failures, + "Save the file as UTF-8 and run `make check` again.", + ) + return None + + +def validate_script_references(failures: list[str]) -> None: + print("==> Validating script entry points") + installer_text = read_text(INSTALLER_MANIFEST, failures) + + discovered_scripts = { + path.relative_to(ROOT).as_posix() + for path in (ROOT / "scripts").rglob("*") + if path.is_file() and path.suffix.lower() in SCRIPT_SUFFIXES + } + registered_scripts = set(SCRIPT_REFERENCES) + + for script_path in sorted(discovered_scripts - registered_scripts): + fail( + f"Script has no registered caller contract: {script_path}", + failures, + "Add the script and its Makefile/workflow caller to SCRIPT_REFERENCES in repo_quality.py.", + ) + + for script_path in sorted(registered_scripts - discovered_scripts): + fail( + f"Registered script is missing from the scripts directory: {script_path}", + failures, + "Restore the script or remove the obsolete SCRIPT_REFERENCES entry.", + ) + + for script_path, owners in SCRIPT_REFERENCES.items(): + script = ROOT / script_path + if not script.is_file(): + continue + + print(f"script={script_path} exists=yes") + for owner in owners: + owner_text = read_text(owner, failures) + if owner_text is None: + continue + if script_path not in owner_text: + fail( + f"{owner} no longer references {script_path}", + failures, + f"Restore the `{script_path}` invocation in {owner} or update SCRIPT_REFERENCES intentionally.", + ) + else: + print(f" referenced_by={owner} status=ok") + + if installer_text is not None: + if script_path not in installer_text: + fail( + f"Installer does not copy required script: {script_path}", + failures, + f"Add `{script_path}` to CORE_TEMPLATE_FILES in {INSTALLER_MANIFEST}.", + ) + else: + print(f" installer={INSTALLER_MANIFEST} status=ok") + + +def main() -> int: + failures: list[str] = [] + + print("==> Checking required repository files") + for relative_path in REQUIRED_PATHS: + if not (ROOT / relative_path).is_file(): + fail( + f"Required file is missing: {relative_path}", + failures, + "Restore the file from the project_setup template or rerun the installer.", + ) + + print("==> Checking committed files") + for path in tracked_files(failures): + relative_path = path.relative_to(ROOT) + relative = relative_path.as_posix() + + if "__pycache__" in relative_path.parts or path.suffix in {".pyc", ".pyo"}: + fail( + f"Generated Python artifact is committed: {relative}", + failures, + f"Run `git rm --cached -- {relative}` and then `make clean`. Local untracked cache files are allowed.", + ) + continue + + for forbidden in FORBIDDEN_REFERENCES: + if forbidden in relative: + fail( + f"Legacy reference '{forbidden}' found in path: {relative}", + failures, + "Rename or remove the legacy path so only project_setup remains.", + ) + + if not path.is_file() or (path.suffix.lower() not in TEXT_SUFFIXES and path.name not in {"Makefile", ".env.example"}): + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for forbidden in FORBIDDEN_REFERENCES: + if forbidden in text: + fail( + f"Legacy reference '{forbidden}' found in {relative}", + failures, + "Replace the reference with project_setup or remove obsolete documentation.", + ) + + validate_script_references(failures) + + print("==> Validating JSON configuration") + json_paths = [ROOT / "project_setup.json", *sorted((ROOT / "config").rglob("*.json"))] + for path in json_paths: + try: + json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + fail( + f"JSON configuration is missing: {path.relative_to(ROOT)}", + failures, + "Restore the file or update project_setup.json to reference an existing manifest.", + ) + except json.JSONDecodeError as exc: + fail( + f"Invalid JSON in {path.relative_to(ROOT)} at line {exc.lineno}, column {exc.colno}: {exc.msg}", + failures, + "Correct the JSON syntax and run `make check` again.", + ) + + print("==> Validating Python package metadata") + try: + with (ROOT / "pyproject.toml").open("rb") as file: + pyproject = tomllib.load(file) + scripts = pyproject.get("project", {}).get("scripts", {}) + expected_entry_points = { + "project-setup": "project_setup.cli:main", + "project_setup": "project_setup.cli:main", + } + for command, expected in expected_entry_points.items(): + if scripts.get(command) != expected: + fail( + f"pyproject.toml does not expose `{command} = {expected}`.", + failures, + f"Restore the {command} entry point under [project.scripts].", + ) + except FileNotFoundError: + fail("pyproject.toml is missing.", failures, "Restore pyproject.toml before running the checks.") + except tomllib.TOMLDecodeError as exc: + fail(f"Invalid pyproject.toml: {exc}", failures, "Correct the TOML syntax and run `make check` again.") + + if failures: + print("", file=sys.stderr) + print(f"Repository quality failed with {len(failures)} error(s).", file=sys.stderr) + print("Review each `Fix:` line above. No remote GitHub changes were made.", file=sys.stderr) + return 1 + + print( + "Repository quality checks passed: required files, committed artifacts, script references, JSON, " + "and package metadata are valid." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validation/validate_pr_body.py b/scripts/validation/validate_pr_body.py new file mode 100644 index 0000000..9cb7307 --- /dev/null +++ b/scripts/validation/validate_pr_body.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from project_setup.github import get_token, require_client +from project_setup.pr_validation import upsert_validation_comment, validate_pull_request + + +def read_body(args: argparse.Namespace) -> str: + if args.file: + return Path(args.file).read_text(encoding="utf-8") + if args.repo and args.pr_number and get_token(): + return require_client().get_issue(args.repo, args.pr_number).get("body") or "" + if os.getenv("PR_BODY") is not None: + return os.environ["PR_BODY"] + if not sys.stdin.isatty(): + return sys.stdin.read() + return "" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate pull request branch naming and metadata") + parser.add_argument("--file") + parser.add_argument("--branch") + parser.add_argument("--base-branch") + parser.add_argument("--repo") + parser.add_argument("--pr-number", type=int) + parser.add_argument("--comment", action="store_true") + args = parser.parse_args() + + findings = validate_pull_request(args.branch, read_body(args), args.base_branch) + for finding in findings: + print(f"{finding.section}: {finding.problem}", file=sys.stderr) + print(f" Fix: {finding.fix}", file=sys.stderr) + if args.comment: + if not args.repo or not args.pr_number: + print("--comment requires --repo and --pr-number", file=sys.stderr) + return 2 + upsert_validation_comment(require_client(), args.repo, args.pr_number, findings) + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 0000000..e6bdb32 --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import io +import urllib.error +import unittest +from unittest.mock import patch + +from project_setup.github import API_BASE, HTTP_TIMEOUT_SECONDS, GitHubClient, GitHubRequestError + + +class _Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self) -> bytes: + return b"{}" + + +class GitHubClientTests(unittest.TestCase): + def test_mutation_transport_failure_is_not_retried(self): + client = GitHubClient("token") + with patch( + "project_setup.github.urllib.request.urlopen", + side_effect=urllib.error.URLError("connection lost"), + ) as urlopen: + with self.assertRaises(GitHubRequestError): + client.request_json("POST", f"{API_BASE}/repos/owner/repository/issues", {"title": "Example"}) + self.assertEqual(urlopen.call_count, 1) + + def test_request_uses_finite_timeout(self): + client = GitHubClient("token") + with patch("project_setup.github.urllib.request.urlopen", return_value=_Response()) as urlopen: + self.assertEqual(client.request_json("GET", f"{API_BASE}/repos/owner/repository"), {}) + self.assertEqual(urlopen.call_args.kwargs["timeout"], HTTP_TIMEOUT_SECONDS) + + def test_non_github_api_url_is_rejected_before_opening_connection(self): + client = GitHubClient("token") + with patch("project_setup.github.urllib.request.urlopen") as urlopen: + with self.assertRaisesRegex(ValueError, "Unsupported GitHub API URL"): + client.request_json("GET", "https://example.com/resource") + urlopen.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_governance_bootstrap.py b/tests/test_governance_bootstrap.py deleted file mode 100644 index c2cb0fc..0000000 --- a/tests/test_governance_bootstrap.py +++ /dev/null @@ -1,306 +0,0 @@ -import io -import os -import tempfile -import unittest -from contextlib import redirect_stdout -from types import SimpleNamespace -from unittest.mock import patch - -from governance_bootstrap.auto_label import infer_issue_labels -from governance_bootstrap.discovery import detect_auth_status, detect_project_matches -from governance_bootstrap.cli import main -from governance_bootstrap.issue_milestones import milestone_from_body, parent_issue_number_from_body -from governance_bootstrap.issues import load_backlog -from governance_bootstrap.labels import load_labels -from governance_bootstrap.milestones import load_milestones -from governance_bootstrap.project import label_value - - -class GovernanceBootstrapTests(unittest.TestCase): - # ------------------------------------------------------------------ # - # Label helpers # - # ------------------------------------------------------------------ # - - def test_auto_label_infers_type_status_priority_and_test(self): - issue = { - "title": "US-01 | Example", - "body": "Severity\nHigh\n\nTest type: smoke", - "labels": [], - } - - self.assertEqual( - infer_issue_labels(issue), - {"type:user-story", "status:backlog", "priority:high", "test:smoke"}, - ) - - def test_label_value_reads_github_label_payloads(self): - labels = [{"name": "type:task"}, {"name": "priority:critical"}] - - self.assertEqual(label_value(labels, "priority:"), "critical") - - def test_label_value_returns_none_when_no_match(self): - labels = [{"name": "type:task"}] - - self.assertIsNone(label_value(labels, "priority:")) - - # ------------------------------------------------------------------ # - # Manifest loaders — validation errors # - # ------------------------------------------------------------------ # - - def test_load_backlog_raises_when_milestones_key_missing(self): - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - f.write('{"version": "1.0.0", "stories": []}') - path = f.name - try: - with self.assertRaises(ValueError): - load_backlog(path) - finally: - os.unlink(path) - - def test_load_labels_raises_when_color_missing(self): - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - f.write('[{"name": "status:backlog"}]') - path = f.name - try: - with self.assertRaises(ValueError): - load_labels(path) - finally: - os.unlink(path) - - def test_load_milestones_raises_when_title_missing(self): - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - f.write('[{"description": "no title here"}]') - path = f.name - try: - with self.assertRaises(ValueError): - load_milestones(path) - finally: - os.unlink(path) - - # ------------------------------------------------------------------ # - # Issue metadata parsers # - # ------------------------------------------------------------------ # - - def test_issue_metadata_parsers_accept_generic_milestones(self): - body = "Parent story: US-01 (#42)\n\n- Milestone: Release-1.0" - - self.assertEqual(milestone_from_body(body), "Release-1.0") - self.assertEqual(parent_issue_number_from_body(body), 42) - - # ------------------------------------------------------------------ # - # Sync dry-run output # - # ------------------------------------------------------------------ # - - def test_sync_labels_dry_run_prints_label_list(self): - with tempfile.TemporaryDirectory() as tmp: - labels_file = os.path.join(tmp, "labels.json") - with open(labels_file, "w", encoding="utf-8") as f: - f.write('[{"name":"status:backlog","color":"C5DEF5","description":"Backlog"}]') - - from governance_bootstrap.labels import sync_labels - from governance_bootstrap.github import GitHubClient - - output = io.StringIO() - with redirect_stdout(output): - sync_labels(GitHubClient(""), "owner/repo", labels_file, dry_run=True) - - text = output.getvalue() - self.assertIn("[DRY-RUN] Would sync 1 labels", text) - self.assertIn("status:backlog", text) - - def test_sync_milestones_dry_run_prints_milestone_list(self): - with tempfile.TemporaryDirectory() as tmp: - milestones_file = os.path.join(tmp, "milestones.json") - with open(milestones_file, "w", encoding="utf-8") as f: - f.write('[{"title":"M0","description":"Setup","due_on":"2026-01-31T00:00:00Z"}]') - - from governance_bootstrap.milestones import sync_milestones - from governance_bootstrap.github import GitHubClient - - output = io.StringIO() - with redirect_stdout(output): - sync_milestones(GitHubClient(""), "owner/repo", milestones_file, dry_run=True) - - text = output.getvalue() - self.assertIn("[DRY-RUN] Would sync 1 milestones", text) - self.assertIn("M0", text) - - # ------------------------------------------------------------------ # - # Issue generation dry run # - # ------------------------------------------------------------------ # - - def test_issue_generation_dry_run_prints_stories_and_tasks(self): - with tempfile.TemporaryDirectory() as tmp: - labels = os.path.join(tmp, "labels.json") - milestones = os.path.join(tmp, "milestones.json") - project = os.path.join(tmp, "project.json") - backlog = os.path.join(tmp, "backlog.json") - config = os.path.join(tmp, "governance.bootstrap.json") - - with open(labels, "w", encoding="utf-8") as f: - f.write('[{"name":"status:backlog","color":"C5DEF5","description":"Backlog"},' - '{"name":"type:user-story","color":"1D76DB","description":"Story"},' - '{"name":"type:task","color":"0E8A16","description":"Task"}]') - with open(milestones, "w", encoding="utf-8") as f: - f.write('[{"title":"M0","description":"Setup"}]') - with open(project, "w", encoding="utf-8") as f: - f.write('{"name":"Board","fields":[]}') - with open(backlog, "w", encoding="utf-8") as f: - f.write( - '{"milestones":[{"milestone":"M0","stories":[{' - '"storyId":"US-00","title":"US-00 | Setup",' - '"labels":["type:user-story"],"body":"As a team...",' - '"tasks":["T-00.1 | Create milestones"]' - '}]}]}' - ) - with open(config, "w", encoding="utf-8") as f: - f.write( - "{" - f'"labelsFile":"{labels}",' - f'"milestonesFile":"{milestones}",' - f'"projectDefinitionFile":"{project}",' - f'"backlogManifestFile":"{backlog}",' - '"defaults":{"dryRun":true,"runLabels":false,"runMilestones":false,' - '"runProjectCreation":false,"runIssueGeneration":true}' - "}" - ) - - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False): - output = io.StringIO() - with redirect_stdout(output): - result = main(["bootstrap", "--repo", "owner/repo", "--config", config, "--dry-run"]) - - self.assertEqual(result, 0) - text = output.getvalue() - self.assertIn("[DRY-RUN] Story: US-00 | Setup", text) - self.assertIn("[DRY-RUN] Task: T-00.1 | Create milestones", text) - - # ------------------------------------------------------------------ # - # Full bootstrap dry run # - # ------------------------------------------------------------------ # - - def test_bootstrap_dry_run_does_not_require_token(self): - with tempfile.TemporaryDirectory() as tmp: - labels = os.path.join(tmp, "labels.json") - milestones = os.path.join(tmp, "milestones.json") - project = os.path.join(tmp, "project.json") - backlog = os.path.join(tmp, "backlog.json") - config = os.path.join(tmp, "governance.bootstrap.json") - - with open(labels, "w", encoding="utf-8") as f: - f.write('[{"name":"status:backlog","color":"C5DEF5","description":"Backlog"}]') - with open(milestones, "w", encoding="utf-8") as f: - f.write('[{"title":"M1","description":"Milestone"}]') - with open(project, "w", encoding="utf-8") as f: - f.write('{"name":"Board","fields":[]}') - with open(backlog, "w", encoding="utf-8") as f: - f.write('{"milestones":[]}') - with open(config, "w", encoding="utf-8") as f: - f.write( - "{" - f'"labelsFile":"{labels}",' - f'"milestonesFile":"{milestones}",' - f'"projectDefinitionFile":"{project}",' - f'"backlogManifestFile":"{backlog}",' - '"defaults":{"dryRun":true,"runLabels":true,"runMilestones":true,' - '"runProjectCreation":true,"runIssueGeneration":true}' - "}" - ) - - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False): - output = io.StringIO() - with redirect_stdout(output): - result = main(["bootstrap", "--repo", "owner/repo", "--config", config, "--dry-run"]) - - self.assertEqual(result, 0) - self.assertIn("[DRY-RUN] Would sync 1 labels", output.getvalue()) - self.assertIn("Governance bootstrap finished.", output.getvalue()) - - # ------------------------------------------------------------------ # - # Auth detection # - # ------------------------------------------------------------------ # - - def test_discovery_prefers_env_token(self): - with patch.dict(os.environ, {"GITHUB_TOKEN": "token-from-env", "GH_TOKEN": ""}, clear=False): - auth = detect_auth_status() - - self.assertTrue(auth.configured) - self.assertEqual(auth.source, "environment") - - def test_get_token_falls_back_to_gh_auth(self): - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False), patch( - "governance_bootstrap.github.shutil.which", return_value="/usr/bin/gh" - ), patch("governance_bootstrap.github.subprocess.run") as run: - run.return_value = SimpleNamespace(returncode=0, stdout="token-from-gh\n") - - from governance_bootstrap.github import get_token - - token = get_token() - - self.assertEqual(token, "token-from-gh") - - # ------------------------------------------------------------------ # - # Discovery / project detection # - # ------------------------------------------------------------------ # - - def test_discovery_detects_project_markers(self): - with tempfile.TemporaryDirectory() as tmp: - with open(os.path.join(tmp, "pyproject.toml"), "w", encoding="utf-8") as f: - f.write("[project]\nname = 'demo'\n") - with open(os.path.join(tmp, "package.json"), "w", encoding="utf-8") as f: - f.write('{"name":"demo"}') - - matches = detect_project_matches(tmp) - - self.assertGreaterEqual(len(matches), 2) - self.assertEqual(matches[0].project_type, "python") - self.assertIn("pyproject.toml", matches[0].markers) - - def test_discover_auto_mode_reports_summary(self): - with tempfile.TemporaryDirectory() as tmp: - config = os.path.join(tmp, "governance.bootstrap.json") - root = os.path.join(tmp, "repo") - os.makedirs(root, exist_ok=True) - with open(os.path.join(root, "go.mod"), "w", encoding="utf-8") as f: - f.write("module example.com/demo\n") - with open(config, "w", encoding="utf-8") as f: - f.write( - "{" - '"workflowVar":"GOVERNANCE_PAT",' - '"defaults":{"dryRun":true,"runLabels":true,"runMilestones":true,' - '"runProjectCreation":false,"runIssueGeneration":true,"linkSubissues":true}' - "}" - ) - - with patch.dict(os.environ, {"GITHUB_TOKEN": "token-from-env", "GH_TOKEN": ""}, clear=False): - output = io.StringIO() - with redirect_stdout(output): - result = main(["discover", "--repo", "owner/repo", "--config", config, "--root", root, "--auto"]) - - self.assertEqual(result, 0) - text = output.getvalue() - self.assertIn("Configured: yes (environment)", text) - self.assertIn("Detected project type: go", text) - self.assertIn("Recommended command", text) - self.assertIn("python -m governance_bootstrap bootstrap", text) - - def test_discover_reports_missing_auth(self): - with tempfile.TemporaryDirectory() as tmp: - config = os.path.join(tmp, "governance.bootstrap.json") - with open(config, "w", encoding="utf-8") as f: - f.write('{"workflowVar":"GOVERNANCE_PAT","defaults":{"dryRun":true}}') - - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False), patch( - "governance_bootstrap.discovery.shutil.which", return_value=None - ): - output = io.StringIO() - with redirect_stdout(output): - result = main(["discover", "--repo", "owner/repo", "--config", config, "--auto"]) - - self.assertEqual(result, 1) - self.assertIn("Configured: no (missing)", output.getvalue()) - self.assertIn("Expected workflow secret: GOVERNANCE_PAT", output.getvalue()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_project_setup.py b/tests/test_project_setup.py new file mode 100644 index 0000000..8f08cd8 --- /dev/null +++ b/tests/test_project_setup.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +import io +import json +import os +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch + +from project_setup.auto_label import infer_issue_labels, infer_pr_labels +from project_setup.cli import main +from project_setup.discovery import build_apply_command, detect_project_matches +from project_setup.github import GitHubClient, get_gh_auth_status, get_token, load_env_file, require_project_client +from project_setup.installer import install_repository +from project_setup.issue_milestones import milestone_from_body, parent_issue_number_from_body +from project_setup.issues import load_backlog +from project_setup.labels import load_labels, sync_labels +from project_setup.milestones import load_milestones, sync_milestones +from project_setup.pr_validation import validate_pull_request +from project_setup.project import label_value + + +ROOT = Path(__file__).resolve().parents[1] + + +class ProjectSetupTests(unittest.TestCase): + def test_auto_label_infers_type_status_priority_and_test(self): + issue = { + "title": "US-01 | Example", + "body": "Severity\nHigh\n\nTest type: smoke", + "labels": [], + } + self.assertEqual( + infer_issue_labels(issue), + {"type:user-story", "status:backlog", "priority:high", "test:smoke"}, + ) + + def test_existing_pr_type_label_suppresses_branch_fallback(self): + pull_request = { + "body": "", + "labels": [{"name": "type:task"}], + "head": {"ref": "fix/example"}, + } + self.assertNotIn("type:bug", infer_pr_labels("owner/repository", pull_request, None)) + + def test_manifest_loaders_validate_required_fields(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + labels = root / "labels.json" + milestones = root / "milestones.json" + backlog = root / "backlog.json" + labels.write_text('[{"name":"missing-color"}]', encoding="utf-8") + milestones.write_text('["not-an-object"]', encoding="utf-8") + backlog.write_text('{"stories":[]}', encoding="utf-8") + with self.assertRaises(ValueError): + load_labels(str(labels)) + with self.assertRaisesRegex(ValueError, "JSON object"): + load_milestones(str(milestones)) + with self.assertRaises(ValueError): + load_backlog(str(backlog)) + + def test_issue_metadata_parsers_accept_generic_milestones(self): + body = "Parent story: US-01 (#42)\n\n- Milestone: Release-1.0" + self.assertEqual(milestone_from_body(body), "Release-1.0") + self.assertEqual(parent_issue_number_from_body(body), 42) + + def test_label_value_reads_github_label_payloads(self): + labels = [{"name": "type:task"}, {"name": "priority:critical"}] + self.assertEqual(label_value(labels, "priority:"), "critical") + self.assertIsNone(label_value(labels, "status:")) + + def test_sync_dry_runs_print_planned_resources(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + labels = root / "labels.json" + milestones = root / "milestones.json" + labels.write_text('[{"name":"status:backlog","color":"C5DEF5"}]', encoding="utf-8") + milestones.write_text('[{"title":"M1","description":"Delivery"}]', encoding="utf-8") + output = io.StringIO() + with redirect_stdout(output): + sync_labels(GitHubClient(""), "owner/repository", str(labels), dry_run=True) + sync_milestones(GitHubClient(""), "owner/repository", str(milestones), dry_run=True) + text = output.getvalue() + self.assertIn("[DRY-RUN] Would sync 1 labels", text) + self.assertIn("[DRY-RUN] Would sync 1 milestones", text) + + def test_individual_commands_default_to_dry_run(self): + with tempfile.TemporaryDirectory() as temporary_directory: + labels = Path(temporary_directory) / "labels.json" + labels.write_text('[{"name":"status:backlog","color":"C5DEF5"}]', encoding="utf-8") + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "", "PROJECT_SETUP_PAT": ""}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + result = main(["labels", "sync", "--repo", "owner/repository", "--file", str(labels)]) + self.assertEqual(result, 0) + self.assertIn("[DRY-RUN]", output.getvalue()) + + def test_apply_dry_run_does_not_require_token(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + (root / "labels.json").write_text('[{"name":"status:backlog","color":"C5DEF5"}]', encoding="utf-8") + (root / "milestones.json").write_text('[{"title":"M1"}]', encoding="utf-8") + (root / "project.json").write_text('{"name":"Board","fields":[]}', encoding="utf-8") + (root / "backlog.json").write_text('{"phases":[]}', encoding="utf-8") + config = root / "project_setup.json" + config.write_text( + json.dumps( + { + "labelsFile": str(root / "labels.json"), + "milestonesFile": str(root / "milestones.json"), + "projectDefinitionFile": str(root / "project.json"), + "backlogManifestFile": str(root / "backlog.json"), + "defaults": { + "dryRun": False, + "runLabels": True, + "runMilestones": True, + "runProjectCreation": True, + "runIssueGeneration": True, + }, + } + ), + encoding="utf-8", + ) + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "", "PROJECT_SETUP_PAT": ""}, clear=False), patch( + "project_setup.github.shutil.which", return_value=None + ): + output = io.StringIO() + with redirect_stdout(output): + result = main(["apply", "--repo", "owner/repository", "--config", str(config)]) + self.assertEqual(result, 0) + self.assertIn("[DRY-RUN] Would sync 1 labels", output.getvalue()) + self.assertIn("Project setup finished.", output.getvalue()) + + def test_project_sync_without_pat_uses_offline_preview(self): + with tempfile.TemporaryDirectory() as temporary_directory: + definition = Path(temporary_directory) / "project.json" + definition.write_text('{"name":"Board","fields":[{"name":"Status","type":"single_select"}]}', encoding="utf-8") + with patch.dict(os.environ, {"PROJECT_SETUP_PAT": ""}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + result = main( + [ + "project", + "sync", + "--repo", + "owner/repository", + "--project-number", + "1", + "--file", + str(definition), + ] + ) + self.assertEqual(result, 0) + self.assertIn("Offline Project v2 preview", output.getvalue()) + self.assertIn("Remote Project fields, items, and issues were not queried", output.getvalue()) + + def test_env_file_loads_values_without_overriding_process_environment(self): + with tempfile.TemporaryDirectory() as temporary_directory: + env_file = Path(temporary_directory) / ".env" + env_file.write_text( + "GITHUB_REPOSITORY=owner/from-file\nPROJECT_SETUP_PAT=token-from-file\n", + encoding="utf-8", + ) + with patch.dict(os.environ, {"GITHUB_REPOSITORY": "owner/from-process"}, clear=True): + loaded = load_env_file(env_file) + self.assertEqual(loaded, env_file.resolve()) + self.assertEqual(os.environ["GITHUB_REPOSITORY"], "owner/from-process") + self.assertEqual(os.environ["PROJECT_SETUP_PAT"], "token-from-file") + + def test_invalid_env_file_reports_line_number(self): + with tempfile.TemporaryDirectory() as temporary_directory: + env_file = Path(temporary_directory) / ".env" + env_file.write_text("THIS IS NOT VALID\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "line 1"): + load_env_file(env_file) + + def test_project_client_error_explains_pat_setup(self): + with patch.dict(os.environ, {"PROJECT_SETUP_PAT": "", "GITHUB_TOKEN": "token"}, clear=False): + with self.assertRaises(SystemExit) as context: + require_project_client() + message = str(context.exception) + self.assertIn("PROJECT_SETUP_PAT", message) + self.assertIn("Tokens (classic)", message) + self.assertIn("repo", message) + self.assertIn("project", message) + self.assertIn(".env", message) + + def test_installer_copies_core_files_and_preserves_existing_files(self): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + existing = target / ".github" / "pull_request_template.md" + existing.parent.mkdir(parents=True) + existing.write_text("custom template", encoding="utf-8") + result = install_repository(target, source=ROOT, profile="core") + self.assertEqual(existing.read_text(encoding="utf-8"), "custom template") + self.assertIn(".github/pull_request_template.md", result.skipped) + self.assertTrue((target / "project_setup" / "cli.py").is_file()) + self.assertTrue((target / "project_setup" / "discovery.py").is_file()) + self.assertTrue((target / ".github" / "workflows" / "project-setup.yml").is_file()) + self.assertTrue((target / ".env.example").is_file()) + self.assertTrue((target / "Makefile").is_file()) + + def test_installer_dry_run_does_not_create_target_directory(self): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) / "missing-target" + install_repository(target, source=ROOT, profile="core", dry_run=True) + self.assertFalse(target.exists()) + + def test_pull_request_validation_accepts_complete_template_and_inline_url(self): + body = """## Linked Issue +- Closes #123 + +## Milestone +- M1 + +## Summary +- Add repository setup. + +## How to test +- Run make check. + +## Known risks +- None identified. + +## DoD checklist +- [x] Checks passed. +""" + self.assertEqual(validate_pull_request("feat/project-setup", body, "develop"), []) + + def test_discovery_detects_multiple_project_types(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + (root / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") + (root / "package.json").write_text('{"name":"demo"}', encoding="utf-8") + matches = detect_project_matches(root) + self.assertEqual([match.project_type for match in matches[:2]], ["python", "node"]) + + def test_discovery_builds_quoted_project_setup_command(self): + command = build_apply_command( + "owner/repository", + "config folder/project_setup.json", + True, + True, + True, + False, + False, + True, + ) + self.assertIn("project_setup", command) + self.assertIn("--dry-run", command) + self.assertIn("--skip-project-creation", command) + self.assertIn("config folder", command) + + def test_get_token_falls_back_to_gh_auth(self): + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "", "PROJECT_SETUP_PAT": ""}, clear=False), patch( + "project_setup.github.shutil.which", return_value="/usr/bin/gh" + ), patch("project_setup.github.subprocess.run") as run: + run.return_value = SimpleNamespace(returncode=0, stdout="token-from-gh\n", stderr="") + token = get_token() + self.assertEqual(token, "token-from-gh") + + def test_gh_auth_status_reports_invalid_session(self): + with patch("project_setup.github.shutil.which", return_value="gh"), patch( + "project_setup.github.subprocess.run" + ) as run: + run.return_value = SimpleNamespace(returncode=1, stdout="", stderr="invalid token\n") + status = get_gh_auth_status() + self.assertTrue(status.installed) + self.assertFalse(status.authenticated) + self.assertEqual(status.detail, "invalid token") + + def test_discover_auto_mode_reports_summary(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + target = root / "repository" + target.mkdir() + (target / "go.mod").write_text("module example.com/demo\n", encoding="utf-8") + config = root / "project_setup.json" + config.write_text( + json.dumps( + { + "labelsFile": "labels.json", + "milestonesFile": "milestones.json", + "projectDefinitionFile": "project.json", + "backlogManifestFile": "backlog.json", + "secretName": "PROJECT_SETUP_PAT", + "defaults": {"dryRun": False, "runLabels": True, "runMilestones": True}, + } + ), + encoding="utf-8", + ) + with patch.dict(os.environ, {"PROJECT_SETUP_PAT": "token", "GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + result = main( + [ + "discover", + "--repo", + "owner/repository", + "--config", + str(config), + "--root", + str(target), + "--auto", + ] + ) + self.assertEqual(result, 0) + text = output.getvalue() + self.assertIn("Configured: yes (PROJECT_SETUP_PAT)", text) + self.assertIn("Detected project type: go", text) + self.assertIn("project_setup", text) + self.assertIn("--dry-run", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_script_references.py b/tests/test_script_references.py new file mode 100644 index 0000000..bdd5f0d --- /dev/null +++ b/tests/test_script_references.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_REFERENCES = { + "scripts/validation/repo_quality.py": ( + "Makefile", + ), + "scripts/validation/validate_pr_body.py": ( + ".github/workflows/pr-metadata.yml", + ), +} +INSTALLER = "project_setup/installer.py" +SCRIPT_SUFFIXES = {".py", ".sh", ".ps1"} + + +class ScriptReferenceTests(unittest.TestCase): + def test_every_validation_script_has_a_registered_entry_point(self): + discovered = { + path.relative_to(ROOT).as_posix() + for path in (ROOT / "scripts").rglob("*") + if path.is_file() and path.suffix.lower() in SCRIPT_SUFFIXES + } + self.assertEqual(discovered, set(SCRIPT_REFERENCES)) + + def test_script_callers_and_installer_reference_current_paths(self): + installer_text = (ROOT / INSTALLER).read_text(encoding="utf-8") + for script_path, owners in SCRIPT_REFERENCES.items(): + with self.subTest(script=script_path): + self.assertTrue((ROOT / script_path).is_file()) + self.assertIn(script_path, installer_text) + for owner in owners: + owner_text = (ROOT / owner).read_text(encoding="utf-8") + self.assertIn(script_path, owner_text) + + def test_validation_scripts_use_only_the_project_setup_namespace(self): + legacy_names = ( + "governance" + "_bootstrap", + "governance" + "_bootstarp", + "governance" + ".bootstrap.json", + "governance" + "-bootstrap", + ) + for script_path in SCRIPT_REFERENCES: + text = (ROOT / script_path).read_text(encoding="utf-8") + for legacy_name in legacy_names: + with self.subTest(script=script_path, legacy_name=legacy_name): + self.assertNotIn(legacy_name, text) + + +if __name__ == "__main__": + unittest.main()