From 7793f1501b223fad1afd4247745bcefd3bbe7134 Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:50:08 +0200 Subject: [PATCH 1/4] feat(config): declare repository rulesets, classes and registry --- config/repositories.yaml | 312 ++++++++++++++++ config/repository-classes.yaml | 38 ++ .../rulesets/Codeowner_review_required.json | 49 +++ config/rulesets/Only_Codeowner_Can_Merge.json | 49 +++ .../pre-release-pointer-protection.json | 36 ++ .../rulesets/release-pointer-protection.json | 39 ++ .../rulesets/release-snapshot-protection.json | 68 ++++ config/rulesets/release-tag-protection.json | 39 ++ scripts/apply-release-rulesets.sh | 332 ------------------ 9 files changed, 630 insertions(+), 332 deletions(-) create mode 100644 config/repositories.yaml create mode 100644 config/repository-classes.yaml create mode 100644 config/rulesets/Codeowner_review_required.json create mode 100644 config/rulesets/Only_Codeowner_Can_Merge.json create mode 100644 config/rulesets/pre-release-pointer-protection.json create mode 100644 config/rulesets/release-pointer-protection.json create mode 100644 config/rulesets/release-snapshot-protection.json create mode 100644 config/rulesets/release-tag-protection.json delete mode 100755 scripts/apply-release-rulesets.sh diff --git a/config/repositories.yaml b/config/repositories.yaml new file mode 100644 index 00000000..971f7461 --- /dev/null +++ b/config/repositories.yaml @@ -0,0 +1,312 @@ +# Repository registry: one entry per organisation repository, keyed by name. +# +# class: api-repository | non-api | unmanaged (see repository-classes.yaml) +# single_codeowner: true when the default branch's CODEOWNERS `*` line names one user; +# `plan` cross-checks the flag against CODEOWNERS. +# archived: true for archived repositories (rulesets are skipped). +# A GitHub repository without an entry is reported as unregistered. + +repositories: + .github: + class: unmanaged + + APIBacklog: + class: non-api + + ApplicationEndpointDiscovery: + class: api-repository + + ApplicationEndpointRegistration: + class: api-repository + + ApplicationProfiles: + class: api-repository + + BlockchainPublicAddress: + class: api-repository + + CallForwardingSignal: + class: api-repository + + camara-landscape: + class: unmanaged + + camaraproject.github.io: + class: unmanaged + + CapabilitiesAndRuntimeRestrictions: + class: api-repository + + CarrierBillingCheckOut: + class: api-repository + + ClickToDial: + class: api-repository + + Commonalities: + class: non-api + + CommonalitiesTest: + class: api-repository + + ConnectedNetworkType: + class: api-repository + + ConnectivityInsights: + class: api-repository + + ConnectivityQualityManagement: + class: non-api + + ConsentInfo: + class: api-repository + + ConsentManagement: + class: api-repository + + CustomerInsights: + class: api-repository + + DedicatedNetworks: + class: api-repository + + DeviceAuthenticity: + class: api-repository + + DeviceDataVolume: + class: api-repository + + DeviceIdentifier: + class: api-repository + + DeviceLocation: + class: api-repository + + DeviceMediaStreamingRate: + class: api-repository + + DeviceReachabilityStatus: + class: api-repository + + DeviceRoamingStatus: + class: api-repository + + DeviceStatus: + class: api-repository + + DeviceSwap: + class: api-repository + + DeviceVisitLocation: + class: api-repository + + EasyCLA: + class: unmanaged + + EdgeApplicationManagement: + class: api-repository + + EdgeCloud: + class: non-api + + EnergyFootprintNotification: + class: api-repository + single_codeowner: true + + EnergyFootprintNotification_PI: + class: non-api + single_codeowner: true + + eSimRemoteManagement: + class: api-repository + + Governance: + class: non-api + + HighThroughputElasticNetworks: + class: api-repository + single_codeowner: true + + HomeDevicesQoD: + class: api-repository + archived: true + + IdentityAndConsentManagement: + class: non-api + + InHomeDeviceManagement: + class: api-repository + + IoTDeviceManagement: + class: api-repository + + IoTNetworkOptimization: + class: api-repository + + IoTNetworkOptimization_PI: + class: non-api + single_codeowner: true + + IoTSIMFraudPrevention: + class: api-repository + + KnowYourCustomer: + class: api-repository + + KnowYourCustomerAgeVerification: + class: api-repository + + KnowYourCustomerFill-in: + class: api-repository + + KnowYourCustomerMatch: + class: api-repository + + Marketing: + class: non-api + + MCPEnablement_PI1: + class: non-api + + ModelAsAService: + class: api-repository + single_codeowner: true + + MostFrequentLocation: + class: api-repository + + MultiPointVPN: + class: api-repository + + NetworkAccessManagement: + class: api-repository + + NetworkInsights: + class: api-repository + + NetworkServiceAreas: + class: api-repository + + NetworkSliceBooking: + class: api-repository + + NumberRecycling: + class: api-repository + + NumberVerification: + class: api-repository + + OptimalEdgeDiscovery: + class: api-repository + + OTPValidation: + class: api-repository + + PopulationDensityData: + class: api-repository + + PredictiveConnectivityData: + class: api-repository + + project-administration: + class: non-api + + QoSBooking: + class: api-repository + + QoSProfiles: + class: api-repository + + QualityOnDemand: + class: api-repository + + QualityOnDemand_PI1: + class: non-api + + QualityOnDemand_PI2: + class: non-api + + QualityOnDemand_PI3: + class: non-api + single_codeowner: true + + RainfallIntensity: + class: api-repository + single_codeowner: true + + RegionDeviceCount: + class: api-repository + + ReleaseManagement: + class: non-api + + ReleaseTest: + class: api-repository + single_codeowner: true + + SessionInsights: + class: api-repository + + ShortMessageService: + class: api-repository + archived: true + + SimpleEdgeDiscovery: + class: api-repository + + SimSwap: + class: api-repository + + SiteToCloudVPN: + class: api-repository + archived: true + + SponsoredData: + class: api-repository + single_codeowner: true + + SubscriptionStatus: + class: api-repository + + Template_API_Repository: + class: api-repository + + Template_PI_Repository: + class: non-api + + Tenure: + class: api-repository + + test-repo-w-linting: + class: unmanaged + + test-repo-wo-linting: + class: unmanaged + + TestRepo: + class: unmanaged + archived: true + + tooling: + class: non-api + + TrafficInfluence: + class: api-repository + + VerifiedCaller: + class: api-repository + + VoiceNotification: + class: api-repository + single_codeowner: true + + VoiceVerificationCode: + class: api-repository + single_codeowner: true + + WebRTC: + class: api-repository + + WorkingGroups: + class: non-api + archived: true diff --git a/config/repository-classes.yaml b/config/repository-classes.yaml new file mode 100644 index 00000000..7a839ab2 --- /dev/null +++ b/config/repository-classes.yaml @@ -0,0 +1,38 @@ +# Repository classes: which declared rulesets a repository carries. +# +# Ruleset bodies live in config/rulesets/.json; the class of each +# repository is set in config/repositories.yaml. The tool is +# workflows/repository-config/. + +classes: + # Automated releases: release rulesets, tag protection and main protection. + api-repository: + rulesets: + - release-snapshot-protection + - release-pointer-protection + - pre-release-pointer-protection + - release-tag-protection + - Only_Codeowner_Can_Merge + - Codeowner_review_required + + # Manual releases: main protection only. + non-api: + rulesets: + - Only_Codeowner_Can_Merge + - Codeowner_review_required + +# `main` protection. Classic branch protection on the default branch is removed +# only once these rulesets are active on the repository. +main_rulesets: + - Only_Codeowner_Can_Merge + - Codeowner_review_required + +# Absent (not disabled) on a repository with `single_codeowner: true`. +single_codeowner_excludes: + - Codeowner_review_required + +# Removed wherever found, in every class. +retired: + - code-owner-review-required-or-sole-codeowner + - release-review-protection + - release-snapshot-pr-rules diff --git a/config/rulesets/Codeowner_review_required.json b/config/rulesets/Codeowner_review_required.json new file mode 100644 index 00000000..ab7c9cb4 --- /dev/null +++ b/config/rulesets/Codeowner_review_required.json @@ -0,0 +1,49 @@ +{ + "name": "Codeowner_review_required", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ] +} diff --git a/config/rulesets/Only_Codeowner_Can_Merge.json b/config/rulesets/Only_Codeowner_Can_Merge.json new file mode 100644 index 00000000..7afbafdd --- /dev/null +++ b/config/rulesets/Only_Codeowner_Can_Merge.json @@ -0,0 +1,49 @@ +{ + "name": "Only_Codeowner_Can_Merge", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ] +} diff --git a/config/rulesets/pre-release-pointer-protection.json b/config/rulesets/pre-release-pointer-protection.json new file mode 100644 index 00000000..b768bfaf --- /dev/null +++ b/config/rulesets/pre-release-pointer-protection.json @@ -0,0 +1,36 @@ +{ + "name": "pre-release-pointer-protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/pre-release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "non_fast_forward" + }, + { + "type": "update" + } + ], + "bypass_actors": [ + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + }, + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ] +} diff --git a/config/rulesets/release-pointer-protection.json b/config/rulesets/release-pointer-protection.json new file mode 100644 index 00000000..a18e7886 --- /dev/null +++ b/config/rulesets/release-pointer-protection.json @@ -0,0 +1,39 @@ +{ + "name": "release-pointer-protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "update" + } + ], + "bypass_actors": [ + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + }, + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ] +} diff --git a/config/rulesets/release-snapshot-protection.json b/config/rulesets/release-snapshot-protection.json new file mode 100644 index 00000000..c376fcfd --- /dev/null +++ b/config/rulesets/release-snapshot-protection.json @@ -0,0 +1,68 @@ +{ + "name": "release-snapshot-protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release-snapshot/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [ + { + "minimum_approvals": 1, + "file_patterns": [ + "*" + ], + "reviewer": { + "id": 13109132, + "type": "Team" + } + } + ], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + }, + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ] +} diff --git a/config/rulesets/release-tag-protection.json b/config/rulesets/release-tag-protection.json new file mode 100644 index 00000000..03e602c6 --- /dev/null +++ b/config/rulesets/release-tag-protection.json @@ -0,0 +1,39 @@ +{ + "name": "release-tag-protection", + "target": "tag", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/tags/r*" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "update" + } + ], + "bypass_actors": [ + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + }, + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ] +} diff --git a/scripts/apply-release-rulesets.sh b/scripts/apply-release-rulesets.sh deleted file mode 100755 index ae109d00..00000000 --- a/scripts/apply-release-rulesets.sh +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env bash -# ========================================================================================= -# CAMARA Project - Admin Script: Apply Release Automation Ruleset -# -# Creates or updates the repository rulesets required by the release automation. -# Also removes legacy rulesets from earlier versions if present. -# Idempotent: safe to run multiple times on the same repository. -# -# Rulesets managed: -# 1. release-snapshot-protection: Protects release-snapshot/** branches -# - Only the camara-release-automation GitHub App can create/delete branches -# - All changes must go through PRs with 2 approvals, code owner review, and RM team approval -# 2. release-pointer-protection: Protects release/** pointer branches (fully immutable) -# 3. pre-release-pointer-protection: Protects pre-release/** pointer branches -# (immutable but deletable by codeowners) -# -# PREREQUISITES: -# - gh CLI authenticated with a Fine-grained PAT that has: -# - Repository Administration: Read and Write -# - Organization: Read (for listing repos) -# -# USAGE: -# ./apply-release-rulesets.sh --repos "ReleaseTest,QualityOnDemand" [--org camaraproject] [--dry-run] -# ./apply-release-rulesets.sh --repos "ReleaseTest" --dry-run -# -# REFERENCE: -# The canonical ruleset was created manually in Template_API_Repository and serves -# as the reference. This script replicates that configuration to existing repos. -# See: camaraproject/tooling release_automation/docs/repository-setup.md -# -# ========================================================================================= - -set -euo pipefail - -# Defaults -ORG="camaraproject" -DRY_RUN=false -REPOS="" - -# camara-release-automation GitHub App actor_id (same as App ID) -# Verified from Template_API_Repository ruleset extraction -APP_ACTOR_ID=2865881 - -# release-management_reviewers team ID (required reviewer for Release PRs) -RM_REVIEWERS_TEAM_ID=13109132 - -usage() { - echo "Usage: $0 --repos [--org ] [--dry-run]" - echo "" - echo "Options:" - echo " --repos Comma-separated list of repository names (required)" - echo " --org GitHub organization (default: camaraproject)" - echo " --dry-run Report what would be done without applying changes" - echo "" - echo "Examples:" - echo " $0 --repos ReleaseTest --dry-run" - echo " $0 --repos 'ReleaseTest,QualityOnDemand' --org camaraproject" - exit 1 -} - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --repos) REPOS="$2"; shift 2 ;; - --org) ORG="$2"; shift 2 ;; - --dry-run) DRY_RUN=true; shift ;; - -h|--help) usage ;; - *) echo "Unknown option: $1"; usage ;; - esac -done - -if [ -z "$REPOS" ]; then - echo "Error: --repos is required" - usage -fi - -# ── Ruleset Definition ─────────────────────────────────────────────────────── - -# Single combined ruleset: branch protection + PR review requirements -# Matches the manually created ruleset in Template_API_Repository -ruleset_snapshot_protection() { - cat </dev/null || echo "") - - if [ -n "$existing_id" ]; then - if [ "$DRY_RUN" = true ]; then - echo " [dry-run] Would UPDATE ruleset '${ruleset_name}' (id: ${existing_id})" - else - echo "$payload" | gh api -X PUT "repos/${ORG}/${repo}/rulesets/${existing_id}" \ - --input - -H "Accept: application/vnd.github+json" > /dev/null - echo " Updated ruleset '${ruleset_name}' (id: ${existing_id})" - fi - else - if [ "$DRY_RUN" = true ]; then - echo " [dry-run] Would CREATE ruleset '${ruleset_name}'" - else - local new_id - new_id=$(echo "$payload" | gh api -X POST "repos/${ORG}/${repo}/rulesets" \ - --input - -H "Accept: application/vnd.github+json" --jq '.id') - echo " Created ruleset '${ruleset_name}' (id: ${new_id})" - fi - fi -} - -# Remove a legacy ruleset if it exists -remove_legacy_ruleset() { - local repo="$1" - local ruleset_name="$2" - - local legacy_id - legacy_id=$(gh api "repos/${ORG}/${repo}/rulesets" \ - --jq ".[] | select(.name == \"${ruleset_name}\") | .id" 2>/dev/null || echo "") - - if [ -n "$legacy_id" ]; then - if [ "$DRY_RUN" = true ]; then - echo " [dry-run] Would DELETE legacy ruleset '${ruleset_name}' (id: ${legacy_id})" - else - gh api -X DELETE "repos/${ORG}/${repo}/rulesets/${legacy_id}" \ - -H "Accept: application/vnd.github+json" > /dev/null - echo " Deleted legacy ruleset '${ruleset_name}' (id: ${legacy_id})" - fi - fi -} - -# ── Main ───────────────────────────────────────────────────────────────────── - -echo "=== Release Automation Ruleset ===" -echo "Organization: ${ORG}" -echo "Mode: $([ "$DRY_RUN" = true ] && echo 'DRY RUN' || echo 'APPLY')" -echo "" - -# Parse comma-separated repos -IFS=',' read -ra REPO_LIST <<< "$REPOS" - -TOTAL=0 -SUCCESS=0 -FAILED=0 - -for repo in "${REPO_LIST[@]}"; do - repo=$(echo "$repo" | xargs) # trim whitespace - [ -z "$repo" ] && continue - - echo "Repository: ${ORG}/${repo}" - - # Verify repository exists and is accessible - if ! gh api "repos/${ORG}/${repo}" --jq '.name' > /dev/null 2>&1; then - echo " ERROR: Repository not found or not accessible" - FAILED=$((FAILED + 1)) - TOTAL=$((TOTAL + 1)) - continue - fi - - REPO_OK=true - - # Apply snapshot protection ruleset - payload=$(ruleset_snapshot_protection) - ruleset_name=$(echo "$payload" | jq -r '.name') - - if ! apply_ruleset "$repo" "$ruleset_name" "$payload"; then - echo " ERROR: Failed to apply '${ruleset_name}'" - REPO_OK=false - fi - - # Apply release pointer protection ruleset - payload=$(ruleset_release_pointer_protection) - ruleset_name=$(echo "$payload" | jq -r '.name') - - if ! apply_ruleset "$repo" "$ruleset_name" "$payload"; then - echo " ERROR: Failed to apply '${ruleset_name}'" - REPO_OK=false - fi - - # Apply pre-release pointer protection ruleset - payload=$(ruleset_pre_release_pointer_protection) - ruleset_name=$(echo "$payload" | jq -r '.name') - - if ! apply_ruleset "$repo" "$ruleset_name" "$payload"; then - echo " ERROR: Failed to apply '${ruleset_name}'" - REPO_OK=false - fi - - # Remove legacy rulesets from earlier versions (if present) - for legacy_name in "release-review-protection" "release-snapshot-pr-rules"; do - if ! remove_legacy_ruleset "$repo" "$legacy_name"; then - echo " WARNING: Failed to remove legacy ruleset '${legacy_name}'" - fi - done - - if [ "$REPO_OK" = true ]; then - SUCCESS=$((SUCCESS + 1)) - else - FAILED=$((FAILED + 1)) - fi - TOTAL=$((TOTAL + 1)) - echo "" -done - -echo "=== Summary ===" -echo "Total: ${TOTAL} | Success: ${SUCCESS} | Failed: ${FAILED}" -echo "Mode: $([ "$DRY_RUN" = true ] && echo 'DRY RUN (no changes applied)' || echo 'APPLIED')" From 2affdcf01dc09b16e07e3d71ac5200ab195bf9c5 Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:50:09 +0200 Subject: [PATCH 2/4] feat(repository-config): add plan and apply tool for rulesets and branch protection --- workflows/repository-config/README.md | 72 ++++ .../repository-config/scripts/__init__.py | 0 workflows/repository-config/scripts/cli.py | 117 +++++++ .../repository-config/scripts/codeowners.py | 31 ++ workflows/repository-config/scripts/config.py | 95 +++++ .../repository-config/scripts/github_api.py | 160 +++++++++ .../repository-config/scripts/normalise.py | 38 ++ .../repository-config/scripts/planner.py | 178 ++++++++++ workflows/repository-config/scripts/report.py | 71 ++++ workflows/repository-config/tests/__init__.py | 0 workflows/repository-config/tests/conftest.py | 23 ++ workflows/repository-config/tests/fakes.py | 87 +++++ .../live_release_snapshot_protection.json | 1 + .../repos/ApplicationEndpointDiscovery.json | 327 ++++++++++++++++++ .../fixtures/repos/ConnectedNetworkType.json | 327 ++++++++++++++++++ .../repos/ConnectivityQualityManagement.json | 80 +++++ .../tests/fixtures/repos/EdgeCloud.json | 237 +++++++++++++ .../tests/fixtures/repos/HomeDevicesQoD.json | 10 + .../fixtures/repos/ReleaseManagement.json | 48 +++ .../tests/fixtures/repos/ReleaseTest.json | 317 +++++++++++++++++ .../tests/fixtures/repos/SimSwap.json | 237 +++++++++++++ .../tests/fixtures/repos/SponsoredData.json | 327 ++++++++++++++++++ .../repository-config/tests/test_apply.py | 39 +++ workflows/repository-config/tests/test_cli.py | 116 +++++++ .../tests/test_codeowners.py | 50 +++ .../repository-config/tests/test_config.py | 104 ++++++ .../tests/test_github_api.py | 157 +++++++++ .../repository-config/tests/test_normalise.py | 52 +++ .../repository-config/tests/test_planner.py | 180 ++++++++++ 29 files changed, 3481 insertions(+) create mode 100644 workflows/repository-config/README.md create mode 100644 workflows/repository-config/scripts/__init__.py create mode 100644 workflows/repository-config/scripts/cli.py create mode 100644 workflows/repository-config/scripts/codeowners.py create mode 100644 workflows/repository-config/scripts/config.py create mode 100644 workflows/repository-config/scripts/github_api.py create mode 100644 workflows/repository-config/scripts/normalise.py create mode 100644 workflows/repository-config/scripts/planner.py create mode 100644 workflows/repository-config/scripts/report.py create mode 100644 workflows/repository-config/tests/__init__.py create mode 100644 workflows/repository-config/tests/conftest.py create mode 100644 workflows/repository-config/tests/fakes.py create mode 100644 workflows/repository-config/tests/fixtures/live_release_snapshot_protection.json create mode 100644 workflows/repository-config/tests/fixtures/repos/ApplicationEndpointDiscovery.json create mode 100644 workflows/repository-config/tests/fixtures/repos/ConnectedNetworkType.json create mode 100644 workflows/repository-config/tests/fixtures/repos/ConnectivityQualityManagement.json create mode 100644 workflows/repository-config/tests/fixtures/repos/EdgeCloud.json create mode 100644 workflows/repository-config/tests/fixtures/repos/HomeDevicesQoD.json create mode 100644 workflows/repository-config/tests/fixtures/repos/ReleaseManagement.json create mode 100644 workflows/repository-config/tests/fixtures/repos/ReleaseTest.json create mode 100644 workflows/repository-config/tests/fixtures/repos/SimSwap.json create mode 100644 workflows/repository-config/tests/fixtures/repos/SponsoredData.json create mode 100644 workflows/repository-config/tests/test_apply.py create mode 100644 workflows/repository-config/tests/test_cli.py create mode 100644 workflows/repository-config/tests/test_codeowners.py create mode 100644 workflows/repository-config/tests/test_config.py create mode 100644 workflows/repository-config/tests/test_github_api.py create mode 100644 workflows/repository-config/tests/test_normalise.py create mode 100644 workflows/repository-config/tests/test_planner.py diff --git a/workflows/repository-config/README.md b/workflows/repository-config/README.md new file mode 100644 index 00000000..cbc87cba --- /dev/null +++ b/workflows/repository-config/README.md @@ -0,0 +1,72 @@ +# Repository Configuration + +Declares repository rulesets and `main` branch protection once, and reports where a repository differs. + +## Declared configuration + +All declarations live in [config/](../../config/): + +| File | Content | +|---|---| +| `repositories.yaml` | One entry per organisation repository: `class`, `single_codeowner`, `archived` | +| `repository-classes.yaml` | Which rulesets each class carries, the `main` rulesets, the retired ruleset names | +| `rulesets/.json` | The ruleset as GitHub returns it, without the fields GitHub sets itself | + +Classes: + +- `api-repository`: the release rulesets, `release-tag-protection` and the two `main` rulesets. +- `non-api`: the two `main` rulesets. +- `unmanaged`: not touched. + +The `main` rulesets are `Only_Codeowner_Can_Merge` (code owner review, no further approval) and `Codeowner_review_required` (one approval). A repository with `single_codeowner: true` carries only the first; `plan` compares the flag with the `*` line of the default branch's `CODEOWNERS`, where a team counts as several people. + +A GitHub repository without an entry in `repositories.yaml` is reported as `unregistered`. Archived repositories are skipped; the `archived` flag is compared with GitHub. + +### Bypass actors + +Actor IDs are literal in the ruleset files. + +| `actor_id` | `actor_type` | Actor | +|---|---|---| +| `2865881` | `Integration` | `camara-release-automation` GitHub App | +| `13109132` | `Team` (required reviewer) | `release-management_reviewers` | +| `null` | `OrganizationAdmin` | Organisation administrators | + +## What `plan` reports + +Per repository, rulesets are matched by name: + +| Live ruleset | Action | +|---|---| +| declared for the class, missing | `create` | +| declared for the class, content differs | `update`, with a diff | +| declared for another class, or retired | `remove` | +| any other name | `unmanaged`, listed and left alone | + +Classic branch protection on the default branch is reported as `remove-classic-protection`. `apply` removes it only after the declared `main` rulesets are confirmed `active` on that repository. + +Comparison drops `id`, `source`, `source_type`, `node_id`, `_links`, `created_at`, `updated_at` and `current_user_can_bypass`, sorts `rules` and `bypass_actors`, and compares everything else exactly, `enforcement` included. + +## Usage + +Run from this directory. Authentication is `GITHUB_TOKEN`, or the `gh` login if unset. + +```bash +python -m scripts.cli plan [--repos A,B] [--verbose] +python -m scripts.cli apply --repos A,B [--yes] +python -m scripts.cli export --repo A --ruleset NAME +``` + +- `plan` exits 0 without drift, 2 with drift, 1 on error. +- `apply` needs a repository list, re-plans each repository, shows the plan and asks before changing anything. Order: create and update, then remove, then classic protection. +- `export` writes a live ruleset to `config/rulesets/NAME.json`. Reading `bypass_actors` needs write access to the ruleset. + +The workflow [repository-config-plan.yml](../../.github/workflows/repository-config-plan.yml) runs `plan` weekly and on dispatch with the `camara-repository-config` GitHub App and fails on drift. `apply` runs from the command line only. + +## Tests + +```bash +python -m pytest workflows/repository-config/tests +``` + +Tests run against API responses recorded in `tests/fixtures/repos/`. diff --git a/workflows/repository-config/scripts/__init__.py b/workflows/repository-config/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workflows/repository-config/scripts/cli.py b/workflows/repository-config/scripts/cli.py new file mode 100644 index 00000000..8b65fc11 --- /dev/null +++ b/workflows/repository-config/scripts/cli.py @@ -0,0 +1,117 @@ +"""Command line: plan, apply, export. + +Exit codes of ``plan``: 0 no drift, 2 drift, 1 error. +""" + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable, List, Optional + +from .config import DEFAULT_CONFIG_DIR, ConfigError, load_config +from .github_api import GitHubAPI, GitHubError +from .normalise import normalise +from .planner import UNMANAGED_RULESET, apply_plan, plan_all +from .report import format_markdown, format_plan, format_repo + +DEFAULT_ORG = "camaraproject" + + +def _repo_list(value: str) -> List[str]: + return [r.strip() for r in value.split(",") if r.strip()] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="repository-config", description=__doc__) + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--org", default=DEFAULT_ORG) + common.add_argument("--config-dir", type=Path, default=DEFAULT_CONFIG_DIR) + sub = parser.add_subparsers(dest="command", required=True) + + plan = sub.add_parser("plan", parents=[common], help="report drift between the declared configuration and GitHub") + plan.add_argument("--repos", type=_repo_list, help="comma-separated names (default: all registry entries)") + plan.add_argument("--markdown", type=Path, help="append a Markdown summary to this file") + plan.add_argument("--verbose", action="store_true", help="also list repositories without drift") + + apply = sub.add_parser("apply", parents=[common], help="bring the named repositories to the declared state") + apply.add_argument("--repos", type=_repo_list, required=True, help="comma-separated names (required)") + apply.add_argument("--yes", action="store_true", help="do not ask for confirmation") + + export = sub.add_parser("export", parents=[common], help="write a live ruleset as a declared file") + export.add_argument("--repo", required=True) + export.add_argument("--ruleset", required=True, help="ruleset name") + export.add_argument("--output-dir", type=Path, help="default: /rulesets") + return parser + + +def _plan(args, api, cfg, out) -> int: + plans = plan_all(api, cfg, args.org, only=args.repos) + out(format_plan(plans, verbose=args.verbose)) + calls = getattr(api, "api_calls", None) + if calls is not None: + out(f"API calls: {calls}") + if args.markdown: + with args.markdown.open("a") as handle: + handle.write(format_markdown(plans) + "\n") + if any(p.error for p in plans): + return 1 + return 2 if any(p.drift for p in plans) else 0 + + +def _apply(args, api, cfg, out, ask) -> int: + code = 0 + for repo in args.repos: + plan = plan_all(api, cfg, args.org, only=[repo])[0] + if plan.error: + out(format_repo(plan)) + code = 1 + continue + if not any(a.kind != UNMANAGED_RULESET for a in plan.actions): + if plan.findings: + out(format_repo(plan)) + out(f"{repo}: nothing to do") + continue + out(format_repo(plan)) + if not args.yes and ask(f"Apply to {repo}? [y/N] ").strip().lower() != "y": + out(f"{repo}: declined") + code = 1 + continue + try: + apply_plan(api, cfg, args.org, plan, log=lambda message, repo=repo: out(f"{repo}: {message}")) + except GitHubError as exc: + out(f"{repo}: ERROR {exc}") + code = 1 + return code + + +def _export(args, api, cfg, out) -> int: + matches = [r for r in api.list_rulesets(args.org, args.repo) if r["name"] == args.ruleset] + if len(matches) != 1: + out(f"{args.repo}: expected one ruleset named {args.ruleset}, found {len(matches)}") + return 1 + body = normalise(api.get_ruleset(args.org, args.repo, matches[0]["id"])) + target = (args.output_dir or args.config_dir / "rulesets") / f"{args.ruleset}.json" + target.write_text(json.dumps(body, indent=2) + "\n") + out(f"wrote {target}") + return 0 + + +def main(argv: Optional[List[str]] = None, api=None, out: Callable[[str], None] = print, + ask: Optional[Callable[[str], str]] = input) -> int: + args = build_parser().parse_args(argv) + try: + cfg = load_config(args.config_dir) if args.command != "export" else None + api = api or GitHubAPI() + if args.command == "plan": + return _plan(args, api, cfg, out) + if args.command == "apply": + return _apply(args, api, cfg, out, ask) + return _export(args, api, cfg, out) + except (ConfigError, GitHubError) as exc: + out(f"ERROR {exc}") + return 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/workflows/repository-config/scripts/codeowners.py b/workflows/repository-config/scripts/codeowners.py new file mode 100644 index 00000000..61e3c50e --- /dev/null +++ b/workflows/repository-config/scripts/codeowners.py @@ -0,0 +1,31 @@ +"""Default-owner check on a CODEOWNERS file. + +Only the ``*`` line matters: the last one wins, as in GitHub's own matching. +A team counts as several people. +""" + +from typing import List, Optional + + +def default_owners(text: str) -> Optional[List[str]]: + """Owners of the last ``*`` line, or None when the file has none.""" + owners: Optional[List[str]] = None + for raw in text.splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + pattern, *rest = line.split() + if pattern == "*": + owners = rest + return owners + + +def is_single_codeowner(text: str) -> Optional[bool]: + """True for exactly one user owner on the ``*`` line; None when undetermined.""" + owners = default_owners(text) + if not owners: + return None + if len(owners) > 1: + return False + owner = owners[0] + return not (owner.startswith("@") and "/" in owner) diff --git a/workflows/repository-config/scripts/config.py b/workflows/repository-config/scripts/config.py new file mode 100644 index 00000000..a6d3bf65 --- /dev/null +++ b/workflows/repository-config/scripts/config.py @@ -0,0 +1,95 @@ +"""Load the declared configuration: rulesets, repository classes, repo registry.""" + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List + +import yaml + +from .normalise import normalise + +# /config, from workflows/repository-config/scripts/config.py +DEFAULT_CONFIG_DIR = Path(__file__).resolve().parents[3] / "config" + +UNMANAGED = "unmanaged" + + +class ConfigError(Exception): + """The declared configuration is inconsistent.""" + + +@dataclass(frozen=True) +class RepoEntry: + name: str + cls: str + single_codeowner: bool = False + archived: bool = False + + +@dataclass +class Config: + rulesets: Dict[str, Dict[str, Any]] + classes: Dict[str, List[str]] + main_rulesets: List[str] + single_codeowner_excludes: List[str] + retired: List[str] + registry: Dict[str, RepoEntry] = field(default_factory=dict) + + def desired_rulesets(self, entry: RepoEntry) -> List[str]: + """Ruleset names the repository's class declares, minus its exclusions.""" + names = self.classes.get(entry.cls, []) + if entry.single_codeowner: + names = [n for n in names if n not in self.single_codeowner_excludes] + return list(names) + + +def _load_yaml(path: Path) -> Dict[str, Any]: + try: + return yaml.safe_load(path.read_text()) or {} + except (OSError, yaml.YAMLError) as exc: + raise ConfigError(f"{path}: {exc}") from exc + + +def load_config(config_dir: Path = DEFAULT_CONFIG_DIR) -> Config: + config_dir = Path(config_dir) + + rulesets: Dict[str, Dict[str, Any]] = {} + for path in sorted((config_dir / "rulesets").glob("*.json")): + body = json.loads(path.read_text()) + if body.get("name") != path.stem: + raise ConfigError(f"{path.name}: name '{body.get('name')}' differs from file '{path.stem}'") + if "bypass_actors" not in body: + raise ConfigError(f"{path.name}: bypass_actors missing (export with write access)") + rulesets[path.stem] = normalise(body) + + raw = _load_yaml(config_dir / "repository-classes.yaml") + classes = {name: list(spec.get("rulesets", [])) for name, spec in (raw.get("classes") or {}).items()} + cfg = Config( + rulesets=rulesets, + classes=classes, + main_rulesets=list(raw.get("main_rulesets") or []), + single_codeowner_excludes=list(raw.get("single_codeowner_excludes") or []), + retired=list(raw.get("retired") or []), + ) + + for cls, names in classes.items(): + for name in names: + if name not in rulesets: + raise ConfigError(f"class {cls}: ruleset '{name}' has no file in rulesets/") + for name in cfg.retired: + if name in rulesets or any(name in names for names in classes.values()): + raise ConfigError(f"'{name}' is both declared and retired") + + registry = (_load_yaml(config_dir / "repositories.yaml")).get("repositories") or {} + for name, spec in registry.items(): + cls = spec.get("class") + if cls != UNMANAGED and cls not in classes: + raise ConfigError(f"repository {name}: unknown class '{cls}'") + cfg.registry[name] = RepoEntry( + name=name, + cls=cls, + single_codeowner=bool(spec.get("single_codeowner", False)), + archived=bool(spec.get("archived", False)), + ) + return cfg diff --git a/workflows/repository-config/scripts/github_api.py b/workflows/repository-config/scripts/github_api.py new file mode 100644 index 00000000..27aeb129 --- /dev/null +++ b/workflows/repository-config/scripts/github_api.py @@ -0,0 +1,160 @@ +"""Thin GitHub REST client for the repository-config tool. + +Reads are ``plan``; the write methods are only called by ``apply``. +""" + +import base64 +import os +import subprocess +import time +from typing import Any, Dict, List, Optional + +import requests + +API = "https://api.github.com" +RETRY_STATUS_CODES = frozenset({502, 503, 504}) +RETRY_BACKOFF_SECONDS = (1, 2, 4) +# GitHub's own lookup order for CODEOWNERS. +CODEOWNERS_PATHS = (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS") + + +class GitHubError(Exception): + """An API call failed.""" + + +class RepoNotFound(GitHubError): + """The repository (or the resource's parent) does not exist.""" + + +class MissingBypassActors(GitHubError): + """GET ruleset omitted ``bypass_actors``: the caller lacks write access to it.""" + + +def resolve_token() -> str: + """GITHUB_TOKEN, else the operator's ``gh`` login.""" + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + return token + try: + out = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=True) + except (OSError, subprocess.CalledProcessError) as exc: + raise GitHubError("no GITHUB_TOKEN and `gh auth token` failed") from exc + return out.stdout.strip() + + +class GitHubAPI: + def __init__(self, token: Optional[str] = None, session=None, sleep=time.sleep): + self.session = session or requests.Session() + self.session.headers["Authorization"] = f"Bearer {token or resolve_token()}" + self.session.headers["Accept"] = "application/vnd.github+json" + self.session.headers["X-GitHub-Api-Version"] = "2022-11-28" + self._sleep = sleep + self.api_calls = 0 + + # -- transport --------------------------------------------------------- + + def _request(self, method: str, path: str, **kwargs): + url = path if path.startswith("http") else f"{API}{path}" + for attempt in range(len(RETRY_BACKOFF_SECONDS) + 1): + self.api_calls += 1 + try: + resp = self.session.request(method, url, timeout=30, **kwargs) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + if attempt < len(RETRY_BACKOFF_SECONDS): + self._sleep(RETRY_BACKOFF_SECONDS[attempt]) + continue + raise + if resp.status_code in RETRY_STATUS_CODES and attempt < len(RETRY_BACKOFF_SECONDS): + self._sleep(RETRY_BACKOFF_SECONDS[attempt]) + continue + return resp + return resp # pragma: no cover + + @staticmethod + def _message(resp) -> str: + try: + return str(resp.json().get("message", resp.text)) + except (ValueError, AttributeError): + return resp.text + + def _check(self, resp, what: str): + if resp.status_code == 403 and resp.headers.get("X-RateLimit-Remaining") == "0": + raise GitHubError(f"{what}: API rate limit exhausted") + if resp.status_code == 404: + raise RepoNotFound(f"{what}: {self._message(resp)}") + if not resp.ok: + raise GitHubError(f"{what}: HTTP {resp.status_code} {self._message(resp)}") + return resp + + def _get_json(self, path: str, what: str) -> Any: + return self._check(self._request("GET", path), what).json() + + def _paginate(self, path: str, what: str) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + url: Optional[str] = path + while url: + resp = self._check(self._request("GET", url), what) + items.extend(resp.json()) + url = None + for part in resp.headers.get("Link", "").split(","): + if 'rel="next"' in part: + url = part[part.index("<") + 1:part.index(">")] + return items + + # -- reads ------------------------------------------------------------- + + def list_org_repos(self, org: str) -> List[Dict[str, Any]]: + return self._paginate(f"/orgs/{org}/repos?per_page=100&type=all", f"list repos of {org}") + + def get_repo(self, org: str, repo: str) -> Dict[str, Any]: + return self._get_json(f"/repos/{org}/{repo}", f"get repo {repo}") + + def list_rulesets(self, org: str, repo: str) -> List[Dict[str, Any]]: + """Summaries only (id, name, target, enforcement); use get_ruleset for content.""" + return self._paginate(f"/repos/{org}/{repo}/rulesets?per_page=100", f"list rulesets of {repo}") + + def get_ruleset(self, org: str, repo: str, ruleset_id: int) -> Dict[str, Any]: + body = self._get_json(f"/repos/{org}/{repo}/rulesets/{ruleset_id}", f"get ruleset {ruleset_id} of {repo}") + if "bypass_actors" not in body: + raise MissingBypassActors( + f"{repo} ruleset {ruleset_id}: bypass_actors missing from the response " + "(token needs write access to the ruleset)" + ) + return body + + def get_classic_protection(self, org: str, repo: str, branch: str) -> Optional[Dict[str, Any]]: + """Classic branch protection; None when the branch has none. + + "Branch not protected" is told apart from a missing repo by its message. + """ + resp = self._request("GET", f"/repos/{org}/{repo}/branches/{branch}/protection") + if resp.status_code == 404 and self._message(resp) == "Branch not protected": + return None + return self._check(resp, f"get protection of {repo}@{branch}").json() + + def get_codeowners(self, org: str, repo: str, ref: str) -> Optional[str]: + for path in CODEOWNERS_PATHS: + resp = self._request("GET", f"/repos/{org}/{repo}/contents/{path}?ref={ref}") + if resp.status_code == 404: + continue + body = self._check(resp, f"get {path} of {repo}").json() + return base64.b64decode(body["content"]).decode("utf-8") + return None + + # -- writes (apply only) ------------------------------------------------- + + def create_ruleset(self, org: str, repo: str, payload: Dict[str, Any]) -> Dict[str, Any]: + resp = self._request("POST", f"/repos/{org}/{repo}/rulesets", json=payload) + return self._check(resp, f"create ruleset {payload.get('name')} on {repo}").json() + + def update_ruleset(self, org: str, repo: str, ruleset_id: int, payload: Dict[str, Any]) -> None: + resp = self._request("PUT", f"/repos/{org}/{repo}/rulesets/{ruleset_id}", json=payload) + self._check(resp, f"update ruleset {ruleset_id} of {repo}") + + def delete_ruleset(self, org: str, repo: str, ruleset_id: int) -> None: + resp = self._request("DELETE", f"/repos/{org}/{repo}/rulesets/{ruleset_id}") + self._check(resp, f"delete ruleset {ruleset_id} of {repo}") + + def delete_classic_protection(self, org: str, repo: str, branch: str) -> None: + resp = self._request("DELETE", f"/repos/{org}/{repo}/branches/{branch}/protection") + self._check(resp, f"delete protection of {repo}@{branch}") diff --git a/workflows/repository-config/scripts/normalise.py b/workflows/repository-config/scripts/normalise.py new file mode 100644 index 00000000..93750239 --- /dev/null +++ b/workflows/repository-config/scripts/normalise.py @@ -0,0 +1,38 @@ +"""Normalise a GitHub ruleset for export and comparison. + +One function serves both: declared files are written in normalised form and +live rulesets are normalised before they are compared with them. +""" + +import copy +from typing import Any, Dict + +# Fields GitHub sets itself; they never belong to the declared state. +VOLATILE_KEYS = ( + "id", + "source", + "source_type", + "node_id", + "_links", + "created_at", + "updated_at", + "current_user_can_bypass", +) + + +def normalise(ruleset: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy without volatile fields; rules and bypass_actors sorted. + + Everything else is kept exactly, ``enforcement`` included. + """ + result = copy.deepcopy(ruleset) + for key in VOLATILE_KEYS: + result.pop(key, None) + if "rules" in result: + result["rules"] = sorted(result["rules"], key=lambda r: r["type"]) + if "bypass_actors" in result: + result["bypass_actors"] = sorted( + result["bypass_actors"], + key=lambda a: (a["actor_type"], a["actor_id"] if a["actor_id"] is not None else -1), + ) + return result diff --git a/workflows/repository-config/scripts/planner.py b/workflows/repository-config/scripts/planner.py new file mode 100644 index 00000000..ed9d6ee6 --- /dev/null +++ b/workflows/repository-config/scripts/planner.py @@ -0,0 +1,178 @@ +"""Plan and apply the declared rulesets and classic branch protection. + +``plan_*`` only reads. ``apply_plan`` runs a plan: create/update first, then +remove, classic protection last and only once the declared ``main`` rulesets +are confirmed active. +""" + +import difflib +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .codeowners import is_single_codeowner +from .config import UNMANAGED, Config, RepoEntry +from .github_api import GitHubError +from .normalise import normalise + +CREATE = "create" +UPDATE = "update" +REMOVE = "remove" +UNMANAGED_RULESET = "unmanaged" +REMOVE_CLASSIC = "remove-classic-protection" + + +@dataclass +class Action: + kind: str + ruleset: Optional[str] = None + ruleset_id: Optional[int] = None + diff: str = "" + reason: str = "" + + +@dataclass +class RepoPlan: + repo: str + entry: Optional[RepoEntry] = None + default_branch: str = "" + actions: List[Action] = field(default_factory=list) + findings: List[str] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + skipped: str = "" + error: str = "" + + @property + def drift(self) -> bool: + return bool(self.findings) or any(a.kind != UNMANAGED_RULESET for a in self.actions) + + +def ruleset_diff(declared: Dict[str, Any], live: Dict[str, Any]) -> str: + """Unified diff of the two normalised rulesets; empty when equal.""" + if declared == live: + return "" + def dump(r): + return json.dumps(r, indent=2, sort_keys=True).splitlines() + return "\n".join(difflib.unified_diff(dump(live), dump(declared), "live", "declared", lineterm="", n=2)) + + +def plan_repo(api, cfg: Config, org: str, entry: RepoEntry, gh_repo: Dict[str, Any]) -> RepoPlan: + name = entry.name + plan = RepoPlan(repo=name, entry=entry, default_branch=gh_repo["default_branch"]) + + if bool(gh_repo.get("archived")) != entry.archived: + plan.findings.append( + f"archived flag mismatch (registry {str(entry.archived).lower()}, " + f"GitHub {str(bool(gh_repo.get('archived'))).lower()})" + ) + if gh_repo.get("archived"): + plan.skipped = "archived" + return plan + if entry.cls == UNMANAGED: + plan.skipped = UNMANAGED + return plan + + _check_single_codeowner(api, org, entry, plan) + + desired = cfg.desired_rulesets(entry) + declared_names = {n for names in cfg.classes.values() for n in names} + removable = (declared_names - set(desired)) | set(cfg.retired) + + live_by_name: Dict[str, List[Dict[str, Any]]] = {} + for summary in api.list_rulesets(org, name): + live_by_name.setdefault(summary["name"], []).append(summary) + + writes: List[Action] = [] + removes: List[Action] = [] + unmanaged: List[Action] = [] + + for ruleset in desired: + found = live_by_name.get(ruleset, []) + if len(found) > 1: + plan.findings.append(f"duplicate ruleset name '{ruleset}'") + elif not found: + writes.append(Action(CREATE, ruleset)) + else: + live = normalise(api.get_ruleset(org, name, found[0]["id"])) + diff = ruleset_diff(cfg.rulesets[ruleset], live) + if diff: + writes.append(Action(UPDATE, ruleset, found[0]["id"], diff)) + + for ruleset, found in sorted(live_by_name.items()): + if ruleset in desired: + continue + for summary in found: + if ruleset in removable: + reason = "retired" if ruleset in cfg.retired else "not declared for this repository" + removes.append(Action(REMOVE, ruleset, summary["id"], reason=reason)) + else: + unmanaged.append(Action(UNMANAGED_RULESET, ruleset, summary["id"])) + + plan.actions = writes + removes + unmanaged + if api.get_classic_protection(org, name, plan.default_branch) is not None: + plan.actions.append(Action(REMOVE_CLASSIC, reason=f"classic protection on {plan.default_branch}")) + return plan + + +def _check_single_codeowner(api, org: str, entry: RepoEntry, plan: RepoPlan) -> None: + text = api.get_codeowners(org, entry.name, plan.default_branch) + actual = is_single_codeowner(text) if text is not None else None + if actual is None: + plan.notes.append("CODEOWNERS has no default owner: single_codeowner not checked") + elif actual != entry.single_codeowner: + plan.findings.append( + f"single_codeowner mismatch (registry {str(entry.single_codeowner).lower()}, " + f"CODEOWNERS {str(actual).lower()})" + ) + + +def plan_all(api, cfg: Config, org: str, only: Optional[List[str]] = None) -> List[RepoPlan]: + gh_repos = {r["name"]: r for r in api.list_org_repos(org)} + names = list(only) if only else sorted(set(cfg.registry) | set(gh_repos)) + plans: List[RepoPlan] = [] + for name in names: + entry, gh_repo = cfg.registry.get(name), gh_repos.get(name) + if entry is None and gh_repo is None: + plans.append(RepoPlan(repo=name, error="not in the registry and not found on GitHub")) + elif entry is None: + plans.append(RepoPlan(repo=name, findings=["unregistered"])) + elif gh_repo is None: + plans.append(RepoPlan(repo=name, entry=entry, findings=["registered but not found on GitHub"])) + else: + try: + plans.append(plan_repo(api, cfg, org, entry, gh_repo)) + except GitHubError as exc: + plans.append(RepoPlan(repo=name, entry=entry, error=str(exc))) + return plans + + +def apply_plan(api, cfg: Config, org: str, plan: RepoPlan, log=lambda message: None) -> None: + """Execute the actions of a fresh plan for one repository.""" + name = plan.repo + for action in plan.actions: + if action.kind in (CREATE, UPDATE): + payload = cfg.rulesets[action.ruleset] + if action.kind == CREATE: + api.create_ruleset(org, name, payload) + else: + api.update_ruleset(org, name, action.ruleset_id, payload) + log(f"{action.kind} {action.ruleset}") + for action in plan.actions: + if action.kind == REMOVE: + api.delete_ruleset(org, name, action.ruleset_id) + log(f"remove {action.ruleset}") + for action in plan.actions: + if action.kind == REMOVE_CLASSIC: + _require_main_rulesets_active(api, cfg, org, plan) + api.delete_classic_protection(org, name, plan.default_branch) + log(f"remove classic protection on {plan.default_branch}") + + +def _require_main_rulesets_active(api, cfg: Config, org: str, plan: RepoPlan) -> None: + required = [n for n in cfg.main_rulesets if n in cfg.desired_rulesets(plan.entry)] + live = {r["name"]: r["enforcement"] for r in api.list_rulesets(org, plan.repo)} + missing = [n for n in required if live.get(n) != "active"] + if missing: + raise GitHubError( + f"{plan.repo}: not removing classic protection, ruleset(s) not active: {', '.join(missing)}" + ) diff --git a/workflows/repository-config/scripts/report.py b/workflows/repository-config/scripts/report.py new file mode 100644 index 00000000..40959301 --- /dev/null +++ b/workflows/repository-config/scripts/report.py @@ -0,0 +1,71 @@ +"""Render plans as terminal text and as a Markdown job summary.""" + +from typing import List + +from .planner import UNMANAGED_RULESET, RepoPlan + + +def _plain_action(action) -> str: + # Fixed two-space gap after the kind keeps `create name` greppable. + label = action.ruleset or "" + parts = [f"{action.kind} {label}".rstrip()] + if action.ruleset_id and action.kind in ("update", "remove", UNMANAGED_RULESET): + parts.append(f"(id {action.ruleset_id})") + if action.reason: + parts.append(f"- {action.reason}") + return " ".join(parts) + + +def format_repo(plan: RepoPlan) -> str: + lines = [plan.repo] + if plan.error: + lines.append(f" ERROR {plan.error}") + for finding in plan.findings: + lines.append(f" DRIFT {finding}") + if plan.skipped: + lines.append(f" skipped ({plan.skipped})") + for action in plan.actions: + lines.append(f" {_plain_action(action)}") + if action.diff: + lines.extend(f" {d}" for d in action.diff.splitlines()) + for note in plan.notes: + lines.append(f" note: {note}") + if len(lines) == 1: + lines.append(" ok") + return "\n".join(lines) + + +def summary_line(plans: List[RepoPlan]) -> str: + errors = sum(1 for p in plans if p.error) + drift = sum(1 for p in plans if p.drift and not p.error) + clean = len(plans) - errors - drift + return f"{len(plans)} repositories: {clean} clean, {drift} with drift, {errors} errors" + + +def format_plan(plans: List[RepoPlan], verbose: bool = False) -> str: + """All repositories with something to show, then the summary line.""" + blocks = [ + format_repo(p) for p in plans + if verbose or p.error or p.drift or any(a.kind == UNMANAGED_RULESET for a in p.actions) + ] + return "\n\n".join(blocks + [summary_line(plans)]) + + +def format_markdown(plans: List[RepoPlan]) -> str: + out = ["## Repository configuration plan", "", summary_line(plans), ""] + for plan in plans: + if not (plan.error or plan.drift): + continue + out.append(f"### {plan.repo}") + if plan.error: + out.append(f"- **error:** {plan.error}") + for finding in plan.findings: + out.append(f"- **drift:** {finding}") + for action in plan.actions: + if action.kind == UNMANAGED_RULESET: + continue + out.append(f"- `{_plain_action(action)}`") + if action.diff: + out += ["", " ```diff", *[f" {d}" for d in action.diff.splitlines()], " ```", ""] + out.append("") + return "\n".join(out) diff --git a/workflows/repository-config/tests/__init__.py b/workflows/repository-config/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workflows/repository-config/tests/conftest.py b/workflows/repository-config/tests/conftest.py new file mode 100644 index 00000000..61890d24 --- /dev/null +++ b/workflows/repository-config/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared fixtures for repository-config tests.""" + +import copy +import json +import sys +from pathlib import Path + +import pytest + +# Ensure the scripts package is importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +def load_fixture(name): + return json.loads((FIXTURES / name).read_text()) + + +@pytest.fixture +def live_ruleset(): + """A ruleset as GET /repos/{r}/rulesets/{id} returns it (volatile fields included).""" + return copy.deepcopy(load_fixture("live_release_snapshot_protection.json")) diff --git a/workflows/repository-config/tests/fakes.py b/workflows/repository-config/tests/fakes.py new file mode 100644 index 00000000..a4ce6e5d --- /dev/null +++ b/workflows/repository-config/tests/fakes.py @@ -0,0 +1,87 @@ +"""In-memory stand-in for GitHubAPI, built from recorded responses.""" + +import copy +import json +from pathlib import Path + +from scripts.github_api import RepoNotFound + +REPO_FIXTURES = Path(__file__).resolve().parent / "fixtures" / "repos" + + +class FakeAPI: + """Same read/write surface as GitHubAPI; writes mutate state and are logged.""" + + def __init__(self, repos, extra_repos=()): + # repos: name -> fixture dict; extra_repos: bare names with no rulesets + self.state = {name: copy.deepcopy(data) for name, data in repos.items()} + for name in extra_repos: + self.state[name] = { + "repo": {"name": name, "archived": False, "default_branch": "main"}, + "rulesets": [], + "protection": None, + "codeowners": "* @a @b\n", + } + self.calls = [] + self._next_id = 9_000_000 + + @classmethod + def from_fixtures(cls, names, extra_repos=()): + repos = {n: json.loads((REPO_FIXTURES / f"{n}.json").read_text()) for n in names} + return cls(repos, extra_repos) + + def _repo(self, repo): + if repo not in self.state: + raise RepoNotFound(repo) + return self.state[repo] + + # reads + def list_org_repos(self, org): + return [dict(d["repo"]) for d in self.state.values()] + + def get_repo(self, org, repo): + return dict(self._repo(repo)["repo"]) + + def list_rulesets(self, org, repo): + return [ + {"id": r["id"], "name": r["name"], "target": r["target"], "enforcement": r["enforcement"]} + for r in self._repo(repo)["rulesets"] + ] + + def get_ruleset(self, org, repo, ruleset_id): + for r in self._repo(repo)["rulesets"]: + if r["id"] == ruleset_id: + return copy.deepcopy(r) + raise RepoNotFound(f"ruleset {ruleset_id}") + + def get_classic_protection(self, org, repo, branch): + return copy.deepcopy(self._repo(repo)["protection"]) + + def get_codeowners(self, org, repo, ref): + return self._repo(repo)["codeowners"] + + # writes + def create_ruleset(self, org, repo, payload): + self.calls.append(("create", repo, payload["name"])) + self._next_id += 1 + self._repo(repo)["rulesets"].append({**copy.deepcopy(payload), "id": self._next_id, "source_type": "Repository"}) + return {"id": self._next_id} + + def update_ruleset(self, org, repo, ruleset_id, payload): + self.calls.append(("update", repo, payload["name"])) + rulesets = self._repo(repo)["rulesets"] + for i, r in enumerate(rulesets): + if r["id"] == ruleset_id: + rulesets[i] = {**copy.deepcopy(payload), "id": ruleset_id, "source_type": "Repository"} + return + raise RepoNotFound(f"ruleset {ruleset_id}") + + def delete_ruleset(self, org, repo, ruleset_id): + rulesets = self._repo(repo)["rulesets"] + name = next(r["name"] for r in rulesets if r["id"] == ruleset_id) + self.calls.append(("delete", repo, name)) + self._repo(repo)["rulesets"] = [r for r in rulesets if r["id"] != ruleset_id] + + def delete_classic_protection(self, org, repo, branch): + self.calls.append(("delete-classic", repo, branch)) + self._repo(repo)["protection"] = None diff --git a/workflows/repository-config/tests/fixtures/live_release_snapshot_protection.json b/workflows/repository-config/tests/fixtures/live_release_snapshot_protection.json new file mode 100644 index 00000000..fc819b15 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/live_release_snapshot_protection.json @@ -0,0 +1 @@ +{"id":12904446,"name":"release-snapshot-protection","target":"branch","source_type":"Repository","source":"camaraproject/Template_API_Repository","enforcement":"active","conditions":{"ref_name":{"exclude":[],"include":["refs/heads/release-snapshot/**"]}},"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"creation"},{"type":"pull_request","parameters":{"required_approving_review_count":2,"dismiss_stale_reviews_on_push":true,"required_reviewers":[{"minimum_approvals":1,"file_patterns":["*"],"reviewer":{"id":13109132,"type":"Team"}}],"require_code_owner_review":true,"dismissal_restriction":{"enabled":false,"allowed_actors":[]},"require_last_push_approval":false,"required_review_thread_resolution":false,"require_extra_approval_for_unattributed_changes":true,"allowed_merge_methods":["merge","squash","rebase"]}}],"node_id":"RRS_lACqUmVwb3NpdG9yec4iCxCnzgDE5_4","created_at":"2026-02-17T13:12:27.294+01:00","updated_at":"2026-02-23T19:53:50.985+01:00","bypass_actors":[{"actor_id":null,"actor_type":"OrganizationAdmin","bypass_mode":"always"},{"actor_id":2865881,"actor_type":"Integration","bypass_mode":"always"}],"current_user_can_bypass":"always","_links":{"self":{"href":"https://api.github.com/repos/camaraproject/Template_API_Repository/rulesets/12904446"},"html":{"href":"https://github.com/camaraproject/Template_API_Repository/rules/12904446"}}} \ No newline at end of file diff --git a/workflows/repository-config/tests/fixtures/repos/ApplicationEndpointDiscovery.json b/workflows/repository-config/tests/fixtures/repos/ApplicationEndpointDiscovery.json new file mode 100644 index 00000000..e5c7e886 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/ApplicationEndpointDiscovery.json @@ -0,0 +1,327 @@ +{ + "repo": { + "name": "ApplicationEndpointDiscovery", + "archived": false, + "default_branch": "main" + }, + "rulesets": [ + { + "id": 5942964, + "name": "Codeowner_review_required", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ApplicationEndpointDiscovery", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec47hvP6zgBarrQ", + "created_at": "2025-06-09T07:36:07.276+02:00", + "updated_at": "2025-06-09T07:38:47.015+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ApplicationEndpointDiscovery/rulesets/5942964" + }, + "html": { + "href": "https://github.com/camaraproject/ApplicationEndpointDiscovery/rules/5942964" + } + } + }, + { + "id": 5942965, + "name": "Only_Codeowner_Can_Merge", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ApplicationEndpointDiscovery", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec47hvP6zgBarrU", + "created_at": "2025-06-09T07:36:07.860+02:00", + "updated_at": "2025-06-09T07:36:07.860+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ApplicationEndpointDiscovery/rulesets/5942965" + }, + "html": { + "href": "https://github.com/camaraproject/ApplicationEndpointDiscovery/rules/5942965" + } + } + }, + { + "id": 13476009, + "name": "pre-release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ApplicationEndpointDiscovery", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/pre-release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec47hvP6zgDNoKk", + "created_at": "2026-03-03T23:39:08.215+01:00", + "updated_at": "2026-03-03T23:39:08.323+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ApplicationEndpointDiscovery/rulesets/13476009" + }, + "html": { + "href": "https://github.com/camaraproject/ApplicationEndpointDiscovery/rules/13476009" + } + } + }, + { + "id": 13476008, + "name": "release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ApplicationEndpointDiscovery", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec47hvP6zgDNoKg", + "created_at": "2026-03-03T23:39:07.219+01:00", + "updated_at": "2026-03-03T23:39:07.311+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ApplicationEndpointDiscovery/rulesets/13476008" + }, + "html": { + "href": "https://github.com/camaraproject/ApplicationEndpointDiscovery/rules/13476008" + } + } + }, + { + "id": 13048142, + "name": "release-snapshot-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ApplicationEndpointDiscovery", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release-snapshot/**" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "creation" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [ + { + "minimum_approvals": 1, + "file_patterns": [ + "*" + ], + "reviewer": { + "id": 13109132, + "type": "Team" + } + } + ], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec47hvP6zgDHGU4", + "created_at": "2026-02-20T18:04:22.116+01:00", + "updated_at": "2026-02-23T20:18:20.810+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ApplicationEndpointDiscovery/rulesets/13048142" + }, + "html": { + "href": "https://github.com/camaraproject/ApplicationEndpointDiscovery/rules/13048142" + } + } + } + ], + "protection": null, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n# Replace the following line with \"* @codeowner1 @codeowner2 @codeowner3\" with the individual codeowner user names (the CODEOWNER file is the source of truth for the sub project codeowner team)\n* @gunjald @Kevsy @FabrizioMoggio @seralogar @gainsley @JoseMConde @maheshc01\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/ConnectedNetworkType.json b/workflows/repository-config/tests/fixtures/repos/ConnectedNetworkType.json new file mode 100644 index 00000000..4ffa0777 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/ConnectedNetworkType.json @@ -0,0 +1,327 @@ +{ + "repo": { + "name": "ConnectedNetworkType", + "archived": false, + "default_branch": "main" + }, + "rulesets": [ + { + "id": 5345241, + "name": "Codeowner_review_required", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ConnectedNetworkType", + "enforcement": "disabled", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec46YTUHzgBRj9k", + "created_at": "2025-05-07T16:24:45.622+02:00", + "updated_at": "2025-05-07T16:24:45.622+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ConnectedNetworkType/rulesets/5345241" + }, + "html": { + "href": "https://github.com/camaraproject/ConnectedNetworkType/rules/5345241" + } + } + }, + { + "id": 5345242, + "name": "Only_Codeowner_Can_Merge", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ConnectedNetworkType", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec46YTUHzgBRj9o", + "created_at": "2025-05-07T16:24:46.227+02:00", + "updated_at": "2025-05-07T16:24:46.227+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ConnectedNetworkType/rulesets/5345242" + }, + "html": { + "href": "https://github.com/camaraproject/ConnectedNetworkType/rules/5345242" + } + } + }, + { + "id": 13476032, + "name": "pre-release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ConnectedNetworkType", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/pre-release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec46YTUHzgDNoMA", + "created_at": "2026-03-03T23:39:40.663+01:00", + "updated_at": "2026-03-03T23:39:40.753+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ConnectedNetworkType/rulesets/13476032" + }, + "html": { + "href": "https://github.com/camaraproject/ConnectedNetworkType/rules/13476032" + } + } + }, + { + "id": 13476031, + "name": "release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ConnectedNetworkType", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec46YTUHzgDNoL8", + "created_at": "2026-03-03T23:39:39.738+01:00", + "updated_at": "2026-03-03T23:39:39.841+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ConnectedNetworkType/rulesets/13476031" + }, + "html": { + "href": "https://github.com/camaraproject/ConnectedNetworkType/rules/13476031" + } + } + }, + { + "id": 13048191, + "name": "release-snapshot-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ConnectedNetworkType", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release-snapshot/**" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "creation" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [ + { + "minimum_approvals": 1, + "file_patterns": [ + "*" + ], + "reviewer": { + "id": 13109132, + "type": "Team" + } + } + ], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec46YTUHzgDHGX8", + "created_at": "2026-02-20T18:06:14.664+01:00", + "updated_at": "2026-02-23T20:18:39.200+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ConnectedNetworkType/rulesets/13048191" + }, + "html": { + "href": "https://github.com/camaraproject/ConnectedNetworkType/rules/13048191" + } + } + } + ], + "protection": null, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n# Replace the following line with \"* @codeowner1 @codeowner2 @codeowner3\" with the individual codeowner user names (the CODEOWNER file is the source of truth for the sub project codeowner team)\n* @eric-murray @bigludo7 @sachinvodafone @akoshunyadi\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/ConnectivityQualityManagement.json b/workflows/repository-config/tests/fixtures/repos/ConnectivityQualityManagement.json new file mode 100644 index 00000000..9dbed7f8 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/ConnectivityQualityManagement.json @@ -0,0 +1,80 @@ +{ + "repo": { + "name": "ConnectivityQualityManagement", + "archived": false, + "default_branch": "main" + }, + "rulesets": [ + { + "id": 16351050, + "name": "code-owner-review-required-with-write-permission-bypass", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ConnectivityQualityManagement", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec5Jx99RzgD5f0o", + "created_at": "2026-05-13T16:54:49.529+02:00", + "updated_at": "2026-05-14T22:00:19.388+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 4, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ConnectivityQualityManagement/rulesets/16351050" + }, + "html": { + "href": "https://github.com/camaraproject/ConnectivityQualityManagement/rules/16351050" + } + } + } + ], + "protection": null, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n# Replace the following line with \"* @codeowner1 @codeowner2 @codeowner3\" with the individual codeowner user names (the CODEOWNER file is the source of truth for the sub project codeowner team)\n* @tlohmar @Masa8106 @albertoramosmonagas @hdamker\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/EdgeCloud.json b/workflows/repository-config/tests/fixtures/repos/EdgeCloud.json new file mode 100644 index 00000000..b7384a64 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/EdgeCloud.json @@ -0,0 +1,237 @@ +{ + "repo": { + "name": "EdgeCloud", + "archived": false, + "default_branch": "main" + }, + "rulesets": [ + { + "id": 13476070, + "name": "pre-release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/EdgeCloud", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/pre-release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4c2Jd1zgDNoOY", + "created_at": "2026-03-03T23:40:46.796+01:00", + "updated_at": "2026-03-03T23:40:47.030+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/EdgeCloud/rulesets/13476070" + }, + "html": { + "href": "https://github.com/camaraproject/EdgeCloud/rules/13476070" + } + } + }, + { + "id": 13476068, + "name": "release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/EdgeCloud", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4c2Jd1zgDNoOQ", + "created_at": "2026-03-03T23:40:45.613+01:00", + "updated_at": "2026-03-03T23:40:45.710+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/EdgeCloud/rulesets/13476068" + }, + "html": { + "href": "https://github.com/camaraproject/EdgeCloud/rules/13476068" + } + } + }, + { + "id": 13048235, + "name": "release-snapshot-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/EdgeCloud", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release-snapshot/**" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "creation" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [ + { + "minimum_approvals": 1, + "file_patterns": [ + "*" + ], + "reviewer": { + "id": 13109132, + "type": "Team" + } + } + ], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4c2Jd1zgDHGas", + "created_at": "2026-02-20T18:06:49.706+01:00", + "updated_at": "2026-02-23T20:19:14.396+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/EdgeCloud/rulesets/13048235" + }, + "html": { + "href": "https://github.com/camaraproject/EdgeCloud/rules/13048235" + } + } + } + ], + "protection": { + "url": "https://api.github.com/repos/camaraproject/EdgeCloud/branches/main/protection", + "required_pull_request_reviews": { + "url": "https://api.github.com/repos/camaraproject/EdgeCloud/branches/main/protection/required_pull_request_reviews", + "dismiss_stale_reviews": true, + "require_code_owner_reviews": true, + "require_last_push_approval": false, + "required_approving_review_count": 1 + }, + "required_signatures": { + "url": "https://api.github.com/repos/camaraproject/EdgeCloud/branches/main/protection/required_signatures", + "enabled": false + }, + "enforce_admins": { + "url": "https://api.github.com/repos/camaraproject/EdgeCloud/branches/main/protection/enforce_admins", + "enabled": false + }, + "required_linear_history": { + "enabled": false + }, + "allow_force_pushes": { + "enabled": false + }, + "allow_deletions": { + "enabled": false + }, + "block_creations": { + "enabled": false + }, + "required_conversation_resolution": { + "enabled": false + }, + "lock_branch": { + "enabled": false + }, + "allow_fork_syncing": { + "enabled": false + } + }, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n\n* @gunjald @kevsy @FabrizioMoggio @seralogar @gainsley @JoseMConde @maheshc01\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/HomeDevicesQoD.json b/workflows/repository-config/tests/fixtures/repos/HomeDevicesQoD.json new file mode 100644 index 00000000..ee89dd02 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/HomeDevicesQoD.json @@ -0,0 +1,10 @@ +{ + "repo": { + "name": "HomeDevicesQoD", + "archived": true, + "default_branch": "main" + }, + "rulesets": [], + "protection": null, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n\n* @jpengar @fernandopradocabrillo\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/ReleaseManagement.json b/workflows/repository-config/tests/fixtures/repos/ReleaseManagement.json new file mode 100644 index 00000000..81f91e51 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/ReleaseManagement.json @@ -0,0 +1,48 @@ +{ + "repo": { + "name": "ReleaseManagement", + "archived": false, + "default_branch": "main" + }, + "rulesets": [], + "protection": { + "url": "https://api.github.com/repos/camaraproject/ReleaseManagement/branches/main/protection", + "required_pull_request_reviews": { + "url": "https://api.github.com/repos/camaraproject/ReleaseManagement/branches/main/protection/required_pull_request_reviews", + "dismiss_stale_reviews": true, + "require_code_owner_reviews": true, + "require_last_push_approval": false, + "required_approving_review_count": 1 + }, + "required_signatures": { + "url": "https://api.github.com/repos/camaraproject/ReleaseManagement/branches/main/protection/required_signatures", + "enabled": false + }, + "enforce_admins": { + "url": "https://api.github.com/repos/camaraproject/ReleaseManagement/branches/main/protection/enforce_admins", + "enabled": false + }, + "required_linear_history": { + "enabled": false + }, + "allow_force_pushes": { + "enabled": false + }, + "allow_deletions": { + "enabled": false + }, + "block_creations": { + "enabled": false + }, + "required_conversation_resolution": { + "enabled": false + }, + "lock_branch": { + "enabled": false + }, + "allow_fork_syncing": { + "enabled": false + } + }, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n* @soadeyemo @tanjadegroot @rartych @hdamker\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/ReleaseTest.json b/workflows/repository-config/tests/fixtures/repos/ReleaseTest.json new file mode 100644 index 00000000..5b25486e --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/ReleaseTest.json @@ -0,0 +1,317 @@ +{ + "repo": { + "name": "ReleaseTest", + "archived": false, + "default_branch": "main" + }, + "rulesets": [ + { + "id": 12989592, + "name": "code-owner-review-required-or-sole-codeowner", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ReleaseTest", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec5FPCNEzgDGNJg", + "created_at": "2026-02-19T10:06:12.474+01:00", + "updated_at": "2026-02-19T10:06:12.521+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ReleaseTest/rulesets/12989592" + }, + "html": { + "href": "https://github.com/camaraproject/ReleaseTest/rules/12989592" + } + } + }, + { + "id": 13372663, + "name": "pre-release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ReleaseTest", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/pre-release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec5FPCNEzgDMDPc", + "created_at": "2026-03-01T14:12:33.683+01:00", + "updated_at": "2026-03-01T14:12:33.810+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ReleaseTest/rulesets/13372663" + }, + "html": { + "href": "https://github.com/camaraproject/ReleaseTest/rules/13372663" + } + } + }, + { + "id": 13372662, + "name": "release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ReleaseTest", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec5FPCNEzgDMDPY", + "created_at": "2026-03-01T14:12:32.651+01:00", + "updated_at": "2026-03-01T14:12:32.750+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ReleaseTest/rulesets/13372662" + }, + "html": { + "href": "https://github.com/camaraproject/ReleaseTest/rules/13372662" + } + } + }, + { + "id": 12989594, + "name": "release-snapshot-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/ReleaseTest", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release-snapshot/**" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "creation" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [ + { + "minimum_approvals": 1, + "file_patterns": [ + "*" + ], + "reviewer": { + "id": 13109132, + "type": "Team" + } + } + ], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec5FPCNEzgDGNJo", + "created_at": "2026-02-19T10:06:13.319+01:00", + "updated_at": "2026-03-01T14:15:31.542+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ReleaseTest/rulesets/12989594" + }, + "html": { + "href": "https://github.com/camaraproject/ReleaseTest/rules/12989594" + } + } + }, + { + "id": 23336380, + "name": "release-tag-protection", + "target": "tag", + "source_type": "Repository", + "source": "camaraproject/ReleaseTest", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/tags/r*" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec5FPCNEzgFkFbw", + "created_at": "2026-09-14T19:04:53.033+02:00", + "updated_at": "2026-09-14T19:04:53.125+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/ReleaseTest/rulesets/23336380" + }, + "html": { + "href": "https://github.com/camaraproject/ReleaseTest/rules/23336380" + } + } + } + ], + "protection": null, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n# Replace the following line with \"* @codeowner1 @codeowner2 @codeowner3\" with the individual codeowner user names (the CODEOWNER file is the source of truth for the sub project codeowner team)\n* @hdamker-bot\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/SimSwap.json b/workflows/repository-config/tests/fixtures/repos/SimSwap.json new file mode 100644 index 00000000..752c7f44 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/SimSwap.json @@ -0,0 +1,237 @@ +{ + "repo": { + "name": "SimSwap", + "archived": false, + "default_branch": "main" + }, + "rulesets": [ + { + "id": 13476164, + "name": "pre-release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SimSwap", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/pre-release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4g_KNkzgDNoUQ", + "created_at": "2026-03-03T23:43:03.012+01:00", + "updated_at": "2026-03-03T23:43:03.097+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SimSwap/rulesets/13476164" + }, + "html": { + "href": "https://github.com/camaraproject/SimSwap/rules/13476164" + } + } + }, + { + "id": 13476163, + "name": "release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SimSwap", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4g_KNkzgDNoUM", + "created_at": "2026-03-03T23:43:02.008+01:00", + "updated_at": "2026-03-03T23:43:02.136+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SimSwap/rulesets/13476163" + }, + "html": { + "href": "https://github.com/camaraproject/SimSwap/rules/13476163" + } + } + }, + { + "id": 13048283, + "name": "release-snapshot-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SimSwap", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release-snapshot/**" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "creation" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [ + { + "minimum_approvals": 1, + "file_patterns": [ + "*" + ], + "reviewer": { + "id": 13109132, + "type": "Team" + } + } + ], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4g_KNkzgDHGds", + "created_at": "2026-02-20T18:07:55.029+01:00", + "updated_at": "2026-02-23T20:20:21.574+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SimSwap/rulesets/13048283" + }, + "html": { + "href": "https://github.com/camaraproject/SimSwap/rules/13048283" + } + } + } + ], + "protection": { + "url": "https://api.github.com/repos/camaraproject/SimSwap/branches/main/protection", + "required_pull_request_reviews": { + "url": "https://api.github.com/repos/camaraproject/SimSwap/branches/main/protection/required_pull_request_reviews", + "dismiss_stale_reviews": true, + "require_code_owner_reviews": true, + "require_last_push_approval": false, + "required_approving_review_count": 1 + }, + "required_signatures": { + "url": "https://api.github.com/repos/camaraproject/SimSwap/branches/main/protection/required_signatures", + "enabled": false + }, + "enforce_admins": { + "url": "https://api.github.com/repos/camaraproject/SimSwap/branches/main/protection/enforce_admins", + "enabled": false + }, + "required_linear_history": { + "enabled": false + }, + "allow_force_pushes": { + "enabled": false + }, + "allow_deletions": { + "enabled": false + }, + "block_creations": { + "enabled": false + }, + "required_conversation_resolution": { + "enabled": false + }, + "lock_branch": { + "enabled": false + }, + "allow_fork_syncing": { + "enabled": false + } + }, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n\n* @bigludo7 @fernandopradocabrillo @maxl2287\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/fixtures/repos/SponsoredData.json b/workflows/repository-config/tests/fixtures/repos/SponsoredData.json new file mode 100644 index 00000000..d98b8641 --- /dev/null +++ b/workflows/repository-config/tests/fixtures/repos/SponsoredData.json @@ -0,0 +1,327 @@ +{ + "repo": { + "name": "SponsoredData", + "archived": false, + "default_branch": "main" + }, + "rulesets": [ + { + "id": 8441211, + "name": "Codeowner_review_required", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SponsoredData", + "enforcement": "disabled", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4_d4ODzgCAzXs", + "created_at": "2025-09-26T17:07:24.815+02:00", + "updated_at": "2025-09-26T17:07:24.874+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SponsoredData/rulesets/8441211" + }, + "html": { + "href": "https://github.com/camaraproject/SponsoredData/rules/8441211" + } + } + }, + { + "id": 8441214, + "name": "Only_Codeowner_Can_Merge", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SponsoredData", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4_d4ODzgCAzX4", + "created_at": "2025-09-26T17:07:25.700+02:00", + "updated_at": "2025-09-26T17:07:25.749+02:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SponsoredData/rulesets/8441214" + }, + "html": { + "href": "https://github.com/camaraproject/SponsoredData/rules/8441214" + } + } + }, + { + "id": 13476168, + "name": "pre-release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SponsoredData", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/pre-release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4_d4ODzgDNoUg", + "created_at": "2026-03-03T23:43:07.034+01:00", + "updated_at": "2026-03-03T23:43:07.133+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SponsoredData/rulesets/13476168" + }, + "html": { + "href": "https://github.com/camaraproject/SponsoredData/rules/13476168" + } + } + }, + { + "id": 13476166, + "name": "release-pointer-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SponsoredData", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**" + ] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "deletion" + }, + { + "type": "update" + }, + { + "type": "non_fast_forward" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4_d4ODzgDNoUY", + "created_at": "2026-03-03T23:43:05.978+01:00", + "updated_at": "2026-03-03T23:43:06.126+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SponsoredData/rulesets/13476166" + }, + "html": { + "href": "https://github.com/camaraproject/SponsoredData/rules/13476166" + } + } + }, + { + "id": 13048286, + "name": "release-snapshot-protection", + "target": "branch", + "source_type": "Repository", + "source": "camaraproject/SponsoredData", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release-snapshot/**" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "creation" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [ + { + "minimum_approvals": 1, + "file_patterns": [ + "*" + ], + "reviewer": { + "id": 13109132, + "type": "Team" + } + } + ], + "require_code_owner_review": true, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4_d4ODzgDHGd4", + "created_at": "2026-02-20T18:07:59.485+01:00", + "updated_at": "2026-02-23T20:20:23.791+01:00", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + }, + { + "actor_id": 2865881, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "current_user_can_bypass": "always", + "_links": { + "self": { + "href": "https://api.github.com/repos/camaraproject/SponsoredData/rulesets/13048286" + }, + "html": { + "href": "https://github.com/camaraproject/SponsoredData/rules/13048286" + } + } + } + ], + "protection": null, + "codeowners": "# This file provides an overview of code owners in this repository.\n\n# Each line is a file pattern followed by one or more owners.\n# The last matching pattern has the most precedence.\n# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/.\n\n# These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file.\n# Replace the following line with \"* @codeowner1 @codeowner2 @codeowner3\" with the individual codeowner user names (the CODEOWNER file is the source of truth for the sub project codeowner team)\n* @jafilippini1\n\n# Owners of the CODEOWNER and Maintainer.md files are the admins of CAMARA (to allow them to keep the teams within the CAMARA organization in sync in case of changes)\n/CODEOWNERS @camaraproject/admins\n/MAINTAINERS.MD @camaraproject/admins\n\n# The following lines ensure that the release-management_reviewers team will automatically added as reviewers\n# if a pull requests is changing the CHANGELOG file (aka \"release PR\") and that such PRs can only be merged with an approval from a team member.\n/CHANGELOG.MD @camaraproject/release-management_reviewers\n/CHANGELOG.md @camaraproject/release-management_reviewers\n" +} diff --git a/workflows/repository-config/tests/test_apply.py b/workflows/repository-config/tests/test_apply.py new file mode 100644 index 00000000..1078d9e0 --- /dev/null +++ b/workflows/repository-config/tests/test_apply.py @@ -0,0 +1,39 @@ +"""Tests for apply ordering and the classic-protection guard.""" + +import pytest + +from scripts.github_api import GitHubError +from scripts.planner import apply_plan, plan_all + +from .fakes import FakeAPI +from .test_planner import ORG, make_config + + +def test_apply_orders_writes_then_removes_then_classic(tmp_path): + cfg = make_config(tmp_path, "repositories:\n EdgeCloud: {class: non-api}\n") + api = FakeAPI.from_fixtures(["EdgeCloud"]) + plan = plan_all(api, cfg, ORG, only=["EdgeCloud"])[0] + apply_plan(api, cfg, ORG, plan) + ops = [c[0] for c in api.calls] + assert ops == ["create", "create", "delete", "delete", "delete", "delete-classic"] + + +def test_classic_protection_kept_unless_main_rulesets_are_active(tmp_path): + cfg = make_config(tmp_path, "repositories:\n ReleaseManagement: {class: non-api}\n") + cfg.rulesets["Only_Codeowner_Can_Merge"]["enforcement"] = "disabled" + api = FakeAPI.from_fixtures(["ReleaseManagement"]) + plan = plan_all(api, cfg, ORG, only=["ReleaseManagement"])[0] + with pytest.raises(GitHubError, match="not active: Only_Codeowner_Can_Merge"): + apply_plan(api, cfg, ORG, plan) + assert ("delete-classic", "ReleaseManagement", "main") not in api.calls + assert api.state["ReleaseManagement"]["protection"] is not None + + +def test_apply_uses_the_declared_payload(tmp_path): + cfg = make_config(tmp_path, "repositories:\n ConnectedNetworkType: {class: api-repository}\n") + api = FakeAPI.from_fixtures(["ConnectedNetworkType"]) + plan = plan_all(api, cfg, ORG, only=["ConnectedNetworkType"])[0] + apply_plan(api, cfg, ORG, plan) + live = {r["name"]: r for r in api.state["ConnectedNetworkType"]["rulesets"]} + assert live["Codeowner_review_required"]["enforcement"] == "active" + assert "release-tag-protection" in live diff --git a/workflows/repository-config/tests/test_cli.py b/workflows/repository-config/tests/test_cli.py new file mode 100644 index 00000000..f8ffddf6 --- /dev/null +++ b/workflows/repository-config/tests/test_cli.py @@ -0,0 +1,116 @@ +"""Tests for the command line: exit codes, output, confirmation, export.""" + +import json + +import pytest + +from scripts.cli import main +from scripts.normalise import normalise + +from .fakes import FakeAPI +from .test_planner import make_config + + +def run(tmp_path, argv, repos, registry, inputs=(), extra_repos=()): + make_config(tmp_path, registry) + api = FakeAPI.from_fixtures(repos, extra_repos) + lines = [] + answers = iter(inputs) + code = main( + argv + ["--config-dir", str(tmp_path)], + api=api, + out=lines.append, + ask=lambda prompt: next(answers), + ) + return code, "\n".join(lines), api + + +CLEAN_REGISTRY = "repositories:\n HomeDevicesQoD: {class: api-repository, archived: true}\n" +DRIFT_REGISTRY = "repositories:\n ConnectedNetworkType: {class: api-repository}\n" + + +def test_plan_exit_0_when_clean(tmp_path): + code, text, _ = run(tmp_path, ["plan"], ["HomeDevicesQoD"], CLEAN_REGISTRY) + assert code == 0 + assert "1 repositories: 1 clean, 0 with drift, 0 errors" in text + + +def test_plan_exit_2_on_drift_and_shows_diff(tmp_path): + code, text, _ = run(tmp_path, ["plan"], ["ConnectedNetworkType"], DRIFT_REGISTRY) + assert code == 2 + assert "ConnectedNetworkType" in text + assert "create release-tag-protection" in text + assert "update Codeowner_review_required" in text + assert '- "enforcement": "disabled"' in text + + +def test_plan_exit_1_on_error(tmp_path): + code, text, _ = run(tmp_path, ["plan", "--repos", "Nope"], ["HomeDevicesQoD"], CLEAN_REGISTRY) + assert code == 1 + assert "Nope" in text + + +def test_plan_repos_filter(tmp_path): + registry = DRIFT_REGISTRY + " HomeDevicesQoD: {class: api-repository, archived: true}\n" + code, text, _ = run(tmp_path, ["plan", "--repos", "HomeDevicesQoD"], ["ConnectedNetworkType", "HomeDevicesQoD"], registry) + assert code == 0 + assert "ConnectedNetworkType" not in text + + +def test_plan_reports_unregistered_repo(tmp_path): + code, text, _ = run(tmp_path, ["plan"], ["HomeDevicesQoD"], CLEAN_REGISTRY, extra_repos=["Fresh"]) + assert code == 2 + assert "Fresh" in text and "unregistered" in text + + +def test_plan_writes_markdown_summary(tmp_path): + summary = tmp_path / "summary.md" + run(tmp_path, ["plan", "--markdown", str(summary)], ["ConnectedNetworkType"], DRIFT_REGISTRY) + body = summary.read_text() + assert "### ConnectedNetworkType" in body + assert "```diff" in body + + +def test_apply_requires_repos(tmp_path): + with pytest.raises(SystemExit): + run(tmp_path, ["apply"], ["ConnectedNetworkType"], DRIFT_REGISTRY) + + +def test_apply_asks_for_confirmation_and_can_decline(tmp_path): + code, text, api = run(tmp_path, ["apply", "--repos", "ConnectedNetworkType"], + ["ConnectedNetworkType"], DRIFT_REGISTRY, inputs=["n"]) + assert code == 1 + assert api.calls == [] + + +def test_apply_with_yes_applies_then_plan_is_clean(tmp_path): + code, text, api = run(tmp_path, ["apply", "--repos", "ConnectedNetworkType", "--yes"], + ["ConnectedNetworkType"], DRIFT_REGISTRY) + assert code == 0 + assert ("create", "ConnectedNetworkType", "release-tag-protection") in api.calls + assert ("update", "ConnectedNetworkType", "Codeowner_review_required") in api.calls + + +def test_apply_with_nothing_to_do(tmp_path): + code, text, api = run(tmp_path, ["apply", "--repos", "HomeDevicesQoD", "--yes"], + ["HomeDevicesQoD"], CLEAN_REGISTRY) + assert code == 0 + assert api.calls == [] + assert "nothing to do" in text + + +def test_export_writes_normalised_file(tmp_path): + make_config(tmp_path, CLEAN_REGISTRY) + api = FakeAPI.from_fixtures(["ConnectedNetworkType"]) + out = tmp_path / "rulesets-out" + out.mkdir() + lines = [] + code = main( + ["export", "--repo", "ConnectedNetworkType", "--ruleset", "Only_Codeowner_Can_Merge", + "--output-dir", str(out), "--config-dir", str(tmp_path)], + api=api, out=lines.append, ask=None, + ) + assert code == 0 + written = json.loads((out / "Only_Codeowner_Can_Merge.json").read_text()) + live = next(r for r in api.state["ConnectedNetworkType"]["rulesets"] if r["name"] == "Only_Codeowner_Can_Merge") + assert written == normalise(live) diff --git a/workflows/repository-config/tests/test_codeowners.py b/workflows/repository-config/tests/test_codeowners.py new file mode 100644 index 00000000..e6ba4e5f --- /dev/null +++ b/workflows/repository-config/tests/test_codeowners.py @@ -0,0 +1,50 @@ +"""Tests for the CODEOWNERS default-owner check.""" + +from scripts.codeowners import default_owners, is_single_codeowner + + +def test_single_user_owner(): + assert default_owners("* @alice\n") == ["@alice"] + assert is_single_codeowner("* @alice\n") is True + + +def test_several_owners(): + text = "* @alice @bob\n" + assert default_owners(text) == ["@alice", "@bob"] + assert is_single_codeowner(text) is False + + +def test_last_star_line_wins(): + text = "* @alice\n/docs/ @carol\n* @alice @bob\n" + assert default_owners(text) == ["@alice", "@bob"] + + +def test_other_patterns_are_ignored(): + text = "/code/ @alice @bob\n*.md @carol\n* @dave\n" + assert default_owners(text) == ["@dave"] + assert is_single_codeowner(text) is True + + +def test_team_counts_as_several_people(): + assert is_single_codeowner("* @camaraproject/some-team\n") is False + + +def test_comments_and_blank_lines_are_skipped(): + text = "# owners\n\n* @alice # inline comment\n" + assert default_owners(text) == ["@alice"] + + +def test_email_owner(): + assert default_owners("* alice@example.com\n") == ["alice@example.com"] + assert is_single_codeowner("* alice@example.com\n") is True + + +def test_no_star_line_is_undetermined(): + assert default_owners("/docs/ @alice\n") is None + assert is_single_codeowner("/docs/ @alice\n") is None + + +def test_star_line_without_owner_is_undetermined(): + # A bare "*" line removes ownership; there is no codeowner to count. + assert default_owners("*\n") == [] + assert is_single_codeowner("*\n") is None diff --git a/workflows/repository-config/tests/test_config.py b/workflows/repository-config/tests/test_config.py new file mode 100644 index 00000000..9bc199bb --- /dev/null +++ b/workflows/repository-config/tests/test_config.py @@ -0,0 +1,104 @@ +"""Tests for loading the declared configuration.""" + +import json +import textwrap + +import pytest + +from scripts.config import ConfigError, load_config +from scripts.normalise import normalise + + +def write_config(tmp_path, classes=None, repositories=None, rulesets=None): + (tmp_path / "rulesets").mkdir() + for name, body in (rulesets or {"a": {"name": "a", "rules": [], "bypass_actors": []}}).items(): + (tmp_path / "rulesets" / f"{name}.json").write_text(json.dumps(body)) + (tmp_path / "repository-classes.yaml").write_text(textwrap.dedent(classes or """ + classes: + api-repository: + rulesets: [a] + main_rulesets: [a] + single_codeowner_excludes: [] + retired: [old] + """)) + (tmp_path / "repositories.yaml").write_text(textwrap.dedent(repositories or """ + repositories: + R1: + class: api-repository + R2: + class: unmanaged + """)) + return tmp_path + + +def test_loads_rulesets_classes_and_registry(tmp_path): + cfg = load_config(write_config(tmp_path)) + assert cfg.rulesets["a"]["name"] == "a" + assert cfg.classes == {"api-repository": ["a"]} + assert cfg.retired == ["old"] + assert cfg.registry["R1"].cls == "api-repository" + assert cfg.registry["R1"].single_codeowner is False + assert cfg.registry["R1"].archived is False + + +def test_registry_flags(tmp_path): + cfg = load_config(write_config(tmp_path, repositories=""" + repositories: + R1: {class: api-repository, single_codeowner: true, archived: true} + """)) + assert cfg.registry["R1"].single_codeowner is True + assert cfg.registry["R1"].archived is True + + +def test_declared_rulesets_are_normalised(tmp_path): + body = {"name": "a", "id": 5, "rules": [{"type": "z"}, {"type": "b"}], "bypass_actors": []} + cfg = load_config(write_config(tmp_path, rulesets={"a": body})) + assert cfg.rulesets["a"] == normalise(body) + + +def test_unknown_class_in_registry(tmp_path): + with pytest.raises(ConfigError, match="R1.*unknown class"): + load_config(write_config(tmp_path, repositories="repositories:\n R1: {class: nope}\n")) + + +def test_class_references_missing_ruleset_file(tmp_path): + with pytest.raises(ConfigError, match="missing.*no file"): + load_config(write_config(tmp_path, classes=""" + classes: + api-repository: + rulesets: [missing] + main_rulesets: [] + single_codeowner_excludes: [] + retired: [] + """)) + + +def test_ruleset_name_must_match_filename(tmp_path): + with pytest.raises(ConfigError, match="name 'b' differs from file 'a'"): + load_config(write_config(tmp_path, rulesets={"a": {"name": "b", "rules": [], "bypass_actors": []}})) + + +def test_declared_ruleset_cannot_be_retired(tmp_path): + with pytest.raises(ConfigError, match="retired"): + load_config(write_config(tmp_path, classes=""" + classes: + api-repository: + rulesets: [a] + main_rulesets: [] + single_codeowner_excludes: [] + retired: [a] + """)) + + +def test_ruleset_without_bypass_actors_is_rejected(tmp_path): + with pytest.raises(ConfigError, match="bypass_actors"): + load_config(write_config(tmp_path, rulesets={"a": {"name": "a", "rules": []}})) + + +def test_shipped_config_loads(): + """The repository's own config/ stays consistent.""" + from scripts.config import DEFAULT_CONFIG_DIR + + cfg = load_config(DEFAULT_CONFIG_DIR) + assert "release-tag-protection" in cfg.classes["api-repository"] + assert "release-tag-protection" not in cfg.classes["non-api"] diff --git a/workflows/repository-config/tests/test_github_api.py b/workflows/repository-config/tests/test_github_api.py new file mode 100644 index 00000000..c1eff1f5 --- /dev/null +++ b/workflows/repository-config/tests/test_github_api.py @@ -0,0 +1,157 @@ +"""Tests for the GitHub client, against a scripted session.""" + +import base64 +import json + +import pytest + +from scripts.github_api import ( + GitHubAPI, + GitHubError, + MissingBypassActors, + RepoNotFound, +) + + +class FakeResponse: + def __init__(self, status=200, body=None, headers=None): + self.status_code = status + self._body = body + self.headers = headers or {} + self.text = json.dumps(body) if body is not None else "" + + def json(self): + return self._body + + @property + def ok(self): + return self.status_code < 400 + + +class FakeSession: + """Maps (method, path) to a response or a list of responses served in order.""" + + def __init__(self, routes): + self.routes = routes + self.calls = [] + self.headers = {} + + def request(self, method, url, **kwargs): + path = url.replace("https://api.github.com", "") + self.calls.append((method, path, kwargs)) + key = (method, path) + if key not in self.routes: + raise AssertionError(f"unexpected call {key}") + value = self.routes[key] + if isinstance(value, list): + return value.pop(0) + return value + + +def api(routes): + session = FakeSession(routes) + return GitHubAPI(token="t", session=session, sleep=lambda s: None), session + + +def test_get_ruleset_returns_full_body(): + body = {"id": 1, "name": "x", "bypass_actors": []} + gh, _ = api({("GET", "/repos/o/r/rulesets/1"): FakeResponse(200, body)}) + assert gh.get_ruleset("o", "r", 1) == body + + +def test_get_ruleset_without_bypass_actors_is_an_error(): + body = {"id": 1, "name": "x"} + gh, _ = api({("GET", "/repos/o/r/rulesets/1"): FakeResponse(200, body)}) + with pytest.raises(MissingBypassActors): + gh.get_ruleset("o", "r", 1) + + +def test_list_rulesets_follows_pagination(): + page1 = FakeResponse(200, [{"id": 1}], {"Link": '; rel="next"'}) + page2 = FakeResponse(200, [{"id": 2}]) + gh, _ = api({ + ("GET", "/repos/o/r/rulesets?per_page=100"): page1, + ("GET", "/repos/o/r/rulesets?page=2"): page2, + }) + assert [r["id"] for r in gh.list_rulesets("o", "r")] == [1, 2] + + +def test_classic_protection_absent_is_none(): + resp = FakeResponse(404, {"message": "Branch not protected"}) + gh, _ = api({("GET", "/repos/o/r/branches/main/protection"): resp}) + assert gh.get_classic_protection("o", "r", "main") is None + + +def test_classic_protection_on_missing_repo_is_an_error(): + resp = FakeResponse(404, {"message": "Not Found"}) + gh, _ = api({("GET", "/repos/o/r/branches/main/protection"): resp}) + with pytest.raises(RepoNotFound): + gh.get_classic_protection("o", "r", "main") + + +def test_classic_protection_present(): + body = {"required_pull_request_reviews": {"required_approving_review_count": 1}} + gh, _ = api({("GET", "/repos/o/r/branches/main/protection"): FakeResponse(200, body)}) + assert gh.get_classic_protection("o", "r", "main") == body + + +def test_codeowners_search_order_and_decoding(): + content = base64.b64encode(b"* @alice\n").decode() + gh, session = api({ + ("GET", "/repos/o/r/contents/.github/CODEOWNERS?ref=main"): FakeResponse(404, {"message": "Not Found"}), + ("GET", "/repos/o/r/contents/CODEOWNERS?ref=main"): FakeResponse(200, {"content": content, "encoding": "base64"}), + }) + assert gh.get_codeowners("o", "r", "main") == "* @alice\n" + assert [c[1] for c in session.calls] == [ + "/repos/o/r/contents/.github/CODEOWNERS?ref=main", + "/repos/o/r/contents/CODEOWNERS?ref=main", + ] + + +def test_codeowners_missing_everywhere_is_none(): + nf = lambda: FakeResponse(404, {"message": "Not Found"}) + gh, _ = api({ + ("GET", "/repos/o/r/contents/.github/CODEOWNERS?ref=main"): nf(), + ("GET", "/repos/o/r/contents/CODEOWNERS?ref=main"): nf(), + ("GET", "/repos/o/r/contents/docs/CODEOWNERS?ref=main"): nf(), + }) + assert gh.get_codeowners("o", "r", "main") is None + + +def test_transient_error_is_retried(): + routes = {("GET", "/repos/o/r"): [FakeResponse(503, {}), FakeResponse(200, {"name": "r"})]} + gh, session = api(routes) + assert gh.get_repo("o", "r") == {"name": "r"} + assert len(session.calls) == 2 + + +def test_rate_limit_raises(): + resp = FakeResponse(403, {"message": "API rate limit exceeded"}, {"X-RateLimit-Remaining": "0"}) + gh, _ = api({("GET", "/repos/o/r"): resp}) + with pytest.raises(GitHubError, match="rate limit"): + gh.get_repo("o", "r") + + +def test_create_ruleset_posts_payload(): + payload = {"name": "x", "bypass_actors": []} + gh, session = api({("POST", "/repos/o/r/rulesets"): FakeResponse(201, {"id": 9})}) + gh.create_ruleset("o", "r", payload) + assert session.calls[0][2]["json"] == payload + + +def test_update_and_delete_ruleset_and_classic(): + gh, session = api({ + ("PUT", "/repos/o/r/rulesets/3"): FakeResponse(200, {}), + ("DELETE", "/repos/o/r/rulesets/3"): FakeResponse(204), + ("DELETE", "/repos/o/r/branches/main/protection"): FakeResponse(204), + }) + gh.update_ruleset("o", "r", 3, {"name": "x"}) + gh.delete_ruleset("o", "r", 3) + gh.delete_classic_protection("o", "r", "main") + assert [c[0] for c in session.calls] == ["PUT", "DELETE", "DELETE"] + + +def test_error_status_raises_with_message(): + gh, _ = api({("DELETE", "/repos/o/r/rulesets/3"): FakeResponse(403, {"message": "Must have admin rights"})}) + with pytest.raises(GitHubError, match="Must have admin rights"): + gh.delete_ruleset("o", "r", 3) diff --git a/workflows/repository-config/tests/test_normalise.py b/workflows/repository-config/tests/test_normalise.py new file mode 100644 index 00000000..6533f53b --- /dev/null +++ b/workflows/repository-config/tests/test_normalise.py @@ -0,0 +1,52 @@ +"""Tests for ruleset normalisation.""" + +import copy + +from scripts.normalise import VOLATILE_KEYS, normalise + + +def test_drops_volatile_fields(live_ruleset): + result = normalise(live_ruleset) + for key in VOLATILE_KEYS: + assert key not in result + assert result["name"] == "release-snapshot-protection" + assert result["enforcement"] == "active" + + +def test_does_not_mutate_input(live_ruleset): + original = copy.deepcopy(live_ruleset) + normalise(live_ruleset) + assert live_ruleset == original + + +def test_sorts_rules_by_type(live_ruleset): + result = normalise(live_ruleset) + types = [r["type"] for r in result["rules"]] + assert types == sorted(types) + + +def test_sorts_bypass_actors_by_type_then_id(live_ruleset): + live_ruleset["bypass_actors"] = [ + {"actor_id": 2865881, "actor_type": "Integration", "bypass_mode": "always"}, + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"}, + {"actor_id": 5, "actor_type": "Integration", "bypass_mode": "always"}, + ] + result = normalise(live_ruleset) + assert [(a["actor_type"], a["actor_id"]) for a in result["bypass_actors"]] == [ + ("Integration", 5), + ("Integration", 2865881), + ("OrganizationAdmin", None), + ] + + +def test_rule_order_does_not_matter(live_ruleset): + shuffled = copy.deepcopy(live_ruleset) + shuffled["rules"].reverse() + assert normalise(shuffled) == normalise(live_ruleset) + + +def test_keeps_everything_else_exactly(live_ruleset): + result = normalise(live_ruleset) + pr = next(r for r in result["rules"] if r["type"] == "pull_request") + assert pr["parameters"]["require_extra_approval_for_unattributed_changes"] is True + assert result["conditions"]["ref_name"]["include"] == ["refs/heads/release-snapshot/**"] diff --git a/workflows/repository-config/tests/test_planner.py b/workflows/repository-config/tests/test_planner.py new file mode 100644 index 00000000..5ba1f1d2 --- /dev/null +++ b/workflows/repository-config/tests/test_planner.py @@ -0,0 +1,180 @@ +"""Tests for the plan: one case per rule, over recorded responses.""" + +import shutil + +import pytest + +from scripts.config import DEFAULT_CONFIG_DIR, load_config +from scripts.planner import plan_all, plan_repo + +from .fakes import FakeAPI + +ORG = "camaraproject" + + +def make_config(tmp_path, registry_yaml): + """The shipped classes and rulesets with a test registry.""" + shutil.copytree(DEFAULT_CONFIG_DIR / "rulesets", tmp_path / "rulesets", dirs_exist_ok=True) + shutil.copy(DEFAULT_CONFIG_DIR / "repository-classes.yaml", tmp_path) + (tmp_path / "repositories.yaml").write_text(registry_yaml) + return load_config(tmp_path) + + +def kinds(plan): + return sorted((a.kind, a.ruleset) for a in plan.actions) + + +def one(plan, kind, ruleset): + found = [a for a in plan.actions if a.kind == kind and a.ruleset == ruleset] + assert len(found) == 1, f"{kind} {ruleset} in {kinds(plan)}" + return found[0] + + +def plan_one(tmp_path, name, registry_yaml): + cfg = make_config(tmp_path, registry_yaml) + api = FakeAPI.from_fixtures([name]) + return plan_all(api, cfg, ORG, only=[name])[0] + + +def test_clean_repo_only_misses_tag_protection(tmp_path): + plan = plan_one(tmp_path, "ApplicationEndpointDiscovery", + "repositories:\n ApplicationEndpointDiscovery: {class: api-repository}\n") + assert kinds(plan) == [("create", "release-tag-protection")] + assert plan.findings == [] + assert plan.drift + + +def test_stale_disabled_ruleset_is_an_update_with_diff(tmp_path): + plan = plan_one(tmp_path, "ConnectedNetworkType", + "repositories:\n ConnectedNetworkType: {class: api-repository}\n") + update = one(plan, "update", "Codeowner_review_required") + assert '- "enforcement": "disabled"' in update.diff + assert '+ "enforcement": "active"' in update.diff + assert kinds(plan) == [("create", "release-tag-protection"), ("update", "Codeowner_review_required")] + + +def test_single_codeowner_repo_has_no_second_ruleset(tmp_path): + plan = plan_one(tmp_path, "SponsoredData", + "repositories:\n SponsoredData: {class: api-repository, single_codeowner: true}\n") + assert one(plan, "remove", "Codeowner_review_required").ruleset_id == 8441211 + assert plan.findings == [] + + +def test_single_codeowner_flag_cross_checked_against_codeowners(tmp_path): + # CODEOWNERS has one owner, the registry says several + plan = plan_one(tmp_path, "SponsoredData", + "repositories:\n SponsoredData: {class: api-repository}\n") + assert any("single_codeowner" in f for f in plan.findings) + # and the other way round + plan = plan_one(tmp_path, "ApplicationEndpointDiscovery", + "repositories:\n ApplicationEndpointDiscovery: {class: api-repository, single_codeowner: true}\n") + assert any("single_codeowner" in f for f in plan.findings) + + +def test_other_class_rulesets_are_removed_and_main_rulesets_created(tmp_path): + plan = plan_one(tmp_path, "EdgeCloud", "repositories:\n EdgeCloud: {class: non-api}\n") + for name in ("release-snapshot-protection", "release-pointer-protection", "pre-release-pointer-protection"): + assert one(plan, "remove", name) + assert one(plan, "create", "Only_Codeowner_Can_Merge") + assert one(plan, "create", "Codeowner_review_required") + assert one(plan, "remove-classic-protection", None) + + +def test_unmanaged_names_are_listed_and_untouched(tmp_path): + plan = plan_one(tmp_path, "ConnectivityQualityManagement", + "repositories:\n ConnectivityQualityManagement: {class: non-api}\n") + unmanaged = one(plan, "unmanaged", "code-owner-review-required-with-write-permission-bypass") + assert unmanaged.ruleset_id + assert ("create", "Only_Codeowner_Can_Merge") in kinds(plan) + + +def test_classic_only_repo(tmp_path): + plan = plan_one(tmp_path, "SimSwap", "repositories:\n SimSwap: {class: api-repository}\n") + assert kinds(plan) == [ + ("create", "Codeowner_review_required"), + ("create", "Only_Codeowner_Can_Merge"), + ("create", "release-tag-protection"), + ("remove-classic-protection", None), + ] + + +def test_zero_rulesets_and_classic(tmp_path): + plan = plan_one(tmp_path, "ReleaseManagement", "repositories:\n ReleaseManagement: {class: non-api}\n") + assert kinds(plan) == [ + ("create", "Codeowner_review_required"), + ("create", "Only_Codeowner_Can_Merge"), + ("remove-classic-protection", None), + ] + + +def test_retired_ruleset_is_removed(tmp_path): + plan = plan_one(tmp_path, "ReleaseTest", + "repositories:\n ReleaseTest: {class: api-repository, single_codeowner: true}\n") + assert one(plan, "remove", "code-owner-review-required-or-sole-codeowner") + assert one(plan, "create", "Only_Codeowner_Can_Merge") + assert not [a for a in plan.actions if a.ruleset == "Codeowner_review_required"] + assert not [a for a in plan.actions if a.ruleset == "release-tag-protection"] + + +def test_archived_repo_is_skipped(tmp_path): + plan = plan_one(tmp_path, "HomeDevicesQoD", + "repositories:\n HomeDevicesQoD: {class: api-repository, archived: true}\n") + assert plan.actions == [] + assert plan.skipped == "archived" + assert not plan.drift + + +def test_archived_flag_mismatch_is_drift(tmp_path): + plan = plan_one(tmp_path, "HomeDevicesQoD", "repositories:\n HomeDevicesQoD: {class: api-repository}\n") + assert any("archived" in f for f in plan.findings) + assert plan.drift + + +def test_unmanaged_class_is_not_touched(tmp_path): + plan = plan_one(tmp_path, "EdgeCloud", "repositories:\n EdgeCloud: {class: unmanaged}\n") + assert plan.actions == [] + assert not plan.drift + + +def test_unregistered_and_missing_repos_are_drift(tmp_path): + cfg = make_config(tmp_path, "repositories:\n Gone: {class: non-api}\n Known: {class: unmanaged}\n") + api = FakeAPI({}, extra_repos=["Known", "Fresh"]) + plans = {p.repo: p for p in plan_all(api, cfg, ORG)} + assert plans["Fresh"].findings == ["unregistered"] + assert plans["Gone"].findings == ["registered but not found on GitHub"] + assert not plans["Known"].drift + + +def test_plan_after_apply_is_clean(tmp_path): + cfg = make_config(tmp_path, "repositories:\n EdgeCloud: {class: non-api}\n") + api = FakeAPI.from_fixtures(["EdgeCloud"]) + plan = plan_all(api, cfg, ORG, only=["EdgeCloud"])[0] + assert plan.drift + from scripts.planner import apply_plan + apply_plan(api, cfg, ORG, plan) + assert not plan_all(api, cfg, ORG, only=["EdgeCloud"])[0].drift + + +def test_bypass_actors_missing_is_an_error_not_a_diff(tmp_path): + from scripts.github_api import MissingBypassActors + + cfg = make_config(tmp_path, "repositories:\n ApplicationEndpointDiscovery: {class: api-repository}\n") + api = FakeAPI.from_fixtures(["ApplicationEndpointDiscovery"]) + + def broken(org, repo, ruleset_id): + raise MissingBypassActors("no write access") + + api.get_ruleset = broken + plan = plan_all(api, cfg, ORG, only=["ApplicationEndpointDiscovery"])[0] + assert "no write access" in plan.error + assert plan.actions == [] + + +def test_round_trip_export_then_plan_has_no_drift(tmp_path): + """A declared file exported from a live ruleset matches that live ruleset.""" + cfg = make_config(tmp_path, "repositories:\n ApplicationEndpointDiscovery: {class: api-repository}\n") + api = FakeAPI.from_fixtures(["ApplicationEndpointDiscovery"]) + plan = plan_repo(api, cfg, ORG, cfg.registry["ApplicationEndpointDiscovery"], + api.get_repo(ORG, "ApplicationEndpointDiscovery")) + assert not [a for a in plan.actions if a.kind in ("update", "remove")] + assert not [a for a in plan.actions if a.ruleset and a.ruleset.startswith("release-") and a.kind == "update"] From a732bd7aa94aa43f2b30f7ed4878a5fc8d13be74 Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:50:09 +0200 Subject: [PATCH 3/4] feat(repository-config): add plan and test workflows, retire apply-release-rulesets script --- ...campaign-release-automation-onboarding.yml | 2 +- .github/workflows/repository-config-plan.yml | 68 +++++++++++++++++++ .github/workflows/repository-config-tests.yml | 37 ++++++++++ README.md | 18 +++-- scripts/README.md | 10 --- 5 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/repository-config-plan.yml create mode 100644 .github/workflows/repository-config-tests.yml diff --git a/.github/workflows/campaign-release-automation-onboarding.yml b/.github/workflows/campaign-release-automation-onboarding.yml index a008bdda..b59dfa9c 100644 --- a/.github/workflows/campaign-release-automation-onboarding.yml +++ b/.github/workflows/campaign-release-automation-onboarding.yml @@ -19,7 +19,7 @@ # Requires org-level variable RELEASE_APP_CLIENT_ID and secret RELEASE_APP_PRIVATE_KEY # # NOTE: Repository rulesets require admin-level access and are applied separately -# via the apply-release-rulesets.sh script. +# with the repository-config tool, see workflows/repository-config/README.md. # # DOCUMENTATION: # See release_automation/docs/repository-setup.md in camaraproject/tooling diff --git a/.github/workflows/repository-config-plan.yml b/.github/workflows/repository-config-plan.yml new file mode 100644 index 00000000..e8f757c4 --- /dev/null +++ b/.github/workflows/repository-config-plan.yml @@ -0,0 +1,68 @@ +# ========================================================================================= +# CAMARA Project - Repository Configuration Plan +# +# Compares the declared repository configuration (config/) with the live state of every +# repository in the organisation and fails when they differ. Read-only: nothing is +# changed. Apply a plan from the command line, see workflows/repository-config/README.md. +# +# AUTHENTICATION: +# - GitHub App camara-repository-config, token minted with create-github-app-token. +# Environment repository-config: variable REPO_CONFIG_APP_CLIENT_ID, +# secret REPO_CONFIG_APP_PRIVATE_KEY. The environment only admits the main branch. +# +# DOCUMENTATION: +# https://github.com/camaraproject/project-administration/blob/main/workflows/repository-config/README.md +# ========================================================================================= + +name: Repository Configuration Plan + +on: + schedule: + - cron: '17 5 * * 1' + workflow_dispatch: + inputs: + repos: + description: 'Comma-separated repository names to plan. Empty = all registry entries.' + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + environment: repository-config + steps: + - name: Generate App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.REPO_CONFIG_APP_CLIENT_ID }} + private-key: ${{ secrets.REPO_CONFIG_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + + - name: Install dependencies + run: pip install PyYAML requests + + - name: Plan + working-directory: workflows/repository-config + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + REPOS: ${{ inputs.repos }} + run: | + args=() + if [ -n "$REPOS" ]; then + args+=(--repos "$REPOS") + fi + python -m scripts.cli plan "${args[@]}" --markdown "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/repository-config-tests.yml b/.github/workflows/repository-config-tests.yml new file mode 100644 index 00000000..a7f55c95 --- /dev/null +++ b/.github/workflows/repository-config-tests.yml @@ -0,0 +1,37 @@ +# ========================================================================================= +# CAMARA Project - Repository Configuration Tests +# +# Runs the unit tests of workflows/repository-config and loads the declared configuration +# in config/ so that an inconsistent change fails before it is merged. +# ========================================================================================= + +name: Repository Configuration Tests + +on: + pull_request: + paths: + - 'workflows/repository-config/**' + - 'config/**' + - '.github/workflows/repository-config-tests.yml' + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + + - name: Install dependencies + run: pip install PyYAML requests pytest + + - name: Run tests + run: python -m pytest workflows/repository-config/tests diff --git a/README.md b/README.md index 6df1aba3..b93b7656 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,20 @@ Automates setup of new API repositories from [Template_API_Repository](https://g * **Workflow**: `admin-api-repository-creation.yml` * **Requirements**: Environment `repository-creation` with `GH_REPO_CREATE_TOKEN` +### Repository Configuration + +Declares repository rulesets and `main` branch protection once and reports drift per repository. + +* **Location**: [workflows/repository-config/](workflows/repository-config/) +* **Declarations**: [config/repositories.yaml](config/repositories.yaml), [config/repository-classes.yaml](config/repository-classes.yaml), [config/rulesets/](config/rulesets/) +* **Documentation**: [workflows/repository-config/README.md](workflows/repository-config/README.md) +* **Workflows**: `repository-config-plan.yml` (weekly plan), `repository-config-tests.yml` + ### Admin Scripts Scripts for administrative tasks that complement campaigns. * **Location**: [scripts/](scripts/) -* **Scripts**: - * `apply-release-rulesets.sh` - Applies release automation rulesets to API repositories (companion to the onboarding campaign) ### Legacy Reporting (to be replaced) @@ -116,15 +123,18 @@ project-administration/ │ └── release-plan-rollout/ # Release plan file generation ├── config/ # Shared configuration files │ ├── api-landscape.yaml # API portfolio metadata -│ └── meta-release-mappings.yaml +│ ├── meta-release-mappings.yaml +│ ├── repositories.yaml # Repository registry +│ ├── repository-classes.yaml # Rulesets per repository class +│ └── rulesets/ # Declared rulesets (JSON) ├── data/ # Release Collector outputs (master data) │ └── releases-master.yaml # Master release metadata ├── reports/ # Release Collector outputs (JSON reports) ├── scripts/ # Admin scripts -│ └── apply-release-rulesets.sh └── workflows/ ├── api-repository-creation/ # Repository creation system │ └── docs/README.md + ├── repository-config/ # Rulesets and branch protection: plan, apply └── release-collector/ # Release tracking system ├── docs/ # Documentation ├── schemas/ # YAML schemas diff --git a/scripts/README.md b/scripts/README.md index 1268ab54..ad4bd1f0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -36,13 +36,3 @@ Configurable exceptions: `SKIP_REPOS` for repositories with non-standard structu ```bash ./check-team-repo-compliance.sh [--org camaraproject] [--verbose] ``` - -### apply-release-rulesets.sh - -Creates or updates the repository ruleset required by the release automation workflow. Protects `release-snapshot/**` branches so that only the `camara-release-automation` GitHub App can create, push, and delete them, while humans must use PRs with required approvals. - -**Note:** This script modifies repository settings. Requires a Fine-grained PAT with Repository Administration write access. - -```bash -./apply-release-rulesets.sh --repos "ReleaseTest,QualityOnDemand" [--org camaraproject] [--dry-run] -``` From 7778d0d7d3828fbc0f110a057e953fb9fabc62ad Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:34:54 +0200 Subject: [PATCH 4/4] refactor(repository-config): rename registry field class to ruleset_class --- README.md | 4 +- config/repositories.yaml | 194 +++++++++--------- ...tory-classes.yaml => ruleset-classes.yaml} | 10 +- workflows/repository-config/README.md | 12 +- workflows/repository-config/scripts/config.py | 24 +-- .../repository-config/scripts/planner.py | 4 +- .../repository-config/tests/test_apply.py | 6 +- workflows/repository-config/tests/test_cli.py | 6 +- .../repository-config/tests/test_config.py | 26 +-- .../repository-config/tests/test_planner.py | 36 ++-- 10 files changed, 161 insertions(+), 161 deletions(-) rename config/{repository-classes.yaml => ruleset-classes.yaml} (77%) diff --git a/README.md b/README.md index b93b7656..340096eb 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Automates setup of new API repositories from [Template_API_Repository](https://g Declares repository rulesets and `main` branch protection once and reports drift per repository. * **Location**: [workflows/repository-config/](workflows/repository-config/) -* **Declarations**: [config/repositories.yaml](config/repositories.yaml), [config/repository-classes.yaml](config/repository-classes.yaml), [config/rulesets/](config/rulesets/) +* **Declarations**: [config/repositories.yaml](config/repositories.yaml), [config/ruleset-classes.yaml](config/ruleset-classes.yaml), [config/rulesets/](config/rulesets/) * **Documentation**: [workflows/repository-config/README.md](workflows/repository-config/README.md) * **Workflows**: `repository-config-plan.yml` (weekly plan), `repository-config-tests.yml` @@ -125,7 +125,7 @@ project-administration/ │ ├── api-landscape.yaml # API portfolio metadata │ ├── meta-release-mappings.yaml │ ├── repositories.yaml # Repository registry -│ ├── repository-classes.yaml # Rulesets per repository class +│ ├── ruleset-classes.yaml # Rulesets per ruleset class │ └── rulesets/ # Declared rulesets (JSON) ├── data/ # Release Collector outputs (master data) │ └── releases-master.yaml # Master release metadata diff --git a/config/repositories.yaml b/config/repositories.yaml index 971f7461..7a4c7c2d 100644 --- a/config/repositories.yaml +++ b/config/repositories.yaml @@ -1,6 +1,6 @@ # Repository registry: one entry per organisation repository, keyed by name. # -# class: api-repository | non-api | unmanaged (see repository-classes.yaml) +# ruleset_class: api-repository | non-api | unmanaged (see ruleset-classes.yaml) # single_codeowner: true when the default branch's CODEOWNERS `*` line names one user; # `plan` cross-checks the flag against CODEOWNERS. # archived: true for archived repositories (rulesets are skipped). @@ -8,305 +8,305 @@ repositories: .github: - class: unmanaged + ruleset_class: unmanaged APIBacklog: - class: non-api + ruleset_class: non-api ApplicationEndpointDiscovery: - class: api-repository + ruleset_class: api-repository ApplicationEndpointRegistration: - class: api-repository + ruleset_class: api-repository ApplicationProfiles: - class: api-repository + ruleset_class: api-repository BlockchainPublicAddress: - class: api-repository + ruleset_class: api-repository CallForwardingSignal: - class: api-repository + ruleset_class: api-repository camara-landscape: - class: unmanaged + ruleset_class: unmanaged camaraproject.github.io: - class: unmanaged + ruleset_class: unmanaged CapabilitiesAndRuntimeRestrictions: - class: api-repository + ruleset_class: api-repository CarrierBillingCheckOut: - class: api-repository + ruleset_class: api-repository ClickToDial: - class: api-repository + ruleset_class: api-repository Commonalities: - class: non-api + ruleset_class: non-api CommonalitiesTest: - class: api-repository + ruleset_class: api-repository ConnectedNetworkType: - class: api-repository + ruleset_class: api-repository ConnectivityInsights: - class: api-repository + ruleset_class: api-repository ConnectivityQualityManagement: - class: non-api + ruleset_class: non-api ConsentInfo: - class: api-repository + ruleset_class: api-repository ConsentManagement: - class: api-repository + ruleset_class: api-repository CustomerInsights: - class: api-repository + ruleset_class: api-repository DedicatedNetworks: - class: api-repository + ruleset_class: api-repository DeviceAuthenticity: - class: api-repository + ruleset_class: api-repository DeviceDataVolume: - class: api-repository + ruleset_class: api-repository DeviceIdentifier: - class: api-repository + ruleset_class: api-repository DeviceLocation: - class: api-repository + ruleset_class: api-repository DeviceMediaStreamingRate: - class: api-repository + ruleset_class: api-repository DeviceReachabilityStatus: - class: api-repository + ruleset_class: api-repository DeviceRoamingStatus: - class: api-repository + ruleset_class: api-repository DeviceStatus: - class: api-repository + ruleset_class: api-repository DeviceSwap: - class: api-repository + ruleset_class: api-repository DeviceVisitLocation: - class: api-repository + ruleset_class: api-repository EasyCLA: - class: unmanaged + ruleset_class: unmanaged EdgeApplicationManagement: - class: api-repository + ruleset_class: api-repository EdgeCloud: - class: non-api + ruleset_class: non-api EnergyFootprintNotification: - class: api-repository + ruleset_class: api-repository single_codeowner: true EnergyFootprintNotification_PI: - class: non-api + ruleset_class: non-api single_codeowner: true eSimRemoteManagement: - class: api-repository + ruleset_class: api-repository Governance: - class: non-api + ruleset_class: non-api HighThroughputElasticNetworks: - class: api-repository + ruleset_class: api-repository single_codeowner: true HomeDevicesQoD: - class: api-repository + ruleset_class: api-repository archived: true IdentityAndConsentManagement: - class: non-api + ruleset_class: non-api InHomeDeviceManagement: - class: api-repository + ruleset_class: api-repository IoTDeviceManagement: - class: api-repository + ruleset_class: api-repository IoTNetworkOptimization: - class: api-repository + ruleset_class: api-repository IoTNetworkOptimization_PI: - class: non-api + ruleset_class: non-api single_codeowner: true IoTSIMFraudPrevention: - class: api-repository + ruleset_class: api-repository KnowYourCustomer: - class: api-repository + ruleset_class: api-repository KnowYourCustomerAgeVerification: - class: api-repository + ruleset_class: api-repository KnowYourCustomerFill-in: - class: api-repository + ruleset_class: api-repository KnowYourCustomerMatch: - class: api-repository + ruleset_class: api-repository Marketing: - class: non-api + ruleset_class: non-api MCPEnablement_PI1: - class: non-api + ruleset_class: non-api ModelAsAService: - class: api-repository + ruleset_class: api-repository single_codeowner: true MostFrequentLocation: - class: api-repository + ruleset_class: api-repository MultiPointVPN: - class: api-repository + ruleset_class: api-repository NetworkAccessManagement: - class: api-repository + ruleset_class: api-repository NetworkInsights: - class: api-repository + ruleset_class: api-repository NetworkServiceAreas: - class: api-repository + ruleset_class: api-repository NetworkSliceBooking: - class: api-repository + ruleset_class: api-repository NumberRecycling: - class: api-repository + ruleset_class: api-repository NumberVerification: - class: api-repository + ruleset_class: api-repository OptimalEdgeDiscovery: - class: api-repository + ruleset_class: api-repository OTPValidation: - class: api-repository + ruleset_class: api-repository PopulationDensityData: - class: api-repository + ruleset_class: api-repository PredictiveConnectivityData: - class: api-repository + ruleset_class: api-repository project-administration: - class: non-api + ruleset_class: non-api QoSBooking: - class: api-repository + ruleset_class: api-repository QoSProfiles: - class: api-repository + ruleset_class: api-repository QualityOnDemand: - class: api-repository + ruleset_class: api-repository QualityOnDemand_PI1: - class: non-api + ruleset_class: non-api QualityOnDemand_PI2: - class: non-api + ruleset_class: non-api QualityOnDemand_PI3: - class: non-api + ruleset_class: non-api single_codeowner: true RainfallIntensity: - class: api-repository + ruleset_class: api-repository single_codeowner: true RegionDeviceCount: - class: api-repository + ruleset_class: api-repository ReleaseManagement: - class: non-api + ruleset_class: non-api ReleaseTest: - class: api-repository + ruleset_class: api-repository single_codeowner: true SessionInsights: - class: api-repository + ruleset_class: api-repository ShortMessageService: - class: api-repository + ruleset_class: api-repository archived: true SimpleEdgeDiscovery: - class: api-repository + ruleset_class: api-repository SimSwap: - class: api-repository + ruleset_class: api-repository SiteToCloudVPN: - class: api-repository + ruleset_class: api-repository archived: true SponsoredData: - class: api-repository + ruleset_class: api-repository single_codeowner: true SubscriptionStatus: - class: api-repository + ruleset_class: api-repository Template_API_Repository: - class: api-repository + ruleset_class: api-repository Template_PI_Repository: - class: non-api + ruleset_class: non-api Tenure: - class: api-repository + ruleset_class: api-repository test-repo-w-linting: - class: unmanaged + ruleset_class: unmanaged test-repo-wo-linting: - class: unmanaged + ruleset_class: unmanaged TestRepo: - class: unmanaged + ruleset_class: unmanaged archived: true tooling: - class: non-api + ruleset_class: non-api TrafficInfluence: - class: api-repository + ruleset_class: api-repository VerifiedCaller: - class: api-repository + ruleset_class: api-repository VoiceNotification: - class: api-repository + ruleset_class: api-repository single_codeowner: true VoiceVerificationCode: - class: api-repository + ruleset_class: api-repository single_codeowner: true WebRTC: - class: api-repository + ruleset_class: api-repository WorkingGroups: - class: non-api + ruleset_class: non-api archived: true diff --git a/config/repository-classes.yaml b/config/ruleset-classes.yaml similarity index 77% rename from config/repository-classes.yaml rename to config/ruleset-classes.yaml index 7a839ab2..315e38dd 100644 --- a/config/repository-classes.yaml +++ b/config/ruleset-classes.yaml @@ -1,10 +1,10 @@ -# Repository classes: which declared rulesets a repository carries. +# Ruleset classes: which declared rulesets a repository carries. # -# Ruleset bodies live in config/rulesets/.json; the class of each -# repository is set in config/repositories.yaml. The tool is +# Ruleset bodies live in config/rulesets/.json; the ruleset_class +# of each repository is set in config/repositories.yaml. The tool is # workflows/repository-config/. -classes: +ruleset_classes: # Automated releases: release rulesets, tag protection and main protection. api-repository: rulesets: @@ -31,7 +31,7 @@ main_rulesets: single_codeowner_excludes: - Codeowner_review_required -# Removed wherever found, in every class. +# Removed wherever found, in every ruleset class. retired: - code-owner-review-required-or-sole-codeowner - release-review-protection diff --git a/workflows/repository-config/README.md b/workflows/repository-config/README.md index cbc87cba..2b295d50 100644 --- a/workflows/repository-config/README.md +++ b/workflows/repository-config/README.md @@ -8,11 +8,11 @@ All declarations live in [config/](../../config/): | File | Content | |---|---| -| `repositories.yaml` | One entry per organisation repository: `class`, `single_codeowner`, `archived` | -| `repository-classes.yaml` | Which rulesets each class carries, the `main` rulesets, the retired ruleset names | +| `repositories.yaml` | One entry per organisation repository: `ruleset_class`, `single_codeowner`, `archived` | +| `ruleset-classes.yaml` | Which rulesets each ruleset class carries, the `main` rulesets, the retired ruleset names | | `rulesets/.json` | The ruleset as GitHub returns it, without the fields GitHub sets itself | -Classes: +Ruleset classes: - `api-repository`: the release rulesets, `release-tag-protection` and the two `main` rulesets. - `non-api`: the two `main` rulesets. @@ -38,9 +38,9 @@ Per repository, rulesets are matched by name: | Live ruleset | Action | |---|---| -| declared for the class, missing | `create` | -| declared for the class, content differs | `update`, with a diff | -| declared for another class, or retired | `remove` | +| declared for the ruleset class, missing | `create` | +| declared for the ruleset class, content differs | `update`, with a diff | +| declared for another ruleset class, or retired | `remove` | | any other name | `unmanaged`, listed and left alone | Classic branch protection on the default branch is reported as `remove-classic-protection`. `apply` removes it only after the declared `main` rulesets are confirmed `active` on that repository. diff --git a/workflows/repository-config/scripts/config.py b/workflows/repository-config/scripts/config.py index a6d3bf65..5ddc7d18 100644 --- a/workflows/repository-config/scripts/config.py +++ b/workflows/repository-config/scripts/config.py @@ -1,4 +1,4 @@ -"""Load the declared configuration: rulesets, repository classes, repo registry.""" +"""Load the declared configuration: rulesets, ruleset classes, repo registry.""" import json from dataclasses import dataclass, field @@ -22,7 +22,7 @@ class ConfigError(Exception): @dataclass(frozen=True) class RepoEntry: name: str - cls: str + ruleset_class: str single_codeowner: bool = False archived: bool = False @@ -30,15 +30,15 @@ class RepoEntry: @dataclass class Config: rulesets: Dict[str, Dict[str, Any]] - classes: Dict[str, List[str]] + ruleset_classes: Dict[str, List[str]] main_rulesets: List[str] single_codeowner_excludes: List[str] retired: List[str] registry: Dict[str, RepoEntry] = field(default_factory=dict) def desired_rulesets(self, entry: RepoEntry) -> List[str]: - """Ruleset names the repository's class declares, minus its exclusions.""" - names = self.classes.get(entry.cls, []) + """Ruleset names the repository's ruleset class declares, minus its exclusions.""" + names = self.ruleset_classes.get(entry.ruleset_class, []) if entry.single_codeowner: names = [n for n in names if n not in self.single_codeowner_excludes] return list(names) @@ -63,11 +63,11 @@ def load_config(config_dir: Path = DEFAULT_CONFIG_DIR) -> Config: raise ConfigError(f"{path.name}: bypass_actors missing (export with write access)") rulesets[path.stem] = normalise(body) - raw = _load_yaml(config_dir / "repository-classes.yaml") - classes = {name: list(spec.get("rulesets", [])) for name, spec in (raw.get("classes") or {}).items()} + raw = _load_yaml(config_dir / "ruleset-classes.yaml") + classes = {name: list(spec.get("rulesets", [])) for name, spec in (raw.get("ruleset_classes") or {}).items()} cfg = Config( rulesets=rulesets, - classes=classes, + ruleset_classes=classes, main_rulesets=list(raw.get("main_rulesets") or []), single_codeowner_excludes=list(raw.get("single_codeowner_excludes") or []), retired=list(raw.get("retired") or []), @@ -76,19 +76,19 @@ def load_config(config_dir: Path = DEFAULT_CONFIG_DIR) -> Config: for cls, names in classes.items(): for name in names: if name not in rulesets: - raise ConfigError(f"class {cls}: ruleset '{name}' has no file in rulesets/") + raise ConfigError(f"ruleset_class {cls}: ruleset '{name}' has no file in rulesets/") for name in cfg.retired: if name in rulesets or any(name in names for names in classes.values()): raise ConfigError(f"'{name}' is both declared and retired") registry = (_load_yaml(config_dir / "repositories.yaml")).get("repositories") or {} for name, spec in registry.items(): - cls = spec.get("class") + cls = spec.get("ruleset_class") if cls != UNMANAGED and cls not in classes: - raise ConfigError(f"repository {name}: unknown class '{cls}'") + raise ConfigError(f"repository {name}: unknown ruleset_class '{cls}'") cfg.registry[name] = RepoEntry( name=name, - cls=cls, + ruleset_class=cls, single_codeowner=bool(spec.get("single_codeowner", False)), archived=bool(spec.get("archived", False)), ) diff --git a/workflows/repository-config/scripts/planner.py b/workflows/repository-config/scripts/planner.py index ed9d6ee6..dfbde4c3 100644 --- a/workflows/repository-config/scripts/planner.py +++ b/workflows/repository-config/scripts/planner.py @@ -68,14 +68,14 @@ def plan_repo(api, cfg: Config, org: str, entry: RepoEntry, gh_repo: Dict[str, A if gh_repo.get("archived"): plan.skipped = "archived" return plan - if entry.cls == UNMANAGED: + if entry.ruleset_class == UNMANAGED: plan.skipped = UNMANAGED return plan _check_single_codeowner(api, org, entry, plan) desired = cfg.desired_rulesets(entry) - declared_names = {n for names in cfg.classes.values() for n in names} + declared_names = {n for names in cfg.ruleset_classes.values() for n in names} removable = (declared_names - set(desired)) | set(cfg.retired) live_by_name: Dict[str, List[Dict[str, Any]]] = {} diff --git a/workflows/repository-config/tests/test_apply.py b/workflows/repository-config/tests/test_apply.py index 1078d9e0..45ca2d59 100644 --- a/workflows/repository-config/tests/test_apply.py +++ b/workflows/repository-config/tests/test_apply.py @@ -10,7 +10,7 @@ def test_apply_orders_writes_then_removes_then_classic(tmp_path): - cfg = make_config(tmp_path, "repositories:\n EdgeCloud: {class: non-api}\n") + cfg = make_config(tmp_path, "repositories:\n EdgeCloud: {ruleset_class: non-api}\n") api = FakeAPI.from_fixtures(["EdgeCloud"]) plan = plan_all(api, cfg, ORG, only=["EdgeCloud"])[0] apply_plan(api, cfg, ORG, plan) @@ -19,7 +19,7 @@ def test_apply_orders_writes_then_removes_then_classic(tmp_path): def test_classic_protection_kept_unless_main_rulesets_are_active(tmp_path): - cfg = make_config(tmp_path, "repositories:\n ReleaseManagement: {class: non-api}\n") + cfg = make_config(tmp_path, "repositories:\n ReleaseManagement: {ruleset_class: non-api}\n") cfg.rulesets["Only_Codeowner_Can_Merge"]["enforcement"] = "disabled" api = FakeAPI.from_fixtures(["ReleaseManagement"]) plan = plan_all(api, cfg, ORG, only=["ReleaseManagement"])[0] @@ -30,7 +30,7 @@ def test_classic_protection_kept_unless_main_rulesets_are_active(tmp_path): def test_apply_uses_the_declared_payload(tmp_path): - cfg = make_config(tmp_path, "repositories:\n ConnectedNetworkType: {class: api-repository}\n") + cfg = make_config(tmp_path, "repositories:\n ConnectedNetworkType: {ruleset_class: api-repository}\n") api = FakeAPI.from_fixtures(["ConnectedNetworkType"]) plan = plan_all(api, cfg, ORG, only=["ConnectedNetworkType"])[0] apply_plan(api, cfg, ORG, plan) diff --git a/workflows/repository-config/tests/test_cli.py b/workflows/repository-config/tests/test_cli.py index f8ffddf6..f9ecdc58 100644 --- a/workflows/repository-config/tests/test_cli.py +++ b/workflows/repository-config/tests/test_cli.py @@ -25,8 +25,8 @@ def run(tmp_path, argv, repos, registry, inputs=(), extra_repos=()): return code, "\n".join(lines), api -CLEAN_REGISTRY = "repositories:\n HomeDevicesQoD: {class: api-repository, archived: true}\n" -DRIFT_REGISTRY = "repositories:\n ConnectedNetworkType: {class: api-repository}\n" +CLEAN_REGISTRY = "repositories:\n HomeDevicesQoD: {ruleset_class: api-repository, archived: true}\n" +DRIFT_REGISTRY = "repositories:\n ConnectedNetworkType: {ruleset_class: api-repository}\n" def test_plan_exit_0_when_clean(tmp_path): @@ -51,7 +51,7 @@ def test_plan_exit_1_on_error(tmp_path): def test_plan_repos_filter(tmp_path): - registry = DRIFT_REGISTRY + " HomeDevicesQoD: {class: api-repository, archived: true}\n" + registry = DRIFT_REGISTRY + " HomeDevicesQoD: {ruleset_class: api-repository, archived: true}\n" code, text, _ = run(tmp_path, ["plan", "--repos", "HomeDevicesQoD"], ["ConnectedNetworkType", "HomeDevicesQoD"], registry) assert code == 0 assert "ConnectedNetworkType" not in text diff --git a/workflows/repository-config/tests/test_config.py b/workflows/repository-config/tests/test_config.py index 9bc199bb..16e3fbfe 100644 --- a/workflows/repository-config/tests/test_config.py +++ b/workflows/repository-config/tests/test_config.py @@ -13,8 +13,8 @@ def write_config(tmp_path, classes=None, repositories=None, rulesets=None): (tmp_path / "rulesets").mkdir() for name, body in (rulesets or {"a": {"name": "a", "rules": [], "bypass_actors": []}}).items(): (tmp_path / "rulesets" / f"{name}.json").write_text(json.dumps(body)) - (tmp_path / "repository-classes.yaml").write_text(textwrap.dedent(classes or """ - classes: + (tmp_path / "ruleset-classes.yaml").write_text(textwrap.dedent(classes or """ + ruleset_classes: api-repository: rulesets: [a] main_rulesets: [a] @@ -24,9 +24,9 @@ def write_config(tmp_path, classes=None, repositories=None, rulesets=None): (tmp_path / "repositories.yaml").write_text(textwrap.dedent(repositories or """ repositories: R1: - class: api-repository + ruleset_class: api-repository R2: - class: unmanaged + ruleset_class: unmanaged """)) return tmp_path @@ -34,9 +34,9 @@ def write_config(tmp_path, classes=None, repositories=None, rulesets=None): def test_loads_rulesets_classes_and_registry(tmp_path): cfg = load_config(write_config(tmp_path)) assert cfg.rulesets["a"]["name"] == "a" - assert cfg.classes == {"api-repository": ["a"]} + assert cfg.ruleset_classes == {"api-repository": ["a"]} assert cfg.retired == ["old"] - assert cfg.registry["R1"].cls == "api-repository" + assert cfg.registry["R1"].ruleset_class == "api-repository" assert cfg.registry["R1"].single_codeowner is False assert cfg.registry["R1"].archived is False @@ -44,7 +44,7 @@ def test_loads_rulesets_classes_and_registry(tmp_path): def test_registry_flags(tmp_path): cfg = load_config(write_config(tmp_path, repositories=""" repositories: - R1: {class: api-repository, single_codeowner: true, archived: true} + R1: {ruleset_class: api-repository, single_codeowner: true, archived: true} """)) assert cfg.registry["R1"].single_codeowner is True assert cfg.registry["R1"].archived is True @@ -57,14 +57,14 @@ def test_declared_rulesets_are_normalised(tmp_path): def test_unknown_class_in_registry(tmp_path): - with pytest.raises(ConfigError, match="R1.*unknown class"): - load_config(write_config(tmp_path, repositories="repositories:\n R1: {class: nope}\n")) + with pytest.raises(ConfigError, match="R1.*unknown ruleset_class"): + load_config(write_config(tmp_path, repositories="repositories:\n R1: {ruleset_class: nope}\n")) def test_class_references_missing_ruleset_file(tmp_path): with pytest.raises(ConfigError, match="missing.*no file"): load_config(write_config(tmp_path, classes=""" - classes: + ruleset_classes: api-repository: rulesets: [missing] main_rulesets: [] @@ -81,7 +81,7 @@ def test_ruleset_name_must_match_filename(tmp_path): def test_declared_ruleset_cannot_be_retired(tmp_path): with pytest.raises(ConfigError, match="retired"): load_config(write_config(tmp_path, classes=""" - classes: + ruleset_classes: api-repository: rulesets: [a] main_rulesets: [] @@ -100,5 +100,5 @@ def test_shipped_config_loads(): from scripts.config import DEFAULT_CONFIG_DIR cfg = load_config(DEFAULT_CONFIG_DIR) - assert "release-tag-protection" in cfg.classes["api-repository"] - assert "release-tag-protection" not in cfg.classes["non-api"] + assert "release-tag-protection" in cfg.ruleset_classes["api-repository"] + assert "release-tag-protection" not in cfg.ruleset_classes["non-api"] diff --git a/workflows/repository-config/tests/test_planner.py b/workflows/repository-config/tests/test_planner.py index 5ba1f1d2..86ca08b5 100644 --- a/workflows/repository-config/tests/test_planner.py +++ b/workflows/repository-config/tests/test_planner.py @@ -15,7 +15,7 @@ def make_config(tmp_path, registry_yaml): """The shipped classes and rulesets with a test registry.""" shutil.copytree(DEFAULT_CONFIG_DIR / "rulesets", tmp_path / "rulesets", dirs_exist_ok=True) - shutil.copy(DEFAULT_CONFIG_DIR / "repository-classes.yaml", tmp_path) + shutil.copy(DEFAULT_CONFIG_DIR / "ruleset-classes.yaml", tmp_path) (tmp_path / "repositories.yaml").write_text(registry_yaml) return load_config(tmp_path) @@ -38,7 +38,7 @@ def plan_one(tmp_path, name, registry_yaml): def test_clean_repo_only_misses_tag_protection(tmp_path): plan = plan_one(tmp_path, "ApplicationEndpointDiscovery", - "repositories:\n ApplicationEndpointDiscovery: {class: api-repository}\n") + "repositories:\n ApplicationEndpointDiscovery: {ruleset_class: api-repository}\n") assert kinds(plan) == [("create", "release-tag-protection")] assert plan.findings == [] assert plan.drift @@ -46,7 +46,7 @@ def test_clean_repo_only_misses_tag_protection(tmp_path): def test_stale_disabled_ruleset_is_an_update_with_diff(tmp_path): plan = plan_one(tmp_path, "ConnectedNetworkType", - "repositories:\n ConnectedNetworkType: {class: api-repository}\n") + "repositories:\n ConnectedNetworkType: {ruleset_class: api-repository}\n") update = one(plan, "update", "Codeowner_review_required") assert '- "enforcement": "disabled"' in update.diff assert '+ "enforcement": "active"' in update.diff @@ -55,7 +55,7 @@ def test_stale_disabled_ruleset_is_an_update_with_diff(tmp_path): def test_single_codeowner_repo_has_no_second_ruleset(tmp_path): plan = plan_one(tmp_path, "SponsoredData", - "repositories:\n SponsoredData: {class: api-repository, single_codeowner: true}\n") + "repositories:\n SponsoredData: {ruleset_class: api-repository, single_codeowner: true}\n") assert one(plan, "remove", "Codeowner_review_required").ruleset_id == 8441211 assert plan.findings == [] @@ -63,16 +63,16 @@ def test_single_codeowner_repo_has_no_second_ruleset(tmp_path): def test_single_codeowner_flag_cross_checked_against_codeowners(tmp_path): # CODEOWNERS has one owner, the registry says several plan = plan_one(tmp_path, "SponsoredData", - "repositories:\n SponsoredData: {class: api-repository}\n") + "repositories:\n SponsoredData: {ruleset_class: api-repository}\n") assert any("single_codeowner" in f for f in plan.findings) # and the other way round plan = plan_one(tmp_path, "ApplicationEndpointDiscovery", - "repositories:\n ApplicationEndpointDiscovery: {class: api-repository, single_codeowner: true}\n") + "repositories:\n ApplicationEndpointDiscovery: {ruleset_class: api-repository, single_codeowner: true}\n") assert any("single_codeowner" in f for f in plan.findings) def test_other_class_rulesets_are_removed_and_main_rulesets_created(tmp_path): - plan = plan_one(tmp_path, "EdgeCloud", "repositories:\n EdgeCloud: {class: non-api}\n") + plan = plan_one(tmp_path, "EdgeCloud", "repositories:\n EdgeCloud: {ruleset_class: non-api}\n") for name in ("release-snapshot-protection", "release-pointer-protection", "pre-release-pointer-protection"): assert one(plan, "remove", name) assert one(plan, "create", "Only_Codeowner_Can_Merge") @@ -82,14 +82,14 @@ def test_other_class_rulesets_are_removed_and_main_rulesets_created(tmp_path): def test_unmanaged_names_are_listed_and_untouched(tmp_path): plan = plan_one(tmp_path, "ConnectivityQualityManagement", - "repositories:\n ConnectivityQualityManagement: {class: non-api}\n") + "repositories:\n ConnectivityQualityManagement: {ruleset_class: non-api}\n") unmanaged = one(plan, "unmanaged", "code-owner-review-required-with-write-permission-bypass") assert unmanaged.ruleset_id assert ("create", "Only_Codeowner_Can_Merge") in kinds(plan) def test_classic_only_repo(tmp_path): - plan = plan_one(tmp_path, "SimSwap", "repositories:\n SimSwap: {class: api-repository}\n") + plan = plan_one(tmp_path, "SimSwap", "repositories:\n SimSwap: {ruleset_class: api-repository}\n") assert kinds(plan) == [ ("create", "Codeowner_review_required"), ("create", "Only_Codeowner_Can_Merge"), @@ -99,7 +99,7 @@ def test_classic_only_repo(tmp_path): def test_zero_rulesets_and_classic(tmp_path): - plan = plan_one(tmp_path, "ReleaseManagement", "repositories:\n ReleaseManagement: {class: non-api}\n") + plan = plan_one(tmp_path, "ReleaseManagement", "repositories:\n ReleaseManagement: {ruleset_class: non-api}\n") assert kinds(plan) == [ ("create", "Codeowner_review_required"), ("create", "Only_Codeowner_Can_Merge"), @@ -109,7 +109,7 @@ def test_zero_rulesets_and_classic(tmp_path): def test_retired_ruleset_is_removed(tmp_path): plan = plan_one(tmp_path, "ReleaseTest", - "repositories:\n ReleaseTest: {class: api-repository, single_codeowner: true}\n") + "repositories:\n ReleaseTest: {ruleset_class: api-repository, single_codeowner: true}\n") assert one(plan, "remove", "code-owner-review-required-or-sole-codeowner") assert one(plan, "create", "Only_Codeowner_Can_Merge") assert not [a for a in plan.actions if a.ruleset == "Codeowner_review_required"] @@ -118,26 +118,26 @@ def test_retired_ruleset_is_removed(tmp_path): def test_archived_repo_is_skipped(tmp_path): plan = plan_one(tmp_path, "HomeDevicesQoD", - "repositories:\n HomeDevicesQoD: {class: api-repository, archived: true}\n") + "repositories:\n HomeDevicesQoD: {ruleset_class: api-repository, archived: true}\n") assert plan.actions == [] assert plan.skipped == "archived" assert not plan.drift def test_archived_flag_mismatch_is_drift(tmp_path): - plan = plan_one(tmp_path, "HomeDevicesQoD", "repositories:\n HomeDevicesQoD: {class: api-repository}\n") + plan = plan_one(tmp_path, "HomeDevicesQoD", "repositories:\n HomeDevicesQoD: {ruleset_class: api-repository}\n") assert any("archived" in f for f in plan.findings) assert plan.drift def test_unmanaged_class_is_not_touched(tmp_path): - plan = plan_one(tmp_path, "EdgeCloud", "repositories:\n EdgeCloud: {class: unmanaged}\n") + plan = plan_one(tmp_path, "EdgeCloud", "repositories:\n EdgeCloud: {ruleset_class: unmanaged}\n") assert plan.actions == [] assert not plan.drift def test_unregistered_and_missing_repos_are_drift(tmp_path): - cfg = make_config(tmp_path, "repositories:\n Gone: {class: non-api}\n Known: {class: unmanaged}\n") + cfg = make_config(tmp_path, "repositories:\n Gone: {ruleset_class: non-api}\n Known: {ruleset_class: unmanaged}\n") api = FakeAPI({}, extra_repos=["Known", "Fresh"]) plans = {p.repo: p for p in plan_all(api, cfg, ORG)} assert plans["Fresh"].findings == ["unregistered"] @@ -146,7 +146,7 @@ def test_unregistered_and_missing_repos_are_drift(tmp_path): def test_plan_after_apply_is_clean(tmp_path): - cfg = make_config(tmp_path, "repositories:\n EdgeCloud: {class: non-api}\n") + cfg = make_config(tmp_path, "repositories:\n EdgeCloud: {ruleset_class: non-api}\n") api = FakeAPI.from_fixtures(["EdgeCloud"]) plan = plan_all(api, cfg, ORG, only=["EdgeCloud"])[0] assert plan.drift @@ -158,7 +158,7 @@ def test_plan_after_apply_is_clean(tmp_path): def test_bypass_actors_missing_is_an_error_not_a_diff(tmp_path): from scripts.github_api import MissingBypassActors - cfg = make_config(tmp_path, "repositories:\n ApplicationEndpointDiscovery: {class: api-repository}\n") + cfg = make_config(tmp_path, "repositories:\n ApplicationEndpointDiscovery: {ruleset_class: api-repository}\n") api = FakeAPI.from_fixtures(["ApplicationEndpointDiscovery"]) def broken(org, repo, ruleset_id): @@ -172,7 +172,7 @@ def broken(org, repo, ruleset_id): def test_round_trip_export_then_plan_has_no_drift(tmp_path): """A declared file exported from a live ruleset matches that live ruleset.""" - cfg = make_config(tmp_path, "repositories:\n ApplicationEndpointDiscovery: {class: api-repository}\n") + cfg = make_config(tmp_path, "repositories:\n ApplicationEndpointDiscovery: {ruleset_class: api-repository}\n") api = FakeAPI.from_fixtures(["ApplicationEndpointDiscovery"]) plan = plan_repo(api, cfg, ORG, cfg.registry["ApplicationEndpointDiscovery"], api.get_repo(ORG, "ApplicationEndpointDiscovery"))