Files
2026-07-13 13:12:33 +08:00

438 lines
14 KiB
Python

#!/usr/bin/env python3
"""Synchronize OpenSquilla issue state after pull request lifecycle changes.
This script is intentionally safe for `pull_request_target`: it treats pull
request text as data only and never executes code from the pull request head.
"""
from __future__ import annotations
import json
import os
import re
import sys
from typing import Any, NamedTuple
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
HAS_LINKED_PR_LABEL = "has-linked-pr"
MERGED_TO_DEV_LABEL = "merged-to-dev"
NEEDS_VERIFICATION_LABEL = "needs-verification"
LABEL_DEFINITIONS = {
HAS_LINKED_PR_LABEL: {
"color": "C5DEF5",
"description": "An open pull request is linked to this issue",
},
MERGED_TO_DEV_LABEL: {
"color": "5319E7",
"description": "A related fix has merged to dev but has not necessarily shipped",
},
NEEDS_VERIFICATION_LABEL: {
"color": "C5DEF5",
"description": "Maintainers or reporters should retest the current behavior",
},
}
CLOSING_KEYWORDS = frozenset(
{
"close",
"closes",
"closed",
"fix",
"fixes",
"fixed",
"resolve",
"resolves",
"resolved",
}
)
#: Mutation statuses that describe the target's state or the token's scope on
#: that one target (labeling a pull request needs pull-requests write scope
#: this workflow does not request; locked or deleted issues answer 403/410)
#: rather than a broken sync setup — the affected action is logged and
#: skipped instead of failing every remaining action in the job.
TOLERATED_MUTATION_STATUSES = frozenset({403, 404, 410})
REFERENCE_KEYWORDS = frozenset({"ref", "refs", "reference", "references"})
OPEN_PR_ACTIONS = frozenset({"opened", "reopened", "edited"})
KEYWORD_RE = re.compile(
r"\b(?P<keyword>close|closes|closed|fix|fixes|fixed|resolve|resolves|"
r"resolved|ref|refs|reference|references)\s*:?\s+(?P<ref>"
r"#\d+|"
r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+|"
r"https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/\d+"
r")\b",
re.IGNORECASE,
)
LOCAL_REF_RE = re.compile(r"^#(?P<number>\d+)$")
REPO_REF_RE = re.compile(
r"^(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)#(?P<number>\d+)$"
)
ISSUE_URL_RE = re.compile(
r"^https://github\.com/(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)/"
r"issues/(?P<number>\d+)$"
)
class ParsedIssueLinks(NamedTuple):
closing: tuple[int, ...]
references: tuple[int, ...]
@property
def all(self) -> tuple[int, ...]:
return tuple(dict.fromkeys((*self.closing, *self.references)))
class IssueSyncAction(NamedTuple):
issue_number: int
kind: str
pr_number: int
pr_url: str
class GitHubClient:
def __init__(self, *, token: str, repository: str) -> None:
self._token = token
self._repository = repository
self._api_root = f"https://api.github.com/repos/{repository}"
def request_json(
self,
method: str,
path: str,
*,
payload: dict[str, Any] | None = None,
ignore_statuses: set[int] | None = None,
) -> Any:
ignore_statuses = ignore_statuses or set()
data = None
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self._token}",
"X-GitHub-Api-Version": "2022-11-28",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
f"{self._api_root}{path}",
data=data,
headers=headers,
method=method,
)
try:
with urlopen(request, timeout=30) as response:
raw = response.read()
except HTTPError as exc:
if exc.code in ignore_statuses:
return None
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"GitHub API {method} {path} failed: {exc.code} {body}") from exc
if not raw:
return None
return json.loads(raw.decode("utf-8"))
def get_issue(self, issue_number: int) -> dict[str, Any] | None:
"""Fetch an issue, or None when it is missing or unreadable.
Pull requests are returned too (the REST issues endpoint covers
both) — callers use the ``pull_request`` key to tell them apart.
"""
found = self.request_json(
"GET",
f"/issues/{issue_number}",
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
return found if isinstance(found, dict) else None
def ensure_label(self, name: str) -> None:
definition = LABEL_DEFINITIONS[name]
label_name = quote(name, safe="")
found = self.request_json(
"GET",
f"/labels/{label_name}",
ignore_statuses={404},
)
if found is not None:
return
self.request_json(
"POST",
"/labels",
payload={
"name": name,
"color": definition["color"],
"description": definition["description"],
},
# 422: another run created the label between the GET and here.
ignore_statuses=TOLERATED_MUTATION_STATUSES | {422},
)
def add_labels(self, issue_number: int, labels: list[str]) -> None:
for label in labels:
self.ensure_label(label)
self.request_json(
"POST",
f"/issues/{issue_number}/labels",
payload={"labels": labels},
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
def remove_label(self, issue_number: int, label: str) -> None:
label_name = quote(label, safe="")
self.request_json(
"DELETE",
f"/issues/{issue_number}/labels/{label_name}",
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
def list_comments(self, issue_number: int) -> list[dict[str, Any]]:
all_comments: list[dict[str, Any]] = []
page = 1
while True:
comments = self.request_json(
"GET",
f"/issues/{issue_number}/comments?per_page=100&page={page}",
)
if not isinstance(comments, list) or not comments:
return all_comments
all_comments.extend(comments)
if len(comments) < 100:
return all_comments
page += 1
def create_comment(self, issue_number: int, body: str) -> None:
self.request_json(
"POST",
f"/issues/{issue_number}/comments",
payload={"body": body},
# Locked conversations reject comments with 403; the labels above
# still applied, so the sync outcome is preserved.
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
def _normalize_repo_ref(raw_ref: str, *, owner: str, repo: str) -> int | None:
local = LOCAL_REF_RE.match(raw_ref)
if local is not None:
return int(local.group("number"))
repo_ref = REPO_REF_RE.match(raw_ref)
if repo_ref is not None:
if repo_ref.group("owner").lower() != owner.lower():
return None
if repo_ref.group("repo").lower() != repo.lower():
return None
return int(repo_ref.group("number"))
issue_url = ISSUE_URL_RE.match(raw_ref)
if issue_url is not None:
if issue_url.group("owner").lower() != owner.lower():
return None
if issue_url.group("repo").lower() != repo.lower():
return None
return int(issue_url.group("number"))
return None
def parse_linked_issues(body: str | None, *, owner: str, repo: str) -> ParsedIssueLinks:
closing: list[int] = []
references: list[int] = []
for match in KEYWORD_RE.finditer(body or ""):
issue_number = _normalize_repo_ref(match.group("ref"), owner=owner, repo=repo)
if issue_number is None:
continue
keyword = match.group("keyword").lower()
if keyword in CLOSING_KEYWORDS:
closing.append(issue_number)
elif keyword in REFERENCE_KEYWORDS:
references.append(issue_number)
return ParsedIssueLinks(
closing=tuple(dict.fromkeys(closing)),
references=tuple(dict.fromkeys(references)),
)
def plan_issue_sync_actions(event: dict[str, Any]) -> tuple[IssueSyncAction, ...]:
action = event.get("action")
pr = event.get("pull_request") or {}
repository = event.get("repository") or {}
full_name = repository.get("full_name") or ""
if "/" not in full_name:
return ()
owner, repo = full_name.split("/", 1)
pr_number = int(pr["number"])
pr_url = str(pr.get("html_url") or "")
parsed = parse_linked_issues(pr.get("body"), owner=owner, repo=repo)
if action in OPEN_PR_ACTIONS:
return tuple(
IssueSyncAction(
issue_number=issue_number,
kind="linked_pr_open",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.all
)
if action != "closed":
return ()
if pr.get("merged") is True and (pr.get("base") or {}).get("ref") == "dev":
closing_actions = tuple(
IssueSyncAction(
issue_number=issue_number,
kind="merged_to_dev",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.closing
)
closing_issue_numbers = set(parsed.closing)
reference_cleanup_actions = tuple(
IssueSyncAction(
issue_number=issue_number,
kind="remove_linked_pr",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.references
if issue_number not in closing_issue_numbers
)
return (*closing_actions, *reference_cleanup_actions)
if pr.get("merged") is not True:
return tuple(
IssueSyncAction(
issue_number=issue_number,
kind="closed_unmerged",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.all
)
return tuple(
IssueSyncAction(
issue_number=issue_number,
kind="remove_linked_pr",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.all
)
def comment_marker(*, kind: str, pr_number: int) -> str:
marker_kind = kind.replace("_", "-")
return f"<!-- opensquilla-issue-link-sync:{marker_kind}:pr-{pr_number} -->"
def has_marker(comments: list[dict[str, Any]], marker: str) -> bool:
return any(marker in str(comment.get("body") or "") for comment in comments)
def _merged_to_dev_comment(action: IssueSyncAction) -> str:
marker = comment_marker(kind=action.kind, pr_number=action.pr_number)
return (
f"{marker}\n"
f"The linked fix for this issue has merged to `dev` via #{action.pr_number} "
f"({action.pr_url}). Keeping it open for verification before release."
)
def apply_action(client: GitHubClient, action: IssueSyncAction) -> None:
if action.kind == "linked_pr_open":
client.add_labels(action.issue_number, [HAS_LINKED_PR_LABEL])
return
if action.kind == "merged_to_dev":
client.add_labels(
action.issue_number,
[MERGED_TO_DEV_LABEL, NEEDS_VERIFICATION_LABEL],
)
client.remove_label(action.issue_number, HAS_LINKED_PR_LABEL)
marker = comment_marker(kind=action.kind, pr_number=action.pr_number)
if not has_marker(client.list_comments(action.issue_number), marker):
client.create_comment(action.issue_number, _merged_to_dev_comment(action))
return
if action.kind in {"closed_unmerged", "remove_linked_pr"}:
client.remove_label(action.issue_number, HAS_LINKED_PR_LABEL)
return
raise ValueError(f"Unsupported issue sync action: {action.kind}")
def classify_sync_target(client: GitHubClient, issue_number: int) -> str:
"""Classify a parsed ``#N`` reference before mutating it.
``#N`` is textual and shares one number space between issues and pull
requests, so a PR body saying "the main path was fixed in #535" parses as
an issue link even though 535 is a pull request. Labeling a PR needs a
scope this workflow does not hold (and is not the sync's job), so only
``"issue"`` targets are acted on; ``"pull_request"`` and ``"missing"``
are skipped by the caller.
"""
found = client.get_issue(issue_number)
if found is None:
return "missing"
if found.get("pull_request"):
return "pull_request"
return "issue"
def _load_event(path: str) -> dict[str, Any]:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
def main() -> int:
event_path = os.environ.get("GITHUB_EVENT_PATH")
repository = os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("GITHUB_TOKEN")
if not event_path:
print("GITHUB_EVENT_PATH is required.", file=sys.stderr)
return 2
if not repository:
print("GITHUB_REPOSITORY is required.", file=sys.stderr)
return 2
if not token:
print("GITHUB_TOKEN is required.", file=sys.stderr)
return 2
event = _load_event(event_path)
actions = plan_issue_sync_actions(event)
if not actions:
print("No issue sync actions required.")
return 0
client = GitHubClient(token=token, repository=repository)
for action in actions:
target_kind = classify_sync_target(client, action.issue_number)
if target_kind != "issue":
print(
f"Skipping {action.kind} for #{action.issue_number} "
f"from PR #{action.pr_number}: target is {target_kind}."
)
continue
print(
f"Applying {action.kind} for issue #{action.issue_number} "
f"from PR #{action.pr_number}."
)
apply_action(client, action)
return 0
if __name__ == "__main__":
raise SystemExit(main())