|
10 | 10 | import os |
11 | 11 | import re |
12 | 12 | import sys |
| 13 | +import json |
13 | 14 | from pathlib import Path |
| 15 | +from urllib import error, parse, request |
14 | 16 |
|
15 | 17 |
|
16 | 18 | 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]: |
74 | 76 | return list(set(drafts)) # Remove duplicates |
75 | 77 |
|
76 | 78 |
|
| 79 | +def github_api_request(path: str, token: str, params: dict | None = None): |
| 80 | + """Small helper to call the GitHub REST API. |
| 81 | +
|
| 82 | + Returns parsed JSON or an empty list/dict on failure. |
| 83 | + """ |
| 84 | + |
| 85 | + base_url = "https://api.github.com" |
| 86 | + url = f"{base_url}{path}" |
| 87 | + if params: |
| 88 | + query = parse.urlencode(params) |
| 89 | + url = f"{url}?{query}" |
| 90 | + |
| 91 | + headers = { |
| 92 | + "Accept": "application/vnd.github+json", |
| 93 | + "User-Agent": "xrpl-standards-xls-number-bot", |
| 94 | + } |
| 95 | + if token: |
| 96 | + headers["Authorization"] = f"Bearer {token}" |
| 97 | + |
| 98 | + req = request.Request(url, headers=headers) |
| 99 | + |
| 100 | + try: |
| 101 | + with request.urlopen(req) as resp: |
| 102 | + data = resp.read() |
| 103 | + return json.loads(data.decode("utf-8")) |
| 104 | + except error.HTTPError as e: |
| 105 | + print(f"Warning: GitHub API HTTP error {e.code} for {url}: {e.reason}") |
| 106 | + except error.URLError as e: |
| 107 | + print(f"Warning: GitHub API URL error for {url}: {e.reason}") |
| 108 | + |
| 109 | + # On any failure, return an empty list so callers can treat it as "no data". |
| 110 | + return [] |
| 111 | + |
| 112 | + |
| 113 | +def extract_xls_number_from_comments( |
| 114 | + owner: str, repo: str, token: str, issue_number: int |
| 115 | +) -> int | None: |
| 116 | + """Extract reserved XLS number for an issue/PR from bot comments. |
| 117 | +
|
| 118 | + Looks for a marker of the form: <!-- XLS_NUMBER:0123 --> in comments |
| 119 | + authored by github-actions[bot]. |
| 120 | + """ |
| 121 | + |
| 122 | + page = 1 |
| 123 | + while True: |
| 124 | + comments = github_api_request( |
| 125 | + f"/repos/{owner}/{repo}/issues/{issue_number}/comments", |
| 126 | + token, |
| 127 | + {"per_page": 100, "page": page}, |
| 128 | + ) |
| 129 | + |
| 130 | + if not comments: |
| 131 | + break |
| 132 | + |
| 133 | + for comment in comments: |
| 134 | + if comment.get("user", {}).get("login") != "github-actions[bot]": |
| 135 | + continue |
| 136 | + body = comment.get("body") or "" |
| 137 | + match = re.search(r"<!--\s*XLS_NUMBER:(\\d+)\s*-->", body) |
| 138 | + if match: |
| 139 | + try: |
| 140 | + return int(match.group(1)) |
| 141 | + except ValueError: |
| 142 | + continue |
| 143 | + |
| 144 | + if len(comments) < 100: |
| 145 | + break |
| 146 | + page += 1 |
| 147 | + |
| 148 | + return None |
| 149 | + |
| 150 | + |
| 151 | +def get_reserved_xls_numbers_from_prs( |
| 152 | + token: str, repo: str, current_pr_number: int | None |
| 153 | +) -> tuple[set[int], int | None]: |
| 154 | + """Find XLS numbers reserved by open PRs with the 'has-xls-number' label. |
| 155 | +
|
| 156 | + Returns a tuple of (set of reserved numbers, number assigned to the current PR |
| 157 | + if any). |
| 158 | + """ |
| 159 | + |
| 160 | + if not token or not repo: |
| 161 | + # Without a token or repo, we cannot query the API; treat as no reservations. |
| 162 | + return set(), None |
| 163 | + |
| 164 | + if "/" not in repo: |
| 165 | + return set(), None |
| 166 | + |
| 167 | + owner, repo_name = repo.split("/", 1) |
| 168 | + |
| 169 | + reserved_numbers: set[int] = set() |
| 170 | + current_pr_assigned: int | None = None |
| 171 | + |
| 172 | + page = 1 |
| 173 | + while True: |
| 174 | + issues = github_api_request( |
| 175 | + f"/repos/{owner}/{repo_name}/issues", |
| 176 | + token, |
| 177 | + { |
| 178 | + "state": "open", |
| 179 | + "labels": "has-xls-number", |
| 180 | + "per_page": 100, |
| 181 | + "page": page, |
| 182 | + }, |
| 183 | + ) |
| 184 | + |
| 185 | + if not issues: |
| 186 | + break |
| 187 | + |
| 188 | + for issue in issues: |
| 189 | + # Filter to PRs only |
| 190 | + if "pull_request" not in issue: |
| 191 | + continue |
| 192 | + |
| 193 | + issue_number = issue.get("number") |
| 194 | + num = extract_xls_number_from_comments(owner, repo_name, token, issue_number) |
| 195 | + if num is None: |
| 196 | + continue |
| 197 | + |
| 198 | + reserved_numbers.add(num) |
| 199 | + |
| 200 | + if current_pr_number is not None and issue_number == current_pr_number: |
| 201 | + current_pr_assigned = num |
| 202 | + |
| 203 | + if len(issues) < 100: |
| 204 | + break |
| 205 | + |
| 206 | + page += 1 |
| 207 | + |
| 208 | + return reserved_numbers, current_pr_assigned |
| 209 | + |
| 210 | + |
77 | 211 | def main(): |
78 | 212 | """Main entry point for the script.""" |
79 | 213 | # Get repository root (parent of .github directory) |
@@ -110,24 +244,56 @@ def set_github_output(name: str, value: str): |
110 | 244 | set_github_output("has_drafts", "false") |
111 | 245 | return |
112 | 246 |
|
113 | | - # Get existing XLS numbers |
| 247 | + # Discover reserved XLS numbers from other open PRs and from this PR (if any) |
| 248 | + github_token = os.environ.get("GITHUB_TOKEN", "") |
| 249 | + github_repo = os.environ.get("GITHUB_REPOSITORY", "") |
| 250 | + current_pr_number = None |
| 251 | + event_path = os.environ.get("GITHUB_EVENT_PATH") |
| 252 | + if event_path and os.path.isfile(event_path): |
| 253 | + try: |
| 254 | + with open(event_path, "r", encoding="utf-8") as f: |
| 255 | + event = json.load(f) |
| 256 | + current_pr_number = event.get("pull_request", {}).get("number") |
| 257 | + except Exception as exc: # pragma: no cover - defensive |
| 258 | + print(f"Warning: Failed to parse GITHUB_EVENT_PATH: {exc}") |
| 259 | + |
| 260 | + reserved_numbers, current_pr_assigned = get_reserved_xls_numbers_from_prs( |
| 261 | + github_token, |
| 262 | + github_repo, |
| 263 | + current_pr_number, |
| 264 | + ) |
| 265 | + |
| 266 | + if reserved_numbers: |
| 267 | + print(f"Found {len(reserved_numbers)} XLS numbers reserved by open PRs.") |
| 268 | + |
| 269 | + # Get existing XLS numbers from the base branch |
114 | 270 | existing_numbers = get_existing_xls_numbers(repo_root) |
115 | | - print(f"Found {len(existing_numbers)} existing XLS numbers.") |
| 271 | + print(f"Found {len(existing_numbers)} existing XLS numbers in the repository.") |
116 | 272 | print(f"Highest existing number: {max(existing_numbers) if existing_numbers else 0}") |
117 | 273 |
|
118 | | - # Assign numbers to each draft |
119 | | - next_number = get_next_xls_number(existing_numbers) |
| 274 | + all_numbers = existing_numbers | reserved_numbers |
| 275 | + |
120 | 276 | assignments = [] |
121 | 277 |
|
| 278 | + if current_pr_assigned is not None: |
| 279 | + print( |
| 280 | + f"Reusing previously reserved XLS number {current_pr_assigned:04d} for this PR." |
| 281 | + ) |
| 282 | + next_number = current_pr_assigned |
| 283 | + else: |
| 284 | + next_number = get_next_xls_number(all_numbers) |
| 285 | + |
122 | 286 | for draft_dir in sorted(draft_dirs): |
123 | 287 | assigned_number = next_number |
| 288 | + all_numbers.add(assigned_number) |
124 | 289 | new_dir_name = re.sub(r"^XLS-draft-", f"XLS-{assigned_number:04d}-", draft_dir) |
125 | 290 | assignments.append({ |
126 | 291 | "draft": draft_dir, |
127 | 292 | "number": assigned_number, |
128 | 293 | "new_name": new_dir_name, |
129 | 294 | }) |
130 | | - next_number += 1 |
| 295 | + # Calculate the next free number for any additional drafts |
| 296 | + next_number = get_next_xls_number(all_numbers) |
131 | 297 |
|
132 | 298 | # Output results |
133 | 299 | print("\n=== XLS Number Assignments ===") |
|
0 commit comments