Files
opensquilla--opensquilla/tests/test_github_issue_link_sync.py
2026-07-13 13:12:33 +08:00

436 lines
14 KiB
Python

from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any
DUMMY_MERGED_DEV_PR = 9001
DUMMY_CLOSED_PR = 9002
DUMMY_OPEN_PR = 9003
DUMMY_MERGED_MAIN_PR = 9004
DUMMY_MERGED_DEV_PR_URL = "https://example.invalid/pulls/9001"
DUMMY_CLOSED_PR_URL = "https://example.invalid/pulls/9002"
DUMMY_OPEN_PR_URL = "https://example.invalid/pulls/9003"
DUMMY_MERGED_MAIN_PR_URL = "https://example.invalid/pulls/9004"
def _load_sync_module():
script = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "issue_link_sync.py"
spec = importlib.util.spec_from_file_location("issue_link_sync", script)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class RecordingClient:
def __init__(self, comments: list[dict[str, Any]] | None = None) -> None:
self.comments = comments or []
self.calls: list[tuple[Any, ...]] = []
def add_labels(self, issue_number: int, labels: list[str]) -> None:
self.calls.append(("add_labels", issue_number, tuple(labels)))
def remove_label(self, issue_number: int, label: str) -> None:
self.calls.append(("remove_label", issue_number, label))
def list_comments(self, issue_number: int) -> list[dict[str, Any]]:
self.calls.append(("list_comments", issue_number))
return self.comments
def create_comment(self, issue_number: int, body: str) -> None:
self.calls.append(("create_comment", issue_number, body))
class PaginatedGitHubClient:
def __init__(self, pages: list[list[dict[str, Any]]]) -> None:
self.pages = pages
self.paths: list[str] = []
def request_json(self, method: str, path: str) -> list[dict[str, Any]]:
assert method == "GET"
self.paths.append(path)
return self.pages.pop(0)
def test_parse_linked_issues_splits_closing_and_reference_keywords() -> None:
sync = _load_sync_module()
parsed = sync.parse_linked_issues(
"\n".join(
[
"Fixes #100",
"Closes: opensquilla/opensquilla#101",
"resolves https://github.com/opensquilla/opensquilla/issues/102",
"Refs #200",
"References opensquilla/opensquilla#201",
"Fixes other/project#999",
]
),
owner="opensquilla",
repo="opensquilla",
)
assert parsed.closing == (100, 101, 102)
assert parsed.references == (200, 201)
assert parsed.all == (100, 101, 102, 200, 201)
def test_parse_linked_issues_deduplicates_and_ignores_pr_style_urls() -> None:
sync = _load_sync_module()
parsed = sync.parse_linked_issues(
"\n".join(
[
"Fixes #100",
"fixes #100",
"Refs https://github.com/opensquilla/opensquilla/pull/9999",
"Fixes https://github.com/opensquilla/opensquilla/issues/101",
]
),
owner="opensquilla",
repo="opensquilla",
)
assert parsed.closing == (100, 101)
assert parsed.references == ()
assert parsed.all == (100, 101)
def test_plan_merged_dev_pr_updates_only_closing_issues() -> None:
sync = _load_sync_module()
actions = sync.plan_issue_sync_actions(
{
"action": "closed",
"pull_request": {
"number": DUMMY_MERGED_DEV_PR,
"merged": True,
"body": "Fixes #100\nRefs #200",
"base": {"ref": "dev"},
"html_url": DUMMY_MERGED_DEV_PR_URL,
},
"repository": {
"full_name": "opensquilla/opensquilla",
},
}
)
assert actions == (
sync.IssueSyncAction(
issue_number=100,
kind="merged_to_dev",
pr_number=DUMMY_MERGED_DEV_PR,
pr_url=DUMMY_MERGED_DEV_PR_URL,
),
sync.IssueSyncAction(
issue_number=200,
kind="remove_linked_pr",
pr_number=DUMMY_MERGED_DEV_PR,
pr_url=DUMMY_MERGED_DEV_PR_URL,
),
)
def test_plan_open_or_updated_pr_adds_linked_pr_label_to_all_linked_issues() -> None:
sync = _load_sync_module()
for action_name in ["opened", "reopened", "edited"]:
actions = sync.plan_issue_sync_actions(
{
"action": action_name,
"pull_request": {
"number": DUMMY_OPEN_PR,
"merged": False,
"body": "Fixes #100\nRefs #200",
"base": {"ref": "dev"},
"html_url": DUMMY_OPEN_PR_URL,
},
"repository": {
"full_name": "opensquilla/opensquilla",
},
}
)
assert actions == (
sync.IssueSyncAction(
issue_number=100,
kind="linked_pr_open",
pr_number=DUMMY_OPEN_PR,
pr_url=DUMMY_OPEN_PR_URL,
),
sync.IssueSyncAction(
issue_number=200,
kind="linked_pr_open",
pr_number=DUMMY_OPEN_PR,
pr_url=DUMMY_OPEN_PR_URL,
),
)
def test_plan_merged_main_pr_removes_linked_pr_label_without_dev_status() -> None:
sync = _load_sync_module()
actions = sync.plan_issue_sync_actions(
{
"action": "closed",
"pull_request": {
"number": DUMMY_MERGED_MAIN_PR,
"merged": True,
"body": "Fixes #100\nRefs #200",
"base": {"ref": "main"},
"html_url": DUMMY_MERGED_MAIN_PR_URL,
},
"repository": {
"full_name": "opensquilla/opensquilla",
},
}
)
assert actions == (
sync.IssueSyncAction(
issue_number=100,
kind="remove_linked_pr",
pr_number=DUMMY_MERGED_MAIN_PR,
pr_url=DUMMY_MERGED_MAIN_PR_URL,
),
sync.IssueSyncAction(
issue_number=200,
kind="remove_linked_pr",
pr_number=DUMMY_MERGED_MAIN_PR,
pr_url=DUMMY_MERGED_MAIN_PR_URL,
),
)
def test_plan_closed_unmerged_pr_removes_linked_pr_label_from_all_linked_issues() -> None:
sync = _load_sync_module()
actions = sync.plan_issue_sync_actions(
{
"action": "closed",
"pull_request": {
"number": DUMMY_CLOSED_PR,
"merged": False,
"body": "Fixes #100\nRefs #200",
"base": {"ref": "dev"},
"html_url": DUMMY_CLOSED_PR_URL,
},
"repository": {
"full_name": "opensquilla/opensquilla",
},
}
)
assert actions == (
sync.IssueSyncAction(
issue_number=100,
kind="closed_unmerged",
pr_number=DUMMY_CLOSED_PR,
pr_url=DUMMY_CLOSED_PR_URL,
),
sync.IssueSyncAction(
issue_number=200,
kind="closed_unmerged",
pr_number=DUMMY_CLOSED_PR,
pr_url=DUMMY_CLOSED_PR_URL,
),
)
def test_comment_marker_is_pr_scoped_for_idempotent_merged_to_dev_comments() -> None:
sync = _load_sync_module()
marker = sync.comment_marker(kind="merged_to_dev", pr_number=DUMMY_MERGED_DEV_PR)
assert marker == "<!-- opensquilla-issue-link-sync:merged-to-dev:pr-9001 -->"
assert sync.has_marker([{"body": f"{marker}\nThis is already posted."}], marker)
assert not sync.has_marker(
[{"body": "<!-- opensquilla-issue-link-sync:merged-to-dev:pr-9000 -->"}],
marker,
)
def test_apply_merged_dev_action_labels_removes_open_pr_label_and_comments_once() -> None:
sync = _load_sync_module()
action = sync.IssueSyncAction(
issue_number=100,
kind="merged_to_dev",
pr_number=DUMMY_MERGED_DEV_PR,
pr_url=DUMMY_MERGED_DEV_PR_URL,
)
client = RecordingClient()
sync.apply_action(client, action)
assert client.calls == [
("add_labels", 100, ("merged-to-dev", "needs-verification")),
("remove_label", 100, "has-linked-pr"),
("list_comments", 100),
(
"create_comment",
100,
"<!-- opensquilla-issue-link-sync:merged-to-dev:pr-9001 -->\n"
"The linked fix for this issue has merged to `dev` via #9001 "
f"({DUMMY_MERGED_DEV_PR_URL}). "
"Keeping it open for verification before release.",
),
]
marker = sync.comment_marker(kind="merged_to_dev", pr_number=DUMMY_MERGED_DEV_PR)
client = RecordingClient(comments=[{"body": marker}])
sync.apply_action(client, action)
assert client.calls == [
("add_labels", 100, ("merged-to-dev", "needs-verification")),
("remove_label", 100, "has-linked-pr"),
("list_comments", 100),
]
def test_apply_open_linked_pr_action_adds_open_pr_label() -> None:
sync = _load_sync_module()
action = sync.IssueSyncAction(
issue_number=100,
kind="linked_pr_open",
pr_number=DUMMY_OPEN_PR,
pr_url=DUMMY_OPEN_PR_URL,
)
client = RecordingClient()
sync.apply_action(client, action)
assert client.calls == [("add_labels", 100, ("has-linked-pr",))]
def test_apply_remove_linked_pr_action_only_removes_open_pr_label() -> None:
sync = _load_sync_module()
action = sync.IssueSyncAction(
issue_number=200,
kind="remove_linked_pr",
pr_number=DUMMY_MERGED_MAIN_PR,
pr_url=DUMMY_MERGED_MAIN_PR_URL,
)
client = RecordingClient()
sync.apply_action(client, action)
assert client.calls == [("remove_label", 200, "has-linked-pr")]
def test_apply_closed_unmerged_action_only_removes_open_pr_label() -> None:
sync = _load_sync_module()
action = sync.IssueSyncAction(
issue_number=200,
kind="closed_unmerged",
pr_number=DUMMY_CLOSED_PR,
pr_url=DUMMY_CLOSED_PR_URL,
)
client = RecordingClient()
sync.apply_action(client, action)
assert client.calls == [("remove_label", 200, "has-linked-pr")]
class ClassifyingClient:
"""get_issue stub: maps issue number -> API response (or None)."""
def __init__(self, targets: dict[int, dict[str, Any] | None]) -> None:
self.targets = targets
self.lookups: list[int] = []
def get_issue(self, issue_number: int) -> dict[str, Any] | None:
self.lookups.append(issue_number)
return self.targets.get(issue_number)
def test_classify_sync_target_distinguishes_issue_pr_and_missing() -> None:
sync = _load_sync_module()
client = ClassifyingClient(
{
100: {"number": 100},
535: {"number": 535, "pull_request": {"url": "https://example.invalid/pulls/535"}},
}
)
assert sync.classify_sync_target(client, 100) == "issue"
assert sync.classify_sync_target(client, 535) == "pull_request"
assert sync.classify_sync_target(client, 999) == "missing"
class StatusRaisingClient:
"""request_json stub that answers every mutation with a fixed HTTP status."""
def __init__(self, status: int) -> None:
self.status = status
self.calls: list[tuple[str, str]] = []
def request_json(
self,
method: str,
path: str,
*,
payload: dict[str, Any] | None = None,
ignore_statuses: set[int] | None = None,
) -> Any:
self.calls.append((method, path))
if self.status in (ignore_statuses or set()):
return None
raise RuntimeError(f"GitHub API {method} {path} failed: {self.status}")
def ensure_label(self, name: str) -> None:
self.calls.append(("ENSURE", name))
def test_label_and_comment_mutations_tolerate_403_and_410() -> None:
"""A PR-numbered target or a locked/deleted issue must not fail the job."""
sync = _load_sync_module()
for status in (403, 404, 410):
client = StatusRaisingClient(status)
sync.GitHubClient.add_labels(client, 535, ["has-linked-pr"])
sync.GitHubClient.remove_label(client, 535, "has-linked-pr")
sync.GitHubClient.create_comment(client, 535, "body")
assert ("POST", "/issues/535/labels") in client.calls
assert ("POST", "/issues/535/comments") in client.calls
def test_ensure_label_tolerates_create_race() -> None:
"""A 422 from a concurrent run creating the same label is not an error."""
sync = _load_sync_module()
class LabelRaceClient(StatusRaisingClient):
def request_json(self, method: str, path: str, **kwargs: Any) -> Any:
if method == "GET":
self.calls.append((method, path))
return None # label not found -> triggers POST /labels
return super().request_json(method, path, **kwargs)
client = LabelRaceClient(422)
sync.GitHubClient.ensure_label(client, "has-linked-pr")
assert ("POST", "/labels") in client.calls
def test_list_comments_reads_all_pages_before_idempotency_check() -> None:
sync = _load_sync_module()
client = PaginatedGitHubClient(
[
[{"body": f"old comment {index}"} for index in range(100)],
[{"body": "<!-- opensquilla-issue-link-sync:merged-to-dev:pr-9001 -->"}],
]
)
comments = sync.GitHubClient.list_comments(client, 100)
assert sync.has_marker(
comments,
"<!-- opensquilla-issue-link-sync:merged-to-dev:pr-9001 -->",
)
assert client.paths == [
"/issues/100/comments?per_page=100&page=1",
"/issues/100/comments?per_page=100&page=2",
]