diff --git a/.agents/skills/buildkite-get-results/SKILL.md b/.agents/skills/buildkite-get-results/SKILL.md new file mode 100644 index 0000000000..a2a936513e --- /dev/null +++ b/.agents/skills/buildkite-get-results/SKILL.md @@ -0,0 +1,10 @@ +--- +name: buildkite-get-results +description: Gets buildkite build results +--- + +Pass the PR number to the `scripts/get_buildkite_results.py` script. + +The `--jobs` flag can do glob-style filtering of jobs. + +The `--download` flag will download job logs. diff --git a/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py new file mode 100755 index 0000000000..9df9a9d688 --- /dev/null +++ b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import subprocess +import sys +import urllib.request + + +def get_pr_checks(pr_number): + try: + # Check if gh is installed + subprocess.run( + ["gh", "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + print( + "Error: 'gh' (GitHub CLI) is not installed or not in PATH.", file=sys.stderr + ) + sys.exit(1) + except subprocess.CalledProcessError: + print("Error: 'gh' command failed. Is it installed?", file=sys.stderr) + sys.exit(1) + + cmd = ["gh", "pr", "checks", str(pr_number), "--json", "bucket,name,link,state"] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return json.loads(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Error fetching PR checks: {e.stderr}", file=sys.stderr) + sys.exit(1) + + +def get_buildkite_build_url(checks): + for check in checks: + # Looking for Buildkite check. The name usually contains "buildkite" + if "buildkite" in check.get("name", "").lower(): + return check.get("link") + return None + + +def fetch_buildkite_data(build_url): + # Convert https://buildkite.com/org/pipeline/builds/number + # to https://buildkite.com/org/pipeline/builds/number.json + if not build_url.endswith(".json"): + json_url = build_url + ".json" + else: + json_url = build_url + + try: + with urllib.request.urlopen(json_url) as response: + if response.status != 200: + print( + f"Error fetching data from {json_url}: Status {response.status}", + file=sys.stderr, + ) + return None + return json.loads(response.read().decode()) + except Exception as e: + print(f"Error fetching data from {json_url}: {e}", file=sys.stderr) + return None + + +def download_log(job_url, output_path): + # Construct raw log URL: job_url + "/raw" (Buildkite convention) + # job_url e.g. https://buildkite.com/org/pipeline/builds/14394#job-id + # Wait, the job['path'] gives /org/pipeline/builds/14394#job-id + # We want /org/pipeline/builds/14394/jobs/job-id/raw? No + # The clean URL for a job is https://buildkite.com/org/pipeline/builds/14394/jobs/job-id + # And raw log is https://buildkite.com/org/pipeline/builds/14394/jobs/job-id/raw + + # We have full_url e.g. https://buildkite.com/bazel/rules-python-python/builds/14394#019c5cf9-e3cf-468f-a7b1-8f9f5ad4b08c + # We need to transform it. + + if "#" in job_url: + base, job_id = job_url.split("#") + # Ensure base doesn't end with / + if base.endswith("/"): + base = base[:-1] + + # Build raw URL + raw_url = f"{base}/jobs/{job_id}/raw" + else: + print(f"Could not parse job URL for download: {job_url}", file=sys.stderr) + return False + + try: + with urllib.request.urlopen(raw_url) as response: + if response.status != 200: + print( + f"Error downloading log from {raw_url}: Status {response.status}", + file=sys.stderr, + ) + return False + with open(output_path, "wb") as f: + f.write(response.read()) + return True + except Exception as e: + print(f"Error downloading log from {raw_url}: {e}", file=sys.stderr) + return False + + +def main(): + parser = argparse.ArgumentParser(description="Get Buildkite CI results for a PR.") + parser.add_argument("pr_number", help="The PR number.") + parser.add_argument( + "--jobs", + action="append", + help="Filter by job name (regex match). Can be specified multiple times.", + ) + parser.add_argument( + "--download", + action="store_true", + help="If exactly one job is matched, download its log to a local file.", + ) + + args = parser.parse_args() + + print(f"Fetching checks for PR #{args.pr_number}...", file=sys.stderr) + checks = get_pr_checks(args.pr_number) + + build_url = get_buildkite_build_url(checks) + if not build_url: + print("No Buildkite check found for this PR.", file=sys.stderr) + sys.exit(1) + + print(f"Found Buildkite URL: {build_url}", file=sys.stderr) + + data = fetch_buildkite_data(build_url) + if not data: + sys.exit(1) + + print(f"Build State: {data.get('state')}") + print("-" * 40) + + jobs = data.get("jobs", []) + + filtered_jobs = [] + if args.jobs: + for job in jobs: + job_name = job.get("name") + if not job_name: + continue + for pattern in args.jobs: + if re.search(pattern, job_name, re.IGNORECASE): + filtered_jobs.append(job) + break + else: + filtered_jobs = jobs + + for job in filtered_jobs: + name = job.get("name", "Unknown") + state = job.get("state", "Unknown") + path = job.get("path") + full_url = f"https://buildkite.com{path}" if path else "N/A" + + passed = job.get("passed", False) + outcome = job.get("outcome") + + if passed: + result_str = "PASSED" + elif outcome: + result_str = outcome.upper() + else: + result_str = state.upper() + + print(f"Job: {name}") + print(f" Result: {result_str}") + print(f" URL: {full_url}") + print("") + + if args.download: + if len(filtered_jobs) == 1: + job = filtered_jobs[0] + name = job.get("name", "unknown_job") + # Sanitize name for filename + safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) + output_path = f"{safe_name}.log" + + path = job.get("path") + if path: + full_url = f"https://buildkite.com{path}" + print(f"Downloading log for '{name}'...", file=sys.stderr) + if download_log(full_url, output_path): + print(f"Downloaded log to: {output_path}") + else: + print("Failed to download log.", file=sys.stderr) + else: + print("Job has no URL path, cannot download.", file=sys.stderr) + elif len(filtered_jobs) == 0: + print("No jobs matched to download.", file=sys.stderr) + else: + print( + f"Matched {len(filtered_jobs)} jobs. Please filter to exactly one job to download.", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/buildkite-retry-job/SKILL.md b/.agents/skills/buildkite-retry-job/SKILL.md new file mode 100644 index 0000000000..e8e3bcd491 --- /dev/null +++ b/.agents/skills/buildkite-retry-job/SKILL.md @@ -0,0 +1,15 @@ +--- +name: buildkite-retry-job +description: Retry a failed build kite job +--- + +Use `scripts/retry_buildkite_jobs.py` to retry a job. This is best used +when there are network failures. + +example: + +``` +retry_buildkite_jobs.py org pipeline build +``` + +The `--jobs` flag can be used to retry specific jobs. diff --git a/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py b/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py new file mode 100755 index 0000000000..67385fb8fd --- /dev/null +++ b/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import sys +import urllib.request +from urllib.error import HTTPError + + +def make_request(url, method="GET", data=None, token=None): + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + if data: + data = json.dumps(data).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode()) + except HTTPError as e: + print(f"HTTP Error: {e.code} - {e.reason}", file=sys.stderr) + if e.fp: + print(e.fp.read().decode(), file=sys.stderr) + return None + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return None + + +def main(): + parser = argparse.ArgumentParser( + description="Retry failed jobs in a Buildkite build." + ) + parser.add_argument("org", help="Organization slug") + parser.add_argument("pipeline", help="Pipeline slug") + parser.add_argument("build", help="Build number") + parser.add_argument( + "--job-name", + help="Specific job name to retry (if failed). Regex/substring allowed.", + ) + + args = parser.parse_args() + token = os.environ.get("BUILDKITE_API_TOKEN") + + if not token: + print( + "Please set the BUILDKITE_API_TOKEN environment variable.", file=sys.stderr + ) + sys.exit(1) + + url = f"https://api.buildkite.com/v2/organizations/{args.org}/pipelines/{args.pipeline}/builds/{args.build}" + print(f"Fetching build details from {url}...") + build_data = make_request(url, token=token) + + if not build_data: + print("Failed to fetch build details.", file=sys.stderr) + sys.exit(1) + + jobs = build_data.get("jobs", []) + failed_jobs = [j for j in jobs if j.get("state") == "failed"] + + if not failed_jobs: + print("No failed jobs found in this build.") + sys.exit(0) + + for job in failed_jobs: + job_id = job.get("id") + job_name = job.get("name", "Unknown") + + if ( + args.job_name + and args.job_name.lower() not in job_name.lower() + and args.job_name.lower() not in job.get("step_key", "").lower() + ): + continue + + print(f"Retrying job: {job_name} ({job_id})") + retry_url = f"https://api.buildkite.com/v2/organizations/{args.org}/pipelines/{args.pipeline}/builds/{args.build}/jobs/{job_id}/retry" + + result = make_request(retry_url, method="PUT", token=token) + if result: + print(f" Successfully triggered retry for {job_name}") + else: + print(f" Failed to trigger retry for {job_name}") + + +if __name__ == "__main__": + main() diff --git a/AGENTS.md b/AGENTS.md index c1c9f7902b..38ea8d1be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,14 @@ Act as an expert in Bazel, rules_python, Starlark, and Python. DO NOT `git commit` or `git push`. +## RULES TO ALWAYS FOLLOW AND NEVER IGNORE + +ALWAYS FOLLOW THESE RULES. NEVER VIOLATE THEM. + +Ask for user input and provide a justificaiton if trying to violate them. + +* NEVER run `bazel clean --expunge`. + ## Style and conventions Read `.editorconfig` for line length wrapping @@ -121,12 +129,22 @@ bzl_library( Tests are under the `tests/` directory. -When testing, add `--test_tag_filters=-integration-test`. +When testing, add `--config=fast-tests`. -When building, add `--build_tag_filters=-integration-test`. +When building, add `--config=fast-tests`. + +The `--config=fast-tests` flag avoids running expensive and slow tests can that +freeze the host machine or cause flakiness. ## Understanding the code base +This repository contains 3 Bazel bzlmod modules. + + * `sphinxdocs/` is for the `@sphinxdocs` module. + * `gazelle/` is for the `@rules_python_gazelle_plugin` module. + * All other code is part of `@rules_python`. + + `python/config_settings/BUILD.bazel` contains build flags that are part of the public API. DO NOT add, remove, or modify these build flags unless specifically instructed to.