|
| 1 | +""" |
| 2 | +Post-validation feedback for ServerMappings PRs. |
| 3 | +
|
| 4 | +Triggered by the workflow_run job in the trusted base-repo context. Reads |
| 5 | +pr_results.json (uploaded by the validate-servers job that ran in the |
| 6 | +untrusted PR context) and either applies a "Ready for review" label or |
| 7 | +posts a review with the validation errors. |
| 8 | +
|
| 9 | +Because pr_results.json is produced by code controlled by the PR author, |
| 10 | +its pr_id field cannot be trusted. The trusted PR number is derived from |
| 11 | +the workflow_run payload (with a head-SHA lookup as a fallback for fork |
| 12 | +PRs, whose pull_requests array is empty) and the artifact's pr_id is |
| 13 | +rejected if it doesn't match one of those numbers. |
| 14 | +""" |
| 15 | + |
| 16 | +import json |
| 17 | +import os |
| 18 | + |
| 19 | +import requests |
| 20 | + |
| 21 | +API_ROOT = "https://api.github.com" |
| 22 | +READY_LABEL = "Ready for review" |
| 23 | +RESULTS_FILE = "pr_results.json" |
| 24 | + |
| 25 | + |
| 26 | +def gh_request(method: str, path: str, token: str, **kwargs) -> requests.Response: |
| 27 | + return requests.request( |
| 28 | + method, |
| 29 | + f"{API_ROOT}{path}", |
| 30 | + headers={ |
| 31 | + "accept": "application/vnd.github+json", |
| 32 | + "Authorization": f"Bearer {token}", |
| 33 | + "X-GitHub-Api-Version": "2022-11-28", |
| 34 | + }, |
| 35 | + timeout=30, |
| 36 | + **kwargs, |
| 37 | + ) |
| 38 | + |
| 39 | + |
| 40 | +def resolve_trusted_pr_numbers( |
| 41 | + repo: str, head_sha: str, pr_payload: list, token: str |
| 42 | +) -> set[int]: |
| 43 | + """Return PR numbers that are safe to act on with the privileged token.""" |
| 44 | + numbers: set[int] = set() |
| 45 | + for pr in pr_payload or []: |
| 46 | + if isinstance(pr, dict) and isinstance(pr.get("number"), int): |
| 47 | + numbers.add(pr["number"]) |
| 48 | + |
| 49 | + if numbers or not head_sha: |
| 50 | + return numbers |
| 51 | + |
| 52 | + res = gh_request("GET", f"/repos/{repo}/commits/{head_sha}/pulls", token) |
| 53 | + if not res.ok: |
| 54 | + print( |
| 55 | + f"::warning::Failed to look up PRs for head SHA {head_sha}: " |
| 56 | + f"{res.status_code} {res.text}" |
| 57 | + ) |
| 58 | + return numbers |
| 59 | + |
| 60 | + for pr in res.json(): |
| 61 | + if pr.get("state") == "open": |
| 62 | + numbers.add(int(pr["number"])) |
| 63 | + return numbers |
| 64 | + |
| 65 | + |
| 66 | +def remove_ready_label(repo: str, pr_number: int, token: str) -> None: |
| 67 | + encoded = requests.utils.quote(READY_LABEL, safe="") |
| 68 | + res = gh_request( |
| 69 | + "DELETE", f"/repos/{repo}/issues/{pr_number}/labels/{encoded}", token |
| 70 | + ) |
| 71 | + if res.status_code not in (200, 204, 404): |
| 72 | + res.raise_for_status() |
| 73 | + |
| 74 | + |
| 75 | +def add_ready_label(repo: str, pr_number: int, token: str) -> None: |
| 76 | + res = gh_request( |
| 77 | + "POST", |
| 78 | + f"/repos/{repo}/issues/{pr_number}/labels", |
| 79 | + token, |
| 80 | + json={"labels": [READY_LABEL]}, |
| 81 | + ) |
| 82 | + res.raise_for_status() |
| 83 | + |
| 84 | + |
| 85 | +def request_changes(repo: str, pr_number: int, body: str, token: str) -> None: |
| 86 | + res = gh_request( |
| 87 | + "POST", |
| 88 | + f"/repos/{repo}/pulls/{pr_number}/reviews", |
| 89 | + token, |
| 90 | + json={"event": "REQUEST_CHANGES", "body": body}, |
| 91 | + ) |
| 92 | + res.raise_for_status() |
| 93 | + |
| 94 | + |
| 95 | +def build_review_body(errors: dict) -> str: |
| 96 | + body = "" |
| 97 | + for server_id, msgs in (errors or {}).items(): |
| 98 | + if not msgs: |
| 99 | + continue |
| 100 | + joined = "\n- ".join(msgs) |
| 101 | + body += f"\n\nErrors found for **{server_id}**:\n- {joined}" |
| 102 | + return body |
| 103 | + |
| 104 | + |
| 105 | +def main() -> None: |
| 106 | + token = os.environ["BOT_PAT"] |
| 107 | + repo = os.environ["GITHUB_REPOSITORY"] |
| 108 | + head_sha = os.environ.get("WORKFLOW_RUN_HEAD_SHA", "") |
| 109 | + conclusion = os.environ.get("WORKFLOW_RUN_CONCLUSION", "") |
| 110 | + download_outcome = os.environ.get("DOWNLOAD_OUTCOME", "") |
| 111 | + |
| 112 | + try: |
| 113 | + pr_payload = json.loads(os.environ.get("WORKFLOW_RUN_PULL_REQUESTS") or "[]") |
| 114 | + except json.JSONDecodeError: |
| 115 | + pr_payload = [] |
| 116 | + |
| 117 | + trusted = resolve_trusted_pr_numbers(repo, head_sha, pr_payload, token) |
| 118 | + if not trusted: |
| 119 | + print("No PR could be associated with this workflow run; nothing to do.") |
| 120 | + return |
| 121 | + |
| 122 | + artifact_present = ( |
| 123 | + conclusion == "success" |
| 124 | + and download_outcome == "success" |
| 125 | + and os.path.isfile(RESULTS_FILE) |
| 126 | + ) |
| 127 | + |
| 128 | + if not artifact_present: |
| 129 | + for n in trusted: |
| 130 | + remove_ready_label(repo, n, token) |
| 131 | + print( |
| 132 | + "Upstream did not succeed or artifact missing — " |
| 133 | + "removed stale ready label." |
| 134 | + ) |
| 135 | + return |
| 136 | + |
| 137 | + with open(RESULTS_FILE, "r", encoding="utf-8") as f: |
| 138 | + data = json.load(f) |
| 139 | + |
| 140 | + pr_id = data.get("pr_id") |
| 141 | + if not isinstance(pr_id, int) or pr_id not in trusted: |
| 142 | + print( |
| 143 | + f"::warning::Artifact pr_id ({pr_id!r}) does not match trusted " |
| 144 | + f"PR number(s) {sorted(trusted)}; refusing to act on it." |
| 145 | + ) |
| 146 | + return |
| 147 | + |
| 148 | + if data.get("status") == "ready": |
| 149 | + add_ready_label(repo, pr_id, token) |
| 150 | + return |
| 151 | + |
| 152 | + remove_ready_label(repo, pr_id, token) |
| 153 | + |
| 154 | + body = build_review_body(data.get("errors") or {}) |
| 155 | + if not body: |
| 156 | + return |
| 157 | + |
| 158 | + request_changes(repo, pr_id, body, token) |
| 159 | + |
| 160 | + |
| 161 | +if __name__ == "__main__": |
| 162 | + main() |
0 commit comments