From c04f2b0c00dd4667835e3836c0432642a8e98d45 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 14 Oct 2026 20:26:56 +0300 Subject: [PATCH] docs: publish a demo page to GitHub Pages --- .github/workflows/demo.yml | 41 ++++ AGENTS.md | 4 +- README.md | 3 + demo/__init__.py | 0 demo/build.py | 42 ++++ demo/scenario.py | 402 +++++++++++++++++++++++++++++++++++++ justfile | 8 + tests/test_browser.py | 24 ++- 8 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/demo.yml create mode 100644 demo/__init__.py create mode 100644 demo/build.py create mode 100644 demo/scenario.py diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 0000000..bbdf36d --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,41 @@ +name: Demo + +# Publishes the demo page to GitHub Pages: the CLI collects the made-up scenario in +# demo/scenario.py, so the page and its report.json match the released code. +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: {} + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 + - uses: astral-sh/setup-uv@v8.2.0 + - run: uv python install 3.13 + - run: uv python pin 3.13 + - run: just install + - run: just demo + - uses: actions/upload-pages-artifact@v5 + with: + path: site/ + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/AGENTS.md b/AGENTS.md index 0733acf..57eb63b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,9 @@ turns the range plus merge requests into rows; `_candidates.py` is pure and turn release could ship. `_static/` is the site `collect` copies next to `report.json`: `index.html` with vendored Alpine.js. Besides the translation checks in `tests/test_page.py`, it has browser smoke tests in `tests/test_browser.py`, marked `smoke` and run by `just test-smoke`, not `just test`; still check a page change in a browser against a report from -the CLI tests. Tests +the CLI tests, or against `just demo-serve`. `demo/` holds the made-up scenario behind the published demo page: the +CLI collects it against respx routes, and `.github/workflows/demo.yml` publishes it to GitHub Pages on each release +tag. A page change that adds a state belongs in the scenario too. Tests mock GitLab and Jira only with respx routes (pytest-httpx2): the `gitlab` and `jira` fixtures in `tests/conftest.py` declare one named static route per call of the scenario in `tests/payloads.py`. A test changes a response by re-mocking a named route; add no fakes, callbacks, or stubs. diff --git a/README.md b/README.md index 9c7c65d..4bc759d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ issue's Web links whenever a commit or MR mentions it; a link counts only if it the Jira keys of every row its links point to, so a key that only a commit message inside a merge request names still counts for that row and for the tags that ship it. +The [demo page](https://modern-python.github.io/release-scope/) is that site for a made-up shop with four +services and six Jira issues. Choose an issue to see which tags it needs. Its GitLab and Jira links lead nowhere. + ## Quickstart ```sh diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/build.py b/demo/build.py new file mode 100644 index 0000000..1b54116 --- /dev/null +++ b/demo/build.py @@ -0,0 +1,42 @@ +import datetime as dt +import json +import os +import pathlib +import sys +import typing +from unittest import mock + +import pytest_httpx2 # noqa: F401 registers the httpcore2 mocker with respx +import respx +from typer.testing import CliRunner + +from demo import scenario +from release_scope import ioc +from release_scope.__main__ import MAIN_APP + + +_ENV: typing.Final = { + "GITLAB_TOKEN": "demo", + "RELEASE_SCOPE_GITLAB__ENDPOINT": scenario.ENDPOINT, + "RELEASE_SCOPE_ENVIRONMENTS": json.dumps(scenario.ENVIRONMENTS), + "RELEASE_SCOPE_JIRA_ENDPOINT": scenario.JIRA_ENDPOINT, + "JIRA_TOKEN": "demo", +} + + +def build(output: pathlib.Path) -> None: + now: typing.Final = dt.datetime.now(dt.UTC).replace(minute=0, second=0, microsecond=0) + with ( + mock.patch.dict(os.environ, _ENV), + respx.mock(using="httpcore2", assert_all_called=False) as router, + ioc.container, + ): + scenario.mock(router, now) + result: typing.Final = CliRunner().invoke(MAIN_APP, ["collect", "-g", scenario.GROUP, "-o", str(output)]) + if result.exit_code != 0: + msg = f"collect exited with {result.exit_code}:\n{result.output}" + raise RuntimeError(msg) + + +if __name__ == "__main__": + build(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "site")) diff --git a/demo/scenario.py b/demo/scenario.py new file mode 100644 index 0000000..17f2ab5 --- /dev/null +++ b/demo/scenario.py @@ -0,0 +1,402 @@ +import dataclasses +import datetime as dt +import hashlib +import typing + +import respx + + +ENDPOINT: typing.Final = "https://gitlab.example.com" +JIRA_ENDPOINT: typing.Final = "https://jira.example.com" +GROUP: typing.Final = "shop" +ENVIRONMENTS: typing.Final = ("staging",) +_API: typing.Final = f"{ENDPOINT}/api/v4" + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class Change: + title: str + hours_ago: int + author: str + iid: int | None = None + branch: str | None = None + description: str | None = None + tag: str | None = None + pipeline: str = "success" + failed_jobs: tuple[str, ...] = () + on_staging: bool = False + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class LinkedMergeRequest: + iid: int + title: str + state: str + target_branch: str = "main" + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class DemoService: + id: int + name: str + production: str + older_tags: tuple[str, ...] = () + changes: tuple[Change, ...] = () + linked: tuple[LinkedMergeRequest, ...] = () + + @property + def path(self) -> str: + return f"{GROUP}/{self.name}" + + @property + def url(self) -> str: + return f"{ENDPOINT}/{self.path}" + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class DemoIssue: + key: str + summary: str + status: str + category: str + issue_type: str = "Story" + links: tuple[str, ...] = () + + +SERVICES: typing.Final = ( + DemoService( + id=11, + name="api", + production="2.3.0", + older_tags=("2.2.0",), + changes=( + Change( + title="SHOP-41 Rate limit the checkout endpoint", + hours_ago=3, + author="dina", + iid=58, + branch="feature/SHOP-41-rate-limit", + pipeline="running", + ), + Change( + title="SHOP-38 Export orders as CSV", + hours_ago=26, + author="anna", + iid=57, + branch="feature/SHOP-38-order-export", + tag="2.5.0", + on_staging=True, + ), + Change( + title="Fix VAT rounding for mixed carts", + hours_ago=30, + author="ben", + iid=55, + branch="fix/vat-rounding", + description="Closes SHOP-35", + ), + Change( + title="Bump httpx and pydantic", + hours_ago=52, + author="renovate-bot", + iid=54, + branch="renovate/all", + tag="2.4.0", + pipeline="failed", + failed_jobs=("lint",), + ), + Change( + title="SHOP-33 Search products by SKU", + hours_ago=75, + author="chen", + iid=52, + branch="feature/SHOP-33-sku-search", + on_staging=True, + ), + ), + linked=(LinkedMergeRequest(iid=40, title="SHOP-36 Theme tokens endpoint", state="merged"),), + ), + DemoService( + id=12, + name="web", + production="1.8.0", + older_tags=("1.7.1",), + changes=( + Change(title="Fix typo in the footer", hours_ago=5, author="anna"), + Change( + title="SHOP-38 Export button on the orders page", + hours_ago=28, + author="anna", + iid=120, + branch="feature/SHOP-38-export-button", + tag="1.9.0", + on_staging=True, + ), + Change( + title="SHOP-36 Dark mode", + hours_ago=49, + author="chen", + iid=118, + branch="feature/SHOP-36-dark-mode", + ), + ), + linked=( + LinkedMergeRequest(iid=125, title="SHOP-41 Show a retry hint on 429", state="opened"), + LinkedMergeRequest(iid=99, title="SHOP-33 SKU field in search", state="closed"), + ), + ), + DemoService( + id=13, + name="worker", + production="0.14.2", + older_tags=("0.14.1",), + changes=( + Change( + title="SHOP-40 Retry failed webhooks with backoff", + hours_ago=8, + author="ben", + iid=32, + branch="feature/SHOP-40-webhook-retries", + ), + Change( + title="SHOP-35 Recalculate VAT on invoices", + hours_ago=31, + author="ben", + iid=31, + branch="feature/SHOP-35-invoice-vat", + tag="0.15.0", + on_staging=True, + ), + ), + linked=( + LinkedMergeRequest( + iid=29, title="SHOP-40 Webhook retries hotfix", state="merged", target_branch="release/0.14" + ), + ), + ), + DemoService(id=14, name="admin", production="3.1.0"), +) + +ISSUES: typing.Final = ( + DemoIssue( + key="SHOP-33", + summary="Search products by SKU", + status="Ready for release", + category="done", + links=(f"{ENDPOINT}/shop/web/-/merge_requests/99",), + ), + DemoIssue( + key="SHOP-35", + summary="VAT is off by a cent on mixed carts", + status="Ready for release", + category="done", + issue_type="Bug", + links=(f"{ENDPOINT}/shop/worker/-/merge_requests/31",), + ), + DemoIssue( + key="SHOP-36", + summary="Dark mode", + status="In Review", + category="indeterminate", + links=(f"{ENDPOINT}/shop/api/-/merge_requests/40",), + ), + DemoIssue( + key="SHOP-38", + summary="Export orders as CSV", + status="Ready for release", + category="done", + links=( + f"{ENDPOINT}/shop/api/-/merge_requests/57", + f"{ENDPOINT}/shop/web/-/merge_requests/120", + f"{ENDPOINT}/shop/mobile/-/merge_requests/7", + ), + ), + DemoIssue( + key="SHOP-40", + summary="Retry failed webhooks", + status="In Progress", + category="indeterminate", + links=(f"{ENDPOINT}/shop/worker/-/merge_requests/29",), + ), + DemoIssue( + key="SHOP-41", + summary="Rate limit checkout", + status="In Progress", + category="indeterminate", + links=(f"{ENDPOINT}/shop/web/-/merge_requests/125",), + ), +) + + +def _sha(*parts: str) -> str: + return hashlib.sha1("/".join(parts).encode(), usedforsecurity=False).hexdigest() + + +def _time(now: dt.datetime, hours_ago: float) -> str: + return (now - dt.timedelta(hours=hours_ago)).isoformat() + + +def _project(service: DemoService) -> dict[str, typing.Any]: + return { + "id": service.id, + "path_with_namespace": service.path, + "web_url": service.url, + "default_branch": "main", + } + + +def _commit(service: DemoService, change: Change, now: dt.datetime) -> dict[str, typing.Any]: + sha: typing.Final = _sha(service.path, change.title) + title: typing.Final = f"Merge branch '{change.branch}' into 'main'" if change.iid else change.title + return { + "id": sha, + "short_id": sha[:8], + "title": title, + "message": title, + "author_name": change.author, + "committed_date": _time(now, change.hours_ago), + "web_url": f"{service.url}/-/commit/{sha}", + } + + +def _merge_request(service: DemoService, change: Change, now: dt.datetime) -> dict[str, typing.Any]: + return { + "iid": change.iid, + "title": change.title, + "description": change.description, + "source_branch": change.branch, + "target_branch": "main", + "state": "merged", + "web_url": f"{service.url}/-/merge_requests/{change.iid}", + "merge_commit_sha": _sha(service.path, change.title), + "merged_at": _time(now, change.hours_ago), + "author": {"username": change.author, "name": change.author}, + } + + +def _linked_merge_request(service: DemoService, linked: LinkedMergeRequest, now: dt.datetime) -> dict[str, typing.Any]: + return { + "iid": linked.iid, + "title": linked.title, + "source_branch": f"branch-{linked.iid}", + "target_branch": linked.target_branch, + "state": linked.state, + "web_url": f"{service.url}/-/merge_requests/{linked.iid}", + "merged_at": _time(now, 24 * 20) if linked.state == "merged" else None, + "author": {"username": "dev", "name": "dev"}, + } + + +def _pipeline( # noqa: PLR0913, PLR0917 + service: DemoService, pipeline_id: int, sha: str, ref: str, status: str, at: str +) -> dict[str, typing.Any]: + return { + "id": pipeline_id, + "sha": sha, + "ref": ref, + "status": status, + "web_url": f"{service.url}/-/pipelines/{pipeline_id}", + "updated_at": at, + } + + +def _deployment(deployment_id: int, ref: str, sha: str, at: str, *, tag: bool) -> dict[str, typing.Any]: + return { + "id": deployment_id, + "ref": ref, + "sha": sha, + "created_at": at, + "deployable": {"web_url": None, "tag": tag, "finished_at": at}, + } + + +def _mock_service(router: respx.Router, service: DemoService, now: dt.datetime) -> None: + api: typing.Final = f"{_API}/projects/{service.id}" + production_sha: typing.Final = _sha(service.path, "production") + production: typing.Final = _deployment( + service.id * 100, service.production, production_sha, _time(now, 24 * 9), tag=True + ) + router.get(f"{api}/deployments", params={"environment": "production"}).respond(json=[production]) + staged: typing.Final = [ + _deployment( + service.id * 100 + index, + "main", + _sha(service.path, change.title), + _time(now, change.hours_ago - 1), + tag=False, + ) + for index, change in enumerate(reversed(service.changes), start=1) + if change.on_staging + ] + router.get(f"{api}/deployments", params={"environment": "staging", "order_by": "finished_at"}).respond(json=staged) + router.get(f"{api}/deployments", params={"environment": "staging"}).respond(json=staged[-1:] or [production]) + router.get(f"{api}/repository/commits").respond(json=[_commit(service, change, now) for change in service.changes]) + router.get(f"{api}/merge_requests", params={"state": "merged"}).respond( + json=[_merge_request(service, change, now) for change in service.changes if change.iid] + ) + for linked in service.linked: + router.get(f"{api}/merge_requests/{linked.iid}").respond(json=_linked_merge_request(service, linked, now)) + for change in service.changes: + if not change.iid: + router.get(f"{api}/repository/commits/{_sha(service.path, change.title)}/merge_requests").respond(json=[]) + tags: typing.Final = [ + {"name": change.tag, "commit": {"id": _sha(service.path, change.title)}} + for change in service.changes + if change.tag + ] + tags.append({"name": service.production, "commit": {"id": production_sha}}) + tags.extend({"name": name, "commit": {"id": _sha(service.path, name)}} for name in service.older_tags) + router.get(f"{api}/repository/tags").respond(json=tags) + push: typing.Final = [] + for index, change in enumerate(service.changes): + pipeline_id = service.id * 1000 + index + sha = _sha(service.path, change.title) + push.append(_pipeline(service, pipeline_id, sha, "main", change.pipeline, _time(now, change.hours_ago))) + router.get(f"{api}/pipelines/{pipeline_id}/jobs").respond( + json=[ + { + "name": name, + "stage": "test", + "status": "failed", + "web_url": f"{service.url}/-/jobs/{pipeline_id}{position}", + "failure_reason": "script_failure", + } + for position, name in enumerate(change.failed_jobs) + ] + ) + router.get(f"{api}/pipelines/{pipeline_id}/bridges").respond(json=[]) + if change.tag: + tag_pipeline = service.id * 1000 + 500 + index + router.get(f"{api}/pipelines", params={"ref": change.tag}).respond( + json=[_pipeline(service, tag_pipeline, sha, change.tag, "success", _time(now, change.hours_ago - 1))] + ) + router.get(f"{api}/pipelines/{tag_pipeline}/jobs").respond(json=[]) + router.get(f"{api}/pipelines/{tag_pipeline}/bridges").respond(json=[]) + router.get(f"{api}/pipelines", params={"source": "push"}).respond(json=push) + + +def _issue(issue: DemoIssue) -> dict[str, typing.Any]: + return { + "key": issue.key, + "fields": { + "summary": issue.summary, + "status": {"name": issue.status, "statusCategory": {"key": issue.category}}, + "issuetype": {"name": issue.issue_type}, + }, + } + + +def mock(router: respx.Router, now: dt.datetime) -> None: + router.get(f"{_API}/groups/{GROUP}/projects").respond(json=[_project(service) for service in SERVICES]) + for service in SERVICES: + _mock_service(router, service, now) + router.post(f"{JIRA_ENDPOINT}/rest/api/2/search").respond( + json={"startAt": 0, "maxResults": 100, "total": len(ISSUES), "issues": [_issue(issue) for issue in ISSUES]} + ) + for issue in ISSUES: + router.get(f"{JIRA_ENDPOINT}/rest/api/2/issue/{issue.key}/remotelink").respond( + json=[ + {"id": index, "object": {"url": url, "title": "GitLab"}} + for index, url in enumerate(issue.links, start=1) + ] + ) diff --git a/justfile b/justfile index 4c3ce6f..5d740ab 100644 --- a/justfile +++ b/justfile @@ -43,6 +43,14 @@ test-smoke *args: uv run --no-sync playwright install chromium uv run --no-sync pytest -m smoke {{ args }} +# Build the demo site into site/: the real CLI collects the made-up scenario in demo/scenario.py. +demo: + uv run --no-sync python -m demo.build site + +# Build the demo and serve it at http://127.0.0.1:8000. +demo-serve: demo + uv run --no-sync python -m http.server 8000 --bind 127.0.0.1 --directory site + # Branch-coverage run (diagnostic; line coverage is the enforced gate, not branch). test-branch: uv run --no-sync pytest --cov=release_scope --cov-branch diff --git a/tests/test_browser.py b/tests/test_browser.py index 9d1c9ba..da85f2b 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -11,6 +11,7 @@ from playwright.sync_api import ConsoleMessage, Error, Locator, Page, expect from typer.testing import CliRunner +from demo.build import build from release_scope import ioc from release_scope.__main__ import MAIN_APP from tests.payloads import ENDPOINT, JIRA_ENDPOINT @@ -36,8 +37,19 @@ def site(jira: respx.Router, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib. result = CliRunner().invoke(MAIN_APP, ["collect", "-g", "team", "-o", str(output)]) assert result.exit_code == 0, result.output assert jira["jira_search"].called + yield from _serve(output) + + +@pytest.fixture +def demo_site(tmp_path: pathlib.Path) -> collections.abc.Iterator[str]: + output: typing.Final = tmp_path / "demo" + build(output) + yield from _serve(output) + + +def _serve(directory: pathlib.Path) -> collections.abc.Iterator[str]: server: typing.Final = http.server.ThreadingHTTPServer( - ("127.0.0.1", 0), functools.partial(_QuietHandler, directory=str(output)) + ("127.0.0.1", 0), functools.partial(_QuietHandler, directory=str(directory)) ) thread: typing.Final = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -112,3 +124,13 @@ def test_russian_page_has_no_untranslated_placeholders(page: Page, site: str, er expect(page.locator("h1")).to_have_text("Состав релиза") expect(page.locator("main")).not_to_contain_text(re.compile(r"\{\w+\}")) assert errors == [] + + +def test_demo_lists_its_services_and_picks_tags_for_an_issue(page: Page, demo_site: str, errors: list[str]) -> None: + page.goto(demo_site) + + expect(page.locator("details.service .name")).to_have_text(["shop/admin", "shop/api", "shop/web", "shop/worker"]) + expect(page.locator(".issue-row code")).to_have_count(6) + _issue(page, "SHOP-38").check() + expect(page).to_have_url(re.compile(r"#issues=SHOP-38&shop/api=2\.5\.0&shop/web=1\.9\.0$")) + assert errors == []