diff --git a/.github/workflows/dtc-pipeline.yml b/.github/workflows/dtc-pipeline.yml new file mode 100644 index 0000000..db6e9b8 --- /dev/null +++ b/.github/workflows/dtc-pipeline.yml @@ -0,0 +1,69 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: DTC Review Pipeline + +on: + schedule: + # Sync Next DTC Agenda assignees every 15 minutes + - cron: "*/15 * * * *" + repository_dispatch: + types: + - projects_v2_item + workflow_dispatch: + inputs: + mode: + description: "Command to execute (sync-agenda-assignees or scan-spotlight)" + required: false + type: choice + options: + - sync-agenda-assignees + - scan-spotlight + default: "sync-agenda-assignees" + +permissions: + contents: read # Check out org-tools/dtc-pipeline scripts + +jobs: + dtc-pipeline: + name: Run DTC Pipeline + runs-on: ubuntu-latest + permissions: + contents: read # Check out org-tools/dtc-pipeline scripts + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.12" + enable-cache: "false" + + - name: Sync Next DTC Agenda assignees + if: inputs.mode != 'scan-spotlight' + env: + ORG_TRIAGE_TOKEN: ${{ secrets.ORG_TRIAGE_TOKEN }} + DTC_PROJECT_ID: ${{ vars.DTC_PROJECT_ID }} + run: uv run --frozen python org-tools/dtc-pipeline/scripts/dtc_pipeline.py sync-agenda-assignees --apply + + - name: Run Community Spotlight scan + if: inputs.mode == 'scan-spotlight' + env: + ORG_TRIAGE_TOKEN: ${{ secrets.ORG_TRIAGE_TOKEN }} + DTC_PROJECT_ID: ${{ vars.DTC_PROJECT_ID }} + ORG_NAME: ${{ github.repository_owner }} + run: uv run --frozen python org-tools/dtc-pipeline/scripts/dtc_pipeline.py scan-spotlight --org "$ORG_NAME" --repo ucp --apply diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 49933e5..aa5a6b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,3 +54,6 @@ jobs: uv run --with jsonschema==4.26.0 --with markdown-it-py==3.0.0 --with markdown==3.10.2 python -m unittest discover -s org-tools/requirements-extractor/tests + + - name: Run dtc-pipeline tests + run: uv run --frozen python -m unittest discover -s org-tools/dtc-pipeline/tests diff --git a/org-tools/README.md b/org-tools/README.md index 8cfe1a4..bd2d428 100644 --- a/org-tools/README.md +++ b/org-tools/README.md @@ -8,6 +8,7 @@ This directory contains CLI utilities and configuration files for organization-w ```text org-tools/ +├── dtc-pipeline/ # DTC review pipeline & Community Spotlight engine (see dtc-pipeline/README.md) ├── governance/ # PR governance gate and approval verification (see governance/README.md) ├── label-sync/ # GitHub label synchronization utility (see label-sync/README.md) ├── requirements-extractor/ # Requirements catalog from specification prose (see requirements-extractor/README.md) @@ -22,6 +23,7 @@ org-tools/ - **[🛡️ Automated Governance Gate](governance/README.md)**: Enforces path-based repository ownership rules and PR approval thresholds. - **[📋 Requirements Extractor](requirements-extractor/README.md)**: Generates a machine-readable catalog of a specification's RFC 2119 requirements, and checks in CI that a committed catalog matches the specification. - **[🤖 Central PR Triage Tool](triage/README.md)**: Automatically applies the `status:needs-triage` label to eligible open, non-draft Pull Requests. +- **[🗳️ DTC Review Pipeline & Community Spotlight Engine](dtc-pipeline/README.md)**: Provisions the unified 5-view `UCP DTC Review Pipeline` Project v2 board, auto-assigns TC members who drag items into `Next DTC Agenda`, and runs the 3-gate + 100-point Community Spotlight scan. --- @@ -36,5 +38,6 @@ uv run --with markdown-it-py==3.0.0 --with markdown==3.10.2 \ uv run python3 -m unittest discover -s org-tools/label-sync && \ uv run python3 -m unittest discover -s org-tools/triage/tests && \ uv run --with jsonschema==4.26.0 --with markdown-it-py==3.0.0 --with markdown==3.10.2 \ - python3 -m unittest discover -s org-tools/requirements-extractor/tests + python3 -m unittest discover -s org-tools/requirements-extractor/tests && \ +uv run python3 -m unittest discover -s org-tools/dtc-pipeline/tests ``` diff --git a/org-tools/dtc-pipeline/README.md b/org-tools/dtc-pipeline/README.md new file mode 100644 index 0000000..8365a97 --- /dev/null +++ b/org-tools/dtc-pipeline/README.md @@ -0,0 +1,54 @@ +# UCP DTC Review Pipeline & Community Spotlight Engine + +Implements the governance workflow for surfacing and resolving Issues and Pull Requests in weekly Domain Technical Council (DTC) meetings across `Universal-Commerce-Protocol/ucp`: + +1. **General DTC Escalation & Native `Next DTC Agenda` Column Gate** + - Embeds the framing check directly into Column 2 (**`Next DTC Agenda`**, Orange, with column subtitle _"`⚠️ Confirm Framing Comment (or self-contained body) is in place before moving here.`"_) — 100% native in GitHub Projects UI with zero custom badge fields, comment scraping, webhooks, or bot noise on PR threads. +2. **Unified 5-View GitHub Project v2 Architecture (`UCP DTC Review Pipeline`)** + - Provisions and configures a **single Kanban Board per vertical** (`Shopping TC`, `Payments TC`, `Food TC`, `Lodging TC`) with a **6-column `Status` workflow** (handling triage, next-meeting agenda slotting, and post-meeting resolution outcomes in one board view with zero custom date or Iteration fields): + 1. `Triage / Unscheduled` (Gray — set automatically by GitHub Projects' built-in _Item added to project_ workflow) + 2. `Next DTC Agenda` (Orange — `⚠️ Confirm Framing Comment (or self-contained body) is in place before moving here.`; queued for the upcoming weekly meeting, capped at 5–7 items via GitHub Projects' native Board Column Limit) + 3. `Approved` (Green — approved by DTC; `gov:tc-meeting` removed and circle-back comment posted once `meeting-minutes` merges) + 4. `Follow-up / Revisions` (Purple — discussed in DTC; direction approved with revisions or follow-up requested) + 5. `Redirected / Declined` (Red — redirected to a Domain Working Group / self-hosted extension or declined) + 6. `Closed / Merged` (Gray — set automatically by GitHub Projects' built-in _Item closed_ and _Pull request merged_ workflows) + - Configures 5 views total: the 4 per-vertical Kanban boards (`Shopping TC`, `Payments TC`, `Food TC`, `Lodging TC`, grouped by `Status`) plus the cross-vertical `Community Spotlight Pool` Table view, surfacing `Track` (`General Track` | `Community Spotlight`), `Priority Score` (`NUMBER`), and the native repository `Milestone` field across all 5 views. + - Orders General Track `T - 24h` agenda slotting by Release Train Milestone urgency (`Active Milestone` [breaking `!` changes first due to the 2-week public notice window, then `area:common` items requiring GC sign-off] $\rightarrow$ `Next Milestone` $\rightarrow$ `No Milestone`), while keeping the 100-point Community Spotlight heuristic strictly Milestone-blind so external contributors are never penalized for lacking internal Milestone sponsorship. +3. **Dedicated Community Contribution Track ("Community Spotlight")** + - **Gate 1 (Non-DTC Corporate Affiliation):** Queries the GitHub Org Teams API (`@shopping-tech-council`, `@payments-tech-council`, `@food-tech-council`, `@lodging-tech-council`, `@governance-council`, `@maintainers`), parses `MAINTAINERS.md` for excluded member companies, inspects GitHub user `company` and email domains, and excludes bots. + - **Gate 2 (DTC Governance Scope Check):** Bundles linked Proposal Issues and PRs; matches PRs carrying `gov:needs-tc-review` or `gov:tc-meeting` (applied by `.github/org-tools/governance`), plus standalone proposal issues carrying `area:` and proposal prefixes/labels. + - **Gate 3 (Baseline Readiness & Hygiene Gate):** Enforces non-draft state, clean merge state (`mergeable_state != "dirty"`), passing `cla/google` and validation CI checks, structured proposal issue headers (`enhancement-proposal.yml` / `feature-request.yml`), and excludes items carrying `status:blocked`, `status:stale`, or `wontfix`. + - **100-Point Deterministic Priority Formula:** + $$\text{Priority Score (0–100)} = \text{Readiness (0–40)} + \text{Capped Traction (0–30)} + \text{Wait-Time Boost (0–30)} - \text{Cooldown (0 or 30)}$$ + +## Usage + +### Run Unit Tests + +```bash +uv run python -m unittest discover -s org-tools/dtc-pipeline/tests -v +``` + +### Run Community Spotlight Scan (Read-Only) + +```bash +uv run python org-tools/dtc-pipeline/scripts/dtc_pipeline.py scan-spotlight --org Universal-Commerce-Protocol --repo ucp +``` + +### Provision or Clone a GitHub Project v2 Board + +```bash +uv run python org-tools/dtc-pipeline/scripts/dtc_pipeline.py provision-project --owner Universal-Commerce-Protocol --title "UCP DTC Review Pipeline" --public +``` + +### Configure the 5 Project Views + +```bash +uv run python org-tools/dtc-pipeline/scripts/dtc_pipeline.py configure-views --project-id PVT_... +``` + +### Auto-Assign TC Member Who Moved Items to `Next DTC Agenda` + +```bash +uv run python org-tools/dtc-pipeline/scripts/dtc_pipeline.py sync-agenda-assignees --project-id PVT_... --apply +``` diff --git a/org-tools/dtc-pipeline/scripts/dtc_pipeline.py b/org-tools/dtc-pipeline/scripts/dtc_pipeline.py new file mode 100755 index 0000000..218d065 --- /dev/null +++ b/org-tools/dtc-pipeline/scripts/dtc_pipeline.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CLI entrypoint and package root for the UCP DTC Review Pipeline.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess +import sys + +# Expose `scripts/dtc_pipeline/` as submodules (`dtc_pipeline.config`, +# `dtc_pipeline.spotlight`, `dtc_pipeline.project_v2`) while keeping +# `scripts/dtc_pipeline.py` as the direct CLI script. +__path__ = [str(Path(__file__).resolve().with_suffix(""))] +if __spec__ is not None: + __spec__.submodule_search_locations = __path__ +sys.modules.setdefault("dtc_pipeline", sys.modules[__name__]) + +from dtc_pipeline.config import ( # noqa: E402,F401 + CIRCLE_BACK_MARKER_RE, + DEFAULT_EXCLUDED_COMPANIES, + DEFAULT_MAINTAINERS_PATH, + DEFAULT_ORG, + DEFAULT_REPO, + DOMAIN_LABELS, + EXCLUDED_EMAIL_DOMAINS, + EXCLUDED_ISSUE_LABELS, + EXCLUDED_ORG_TEAMS, + HYGIENE_BLOCKED_ISSUE_LABELS, + PROJECT_STATUS_OPTIONS, + PROJECT_TRACK_OPTIONS, + PROJECT_VERTICAL_DOMAINS, + PROPOSAL_LABELS, + PROPOSAL_PREFIX_RE, + VERTICAL_DOMAIN_LABELS, + infer_item_verticals, + logger, +) +from dtc_pipeline.project_v2 import GitHubProjectV2Manager # noqa: E402,F401 +from dtc_pipeline.spotlight import ( # noqa: E402,F401 + AuthorMeta, + CommentMeta, + ContributionCandidate, + GateEvaluation, + PriorityScoreBreakdown, + compute_priority_score, + evaluate_candidate_gates, + evaluate_gate1_affiliation, + evaluate_gate2_scope, + evaluate_gate3_hygiene, + fetch_excluded_handles, + has_structured_proposal_body, + is_bot_user, + log_spotlight_report, + parse_maintainers_companies, + scan_live_spotlight, +) + + +def build_parser() -> argparse.ArgumentParser: + """Builds the CLI argument parser for `dtc_pipeline.py`.""" + parser = argparse.ArgumentParser( + description="UCP DTC Meeting Pipeline & Community Spotlight Election Engine." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + scan_p = subparsers.add_parser( + "scan-spotlight", + help="Run the Community Spotlight 3-gate + 100-point election scan (and optionally sync Project v2 without applying repo labels).", + ) + scan_p.add_argument( + "--org", + default=DEFAULT_ORG, + help="GitHub organization (default: Universal-Commerce-Protocol).", + ) + scan_p.add_argument( + "--repo", + default=DEFAULT_REPO, + help="GitHub repository (default: ucp).", + ) + scan_p.add_argument( + "--project-id", + default=os.getenv("DTC_PROJECT_ID", ""), + help="Optional GitHub Project v2 GraphQL node ID (or comma-separated IDs; defaults to DTC_PROJECT_ID env var).", + ) + scan_p.add_argument( + "--apply", + action="store_true", + help="Sync eligible items (`Track = Community Spotlight` and `Priority Score`) onto `--project-id` (no repo labels applied).", + ) + + provision_p = subparsers.add_parser( + "provision-project", + help="Create or clone a GitHub Project v2 board with UCP DTC fields and views.", + ) + provision_p.add_argument( + "--owner", + required=True, + help="GitHub user or organization login (e.g. Universal-Commerce-Protocol).", + ) + provision_p.add_argument( + "--title", + default="UCP DTC Review Pipeline", + help="Project title.", + ) + provision_p.add_argument( + "--public", + action="store_true", + help="Make the project public (defaults to private).", + ) + provision_p.add_argument( + "--template-project-id", + default="", + help="Optional template Project v2 node ID to clone views and workflows from.", + ) + provision_p.add_argument( + "--replace-project-id", + default="", + help="Optional existing Project v2 node ID to delete before provisioning.", + ) + + views_p = subparsers.add_parser( + "configure-views", + help="Configure the 4 vertical TC Boards and the Community Spotlight Pool Table.", + ) + views_p.add_argument( + "--project-id", + required=True, + help="GitHub Project v2 GraphQL node ID (or comma-separated IDs).", + ) + + assignees_p = subparsers.add_parser( + "sync-agenda-assignees", + help="Auto-assign the TC member who dragged an item into Next DTC Agenda (`statusValue.creator`).", + ) + assignees_p.add_argument( + "--project-id", + default=os.getenv("DTC_PROJECT_ID", ""), + help="GitHub Project v2 GraphQL node ID (or comma-separated IDs; defaults to DTC_PROJECT_ID env var).", + ) + assignees_p.add_argument( + "--apply", + action="store_true", + help="Apply assignee mutations via GraphQL (defaults to dry-run preview).", + ) + return parser + + +def _resolve_github_token() -> str | None: + """Resolves a GitHub token from `ORG_TRIAGE_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`.""" + token = os.getenv("ORG_TRIAGE_TOKEN") or os.getenv("GITHUB_TOKEN") + if token: + return token + try: + proc = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + check=True, + ) + return proc.stdout.strip() + except Exception as exc: + logger.error("No GitHub token found in env or `gh auth token`: %s", exc) + return None + + +def main(argv: list[str] | None = None) -> int: + """CLI entrypoint for UCP DTC Pipeline & Project v2 management.""" + args = build_parser().parse_args(argv) + token = _resolve_github_token() + if not token: + return 1 + + manager = GitHubProjectV2Manager(token=token) + if args.command == "scan-spotlight": + res = manager.scan_live_spotlight(org=args.org, repo=args.repo) + log_spotlight_report(res, org=args.org, repo=args.repo) + pids = [ + pid.strip() for pid in (args.project_id or "").split(",") if pid.strip() + ] + if pids: + applied = manager.apply_spotlight_scan( + res, + project_ids=pids, + apply=args.apply, + ) + mode = "Synced" if args.apply else "[DRY-RUN] Would sync" + logger.info( + "%s %d Community Spotlight items to Project v2 (no repo labels applied).", + mode, + len(applied["synced_projects"]), + ) + return 0 + + if args.command == "provision-project": + if args.replace_project_id: + manager.delete_project(args.replace_project_id) + logger.info("Deleted old Project (%s).", args.replace_project_id) + project = manager.create_dtc_project( + owner_login=args.owner, + title=args.title, + public=args.public, + template_project_id=args.template_project_id or None, + ) + logger.info( + "Created Project #%s: %s (%s)", + project["number"], + project["title"], + project["url"], + ) + return 0 + + project_ids = [pid.strip() for pid in args.project_id.split(",") if pid.strip()] + if args.command == "configure-views": + for pid in project_ids: + for v in manager.configure_project_views(pid): + logger.info( + "[%s] Configured View #%s '%s' (%s, filter=%r)", + pid, + v.get("number"), + v.get("name"), + v.get("layout"), + v.get("filter"), + ) + return 0 + + if args.command == "sync-agenda-assignees": + if not project_ids: + logger.info( + "No project ID provided (--project-id or DTC_PROJECT_ID); skipping sync-agenda-assignees." + ) + return 0 + for pid in project_ids: + res = manager.sync_agenda_assignees(pid, apply=args.apply) + for item in res["assigned"]: + mode = "Assigned" if args.apply else "[DRY-RUN] Would assign" + logger.info( + "[%s] %s @%s to #%s (%s)", + pid, + mode, + item["assignee"], + item["number"], + item["title"], + ) + for item in res["already_assigned"]: + logger.info( + "[%s] #%s already assigned to @%s", + pid, + item["number"], + item["assignee"], + ) + return 0 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/org-tools/dtc-pipeline/scripts/dtc_pipeline/__init__.py b/org-tools/dtc-pipeline/scripts/dtc_pipeline/__init__.py new file mode 100644 index 0000000..94f82ac --- /dev/null +++ b/org-tools/dtc-pipeline/scripts/dtc_pipeline/__init__.py @@ -0,0 +1,57 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""UCP DTC Review Pipeline package.""" + +from __future__ import annotations + +from .config import ( # noqa: F401 + CIRCLE_BACK_MARKER_RE, + DEFAULT_EXCLUDED_COMPANIES, + DEFAULT_MAINTAINERS_PATH, + DEFAULT_ORG, + DEFAULT_REPO, + DOMAIN_LABELS, + EXCLUDED_EMAIL_DOMAINS, + EXCLUDED_ISSUE_LABELS, + EXCLUDED_ORG_TEAMS, + HYGIENE_BLOCKED_ISSUE_LABELS, + PROJECT_STATUS_OPTIONS, + PROJECT_TRACK_OPTIONS, + PROJECT_VERTICAL_DOMAINS, + PROPOSAL_LABELS, + PROPOSAL_PREFIX_RE, + VERTICAL_DOMAIN_LABELS, + infer_item_verticals, + logger, +) +from .project_v2 import GitHubProjectV2Manager # noqa: F401 +from .spotlight import ( # noqa: F401 + AuthorMeta, + CommentMeta, + ContributionCandidate, + GateEvaluation, + PriorityScoreBreakdown, + compute_priority_score, + evaluate_candidate_gates, + evaluate_gate1_affiliation, + evaluate_gate2_scope, + evaluate_gate3_hygiene, + fetch_excluded_handles, + has_structured_proposal_body, + is_bot_user, + log_spotlight_report, + parse_maintainers_companies, + scan_live_spotlight, +) diff --git a/org-tools/dtc-pipeline/scripts/dtc_pipeline/config.py b/org-tools/dtc-pipeline/scripts/dtc_pipeline/config.py new file mode 100644 index 0000000..881e1c9 --- /dev/null +++ b/org-tools/dtc-pipeline/scripts/dtc_pipeline/config.py @@ -0,0 +1,209 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configuration constants and label definitions for the UCP DTC Review Pipeline.""" + +from __future__ import annotations + +import logging +from pathlib import Path +import re + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("dtc_pipeline") + +DEFAULT_ORG = "Universal-Commerce-Protocol" +DEFAULT_REPO = "ucp" +DEFAULT_MAINTAINERS_PATH = str(Path(__file__).resolve().parents[4] / "MAINTAINERS.md") + +DOMAIN_LABELS = frozenset( + { + "area:shopping", + "area:payments", + "area:food", + "area:lodging", + "area:common", + } +) +VERTICAL_DOMAIN_LABELS = frozenset( + { + "area:shopping", + "area:payments", + "area:food", + "area:lodging", + } +) + +EXCLUDED_ORG_TEAMS = ( + "shopping-tech-council", + "payments-tech-council", + "food-tech-council", + "lodging-tech-council", + "governance-council", + "maintainers", +) + +DEFAULT_EXCLUDED_COMPANIES = frozenset( + { + "google", + "alphabet", + "shopify", + "stripe", + "amazon", + "meta", + "facebook", + "microsoft", + "salesforce", + "etsy", + "target", + "wayfair", + "paypal", + "adyen", + "coinbase", + "global payments", + "ant international", + "alipay", + "square", + "block", + "toast", + "doordash", + "uber", + "uber eats", + "expedia", + "booking.com", + "booking", + "trip.com", + "marriott", + "hilton", + "amadeus", + } +) + +EXCLUDED_EMAIL_DOMAINS = frozenset( + { + "google.com", + "shopify.com", + "stripe.com", + "amazon.com", + "meta.com", + "fb.com", + "microsoft.com", + "salesforce.com", + "etsy.com", + "target.com", + "wayfair.com", + "paypal.com", + "adyen.com", + "coinbase.com", + "globalpay.com", + "antgroup.com", + "squareup.com", + "block.xyz", + "toasttab.com", + "doordash.com", + "uber.com", + "expediagroup.com", + "expedia.com", + "booking.com", + "trip.com", + "marriott.com", + "hilton.com", + "amadeus.com", + } +) + +PROPOSAL_PREFIX_RE = re.compile( + r"^\s*\[(?:Feat|Proposal|Enhancement Proposal|RFC)\]\s*:?", + re.IGNORECASE, +) +PROPOSAL_LABELS = frozenset( + { + "enhancement-proposal", + "enhancement", + "rfc", + "gov:tc-meeting", + } +) +EXCLUDED_ISSUE_LABELS = frozenset({"bug", "question"}) +HYGIENE_BLOCKED_ISSUE_LABELS = frozenset({"status:stale", "status:blocked", "wontfix"}) + +CIRCLE_BACK_MARKER_RE = re.compile(r"") + +PROJECT_STATUS_OPTIONS = ( + ( + "Triage / Unscheduled", + "GRAY", + "Tagged gov:tc-meeting or community-spotlight; raw nomination awaiting triage.", + ), + ( + "Next DTC Agenda", + "ORANGE", + "⚠️ Confirm Framing Comment (or self-contained body) is in place before moving here.", + ), + ( + "Approved", + "GREEN", + "Approved by DTC; gov:tc-meeting removed and circle-back comment posted once meeting-minutes merges.", + ), + ( + "Follow-up / Revisions", + "PURPLE", + "Discussed in DTC; direction approved with revisions or follow-up requested.", + ), + ( + "Redirected / Declined", + "RED", + "Redirected to a Domain Working Group / self-hosted extension or declined.", + ), + ( + "Closed / Merged", + "GRAY", + "Automatically set by GitHub Projects native workflow when the PR/Issue is closed or merged.", + ), +) + +PROJECT_TRACK_OPTIONS = ( + ( + "General Track", + "BLUE", + "DTC or Maintainer nominated item for synchronous discussion.", + ), + ( + "Community Spotlight", + "GREEN", + "1 reserved slot/week per DTC via 100-point automated election heuristic.", + ), +) + +PROJECT_VERTICAL_DOMAINS = ( + ("Shopping", "label:area:shopping,area:common"), + ("Payments", "label:area:payments"), + ("Food", "label:area:food"), + ("Lodging", "label:area:lodging"), +) + + +def infer_item_verticals(labels: set[str] | list[str]) -> list[str]: + """Maps an Issue/PR's `area:*` labels to one or more DTC vertical names.""" + lower = {lbl.strip().lower() for lbl in labels if lbl} + matched: list[str] = [] + if lower & {"area:shopping", "area:common"}: + matched.append("Shopping") + if "area:payments" in lower: + matched.append("Payments") + if "area:food" in lower: + matched.append("Food") + if "area:lodging" in lower: + matched.append("Lodging") + return matched or ["Shopping"] diff --git a/org-tools/dtc-pipeline/scripts/dtc_pipeline/project_v2.py b/org-tools/dtc-pipeline/scripts/dtc_pipeline/project_v2.py new file mode 100644 index 0000000..cdd101c --- /dev/null +++ b/org-tools/dtc-pipeline/scripts/dtc_pipeline/project_v2.py @@ -0,0 +1,829 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""GitHub Projects v2 GraphQL client and 5-view layout configurator.""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request + +from .config import ( + DEFAULT_MAINTAINERS_PATH, + DEFAULT_ORG, + DEFAULT_REPO, + PROJECT_STATUS_OPTIONS, + PROJECT_TRACK_OPTIONS, + PROJECT_VERTICAL_DOMAINS, +) +from .spotlight import ( + AuthorMeta, + fetch_excluded_handles, + is_bot_user, + scan_live_spotlight, +) + + +class GitHubProjectV2Manager: + """Manages GitHub Projects v2 provisioning and Community Spotlight scanning via GraphQL.""" + + def __init__(self, token: str, api_url: str = "https://api.github.com"): + self.token = token + self.graphql_url = f"{api_url.rstrip('/')}/graphql" + + def graphql(self, query: str, variables: dict | None = None) -> dict: + """Executes a GraphQL request against GitHub's API with transient 5xx retry.""" + payload = {"query": query, "variables": variables or {}} + for attempt in range(4): + try: + req = urllib.request.Request( # noqa: S310 + self.graphql_url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "User-Agent": "ucp-dtc-pipeline", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + result = json.loads(resp.read().decode("utf-8")) + if "errors" in result: + err_str = json.dumps(result["errors"]) + if "Something went wrong" in err_str and attempt < 3: + time.sleep(1.0 * (attempt + 1)) + continue + raise RuntimeError(f"GraphQL errors: {err_str}") + return result.get("data", {}) + except urllib.error.HTTPError as exc: + if exc.code in (429, 500, 502, 503, 504) and attempt < 3: + time.sleep(1.0 * (attempt + 1)) + continue + raise + except urllib.error.URLError: + if attempt < 3: + time.sleep(1.0 * (attempt + 1)) + continue + raise + raise RuntimeError("GraphQL retries exhausted") + + def get_owner_id(self, login: str) -> str: + """Resolves a GitHub user or organization login to its GraphQL node ID.""" + data = self.graphql( + """ + query($login: String!) { + repositoryOwner(login: $login) { + id + } + } + """, + {"login": login}, + ) + owner = data.get("repositoryOwner") + if not owner or not owner.get("id"): + raise RuntimeError(f"Could not resolve owner ID for '{login}'.") + return owner["id"] + + def delete_project(self, project_id: str) -> None: + """Deletes a GitHub Project v2 board by its node ID.""" + self.graphql( + """ + mutation($projectId: ID!) { + deleteProjectV2(input: {projectId: $projectId}) { + projectV2 { id } + } + } + """, + {"projectId": project_id}, + ) + + @staticmethod + def _options_match( + existing_options: list[dict] | None, + target_options: tuple[tuple[str, str, str], ...], + ) -> bool: + """Returns True if `existing_options` already match `target_options` `(name, color, description)`.""" + if not existing_options or len(existing_options) != len(target_options): + return False + return [ + ( + opt.get("name"), + opt.get("color"), + opt.get("description") or "", + ) + for opt in existing_options + ] == list(target_options) + + def _ensure_status_field( + self, + status_field_id: str, + existing_options: list[dict] | None = None, + ) -> dict[str, str]: + """Configures the 6-column `Status` options (including `Next DTC Agenda`) on `status_field_id`.""" + if self._options_match(existing_options, PROJECT_STATUS_OPTIONS): + return { + opt["name"]: opt["id"] + for opt in (existing_options or []) + if opt.get("name") and opt.get("id") + } + res = self.graphql( + """ + mutation($fieldId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { + updateProjectV2Field(input: { + fieldId: $fieldId, + singleSelectOptions: $options + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + } + } + """, + { + "fieldId": status_field_id, + "options": [ + {"name": name, "color": color, "description": desc} + for name, color, desc in PROJECT_STATUS_OPTIONS + ], + }, + ) + return { + opt["name"]: opt["id"] + for opt in res["updateProjectV2Field"]["projectV2Field"]["options"] + } + + def _ensure_track_and_score_fields( + self, + project_id: str, + existing_fields: dict[str, str], + existing_options_by_field: dict[str, list[dict]] | None = None, + ) -> None: + track_options = [ + {"name": name, "color": color, "description": desc} + for name, color, desc in PROJECT_TRACK_OPTIONS + ] + if "Track" in existing_fields: + existing_track_opts = (existing_options_by_field or {}).get("Track") + if not self._options_match(existing_track_opts, PROJECT_TRACK_OPTIONS): + self.graphql( + """ + mutation($fieldId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { + updateProjectV2Field(input: { + fieldId: $fieldId, + singleSelectOptions: $options + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { id } + } + } + } + """, + {"fieldId": existing_fields["Track"], "options": track_options}, + ) + else: + self.graphql( + """ + mutation($projectId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { + createProjectV2Field(input: { + projectId: $projectId, + dataType: SINGLE_SELECT, + name: "Track", + singleSelectOptions: $options + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { id } + } + } + } + """, + {"projectId": project_id, "options": track_options}, + ) + + if "Priority Score" not in existing_fields: + self.graphql( + """ + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId, + dataType: NUMBER, + name: "Priority Score" + }) { + projectV2Field { + ... on ProjectV2Field { id } + } + } + } + """, + {"projectId": project_id}, + ) + + def create_dtc_project( + self, + owner_login: str, + title: str = "UCP DTC Review Pipeline", + public: bool = False, + template_project_id: str | None = None, + ) -> dict[str, str]: + """Creates or clones a GitHub Project v2 board and configures UCP DTC fields and 5 views.""" + owner_id = self.get_owner_id(owner_login) + if template_project_id: + create_res = self.graphql( + """ + mutation($projectId: ID!, $ownerId: ID!, $title: String!) { + copyProjectV2(input: { + projectId: $projectId, + ownerId: $ownerId, + title: $title, + includeDraftIssues: false + }) { + projectV2 { id number url title } + } + } + """, + { + "projectId": template_project_id, + "ownerId": owner_id, + "title": title, + }, + ) + project = create_res["copyProjectV2"]["projectV2"] + else: + create_res = self.graphql( + """ + mutation($ownerId: ID!, $title: String!) { + createProjectV2(input: {ownerId: $ownerId, title: $title}) { + projectV2 { id number url title } + } + } + """, + {"ownerId": owner_id, "title": title}, + ) + project = create_res["createProjectV2"]["projectV2"] + project_id = project["id"] + + self.graphql( + """ + mutation($projectId: ID!, $public: Boolean!, $shortDescription: String!) { + updateProjectV2(input: { + projectId: $projectId, + public: $public, + shortDescription: $shortDescription + }) { + projectV2 { id } + } + } + """, + { + "projectId": project_id, + "public": public, + "shortDescription": ( + "Unified DTC agenda review queue and Community Spotlight pipeline " + "(Shopping, Payments, Food, Lodging)." + ), + }, + ) + + fields_data = self.graphql( + """ + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + fields(first: 40) { + nodes { + ... on ProjectV2FieldCommon { id name } + ... on ProjectV2SingleSelectField { + id + name + options { id name color description } + } + } + } + } + } + } + """, + {"projectId": project_id}, + ) + field_nodes = [ + node + for node in ( + fields_data.get("node", {}).get("fields", {}).get("nodes") or [] + ) + if node and node.get("name") and node.get("id") + ] + existing_fields = {node["name"]: node["id"] for node in field_nodes} + existing_options_by_field = { + node["name"]: node["options"] + for node in field_nodes + if isinstance(node.get("options"), list) + } + + if "Status" in existing_fields: + self._ensure_status_field( + existing_fields["Status"], + existing_options=existing_options_by_field.get("Status"), + ) + self._ensure_track_and_score_fields( + project_id, + existing_fields, + existing_options_by_field=existing_options_by_field, + ) + self.configure_project_views(project_id) + return project + + def _upsert_board_view( + self, + project_id: str, + view_name: str, + domain_filter: str, + visible_fields: list[str], + views_by_name: dict[str, dict], + ) -> dict: + if view_name in views_by_name: + vid = views_by_name[view_name]["id"] + target_filter = views_by_name[view_name].get("filter") or domain_filter + else: + vid = self.graphql( + """ + mutation($projectId: ID!, $name: String!, $visibleFieldIds: [ID!]!) { + createProjectV2View(input: { + projectId: $projectId, + name: $name, + layout: BOARD_LAYOUT, + configuration: { visibleFieldIds: $visibleFieldIds } + }) { + projectV2View { id number name } + } + } + """, + { + "projectId": project_id, + "name": view_name, + "visibleFieldIds": visible_fields, + }, + )["createProjectV2View"]["projectV2View"]["id"] + target_filter = domain_filter + + return self.graphql( + """ + mutation($viewId: ID!, $name: String!, $filter: String!, $visibleFieldIds: [ID!]!) { + updateProjectV2View(input: { + viewId: $viewId, + name: $name, + layout: BOARD_LAYOUT, + filter: $filter, + configuration: { visibleFieldIds: $visibleFieldIds } + }) { + projectV2View { id number name layout filter } + } + } + """, + { + "viewId": vid, + "name": view_name, + "filter": target_filter, + "visibleFieldIds": visible_fields, + }, + )["updateProjectV2View"]["projectV2View"] + + def _upsert_spotlight_pool_view( + self, + project_id: str, + fields: dict[str, str], + views_by_name: dict[str, dict], + ) -> dict: + spotlight_fields = [ + fields[n] + for n in ( + "Title", + "Priority Score", + "Status", + "Milestone", + "Assignees", + "Linked pull requests", + ) + if n in fields + ] + if "Community Spotlight Pool" in views_by_name: + cs_id = views_by_name["Community Spotlight Pool"]["id"] + else: + cs_id = self.graphql( + """ + mutation($projectId: ID!, $visibleFieldIds: [ID!]!) { + createProjectV2View(input: { + projectId: $projectId, + name: "Community Spotlight Pool", + layout: TABLE_LAYOUT, + configuration: { visibleFieldIds: $visibleFieldIds } + }) { + projectV2View { id } + } + } + """, + {"projectId": project_id, "visibleFieldIds": spotlight_fields}, + )["createProjectV2View"]["projectV2View"]["id"] + return self.graphql( + """ + mutation($viewId: ID!, $filter: String!, $visibleFieldIds: [ID!]!) { + updateProjectV2View(input: { + viewId: $viewId, + layout: TABLE_LAYOUT, + filter: $filter, + configuration: { visibleFieldIds: $visibleFieldIds } + }) { + projectV2View { id number name layout filter } + } + } + """, + { + "viewId": cs_id, + "filter": ( + views_by_name.get("Community Spotlight Pool", {}).get("filter") + or "label:community-spotlight" + ), + "visibleFieldIds": spotlight_fields, + }, + )["updateProjectV2View"]["projectV2View"] + + def configure_project_views(self, project_id: str) -> list[dict]: + """Configures the 4 vertical TC Boards (` TC`) and the `Community Spotlight Pool` Table.""" + proj_data = self.graphql( + """ + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + fields(first: 40) { + nodes { + ... on ProjectV2FieldCommon { id name } + } + } + views(first: 20) { + nodes { id number name layout filter } + } + } + } + } + """, + {"projectId": project_id}, + ) + pnode = proj_data.get("node") or {} + fields = { + f["name"]: f["id"] + for f in (pnode.get("fields", {}).get("nodes") or []) + if f and f.get("name") and f.get("id") + } + views_by_name = { + v["name"]: v + for v in (pnode.get("views", {}).get("nodes") or []) + if v and v.get("name") and v.get("id") + } + + tc_fields = [ + fields[n] + for n in ( + "Title", + "Status", + "Milestone", + "Track", + "Priority Score", + "Assignees", + "Linked pull requests", + ) + if n in fields + ] + configured: list[dict] = [] + for vertical, domain_filter in PROJECT_VERTICAL_DOMAINS: + configured.append( + self._upsert_board_view( + project_id, + f"{vertical} TC", + domain_filter, + tc_fields, + views_by_name, + ) + ) + + configured.append( + self._upsert_spotlight_pool_view(project_id, fields, views_by_name) + ) + return configured + + def fetch_excluded_handles(self, org: str = DEFAULT_ORG) -> set[str]: + """Fetches GitHub handles belonging to UCP governance/maintainer teams.""" + return fetch_excluded_handles(self.graphql, org=org) + + def scan_live_spotlight( + self, + org: str = DEFAULT_ORG, + repo: str = DEFAULT_REPO, + maintainers_path: str = DEFAULT_MAINTAINERS_PATH, + ) -> dict: + """Runs a strictly read-only live Community Spotlight scan against `org/repo`.""" + return scan_live_spotlight( + self.graphql, + org=org, + repo=repo, + maintainers_path=maintainers_path, + ) + + def apply_spotlight_scan( + self, + scan_result: dict, + project_ids: list[str] | None = None, + apply: bool = False, + ) -> dict[str, list[dict]]: + """Syncs eligible Community Spotlight candidates (`Track` & `Priority Score`) to Project v2 without applying repo labels.""" + synced_projects: list[dict] = [] + + for pid in project_ids or []: + proj_data = self.graphql( + """ + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + fields(first: 30) { + nodes { + ... on ProjectV2FieldCommon { id name } + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + items(first: 100) { + nodes { + id + content { + ... on Issue { id number } + ... on PullRequest { id number } + } + statusVal: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + trackVal: fieldValueByName(name: "Track") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + scoreVal: fieldValueByName(name: "Priority Score") { + ... on ProjectV2ItemFieldNumberValue { number } + } + } + } + } + } + } + """, + {"projectId": pid}, + ) + pnode = proj_data.get("node") or {} + fields: dict[str, str] = {} + options: dict[str, dict[str, str]] = {} + for f in pnode.get("fields", {}).get("nodes") or []: + if not f or "name" not in f: + continue + fields[f["name"]] = f["id"] + if "options" in f: + options[f["name"]] = {o["name"]: o["id"] for o in f["options"]} + + existing_items: dict[int, dict] = {} + for inode in pnode.get("items", {}).get("nodes") or []: + c = (inode or {}).get("content") or {} + if c.get("number") is not None: + existing_items[c["number"]] = inode + + for cand, _geval, score in scan_result.get("eligible") or []: + if not cand.node_id: + continue + inode = existing_items.get(cand.number) + item_id = inode["id"] if inode else None + curr_status = ((inode or {}).get("statusVal") or {}).get("name") + curr_track = ((inode or {}).get("trackVal") or {}).get("name") + curr_score = ((inode or {}).get("scoreVal") or {}).get("number") + + if apply: + if not item_id: + add_res = self.graphql( + """ + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + } + """, + {"projectId": pid, "contentId": cand.node_id}, + ) + item_id = add_res["addProjectV2ItemById"]["item"]["id"] + + if not curr_status and "Status" in fields: + self.graphql( + """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optId } + }) { + projectV2Item { id } + } + } + """, + { + "projectId": pid, + "itemId": item_id, + "fieldId": fields["Status"], + "optId": options["Status"]["Triage / Unscheduled"], + }, + ) + + if curr_track != "Community Spotlight" and "Track" in fields: + self.graphql( + """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optId } + }) { + projectV2Item { id } + } + } + """, + { + "projectId": pid, + "itemId": item_id, + "fieldId": fields["Track"], + "optId": options["Track"]["Community Spotlight"], + }, + ) + + if curr_score != float(score.total) and "Priority Score" in fields: + self.graphql( + """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $num: Float!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { number: $num } + }) { + projectV2Item { id } + } + } + """, + { + "projectId": pid, + "itemId": item_id, + "fieldId": fields["Priority Score"], + "num": float(score.total), + }, + ) + + synced_projects.append( + { + "project_id": pid, + "number": cand.number, + "score": score.total, + "applied": apply, + } + ) + + return {"synced_projects": synced_projects} + + def sync_agenda_assignees( + self, + project_id: str, + apply: bool = False, + ) -> dict[str, list[dict]]: + """Auto-assigns the TC member who dragged an item into `Next DTC Agenda` via `statusValue.creator`.""" + items: list[dict] = [] + cursor: str | None = None + while True: + data = self.graphql( + """ + query($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + content { + __typename + ... on Issue { + id + number + title + assignees(first: 20) { nodes { login } } + } + ... on PullRequest { + id + number + title + assignees(first: 20) { nodes { login } } + } + } + statusValue: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + updatedAt + creator { + __typename + login + ... on User { id login } + } + } + } + } + } + } + } + } + """, + {"projectId": project_id, "cursor": cursor}, + ) + conn = (data.get("node") or {}).get("items") or {} + for node in conn.get("nodes") or []: + if node: + items.append(node) + page_info = conn.get("pageInfo") or {} + if not page_info.get("hasNextPage"): + break + cursor = page_info.get("endCursor") + + assigned: list[dict] = [] + already_assigned: list[dict] = [] + for item in items: + sv = item.get("statusValue") or {} + if sv.get("name") != "Next DTC Agenda": + continue + content = item.get("content") or {} + content_id = content.get("id") + number = content.get("number") + if not content_id or number is None: + continue + creator = sv.get("creator") or {} + creator_login = (creator.get("login") or "").strip() + creator_id = creator.get("id") + creator_type = creator.get("__typename") or "User" + if not creator_login or not creator_id: + continue + if is_bot_user(AuthorMeta(login=creator_login, user_type=creator_type)): + continue + + current_assignees = { + (a.get("login") or "").strip().lower() + for a in ((content.get("assignees") or {}).get("nodes") or []) + if a and a.get("login") + } + entry = { + "number": number, + "title": content.get("title") or "", + "assignee": creator_login, + "applied": apply, + } + if creator_login.lower() in current_assignees: + already_assigned.append(entry) + continue + + if apply: + self.graphql( + """ + mutation($assignableId: ID!, $assigneeIds: [ID!]!) { + addAssigneesToAssignable(input: { + assignableId: $assignableId, + assigneeIds: $assigneeIds + }) { + assignable { __typename } + } + } + """, + { + "assignableId": content_id, + "assigneeIds": [creator_id], + }, + ) + assigned.append(entry) + + return { + "assigned": assigned, + "already_assigned": already_assigned, + } diff --git a/org-tools/dtc-pipeline/scripts/dtc_pipeline/spotlight.py b/org-tools/dtc-pipeline/scripts/dtc_pipeline/spotlight.py new file mode 100644 index 0000000..d28ec25 --- /dev/null +++ b/org-tools/dtc-pipeline/scripts/dtc_pipeline/spotlight.py @@ -0,0 +1,914 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Community Spotlight eligibility gates, 100-point priority scoring, and live GitHub scanner.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +import datetime +from pathlib import Path +import re + +from .config import ( + CIRCLE_BACK_MARKER_RE, + DEFAULT_EXCLUDED_COMPANIES, + DEFAULT_MAINTAINERS_PATH, + DEFAULT_ORG, + DEFAULT_REPO, + DOMAIN_LABELS, + EXCLUDED_EMAIL_DOMAINS, + EXCLUDED_ISSUE_LABELS, + EXCLUDED_ORG_TEAMS, + HYGIENE_BLOCKED_ISSUE_LABELS, + PROPOSAL_LABELS, + PROPOSAL_PREFIX_RE, + infer_item_verticals, + logger, +) + +EXPLICIT_ISSUE_REF_RE = re.compile( + r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|issue|proposal|implements|addresses)\s*:?\s*(?:#|https://github\.com/[\w.-]+/[\w.-]+/issues/)(\d+)", + re.IGNORECASE, +) +ANY_ISSUE_MENTION_RE = re.compile( + r"(?:#|https://github\.com/[\w.-]+/[\w.-]+/issues/)(\d+)", + re.IGNORECASE, +) + + +@dataclass +class AuthorMeta: + """Metadata about a GitHub contribution author.""" + + login: str + user_type: str = "User" + company: str = "" + email: str = "" + + +@dataclass +class CommentMeta: + """Metadata about an issue or PR comment or review.""" + + author_login: str + author_type: str + body: str + created_at: datetime.datetime + + +@dataclass +class ContributionCandidate: + """Normalized representation of a PR, Proposal Issue, or Issue+PR Bundle.""" + + number: int + title: str + url: str + is_pr: bool + created_at: datetime.datetime + author: AuthorMeta + labels: set[str] = field(default_factory=set) + body: str = "" + draft: bool = False + mergeable_state: str = "clean" + cla_passed: bool = True + ci_passed: bool = True + ready_for_review_at: datetime.datetime | None = None + linked_issue_number: int | None = None + comments: list[CommentMeta] = field(default_factory=list) + thumbs_up_count: int = 0 + rocket_count: int = 0 + milestone_title: str | None = None + node_id: str = "" + + +@dataclass(frozen=True) +class GateEvaluation: + """Detailed result of evaluating the 3 Community Spotlight gates.""" + + eligible: bool + gate1_passed: bool + gate2_passed: bool + gate3_passed: bool + reasons: tuple[str, ...] = () + + +@dataclass(frozen=True) +class PriorityScoreBreakdown: + """Deterministic 100-Point Priority Score breakdown.""" + + readiness: int + participant_points: int + reaction_points: int + wait_days: int + wait_boost: int + cooldown_penalty: int + + @property + def traction(self) -> int: + return min(30, self.participant_points + self.reaction_points) + + @property + def total(self) -> int: + raw = self.readiness + self.traction + self.wait_boost - self.cooldown_penalty + return max(0, min(100, raw)) + + +def parse_maintainers_companies(markdown_text: str) -> set[str]: + """Extracts normalized company names from MAINTAINERS.md markdown tables.""" + companies = set(DEFAULT_EXCLUDED_COMPANIES) + for line in markdown_text.splitlines(): + stripped = line.strip() + if not stripped.startswith("|") or stripped.startswith("| :"): + continue + cells = [c.strip() for c in stripped.strip("|").split("|")] + if len(cells) < 2: + continue + name_col, company_col = cells[0].lower(), cells[1].lower() + if name_col in ("name", "") or company_col in ( + "company", + "to be elected", + "open", + "", + ): + continue + companies.add(company_col) + return companies + + +def is_bot_user(author: AuthorMeta) -> bool: + """Returns True if the author is an automated bot account.""" + login_lower = author.login.strip().lower() + return ( + author.user_type.lower() == "bot" + or login_lower.endswith("[bot]") + or login_lower in ("dependabot", "github-actions", "renovate") + ) + + +def _normalize_company(raw_company: str) -> str: + cleaned = raw_company.strip().lower().lstrip("@") + cleaned = re.sub(r"\b(?:inc\.?|llc|ltd\.?|corp\.?|nv|gmbh)\b", "", cleaned) + return re.sub(r"\s+", " ", cleaned).strip(" ,.-") + + +def evaluate_gate1_affiliation( + candidate: ContributionCandidate, + excluded_handles: set[str], + excluded_companies: set[str] = DEFAULT_EXCLUDED_COMPANIES, + excluded_email_domains: set[str] = EXCLUDED_EMAIL_DOMAINS, +) -> tuple[bool, list[str]]: + """Gate 1: Automated Non-DTC Corporate Affiliation (Author Filter).""" + reasons: list[str] = [] + person = candidate.author + if is_bot_user(person): + return (False, [f"Author '{person.login}' is an automated bot."]) + + norm_excluded_handles = {h.lower().lstrip("@") for h in excluded_handles} + norm_excluded_companies = {_normalize_company(c) for c in excluded_companies if c} + + handle = person.login.strip().lower().lstrip("@") + if handle in norm_excluded_handles: + reasons.append( + f"Author '@{person.login}' is a member of a UCP governance/maintainer team." + ) + + for excl in norm_excluded_companies: + if ( + excl + and len(excl) >= 4 + and ( + handle.endswith(f"-{excl}") + or handle.endswith(f"_{excl}") + or handle.startswith(f"{excl}-") + ) + ): + reasons.append( + f"Author '@{person.login}' handle suffix/prefix matches DTC/GC member organization '{excl}'." + ) + break + + if person.company: + norm_comp = _normalize_company(person.company) + for excl in norm_excluded_companies: + if excl and ( + norm_comp == excl + or f" {excl} " in f" {norm_comp} " + or norm_comp.startswith(f"{excl} ") + ): + reasons.append( + f"Author '@{person.login}' company '{person.company}' matches DTC/GC member organization '{excl}'." + ) + break + + if person.email and "@" in person.email: + domain = person.email.rsplit("@", 1)[-1].strip().lower() + if domain in excluded_email_domains or any( + domain.endswith(f".{d}") for d in excluded_email_domains + ): + reasons.append( + f"Author '@{person.login}' email domain '{domain}' belongs to a DTC/GC member organization." + ) + + return (len(reasons) == 0, reasons) + + +def evaluate_gate2_scope(candidate: ContributionCandidate) -> tuple[bool, list[str]]: + """Gate 2: Automated DTC Governance Scope Check (Scope Gate).""" + labels_lower = {lbl.lower() for lbl in candidate.labels} + + if candidate.is_pr: + if "gov:needs-tc-review" in labels_lower or "gov:tc-meeting" in labels_lower: + return (True, []) + return ( + False, + ["PR does not carry 'gov:needs-tc-review' or 'gov:tc-meeting'."], + ) + + if labels_lower & EXCLUDED_ISSUE_LABELS: + return (False, ["Issue is labeled 'bug' or 'question' (non-proposal)."]) + + if not (labels_lower & DOMAIN_LABELS): + return ( + False, + [ + "Standalone Issue lacks an 'area:' label (area:shopping, area:payments, area:food, area:lodging, area:common)." + ], + ) + + has_proposal_prefix = bool(PROPOSAL_PREFIX_RE.search(candidate.title)) + has_proposal_label = bool(labels_lower & PROPOSAL_LABELS) + if not (has_proposal_prefix or has_proposal_label): + return ( + False, + [ + "Standalone Issue lacks a proposal prefix ([Feat], [Proposal], [Enhancement Proposal], [RFC]) or proposal label." + ], + ) + + return (True, []) + + +def has_structured_proposal_body(body: str) -> bool: + """Checks Gate 3 structured body requirement (>= 400 chars + template headers or JSON block).""" + if len(body.strip()) < 400: + return False + lower = body.lower() + has_ep_headers = "### summary" in lower and ( + "### motivation" in lower or "### detailed design" in lower + ) + has_feat_header = "### describe the solution you'd like" in lower + has_legacy_headers = "### problem" in lower and "### proposed solution" in lower + has_json_fence = "```json" in lower + return has_ep_headers or has_feat_header or has_legacy_headers or has_json_fence + + +def evaluate_gate3_hygiene( + candidate: ContributionCandidate, +) -> tuple[bool, list[str]]: + """Gate 3: Automated Baseline Readiness Check (Hygiene Gate).""" + reasons: list[str] = [] + labels_lower = {lbl.lower() for lbl in candidate.labels} + + if labels_lower & HYGIENE_BLOCKED_ISSUE_LABELS: + reasons.append( + "Item carries a blocked/stale label ('status:stale', 'status:blocked', or 'wontfix')." + ) + + if candidate.is_pr: + if candidate.draft: + reasons.append("PR is still a WIP Draft (draft == true).") + if candidate.mergeable_state.lower() == "dirty": + reasons.append( + "PR has unresolved merge conflicts (mergeable_state == 'dirty')." + ) + if not candidate.cla_passed: + reasons.append("Google CLA check ('cla/google') has not passed.") + if not candidate.ci_passed: + reasons.append("Required schema/spec validation CI checks are failing.") + return (len(reasons) == 0, reasons) + + if not has_structured_proposal_body(candidate.body): + reasons.append( + "Issue body is < 400 characters or lacks completed proposal template headers / JSON schema block." + ) + return (len(reasons) == 0, reasons) + + +def evaluate_candidate_gates( + candidate: ContributionCandidate, + excluded_handles: set[str], + excluded_companies: set[str] = DEFAULT_EXCLUDED_COMPANIES, +) -> GateEvaluation: + """Evaluates all 3 automated Community Spotlight eligibility gates.""" + g1_ok, g1_reasons = evaluate_gate1_affiliation( + candidate, + excluded_handles=excluded_handles, + excluded_companies=excluded_companies, + ) + g2_ok, g2_reasons = evaluate_gate2_scope(candidate) + g3_ok, g3_reasons = evaluate_gate3_hygiene(candidate) + all_reasons = tuple(g1_reasons + g2_reasons + g3_reasons) + return GateEvaluation( + eligible=(g1_ok and g2_ok and g3_ok), + gate1_passed=g1_ok, + gate2_passed=g2_ok, + gate3_passed=g3_ok, + reasons=all_reasons, + ) + + +def compute_priority_score( + candidate: ContributionCandidate, + now: datetime.datetime, +) -> PriorityScoreBreakdown: + """Computes the deterministic 100-Point Priority Score for an eligible candidate.""" + # 1. Implementation & Spec Readiness (40 pts for PR, 10 pts for standalone Proposal Issue) + readiness = 40 if candidate.is_pr else 10 + + # 2. Capped Community Traction (0-30 Points) + author_login = candidate.author.login.lower() + distinct_participants: set[str] = set() + latest_circle_back_at: datetime.datetime | None = None + + for comment in candidate.comments: + if CIRCLE_BACK_MARKER_RE.search(comment.body): + if ( + latest_circle_back_at is None + or comment.created_at > latest_circle_back_at + ): + latest_circle_back_at = comment.created_at + continue + + c_login = comment.author_login.strip().lower() + if ( + not c_login + or c_login == author_login + or comment.author_type.lower() == "bot" + or c_login.endswith("[bot]") + ): + continue + if len(comment.body.strip()) > 0: + distinct_participants.add(c_login) + + participant_points = min(15, len(distinct_participants) * 5) + total_reactions = max(0, candidate.thumbs_up_count) + max(0, candidate.rocket_count) + reaction_points = min(15, total_reactions * 3) + + # 3. Anti-Starvation / Queue Aging Boost (0-30 Points) & Cooldown (0 or 30) + anchor_times = [candidate.created_at] + if candidate.ready_for_review_at is not None: + anchor_times.append(candidate.ready_for_review_at) + if latest_circle_back_at is not None: + anchor_times.append(latest_circle_back_at) + + wait_anchor = max(anchor_times) + wait_days = max(0, (now - wait_anchor).days) + wait_boost = min(30, wait_days) + + cooldown_penalty = 0 + if latest_circle_back_at is not None: + days_since_circle_back = max(0, (now - latest_circle_back_at).days) + if days_since_circle_back < 14: + cooldown_penalty = 30 + + return PriorityScoreBreakdown( + readiness=readiness, + participant_points=participant_points, + reaction_points=reaction_points, + wait_days=wait_days, + wait_boost=wait_boost, + cooldown_penalty=cooldown_penalty, + ) + + +def fetch_excluded_handles( + graphql_fn: Callable[[str, dict | None], dict], + org: str = DEFAULT_ORG, +) -> set[str]: + """Fetches GitHub handles belonging to UCP governance/maintainer teams.""" + handles: set[str] = { + "amithanda", + "damaz91", + "karangoel16", + "igrigorik", + "dwyckoff", + "drewolson", + } + try: + data = graphql_fn( + """ + query($org: String!) { + organization(login: $org) { + membersWithRole(first: 100) { + nodes { + login + } + } + teams(first: 30) { + nodes { + slug + members(first: 100) { + nodes { + login + } + } + } + } + } + } + """, + {"org": org}, + ) + org_node = data.get("organization") or {} + for member in org_node.get("membersWithRole", {}).get("nodes") or []: + if member and member.get("login"): + handles.add(member["login"].lower()) + teams = org_node.get("teams", {}).get("nodes") or [] + for team in teams: + if not team: + continue + slug = (team.get("slug") or "").lower() + if slug in EXCLUDED_ORG_TEAMS or "maintainer" in slug or "council" in slug: + for member in team.get("members", {}).get("nodes") or []: + if member and member.get("login"): + handles.add(member["login"].lower()) + except Exception as exc: + logger.debug( + "Could not query org teams (using fallback + company list): %s", exc + ) + return handles + + +def _fetch_open_issues( + graphql_fn: Callable[[str, dict | None], dict], + org: str, + repo: str, +) -> dict[int, dict]: + """Fetches all OPEN repository Issues keyed by issue number.""" + open_issues: dict[int, dict] = {} + cursor = None + while True: + data = graphql_fn( + """ + query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + issues(states: OPEN, first: 50, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { + id + number + title + url + createdAt + body + author { + login + __typename + ... on User { company email } + } + milestone { title } + labels(first: 20) { nodes { name } } + reactions(content: THUMBS_UP) { totalCount } + rocketReactions: reactions(content: ROCKET) { totalCount } + comments(first: 50) { + nodes { + author { login __typename } + body + createdAt + } + } + } + } + } + } + """, + {"owner": org, "name": repo, "cursor": cursor}, + ) + issues_conn = data["repository"]["issues"] + for node in issues_conn["nodes"]: + if node: + open_issues[node["number"]] = node + if not issues_conn["pageInfo"]["hasNextPage"]: + break + cursor = issues_conn["pageInfo"]["endCursor"] + return open_issues + + +def _fetch_open_prs( + graphql_fn: Callable[[str, dict | None], dict], + org: str, + repo: str, +) -> list[dict]: + """Fetches all OPEN repository Pull Requests.""" + open_prs: list[dict] = [] + cursor = None + while True: + data = graphql_fn( + """ + query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 40, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { + id + number + title + url + createdAt + body + isDraft + mergeable + author { + login + __typename + ... on User { company email } + } + milestone { title } + labels(first: 20) { nodes { name } } + closingIssuesReferences(first: 10) { + nodes { number } + } + reactions(content: THUMBS_UP) { totalCount } + rocketReactions: reactions(content: ROCKET) { totalCount } + comments(first: 50) { + nodes { + author { login __typename } + body + createdAt + } + } + reviews(first: 50) { + nodes { + author { login __typename } + body + createdAt + } + } + timelineItems(itemTypes: [READY_FOR_REVIEW_EVENT], last: 5) { + nodes { + ... on ReadyForReviewEvent { createdAt } + } + } + commits(last: 1) { + nodes { + commit { + author { + email + } + statusCheckRollup { + contexts(first: 40) { + nodes { + __typename + ... on CheckRun { name conclusion } + ... on StatusContext { context state } + } + } + } + } + } + } + } + } + } + } + """, + {"owner": org, "name": repo, "cursor": cursor}, + ) + prs_conn = data["repository"]["pullRequests"] + for node in prs_conn["nodes"]: + if node: + open_prs.append(node) + if not prs_conn["pageInfo"]["hasNextPage"]: + break + cursor = prs_conn["pageInfo"]["endCursor"] + return open_prs + + +def _extract_comments_meta(nodes: list[dict | None]) -> list[CommentMeta]: + """Converts raw GraphQL comment/review nodes into `CommentMeta` items.""" + comments: list[CommentMeta] = [] + for node in nodes: + if not node: + continue + author = node.get("author") or {} + comments.append( + CommentMeta( + author_login=author.get("login") or "", + author_type=author.get("__typename") or "User", + body=node.get("body") or "", + created_at=datetime.datetime.fromisoformat( + node["createdAt"].replace("Z", "+00:00") + ), + ) + ) + return comments + + +def _find_linked_issue_numbers( + pr: dict, + open_issues: dict[int, dict], + author_login: str, +) -> list[int]: + """Finds up to 1 primary linked open Proposal Issue for a Pull Request.""" + linked_nums: list[int] = [] + for ref in pr.get("closingIssuesReferences", {}).get("nodes") or []: + if ref and ref.get("number") in open_issues: + linked_nums.append(ref["number"]) + body = pr.get("body") or "" + if not linked_nums: + for m in EXPLICIT_ISSUE_REF_RE.finditer(body): + inum = int(m.group(1)) + if inum in open_issues: + linked_nums.append(inum) + break + if not linked_nums: + for m in ANY_ISSUE_MENTION_RE.finditer(body): + inum = int(m.group(1)) + if inum in open_issues: + iss_author = ( + (open_issues[inum].get("author") or {}).get("login") or "" + ).lower() + if iss_author and iss_author == author_login.lower(): + linked_nums.append(inum) + break + return linked_nums[:1] + + +def _parse_pr_checks(pr: dict) -> tuple[bool, bool]: + """Extracts `(cla_passed, ci_passed)` from the PR's latest commit rollup.""" + cla_passed = True + ci_passed = True + commits_nodes = (pr.get("commits") or {}).get("nodes") or [] + if not (commits_nodes and commits_nodes[-1].get("commit")): + return cla_passed, ci_passed + + rollup = commits_nodes[-1]["commit"].get("statusCheckRollup") or {} + for ctx in rollup.get("contexts", {}).get("nodes") or []: + if not ctx: + continue + if ctx.get("__typename") == "StatusContext": + cname = (ctx.get("context") or "").lower() + cstate = (ctx.get("state") or "").upper() + if "cla" in cname: + cla_passed = cstate == "SUCCESS" + elif "governance" not in cname and cstate in ("FAILURE", "ERROR"): + ci_passed = False + elif ctx.get("__typename") == "CheckRun": + cname = (ctx.get("name") or "").lower() + concl = (ctx.get("conclusion") or "").upper() + if "cla" in cname: + cla_passed = concl in ("SUCCESS", "NEUTRAL", "SKIPPED") + elif "governance" not in cname and concl in ( + "FAILURE", + "TIMED_OUT", + "ACTION_REQUIRED", + ): + ci_passed = False + return cla_passed, ci_passed + + +def _build_pr_candidate( + pr: dict, + open_issues: dict[int, dict], + bundled_issue_nums: set[int], +) -> ContributionCandidate: + """Constructs a `ContributionCandidate` from a raw PR node, bundling any linked open Proposal Issue.""" + author_node = pr.get("author") or {} + commit_email = "" + commits_nodes = (pr.get("commits") or {}).get("nodes") or [] + if commits_nodes and commits_nodes[-1].get("commit"): + commit_email = (commits_nodes[-1]["commit"].get("author") or {}).get( + "email" + ) or "" + author = AuthorMeta( + login=author_node.get("login") or "ghost", + user_type=author_node.get("__typename") or "User", + company=author_node.get("company") or "", + email=author_node.get("email") or commit_email, + ) + labels = { + lbl["name"] + for lbl in (pr.get("labels", {}).get("nodes") or []) + if lbl and lbl.get("name") + } + + pr_created = datetime.datetime.fromisoformat(pr["createdAt"].replace("Z", "+00:00")) + effective_created = pr_created + linked_issue_num = None + + raw_pr_comments = (pr.get("comments", {}).get("nodes") or []) + ( + pr.get("reviews", {}).get("nodes") or [] + ) + comments_list = _extract_comments_meta(raw_pr_comments) + thumbs = (pr.get("reactions") or {}).get("totalCount", 0) + rockets = (pr.get("rocketReactions") or {}).get("totalCount", 0) + + for inum in _find_linked_issue_numbers(pr, open_issues, author.login): + bundled_issue_nums.add(inum) + issue_node = open_issues[inum] + icreated = datetime.datetime.fromisoformat( + issue_node["createdAt"].replace("Z", "+00:00") + ) + if linked_issue_num is None: + linked_issue_num = inum + if icreated < effective_created: + effective_created = icreated + for ilbl in issue_node.get("labels", {}).get("nodes") or []: + if ilbl and ilbl.get("name"): + labels.add(ilbl["name"]) + thumbs += (issue_node.get("reactions") or {}).get("totalCount", 0) + rockets += (issue_node.get("rocketReactions") or {}).get("totalCount", 0) + comments_list.extend( + _extract_comments_meta(issue_node.get("comments", {}).get("nodes") or []) + ) + + ready_at = None + for ev in pr.get("timelineItems", {}).get("nodes") or []: + if ev and ev.get("createdAt"): + ready_at = datetime.datetime.fromisoformat( + ev["createdAt"].replace("Z", "+00:00") + ) + cla_passed, ci_passed = _parse_pr_checks(pr) + mergeable_raw = (pr.get("mergeable") or "MERGEABLE").upper() + mergeable_state = "dirty" if mergeable_raw == "CONFLICTING" else "clean" + + ms_title = (pr.get("milestone") or {}).get("title") + if not ms_title and linked_issue_num is not None: + ms_title = (open_issues[linked_issue_num].get("milestone") or {}).get("title") + + return ContributionCandidate( + number=pr["number"], + title=pr["title"], + url=pr["url"], + is_pr=True, + created_at=effective_created, + author=author, + labels=labels, + body=pr.get("body") or "", + draft=bool(pr.get("isDraft")), + mergeable_state=mergeable_state, + cla_passed=cla_passed, + ci_passed=ci_passed, + ready_for_review_at=ready_at, + linked_issue_number=linked_issue_num, + comments=comments_list, + thumbs_up_count=thumbs, + rocket_count=rockets, + milestone_title=ms_title, + node_id=pr.get("id") or "", + ) + + +def _build_issue_candidate(inum: int, inode: dict) -> ContributionCandidate: + """Constructs a standalone `ContributionCandidate` from a raw Issue node.""" + author_node = inode.get("author") or {} + author = AuthorMeta( + login=author_node.get("login") or "ghost", + user_type=author_node.get("__typename") or "User", + company=author_node.get("company") or "", + email=author_node.get("email") or "", + ) + labels = { + lbl["name"] + for lbl in (inode.get("labels", {}).get("nodes") or []) + if lbl and lbl.get("name") + } + icreated = datetime.datetime.fromisoformat( + inode["createdAt"].replace("Z", "+00:00") + ) + comments_list = _extract_comments_meta(inode.get("comments", {}).get("nodes") or []) + thumbs = (inode.get("reactions") or {}).get("totalCount", 0) + rockets = (inode.get("rocketReactions") or {}).get("totalCount", 0) + return ContributionCandidate( + number=inum, + title=inode["title"], + url=inode["url"], + is_pr=False, + created_at=icreated, + author=author, + labels=labels, + body=inode.get("body") or "", + comments=comments_list, + thumbs_up_count=thumbs, + rocket_count=rockets, + milestone_title=(inode.get("milestone") or {}).get("title"), + node_id=inode.get("id") or "", + ) + + +def scan_live_spotlight( + graphql_fn: Callable[[str, dict | None], dict], + org: str = DEFAULT_ORG, + repo: str = DEFAULT_REPO, + maintainers_path: str = DEFAULT_MAINTAINERS_PATH, +) -> dict: + """Runs a strictly read-only live Community Spotlight scan against `org/repo`.""" + now = datetime.datetime.now(datetime.timezone.utc) + excluded_companies = set(DEFAULT_EXCLUDED_COMPANIES) + mp = Path(maintainers_path) + if mp.exists(): + excluded_companies |= parse_maintainers_companies( + mp.read_text(encoding="utf-8") + ) + excluded_handles = fetch_excluded_handles(graphql_fn, org=org) + + open_issues = _fetch_open_issues(graphql_fn, org, repo) + open_prs = _fetch_open_prs(graphql_fn, org, repo) + + bundled_issue_nums: set[int] = set() + candidates: list[ContributionCandidate] = [ + _build_pr_candidate(pr, open_issues, bundled_issue_nums) for pr in open_prs + ] + for inum, inode in open_issues.items(): + if inum not in bundled_issue_nums: + candidates.append(_build_issue_candidate(inum, inode)) + + eligible_items: list[ + tuple[ContributionCandidate, GateEvaluation, PriorityScoreBreakdown] + ] = [] + gate_counts = { + "total_open_prs": len(open_prs), + "total_open_issues": len(open_issues), + "bundled_pairs": len(bundled_issue_nums), + "total_candidates": len(candidates), + "passed_gate1": 0, + "passed_gate1_and_2": 0, + "passed_all_3_eligible": 0, + } + + for cand in candidates: + geval = evaluate_candidate_gates( + cand, + excluded_handles=excluded_handles, + excluded_companies=excluded_companies, + ) + score = compute_priority_score(cand, now=now) + if geval.gate1_passed: + gate_counts["passed_gate1"] += 1 + if geval.gate1_passed and geval.gate2_passed: + gate_counts["passed_gate1_and_2"] += 1 + if geval.gate3_passed: + gate_counts["passed_all_3_eligible"] += 1 + eligible_items.append((cand, geval, score)) + + eligible_items.sort( + key=lambda item: (-item[2].total, -item[2].readiness, item[0].created_at) + ) + return { + "counts": gate_counts, + "eligible": eligible_items, + } + + +def log_spotlight_report(res: dict, org: str, repo: str) -> None: + """Logs a concise summary of the Community Spotlight scan per vertical.""" + counts = res["counts"] + logger.info("=== Community Spotlight Scan (%s/%s) ===", org, repo) + logger.info( + "Candidates: %d | Passed Gate 1: %d | Passed Gate 1+2: %d | Eligible (All 3 Gates): %d", + counts["total_candidates"], + counts["passed_gate1"], + counts["passed_gate1_and_2"], + counts["passed_all_3_eligible"], + ) + vertical_to_domain = ( + ("Shopping", "area:shopping"), + ("Payments", "area:payments"), + ("Food", "area:food"), + ("Lodging", "area:lodging"), + ) + for vertical, domain in vertical_to_domain: + items = [ + (cand, score) + for cand, _geval, score in res["eligible"] + if vertical in infer_item_verticals(cand.labels) + ] + logger.info("--- %s (%d eligible) ---", domain, len(items)) + for idx, (cand, score) in enumerate(items, start=1): + kind = "PR" if cand.is_pr else "Issue" + logger.info( + " #%d: %s #%d (@%s) | Score: %d/100 (Readiness=%d, Traction=%d, Wait=%d, Cooldown=-%d) | %s", + idx, + kind, + cand.number, + cand.author.login, + score.total, + score.readiness, + score.traction, + score.wait_boost, + score.cooldown_penalty, + cand.title, + ) diff --git a/org-tools/dtc-pipeline/tests/test_dtc_pipeline.py b/org-tools/dtc-pipeline/tests/test_dtc_pipeline.py new file mode 100755 index 0000000..5d1e607 --- /dev/null +++ b/org-tools/dtc-pipeline/tests/test_dtc_pipeline.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the UCP DTC Meeting Pipeline & Community Spotlight Engine.""" + +from __future__ import annotations + +import datetime +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import dtc_pipeline as dp # noqa: E402 + + +class TestDtcPipeline(unittest.TestCase): + """Tests for the 3 automated eligibility gates, 100-point formula, and Project v2 schema.""" + + def setUp(self) -> None: + self.now = datetime.datetime(2026, 10, 20, 9, 0, tzinfo=datetime.timezone.utc) + self.excluded_handles = {"amithanda", "igrigorik", "gsmith85", "karangoel16"} + + def test_parse_maintainers_companies(self) -> None: + md = """ +| Name | Company | +| :--- | :------ | +| Amit Handa* | Google | +| Ilya Grigorik | Shopify | +| Open | to be elected | +""" + companies = dp.parse_maintainers_companies(md) + self.assertIn("google", companies) + self.assertIn("shopify", companies) + self.assertNotIn("to be elected", companies) + + def test_gate1_excludes_dtc_member_and_company_and_bot(self) -> None: + ext_candidate = dp.ContributionCandidate( + number=773, + title="feat: add state-bound Offer extension for Cart proposals", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/773", + is_pr=True, + created_at=self.now - datetime.timedelta(days=36), + author=dp.AuthorMeta( + login="arjun2075", + company="Intuit", + email="arjun@intuit.com", + ), + labels={"gov:needs-tc-review", "area:shopping"}, + ) + ok, reasons = dp.evaluate_gate1_affiliation( + ext_candidate, self.excluded_handles + ) + self.assertTrue(ok) + self.assertEqual(reasons, []) + + goog_candidate = dp.ContributionCandidate( + number=900, + title="feat: internal proposal", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/900", + is_pr=True, + created_at=self.now, + author=dp.AuthorMeta( + login="somegoogler", + company="", + email="dev@google.com", + ), + ) + ok_g, reasons_g = dp.evaluate_gate1_affiliation( + goog_candidate, self.excluded_handles + ) + self.assertFalse(ok_g) + self.assertTrue(any("google.com" in r for r in reasons_g)) + + bot_candidate = dp.ContributionCandidate( + number=901, + title="chore(deps): bump uv", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/901", + is_pr=True, + created_at=self.now, + author=dp.AuthorMeta(login="dependabot[bot]", user_type="Bot"), + ) + ok_b, _ = dp.evaluate_gate1_affiliation(bot_candidate, self.excluded_handles) + self.assertFalse(ok_b) + + def test_gate2_scope_pr_and_issue(self) -> None: + tc_pr = dp.ContributionCandidate( + number=811, + title="feat: add per-seller offer attribution", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/811", + is_pr=True, + created_at=self.now, + author=dp.AuthorMeta(login="proshoumma", company="Klarna"), + labels={"gov:needs-tc-review", "area:shopping"}, + ) + self.assertTrue(dp.evaluate_gate2_scope(tc_pr)[0]) + + typo_pr = dp.ContributionCandidate( + number=812, + title="docs: fix typo in overview", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/812", + is_pr=True, + created_at=self.now, + author=dp.AuthorMeta(login="proshoumma", company="Klarna"), + ) + self.assertFalse(dp.evaluate_gate2_scope(typo_pr)[0]) + + prop_issue = dp.ContributionCandidate( + number=841, + title="[Proposal]: Structured inapplicable discount codes", + url="https://github.com/Universal-Commerce-Protocol/ucp/issues/841", + is_pr=False, + created_at=self.now, + author=dp.AuthorMeta(login="extdev"), + labels={"area:shopping", "enhancement-proposal"}, + ) + self.assertTrue(dp.evaluate_gate2_scope(prop_issue)[0]) + + common_issue = dp.ContributionCandidate( + number=842, + title="[RFC]: Shared envelope pagination cursor", + url="https://github.com/Universal-Commerce-Protocol/ucp/issues/842", + is_pr=False, + created_at=self.now, + author=dp.AuthorMeta(login="extdev"), + labels={"area:common", "rfc"}, + ) + self.assertTrue(dp.evaluate_gate2_scope(common_issue)[0]) + + def test_gate3_hygiene_and_blocked_exclusion(self) -> None: + dirty_pr = dp.ContributionCandidate( + number=700, + title="feat: dirty pr", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/700", + is_pr=True, + created_at=self.now, + author=dp.AuthorMeta(login="extdev"), + mergeable_state="dirty", + ) + ok, reasons = dp.evaluate_gate3_hygiene(dirty_pr) + self.assertFalse(ok) + self.assertTrue(any("dirty" in r for r in reasons)) + + blocked_pr = dp.ContributionCandidate( + number=701, + title="feat: blocked pr", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/701", + is_pr=True, + created_at=self.now, + author=dp.AuthorMeta(login="extdev"), + labels={"status:blocked"}, + ) + ok_b, reasons_b = dp.evaluate_gate3_hygiene(blocked_pr) + self.assertFalse(ok_b) + self.assertTrue(any("status:blocked" in r for r in reasons_b)) + + def test_priority_score_and_cooldown(self) -> None: + pr_773 = dp.ContributionCandidate( + number=773, + title="feat: add state-bound Offer extension for Cart proposals", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/773", + is_pr=True, + created_at=self.now - datetime.timedelta(days=36), + author=dp.AuthorMeta(login="arjun2075", company="Intuit"), + labels={"gov:needs-tc-review", "area:shopping"}, + comments=[ + dp.CommentMeta( + "reviewer1", + "User", + "Looks promising", + self.now - datetime.timedelta(days=10), + ), + dp.CommentMeta( + "reviewer2", + "User", + "Question on schema", + self.now - datetime.timedelta(days=8), + ), + dp.CommentMeta( + "reviewer3", + "User", + "+1 on use case", + self.now - datetime.timedelta(days=5), + ), + ], + ) + score = dp.compute_priority_score(pr_773, self.now) + self.assertEqual(score.readiness, 40) + self.assertEqual(score.traction, 15) + self.assertEqual(score.wait_boost, 30) + self.assertEqual(score.cooldown_penalty, 0) + self.assertEqual(score.total, 85) + + pr_773.comments.append( + dp.CommentMeta( + "github-actions[bot]", + "Bot", + "\n📋 **Discussed**", + self.now - datetime.timedelta(days=3), + ) + ) + score_after = dp.compute_priority_score(pr_773, self.now) + self.assertEqual(score_after.wait_boost, 3) + self.assertEqual(score_after.cooldown_penalty, 30) + self.assertEqual(score_after.total, 40 + 15 + 3 - 30) + + def test_status_options_six_columns_with_next_dtc_agenda(self) -> None: + self.assertEqual( + [opt[0] for opt in dp.PROJECT_STATUS_OPTIONS], + [ + "Triage / Unscheduled", + "Next DTC Agenda", + "Approved", + "Follow-up / Revisions", + "Redirected / Declined", + "Closed / Merged", + ], + ) + self.assertEqual(dp.PROJECT_STATUS_OPTIONS[1][0], "Next DTC Agenda") + self.assertEqual(dp.PROJECT_STATUS_OPTIONS[1][1], "ORANGE") + self.assertIn("⚠️ Confirm Framing", dp.PROJECT_STATUS_OPTIONS[1][2]) + + class StubManager(dp.GitHubProjectV2Manager): + def graphql(self, query: str, variables: dict | None = None) -> dict: + if "updateProjectV2Field" in query and variables is not None: + return { + "updateProjectV2Field": { + "projectV2Field": { + "id": variables["fieldId"], + "options": [ + {"id": f"opt_{idx}", "name": o["name"]} + for idx, o in enumerate(variables["options"]) + ], + } + } + } + return {} + + mgr = StubManager(token="fake") + opts = mgr._ensure_status_field("f_status") + self.assertEqual(len(opts), 6) + self.assertNotIn("Ready / Framed", opts) + self.assertIn("Next DTC Agenda", opts) + + matching_existing = [ + {"id": f"existing_{idx}", "name": name, "color": color, "description": desc} + for idx, (name, color, desc) in enumerate(dp.PROJECT_STATUS_OPTIONS) + ] + mutations_called = False + + class NoOpMutationManager(dp.GitHubProjectV2Manager): + def graphql(self, query: str, variables: dict | None = None) -> dict: + nonlocal mutations_called + if "updateProjectV2Field" in query: + mutations_called = True + return {} + + noop_mgr = NoOpMutationManager(token="fake") + preserved = noop_mgr._ensure_status_field( + "f_status", existing_options=matching_existing + ) + self.assertFalse(mutations_called) + self.assertEqual(preserved["Next DTC Agenda"], "existing_1") + + def test_spotlight_score_is_milestone_blind(self) -> None: + base_pr = dp.ContributionCandidate( + number=811, + title="feat: add per-seller offer attribution", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/811", + is_pr=True, + created_at=self.now - datetime.timedelta(days=24), + author=dp.AuthorMeta(login="proshoumma", company="Klarna"), + labels={"gov:needs-tc-review", "area:shopping"}, + milestone_title=None, + ) + milestoned_pr = dp.ContributionCandidate( + number=811, + title="feat: add per-seller offer attribution", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/811", + is_pr=True, + created_at=self.now - datetime.timedelta(days=24), + author=dp.AuthorMeta(login="proshoumma", company="Klarna"), + labels={"gov:needs-tc-review", "area:shopping"}, + milestone_title="2026-12-08", + ) + self.assertEqual( + dp.compute_priority_score(base_pr, self.now).total, + dp.compute_priority_score(milestoned_pr, self.now).total, + ) + + def test_configure_project_views_five_views_single_board_per_vertical(self) -> None: + calls: list[tuple[str, dict | None]] = [] + + class StubManager(dp.GitHubProjectV2Manager): + def graphql(self, query: str, variables: dict | None = None) -> dict: + calls.append((query, variables)) + if "views(first: 20)" in query: + return { + "node": { + "fields": { + "nodes": [ + {"id": "f_title", "name": "Title"}, + {"id": "f_status", "name": "Status"}, + {"id": "f_ms", "name": "Milestone"}, + {"id": "f_track", "name": "Track"}, + {"id": "f_labels", "name": "Labels"}, + {"id": "f_score", "name": "Priority Score"}, + ] + }, + "views": { + "nodes": [ + { + "id": "v1", + "number": 1, + "name": "Shopping TC", + "layout": "BOARD_LAYOUT", + "filter": "label:area:shopping,area:common", + }, + { + "id": "v2", + "number": 2, + "name": "Payments TC", + "layout": "BOARD_LAYOUT", + "filter": "label:area:payments", + }, + { + "id": "v3", + "number": 3, + "name": "Food TC", + "layout": "BOARD_LAYOUT", + "filter": "label:area:food", + }, + { + "id": "v4", + "number": 4, + "name": "Lodging TC", + "layout": "BOARD_LAYOUT", + "filter": "label:area:lodging", + }, + { + "id": "v5", + "number": 5, + "name": "Community Spotlight Pool", + "layout": "TABLE_LAYOUT", + "filter": "label:community-spotlight", + }, + ] + }, + } + } + if "updateProjectV2View" in query: + assert variables is not None + return { + "updateProjectV2View": { + "projectV2View": { + "id": variables["viewId"], + "number": 1, + "name": variables.get( + "name", "Community Spotlight Pool" + ), + "layout": ( + "BOARD_LAYOUT" + if "BOARD_LAYOUT" in query + else "TABLE_LAYOUT" + ), + "filter": variables.get("filter"), + } + } + } + return {} + + mgr = StubManager(token="fake") + configured = mgr.configure_project_views("PVT_test") + self.assertEqual(len(configured), 5) + + update_vars = [ + v for q, v in calls if "updateProjectV2View" in q and v is not None + ] + self.assertEqual(len(update_vars), 5) + for v in update_vars: + self.assertIn("f_ms", v["visibleFieldIds"]) + self.assertNotIn("f_labels", v["visibleFieldIds"]) + + def test_sync_agenda_assignees_auto_assigns_mover_to_next_dtc_agenda(self) -> None: + mutations: list[dict] = [] + + class StubManager(dp.GitHubProjectV2Manager): + def graphql(self, query: str, variables: dict | None = None) -> dict: + if "addAssigneesToAssignable" in query: + assert variables is not None + mutations.append(variables) + return { + "addAssigneesToAssignable": { + "assignable": {"__typename": "PullRequest"} + } + } + return { + "node": { + "items": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [ + { + "id": "item_1", + "content": { + "__typename": "PullRequest", + "id": "PR_node_773", + "number": 773, + "title": "Offer extension", + "assignees": {"nodes": []}, + }, + "statusValue": { + "name": "Next DTC Agenda", + "updatedAt": "2026-10-29T10:00:00Z", + "creator": { + "__typename": "User", + "id": "U_damaz91", + "login": "damaz91", + }, + }, + }, + { + "id": "item_2", + "content": { + "__typename": "Issue", + "id": "I_node_738", + "number": 738, + "title": "Already assigned", + "assignees": {"nodes": [{"login": "gsmith85"}]}, + }, + "statusValue": { + "name": "Next DTC Agenda", + "updatedAt": "2026-10-29T10:05:00Z", + "creator": { + "__typename": "User", + "id": "U_gsmith85", + "login": "gsmith85", + }, + }, + }, + { + "id": "item_3", + "content": { + "__typename": "PullRequest", + "id": "PR_node_691", + "number": 691, + "title": "In Triage", + "assignees": {"nodes": []}, + }, + "statusValue": { + "name": "Triage / Unscheduled", + "updatedAt": "2026-10-29T10:00:00Z", + "creator": { + "__typename": "User", + "id": "U_damaz91", + "login": "damaz91", + }, + }, + }, + ], + } + } + } + + mgr = StubManager(token="fake") + res = mgr.sync_agenda_assignees("PVT_test", apply=True) + self.assertEqual(len(res["assigned"]), 1) + self.assertEqual(res["assigned"][0]["number"], 773) + self.assertEqual(res["assigned"][0]["assignee"], "damaz91") + self.assertEqual(len(res["already_assigned"]), 1) + self.assertEqual(res["already_assigned"][0]["number"], 738) + self.assertEqual( + mutations, + [{"assignableId": "PR_node_773", "assigneeIds": ["U_damaz91"]}], + ) + + def test_apply_spotlight_scan_syncs_project_without_applying_labels(self) -> None: + mutations: list[tuple[str, dict]] = [] + + class StubManager(dp.GitHubProjectV2Manager): + def graphql(self, query: str, variables: dict | None = None) -> dict: + if "fields(first: 30)" in query: + return { + "node": { + "fields": { + "nodes": [ + { + "id": "F_status", + "name": "Status", + "options": [ + { + "id": "OPT_triage", + "name": "Triage / Unscheduled", + } + ], + }, + { + "id": "F_track", + "name": "Track", + "options": [ + { + "id": "OPT_cs", + "name": "Community Spotlight", + } + ], + }, + {"id": "F_score", "name": "Priority Score"}, + ] + }, + "items": {"nodes": []}, + } + } + if "addProjectV2ItemById" in query: + assert variables is not None + mutations.append(("add_item", variables)) + return {"addProjectV2ItemById": {"item": {"id": "PVTI_811"}}} + if "addLabelsToLabelable" in query: + raise AssertionError("Should never apply repository labels") + if "updateProjectV2ItemFieldValue" in query: + assert variables is not None + mutations.append(("update_field", variables)) + return { + "updateProjectV2ItemFieldValue": { + "projectV2Item": {"id": "PVTI_811"} + } + } + return {} + + cand = dp.ContributionCandidate( + number=811, + title="feat: add per-seller offer attribution", + url="https://github.com/Universal-Commerce-Protocol/ucp/pull/811", + is_pr=True, + created_at=self.now, + author=dp.AuthorMeta(login="proshoumma", company="Klarna"), + labels={"gov:needs-tc-review", "area:shopping"}, + node_id="PR_node_811", + ) + score = dp.PriorityScoreBreakdown( + readiness=40, + participant_points=10, + reaction_points=9, + wait_days=30, + wait_boost=30, + cooldown_penalty=0, + ) + scan_result = { + "eligible": [ + ( + cand, + dp.GateEvaluation(True, True, True, True, ()), + score, + ) + ] + } + mgr = StubManager(token="fake") + applied = mgr.apply_spotlight_scan( + scan_result, + project_ids=["PVT_prod"], + apply=True, + ) + self.assertEqual(len(applied["synced_projects"]), 1) + self.assertEqual( + [m[0] for m in mutations], + ["add_item", "update_field", "update_field", "update_field"], + ) + + def test_pending_cla_check_run_is_not_treated_as_passed(self) -> None: + from dtc_pipeline.spotlight import _parse_pr_checks + + pr_pending_cla = { + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "cla/google", + "conclusion": None, + } + ] + } + } + } + } + ] + } + } + cla_passed, ci_passed = _parse_pr_checks(pr_pending_cla) + self.assertFalse(cla_passed) + self.assertTrue(ci_passed) + + +if __name__ == "__main__": + unittest.main() diff --git a/org-tools/label-sync/labels/general-labels.yml b/org-tools/label-sync/labels/general-labels.yml index d6df982..69fb5f1 100644 --- a/org-tools/label-sync/labels/general-labels.yml +++ b/org-tools/label-sync/labels/general-labels.yml @@ -40,10 +40,6 @@ color: "d876e3" description: Further information is requested -- name: TC review - color: "88b91e" - description: Ready for TC review - - name: Triage color: "f1d66a" diff --git a/org-tools/label-sync/labels/triage-labels.yml b/org-tools/label-sync/labels/triage-labels.yml index b2f68c6..70b9672 100644 --- a/org-tools/label-sync/labels/triage-labels.yml +++ b/org-tools/label-sync/labels/triage-labels.yml @@ -56,3 +56,13 @@ - name: gov:approved color: "C2E0C6" description: Triggers the final code ownership checks + +- name: gov:tc-meeting + color: "88B91E" + description: Nominated for synchronous discussion at an upcoming weekly DTC meeting + aliases: + - "TC review" + +- name: community-spotlight + color: "FBCA04" + description: Eligible external community contribution tracked in the DTC Community Spotlight pool