From d0a323b062c1d57ede18cc17ebeef70da55a9511 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Thu, 9 Jul 2026 23:48:44 +0200 Subject: [PATCH] Add standalone CLA action (#806) Co-authored-by: UltralyticsAssistant --- .github/workflows/cla.yml | 24 +-- README.md | 13 ++ actions/__init__.py | 2 +- actions/cla.py | 281 ++++++++++++++++++++++++++++++++ cla/README.md | 44 ++++++ cla/action.yml | 35 ++++ tests/test_cla.py | 325 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 705 insertions(+), 19 deletions(-) create mode 100644 actions/cla.py create mode 100644 cla/README.md create mode 100644 cla/action.yml create mode 100644 tests/test_cla.py diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 152aff8..7da16cf 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -4,6 +4,8 @@ # This workflow automatically requests Pull Requests (PR) authors to sign the Ultralytics CLA before PRs can be merged name: CLA Assistant +concurrency: + group: cla-${{ github.event.pull_request.number || github.event.issue.number }} on: issue_comment: types: @@ -16,30 +18,16 @@ on: permissions: actions: write - contents: write pull-requests: write - statuses: write jobs: CLA: - if: github.repository == 'ultralytics/actions' + if: github.repository == 'ultralytics/actions' && (github.event_name != 'issue_comment' || github.event.issue.pull_request) runs-on: ubuntu-latest steps: - name: CLA Assistant if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I sign the CLA') || github.event_name == 'pull_request_target' - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Must be repository secret PAT - PERSONAL_ACCESS_TOKEN: ${{ secrets._GITHUB_TOKEN }} + uses: ultralytics/actions/cla@main with: - path-to-signatures: "signatures/version1/cla.json" - path-to-document: "https://docs.ultralytics.com/help/CLA" # CLA document - # Branch must not be protected - branch: cla-signatures - allowlist: dependabot[bot],github-actions,pre-commit*,bot* - - remote-organization-name: ultralytics - remote-repository-name: cla - custom-pr-sign-comment: "I have read the CLA Document and I sign the CLA" - custom-allsigned-prcomment: All Contributors have signed the CLA. ✅ + github-token: ${{ secrets.GITHUB_TOKEN }} + cla-token: ${{ secrets._GITHUB_TOKEN }} diff --git a/README.md b/README.md index e360e56..58819fb 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,19 @@ Update GitHub Actions versions across organization repositories with cached rele [**📖 Full Documentation →**](dependabot/README.md) +### 5. CLA Action + +Check every pull request commit author against the central Ultralytics CLA signature ledger. + +```yaml +- uses: ultralytics/actions/cla@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + cla-token: ${{ secrets._GITHUB_TOKEN }} +``` + +[**📖 Full Documentation →**](cla/README.md) + ## Python Package Install the `ultralytics-actions` package for programmatic access to action utilities, including all [requirements](https://github.com/ultralytics/actions/blob/main/pyproject.toml), in a [**Python>=3.8**](https://www.python.org/) environment. diff --git a/actions/__init__.py b/actions/__init__.py index b4ca7c8..9a1e471 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -28,4 +28,4 @@ # ├── test_summarize_pr.py # └── ... -__version__ = "0.2.23" +__version__ = "0.2.24" diff --git a/actions/cla.py b/actions/cla.py new file mode 100644 index 0000000..2874a76 --- /dev/null +++ b/actions/cla.py @@ -0,0 +1,281 @@ +# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license + +from __future__ import annotations + +import base64 +import json +import os +import time + +from .utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, Action + +CLA_REPOSITORY = "ultralytics/cla" +CLA_PATH = "signatures/version1/cla.json" +CLA_BRANCH = "cla-signatures" +CLA_DOCUMENT = "https://docs.ultralytics.com/help/CLA" +SIGN_COMMENT = "I have read the CLA Document and I sign the CLA" +COMMENT_MARKER = "" +LEGACY_MARKER = "CLA Assistant Lite bot" +BOT_LOGIN = "github-actions[bot]" +ALLOWLIST = frozenset(("dependabot[bot]", "github-actions[bot]", "pre-commit-ci[bot]")) +TRANSIENT_STATUS = (429, 500, 502, 503, 504) +COMMITS_QUERY = """ +query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + commits(first: 100, after: $cursor) { + totalCount + nodes { + commit { + author { + name + email + user { databaseId login } + } + } + } + pageInfo { endCursor hasNextPage } + } + } + } +} +""" + + +def _allowed(login: str) -> bool: + """Return whether a GitHub login matches the configured bot allowlist.""" + return login.casefold() in ALLOWLIST + + +def _read(action: Action, method: str, url: str, **kwargs): + """Retry a read-only GitHub request on transient responses.""" + for attempt in range(4): + response = getattr(action, method)(url, **kwargs) + if response.status_code not in TRANSIENT_STATUS: + response.raise_for_status() + return response + if attempt < 3: + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + response.raise_for_status() + + +def _paginate(action: Action, url: str) -> list[dict]: + """Fetch every page from a GitHub REST collection.""" + items = [] + for page in range(1, 101): + response = _read(action, "get", url, params={"per_page": 100, "page": page}) + page_items = response.json() + items.extend(page_items) + if len(page_items) < 100: + return items + raise RuntimeError(f"GitHub collection exceeded 10,000 items: {url}") + + +def _contributors(action: Action, number: int) -> list[dict]: + """Return the PR opener and every unique commit author.""" + contributors = {} + pr = _read(action, "get", f"{GITHUB_API_URL}/repos/{action.repository}/pulls/{number}").json() + opener = pr["user"] + if not _allowed(opener["login"]): + contributors[opener["id"]] = {"id": opener["id"], "name": opener["login"]} + + owner, name = action.repository.split("/", 1) + cursor = None + count = 0 + for _ in range(100): + response = _read( + action, + "post", + GITHUB_GRAPHQL_URL, + json={ + "query": COMMITS_QUERY, + "variables": {"owner": owner, "name": name, "number": number, "cursor": cursor}, + }, + ).json() + if response.get("errors"): + raise RuntimeError(f"Could not read PR commit authors: {response['errors']}") + commits = response["data"]["repository"]["pullRequest"]["commits"] + for node in commits["nodes"]: + author = node["commit"].get("author") or {} + user = author.get("user") + if user and not _allowed(user["login"]): + contributors[user["databaseId"]] = {"id": user["databaseId"], "name": user["login"]} + elif not user and author.get("name"): + key = f"unknown:{author['name']}:{author.get('email', '')}" + contributors[key] = {"id": None, "name": author["name"]} + count += len(commits["nodes"]) + if not commits["pageInfo"]["hasNextPage"]: + if count != commits["totalCount"]: + raise RuntimeError(f"GitHub returned {count} of {commits['totalCount']} PR commits") + return list(contributors.values()) + cursor = commits["pageInfo"]["endCursor"] + raise RuntimeError("Pull request exceeded 10,000 commits") + + +def _ledger(action: Action) -> tuple[dict, str]: + """Read and validate the existing central signature ledger.""" + url = f"{GITHUB_API_URL}/repos/{CLA_REPOSITORY}/contents/{CLA_PATH}" + response = _read(action, "get", url, params={"ref": CLA_BRANCH}) + payload = response.json() + content = json.loads(base64.b64decode(payload["content"])) + if not isinstance(content.get("signedContributors"), list): + raise RuntimeError("CLA signature ledger has an invalid schema") + return content, payload["sha"] + + +def _comments(action: Action, number: int) -> list[dict]: + """Return every PR comment.""" + return _paginate(action, f"{GITHUB_API_URL}/repos/{action.repository}/issues/{number}/comments") + + +def _record(comment: dict, action: Action, number: int) -> dict: + """Convert a signing comment to the established ledger schema.""" + user = comment["user"] + return { + "name": user["login"], + "id": user["id"], + "comment_id": comment["id"], + "created_at": comment["created_at"], + "repoId": action.event_data["repository"]["id"], + "pullRequestNo": number, + } + + +def _persist(action: Action, records: list[dict], source: Action, number: int) -> None: + """Merge new signatures into the ledger with optimistic concurrency.""" + url = f"{GITHUB_API_URL}/repos/{CLA_REPOSITORY}/contents/{CLA_PATH}" + for attempt in range(4): + content, sha = _ledger(action) + signed_ids = {row["id"] for row in content["signedContributors"]} + additions = [row for row in records if row["id"] not in signed_ids] + if not additions: + return + content["signedContributors"].extend(additions) + names = ", ".join(f"@{row['name']}" for row in additions) + response = action.put( + url, + json={ + "message": f"{names} signed the CLA in {source.repository}#{number}", + "content": base64.b64encode(json.dumps(content, indent=2).encode()).decode(), + "sha": sha, + "branch": CLA_BRANCH, + }, + ) + if response.status_code in (200, 201): + return + if response.status_code not in (409, 429, 500, 502, 503, 504): + response.raise_for_status() + if response.status_code != 409 and attempt < 3: + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + response.raise_for_status() + + +def _comment_body(signed: list[dict], unsigned: list[dict], unknown: list[dict]) -> str: + """Build the single CLA status comment.""" + if not unsigned and not unknown: + return f"{COMMENT_MARKER}\nAll Contributors have signed the CLA. ✅" + names = "\n".join(f"- {'✅' if user in signed else '❌'} @{user['name']}" for user in signed + unsigned) + if unknown: + names += "\n" + "\n".join(f"- ❌ {user['name']} (not linked to a GitHub account)" for user in unknown) + return f"""{COMMENT_MARKER} +Thank you for your contribution. Before it can be accepted, every contributor must sign our [Contributor License Agreement]({CLA_DOCUMENT}) by posting this exact comment: + +> {SIGN_COMMENT} + +{names} +""" + + +def _update_comment(action: Action, number: int, comments: list[dict], body: str) -> None: + """Create or update one bot-owned CLA status comment.""" + existing = [ + comment + for comment in comments + if comment.get("user", {}).get("login") == BOT_LOGIN + and (COMMENT_MARKER in (comment.get("body") or "") or LEGACY_MARKER in (comment.get("body") or "")) + ] + if not existing: + response = action.post( + f"{GITHUB_API_URL}/repos/{action.repository}/issues/{number}/comments", + json={"body": body}, + ) + existing = [ + comment + for comment in _comments(action, number) + if comment.get("user", {}).get("login") == BOT_LOGIN + and (COMMENT_MARKER in (comment.get("body") or "") or LEGACY_MARKER in (comment.get("body") or "")) + ] + if not existing: + response.raise_for_status() + raise RuntimeError("GitHub did not return the created CLA status comment") + + action.patch( + f"{GITHUB_API_URL}/repos/{action.repository}/issues/comments/{existing[0]['id']}", + json={"body": body}, + hard=True, + ) + + +def _rerun_pr_check(action: Action, number: int) -> None: + """Rerun the PR-head CLA workflow after a successful issue-comment signature.""" + if action.event_name != "issue_comment": + return + pr = _read(action, "get", f"{GITHUB_API_URL}/repos/{action.repository}/pulls/{number}").json() + workflow_ref = os.environ["GITHUB_WORKFLOW_REF"] + workflow = workflow_ref.split(f"{action.repository}/", 1)[1].rsplit("@", 1)[0] + run = None + url = f"{GITHUB_API_URL}/repos/{action.repository}/actions/workflows/{workflow}/runs" + for page in range(1, 101): + runs = _read( + action, + "get", + url, + params={"branch": pr["head"]["ref"], "event": "pull_request_target", "per_page": 100, "page": page}, + ).json()["workflow_runs"] + run = next((item for item in runs if item["head_sha"] == pr["head"]["sha"]), None) + if run or len(runs) < 100: + break + if not run: + raise RuntimeError("Could not find the PR-head CLA workflow run") + if run["conclusion"] is None: + return + if run["conclusion"] != "success": + action.post(f"{GITHUB_API_URL}/repos/{action.repository}/actions/runs/{run['id']}/rerun", hard=True) + + +def run(action: Action, ledger_action: Action) -> None: + """Check the PR contributors against the central CLA ledger.""" + number = (action.event_data.get("pull_request") or action.event_data.get("issue"))["number"] + contributors = _contributors(action, number) + comments = _comments(action, number) + content, _ = _ledger(ledger_action) + signed_ids = {row["id"] for row in content["signedContributors"]} + contributor_ids = {user["id"] for user in contributors if user["id"] is not None} + records = { + comment["user"]["id"]: _record(comment, action, number) + for comment in comments + if comment.get("body") == SIGN_COMMENT and comment.get("user", {}).get("id") in contributor_ids - signed_ids + } + if records: + _persist(ledger_action, list(records.values()), action, number) + signed_ids.update(records) + + signed = [user for user in contributors if user["id"] in signed_ids] + unsigned = [user for user in contributors if user["id"] is not None and user["id"] not in signed_ids] + unknown = [user for user in contributors if user["id"] is None] + _update_comment(action, number, comments, _comment_body(signed, unsigned, unknown)) + if unsigned or unknown: + raise RuntimeError("All PR contributors must sign the CLA") + _rerun_pr_check(action, number) + + +def main() -> None: + """Run the CLA check from GitHub Actions environment variables.""" + token = os.environ["GITHUB_TOKEN"] + cla_token = os.environ["CLA_TOKEN"] + action = Action(token=token) + run(action, Action(token=cla_token, event_name=action.event_name, event_data=action.event_data)) + + +if __name__ == "__main__": + main() diff --git a/cla/README.md b/cla/README.md new file mode 100644 index 0000000..5afda72 --- /dev/null +++ b/cla/README.md @@ -0,0 +1,44 @@ +# Ultralytics CLA Action + +Checks every pull request commit author against the shared signature ledger in +`ultralytics/cla` and maintains one status comment on the pull request. + +```yaml +name: CLA Assistant +concurrency: + group: cla-${{ github.event.pull_request.number || github.event.issue.number }} +on: + issue_comment: + types: [created] + pull_request_target: + types: [reopened, opened, synchronize] + +permissions: + actions: write + pull-requests: write + +jobs: + CLA: + if: github.event_name != 'issue_comment' || github.event.issue.pull_request + runs-on: ubuntu-latest + steps: + - name: CLA Assistant + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I sign the CLA') || github.event_name == 'pull_request_target' + uses: ultralytics/actions/cla@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + cla-token: ${{ secrets._GITHUB_TOKEN }} +``` + +The action preserves the existing `signatures/version1/cla.json` schema and +updates its `cla-signatures` branch with optimistic concurrency. Missing or +invalid ledger data, unlinked commit authors, and unsigned contributors fail +the check. + +Contributor identity uses GitHub's numeric user ID, so commits from any email +address GitHub associates with the same account require only one signature. +Raw unlinked commit emails are never guessed or treated as identity. + +The workflow requires `actions: write` to refresh the PR-head check after a +signature comment and `pull-requests: write` to maintain its single status +comment. It never checks out or executes pull request code. diff --git a/cla/action.yml b/cla/action.yml new file mode 100644 index 0000000..fba88f1 --- /dev/null +++ b/cla/action.yml @@ -0,0 +1,35 @@ +# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license + +name: "Ultralytics CLA" +author: "Ultralytics" +description: "Check pull request contributors against the central Ultralytics CLA signature ledger" +branding: + icon: "check-circle" + color: "green" +inputs: + github-token: + description: "GitHub token for pull request comments and workflow reruns" + required: true + cla-token: + description: "Token with read/write access to the ultralytics/cla signature ledger" + required: true +runs: + using: "composite" + steps: + - name: Install requests + run: | + cd "$GITHUB_ACTION_PATH/.." + python -m venv "$RUNNER_TEMP/ultralytics-cla" + "$RUNNER_TEMP/ultralytics-cla/bin/python" -m pip install --disable-pip-version-check \ + "requests==2.32.4; python_version < '3.10'" \ + "requests==2.33.0; python_version >= '3.10'" + shell: bash + + - name: Check CLA + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + CLA_TOKEN: ${{ inputs.cla-token }} + run: | + cd "$GITHUB_ACTION_PATH/.." + "$RUNNER_TEMP/ultralytics-cla/bin/python" -m actions.cla + shell: bash diff --git a/tests/test_cla.py b/tests/test_cla.py new file mode 100644 index 0000000..3af4c0c --- /dev/null +++ b/tests/test_cla.py @@ -0,0 +1,325 @@ +# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license + +import base64 +import json +from unittest.mock import MagicMock + +import pytest + +from actions import cla + + +def response(status=200, data=None, headers=None): + """Create a mock HTTP response.""" + result = MagicMock(status_code=status) + result.json.return_value = data + result.headers = headers or {} + return result + + +def action(event_name="pull_request_target"): + """Create a mock source-repository action.""" + result = MagicMock() + result.repository = "ultralytics/example" + result.event_name = event_name + result.event_data = { + "repository": {"id": 123, "full_name": result.repository}, + "pull_request": {"number": 7}, + } + return result + + +def ledger_response(rows, sha="old-sha"): + """Create a Contents API response for the established CLA schema.""" + content = base64.b64encode(json.dumps({"signedContributors": rows}).encode()).decode() + return response(data={"content": content, "sha": sha}) + + +def commits_response(authors, total=None, has_next=False, cursor=None): + """Create a GraphQL response containing PR commit authors.""" + nodes = [{"commit": {"author": author}} for author in authors] + commits = { + "totalCount": len(nodes) if total is None else total, + "nodes": nodes, + "pageInfo": {"hasNextPage": has_next, "endCursor": cursor}, + } + return response(data={"data": {"repository": {"pullRequest": {"commits": commits}}}}) + + +def test_contributors_paginates_and_requires_verified_github_identity(): + """Collect linked authors while retaining unverified email identities as unknown.""" + source = action() + authors = [ + { + "email": "work@example.com", + "user": {"databaseId": 1, "login": "person"}, + }, + { + "email": "personal@example.com", + "user": {"databaseId": 1, "login": "person"}, + }, + {"name": "Alias", "email": "2+alias@users.noreply.github.com", "user": None}, + {"name": "Unknown", "email": "private@example.com", "user": None}, + {"user": {"databaseId": 3, "login": "dependabot[bot]"}}, + {"user": {"databaseId": 4, "login": "other[bot]"}}, + {"user": {"databaseId": 5, "login": "bot-attacker"}}, + ] + source.get.return_value = response(data={"user": {"id": 1, "login": "person"}}) + source.post.return_value = commits_response(authors) + + assert cla._contributors(source, 7) == [ + {"id": 1, "name": "person"}, + {"id": None, "name": "Alias"}, + {"id": None, "name": "Unknown"}, + {"id": 4, "name": "other[bot]"}, + {"id": 5, "name": "bot-attacker"}, + ] + + +def test_ledger_preserves_existing_schema_and_never_creates_missing_file(): + """Read the established ledger directly without a replacement creation path.""" + store = action() + row = {"name": "old", "id": 1, "comment_id": 2, "created_at": "date", "repoId": 3, "pullRequestNo": 4} + store.get.return_value = ledger_response([row]) + + assert cla._ledger(store) == ({"signedContributors": [row]}, "old-sha") + store.get.assert_called_once_with( + "https://api.github.com/repos/ultralytics/cla/contents/signatures/version1/cla.json", + params={"ref": "cla-signatures"}, + ) + + +def test_contributors_fails_when_github_truncates_commits(): + """Fail instead of silently skipping commit authors beyond an API limit.""" + source = action() + source.get.return_value = response(data={"user": {"id": 1, "login": "person"}}) + source.post.return_value = commits_response([{"name": "Unknown", "user": None}], total=2) + + with pytest.raises(RuntimeError, match="returned 1 of 2"): + cla._contributors(source, 7) + + +def test_contributors_paginates_graphql_commits(): + """Follow GraphQL cursors beyond one hundred PR commits.""" + source = action() + source.get.return_value = response(data={"user": {"id": 1, "login": "opener"}}) + first = [{"user": {"databaseId": i, "login": f"user-{i}"}} for i in range(2, 102)] + source.post.side_effect = [ + commits_response(first, total=101, has_next=True, cursor="next"), + commits_response([{"user": {"databaseId": 102, "login": "user-102"}}], total=101), + ] + + assert len(cla._contributors(source, 7)) == 102 + assert source.post.call_args_list[1].kwargs["json"]["variables"]["cursor"] == "next" + + +def test_persist_rereads_and_merges_after_conflict(): + """Re-read and merge the signature ledger after a concurrent write conflict.""" + source, store = action(), action() + old = {"id": 1} + new = {"name": "new", "id": 2, "comment_id": 3, "created_at": "date", "repoId": 123, "pullRequestNo": 7} + store.get.side_effect = [ledger_response([old], "sha-1"), ledger_response([old], "sha-2")] + store.put.side_effect = [response(409), response(200)] + + cla._persist(store, [new], source, 7) + + assert store.put.call_count == 2 + written = json.loads(base64.b64decode(store.put.call_args.kwargs["json"]["content"])) + assert written["signedContributors"] == [old, new] + assert store.put.call_args.kwargs["json"]["sha"] == "sha-2" + + +def test_persist_honors_retry_after_for_transient_write(monkeypatch): + """Back off before re-reading after an ambiguous transient ledger write.""" + source, store = action(), action() + new = {"name": "new", "id": 2} + store.get.side_effect = [ledger_response([], "sha-1"), ledger_response([], "sha-2")] + store.put.side_effect = [response(429, headers={"Retry-After": "3"}), response(200)] + sleep = MagicMock() + monkeypatch.setattr("actions.cla.time.sleep", sleep) + + cla._persist(store, [new], source, 7) + + sleep.assert_called_once_with(3.0) + + +@pytest.mark.parametrize( + ("statuses", "message"), + [([502, 502, 502, 502], "502 Bad Gateway"), ([502, 502, 502, 409], "409 Conflict")], +) +def test_persist_surfaces_final_exhausted_error(monkeypatch, statuses, message): + """Preserve the final HTTP error when mixed write retries are exhausted.""" + source, store = action(), action() + store.get.side_effect = [ledger_response([], f"sha-{i}") for i in range(4)] + failures = [response(status) for status in statuses] + failures[-1].raise_for_status.side_effect = RuntimeError(message) + store.put.side_effect = failures + monkeypatch.setattr("actions.cla.time.sleep", MagicMock()) + + with pytest.raises(RuntimeError, match=message): + cla._persist(store, [{"name": "new", "id": 2}], source, 7) + + +def test_run_records_exact_sentence_and_updates_legacy_comment(): + """Persist an exact signature and reuse the legacy action's status comment.""" + source, store = action(), action() + signing = { + "id": 30, + "body": cla.SIGN_COMMENT, + "created_at": "date", + "user": {"id": 2, "login": "new", "type": "User"}, + } + bot = {"id": 40, "body": "Posted by the CLA Assistant Lite bot", "user": {"login": cla.BOT_LOGIN}} + source.get.side_effect = [ + response(data={"user": {"id": 2, "login": "new"}}), + response(data=[signing, bot]), + ] + source.post.return_value = commits_response([{"user": {"databaseId": 2, "login": "new"}}]) + store.get.side_effect = [ledger_response([]), ledger_response([])] + store.put.return_value = response(200) + + cla.run(source, store) + + stored = json.loads(base64.b64decode(store.put.call_args.kwargs["json"]["content"]))["signedContributors"] + assert stored == [ + {"name": "new", "id": 2, "comment_id": 30, "created_at": "date", "repoId": 123, "pullRequestNo": 7} + ] + assert cla.SIGN_COMMENT in cla._comment_body([], [{"id": 2, "name": "new"}], []) + source.patch.assert_called_once() + assert "All Contributors have signed" in source.patch.call_args.kwargs["json"]["body"] + + +@pytest.mark.parametrize("body", [f"{cla.SIGN_COMMENT}!", cla.SIGN_COMMENT.lower(), f" {cla.SIGN_COMMENT}"]) +def test_run_rejects_similar_sentence_and_keeps_hard_failure(body): + """Reject a modified signing sentence and leave the CLA gate failed.""" + source, store = action(), action() + user = {"id": 2, "login": "new", "type": "User"} + source.get.side_effect = [ + response(data={"user": {"id": 2, "login": "new"}}), + response(data=[{"id": 30, "body": body, "created_at": "date", "user": user}]), + response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]), + ] + source.post.side_effect = [ + commits_response([{"user": {"databaseId": 2, "login": "new"}}]), + response(201), + ] + store.get.return_value = ledger_response([]) + source.post.return_value = response(201) + + with pytest.raises(RuntimeError, match="must sign"): + cla.run(source, store) + + store.put.assert_not_called() + assert cla.SIGN_COMMENT in source.post.call_args.kwargs["json"]["body"] + + +def test_run_fails_unlinked_email_author(): + """Require commit authors with unlinked emails to link a GitHub account.""" + source, store = action(), action() + source.get.side_effect = [ + response(data={"user": {"id": 2, "login": "new"}}), + response(data=[]), + response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]), + ] + source.post.side_effect = [ + commits_response([{"name": "Unknown", "email": "private@example.com", "user": None}]), + response(201), + ] + store.get.return_value = ledger_response([]) + source.post.return_value = response(201) + + with pytest.raises(RuntimeError, match="must sign"): + cla.run(source, store) + + assert "not linked to a GitHub account" in source.post.call_args.kwargs["json"]["body"] + + +def test_run_requires_pr_opener_when_commit_identity_is_already_signed(): + """Require the submitter to sign even when forged commit metadata names a signer.""" + source, store = action(), action() + source.get.side_effect = [ + response(data={"user": {"id": 9, "login": "submitter"}}), + response(data=[]), + response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]), + ] + source.post.side_effect = [ + commits_response([{"user": {"databaseId": 1, "login": "signed-author"}}]), + response(201), + ] + store.get.return_value = ledger_response([{"id": 1}]) + + with pytest.raises(RuntimeError, match="must sign"): + cla.run(source, store) + + assert "@submitter" in source.patch.call_args.kwargs["json"]["body"] + + +def test_create_comment_confirms_ambiguous_write_before_failing(): + """Treat an ambiguous comment response as success only when the marker exists.""" + source = action() + source.post.return_value = response(502) + source.get.return_value = response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]) + + cla._update_comment(source, 7, [], "body") + + source.get.assert_called_once() + source.post.return_value.raise_for_status.assert_not_called() + source.patch.assert_called_once() + + +def test_update_comment_ignores_marker_from_another_bot(): + """Adopt only comments owned by the authenticated GitHub Actions bot.""" + source = action() + source.post.return_value = response(201) + source.get.return_value = response(data=[{"id": 41, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]) + other = [{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": "other[bot]"}}] + + cla._update_comment(source, 7, other, "body") + + source.post.assert_called_once() + assert source.patch.call_args.args[0].endswith("/issues/comments/41") + + +def test_read_retries_transient_responses(monkeypatch): + """Retry a transient GitHub read without changing unrelated API clients.""" + source = action() + source.get.side_effect = [response(502), response(200, {"ok": True})] + monkeypatch.setattr("actions.cla.time.sleep", MagicMock()) + + assert cla._read(source, "get", "url").json() == {"ok": True} + assert source.get.call_count == 2 + + +def test_rerun_uses_exact_pr_head(monkeypatch): + """Rerun the failed CLA workflow associated with the live PR head only.""" + source = action("issue_comment") + monkeypatch.setenv("GITHUB_WORKFLOW_REF", "ultralytics/example/.github/workflows/cla.yml@refs/heads/main") + source.get.side_effect = [ + response(data={"head": {"ref": "feature", "sha": "exact"}}), + response( + data={ + "workflow_runs": [ + {"id": 1, "head_sha": "stale", "conclusion": "failure"}, + {"id": 2, "head_sha": "exact", "conclusion": "failure"}, + ] + } + ), + ] + + cla._rerun_pr_check(source, 7) + + assert source.post.call_args.args[0].endswith("/actions/runs/2/rerun") + + +def test_rerun_leaves_queued_exact_head_to_complete(monkeypatch): + """Let an incomplete exact-head check run after per-PR concurrency releases.""" + source = action("issue_comment") + monkeypatch.setenv("GITHUB_WORKFLOW_REF", "ultralytics/example/.github/workflows/cla.yml@refs/heads/main") + source.get.side_effect = [ + response(data={"head": {"ref": "feature", "sha": "exact"}}), + response(data={"workflow_runs": [{"id": 2, "head_sha": "exact", "conclusion": None}]}), + ] + + cla._rerun_pr_check(source, 7) + + source.post.assert_not_called()