Skip to content

Commit 099d7d1

Browse files
ci: Add workflows for CI label management and approvals (#115)
* ci: Add workflows for CI label management and approvals Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Update approval workflow with extended author associations checks Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Fix artifact path for extracting PR metadata in CI label workflow Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Reset 'ci-passed' label on PR updates to maintain accurate CI status Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Refactor CI label check logic to handle combined label conditions more effectively Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Remove redundant checkout step from CI label workflow Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Add initial implementation and unit tests for CI label checks system Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Implement stricter CI label checks and enhance workflow automation - Ensure non-member PRs without `ok-to-test` do not run CI. - Add support for handling labels during label resets. - Improve workflows to validate membership and manage labels dynamically. - Update tests to cover new scenarios and stricter label conditions. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Add membership checks and enhance CI orchestration - Introduce `is_member` and `get_own_check_run_id` to streamline membership validation and check ID retrieval. - Replace workflow-level checks with scripted checks for consistency. - Refactor CLI arguments in `ci_checks.py` to reduce redundancy. - Extend tests to cover new client methods and stricter CI conditions. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Add logging and improve polling behavior in CI checks - Introduce detailed logging to improve traceability during CI polling. - Ensure proper logger configuration in `ci_checks.py` for consistency. - Update CI workflow with a timeout to prevent infinite runs. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Set GITHUB_TOKEN in scripts-tests workflow for authentication Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Ensure required labels exist in CI workflow - Add logic to dynamically create missing labels (`ci-passed`, `needs-ok-to-test`, `ok-to-test`) to prevent workflow failures. - Update permissions to include `issues: write` for label management. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Refactor environment variable usage in CI label workflow - Consolidate environment variables (`EVENT_ACTION`, `PR_NUMBER`) for consistency across steps. - Remove redundant `ref` configuration in `ci-checks.yml`. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Update Python version in CI workflow to 3.11 Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Remove custom Python setup step from CI workflow Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Enhance workflow approval logic and error handling - Add error handling for failed approval attempts, with detailed logging for 404 and 422 status codes. - Introduce failure counting and set workflow to fail if any approvals are unsuccessful. - Switch to bash shell for membership check step. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Add GitHub API test marker and separate API tests in CI - Introduce `gh_api` marker for tests that require GITHUB_TOKEN and call the live GitHub API. - Update `pytest.ini` to define the `gh_api` marker. - Refactor tests to use the new `gh_api` decorator for API-specific cases. - Modify `scripts-tests.yml` to split API tests Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Update workflow run pagination logic in approval workflow - Use a custom response mapping to retrieve `workflow_runs` for enhanced accuracy in pagination. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Update permissions in CI workflow to include `issues: write` Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Add tests for paginated check-runs and update API logic - Add test cases to validate behavior with multiple pages of check-runs. - Update `gh` API command to include pagination flag (`--paginate`) for fetching check-runs. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * ci: Refactor membership checks using `author_association` - Replace `is_member` checks with `author_association` for contributor trust determination. - Remove membership logic from `GhClient`. - Update test cases and workflows to reflect the new approach. - Add dedicated tests for the `is_trusted_association` helper. Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> * Update .github/workflows/ci-checks.yml Co-authored-by: Vani Haripriya Mudadla <vmudadla@redhat.com> Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> --------- Signed-off-by: Helber Belmiro <helber.belmiro@gmail.com> Co-authored-by: Vani Haripriya Mudadla <vmudadla@redhat.com>
1 parent bf7b8a0 commit 099d7d1

File tree

9 files changed

+1183
-1
lines changed

9 files changed

+1183
-1
lines changed

.github/scripts/ci_checks/__init__.py

Whitespace-only changes.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""CI checks: reset labels, gate on author association/labels, poll check runs, save PR payload."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import logging
8+
import subprocess
9+
import sys
10+
import time
11+
from pathlib import Path
12+
13+
logger = logging.getLogger(__name__)
14+
15+
_PASSING_CONCLUSIONS = frozenset({"success", "neutral", "skipped"})
16+
_TRUSTED_ASSOCIATIONS = frozenset({"MEMBER", "OWNER", "COLLABORATOR"})
17+
18+
19+
class ChecksError(Exception):
20+
"""Raised when CI checks fail or time out."""
21+
22+
23+
class GhClient:
24+
"""Wraps subprocess calls to the gh CLI."""
25+
26+
def remove_label(self, repo: str, pr_number: int, label: str) -> None:
27+
"""Remove a label from a PR via ``gh pr edit``."""
28+
subprocess.run(
29+
["gh", "pr", "edit", str(pr_number), "--remove-label", label, "--repo", repo],
30+
check=True,
31+
)
32+
33+
def get_check_runs(self, repo: str, head_sha: str) -> dict:
34+
"""Return parsed JSON from the GitHub check-runs API."""
35+
result = subprocess.run(
36+
["gh", "api", "--paginate", f"repos/{repo}/commits/{head_sha}/check-runs"],
37+
capture_output=True,
38+
text=True,
39+
check=True,
40+
)
41+
return json.loads(result.stdout)
42+
43+
def get_own_check_run_id(self, repo: str, head_sha: str, check_name: str) -> int:
44+
"""Return the ID of the check run matching *check_name*."""
45+
data = self.get_check_runs(repo, head_sha)
46+
for cr in data.get("check_runs", []):
47+
if cr["name"] == check_name:
48+
return cr["id"]
49+
raise ChecksError(f"Check run '{check_name}' not found")
50+
51+
52+
def is_trusted_association(author_association: str) -> bool:
53+
"""Return True if *author_association* represents a trusted contributor."""
54+
return author_association in _TRUSTED_ASSOCIATIONS
55+
56+
57+
def should_run_checks(labels: list[str], *, author_association: str) -> bool:
58+
"""Determine whether CI checks should run based on author association and PR labels."""
59+
if is_trusted_association(author_association):
60+
return True
61+
return "ok-to-test" in labels
62+
63+
64+
def reset_label(gh: GhClient, repo: str, pr_number: int, labels: list[str]) -> None:
65+
"""Remove the ci-passed label from a PR if it is present."""
66+
if "ci-passed" in labels:
67+
gh.remove_label(repo, pr_number, "ci-passed")
68+
69+
70+
def wait_for_checks(
71+
gh: GhClient,
72+
repo: str,
73+
head_sha: str,
74+
*,
75+
check_run_id: int,
76+
delay: int,
77+
retries: int,
78+
interval: int,
79+
) -> None:
80+
"""Poll check runs until all pass or retries are exhausted."""
81+
if delay > 0:
82+
logger.info("Waiting %d seconds before first poll...", delay)
83+
time.sleep(delay)
84+
85+
for attempt in range(retries):
86+
if attempt > 0:
87+
time.sleep(interval)
88+
89+
logger.info("Poll %d/%d for commit %s", attempt + 1, retries, head_sha[:12])
90+
data = gh.get_check_runs(repo, head_sha)
91+
all_runs = data.get("check_runs", [])
92+
check_runs = [cr for cr in all_runs if cr["id"] != check_run_id]
93+
94+
if not check_runs:
95+
if all_runs:
96+
logger.info("No other check runs found (only self). Done.")
97+
return
98+
logger.info("No check runs registered yet. Retrying...")
99+
continue
100+
101+
pending = [cr for cr in check_runs if cr["status"] != "completed"]
102+
if pending:
103+
names = ", ".join(cr["name"] for cr in pending)
104+
logger.info("%d pending: %s", len(pending), names)
105+
continue
106+
107+
failed = [cr for cr in check_runs if cr.get("conclusion") not in _PASSING_CONCLUSIONS]
108+
if failed:
109+
names = ", ".join(cr["name"] for cr in failed)
110+
raise ChecksError(f"Check(s) failed: {names}")
111+
112+
logger.info("All %d check(s) passed.", len(check_runs))
113+
return
114+
115+
raise ChecksError("Checks did not complete within the retry limit")
116+
117+
118+
def save_pr_payload(output_dir: str, pr_number: int, event_action: str) -> None:
119+
"""Save PR number and event action to files."""
120+
path = Path(output_dir)
121+
path.mkdir(parents=True, exist_ok=True)
122+
(path / "pr_number").write_text(f"{pr_number}\n")
123+
(path / "event_action").write_text(f"{event_action}\n")
124+
125+
126+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
127+
"""Parse command-line arguments."""
128+
parser = argparse.ArgumentParser(description="CI check orchestration for pull requests.")
129+
parser.add_argument("--pr-number", type=int, required=True)
130+
parser.add_argument("--repo", required=True)
131+
parser.add_argument("--event-action", required=True)
132+
parser.add_argument("--labels", required=True, help="Comma-separated list of PR labels")
133+
parser.add_argument("--author-association", required=True, help="GitHub author_association value")
134+
parser.add_argument("--head-sha", required=True)
135+
parser.add_argument("--check-name", required=True)
136+
parser.add_argument("--delay", type=int, required=True, help="Seconds to wait before first poll")
137+
parser.add_argument("--retries", type=int, required=True)
138+
parser.add_argument("--polling-interval", type=int, required=True, help="Seconds between polls")
139+
parser.add_argument("--output-dir", required=True)
140+
return parser.parse_args(argv)
141+
142+
143+
def main(argv: list[str] | None = None) -> int:
144+
"""CLI entry point."""
145+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
146+
args = parse_args(argv)
147+
labels = [label for label in args.labels.split(",") if label]
148+
gh = GhClient()
149+
150+
if args.event_action in ("synchronize", "reopened"):
151+
reset_label(gh, args.repo, args.pr_number, labels)
152+
153+
if not should_run_checks(labels, author_association=args.author_association):
154+
logger.info("PR requires '/ok-to-test' approval. Skipping CI checks.")
155+
return 0
156+
157+
check_run_id = gh.get_own_check_run_id(args.repo, args.head_sha, args.check_name)
158+
159+
try:
160+
wait_for_checks(
161+
gh,
162+
args.repo,
163+
args.head_sha,
164+
check_run_id=check_run_id,
165+
delay=args.delay,
166+
retries=args.retries,
167+
interval=args.polling_interval,
168+
)
169+
except ChecksError as exc:
170+
logger.error("CI checks failed: %s", exc)
171+
return 1
172+
173+
save_pr_payload(args.output_dir, args.pr_number, args.event_action)
174+
return 0
175+
176+
177+
if __name__ == "__main__":
178+
sys.exit(main())

.github/scripts/ci_checks/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)