diff --git a/.github/scripts/assign_xls_number.py b/.github/scripts/assign_xls_number.py index 6131e089..ebcc2e55 100644 --- a/.github/scripts/assign_xls_number.py +++ b/.github/scripts/assign_xls_number.py @@ -10,7 +10,9 @@ import os import re import sys +import json from pathlib import Path +from urllib import error, parse, request def get_existing_xls_numbers(repo_root: Path) -> set[int]: @@ -74,6 +76,138 @@ def find_draft_xls_files(changed_files: list[str]) -> list[str]: return list(set(drafts)) # Remove duplicates +def github_api_request(path: str, token: str, params: dict | None = None): + """Small helper to call the GitHub REST API. + + Returns parsed JSON or an empty list/dict on failure. + """ + + base_url = "https://api.github.com" + url = f"{base_url}{path}" + if params: + query = parse.urlencode(params) + url = f"{url}?{query}" + + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "xrpl-standards-xls-number-bot", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + req = request.Request(url, headers=headers) + + try: + with request.urlopen(req) as resp: + data = resp.read() + return json.loads(data.decode("utf-8")) + except error.HTTPError as e: + print(f"Warning: GitHub API HTTP error {e.code} for {url}: {e.reason}") + except error.URLError as e: + print(f"Warning: GitHub API URL error for {url}: {e.reason}") + + # On any failure, return an empty list so callers can treat it as "no data". + return [] + + +def extract_xls_number_from_comments( + owner: str, repo: str, token: str, issue_number: int +) -> int | None: + """Extract reserved XLS number for an issue/PR from bot comments. + + Looks for a marker of the form: in comments + authored by github-actions[bot]. + """ + + page = 1 + while True: + comments = github_api_request( + f"/repos/{owner}/{repo}/issues/{issue_number}/comments", + token, + {"per_page": 100, "page": page}, + ) + + if not comments: + break + + for comment in comments: + if comment.get("user", {}).get("login") != "github-actions[bot]": + continue + body = comment.get("body") or "" + match = re.search(r"", body) + if match: + try: + return int(match.group(1)) + except ValueError: + continue + + if len(comments) < 100: + break + page += 1 + + return None + + +def get_reserved_xls_numbers_from_prs( + token: str, repo: str, current_pr_number: int | None +) -> tuple[set[int], int | None]: + """Find XLS numbers reserved by open PRs with the 'has-xls-number' label. + + Returns a tuple of (set of reserved numbers, number assigned to the current PR + if any). + """ + + if not token or not repo: + # Without a token or repo, we cannot query the API; treat as no reservations. + return set(), None + + if "/" not in repo: + return set(), None + + owner, repo_name = repo.split("/", 1) + + reserved_numbers: set[int] = set() + current_pr_assigned: int | None = None + + page = 1 + while True: + issues = github_api_request( + f"/repos/{owner}/{repo_name}/issues", + token, + { + "state": "open", + "labels": "has-xls-number", + "per_page": 100, + "page": page, + }, + ) + + if not issues: + break + + for issue in issues: + # Filter to PRs only + if "pull_request" not in issue: + continue + + issue_number = issue.get("number") + num = extract_xls_number_from_comments(owner, repo_name, token, issue_number) + if num is None: + continue + + reserved_numbers.add(num) + + if current_pr_number is not None and issue_number == current_pr_number: + current_pr_assigned = num + + if len(issues) < 100: + break + + page += 1 + + return reserved_numbers, current_pr_assigned + + def main(): """Main entry point for the script.""" # Get repository root from environment variable (set by GitHub Actions) @@ -116,24 +250,56 @@ def set_github_output(name: str, value: str): set_github_output("has_drafts", "false") return - # Get existing XLS numbers + # Discover reserved XLS numbers from other open PRs and from this PR (if any) + github_token = os.environ.get("GITHUB_TOKEN", "") + github_repo = os.environ.get("GITHUB_REPOSITORY", "") + current_pr_number = None + event_path = os.environ.get("GITHUB_EVENT_PATH") + if event_path and os.path.isfile(event_path): + try: + with open(event_path, "r", encoding="utf-8") as f: + event = json.load(f) + current_pr_number = event.get("pull_request", {}).get("number") + except Exception as exc: # pragma: no cover - defensive + print(f"Warning: Failed to parse GITHUB_EVENT_PATH: {exc}") + + reserved_numbers, current_pr_assigned = get_reserved_xls_numbers_from_prs( + github_token, + github_repo, + current_pr_number, + ) + + if reserved_numbers: + print(f"Found {len(reserved_numbers)} XLS numbers reserved by open PRs.") + + # Get existing XLS numbers from the base branch existing_numbers = get_existing_xls_numbers(repo_root) - print(f"Found {len(existing_numbers)} existing XLS numbers.") + print(f"Found {len(existing_numbers)} existing XLS numbers in the repository.") print(f"Highest existing number: {max(existing_numbers) if existing_numbers else 0}") - # Assign numbers to each draft - next_number = get_next_xls_number(existing_numbers) + all_numbers = existing_numbers | reserved_numbers + assignments = [] + if current_pr_assigned is not None: + print( + f"Reusing previously reserved XLS number {current_pr_assigned:04d} for this PR." + ) + next_number = current_pr_assigned + else: + next_number = get_next_xls_number(all_numbers) + for draft_dir in sorted(draft_dirs): assigned_number = next_number + all_numbers.add(assigned_number) new_dir_name = re.sub(r"^xls-draft-", f"xls-{assigned_number:04d}-", draft_dir.lower()) assignments.append({ "draft": draft_dir, "number": assigned_number, "new_name": new_dir_name, }) - next_number += 1 + # Calculate the next free number for any additional drafts + next_number = get_next_xls_number(all_numbers) # Output results print("\n=== XLS Number Assignments ===") diff --git a/.github/workflows/assign-xls-number.yml b/.github/workflows/assign-xls-number.yml index 0749db26..677195f4 100644 --- a/.github/workflows/assign-xls-number.yml +++ b/.github/workflows/assign-xls-number.yml @@ -4,6 +4,10 @@ on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] +concurrency: + group: assign-xls-number-${{ github.repository }} + cancel-in-progress: false + jobs: assign-xls-number: runs-on: ubuntu-latest @@ -78,10 +82,11 @@ jobs: body-includes: "XLS Number Assignment" - name: Post or update PR comment - if: steps.check-drafts.outputs.has_drafts == 'true' && steps.find-comment.outputs.comment-id == '' + if: steps.check-drafts.outputs.has_drafts == 'true' uses: peter-evans/create-or-update-comment@v5 with: issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} body: | ## 🎫 XLS Number Assignment @@ -101,4 +106,18 @@ jobs: - Set `status:` to `Draft` --- - *This comment was automatically generated. The XLS number is based on the highest existing number in the repository at the time this PR was opened.* + *This comment was automatically generated. The XLS number below is reserved for this PR based on existing XLS numbers in the repository and other open draft PRs.* + + + + - name: Add has-xls-number label + if: steps.check-drafts.outputs.has_drafts == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + curl -sS \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -X POST \ + -d '{"labels":["has-xls-number"]}' \ + "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels"