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]