436 lines
14 KiB
Python
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",
|
|
]
|