Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .agents/skills/buildkite-get-results/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +48 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The URL construction for the JSON endpoint should handle potential trailing slashes in the build URL to ensure a valid path.

    json_url = build_url if build_url.endswith(".json") else build_url.rstrip("/") + ".json"


try:
with urllib.request.urlopen(json_url) as response:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Network requests should include a timeout to prevent the script from hanging indefinitely if the server is unresponsive.

Suggested change
with urllib.request.urlopen(json_url) as response:
with urllib.request.urlopen(json_url, timeout=10) 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("#")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using split("#") can cause a ValueError if the URL contains multiple hash characters. rsplit("#", 1) is safer for extracting the fragment identifier.

Suggested change
base, job_id = job_url.split("#")
base, job_id = job_url.rsplit("#", 1)

# 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Network requests should include a timeout to prevent the script from hanging indefinitely.

Suggested change
with urllib.request.urlopen(raw_url) as response:
with urllib.request.urlopen(raw_url, timeout=10) 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()
15 changes: 15 additions & 0 deletions .agents/skills/buildkite-retry-job/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
import argparse
import json
import os

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The re module is required to support regex matching for job names as described in the help text.

Suggested change
import os
import os
import re

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Network requests should include a timeout to prevent the script from hanging indefinitely.

Suggested change
with urllib.request.urlopen(req) as response:
with urllib.request.urlopen(req, timeout=10) 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
Comment on lines +73 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation should use regex matching to align with the help text for the --job-name argument, which claims to support regex.

        if args.job_name and not re.search(args.job_name, job_name, re.IGNORECASE) and \
           not re.search(args.job_name, job.get("step_key", ""), re.IGNORECASE):
            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()
22 changes: 20 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down