mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 09:30:38 +00:00
Add a `boom` subcommand that generates the `💥` section of CHANGES.md from the closed, non-excluded milestone issues labelled `breaking change`, using the last `## Breaking change note` comment of each issue. The whole comment is copied (headings demoted to `#####` outside code fences), and each issue becomes a `#### <title>` block with the issue and merged closing PR references on their own line. `--write` replaces only that subsection and is idempotent. Add a `comments` subcommand to gh.py (REST, paginated) so the generator can read issue comments, and report anomaly type G (`breaking change` issue missing from `💥`), mirroring the `release highlight` check. Document the flow in `mem:workflow/updating-changelog` (also readable by release managers, like `mem:audit-log`), turn the `update-changelog` skill into a router to it, and update the references in `critical-info`, `creating-issues` and the scripts memories. AI-assisted-by: deepseek-v4.1-flash
161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for scripts/gh.py.
|
|
|
|
Run with:
|
|
|
|
python3 scripts/test_gh.py
|
|
"""
|
|
|
|
import contextlib
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
|
|
# Loading scripts/gh.py should not emit scripts/__pycache__/.
|
|
sys.dont_write_bytecode = True
|
|
|
|
SCRIPT_PATH = pathlib.Path(__file__).resolve().parent / "gh.py"
|
|
|
|
|
|
def load_gh():
|
|
"""Load scripts/gh.py as a module without running its CLI."""
|
|
loader = importlib.machinery.SourceFileLoader("gh_helper", str(SCRIPT_PATH))
|
|
spec = importlib.util.spec_from_loader("gh_helper", loader)
|
|
module = importlib.util.module_from_spec(spec)
|
|
loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
gh = load_gh()
|
|
|
|
|
|
class LinkIssueTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.target_response = {
|
|
"repository": {
|
|
"issue": {"id": "issue-id", "number": 11235},
|
|
"pullRequest": {"id": "pr-id", "number": 11243},
|
|
}
|
|
}
|
|
self.mutation_response = {
|
|
"addCloseIssueReferences": {
|
|
"issue": {"id": "issue-id", "number": 11235}
|
|
}
|
|
}
|
|
|
|
@patch.object(gh, "run_gh_graphql")
|
|
def test_link_issue_to_pr_adds_reference(self, run_graphql):
|
|
run_graphql.side_effect = [
|
|
self.target_response,
|
|
self.mutation_response,
|
|
]
|
|
|
|
result = gh.link_issue_to_pr(11235, 11243)
|
|
|
|
self.assertTrue(result["linked"])
|
|
self.assertEqual(result["issue"]["number"], 11235)
|
|
self.assertEqual(result["pull_request"]["number"], 11243)
|
|
self.assertEqual(run_graphql.call_count, 2)
|
|
self.assertEqual(
|
|
run_graphql.call_args_list[1].args[1],
|
|
{"issueId": "issue-id", "pullRequestIds": ["pr-id"]},
|
|
)
|
|
|
|
@patch.object(gh, "run_gh_graphql")
|
|
def test_link_issue_to_pr_fails_when_mutation_did_not_link(self, run_graphql):
|
|
run_graphql.side_effect = [
|
|
self.target_response,
|
|
{"addCloseIssueReferences": {"issue": {"id": "issue-id", "number": 999}}},
|
|
]
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "did not link issue #11235"):
|
|
gh.link_issue_to_pr(11235, 11243)
|
|
|
|
@patch.object(gh, "link_issue_to_pr")
|
|
def test_cmd_link_issue_outputs_result(self, link_issue):
|
|
expected = {
|
|
"linked": True,
|
|
"issue": {"number": 11235},
|
|
"pull_request": {"number": 11243},
|
|
}
|
|
link_issue.return_value = expected
|
|
args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
|
|
stdout = io.StringIO()
|
|
stderr = io.StringIO()
|
|
|
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
gh.cmd_link_issue(args)
|
|
|
|
self.assertEqual(json.loads(stdout.getvalue()), expected)
|
|
self.assertIn("Linked issue #11235", stderr.getvalue())
|
|
link_issue.assert_called_once_with(11235, 11243)
|
|
|
|
@patch.object(gh, "link_issue_to_pr", side_effect=RuntimeError("link missing"))
|
|
def test_cmd_link_issue_exits_on_error(self, _link_issue):
|
|
args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
|
|
stderr = io.StringIO()
|
|
|
|
with contextlib.redirect_stderr(stderr):
|
|
with self.assertRaises(SystemExit) as error:
|
|
gh.cmd_link_issue(args)
|
|
|
|
self.assertEqual(error.exception.code, 1)
|
|
self.assertIn("link missing", stderr.getvalue())
|
|
|
|
|
|
class CommentsTests(unittest.TestCase):
|
|
def test_paginates_until_a_short_page(self):
|
|
page_one = [
|
|
{"id": i, "user": {"login": "u"}, "created_at": "t", "body": f"b{i}"}
|
|
for i in range(100)
|
|
]
|
|
page_two = [{"id": 100, "user": None, "created_at": "t", "body": "last"}]
|
|
calls = []
|
|
|
|
def fake_rest(path):
|
|
calls.append(path)
|
|
return page_one if len(calls) == 1 else page_two
|
|
|
|
with patch.object(gh, "run_gh_rest", side_effect=fake_rest):
|
|
comments = gh.fetch_issue_comments(7)
|
|
|
|
self.assertEqual(len(comments), 101)
|
|
self.assertEqual(
|
|
comments[0],
|
|
{"issue": 7, "id": 0, "author": "u", "created_at": "t", "body": "b0"},
|
|
)
|
|
self.assertIsNone(comments[-1]["author"])
|
|
self.assertIn("page=1", calls[0])
|
|
self.assertIn("page=2", calls[1])
|
|
|
|
@patch.object(gh, "fetch_issue_comments")
|
|
def test_cmd_comments_outputs_every_issue(self, fetch_comments):
|
|
fetch_comments.side_effect = lambda num: [
|
|
{"issue": num, "id": num, "author": "u", "created_at": "t", "body": "x"}
|
|
]
|
|
args = types.SimpleNamespace(numbers=[1, 2], file=None, stdin=False)
|
|
stdout = io.StringIO()
|
|
|
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(io.StringIO()):
|
|
gh.cmd_comments(args)
|
|
|
|
self.assertEqual([c["issue"] for c in json.loads(stdout.getvalue())], [1, 2])
|
|
|
|
def test_cmd_comments_requires_numbers(self):
|
|
args = types.SimpleNamespace(numbers=[], file=None, stdin=False)
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
with self.assertRaises(SystemExit) as error:
|
|
gh.cmd_comments(args)
|
|
self.assertEqual(error.exception.code, 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|