Add standalone CLA action (#806)
Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
co-authored by
UltralyticsAssistant
parent
a717a806cf
commit
d0a323b062
@@ -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/[email protected]
|
||||
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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@
|
||||
# ├── test_summarize_pr.py
|
||||
# └── ...
|
||||
|
||||
__version__ = "0.2.23"
|
||||
__version__ = "0.2.24"
|
||||
|
||||
+281
@@ -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 = "<!-- ultralytics-cla -->"
|
||||
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()
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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": "[email protected]",
|
||||
"user": {"databaseId": 1, "login": "person"},
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"user": {"databaseId": 1, "login": "person"},
|
||||
},
|
||||
{"name": "Alias", "email": "[email protected]", "user": None},
|
||||
{"name": "Unknown", "email": "[email protected]", "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": "[email protected]", "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()
|
||||
Reference in New Issue
Block a user