This is an automated email from the ASF dual-hosted git repository.

400Ping pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/mahout.git


The following commit(s) were added to refs/heads/main by this push:
     new 317756ddd [CI] Require a committer approval before merging PRs, route 
Discussions to dev@ (#1494)
317756ddd is described below

commit 317756ddd7e5f82f1ef071c96abba6674347401d
Author: Jie-Kai Chang <[email protected]>
AuthorDate: Mon Sep 21 23:21:05 2026 +0800

    [CI] Require a committer approval before merging PRs, route Discussions to 
dev@ (#1494)
    
    Signed-off-by: 400Ping <[email protected]>
---
 .asf.yaml                                         |  13 +-
 .claude/launch.json                               |  18 --
 .github/pmc-members.txt                           |  42 ++++
 .github/scripts/check_pr_approval.py              | 240 ++++++++++++++++++++++
 .github/workflows/pr-approval-check.yml           |  66 ++++++
 docs/community/pr-policy-and-review-guidelines.md |  12 +-
 6 files changed, 371 insertions(+), 20 deletions(-)

diff --git a/.asf.yaml b/.asf.yaml
index fbc1e54a1..1bfb5ec67 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -59,6 +59,17 @@ github:
     main:
       required_linear_history: true
       required_signatures: false
+      # PR policy (docs/community/pr-policy-and-review-guidelines.md): a PR 
needs
+      # at least one approving review from a committer before it can be merged,
+      # unless it was opened by a PMC member (.github/pmc-members.txt).
+      # GitHub's own required_approving_review_count (as used by Airflow) 
cannot
+      # exempt PMC members, so the rule is enforced through this required 
status
+      # check produced by .github/workflows/pr-approval-check.yml.
+      required_status_checks:
+        strict: false
+        checks:
+          - context: "Committer approval"
+            app_id: 15368  # GitHub Actions
   environments:
     pypi:
       required_reviewers:
@@ -81,4 +92,4 @@ notifications:
   commits: [email protected]
   issues: [email protected]
   pullrequests: [email protected]
-  discussions: [email protected]
+  discussions: [email protected]
diff --git a/.claude/launch.json b/.claude/launch.json
deleted file mode 100644
index ba6b3530e..000000000
--- a/.claude/launch.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-  "version": "0.0.1",
-  "configurations": [
-    {
-      "name": "website-serve",
-      "runtimeExecutable": "/usr/local/bin/node",
-      "runtimeArgs": [
-        "node_modules/.bin/docusaurus",
-        "serve",
-        "--port",
-        "3001",
-        "--no-open"
-      ],
-      "cwd": "website",
-      "port": 3001
-    }
-  ]
-}
\ No newline at end of file
diff --git a/.github/pmc-members.txt b/.github/pmc-members.txt
new file mode 100644
index 000000000..671dfbcca
--- /dev/null
+++ b/.github/pmc-members.txt
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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 usernames of current Apache Mahout PMC members.
+#
+# Pull requests opened by these users are exempt from the
+# "at least one committer approval" rule enforced by
+# .github/workflows/pr-approval-check.yml (see
+# docs/community/pr-policy-and-review-guidelines.md).
+#
+# The authoritative PMC roster is LDAP 
(https://whimsy.apache.org/roster/pmc/mahout,
+# public copy at https://whimsy.apache.org/public/public_ldap_projects.json 
under
+# projects.mahout.owners). Only PMC members whose GitHub account is linked to
+# their ASF id (https://gitbox.apache.org/boxer/) can merge; the others are 
listed
+# for completeness. Keep one username per line, case-insensitive.
+#
+# GitHub username   # ASF id
+andrewmusselman     # akm
+andrewpalumbo       # apalumbo
+guan404ming         # guanmingchiu
+MaineC              # isabel
+400Ping             # jiekaichang
+rich7420            # richhuang
+sscdotopen          # ssc
+magsol              # squinn
+sslavic             # sslavic
+rawkintrevo         # rawkintrevo
+# No GitHub account on record (cannot merge until linked): jmannix, pranjan, 
robinanil
diff --git a/.github/scripts/check_pr_approval.py 
b/.github/scripts/check_pr_approval.py
new file mode 100644
index 000000000..a0823bf2e
--- /dev/null
+++ b/.github/scripts/check_pr_approval.py
@@ -0,0 +1,240 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+"""Check that a pull request has at least one approving review from a 
committer.
+
+This backs the ``Committer approval`` required status check on ``main``
+(see ``.github/workflows/pr-approval-check.yml`` and ``.asf.yaml``).
+
+Rules:
+
+* A pull request opened by a PMC member (``.github/pmc-members.txt``) is 
exempt.
+* Otherwise, at least one reviewer whose *latest* review is ``APPROVED`` must 
have
+  write access to the repository. Per ASF policy every committer has write 
access,
+  so "has write access" is used as the definition of "is a committer".
+* Following GitHub's own semantics, a ``COMMENTED`` review does not revoke an
+  earlier approval, while ``CHANGES_REQUESTED`` or a dismissal does.
+
+Only the Python standard library is used so the script runs in CI and locally:
+
+    GITHUB_TOKEN=$(gh auth token) python .github/scripts/check_pr_approval.py 
\\
+        --repo apache/mahout --pr 1234
+
+Exit status is 0 when the PR may be merged and 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com";)
+DEFAULT_PMC_FILE = Path(__file__).resolve().parents[1] / "pmc-members.txt"
+# Review states that change a reviewer's standing. COMMENTED is deliberately
+# excluded: on GitHub a comment-only review leaves a previous approval in 
place.
+DECISIVE_STATES = {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"}
+
+
+class GitHubError(RuntimeError):
+    """Raised when the GitHub API returns an unexpected response."""
+
+
+def _api(token: str, path: str, params: dict[str, str] | None = None) -> Any:
+    url = f"{API_URL}{path}"
+    if params:
+        url = f"{url}?{urllib.parse.urlencode(params)}"
+    request = urllib.request.Request(
+        url,
+        headers={
+            "Accept": "application/vnd.github+json",
+            "Authorization": f"Bearer {token}",
+            "X-GitHub-Api-Version": "2022-11-28",
+            "User-Agent": "apache-mahout-pr-approval-check",
+        },
+    )
+    try:
+        with urllib.request.urlopen(request, timeout=30) as response:
+            return json.load(response)
+    except urllib.error.HTTPError as exc:
+        body = exc.read().decode("utf-8", errors="replace")
+        raise GitHubError(f"GET {path} -> HTTP {exc.code}: {body}") from exc
+
+
+def _paginate(token: str, path: str) -> list[dict[str, Any]]:
+    items: list[dict[str, Any]] = []
+    page = 1
+    while True:
+        chunk = _api(token, path, {"per_page": "100", "page": str(page)})
+        items.extend(chunk)
+        if len(chunk) < 100:
+            return items
+        page += 1
+
+
+def load_pmc_members(path: Path) -> set[str]:
+    """Return the lower-cased GitHub usernames listed in ``path``.
+
+    Blank lines and ``#`` comments (full-line or trailing) are ignored.
+    """
+    members: set[str] = set()
+    if not path.is_file():
+        return members
+    for raw_line in path.read_text(encoding="utf-8").splitlines():
+        line = raw_line.split("#", 1)[0].strip()
+        if line:
+            members.add(line.lower())
+    return members
+
+
+def latest_decisive_reviews(reviews: list[dict[str, Any]]) -> dict[str, 
dict[str, Any]]:
+    """Map each reviewer login (lower-cased key) to their latest decisive 
review."""
+    latest: dict[str, dict[str, Any]] = {}
+    # The API returns reviews in chronological order; sort defensively anyway.
+    for review in sorted(reviews, key=lambda r: (r.get("submitted_at") or "", 
r["id"])):
+        if review.get("state") not in DECISIVE_STATES:
+            continue
+        user = review.get("user") or {}
+        login = user.get("login")
+        if not login or user.get("type") == "Bot":
+            continue
+        latest[login.lower()] = review
+    return latest
+
+
+def has_write_access(token: str, repo: str, login: str) -> bool:
+    path = 
f"/repos/{repo}/collaborators/{urllib.parse.quote(login)}/permission"
+    data = _api(token, path)
+    permissions = (data.get("user") or {}).get("permissions") or {}
+    if "push" in permissions:
+        return bool(permissions["push"])
+    return data.get("permission") in {"write", "admin"}
+
+
+def check(
+    token: str, repo: str, pr_number: int, pmc_members: set[str]
+) -> tuple[bool, list[str]]:
+    """Evaluate the rule. Returns ``(ok, log_lines)``."""
+    lines: list[str] = []
+    pr = _api(token, f"/repos/{repo}/pulls/{pr_number}")
+    author = (pr.get("user") or {}).get("login", "")
+    lines.append(
+        f"PR #{pr_number} by @{author} -> {pr['base']['ref']} "
+        f"(head {pr['head']['sha'][:7]})"
+    )
+
+    if author.lower() in pmc_members:
+        lines.append(
+            f"@{author} is a PMC member: exempt from the committer-approval 
rule."
+        )
+        return True, lines
+
+    reviews = _paginate(token, f"/repos/{repo}/pulls/{pr_number}/reviews")
+    approvers = [
+        review["user"]["login"]
+        for login, review in latest_decisive_reviews(reviews).items()
+        if review["state"] == "APPROVED" and login != author.lower()
+    ]
+    if not approvers:
+        lines.append("No approving reviews found.")
+        return False, lines
+
+    committer_approvers: list[str] = []
+    for login in sorted(approvers, key=str.lower):
+        if has_write_access(token, repo, login):
+            committer_approvers.append(login)
+            lines.append(f"Approved by @{login} (committer).")
+        else:
+            lines.append(
+                f"Approved by @{login} (not a committer; does not satisfy the 
rule)."
+            )
+
+    if committer_approvers:
+        return True, lines
+    lines.append("No approving review from a committer.")
+    return False, lines
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        description=__doc__, 
formatter_class=argparse.RawDescriptionHelpFormatter
+    )
+    parser.add_argument(
+        "--repo",
+        default=os.environ.get("GITHUB_REPOSITORY"),
+        help="owner/name (default: $GITHUB_REPOSITORY)",
+    )
+    parser.add_argument(
+        "--pr",
+        type=int,
+        default=os.environ.get("PR_NUMBER"),
+        help="pull request number (default: $PR_NUMBER)",
+    )
+    parser.add_argument(
+        "--pmc-file",
+        type=Path,
+        default=DEFAULT_PMC_FILE,
+        help=f"PMC member list (default: {DEFAULT_PMC_FILE})",
+    )
+    args = parser.parse_args(argv)
+
+    token = os.environ.get("GITHUB_TOKEN")
+    if not token or not args.repo or args.pr is None:
+        parser.error(
+            "GITHUB_TOKEN, --repo/$GITHUB_REPOSITORY and --pr/$PR_NUMBER are 
required"
+        )
+
+    pmc_members = load_pmc_members(args.pmc_file)
+    if not pmc_members:
+        print(
+            f"::warning::No PMC members loaded from {args.pmc_file}; "
+            "no author is exempt."
+        )
+
+    try:
+        ok, lines = check(token, args.repo, int(args.pr), pmc_members)
+    except GitHubError as exc:
+        print(f"::error::{exc}")
+        return 1
+
+    if ok:
+        verdict = "PASS: at least one committer approval (or PMC author)."
+    else:
+        verdict = "FAIL: needs at least one approving review from a committer."
+    lines.append(verdict)
+    print("\n".join(lines))
+
+    summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
+    if summary_path:
+        with open(summary_path, "a", encoding="utf-8") as summary:
+            summary.write("### Committer approval\n\n")
+            summary.writelines(f"- {line}\n" for line in lines)
+    if not ok:
+        print(
+            f"::error::{verdict} "
+            "See docs/community/pr-policy-and-review-guidelines.md."
+        )
+    return 0 if ok else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/.github/workflows/pr-approval-check.yml 
b/.github/workflows/pr-approval-check.yml
new file mode 100644
index 000000000..819ab5452
--- /dev/null
+++ b/.github/workflows/pr-approval-check.yml
@@ -0,0 +1,66 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+# Enforces the PR policy (docs/community/pr-policy-and-review-guidelines.md):
+# a pull request needs at least one approving review from a committer before
+# it can be merged, unless it was opened by a PMC member
+# (.github/pmc-members.txt). The "Committer approval" job is a required status
+# check on main via .asf.yaml, mirroring Airflow's
+# required_approving_review_count: 1 but with the PMC exemption.
+#
+name: PR Approval Check
+
+on:
+  # Runs with the base repository's workflow definition, also for fork PRs.
+  pull_request_target:
+    types: [opened, synchronize, reopened, ready_for_review]
+  # Re-evaluate whenever a review is submitted, edited or dismissed.
+  pull_request_review:
+    types: [submitted, edited, dismissed]
+
+permissions:
+  contents: read
+  pull-requests: read
+
+concurrency:
+  group: pr-approval-check-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+jobs:
+  committer-approval:
+    # This job name is the status check context referenced in .asf.yaml.
+    name: Committer approval
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+
+    steps:
+      # Always take the script and the PMC list from the base branch so a PR
+      # cannot change the rule it is being checked against. No code from the
+      # PR head is executed.
+      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          ref: ${{ github.event.pull_request.base.ref }}
+          persist-credentials: false
+          sparse-checkout: |
+            .github/pmc-members.txt
+            .github/scripts/check_pr_approval.py
+          sparse-checkout-cone-mode: false
+
+      - name: Check for a committer approval
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+          PR_NUMBER: ${{ github.event.pull_request.number }}
+        run: python3 .github/scripts/check_pr_approval.py
diff --git a/docs/community/pr-policy-and-review-guidelines.md 
b/docs/community/pr-policy-and-review-guidelines.md
index 91949825c..7c4e5f5c8 100644
--- a/docs/community/pr-policy-and-review-guidelines.md
+++ b/docs/community/pr-policy-and-review-guidelines.md
@@ -74,9 +74,19 @@ For non-trivial code contributions, contributors are 
expected to have an ASF ICL
 When deciding whether to merge, committers should verify all of the following:
 
 - Required CI checks are green.
-- At least one committer approves.
+- At least one committer approves (enforced automatically, see below).
 - All blocking comments are resolved.
 
+#### Enforced Committer Approval
+
+The `Committer approval` status check 
([`pr-approval-check.yml`](https://github.com/apache/mahout/blob/main/.github/workflows/pr-approval-check.yml))
 is required on `main` and re-runs whenever a review is submitted, edited, or 
dismissed:
+
+- The PR must have at least one reviewer whose latest review is an approval 
and who has write access to the repository (i.e. a committer). Approvals from 
non-committers are welcome but do not satisfy the check.
+- Following GitHub semantics, a comment-only review keeps an earlier approval 
in place; "Request changes" or dismissing the review removes it.
+- PRs opened by PMC members are exempt. The list of PMC GitHub accounts lives 
in 
[`.github/pmc-members.txt`](https://github.com/apache/mahout/blob/main/.github/pmc-members.txt)
 and must be updated when the PMC roster changes.
+
+The two-approval expectation for high-impact changes below is not enforced by 
tooling and remains a committer responsibility.
+
 The following changes should receive at least two committer approvals and 
prior public discussion (issue or `dev@` thread):
 
 - API or behavior-breaking changes.

Reply via email to