This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 87c160d  Add a command to list releases, and associated tests
87c160d is described below

commit 87c160d8e2f85788d1671b8a188b2a2616575166
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Jul 7 16:01:11 2025 +0100

    Add a command to list releases, and associated tests
---
 atr/blueprints/api/api.py | 109 +++++++++++++++++++++++--------------
 client/atr                | 135 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 205 insertions(+), 39 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 72fb713..b5055de 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -72,6 +72,38 @@ class ReleaseCreateRequest(schema.Strict):
 # We implicitly have /api/openapi.json
 
 
[email protected]("/checks/<project>/<version>")
+@quart_schema.validate_response(list[models.CheckResult], 200)
+async def checks_project_version(project: str, version: str) -> 
tuple[list[Mapping], int]:
+    """List all check results for a given release."""
+    async with db.session() as data:
+        release_name = models.release_name(project, version)
+        check_results = await 
data.check_result(release_name=release_name).all()
+        return [cr.model_dump() for cr in check_results], 200
+
+
[email protected]("/checks/<project>/<version>/<revision>")
+@quart_schema.validate_response(list[models.CheckResult], 200)
+async def checks_project_version_revision(project: str, version: str, 
revision: str) -> tuple[list[Mapping], int]:
+    """List all check results for a specific revision of a release."""
+    async with db.session() as data:
+        project_result = await data.project(name=project).get()
+        if project_result is None:
+            raise exceptions.NotFound(f"Project '{project}' does not exist")
+
+        release_name = models.release_name(project, version)
+        release_result = await data.release(name=release_name).get()
+        if release_result is None:
+            raise exceptions.NotFound(f"Release '{project}-{version}' does not 
exist")
+
+        revision_result = await data.revision(release_name=release_name, 
number=revision).get()
+        if revision_result is None:
+            raise exceptions.NotFound(f"Revision '{revision}' does not exist 
for release '{project}-{version}'")
+
+        check_results = await data.check_result(release_name=release_name, 
revision_number=revision).all()
+        return [cr.model_dump() for cr in check_results], 200
+
+
 @api.BLUEPRINT.route("/committees")
 @quart_schema.validate_response(list[models.Committee], 200)
 async def committees() -> tuple[list[Mapping], int]:
@@ -246,6 +278,36 @@ async def releases_create() -> tuple[Mapping, int]:
     return release.model_dump(), 201
 
 
[email protected]("/releases/<project>")
+@quart_schema.validate_querystring(Pagination)
+async def releases_project(project: str, query_args: Pagination) -> 
quart.Response:
+    """List all releases for a specific project with pagination."""
+    _pagination_args_validate(query_args)
+    async with db.session() as data:
+        project_result = await data.project(name=project).get()
+        if project_result is None:
+            raise exceptions.NotFound(f"Project '{project}' does not exist")
+
+        via = models.validate_instrumented_attribute
+        statement = (
+            sqlmodel.select(models.Release)
+            .where(models.Release.project_name == project)
+            .order_by(via(models.Release.created).desc())
+            .limit(query_args.limit)
+            .offset(query_args.offset)
+        )
+
+        paged_releases = (await data.execute(statement)).scalars().all()
+
+        count_stmt = 
sqlalchemy.select(sqlalchemy.func.count(via(models.Release.name))).where(
+            via(models.Release.project_name) == project
+        )
+        count = (await data.execute(count_stmt)).scalar_one()
+
+        result = {"data": [release.model_dump() for release in 
paged_releases], "count": count}
+        return quart.jsonify(result)
+
+
 @api.BLUEPRINT.route("/releases/<project>/<version>")
 @quart_schema.validate_response(models.Release, 200)
 async def releases_project_version(project: str, version: str) -> 
tuple[Mapping, int]:
@@ -256,16 +318,7 @@ async def releases_project_version(project: str, version: 
str) -> tuple[Mapping,
         return release.model_dump(), 200
 
 
[email protected]("/checks/<project>/<version>")
-@quart_schema.validate_response(list[models.CheckResult], 200)
-async def checks_project_version(project: str, version: str) -> 
tuple[list[Mapping], int]:
-    """List all check results for a given release."""
-    async with db.session() as data:
-        release_name = models.release_name(project, version)
-        check_results = await 
data.check_result(release_name=release_name).all()
-        return [cr.model_dump() for cr in check_results], 200
-
-
+# TODO: Rename this to revisions? I.e. /revisions/<project>/<version>
 @api.BLUEPRINT.route("/releases/<project>/<version>/revisions")
 @quart_schema.validate_response(list[models.Revision], 200)
 async def releases_project_version_revisions(project: str, version: str) -> 
tuple[list[Mapping], int]:
@@ -276,35 +329,13 @@ async def releases_project_version_revisions(project: 
str, version: str) -> tupl
         return [rev.model_dump() for rev in revisions], 200
 
 
[email protected]("/checks/<project>/<version>/<revision>")
-@quart_schema.validate_response(list[models.CheckResult], 200)
-async def checks_project_version_revision(project: str, version: str, 
revision: str) -> tuple[list[Mapping], int]:
-    """List all check results for a specific revision of a release."""
-    async with db.session() as data:
-        project_result = await data.project(name=project).get()
-        if project_result is None:
-            raise exceptions.NotFound(f"Project '{project}' does not exist")
-
-        release_name = models.release_name(project, version)
-        release_result = await data.release(name=release_name).get()
-        if release_result is None:
-            raise exceptions.NotFound(f"Release '{project}-{version}' does not 
exist")
-
-        revision_result = await data.revision(release_name=release_name, 
number=revision).get()
-        if revision_result is None:
-            raise exceptions.NotFound(f"Revision '{revision}' does not exist 
for release '{project}-{version}'")
-
-        check_results = await data.check_result(release_name=release_name, 
revision_number=revision).all()
-        return [cr.model_dump() for cr in check_results], 200
-
-
[email protected]("/secret")
[email protected]
-@quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_response(dict[str, str], 200)
-async def secret() -> tuple[Mapping, int]:
-    """Return a secret."""
-    return {"secret": "*******"}, 200
+# @api.BLUEPRINT.route("/secret")
+# @jwtoken.require
+# @quart_schema.security_scheme([{"BearerAuth": []}])
+# @quart_schema.validate_response(dict[str, str], 200)
+# async def secret() -> tuple[Mapping, int]:
+#     """Return a secret."""
+#     return {"secret": "*******"}, 200
 
 
 @api.BLUEPRINT.route("/ssh-keys")
diff --git a/client/atr b/client/atr
index b6ded47..e500428 100755
--- a/client/atr
+++ b/client/atr
@@ -249,6 +249,18 @@ def app_jwt_show() -> None:
     return app_show("tokens.jwt")
 
 
[email protected](name="list", help="List releases for PROJECT.")
+def app_release_list(project: str) -> None:
+    with config_lock() as config:
+        host = config.get("atr", {}).get("host", "release-test.apache.org")
+
+    url = f"https://{host}/api/releases/{project}";
+    verify_ssl = not host.startswith("127.0.0.1")
+
+    result = asyncio.run(web_get_public(url, verify_ssl))
+    releases_display(result)
+
+
 @RELEASE.command(name="start", help="Start a release.")
 def app_release_start(project: str, version: str) -> None:
     with config_lock() as config:
@@ -424,11 +436,116 @@ def check_results_display(results: list[dict[str, Any]]) 
-> None:
                 print(f"  - {check['checker']}: {check['message']}")
 
 
+def releases_display(result: dict[str, Any]) -> None:
+    if ("data" not in result) or ("count" not in result):
+        LOGGER.error("Invalid response format")
+        sys.exit(1)
+
+    releases = result["data"]
+    count = result["count"]
+
+    if not releases:
+        print("No releases found for this project.")
+        return
+
+    print(f"Total releases: {count}")
+    print(f"  {'Version':<12} {'Phase':<25} {'Created'}")
+    for release in releases:
+        version = release.get("version", "Unknown")
+        phase = release.get("phase", "Unknown")
+        created = release.get("created")
+        created_formatted = timestamp_format(created) if created else "Unknown"
+        print(f"  {version:<12} {phase:<25} {created_formatted}")
+
+
 def main() -> None:
     logging.basicConfig(level=logging.INFO, format="%(message)s")
     APP()
 
 
+def test_app_release_list_not_found(
+    capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, 
tmp_path: pathlib.Path
+) -> None:
+    monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(tmp_path / "atr.yaml"))
+    app_set("atr.host", "example.invalid")
+
+    class Response:
+        status = 404
+
+        async def json(self):
+            return {}
+
+        async def text(self):
+            return "Not Found"
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, exc_type, exc, tb):
+            return False
+
+    class Session:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, exc_type, exc, tb):
+            return False
+
+        def get(self, *args, **kwargs):
+            return Response()
+
+    monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: Session())
+
+    with pytest.raises(SystemExit):
+        app_release_list("nonexistent-project")
+
+
+def test_app_release_list_success(
+    capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, 
tmp_path: pathlib.Path
+) -> None:
+    monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(tmp_path / "atr.yaml"))
+    app_set("atr.host", "example.invalid")
+
+    class Response:
+        status = 200
+
+        async def json(self):
+            return {
+                "data": [
+                    {"version": "2.3.1", "phase": "release_candidate_draft", 
"created": 1735689600},
+                    {"version": "2.3.0", "phase": "release", "created": 
1720051200},
+                ],
+                "count": 2,
+            }
+
+        async def text(self):
+            return ""
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, exc_type, exc, tb):
+            return False
+
+    class Session:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, exc_type, exc, tb):
+            return False
+
+        def get(self, *args, **kwargs):
+            return Response()
+
+    monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: Session())
+
+    app_release_list("test-project")
+    captured = capsys.readouterr()
+    assert "Total releases: 2" in captured.out
+    assert "2.3.1" in captured.out
+    assert "2.3.0" in captured.out
+
+
 def test_app_set_show(
     capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, 
tmp_path: pathlib.Path
 ) -> None:
@@ -553,6 +670,24 @@ async def web_get(url: str, jwt_token: str, verify_ssl: 
bool = True) -> Any:
             return await resp.json()
 
 
+async def web_get_public(url: str, verify_ssl: bool = True) -> Any:
+    connector = None if verify_ssl else aiohttp.TCPConnector(ssl=False)
+    async with aiohttp.ClientSession(connector=connector) as session:
+        async with session.get(url) as resp:
+            if resp.status != 200:
+                text = await resp.text()
+                try:
+                    error_data = json.loads(text)
+                    if isinstance(error_data, dict) and "error" in error_data:
+                        LOGGER.error(error_data["error"])
+                    else:
+                        LOGGER.error(f"Request failed: {resp.status} {text}")
+                except json.JSONDecodeError:
+                    LOGGER.error(f"Request failed: {resp.status} {text}")
+                sys.exit(1)
+            return await resp.json()
+
+
 async def web_post(url: str, payload: dict[str, Any], jwt_token: str, 
verify_ssl: bool = True) -> Any:
     connector = None if verify_ssl else aiohttp.TCPConnector(ssl=False)
     headers = {"Authorization": f"Bearer {jwt_token}"}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to