diff --git a/utilities/dot-project/AGENT.md b/utilities/dot-project/AGENT.md index 2f2aa3b7..bfe8c41f 100644 --- a/utilities/dot-project/AGENT.md +++ b/utilities/dot-project/AGENT.md @@ -13,15 +13,18 @@ utilities/dot-project/ │ ├── landscape-updater/ # Tool to convert project.yaml to landscape format │ ├── staleness-checker/ # Tool to check maintainer data freshness │ ├── audit-checker/ # Tool to verify referenced URLs are accessible -│ └── bootstrap/ # Tool to auto-generate project scaffolds from external data +│ ├── bootstrap/ # Tool to auto-generate project scaffolds from external data +│ └── drift/ # Maintainer health-check tool (opens GitHub issues for stale maintainers.yaml) ├── template/ # Template files for new .project repositories │ ├── project.yaml │ ├── maintainers.yaml -│ └── .github/workflows/validate.yaml +│ ├── .github/workflows/validate.yaml +│ └── .github/workflows/maintainer-drift.yml # Scaffolded health-check workflow ├── example/ # Realistic filled-in example (Kubernetes-like) │ ├── project.yaml │ ├── maintainers.yaml -│ └── .github/workflows/validate.yaml +│ ├── .github/workflows/validate.yaml +│ └── maintainer-health-check-issue.md # Reference rendering of a health-check issue ├── testdata/ # Test fixtures and sample configs ├── bin/ # Build output (gitignored) ├── .cache/ # Validation cache directory (gitignored) @@ -30,6 +33,9 @@ utilities/dot-project/ ├── bootstrap_parsers.go # CODEOWNERS, OWNERS, MAINTAINERS file parsers ├── bootstrap_sources.go # Landscape/CLOMonitor/GitHub API clients, fuzzy matching, data merge ├── bootstrap_scaffold.go # Scaffold generator (project.yaml, maintainers.yaml templates) +├── drift_detector.go # Issue formatter, sortActivityDesc, formatRepoList +├── drift_sources.go # GitHub API fetching: multi-repo activity, PR pagination, team members +├── drift_detector_test.go # Tests for drift detection and formatting ├── validator.go # Project validation logic ├── maintainers.go # Maintainer validation logic with LFX integration ├── landscape.go # Landscape entry conversion and comparison @@ -97,12 +103,13 @@ docker build -t dot-project-validator . make clean ``` -Note: The Makefile `build` target builds the `validator`, `landscape-updater`, and `bootstrap` binaries. The other CLI tools (`staleness-checker`, `audit-checker`) must be built manually: +Note: The Makefile `build` target builds the `validator`, `landscape-updater`, and `bootstrap` binaries. The other CLI tools (`staleness-checker`, `audit-checker`, `drift`) must be built manually: ```bash go build -o bin/landscape-updater ./cmd/landscape-updater go build -o bin/staleness-checker ./cmd/staleness-checker go build -o bin/audit-checker ./cmd/audit-checker +go build -o bin/drift ./cmd/drift ``` ### Running the Validator @@ -214,6 +221,45 @@ Verifies that all URLs referenced in a project (website, artwork, repositories, Exit code 1 if any URL check fails. +### Running the Maintainer Health-Check (drift) + +Fires when `maintainers.yaml` has not been updated in more than the configured number of days. Fetches contributor activity across all repos in `project.yaml` and writes a Markdown issue body. + +```bash +# Check staleness and print issue body to stdout +./bin/drift \ + -project-yaml project.yaml \ + -maintainers-yaml maintainers.yaml \ + -last-updated-days 194 + +# Write issue body to a file (used by the GitHub Actions workflow) +./bin/drift \ + -project-yaml project.yaml \ + -maintainers-yaml maintainers.yaml \ + -last-updated-days 194 \ + -report /tmp/health-report.md + +# Custom staleness threshold (default: 180 days) +./bin/drift -last-updated-days 200 -staleness-days 90 + +# Adjust activity window and concurrency +./bin/drift -activity-months 3 -concurrency 5 -top-contributors 10 +``` + +**drift** (`cmd/drift/main.go`): +- `-project-yaml` — Path to `project.yaml` (default: `project.yaml`) +- `-maintainers-yaml` — Path to `maintainers.yaml` (default: `maintainers.yaml`) +- `-github-token` — GitHub PAT (or set `GITHUB_TOKEN` env var) +- `-team` — Team name in `maintainers.yaml` to read (default: `project-maintainers`) +- `-report` — Write Markdown issue body to this file path (default: stdout) +- `-staleness-days` — Days without update before the health-check fires (default: `180`) +- `-last-updated-days` — Days since `maintainers.yaml` was last git-committed (`-1` = use file mtime) +- `-activity-months` — How many months back to look for contributor activity (default: `6`) +- `-concurrency` — Max parallel GitHub API repo fetches (default: `10`) +- `-top-contributors` — How many non-maintainer contributors to surface (default: `5`) + +Exit codes: `0` = not stale; `1` = stale (issue body written); `2` = fatal error. + ## Testing ### Test Commands @@ -353,6 +399,8 @@ Additional types in domain-specific files: - `--output` - Output format: text, json, yaml (default: `text`) - `--timeout` - HTTP request timeout in seconds (default: 10) +**drift** (`cmd/drift/main.go`): see [Running the Maintainer Health-Check](#running-the-maintainer-health-check-drift) above. + ## Docker ### Build @@ -475,8 +523,109 @@ Edit the corresponding `cmd/*/main.go` file. All CLIs use the standard `flag` pa 3. Use `flag` for argument parsing 4. Support `--output` with text/json/yaml formats for consistency -## Exit Codes +## Testing the Maintainer Health-Check Feature + +The cron trigger fires once a month and only once `maintainers.yaml` is 180 days old, so the feature needs to be tested via four complementary layers: + +### Layer 1 — Unit tests (no network, always fast) + +`go test -short ./...` runs all unit tests. The relevant files are: + +| File | What it covers | +|------|----------------| +| `drift_detector_test.go` | `FormatActivityIssue` rendering, `BuildHealthCheckActivityLists` sorting/filtering, `ParseAllRepos`, `sortActivityDesc`, `isLikelyTeamSlug`, `parseLinkNext`, `LoadProjectHandlesForTeam` | +| `drift_sources_test.go` | HTTP API layer — uses `httptest.NewServer` mocks (no real network calls). Covers: contributor-stats window filtering, 204 no-content, 202 retry logic (skipped with `-short`), non-200 errors, PR merged/unmerged/out-of-window filtering, early-exit pagination, multi-repo fan-out, non-fatal fetch errors, context cancellation, team-members pagination, 403/404 error messages | + +```bash +# Fast: all unit tests, skips the 3-second 202-retry test +make test-short + +# Full: includes the 202-retry slow path +make test +``` + +### Layer 2 — Offline smoke test (no token needed) + +Tests file parsing + staleness detection without touching the GitHub API. Because `-last-updated-days 1` (1 day) is less than the default 180-day threshold, the binary exits `0` ("not stale") before making any network requests. + +```bash +make smoke-drift-offline +# Equivalent: +./bin/drift \ + -project-yaml example/project.yaml \ + -maintainers-yaml example/maintainers.yaml \ + -last-updated-days 1 +``` + +To test the staleness-detection logic specifically (the `daysSince > stalenessDays` gate): + +```bash +# Should exit 0 — 1 day < 180-day threshold +./bin/drift -last-updated-days 1 -staleness-days 180 + +# Should exit 0 — equal is NOT stale (strict greater-than) +./bin/drift -last-updated-days 180 -staleness-days 180 + +# Should exit 1 — 181 > 180, stale, BUT will also call GitHub API +# (use make smoke-drift for this, which needs GITHUB_TOKEN) +./bin/drift -last-updated-days 181 -staleness-days 180 +``` + +### Layer 3 — Live smoke test (needs `GITHUB_TOKEN`) + +Uses `-staleness-days 0` to force the stale path regardless of file age, then runs the full pipeline against the real GitHub API using the Kubernetes example fixtures. Exit code `1` is the expected ("stale") outcome. + +```bash +export GITHUB_TOKEN=ghp_... +make smoke-drift + +# Or manually, writing the issue body to a file for inspection: +GITHUB_TOKEN=$GITHUB_TOKEN ./bin/drift \ + -project-yaml example/project.yaml \ + -maintainers-yaml example/maintainers.yaml \ + -last-updated-days 1 \ + -staleness-days 0 \ + -top-contributors 3 \ + -report /tmp/health-report.md \ + ; echo "exit: $?" +cat /tmp/health-report.md +``` + +### Layer 4 — GitHub Actions workflow (needs a real `.project` repo) + +The scaffolded `template/.github/workflows/maintainer-drift.yml` has a `workflow_dispatch` trigger so the workflow can be triggered manually at any time without waiting for the cron schedule. + +```bash +# Trigger manually from your .project repo's Actions tab, or via CLI: +gh workflow run maintainer-drift.yml \ + --repo /.project \ + --field ref=main +``` + +**To test the full issue-open/update path without a real repo**, use the `act` local runner: + +```bash +brew install act # macOS / Linux +act workflow_dispatch \ + --secret GITHUB_TOKEN="$GITHUB_TOKEN" \ + --workflows .github/workflows/maintainer-drift.yml +``` + +### Cheat-sheet: what each layer covers + +| Layer | Network | Token | What it proves | +|-------|---------|-------|----------------| +| `make test-short` | ✗ | ✗ | All logic paths via mocks — fast, runs in CI | +| `make test` | ✗ | ✗ | Same + 202-retry backoff path | +| `make smoke-drift-offline` | ✗ | ✗ | Binary builds, parses real YAML, exits 0 correctly | +| `make smoke-drift` | ✓ | ✓ | Real GitHub API, full issue body rendered | +| `workflow_dispatch` / `act` | ✓ | ✓ | Full GitHub Actions workflow, issue create/edit | + + -- `0` - All checks passed -- `1` - One or more checks failed (validation errors, stale data, failed URL checks) -- Non-zero for other errors (file not found, parse errors, etc.) +| Code | Meaning | +|------|---------| +| `0` | All checks passed / not stale — no action needed | +| `1` | One or more checks failed (validation errors, stale data, failed URL checks) **or** `drift`: `maintainers.yaml` is stale — issue body written to `-report` | +| `2` | `drift` only: fatal error (bad flags, unreadable file, GitHub API failure) — do **not** open an issue | +| Non-zero | Other errors (file not found, parse errors, etc.) | diff --git a/utilities/dot-project/Makefile b/utilities/dot-project/Makefile index ea5cb3b4..8b0fcaae 100644 --- a/utilities/dot-project/Makefile +++ b/utilities/dot-project/Makefile @@ -17,6 +17,12 @@ build: @echo "Building bootstrap tool..." go build -o bin/bootstrap ./cmd/bootstrap +# Build the drift health-check binary +build-drift: + @echo "Building drift binary..." + @mkdir -p bin + go build -o bin/drift ./cmd/drift + # Build docker image docker-build: @echo "Building docker image..." @@ -27,6 +33,11 @@ test: @echo "Running tests..." go test -v ./... +# Run tests excluding slow retry tests (no network needed) +test-short: + @echo "Running tests (short mode — skips slow retry tests)..." + go test -short -v ./... + # Run tests with coverage test-coverage: @echo "Running tests with coverage..." @@ -87,6 +98,50 @@ test-setup: test-run: test-setup build REPO_ROOT=$(REPO_ROOT) ./bin/validator --config testdata/test-projectlist.yaml +# ── Drift health-check smoke tests ────────────────────────────────────────────── + +# Offline smoke: tests file parsing + the non-stale exit-0 path. +# No GITHUB_TOKEN required — exits before any API call is made. +# +# -last-updated-days 1 → 1 day since last commit +# default -staleness-days 180 → 1 < 180, not stale → exit 0 +smoke-drift-offline: build-drift + @echo "Smoke-testing drift (offline, not-stale path)..." + ./bin/drift \ + -project-yaml example/project.yaml \ + -maintainers-yaml example/maintainers.yaml \ + -last-updated-days 1 + @echo "smoke-drift-offline passed (exit 0 as expected)" + +# Live smoke: tests the full stale path including GitHub API calls. +# Requires GITHUB_TOKEN with public_repo read access. +# +# -staleness-days 0 → any age is stale, forces the stale path +# -last-updated-days 1 → simulate 1-day-old file +# exit 1 (stale) is the expected outcome — treated as success here. +smoke-drift: build-drift + @if [ -z "$(GITHUB_TOKEN)" ]; then \ + echo "ERROR: GITHUB_TOKEN is not set. Export a token with public_repo read access."; \ + exit 1; \ + fi + @echo "Smoke-testing drift (live GitHub API, forced-stale path)..." + @EXIT=0; \ + GITHUB_TOKEN=$(GITHUB_TOKEN) ./bin/drift \ + -project-yaml example/project.yaml \ + -maintainers-yaml example/maintainers.yaml \ + -last-updated-days 1 \ + -staleness-days 0 \ + -top-contributors 3 \ + || EXIT=$$?; \ + if [ "$$EXIT" -eq 1 ]; then \ + echo "smoke-drift PASSED (exit 1 = stale path, issue body printed above)"; \ + elif [ "$$EXIT" -eq 0 ]; then \ + echo "smoke-drift WARNING: exit 0 unexpectedly (check -staleness-days flag)"; \ + else \ + echo "smoke-drift FAILED: exit $$EXIT (tool error — check stderr above)"; \ + exit $$EXIT; \ + fi + # Provision a .project repo (prints usage if no args given) provision: build @if [ -z "$(ORG)" ]; then \ @@ -105,18 +160,22 @@ provision: build # Show help help: @echo "Available targets:" - @echo " build - Build the validator, landscape-updater, and bootstrap binaries" - @echo " docker-build - Build docker image" - @echo " test - Run tests" - @echo " test-coverage - Run tests with coverage report" - @echo " clean - Clean build artifacts" - @echo " install - Install dependencies" - @echo " run - Run validator with default settings" - @echo " run-changes - Show only changes and summary" - @echo " fmt - Format Go code" - @echo " lint - Run linter" - @echo " security - Run security checks" - @echo " test-setup - Create test environment" - @echo " test-run - Run with test setup" - @echo " provision - Provision a .project repo (see: make provision)" - @echo " help - Show this help message" + @echo " build - Build validator, landscape-updater, and bootstrap binaries" + @echo " build-drift - Build the drift health-check binary" + @echo " docker-build - Build docker image" + @echo " test - Run all tests" + @echo " test-short - Run tests, skipping slow retry tests" + @echo " test-coverage - Run tests with coverage report" + @echo " clean - Clean build artifacts" + @echo " install - Install dependencies" + @echo " run - Run validator with default settings" + @echo " run-changes - Show only changes and summary" + @echo " fmt - Format Go code" + @echo " lint - Run linter" + @echo " security - Run security checks" + @echo " test-setup - Create test environment" + @echo " test-run - Run with test setup" + @echo " smoke-drift-offline - Smoke-test drift binary (no token, non-stale path)" + @echo " smoke-drift - Smoke-test drift binary against live GitHub API (needs GITHUB_TOKEN)" + @echo " provision - Provision a .project repo (see: make provision)" + @echo " help - Show this help message" diff --git a/utilities/dot-project/bootstrap_scaffold.go b/utilities/dot-project/bootstrap_scaffold.go index 0383bd4a..61ddb333 100644 --- a/utilities/dot-project/bootstrap_scaffold.go +++ b/utilities/dot-project/bootstrap_scaffold.go @@ -194,6 +194,136 @@ jobs: LFX_AUTH_TOKEN: ${{ secrets.LFX_AUTH_TOKEN }} ` +// maintainerDriftWorkflowTemplate is the raw template for maintainer-drift.yml. +// It contains a single literal placeholder {TIMEOUT_MINUTES} that is replaced +// at package init time with the value derived from DefaultHTTPTimeout. +const maintainerDriftWorkflowTemplate = `name: Maintainer Health Check + +on: + schedule: + - cron: '0 8 1 * *' # Monthly on the 1st at 08:00 UTC + workflow_dispatch: # Also triggerable on demand + +permissions: + issues: write # open / update health-check issues + +jobs: + health-check: + runs-on: ubuntu-latest + # timeout = DefaultHTTPTimeout (30s) × 30 = 900s = 15 min (defaults.go) + # Managed by bootstrap_scaffold.go — do not edit this value directly. + timeout-minutes: "{TIMEOUT_MINUTES}" + steps: + - name: Checkout .project repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + fetch-depth: 0 # needed for git log age calculation + + - name: Checkout cncf/automation (health-check tooling) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + repository: cncf/automation + # Pinned to the SHA that introduced this workflow. + # Update this SHA when pulling in upstream health-check tooling changes. + ref: 74b6112a55fe17053d90c4856037436ce6d01b85 + path: .cncf-automation + sparse-checkout: utilities/dot-project + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 + with: + go-version-file: .cncf-automation/utilities/dot-project/go.mod + + - name: Build drift binary + run: | + cd .cncf-automation/utilities/dot-project + go build -o /usr/local/bin/drift ./cmd/drift + + - name: Get days since maintainers.yaml last changed + id: git_age + run: | + LAST_TS=$(git log -1 --format=%ct -- maintainers.yaml 2>/dev/null || echo "") + if [ -z "$LAST_TS" ]; then + DAYS=0 + else + DAYS=$(( ( $(date +%s) - LAST_TS ) / 86400 )) + fi + echo "days=${DAYS}" >> "$GITHUB_OUTPUT" + + - name: Run maintainer health check + id: healthcheck + run: | + EXIT_CODE=0 + drift \ + -project-yaml project.yaml \ + -maintainers-yaml maintainers.yaml \ + -report /tmp/health-report.md \ + -last-updated-days "${{ steps.git_age.outputs.days }}" \ + || EXIT_CODE=$? + + # exit 0 = not stale, no action needed + # exit 1 = stale, issue body written to /tmp/health-report.md + # exit 2 = tool error — log and don't open an issue + if [ "$EXIT_CODE" -eq 1 ]; then + echo "is_stale=true" >> "$GITHUB_OUTPUT" + elif [ "$EXIT_CODE" -eq 2 ]; then + echo "is_stale=false" >> "$GITHUB_OUTPUT" + echo "::error::health-check tool exited with code 2 — check step logs above for the root cause (bad project.yaml / maintainers.yaml / GitHub API error)" + else + echo "is_stale=false" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true # prevent tool errors from failing the workflow + + - name: Open or update health-check issue + if: steps.healthcheck.outputs.is_stale == 'true' + run: | + LABEL="maintainer-health" + TITLE="Maintainer list health check — please review" + + # Ensure the label exists (idempotent). + gh label create "$LABEL" \ + --description "Automated maintainer health-check ping" \ + --color "e4e669" 2>/dev/null || true + + # Check for an existing open issue with this label. + EXISTING=$(gh issue list \ + --label "$LABEL" \ + --state open \ + --json number \ + --jq '.[0].number' 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Updating existing health-check issue #${EXISTING}" + gh issue edit "$EXISTING" \ + --body-file /tmp/health-report.md + gh issue comment "$EXISTING" \ + --body "Health check re-run on $(date +%Y-%m-%d) — issue body updated above." + else + echo "Opening new health-check issue" + gh issue create \ + --title "$TITLE" \ + --body-file /tmp/health-report.md \ + --label "$LABEL" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +` + +// maintainerDriftWorkflowContent is maintainerDriftWorkflowTemplate with the +// {TIMEOUT_MINUTES} placeholder replaced by the job timeout derived from +// DefaultHTTPTimeout. The job must accommodate a Go build plus several +// concurrent GitHub API calls, so we allow 30× the per-request HTTP timeout: +// +// DefaultHTTPTimeout (30s) × 30 = 900s = 15 minutes +var maintainerDriftWorkflowContent = strings.ReplaceAll( + maintainerDriftWorkflowTemplate, + "{TIMEOUT_MINUTES}", + fmt.Sprintf("%d", int((DefaultHTTPTimeout*30).Minutes())), +) + // updateLandscapeWorkflowContent is the SHA-pinned update-landscape.yml workflow. const updateLandscapeWorkflowContent = `name: Update Landscape on: @@ -392,6 +522,7 @@ func WriteScaffold(dir string, result *BootstrapResult, opts ...WriteScaffoldOpt {".gitignore", staticGen(gitignoreContent)}, {".github/workflows/validate.yaml", staticGen(validateWorkflowContent)}, {".github/workflows/update-landscape.yml", staticGen(updateLandscapeWorkflowContent)}, + {".github/workflows/maintainer-drift.yml", staticGen(maintainerDriftWorkflowContent)}, } // Conditional: SECURITY.md — skip if an existing security policy was discovered diff --git a/utilities/dot-project/bootstrap_sources.go b/utilities/dot-project/bootstrap_sources.go index 44c54e90..147dea8e 100644 --- a/utilities/dot-project/bootstrap_sources.go +++ b/utilities/dot-project/bootstrap_sources.go @@ -15,7 +15,6 @@ import ( const ( cloMonitorSearchPath = "/api/projects/search" defaultCLOMonitorURL = "https://clomonitor.io" - defaultGitHubAPIURL = "https://api.github.com" defaultLandscapeYAMLURL = "https://raw.githubusercontent.com/cncf/landscape/master/landscape.yml" landscapeLogoBaseURL = "https://landscape.cncf.io/logos/" ) diff --git a/utilities/dot-project/cmd/bootstrap/main.go b/utilities/dot-project/cmd/bootstrap/main.go index b403b804..7c3dc6d0 100644 --- a/utilities/dot-project/cmd/bootstrap/main.go +++ b/utilities/dot-project/cmd/bootstrap/main.go @@ -199,6 +199,7 @@ func main() { fmt.Fprintf(os.Stderr, " - .gitignore\n") fmt.Fprintf(os.Stderr, " - .github/workflows/validate.yaml\n") fmt.Fprintf(os.Stderr, " - .github/workflows/update-landscape.yml\n") + fmt.Fprintf(os.Stderr, " - .github/workflows/maintainer-drift.yml\n") // Report discovered file URLs if result.SecurityPolicyURL != "" || result.ContributingURL != "" || result.CodeOfConductURL != "" || result.LicenseURL != "" { diff --git a/utilities/dot-project/cmd/drift/main.go b/utilities/dot-project/cmd/drift/main.go new file mode 100644 index 00000000..cd2dc95d --- /dev/null +++ b/utilities/dot-project/cmd/drift/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "time" + + "gopkg.in/yaml.v3" + + "projects" +) + +// main is the entry point for the drift health-check CLI. +// +// Exit codes: +// +// 0 — not stale, no action needed +// 1 — stale; issue body written to -report (workflow should open/update issue) +// 2 — fatal error (bad flags, unreadable file, GitHub API failure) +func main() { + var ( + projectYAMLPath = flag.String("project-yaml", "project.yaml", "Path to project.yaml") + maintainersPath = flag.String("maintainers-yaml", "maintainers.yaml", "Path to maintainers.yaml") + githubToken = flag.String("github-token", "", "GitHub personal access token (or set GITHUB_TOKEN env var)") + teamName = flag.String("team", "project-maintainers", "Team name in maintainers.yaml to read") + reportPath = flag.String("report", "", "Write Markdown issue body to this file path") + stalenessDays = flag.Int("staleness-days", projects.DefaultStalenessThresholdDays, + "Days without an update before the health-check issue fires") + lastUpdatedDays = flag.Int("last-updated-days", -1, + "Days since maintainers.yaml was last git-committed (-1 = use file mtime)") + activityMonths = flag.Int("activity-months", projects.DefaultActivityWindowMonths, + "How many months back to look for contributor activity") + concurrency = flag.Int("concurrency", projects.DefaultConcurrency, + "Max parallel GitHub API repo fetches") + topContributors = flag.Int("top-contributors", projects.DefaultTopContributors, + "How many non-maintainer contributors to surface in the issue body") + ) + flag.Parse() + + // Context is cancelled on SIGINT (Ctrl-C), which propagates to all + // in-flight HTTP requests and lets the process exit cleanly. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + token := *githubToken + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + + client := &http.Client{Timeout: projects.DefaultHTTPTimeout} + + // ── Staleness check ─────────────────────────────────────────────────────── + daysSince := *lastUpdatedDays + if daysSince < 0 { + // Fall back to file mtime (unreliable in CI; prefer git log via -last-updated-days). + if fi, err := os.Stat(*maintainersPath); err == nil { + daysSince = int(time.Since(fi.ModTime()).Hours() / 24) + } + } + + isStale := daysSince >= 0 && daysSince > *stalenessDays + if !isStale { + fmt.Fprintf(os.Stderr, "not stale (%d/%d days) — no action needed\n", + daysSince, *stalenessDays) + os.Exit(0) + } + + fmt.Fprintf(os.Stderr, "stale: %d days since last update (threshold: %d)\n", + daysSince, *stalenessDays) + + // ── Load project data ───────────────────────────────────────────────────── + maintainerHandles, projectID, org, err := projects.LoadProjectHandlesForTeam(*maintainersPath, *teamName) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + + projData, err := os.ReadFile(*projectYAMLPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading %s: %v\n", *projectYAMLPath, err) + os.Exit(2) + } + var project projects.Project + if err := yaml.Unmarshal(projData, &project); err != nil { + fmt.Fprintf(os.Stderr, "error parsing %s: %v\n", *projectYAMLPath, err) + os.Exit(2) + } + + repos := projects.ParseAllRepos(project.Repositories) + if len(repos) == 0 { + // Fall back: use org from maintainers.yaml as single repo. + if org != "" { + repos = []projects.RepoRef{{Org: org, Repo: org}} + fmt.Fprintf(os.Stderr, "warning: no GitHub repos in project.yaml; falling back to %s/%s\n", org, org) + } else { + fmt.Fprintln(os.Stderr, "error: no GitHub repositories found in project.yaml") + os.Exit(2) + } + } + + fmt.Fprintf(os.Stderr, "scanning %d repo(s) for activity (window: %d months, concurrency: %d)\n", + len(repos), *activityMonths, *concurrency) + + // ── Fetch contributor activity across all repos ─────────────────────────── + since := time.Now().UTC().AddDate(0, -*activityMonths, 0) + activity, scanned, fetchErrs := projects.FetchAllRepoActivity( + ctx, repos, since, token, client, "", *concurrency, + ) + + // Log fetch errors as warnings — they're non-fatal (partial data is still useful). + for _, e := range fetchErrs { + fmt.Fprintf(os.Stderr, "warning: %v\n", e) + } + + fmt.Fprintf(os.Stderr, "fetched activity for %d repo(s), found %d unique contributor(s)\n", + len(scanned), len(activity)) + + // ── Build activity lists ────────────────────────────────────────────────── + maintainerActivity, topActive := projects.BuildHealthCheckActivityLists( + activity, maintainerHandles, *topContributors, + ) + + // Pick up to 3 handles to @mention in the issue greeting, choosing the + // most active maintainers first (maintainerActivity is already sorted by + // activity desc). This mirrors the onboarding issue convention in + // provision.sh::create_onboarding_issue() while preferring handles that + // are clearly still engaged with the project. + const maxMentions = 3 + var mentionHandles []string + for _, a := range maintainerActivity { + if len(mentionHandles) >= maxMentions { + break + } + mentionHandles = append(mentionHandles, a.Handle) + } + + result := projects.HealthCheckResult{ + ProjectID: projectID, + Org: org, + TeamName: *teamName, + IsStale: true, + DaysSinceUpdate: daysSince, + StalenessDaysThreshold: *stalenessDays, + MentionHandles: mentionHandles, + MaintainerActivity: maintainerActivity, + TopNewContributors: topActive, + CheckedAt: time.Now().UTC(), + } + + // ── Write issue body ────────────────────────────────────────────────────── + issueBody := projects.FormatActivityIssue(result) + + if *reportPath != "" { + if err := os.WriteFile(*reportPath, []byte(issueBody), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "error writing report to %s: %v\n", *reportPath, err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "issue body written to %s\n", *reportPath) + } else { + fmt.Print(issueBody) + } + + // ── Summary ─────────────────────────────────────────────────────────────── + fmt.Fprintf(os.Stderr, "summary: project=%s stale=%d days maintainers=%d active-contributors=%d\n", + projectID, daysSince, len(maintainerHandles), len(topActive)) + + os.Exit(1) // stale — caller (workflow) should open/update the issue +} diff --git a/utilities/dot-project/defaults.go b/utilities/dot-project/defaults.go index e2417e8c..a6d0fe26 100644 --- a/utilities/dot-project/defaults.go +++ b/utilities/dot-project/defaults.go @@ -13,6 +13,20 @@ const ( // project's maintainer data is considered stale. DefaultStalenessThresholdDays = 180 + // DefaultActivityWindowMonths is how far back (in months) the maintainer + // health check looks when measuring contributor activity. + DefaultActivityWindowMonths = 6 + + // DefaultConcurrency is the maximum number of GitHub API repo fetches + // that the maintainer health check runs in parallel. Keeps total + // inflight requests well inside GitHub's 5 000 req/hr REST limit even + // for very large projects (Kubernetes-scale). + DefaultConcurrency = 10 + + // DefaultTopContributors is how many non-maintainer contributors to + // surface in the health-check issue body. + DefaultTopContributors = 5 + // DefaultDCOCommitSampleSize is how many recent commits we fetch when // detecting whether a repo uses DCO (Signed-off-by). DefaultDCOCommitSampleSize = 20 @@ -24,4 +38,9 @@ const ( // DefaultFuzzyMatchWeight is the weight applied to partial word-match // scores when fuzzy-matching project names against landscape entries. DefaultFuzzyMatchWeight = 0.5 + + // defaultGitHubAPIURL is the base URL for the GitHub REST API. + // It is kept unexported because callers pass it as an override parameter; + // "" (empty string) is the idiomatic "use the default" sentinel. + defaultGitHubAPIURL = "https://api.github.com" ) diff --git a/utilities/dot-project/drift_detector.go b/utilities/dot-project/drift_detector.go new file mode 100644 index 00000000..4cd73988 --- /dev/null +++ b/utilities/dot-project/drift_detector.go @@ -0,0 +1,155 @@ +package projects + +import ( + "fmt" + "os" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// LoadProjectHandlesForTeam reads maintainers.yaml and returns the GitHub handles +// for the named team (e.g., "project-maintainers"), plus the project_id and org. +// +// If the team is not found, it returns an empty slice (not an error) so the +// caller can decide whether to treat that as noteworthy. +func LoadProjectHandlesForTeam(path, teamName string) (handles []string, projectID, org string, err error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, "", "", fmt.Errorf("reading %s: %w", path, err) + } + + var config MaintainersConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, "", "", fmt.Errorf("parsing %s: %w", path, err) + } + + if len(config.Maintainers) == 0 { + return nil, "", "", fmt.Errorf("%s has no maintainer entries", path) + } + + // Each org/.project repo contains exactly one MaintainerEntry (one project + // per repo). We read only [0]; if someone mistakenly adds a second entry + // it is silently ignored — the validator will catch that separately. + entry := config.Maintainers[0] + projectID = entry.ProjectID + org = entry.Org + + for _, team := range entry.Teams { + if team.Name != teamName { + continue + } + for _, m := range team.Members { + m = strings.TrimSpace(m) + m = strings.TrimPrefix(m, "@") + if m != "" { + handles = append(handles, strings.ToLower(m)) + } + } + return handles, projectID, org, nil + } + + // Team not found; return empty slice so caller can reason about it. + return []string{}, projectID, org, nil +} + +// FormatActivityIssue renders the Markdown body for the maintainer +// health-check GitHub issue from a HealthCheckResult. +func FormatActivityIssue(result HealthCheckResult) string { + var b strings.Builder + + b.WriteString(fmt.Sprintf("## Maintainer List Health Check — `%s`\n\n", result.ProjectID)) + + // Greet up to 3 maintainers by @mention, mirroring the onboarding issue pattern. + if len(result.MentionHandles) > 0 { + var mentions []string + for _, h := range result.MentionHandles { + mentions = append(mentions, "@"+h) + } + b.WriteString(fmt.Sprintf("Hi %s 👋\n\n", strings.Join(mentions, " "))) + } + + b.WriteString(fmt.Sprintf( + "`maintainers.yaml` has not been updated in **%d days** (threshold: %d days).\n"+ + "Please confirm the list below still reflects your project's governance model.\n\n", + result.DaysSinceUpdate, result.StalenessDaysThreshold, + )) + b.WriteString(fmt.Sprintf("🕒 **Checked:** %s \n", + result.CheckedAt.UTC().Format("2006-01-02 15:04 UTC"))) + b.WriteString(fmt.Sprintf("📅 **Activity window:** last %d months\n\n", + DefaultActivityWindowMonths)) + + // ── Current maintainers ────────────────────────────────────────────────── + b.WriteString("### Current Maintainers — Activity\n\n") + if len(result.MaintainerActivity) == 0 { + b.WriteString("_No maintainers found in the `" + result.TeamName + "` team._\n\n") + } else { + b.WriteString("| Maintainer | Commits | Merged PRs | Active repos | Last seen |\n") + b.WriteString("|---|---|---|---|---|\n") + for _, a := range result.MaintainerActivity { + lastSeen := "—" + if !a.LastSeen.IsZero() { + lastSeen = a.LastSeen.UTC().Format("2006-01-02") + } + b.WriteString(fmt.Sprintf("| `@%s` | %d | %d | %s | %s |\n", + a.Handle, a.Commits, a.MergedPRs, formatRepoList(a.ReposTouched), lastSeen)) + } + b.WriteString("\n") + } + + // ── Active contributors not yet in the maintainer list ─────────────────── + if len(result.TopNewContributors) > 0 { + b.WriteString("### Active Contributors Not Yet in Maintainer List\n\n") + b.WriteString(fmt.Sprintf( + "These contributors have been active across project repos in the last %d months "+ + "but do not appear in `maintainers.yaml`. "+ + "Consider whether any should be nominated per your governance process.\n\n", + DefaultActivityWindowMonths, + )) + b.WriteString("| Handle | Commits | Merged PRs | Active repos |\n") + b.WriteString("|---|---|---|---|\n") + for _, a := range result.TopNewContributors { + b.WriteString(fmt.Sprintf("| `@%s` | %d | %d | %s |\n", + a.Handle, a.Commits, a.MergedPRs, formatRepoList(a.ReposTouched))) + } + b.WriteString("\n") + } + + b.WriteString("---\n") + b.WriteString("> **No changes are required automatically.** " + + "Please update `maintainers.yaml` if this no longer reflects your governance, " + + "or close this issue to confirm it is still accurate. " + + "This issue was opened by [cncf/automation](https://github.com/cncf/automation) " + + "maintainer health-check tooling.\n") + + return b.String() +} + +// sortActivityDesc sorts an ActivitySummary slice by total activity +// (commits + merged PRs) descending, with handle as a stable tiebreaker. +func sortActivityDesc(summaries []ActivitySummary) { + sort.Slice(summaries, func(i, j int) bool { + ti := summaries[i].Commits + summaries[i].MergedPRs + tj := summaries[j].Commits + summaries[j].MergedPRs + if ti != tj { + return ti > tj + } + return summaries[i].Handle < summaries[j].Handle + }) +} + +// formatRepoList renders a repo list for a Markdown table cell. +// Up to repoInlineMax repos are shown by name; any remainder is shown as +// "+N more" so the table cell stays readable even for Kubernetes-scale maintainers. +func formatRepoList(repos []string) string { + const repoInlineMax = 3 + if len(repos) == 0 { + return "—" + } + if len(repos) <= repoInlineMax { + return strings.Join(repos, ", ") + } + visible := strings.Join(repos[:repoInlineMax], ", ") + return fmt.Sprintf("%s +%d more", visible, len(repos)-repoInlineMax) +} diff --git a/utilities/dot-project/drift_detector_test.go b/utilities/dot-project/drift_detector_test.go new file mode 100644 index 00000000..827f0e4e --- /dev/null +++ b/utilities/dot-project/drift_detector_test.go @@ -0,0 +1,470 @@ +package projects + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// ── LoadProjectHandlesForTeam ───────────────────────────────────────────────── + +func TestLoadProjectHandlesForTeam(t *testing.T) { + const yamlFixture = ` +maintainers: + - project_id: "testproj" + org: "testorg" + teams: + - name: "project-maintainers" + members: + - alice + - "@bob" + - " Carol " + - name: "reviewers" + members: + - dave +` + + dir := t.TempDir() + path := filepath.Join(dir, "maintainers.yaml") + if err := os.WriteFile(path, []byte(yamlFixture), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + teamName string + wantHandles []string + wantID string + wantOrg string + wantErr bool + }{ + { + name: "found team — normalises case and @ prefix", + teamName: "project-maintainers", + wantHandles: []string{"alice", "bob", "carol"}, + wantID: "testproj", + wantOrg: "testorg", + }, + { + name: "second team", + teamName: "reviewers", + wantHandles: []string{"dave"}, + wantID: "testproj", + wantOrg: "testorg", + }, + { + name: "team not found — returns empty slice, no error", + teamName: "nonexistent", + wantHandles: []string{}, + wantID: "testproj", + wantOrg: "testorg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handles, id, org, err := LoadProjectHandlesForTeam(path, tt.teamName) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(handles, tt.wantHandles) { + t.Errorf("handles: got %v, want %v", handles, tt.wantHandles) + } + if id != tt.wantID { + t.Errorf("projectID: got %q, want %q", id, tt.wantID) + } + if org != tt.wantOrg { + t.Errorf("org: got %q, want %q", org, tt.wantOrg) + } + }) + } + + t.Run("missing file returns error", func(t *testing.T) { + _, _, _, err := LoadProjectHandlesForTeam(filepath.Join(dir, "nosuchfile.yaml"), "project-maintainers") + if err == nil { + t.Error("expected error for missing file, got nil") + } + }) + + t.Run("empty maintainers list returns error", func(t *testing.T) { + empty := filepath.Join(dir, "empty.yaml") + _ = os.WriteFile(empty, []byte("maintainers: []\n"), 0o644) + _, _, _, err := LoadProjectHandlesForTeam(empty, "project-maintainers") + if err == nil { + t.Error("expected error for empty maintainers, got nil") + } + }) +} + +// ── ParseAllRepos ───────────────────────────────────────────────────────────── + +func TestParseAllRepos(t *testing.T) { + tests := []struct { + name string + repositories []string + want []RepoRef + }{ + { + name: "single repo", + repositories: []string{"https://github.com/kubernetes/kubernetes"}, + want: []RepoRef{{Org: "kubernetes", Repo: "kubernetes"}}, + }, + { + name: "multiple repos preserve order", + repositories: []string{ + "https://github.com/org/first", + "https://github.com/org/second", + "https://github.com/org/third", + }, + want: []RepoRef{ + {Org: "org", Repo: "first"}, + {Org: "org", Repo: "second"}, + {Org: "org", Repo: "third"}, + }, + }, + { + name: "skips non-github URLs", + repositories: []string{ + "https://gitlab.com/org/repo", + "https://github.com/org/repo", + }, + want: []RepoRef{{Org: "org", Repo: "repo"}}, + }, + { + name: "trailing slash stripped", + repositories: []string{"https://github.com/argoproj/argo-cd/"}, + want: []RepoRef{{Org: "argoproj", Repo: "argo-cd"}}, + }, + { + name: "deep path — only org/repo extracted", + repositories: []string{"https://github.com/org/repo/tree/main/subdir"}, + want: []RepoRef{{Org: "org", Repo: "repo"}}, + }, + { + name: "empty list", + repositories: []string{}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseAllRepos(tt.repositories) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +// ── ParsePrimaryRepo ───────────────────────────────────────────────────────── + +func TestParsePrimaryRepo(t *testing.T) { + tests := []struct { + name string + repositories []string + wantOrg string + wantRepo string + wantErr bool + }{ + { + name: "simple github URL", + repositories: []string{"https://github.com/kubernetes/kubernetes"}, + wantOrg: "kubernetes", + wantRepo: "kubernetes", + }, + { + name: "trailing slash stripped", + repositories: []string{"https://github.com/argoproj/argo-cd/"}, + wantOrg: "argoproj", + wantRepo: "argo-cd", + }, + { + name: "picks first github URL and ignores rest", + repositories: []string{"https://github.com/org/first", "https://github.com/org/second"}, + wantOrg: "org", + wantRepo: "first", + }, + { + name: "skips non-github URLs", + repositories: []string{"https://gitlab.com/org/repo", "https://github.com/org/repo"}, + wantOrg: "org", + wantRepo: "repo", + }, + { + name: "deep path — only org/repo extracted", + repositories: []string{"https://github.com/org/repo/tree/main/subdir"}, + wantOrg: "org", + wantRepo: "repo", + }, + { + name: "no github URLs — error", + repositories: []string{"https://gitlab.com/org/repo"}, + wantErr: true, + }, + { + name: "empty list — error", + repositories: []string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + org, repo, err := ParsePrimaryRepo(tt.repositories) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if org != tt.wantOrg { + t.Errorf("org: got %q, want %q", org, tt.wantOrg) + } + if repo != tt.wantRepo { + t.Errorf("repo: got %q, want %q", repo, tt.wantRepo) + } + }) + } +} + +// ── BuildHealthCheckActivityLists ──────────────────────────────────────────── + +func TestBuildHealthCheckActivityLists(t *testing.T) { + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + activity := map[string]*ActivitySummary{ + "alice": {Handle: "alice", Commits: 50, MergedPRs: 10, ReposTouched: []string{"repo"}, LastSeen: t0}, + "bob": {Handle: "bob", Commits: 5, MergedPRs: 1, ReposTouched: []string{"repo"}, LastSeen: t0}, + "dave": {Handle: "dave", Commits: 30, MergedPRs: 8, ReposTouched: []string{"repo"}, LastSeen: t0}, + "eve": {Handle: "eve", Commits: 20, MergedPRs: 5, ReposTouched: []string{"repo"}, LastSeen: t0}, + } + + t.Run("maintainers sorted by activity desc", func(t *testing.T) { + maintainers, _ := BuildHealthCheckActivityLists(activity, []string{"alice", "bob"}, 10) + if len(maintainers) != 2 { + t.Fatalf("got %d maintainers, want 2", len(maintainers)) + } + if maintainers[0].Handle != "alice" { + t.Errorf("expected alice first (most active), got %s", maintainers[0].Handle) + } + }) + + t.Run("inactive maintainer still shown with zero activity", func(t *testing.T) { + maintainers, _ := BuildHealthCheckActivityLists(activity, []string{"alice", "carol"}, 10) + found := false + for _, m := range maintainers { + if m.Handle == "carol" && m.Commits == 0 && m.MergedPRs == 0 { + found = true + } + } + if !found { + t.Error("expected carol with zero activity to appear in maintainer list") + } + }) + + t.Run("top contributors excludes maintainers", func(t *testing.T) { + _, top := BuildHealthCheckActivityLists(activity, []string{"alice", "bob"}, 10) + for _, c := range top { + if c.Handle == "alice" || c.Handle == "bob" { + t.Errorf("maintainer %s should not appear in top contributors", c.Handle) + } + } + }) + + t.Run("top contributors capped at topN", func(t *testing.T) { + _, top := BuildHealthCheckActivityLists(activity, []string{"alice"}, 1) + if len(top) > 1 { + t.Errorf("expected at most 1 top contributor, got %d", len(top)) + } + }) + + t.Run("top contributors sorted by activity desc", func(t *testing.T) { + _, top := BuildHealthCheckActivityLists(activity, []string{"alice"}, 10) + for i := 1; i < len(top); i++ { + prev := top[i-1].Commits + top[i-1].MergedPRs + curr := top[i].Commits + top[i].MergedPRs + if curr > prev { + t.Errorf("top contributors not sorted: %s(%d) before %s(%d)", + top[i-1].Handle, prev, top[i].Handle, curr) + } + } + }) + + t.Run("team slugs excluded from top contributors", func(t *testing.T) { + actWithSlug := map[string]*ActivitySummary{ + "alice": {Handle: "alice", Commits: 5, MergedPRs: 1}, + "sig-approvers": {Handle: "sig-approvers", Commits: 100, MergedPRs: 50}, + } + _, top := BuildHealthCheckActivityLists(actWithSlug, []string{}, 10) + for _, c := range top { + if c.Handle == "sig-approvers" { + t.Error("team slug sig-approvers should be excluded from top contributors") + } + } + }) +} + +// ── FormatActivityIssue ─────────────────────────────────────────────────────── + +func TestFormatActivityIssue(t *testing.T) { + result := HealthCheckResult{ + ProjectID: "myproject", + Org: "myorg", + TeamName: "project-maintainers", + IsStale: true, + DaysSinceUpdate: 200, + StalenessDaysThreshold: 180, + MentionHandles: []string{"alice", "bob"}, + MaintainerActivity: []ActivitySummary{ + {Handle: "alice", Commits: 10, MergedPRs: 2, ReposTouched: []string{"myproject"}, + LastSeen: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)}, + {Handle: "bob", Commits: 0, MergedPRs: 0}, // zero LastSeen → "—" + }, + TopNewContributors: []ActivitySummary{ + {Handle: "carol", Commits: 8, MergedPRs: 3, ReposTouched: []string{"myproject-sdk"}}, + }, + CheckedAt: time.Date(2026, 4, 29, 8, 0, 0, 0, time.UTC), + } + + body := FormatActivityIssue(result) + + checks := []struct { + desc string + mustHave string + }{ + {"project ID in heading", "myproject"}, + {"mention alice in greeting", "@alice"}, + {"mention bob in greeting", "@bob"}, + {"greeting emoji", "👋"}, + {"staleness days", "200 days"}, + {"threshold days", "180 days"}, + {"maintainer alice in table", "| `@alice`"}, + {"maintainer bob (zero activity)", "| `@bob`"}, + {"alice last seen date", "2026-01-15"}, + {"bob last seen dash", "| — |"}, + {"top contributor carol", "@carol"}, + {"no auto-changes note", "No changes are required automatically"}, + {"activity window", "6 months"}, + } + + for _, c := range checks { + t.Run(c.desc, func(t *testing.T) { + if !strings.Contains(body, c.mustHave) { + t.Errorf("issue body missing %q", c.mustHave) + } + }) + } +} + +// ── isLikelyTeamSlug ───────────────────────────────────────────────────────── + +func TestIsLikelyTeamSlug(t *testing.T) { + tests := []struct { + handle string + want bool + }{ + // Should be filtered (team slugs) + {"sig-release", true}, + {"sig-contributor-experience-approvers", true}, + {"wg-security", true}, + {"committee-steering", true}, + {"toc-bootstrap", true}, + {"tag-security", true}, + {"cncf-ambassador", true}, + {"k8s-infra-owners", true}, + {"core-approvers", true}, + {"repo-reviewers", true}, + {"project-maintainers", true}, + {"team-leads", true}, + {"org-members", true}, + {"cluster-admins", true}, + {"foo-bar-baz-qux", true}, // 4 segments + // Should NOT be filtered (real usernames) + {"alice", false}, + {"bob-smith", false}, // 2 segments — could be a username + {"john-doe", false}, // common hyphenated username + {"alice-bot", false}, // 2 segments, does not match any suffix + {"thockin", false}, + {"liggitt", false}, + } + + for _, tt := range tests { + t.Run(tt.handle, func(t *testing.T) { + got := isLikelyTeamSlug(tt.handle) + if got != tt.want { + t.Errorf("isLikelyTeamSlug(%q) = %v, want %v", tt.handle, got, tt.want) + } + }) + } +} + +// ── parseLinkNext ───────────────────────────────────────────────────────────── + +func TestParseLinkNext(t *testing.T) { + tests := []struct { + name string + header string + want string + }{ + { + name: "no header", + header: "", + want: "", + }, + { + name: "single next link", + header: `; rel="next"`, + want: "https://api.github.com/orgs/o/teams/t/members?page=2", + }, + { + name: "next and last links", + header: `; rel="next", ; rel="last"`, + want: "https://api.github.com/page=2", + }, + { + name: "last page — no next", + header: `; rel="last"`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseLinkNext(tt.header) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +// ── sortActivityDesc ────────────────────────────────────────────────────────── + +func TestSortActivityDesc(t *testing.T) { + summaries := []ActivitySummary{ + {Handle: "z", Commits: 1, MergedPRs: 0}, + {Handle: "a", Commits: 10, MergedPRs: 5}, + {Handle: "m", Commits: 5, MergedPRs: 5}, + } + sortActivityDesc(summaries) + + if summaries[0].Handle != "a" { + t.Errorf("expected 'a' first (total=15), got %s", summaries[0].Handle) + } + if summaries[1].Handle != "m" { + t.Errorf("expected 'm' second (total=10), got %s", summaries[1].Handle) + } + + // Tiebreaker: alphabetical handle + tied := []ActivitySummary{ + {Handle: "zebra", Commits: 5, MergedPRs: 0}, + {Handle: "apple", Commits: 5, MergedPRs: 0}, + } + sortActivityDesc(tied) + if tied[0].Handle != "apple" { + t.Errorf("expected alphabetical tiebreaker, got %s first", tied[0].Handle) + } +} diff --git a/utilities/dot-project/drift_sources.go b/utilities/dot-project/drift_sources.go new file mode 100644 index 00000000..ab62a418 --- /dev/null +++ b/utilities/dot-project/drift_sources.go @@ -0,0 +1,550 @@ +package projects + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// ── Multi-repo parsing ──────────────────────────────────────────────────────── + +// ParseAllRepos extracts every valid GitHub org/repo pair from a +// project.yaml repositories[] list, preserving order. +// Non-GitHub URLs (e.g., GitLab, plain HTTPS) are silently skipped. +func ParseAllRepos(repositories []string) []RepoRef { + const prefix = "https://github.com/" + var out []RepoRef + for _, u := range repositories { + u = strings.TrimSuffix(strings.TrimSpace(u), "/") + if !strings.HasPrefix(u, prefix) { + continue + } + parts := strings.SplitN(strings.TrimPrefix(u, prefix), "/", 3) + if len(parts) >= 2 && parts[0] != "" && parts[1] != "" { + out = append(out, RepoRef{Org: parts[0], Repo: parts[1]}) + } + } + return out +} + +// ParsePrimaryRepo extracts the GitHub org and repo name from the first GitHub +// URL in a project's repositories list. +// +// "https://github.com/kubernetes/kubernetes" → ("kubernetes", "kubernetes", nil) +// +// Returns an error only if no valid GitHub URL is found. +func ParsePrimaryRepo(repositories []string) (org, repo string, err error) { + refs := ParseAllRepos(repositories) + if len(refs) == 0 { + return "", "", fmt.Errorf("no valid github.com repository URL found in repositories list") + } + return refs[0].Org, refs[0].Repo, nil +} + +// ── Contributor activity fetching ───────────────────────────────────────────── + +// repoActivity holds the raw per-repo activity data before aggregation. +type repoActivity struct { + repo string // "org/repo" + commits map[string]int // handle → commit count + prs map[string]int // handle → merged PR count + lastSeen map[string]time.Time // handle → most recent activity date + fetchErr error +} + +// FetchAllRepoActivity fans out concurrent activity fetches across all repos +// in refs using up to concurrency parallel goroutines, then returns a +// per-handle aggregated ActivitySummary map. +// +// since defines how far back to look. +// token is the GitHub personal access token; baseURL overrides the API +// endpoint (empty = api.github.com, useful for testing). +func FetchAllRepoActivity( + ctx context.Context, + refs []RepoRef, + since time.Time, + token string, + client *http.Client, + baseURL string, + concurrency int, +) (activity map[string]*ActivitySummary, scanned []string, errs []error) { + if baseURL == "" { + baseURL = defaultGitHubAPIURL + } + if concurrency <= 0 { + concurrency = DefaultConcurrency + } + + results := make([]repoActivity, len(refs)) + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + + for i, ref := range refs { + wg.Add(1) + go func(idx int, r RepoRef) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + ra := repoActivity{ + repo: r.Org + "/" + r.Repo, + commits: make(map[string]int), + prs: make(map[string]int), + lastSeen: make(map[string]time.Time), + } + + // Commits via stats/contributors (1 API call per repo, with 202 retry). + commits, dates, err := fetchContributorStats(ctx, r.Org, r.Repo, since, token, client, baseURL) + if err != nil { + ra.fetchErr = fmt.Errorf("%s: commits: %w", ra.repo, err) + results[idx] = ra + return + } + ra.commits = commits + for h, t := range dates { + if cur, ok := ra.lastSeen[h]; !ok || t.After(cur) { + ra.lastSeen[h] = t + } + } + + // Merged PRs (paginated, stops once PRs are older than since). + prs, prDates, err := fetchMergedPRs(ctx, r.Org, r.Repo, since, token, client, baseURL) + if err != nil { + // Non-fatal: record the error but keep commit data. + ra.fetchErr = fmt.Errorf("%s: PRs: %w", ra.repo, err) + } + ra.prs = prs + for h, t := range prDates { + if cur, ok := ra.lastSeen[h]; !ok || t.After(cur) { + ra.lastSeen[h] = t + } + } + + results[idx] = ra + }(i, ref) + } + wg.Wait() + + // Aggregate across repos into a single per-handle map. + aggregate := make(map[string]*ActivitySummary) + + for _, ra := range results { + scanned = append(scanned, ra.repo) + if ra.fetchErr != nil { + errs = append(errs, ra.fetchErr) + } + for h, n := range ra.commits { + s := ensureEntry(aggregate, h) + s.Commits += n + addRepo(s, ra.repo) + if t := ra.lastSeen[h]; !t.IsZero() && t.After(s.LastSeen) { + s.LastSeen = t + } + } + for h, n := range ra.prs { + s := ensureEntry(aggregate, h) + s.MergedPRs += n + addRepo(s, ra.repo) + if t := ra.lastSeen[h]; !t.IsZero() && t.After(s.LastSeen) { + s.LastSeen = t + } + } + } + + return aggregate, scanned, errs +} + +func ensureEntry(m map[string]*ActivitySummary, handle string) *ActivitySummary { + if s, ok := m[handle]; ok { + return s + } + s := &ActivitySummary{Handle: handle} + m[handle] = s + return s +} + +func addRepo(s *ActivitySummary, repo string) { + for _, r := range s.ReposTouched { + if r == repo { + return + } + } + s.ReposTouched = append(s.ReposTouched, repo) +} + +// ── stats/contributors ──────────────────────────────────────────────────────── + +type contributorWeek struct { + W int `json:"w"` // Unix timestamp of the week start + C int `json:"c"` // commit count for that week +} + +type contributorEntry struct { + Author struct { + Login string `json:"login"` + } `json:"author"` + Weeks []contributorWeek `json:"weeks"` +} + +// fetchContributorStats calls GET /repos/{org}/{repo}/stats/contributors, +// retrying up to 3 times on 202 (GitHub computes stats asynchronously). +// Returns per-handle commit counts and latest-activity dates, filtered to +// the window defined by since. +func fetchContributorStats( + ctx context.Context, + org, repo string, + since time.Time, + token string, + client *http.Client, + baseURL string, +) (commits map[string]int, lastSeen map[string]time.Time, err error) { + url := fmt.Sprintf("%s/repos/%s/%s/stats/contributors", baseURL, org, repo) + sinceUnix := since.Unix() + + const maxRetries = 3 + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt*3) * time.Second) + } + + req, reqErr := http.NewRequestWithContext(ctx, "GET", url, nil) + if reqErr != nil { + return nil, nil, fmt.Errorf("building stats request: %w", reqErr) + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, doErr := client.Do(req) + if doErr != nil { + return nil, nil, fmt.Errorf("stats/contributors request: %w", doErr) + } + + switch resp.StatusCode { + case http.StatusAccepted: + // GitHub is still computing; retry after backoff. + resp.Body.Close() //nolint:errcheck + continue + case http.StatusNoContent: + resp.Body.Close() //nolint:errcheck + return make(map[string]int), make(map[string]time.Time), nil + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() //nolint:errcheck + return nil, nil, fmt.Errorf("stats/contributors HTTP %d for %s/%s", resp.StatusCode, org, repo) + } + + var entries []contributorEntry + decErr := json.NewDecoder(resp.Body).Decode(&entries) + resp.Body.Close() //nolint:errcheck + if decErr != nil { + return nil, nil, fmt.Errorf("parsing stats/contributors: %w", decErr) + } + + commitMap := make(map[string]int, len(entries)) + lastSeenMap := make(map[string]time.Time, len(entries)) + + for _, e := range entries { + login := strings.ToLower(e.Author.Login) + if login == "" { + continue + } + for _, w := range e.Weeks { + if int64(w.W) < sinceUnix || w.C == 0 { + continue + } + commitMap[login] += w.C + // Track the end of the latest week with commits. + wEnd := time.Unix(int64(w.W), 0).Add(7 * 24 * time.Hour) + if cur, ok := lastSeenMap[login]; !ok || wEnd.After(cur) { + lastSeenMap[login] = wEnd + } + } + } + return commitMap, lastSeenMap, nil + } + + return nil, nil, fmt.Errorf( + "stats/contributors for %s/%s still returning 202 after %d retries — try again later", + org, repo, maxRetries, + ) +} + +// ── Merged PRs ──────────────────────────────────────────────────────────────── + +type prEntry struct { + MergedAt *time.Time `json:"merged_at"` + UpdatedAt time.Time `json:"updated_at"` + User struct { + Login string `json:"login"` + } `json:"user"` +} + +// fetchMergedPRs fetches merged PRs for a repo since the given time, +// following pagination and stopping early once all PRs on a page are older +// than since (PRs are returned newest-first). +func fetchMergedPRs( + ctx context.Context, + org, repo string, + since time.Time, + token string, + client *http.Client, + baseURL string, +) (counts map[string]int, lastSeen map[string]time.Time, err error) { + counts = make(map[string]int) + lastSeen = make(map[string]time.Time) + + nextURL := fmt.Sprintf( + "%s/repos/%s/%s/pulls?state=closed&sort=updated&direction=desc&per_page=100", + baseURL, org, repo, + ) + + for nextURL != "" { + page, next, done, pageErr := fetchPRPage(ctx, nextURL, since, token, client) + if pageErr != nil { + return counts, lastSeen, pageErr + } + for _, pr := range page { + if pr.MergedAt == nil || pr.MergedAt.Before(since) || pr.User.Login == "" { + continue + } + login := strings.ToLower(pr.User.Login) + counts[login]++ + if cur, ok := lastSeen[login]; !ok || pr.MergedAt.After(cur) { + lastSeen[login] = *pr.MergedAt + } + } + if done { + break + } + nextURL = next + } + return counts, lastSeen, nil +} + +// fetchPRPage fetches one page of pull requests. +// done is true when every PR on this page was updated before since. +func fetchPRPage( + ctx context.Context, + url string, + since time.Time, + token string, + client *http.Client, +) (page []prEntry, next string, done bool, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, "", false, fmt.Errorf("building PR request: %w", err) + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := client.Do(req) + if err != nil { + return nil, "", false, fmt.Errorf("PR list request: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + linkHeader := resp.Header.Get("Link") + + if resp.StatusCode != http.StatusOK { + return nil, "", false, fmt.Errorf("PR list HTTP %d", resp.StatusCode) + } + + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return nil, "", false, fmt.Errorf("parsing PR list: %w", err) + } + + // If every PR on this page was last updated before the window, all + // subsequent pages will be too (results are newest-first by updated_at), + // so we can stop early. We check updated_at, not merged_at, because + // merged_at ≤ updated_at always holds — a PR with updated_at < since + // could not have been merged inside the window. + allOld := len(page) > 0 + for _, pr := range page { + if pr.UpdatedAt.After(since) { + allOld = false + break + } + } + + return page, parseLinkNext(linkHeader), allOld, nil +} + +// ── Result building ─────────────────────────────────────────────────────────── + +// BuildHealthCheckActivityLists splits aggregated activity into two sorted +// lists: one for current maintainers (most active first) and one for top +// contributors not in the maintainer set (capped at topN). +func BuildHealthCheckActivityLists( + activity map[string]*ActivitySummary, + maintainerHandles []string, + topN int, +) (maintainerActivity []ActivitySummary, topContributors []ActivitySummary) { + maintainerSet := make(map[string]bool, len(maintainerHandles)) + for _, h := range maintainerHandles { + maintainerSet[strings.ToLower(h)] = true + } + + // Build maintainer rows — include even those with zero activity. + for _, h := range maintainerHandles { + h = strings.ToLower(h) + if s, ok := activity[h]; ok { + maintainerActivity = append(maintainerActivity, *s) + } else { + maintainerActivity = append(maintainerActivity, ActivitySummary{Handle: h}) + } + } + sortActivityDesc(maintainerActivity) + + // Build top-contributor rows — skip maintainers and team slugs. + for _, s := range activity { + if !maintainerSet[s.Handle] && !isLikelyTeamSlug(s.Handle) { + topContributors = append(topContributors, *s) + } + } + sortActivityDesc(topContributors) + if topN > 0 && len(topContributors) > topN { + topContributors = topContributors[:topN] + } + + // Sort repo lists alphabetically for stable output. + for i := range maintainerActivity { + sort.Strings(maintainerActivity[i].ReposTouched) + } + for i := range topContributors { + sort.Strings(topContributors[i].ReposTouched) + } + + return maintainerActivity, topContributors +} + +// ── Team slug filter ────────────────────────────────────────────────────────── + +var teamSlugPrefixes = []string{ + "sig-", "wg-", "committee-", "toc-", "tag-", + "cncf-", "k8s-infra-", +} + +var teamSlugSuffixes = []string{ + "-approvers", "-reviewers", "-leads", "-maintainers", + "-owners", "-members", "-admins", +} + +// manySegmentsRE matches handles with 4 or more hyphen-separated segments, +// which are extremely unlikely to be personal GitHub usernames. +var manySegmentsRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+){3,}$`) + +// isLikelyTeamSlug returns true when a lowercase handle looks like a GitHub +// org team slug rather than an individual user account. +func isLikelyTeamSlug(h string) bool { + h = strings.ToLower(h) + for _, pfx := range teamSlugPrefixes { + if strings.HasPrefix(h, pfx) { + return true + } + } + for _, sfx := range teamSlugSuffixes { + if strings.HasSuffix(h, sfx) { + return true + } + } + return manySegmentsRE.MatchString(h) +} + +// ── GitHub Teams API ────────────────────────────────────────────────────────── + +// FetchGitHubTeamMembers fetches all members of an org team via the GitHub +// Teams API, following pagination automatically. +// The token must have the read:org scope. +func FetchGitHubTeamMembers(ctx context.Context, org, teamSlug, token string, client *http.Client, baseURL string) ([]string, error) { + if baseURL == "" { + baseURL = defaultGitHubAPIURL + } + var all []string + nextURL := fmt.Sprintf("%s/orgs/%s/teams/%s/members?per_page=100", baseURL, org, teamSlug) + for nextURL != "" { + page, next, err := fetchTeamMembersPage(ctx, nextURL, token, client) + if err != nil { + return nil, err + } + for _, m := range page { + if m.Login != "" { + all = append(all, strings.ToLower(m.Login)) + } + } + nextURL = next + } + return all, nil +} + +type teamMember struct { + Login string `json:"login"` +} + +func fetchTeamMembersPage(ctx context.Context, url, token string, client *http.Client) ([]teamMember, string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, "", fmt.Errorf("building teams request: %w", err) + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("GitHub Teams API request failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + linkHeader := resp.Header.Get("Link") + + switch resp.StatusCode { + case 404: + return nil, "", fmt.Errorf("GitHub team not found (404)") + case 403: + return nil, "", fmt.Errorf("GitHub Teams API: permission denied (403) — token needs read:org scope") + } + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("GitHub Teams API returned HTTP %d", resp.StatusCode) + } + + var page []teamMember + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return nil, "", fmt.Errorf("parsing teams members response: %w", err) + } + return page, parseLinkNext(linkHeader), nil +} + +// parseLinkNext extracts the URL for rel="next" from a GitHub Link header. +// Returns "" when there are no more pages. +func parseLinkNext(linkHeader string) string { + if linkHeader == "" { + return "" + } + for _, part := range strings.Split(linkHeader, ",") { + part = strings.TrimSpace(part) + segments := strings.Split(part, ";") + if len(segments) < 2 { + continue + } + urlPart := strings.TrimSpace(segments[0]) + relPart := strings.TrimSpace(segments[1]) + if relPart == `rel="next"` && + strings.HasPrefix(urlPart, "<") && + strings.HasSuffix(urlPart, ">") { + return urlPart[1 : len(urlPart)-1] + } + } + return "" +} diff --git a/utilities/dot-project/drift_sources_test.go b/utilities/dot-project/drift_sources_test.go new file mode 100644 index 00000000..8e6074f1 --- /dev/null +++ b/utilities/dot-project/drift_sources_test.go @@ -0,0 +1,502 @@ +package projects + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// ── test helpers ────────────────────────────────────────────────────────────── + +// writeJSON encodes v to w as JSON, failing the test on encode error. +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// prUser is a shorthand to build the anonymous user struct in prEntry. +func prUser(login string) struct { + Login string `json:"login"` +} { + return struct { + Login string `json:"login"` + }{Login: login} +} + +// contribAuthor is a shorthand to build the anonymous author struct in contributorEntry. +func contribAuthor(login string) struct { + Login string `json:"login"` +} { + return struct { + Login string `json:"login"` + }{Login: login} +} + +// ── fetchContributorStats ───────────────────────────────────────────────────── + +func TestFetchContributorStats(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + weekIn := int(since.Unix()) + 7*24*3600 // one week inside window + weekOut := int(since.Unix()) - 7*24*3600 // one week before window + + t.Run("counts commits, filters window, lowercases login", func(t *testing.T) { + payload := []contributorEntry{ + { + Author: contribAuthor("Alice"), + Weeks: []contributorWeek{ + {W: weekIn, C: 5}, // in window + {W: weekOut, C: 10}, // before window — must be ignored + }, + }, + { + Author: contribAuthor("BOB"), // should be stored as "bob" + Weeks: []contributorWeek{{W: weekIn, C: 3}}, + }, + { + Author: contribAuthor(""), // empty login — must be skipped + Weeks: []contributorWeek{{W: weekIn, C: 99}}, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, payload) + })) + defer srv.Close() + + commits, lastSeen, err := fetchContributorStats(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if commits["alice"] != 5 { + t.Errorf("alice commits: want 5, got %d", commits["alice"]) + } + if commits["bob"] != 3 { + t.Errorf("bob commits: want 3, got %d", commits["bob"]) + } + if _, ok := commits[""]; ok { + t.Error("empty login should not appear in commits map") + } + if lastSeen["alice"].IsZero() { + t.Error("alice: expected non-zero lastSeen") + } + }) + + t.Run("zero-commit weeks produce no entry", func(t *testing.T) { + payload := []contributorEntry{ + { + Author: contribAuthor("ghost"), + Weeks: []contributorWeek{{W: weekIn, C: 0}}, // zero commits + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, payload) + })) + defer srv.Close() + + commits, _, err := fetchContributorStats(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := commits["ghost"]; ok { + t.Error("ghost should not appear: all weeks have zero commits") + } + }) + + t.Run("204 no content returns empty maps", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + commits, lastSeen, err := fetchContributorStats(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(commits) != 0 || len(lastSeen) != 0 { + t.Errorf("expected empty maps for 204; got commits=%v", commits) + } + }) + + t.Run("non-200 status returns error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, _, err := fetchContributorStats(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err == nil { + t.Fatal("expected error for 500, got nil") + } + }) + + t.Run("retries on 202 then succeeds", func(t *testing.T) { + // First call returns 202; second returns data. + // The retry sleeps 3 s (attempt=1 × 3s) so this is skipped in short mode. + if testing.Short() { + t.Skip("skipping slow retry test (-short)") + } + var callCount int32 + payload := []contributorEntry{ + {Author: contribAuthor("alice"), Weeks: []contributorWeek{{W: weekIn, C: 7}}}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if atomic.AddInt32(&callCount, 1) < 2 { + w.WriteHeader(http.StatusAccepted) // 202 + return + } + writeJSON(w, payload) + })) + defer srv.Close() + + commits, _, err := fetchContributorStats(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error after retry: %v", err) + } + if atomic.LoadInt32(&callCount) != 2 { + t.Errorf("expected 2 calls (1×202 + 1×200), got %d", callCount) + } + if commits["alice"] != 7 { + t.Errorf("alice: want 7 commits, got %d", commits["alice"]) + } + }) + + t.Run("context cancellation returns error", func(t *testing.T) { + // Server blocks until its request context is cancelled. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-cancel so the first request fails immediately + + _, _, err := fetchContributorStats(ctx, "org", "repo", since, "", http.DefaultClient, srv.URL) + if err == nil { + t.Fatal("expected error on cancelled context, got nil") + } + }) +} + +// ── fetchMergedPRs ──────────────────────────────────────────────────────────── + +func TestFetchMergedPRs(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + inWindow := since.Add(24 * time.Hour) + outOfWindow := since.Add(-24 * time.Hour) + + t.Run("counts merged PRs, skips unmerged and out-of-window", func(t *testing.T) { + ma := inWindow + old := outOfWindow + payload := []prEntry{ + {MergedAt: &ma, UpdatedAt: inWindow, User: prUser("alice")}, + {MergedAt: &ma, UpdatedAt: inWindow, User: prUser("alice")}, // alice +1 + {MergedAt: &ma, UpdatedAt: inWindow, User: prUser("bob")}, + {MergedAt: nil, UpdatedAt: inWindow, User: prUser("carol")}, // unmerged — skip + {MergedAt: &old, UpdatedAt: inWindow, User: prUser("dave")}, // merged before window — skip + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, payload) + })) + defer srv.Close() + + counts, _, err := fetchMergedPRs(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if counts["alice"] != 2 { + t.Errorf("alice: want 2, got %d", counts["alice"]) + } + if counts["bob"] != 1 { + t.Errorf("bob: want 1, got %d", counts["bob"]) + } + if _, ok := counts["carol"]; ok { + t.Error("carol (unmerged) should not appear in counts") + } + if _, ok := counts["dave"]; ok { + t.Error("dave (merged before window) should not appear in counts") + } + }) + + t.Run("early exit when all PRs on page have UpdatedAt before since", func(t *testing.T) { + // A page where every PR is last-updated before the window. + // The function should stop after the first page even though a Link: next is provided. + var pagesFetched int32 + old := outOfWindow + page := []prEntry{ + {MergedAt: &old, UpdatedAt: outOfWindow, User: prUser("alice")}, + } + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&pagesFetched, 1) + // Always include a next-page link to prove it is not followed. + w.Header().Set("Link", fmt.Sprintf(`<%s/repos/org/repo/pulls?page=2>; rel="next"`, srv.URL)) + writeJSON(w, page) + })) + defer srv.Close() + + counts, _, err := fetchMergedPRs(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n := atomic.LoadInt32(&pagesFetched); n != 1 { + t.Errorf("expected exactly 1 page (early exit), got %d", n) + } + if len(counts) != 0 { + t.Errorf("expected no counts (all PRs out of window), got %v", counts) + } + }) + + t.Run("pagination follows Link header", func(t *testing.T) { + ma := inWindow + page1 := []prEntry{{MergedAt: &ma, UpdatedAt: inWindow, User: prUser("alice")}} + page2 := []prEntry{{MergedAt: &ma, UpdatedAt: inWindow, User: prUser("bob")}} + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + writeJSON(w, page2) + return + } + w.Header().Set("Link", fmt.Sprintf(`<%s/repos/org/repo/pulls?page=2>; rel="next"`, srv.URL)) + writeJSON(w, page1) + })) + defer srv.Close() + + counts, _, err := fetchMergedPRs(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if counts["alice"] != 1 { + t.Errorf("alice: want 1, got %d", counts["alice"]) + } + if counts["bob"] != 1 { + t.Errorf("bob: want 1, got %d", counts["bob"]) + } + }) + + t.Run("tracks lastSeen as most recent merged_at for same user", func(t *testing.T) { + early := inWindow + late := inWindow.Add(7 * 24 * time.Hour) + payload := []prEntry{ + {MergedAt: &early, UpdatedAt: late, User: prUser("alice")}, + {MergedAt: &late, UpdatedAt: late, User: prUser("alice")}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, payload) + })) + defer srv.Close() + + _, lastSeen, err := fetchMergedPRs(context.Background(), "org", "repo", since, "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !lastSeen["alice"].Equal(late) { + t.Errorf("alice lastSeen: want %v, got %v", late, lastSeen["alice"]) + } + }) +} + +// ── FetchAllRepoActivity ────────────────────────────────────────────────────── + +func TestFetchAllRepoActivity(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + weekIn := int(since.Unix()) + 7*24*3600 + mergedAt := since.Add(24 * time.Hour) + + t.Run("aggregates commits and PRs across multiple repos", func(t *testing.T) { + // Every repo returns alice with 3 commits + 1 merged PR. + contribs := []contributorEntry{ + {Author: contribAuthor("alice"), Weeks: []contributorWeek{{W: weekIn, C: 3}}}, + } + prs := []prEntry{ + {MergedAt: &mergedAt, UpdatedAt: mergedAt, User: prUser("alice")}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/stats/contributors") { + writeJSON(w, contribs) + return + } + writeJSON(w, prs) + })) + defer srv.Close() + + refs := []RepoRef{{Org: "org", Repo: "repo1"}, {Org: "org", Repo: "repo2"}} + activity, scanned, errs := FetchAllRepoActivity(context.Background(), refs, since, "", http.DefaultClient, srv.URL, 2) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if len(scanned) != 2 { + t.Errorf("scanned: want 2, got %d", len(scanned)) + } + a, ok := activity["alice"] + if !ok { + t.Fatal("alice not found in activity map") + } + if a.Commits != 6 { // 3 × 2 repos + t.Errorf("alice.Commits: want 6, got %d", a.Commits) + } + if a.MergedPRs != 2 { // 1 × 2 repos + t.Errorf("alice.MergedPRs: want 2, got %d", a.MergedPRs) + } + if len(a.ReposTouched) != 2 { + t.Errorf("alice.ReposTouched: want 2, got %d", len(a.ReposTouched)) + } + }) + + t.Run("fetch error is non-fatal — other repos still aggregated", func(t *testing.T) { + // "fail-repo" returns 500; "ok-repo" returns bob with 5 commits. + contribs := []contributorEntry{ + {Author: contribAuthor("bob"), Weeks: []contributorWeek{{W: weekIn, C: 5}}}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "fail-repo") { + w.WriteHeader(http.StatusInternalServerError) + return + } + if strings.Contains(r.URL.Path, "/stats/contributors") { + writeJSON(w, contribs) + return + } + writeJSON(w, []prEntry{}) + })) + defer srv.Close() + + refs := []RepoRef{{Org: "org", Repo: "fail-repo"}, {Org: "org", Repo: "ok-repo"}} + activity, _, errs := FetchAllRepoActivity(context.Background(), refs, since, "", http.DefaultClient, srv.URL, 1) + if len(errs) == 0 { + t.Error("expected at least one error, got none") + } + if activity["bob"] == nil { + t.Error("bob (from ok-repo) should still appear despite fail-repo error") + } + }) + + t.Run("context cancellation propagates to in-flight requests", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Block until the request context is done. + <-r.Context().Done() + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before any request starts + + _, _, errs := FetchAllRepoActivity(ctx, []RepoRef{{Org: "org", Repo: "repo"}}, since, "", http.DefaultClient, srv.URL, 1) + if len(errs) == 0 { + t.Error("expected errors after context cancellation, got none") + } + }) + + t.Run("empty refs list returns empty result", func(t *testing.T) { + activity, scanned, errs := FetchAllRepoActivity(context.Background(), nil, since, "", http.DefaultClient, "", 1) + if len(activity) != 0 || len(scanned) != 0 || len(errs) != 0 { + t.Errorf("expected all-empty results for nil refs; got activity=%v scanned=%v errs=%v", activity, scanned, errs) + } + }) +} + +// ── FetchGitHubTeamMembers ──────────────────────────────────────────────────── + +func TestFetchGitHubTeamMembers(t *testing.T) { + t.Run("returns all members lowercased", func(t *testing.T) { + members := []teamMember{{Login: "Alice"}, {Login: "BOB"}, {Login: "carol"}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, members) + })) + defer srv.Close() + + got, err := FetchGitHubTeamMembers(context.Background(), "org", "team", "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"alice", "bob", "carol"} + if len(got) != len(want) { + t.Fatalf("member count: want %d, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("member[%d]: want %q, got %q", i, want[i], got[i]) + } + } + }) + + t.Run("follows pagination via Link header", func(t *testing.T) { + page1 := []teamMember{{Login: "alice"}} + page2 := []teamMember{{Login: "bob"}} + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + writeJSON(w, page2) + return + } + w.Header().Set("Link", fmt.Sprintf(`<%s/orgs/org/teams/team/members?page=2>; rel="next"`, srv.URL)) + writeJSON(w, page1) + })) + defer srv.Close() + + got, err := FetchGitHubTeamMembers(context.Background(), "org", "team", "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Errorf("expected 2 members across 2 pages, got %d: %v", len(got), got) + } + }) + + t.Run("403 returns error mentioning read:org scope", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + _, err := FetchGitHubTeamMembers(context.Background(), "org", "team", "", http.DefaultClient, srv.URL) + if err == nil { + t.Fatal("expected error for 403, got nil") + } + if !strings.Contains(err.Error(), "read:org") { + t.Errorf("expected 'read:org' in error message, got: %v", err) + } + }) + + t.Run("404 returns team-not-found error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := FetchGitHubTeamMembers(context.Background(), "org", "team", "", http.DefaultClient, srv.URL) + if err == nil { + t.Fatal("expected error for 404, got nil") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("expected '404' in error message, got: %v", err) + } + }) + + t.Run("skips empty login in response", func(t *testing.T) { + members := []teamMember{{Login: "alice"}, {Login: ""}, {Login: "bob"}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, members) + })) + defer srv.Close() + + got, err := FetchGitHubTeamMembers(context.Background(), "org", "team", "", http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, h := range got { + if h == "" { + t.Error("empty login should be excluded from results") + } + } + if len(got) != 2 { + t.Errorf("expected 2 members (alice, bob), got %d: %v", len(got), got) + } + }) +} diff --git a/utilities/dot-project/template/.github/workflows/maintainer-drift.yml b/utilities/dot-project/template/.github/workflows/maintainer-drift.yml new file mode 100644 index 00000000..804a695f --- /dev/null +++ b/utilities/dot-project/template/.github/workflows/maintainer-drift.yml @@ -0,0 +1,113 @@ +name: Maintainer Health Check + +on: + schedule: + - cron: '0 8 1 * *' # Monthly on the 1st at 08:00 UTC + workflow_dispatch: # Also triggerable on demand + +permissions: + issues: write # open / update health-check issues + +jobs: + health-check: + runs-on: ubuntu-latest + # timeout = DefaultHTTPTimeout (30s) × 30 = 900s = 15 min (defaults.go) + # Managed by bootstrap_scaffold.go — do not edit this value directly. + timeout-minutes: 15 + steps: + - name: Checkout .project repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + fetch-depth: 0 # needed for git log age calculation + + - name: Checkout cncf/automation (health-check tooling) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + repository: cncf/automation + # Pinned to the SHA that introduced this workflow. + # Update this SHA when pulling in upstream health-check tooling changes. + ref: 74b6112a55fe17053d90c4856037436ce6d01b85 + path: .cncf-automation + sparse-checkout: utilities/dot-project + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 + with: + go-version-file: .cncf-automation/utilities/dot-project/go.mod + + - name: Build drift binary + run: | + cd .cncf-automation/utilities/dot-project + go build -o /usr/local/bin/drift ./cmd/drift + + - name: Get days since maintainers.yaml last changed + id: git_age + run: | + LAST_TS=$(git log -1 --format=%ct -- maintainers.yaml 2>/dev/null || echo "") + if [ -z "$LAST_TS" ]; then + DAYS=0 + else + DAYS=$(( ( $(date +%s) - LAST_TS ) / 86400 )) + fi + echo "days=${DAYS}" >> "$GITHUB_OUTPUT" + + - name: Run maintainer health check + id: healthcheck + run: | + EXIT_CODE=0 + drift \ + -project-yaml project.yaml \ + -maintainers-yaml maintainers.yaml \ + -report /tmp/health-report.md \ + -last-updated-days "${{ steps.git_age.outputs.days }}" \ + || EXIT_CODE=$? + + # exit 0 = not stale, no action needed + # exit 1 = stale, issue body written to /tmp/health-report.md + # exit 2 = tool error — log and don't open an issue + if [ "$EXIT_CODE" -eq 1 ]; then + echo "is_stale=true" >> "$GITHUB_OUTPUT" + elif [ "$EXIT_CODE" -eq 2 ]; then + echo "is_stale=false" >> "$GITHUB_OUTPUT" + echo "::error::health-check tool exited with code 2 — check step logs above for the root cause (bad project.yaml / maintainers.yaml / GitHub API error)" + else + echo "is_stale=false" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true # prevent tool errors from failing the workflow + + - name: Open or update health-check issue + if: steps.healthcheck.outputs.is_stale == 'true' + run: | + LABEL="maintainer-health" + TITLE="Maintainer list health check — please review" + + # Ensure the label exists (idempotent). + gh label create "$LABEL" \ + --description "Automated maintainer health-check ping" \ + --color "e4e669" 2>/dev/null || true + + # Check for an existing open issue with this label. + EXISTING=$(gh issue list \ + --label "$LABEL" \ + --state open \ + --json number \ + --jq '.[0].number' 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Updating existing health-check issue #${EXISTING}" + gh issue edit "$EXISTING" \ + --body-file /tmp/health-report.md + gh issue comment "$EXISTING" \ + --body "🔄 Health check re-run on $(date +%Y-%m-%d) — issue body updated above." + else + echo "Opening new health-check issue" + gh issue create \ + --title "$TITLE" \ + --body-file /tmp/health-report.md \ + --label "$LABEL" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/utilities/dot-project/types.go b/utilities/dot-project/types.go index 5f07175f..e0dd4e0f 100644 --- a/utilities/dot-project/types.go +++ b/utilities/dot-project/types.go @@ -131,8 +131,9 @@ type MaintainerEntry struct { // Team represents a GitHub team and its members type Team struct { - Name string `json:"name" yaml:"name"` - Members []string `json:"members" yaml:"members"` + Name string `json:"name" yaml:"name"` + Members []string `json:"members" yaml:"members"` + GitHubTeam string `json:"github_team,omitempty" yaml:"github_team,omitempty"` // optional: actual GitHub org team slug (e.g., "project-maintainers-team") } // MaintainerLifecycle represents maintainer lifecycle documentation @@ -204,3 +205,33 @@ type ProjectListEntry struct { type ProjectListConfig struct { Projects []ProjectListEntry `json:"projects" yaml:"projects"` } + +// ActivitySummary captures aggregated GitHub activity for a single contributor +// across all repos tracked by a project, over a rolling time window. +type ActivitySummary struct { + Handle string `json:"handle"` + Commits int `json:"commits"` // total commits authored in the window + MergedPRs int `json:"merged_prs"` // total merged PRs authored in the window + ReposTouched []string `json:"repos_touched"` // repos where activity was recorded + LastSeen time.Time `json:"last_seen"` // most recent commit or PR merge date +} + +// RepoRef is a parsed GitHub org/repo pair derived from a repositories[] URL. +type RepoRef struct { + Org string + Repo string +} + +// HealthCheckResult captures the outcome of a single maintainer health-check run. +type HealthCheckResult struct { + ProjectID string `json:"project_id"` + Org string `json:"org"` + TeamName string `json:"team_name"` + IsStale bool `json:"is_stale"` + DaysSinceUpdate int `json:"days_since_update"` + StalenessDaysThreshold int `json:"staleness_days_threshold"` + MentionHandles []string `json:"mention_handles,omitempty"` // up to 3 handles to @mention in the issue greeting + MaintainerActivity []ActivitySummary `json:"maintainer_activity"` + TopNewContributors []ActivitySummary `json:"top_new_contributors"` + CheckedAt time.Time `json:"checked_at"` +}