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-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 3ead693 Move published, ref, and release routes to the new layout
3ead693 is described below
commit 3ead693c61af286603f4a1afd2c2974854d078fa
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Oct 28 15:48:50 2025 +0000
Move published, ref, and release routes to the new layout
---
atr/admin/__init__.py | 2 +-
atr/construct.py | 4 +-
atr/get/__init__.py | 8 +
atr/get/compose.py | 2 +-
atr/get/distribution.py | 2 -
atr/get/download.py | 2 +-
atr/get/draft.py | 1 -
atr/{routes => get}/published.py | 12 +-
atr/{routes => get}/ref.py | 7 +-
atr/{routes => get}/release.py | 29 +-
atr/get/vote.py | 2 +-
atr/log.py | 2 -
atr/{routes => }/mapping.py | 10 +-
atr/models/sql.py | 1 -
atr/post/announce.py | 14 +-
atr/post/candidate.py | 5 +-
atr/post/draft.py | 7 +-
atr/routes/__init__.py | 6 -
atr/routes/projects.py | 599 ----------------------------------
atr/sbom/__init__.py | 2 -
atr/sbom/__main__.py | 2 -
atr/sbom/cli.py | 2 -
atr/server.py | 2 +-
atr/shared/finish.py | 5 +-
atr/storage/writers/__init__.py | 2 +
atr/storage/writers/announce.py | 3 +-
atr/templates/file-selected-path.html | 2 +-
atr/templates/includes/sidebar.html | 2 +-
atr/templates/index-committer.html | 2 +-
atr/templates/phase-view.html | 4 +-
atr/templates/project-select.html | 4 +-
atr/templates/project-view.html | 2 +-
atr/templates/releases-finished.html | 2 +-
atr/templates/releases.html | 2 +-
34 files changed, 75 insertions(+), 678 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index d0835e5..c369435 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -43,9 +43,9 @@ import atr.forms as forms
import atr.get as get
import atr.ldap as ldap
import atr.log as log
+import atr.mapping as mapping
import atr.models.sql as sql
import atr.principal as principal
-import atr.routes.mapping as mapping
import atr.storage as storage
import atr.storage.outcome as outcome
import atr.storage.types as types
diff --git a/atr/construct.py b/atr/construct.py
index 42a8757..f908bbb 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -46,7 +46,7 @@ class StartVoteOptions:
async def announce_release_body(body: str, options: AnnounceReleaseOptions) ->
str:
# NOTE: The present module is imported by routes
# Therefore this must be done here to avoid a circular import
- import atr.routes.release as routes_release
+ import atr.get as get
try:
host = quart.request.host
@@ -65,7 +65,7 @@ async def announce_release_body(body: str, options:
AnnounceReleaseOptions) -> s
raise RuntimeError(f"Release {options.project_name}
{options.version_name} has no committee")
committee = release.committee
- routes_release_view = routes_release.view # type: ignore[has-type]
+ routes_release_view = get.release.view # type: ignore[has-type]
download_path = util.as_url(
routes_release_view, project_name=options.project_name,
version_name=options.version_name
)
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 1c3a872..3694731 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
from typing import Final, Literal
import atr.get.announce as announce
@@ -31,6 +33,9 @@ import atr.get.ignores as ignores
import atr.get.keys as keys
import atr.get.preview as preview
import atr.get.projects as projects
+import atr.get.published as published
+import atr.get.ref as ref
+import atr.get.release as release
import atr.get.vote as vote
ROUTES_MODULE: Final[Literal[True]] = True
@@ -50,5 +55,8 @@ __all__ = [
"keys",
"preview",
"projects",
+ "published",
+ "ref",
+ "release",
"vote",
]
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 7c51a69..2696b38 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -20,8 +20,8 @@ import werkzeug.wrappers.response as response
import atr.blueprints.get as get
import atr.db as db
+import atr.mapping as mapping
import atr.models.sql as sql
-import atr.routes.mapping as mapping
import atr.shared as shared
import atr.web as web
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 29a737d..971be34 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -15,8 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
import atr.blueprints.get as get
import atr.db as db
import atr.forms as forms
diff --git a/atr/get/download.py b/atr/get/download.py
index c5c7225..bcfef95 100644
--- a/atr/get/download.py
+++ b/atr/get/download.py
@@ -29,8 +29,8 @@ import atr.blueprints.get as get
import atr.config as config
import atr.db as db
import atr.htm as htm
+import atr.mapping as mapping
import atr.models.sql as sql
-import atr.routes.mapping as mapping
import atr.routes.root as root
import atr.template as template
import atr.util as util
diff --git a/atr/get/draft.py b/atr/get/draft.py
index 0d2a513..ca9ad87 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-
from __future__ import annotations
import datetime
diff --git a/atr/routes/published.py b/atr/get/published.py
similarity index 92%
rename from atr/routes/published.py
rename to atr/get/published.py
index 3db7cab..6d7e572 100644
--- a/atr/routes/published.py
+++ b/atr/get/published.py
@@ -22,14 +22,14 @@ from datetime import datetime
import aiofiles.os
import quart
+import atr.blueprints.get as get
import atr.htm as htm
-import atr.route as route
import atr.util as util
import atr.web as web
[email protected]("/published/<path:path>")
-async def path(session: route.CommitterSession, path: str) -> quart.Response:
[email protected]("/published/<path:path>")
+async def path(session: web.Committer, path: str) -> quart.Response:
"""View the content of a specific file in the downloads directory."""
# This route is for debugging
# When developing locally, there is no proxy to view the downloads
directory
@@ -37,8 +37,8 @@ async def path(session: route.CommitterSession, path: str) ->
quart.Response:
return await _path(session, path)
[email protected]("/published/")
-async def root(session: route.CommitterSession) -> quart.Response:
[email protected]("/published/")
+async def root(session: web.Committer) -> quart.Response:
return await _path(session, "")
@@ -95,7 +95,7 @@ async def _file_content(full_path: pathlib.Path) ->
quart.Response:
return await quart.send_file(full_path)
-async def _path(session: route.CommitterSession, path: str) -> quart.Response:
+async def _path(session: web.Committer, path: str) -> quart.Response:
downloads_path = util.get_downloads_dir()
full_path = downloads_path / path
if await aiofiles.os.path.isdir(full_path):
diff --git a/atr/routes/ref.py b/atr/get/ref.py
similarity index 95%
rename from atr/routes/ref.py
rename to atr/get/ref.py
index 8bf5772..78dcbef 100644
--- a/atr/routes/ref.py
+++ b/atr/get/ref.py
@@ -21,16 +21,17 @@ import pathlib
import quart
import werkzeug.wrappers.response as response
+import atr.blueprints.get as get
import atr.config as config
-import atr.route as route
+import atr.web as web
# Perhaps GitHub will get around to implementing symbol permalinks:
# https://github.com/orgs/community/discussions/13292
# Then this code will be easier, but we should still keep our own links
[email protected]("/ref/<path:ref_path>")
-async def resolve(session: route.CommitterSession | None, ref_path: str) ->
response.Response:
[email protected]("/ref/<path:ref_path>")
+async def resolve(session: web.Committer | None, ref_path: str) ->
response.Response:
project_root = pathlib.Path(config.get().PROJECT_ROOT)
if ":" in ref_path:
diff --git a/atr/routes/release.py b/atr/get/release.py
similarity index 86%
rename from atr/routes/release.py
rename to atr/get/release.py
index 5b2cc4e..dac1e8a 100644
--- a/atr/routes/release.py
+++ b/atr/get/release.py
@@ -15,27 +15,22 @@
# specific language governing permissions and limitations
# under the License.
-"""release.py"""
-
import datetime
-import asfquart
import asfquart.base as base
import werkzeug.wrappers.response as response
+import atr.blueprints.get as get
import atr.db as db
import atr.db.interaction as interaction
import atr.models.sql as sql
-import atr.route as route
import atr.template as template
import atr.util as util
-
-if asfquart.APP is ...:
- raise RuntimeError("APP is not set")
+import atr.web as web
[email protected]("/releases/finished/<project_name>")
-async def finished(session: route.CommitterSession | None, project_name: str)
-> str:
[email protected]("/releases/finished/<project_name>")
+async def finished(session: web.Committer | None, project_name: str) -> str:
"""View all finished releases for a project."""
async with db.session() as data:
project = await data.project(name=project_name,
status=sql.ProjectStatus.ACTIVE).demand(
@@ -58,8 +53,8 @@ async def finished(session: route.CommitterSession | None,
project_name: str) ->
)
[email protected]("/releases")
-async def releases(session: route.CommitterSession | None) -> str:
[email protected]("/releases")
+async def releases(session: web.Committer | None) -> str:
"""View all releases."""
# Releases are public, so we don't need to filter by user
async with db.session() as data:
@@ -83,8 +78,8 @@ async def releases(session: route.CommitterSession | None) ->
str:
)
[email protected]("/release/select/<project_name>")
-async def select(session: route.CommitterSession, project_name: str) -> str:
[email protected]("/release/select/<project_name>")
+async def select(session: web.Committer, project_name: str) -> str:
"""Show releases in progress for a project."""
await session.check_access(project_name)
@@ -98,8 +93,8 @@ async def select(session: route.CommitterSession,
project_name: str) -> str:
)
[email protected]("/release/view/<project_name>/<version_name>")
-async def view(session: route.CommitterSession | None, project_name: str,
version_name: str) -> response.Response | str:
[email protected]("/release/view/<project_name>/<version_name>")
+async def view(session: web.Committer | None, project_name: str, version_name:
str) -> response.Response | str:
"""View all the files in the rsync upload directory for a release."""
async with db.session() as data:
release_name = sql.release_name(project_name, version_name)
@@ -125,9 +120,9 @@ async def view(session: route.CommitterSession | None,
project_name: str, versio
)
[email protected]("/release/view/<project_name>/<version_name>/<path:file_path>")
[email protected]("/release/view/<project_name>/<version_name>/<path:file_path>")
async def view_path(
- session: route.CommitterSession | None, project_name: str, version_name:
str, file_path: str
+ session: web.Committer | None, project_name: str, version_name: str,
file_path: str
) -> response.Response | str:
"""View the content of a specific file in the final release."""
async with db.session() as data:
diff --git a/atr/get/vote.py b/atr/get/vote.py
index e5eeadc..6a8304e 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -23,9 +23,9 @@ import atr.db as db
import atr.db.interaction as interaction
import atr.forms as forms
import atr.log as log
+import atr.mapping as mapping
import atr.models.results as results
import atr.models.sql as sql
-import atr.routes.mapping as mapping
import atr.shared as shared
import atr.storage as storage
import atr.user as user
diff --git a/atr/log.py b/atr/log.py
index c2ce1aa..5f9e9ab 100644
--- a/atr/log.py
+++ b/atr/log.py
@@ -15,8 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
import inspect
import logging
import logging.handlers
diff --git a/atr/routes/mapping.py b/atr/mapping.py
similarity index 88%
rename from atr/routes/mapping.py
rename to atr/mapping.py
index 930f52d..b465272 100644
--- a/atr/routes/mapping.py
+++ b/atr/mapping.py
@@ -21,18 +21,16 @@ import werkzeug.wrappers.response as response
import atr.get as get
import atr.models.sql as sql
-import atr.route as route
-import atr.routes.release as routes_release
import atr.util as util
import atr.web as web
async def release_as_redirect(
- session: route.CommitterSession | web.Committer,
+ session: web.Committer,
release: sql.Release,
) -> response.Response:
route = release_as_route(release)
- if route is routes_release.finished:
+ if route is get.release.finished:
return await session.redirect(route, project_name=release.project.name)
return await session.redirect(route, project_name=release.project.name,
version_name=release.version)
@@ -46,11 +44,11 @@ def release_as_route(release: sql.Release) -> Callable:
case sql.ReleasePhase.RELEASE_PREVIEW:
return get.finish.selected
case sql.ReleasePhase.RELEASE:
- return routes_release.finished
+ return get.release.finished
def release_as_url(release: sql.Release) -> str:
route = release_as_route(release)
- if route is routes_release.finished:
+ if route is get.release.finished:
return util.as_url(route, project_name=release.project.name)
return util.as_url(route, project_name=release.project.name,
version_name=release.version)
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 968ea83..99da4d8 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -20,7 +20,6 @@
# NOTE: We can't use symbolic annotations here because sqlmodel doesn't
support them
# https://github.com/fastapi/sqlmodel/issues/196
# https://github.com/fastapi/sqlmodel/pull/778/files
-# from __future__ import annotations
import dataclasses
import datetime
diff --git a/atr/post/announce.py b/atr/post/announce.py
index c8e4746..176d0af 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -15,14 +15,18 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
import quart
-import werkzeug.wrappers.response as response
+
+if TYPE_CHECKING:
+ import werkzeug.wrappers.response as response
# TODO: Improve upon the routes_release pattern
import atr.blueprints.post as post
-import atr.get as get
import atr.models.sql as sql
-import atr.routes.release as routes_release
import atr.shared as shared
import atr.storage as storage
import atr.template as template
@@ -37,6 +41,8 @@ class AnnounceError(Exception):
@post.committer("/announce/<project_name>/<version_name>")
async def selected(session: web.Committer, project_name: str, version_name:
str) -> str | response.Response:
"""Handle the announcement form submission and promote the preview to
release."""
+ import atr.get as get
+
await session.check_access(project_name)
permitted_recipients = util.permitted_announce_recipients(session.uid)
@@ -85,7 +91,7 @@ async def selected(session: web.Committer, project_name: str,
version_name: str)
get.announce.selected, error=str(e), project_name=project_name,
version_name=version_name
)
- routes_release_finished = routes_release.finished # type: ignore[has-type]
+ routes_release_finished = get.release.finished # type: ignore[has-type]
return await session.redirect(
routes_release_finished,
success="Preview successfully announced",
diff --git a/atr/post/candidate.py b/atr/post/candidate.py
index 3b56c4d..ca0b011 100644
--- a/atr/post/candidate.py
+++ b/atr/post/candidate.py
@@ -15,17 +15,16 @@
# specific language governing permissions and limitations
# under the License.
-"""candidate.py"""
-
import werkzeug.wrappers.response as response
import atr.blueprints.post as post
-import atr.routes.root as root
import atr.web as web
@post.committer("/candidate/delete")
async def delete(session: web.Committer) -> response.Response:
"""Delete a release candidate."""
+ import atr.routes.root as root
+
# TODO: We need to never retire revisions, if allowing release deletion
return await session.redirect(root.index, error="Not yet implemented")
diff --git a/atr/post/draft.py b/atr/post/draft.py
index 341f7e4..0860d3f 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -31,8 +31,6 @@ import atr.forms as forms
import atr.get.compose as compose
import atr.log as log
import atr.models.sql as sql
-import atr.routes.root as root
-import atr.routes.upload as upload
import atr.shared as shared
import atr.storage as storage
import atr.util as util
@@ -53,6 +51,8 @@ class VotePreviewForm(forms.Typed):
@post.committer("/draft/delete")
async def delete(session: web.Committer) -> response.Response:
"""Delete a candidate draft and all its associated files."""
+ import atr.routes.root as root
+
form = await shared.draft.DeleteForm.create_form(data=await
quart.request.form)
if not await form.validate_on_submit():
for _field, errors in form.errors.items():
@@ -248,6 +248,8 @@ async def sbomgen(session: web.Committer, project_name:
str, version_name: str,
@post.committer("/draft/svnload/<project_name>/<version_name>")
async def svnload(session: web.Committer, project_name: str, version_name:
str) -> response.Response | str:
"""Import files from SVN into a draft."""
+ import atr.routes.upload as upload
+
await session.check_access(project_name)
form = await upload.SvnImportForm.create_form()
@@ -294,6 +296,7 @@ async def vote_preview(
session: web.Committer, project_name: str, version_name: str
) -> quart.wrappers.response.Response | response.Response | str:
"""Show the vote email preview for a release."""
+ import atr.routes.root as root
form = await VotePreviewForm.create_form(data=await quart.request.form)
if not await form.validate_on_submit():
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 9d61ba3..bec11e9 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -15,9 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-import atr.routes.published as published
-import atr.routes.ref as ref
-import atr.routes.release as release
import atr.routes.report as report
import atr.routes.resolve as resolve
import atr.routes.revisions as revisions
@@ -30,9 +27,6 @@ import atr.routes.user as user
import atr.routes.voting as voting
__all__ = [
- "published",
- "ref",
- "release",
"report",
"resolve",
"revisions",
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
deleted file mode 100644
index cd4b5e4..0000000
--- a/atr/routes/projects.py
+++ /dev/null
@@ -1,599 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-"""project.py"""
-
-from __future__ import annotations
-
-import datetime
-import http.client
-import re
-from typing import TYPE_CHECKING, Any
-
-import asfquart.base as base
-import quart
-
-import atr.config as config
-import atr.db as db
-import atr.db.interaction as interaction
-import atr.forms as forms
-import atr.log as log
-import atr.models.policy as policy
-import atr.models.sql as sql
-import atr.registry as registry
-import atr.route as route
-import atr.shared as shared
-import atr.storage as storage
-import atr.template as template
-import atr.user as user
-import atr.util as util
-
-if TYPE_CHECKING:
- import werkzeug.wrappers.response as response
-
-
-class AddForm(forms.Typed):
- committee_name = forms.hidden()
- display_name = forms.string("Display name")
- label = forms.string("Label")
- submit = forms.submit("Add project")
-
-
-class ProjectMetadataForm(forms.Typed):
- project_name = forms.hidden()
- category_to_add = forms.optional("New category name")
- language_to_add = forms.optional("New language name")
-
-
-class ReleasePolicyForm(forms.Typed):
- """
- A Form to create or edit a ReleasePolicy.
-
- TODO: Currently only a single mailto_address is supported.
- see:
https://stackoverflow.com/questions/49066046/append-entry-to-fieldlist-with-flask-wtforms-using-ajax
- """
-
- project_name = forms.hidden()
-
- # Compose section
- source_artifact_paths = forms.textarea(
- "Source artifact paths",
- optional=True,
- rows=5,
- description="Paths to source artifacts to be included in the release.",
- )
- binary_artifact_paths = forms.textarea(
- "Binary artifact paths",
- optional=True,
- rows=5,
- description="Paths to binary artifacts to be included in the release.",
- )
- github_repository_name = forms.optional(
- "GitHub repository name",
- description="The name of the GitHub repository to use for the release,
excluding the apache/ prefix.",
- )
- github_compose_workflow_path = forms.textarea(
- "GitHub compose workflow paths",
- optional=True,
- rows=5,
- description="The full paths to the GitHub workflows to use for the
release,"
- " including the .github/workflows/ prefix.",
- )
- strict_checking = forms.boolean(
- "Strict checking", description="If enabled, then the release cannot be
voted upon unless all checks pass."
- )
-
- # Vote section
- github_vote_workflow_path = forms.textarea(
- "GitHub vote workflow paths",
- optional=True,
- rows=5,
- description="The full paths to the GitHub workflows to use for the
release,"
- " including the .github/workflows/ prefix.",
- )
- mailto_addresses = forms.string(
- "Email",
- validators=[forms.REQUIRED, forms.EMAIL],
- placeholder="E.g. [email protected]",
- description=f"The mailing list where vote emails are sent. This is
usually"
- "your dev list. ATR will currently only send test announcement emails
to"
- f"{util.USER_TESTS_ADDRESS}.",
- )
- manual_vote = forms.boolean(
- "Manual voting process",
- description="If this is set then the vote will be completely manual
and following policy is ignored.",
- )
- default_min_hours_value_at_render = forms.hidden()
- min_hours = forms.integer(
- "Minimum voting period",
- validators=[util.validate_vote_duration],
- default=72,
- description="The minimum time to run the vote, in hours. Must be 0 or
between 72 and 144 inclusive."
- " If 0, then wait until 3 +1 votes and more +1 than -1.",
- )
- pause_for_rm = forms.boolean(
- "Pause for RM", description="If enabled, RM can confirm manually if
the vote has passed."
- )
- release_checklist = forms.textarea(
- "Release checklist",
- optional=True,
- rows=10,
- description="Markdown text describing how to test release candidates.",
- )
- default_start_vote_template_hash = forms.hidden()
- start_vote_template = forms.textarea(
- "Start vote template",
- optional=True,
- rows=10,
- description="Email template for messages to start a vote on a
release.",
- )
-
- # Finish section
- default_announce_release_template_hash = forms.hidden()
- announce_release_template = forms.textarea(
- "Announce release template",
- optional=True,
- rows=10,
- description="Email template for messages to announce a finished
release.",
- )
- github_finish_workflow_path = forms.textarea(
- "GitHub finish workflow paths",
- optional=True,
- rows=5,
- description="The full paths to the GitHub workflows to use for the
release,"
- " including the .github/workflows/ prefix.",
- )
- preserve_download_files = forms.boolean(
- "Preserve download files",
- description="If enabled, existing download files will not be
overwritten.",
- )
-
- submit_policy = forms.submit("Save")
-
- async def validate(self, extra_validators: dict[str, Any] | None = None)
-> bool: # noqa: C901
- await super().validate(extra_validators=extra_validators)
-
- if self.manual_vote.data:
- for field_name in (
- "mailto_addresses",
- "min_hours",
- "pause_for_rm",
- "release_checklist",
- "start_vote_template",
- ):
- field = getattr(self, field_name, None)
- if field is not None:
- forms.clear_errors(field)
- self.errors.pop(field_name, None)
-
- if self.manual_vote.data and self.strict_checking.data:
- msg = "Manual voting process and strict checking cannot be enabled
simultaneously."
- forms.error(self.manual_vote, msg)
- forms.error(self.strict_checking, msg)
-
- github_repository_name = (self.github_repository_name.data or
"").strip()
- compose_raw = self.github_compose_workflow_path.data or ""
- vote_raw = self.github_vote_workflow_path.data or ""
- finish_raw = self.github_finish_workflow_path.data or ""
- compose = [p.strip() for p in compose_raw.split("\n") if p.strip()]
- vote = [p.strip() for p in vote_raw.split("\n") if p.strip()]
- finish = [p.strip() for p in finish_raw.split("\n") if p.strip()]
-
- any_path = bool(compose or vote or finish)
- if any_path and (not github_repository_name):
- forms.error(
- self.github_repository_name,
- "GitHub repository name is required when any workflow path is
set.",
- )
-
- if github_repository_name and ("/" in github_repository_name):
- forms.error(self.github_repository_name, "GitHub repository name
must not contain a slash.")
-
- if compose:
- for p in compose:
- if not p.startswith(".github/workflows/"):
- forms.error(
- self.github_compose_workflow_path,
- "GitHub workflow paths must start with
'.github/workflows/'.",
- )
- break
- if vote:
- for p in vote:
- if not p.startswith(".github/workflows/"):
- forms.error(
- self.github_vote_workflow_path,
- "GitHub workflow paths must start with
'.github/workflows/'.",
- )
- break
- if finish:
- for p in finish:
- if not p.startswith(".github/workflows/"):
- forms.error(
- self.github_finish_workflow_path,
- "GitHub workflow paths must start with
'.github/workflows/'.",
- )
- break
-
- return not self.errors
-
-
[email protected]("/project/add/<committee_name>", methods=["GET", "POST"])
-async def add_project(session: route.CommitterSession, committee_name: str) ->
response.Response | str:
- await session.check_access_committee(committee_name)
-
- async with db.session() as data:
- committee = await data.committee(name=committee_name).demand(
- base.ASFQuartException(f"Committee {committee_name} not found",
errorcode=404)
- )
-
- form = await AddForm.create_form(data={"committee_name": committee_name})
- form.display_name.description = f"""\
-For example, "Apache {committee.display_name}" or "Apache
{committee.display_name} Components".
-You must start with "Apache " and you must use title case.
-"""
- form.label.description = f"""\
-For example, "{committee.name}" or "{committee.name}-components".
-You must start with your committee label, and you must use lower case.
-"""
-
- if await form.validate_on_submit():
- return await _project_add(form, session)
-
- return await template.render("project-add-project.html", form=form,
committee_name=committee.display_name)
-
-
[email protected]("/project/delete", methods=["POST"])
-async def delete(session: route.CommitterSession) -> response.Response:
- """Delete a project created by the user."""
- # TODO: This is not truly empty, so make a form object for this
- await util.validate_empty_form()
- form_data = await quart.request.form
- project_name = form_data.get("project_name")
- if not project_name:
- return await session.redirect(projects, error="Missing project name
for deletion.")
-
- async with storage.write(session) as write:
- wacm = await write.as_project_committee_member(project_name)
- try:
- await wacm.project.delete(project_name)
- except storage.AccessError as e:
- # TODO: Redirect to committees
- return await session.redirect(projects, error=f"Error deleting
project: {e}")
-
- # TODO: Redirect to committees
- return await session.redirect(projects, success=f"Project '{project_name}'
deleted successfully.")
-
-
[email protected]("/projects")
-async def projects(session: route.CommitterSession | None) -> str:
- """Main project directory page."""
- async with db.session() as data:
- projects = await
data.project(_committee=True).order_by(sql.Project.full_name).all()
- return await template.render("projects.html", projects=projects,
empty_form=await forms.Empty.create_form())
-
-
[email protected]("/project/select")
-async def select(session: route.CommitterSession) -> str:
- """Select a project to work on."""
- user_projects = []
- if session.uid:
- async with db.session() as data:
- # TODO: Move this filtering logic somewhere else
- # The ALLOW_TESTS line allows test projects to be shown
- conf = config.get()
- all_projects = await data.project(status=sql.ProjectStatus.ACTIVE,
_committee=True).all()
- user_projects = [
- p
- for p in all_projects
- if p.committee
- and (
- (conf.ALLOW_TESTS and (p.committee.name == "test"))
- or (session.uid in p.committee.committee_members)
- or (session.uid in p.committee.committers)
- or (session.uid in p.committee.release_managers)
- )
- ]
- user_projects.sort(key=lambda p: p.display_name)
-
- return await template.render("project-select.html",
user_projects=user_projects)
-
-
[email protected]("/projects/<name>", methods=["GET", "POST"])
-async def view(session: route.CommitterSession, name: str) ->
response.Response | str:
- policy_form = None
- metadata_form = None
- can_edit = False
-
- async with db.session() as data:
- project = await data.project(
- name=name, _committee=True, _committee_public_signing_keys=True,
_release_policy=True
- ).demand(http.client.HTTPException(404))
-
- is_committee_member = project.committee and
(user.is_committee_member(project.committee, session.uid))
- is_privileged = user.is_admin(session.uid)
- can_edit = is_committee_member or is_privileged
-
- if can_edit and (quart.request.method == "POST"):
- form_data = await quart.request.form
- if "submit_metadata" in form_data:
- edited_metadata, metadata_form = await _metadata_edit(session,
project, form_data)
- if edited_metadata is True:
- return quart.redirect(util.as_url(view, name=project.name))
- elif "submit_policy" in form_data:
- policy_form = await ReleasePolicyForm.create_form(data=form_data)
- if await policy_form.validate_on_submit():
- policy_data =
policy.ReleasePolicyData.model_validate(policy_form.data)
- async with storage.write(session) as write:
- wacm = await
write.as_project_committee_member(project.name)
- try:
- await wacm.policy.edit(project, policy_data)
- except storage.AccessError as e:
- return await session.redirect(view, name=project.name,
error=f"Error editing policy: {e}")
- return quart.redirect(util.as_url(view, name=project.name))
- else:
- log.info(f"policy_form.errors: {policy_form.errors}")
- else:
- log.info(f"Unknown form data: {form_data}")
-
- if metadata_form is None:
- metadata_form = await
ProjectMetadataForm.create_form(data={"project_name": project.name})
- if policy_form is None:
- policy_form = await _policy_form_create(project)
- candidate_drafts = await interaction.candidate_drafts(project)
- candidates = await interaction.candidates(project)
- previews = await interaction.previews(project)
- full_releases = await interaction.full_releases(project)
-
- return await template.render(
- "project-view.html",
- project=project,
- algorithms=shared.algorithms,
- candidate_drafts=candidate_drafts,
- candidates=candidates,
- previews=previews,
- full_releases=full_releases,
- number_of_release_files=util.number_of_release_files,
- now=datetime.datetime.now(datetime.UTC),
- empty_form=await forms.Empty.create_form(),
- policy_form=policy_form,
- can_edit=can_edit,
- metadata_form=metadata_form,
- forbidden_categories=registry.FORBIDDEN_PROJECT_CATEGORIES,
- )
-
-
-async def _metadata_category_add(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project,
category_to_add: str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.category_add(project,
category_to_add.strip())
- except storage.AccessError as e:
- await quart.flash(f"Error adding category: {e}", "error")
- if modified:
- await quart.flash(f"Category '{category_to_add}' added.", "success")
- else:
- await quart.flash(f"Category '{category_to_add}' already exists.",
"error")
- return modified
-
-
-async def _metadata_category_remove(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.category_remove(project, action_value)
- except storage.AccessError as e:
- await quart.flash(f"Error removing category: {e}", "error")
- if modified:
- await quart.flash(f"Category '{action_value}' removed.", "success")
- else:
- await quart.flash(f"Category '{action_value}' does not exist.",
"error")
- return modified
-
-
-async def _metadata_edit(
- session: route.CommitterSession, project: sql.Project, form_data:
dict[str, str]
-) -> tuple[bool, ProjectMetadataForm]:
- metadata_form = await ProjectMetadataForm.create_form(data=form_data)
-
- validated = await metadata_form.validate_on_submit()
- if not validated:
- return False, metadata_form
-
- form_data = await quart.request.form
- action_full = form_data.get("action", "")
- action_type = ""
- action_value = ""
- if ":" in action_full:
- action_type, action_value = action_full.split(":", 1)
- else:
- action_type = action_full
-
- # TODO: Add error handling
- modified = False
- category_to_add = metadata_form.category_to_add.data
- language_to_add = metadata_form.language_to_add.data
-
- async with storage.write(session) as write:
- wacm = await write.as_project_committee_member(project.name)
-
- if (action_type == "add_category") and category_to_add:
- modified = await _metadata_category_add(wacm, project,
category_to_add)
- elif (action_type == "remove_category") and action_value:
- modified = await _metadata_category_remove(wacm, project,
action_value)
- elif (action_type == "add_language") and language_to_add:
- modified = await _metadata_language_add(wacm, project,
language_to_add)
- elif (action_type == "remove_language") and action_value:
- modified = await _metadata_language_remove(wacm, project,
action_value)
-
- return modified, metadata_form
-
-
-async def _metadata_language_add(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project,
language_to_add: str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.language_add(project, language_to_add)
- except storage.AccessError as e:
- await quart.flash(f"Error adding language: {e}", "error")
- if modified:
- await quart.flash(f"Language '{language_to_add}' added.", "success")
- else:
- await quart.flash(f"Language '{language_to_add}' already exists.",
"error")
- return modified
-
-
-async def _metadata_language_remove(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.language_remove(project, action_value)
- except storage.AccessError as e:
- await quart.flash(f"Error removing language: {e}", "error")
- if modified:
- await quart.flash(f"Language '{action_value}' removed.", "success")
- else:
- await quart.flash(f"Language '{action_value}' does not exist.",
"error")
- return modified
-
-
-async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
- # TODO: Use form order for all of these fields
- policy_form = await ReleasePolicyForm.create_form()
- policy_form.project_name.data = project.name
- if project.policy_mailto_addresses:
- policy_form.mailto_addresses.data = project.policy_mailto_addresses[0]
- else:
- policy_form.mailto_addresses.data = f"dev@{project.name}.apache.org"
- policy_form.min_hours.data = project.policy_min_hours
- policy_form.manual_vote.data = project.policy_manual_vote
- policy_form.release_checklist.data = project.policy_release_checklist
- policy_form.start_vote_template.data = project.policy_start_vote_template
- policy_form.announce_release_template.data =
project.policy_announce_release_template
- policy_form.binary_artifact_paths.data =
"\n".join(project.policy_binary_artifact_paths)
- policy_form.source_artifact_paths.data =
"\n".join(project.policy_source_artifact_paths)
- policy_form.pause_for_rm.data = project.policy_pause_for_rm
- policy_form.strict_checking.data = project.policy_strict_checking
- policy_form.github_repository_name.data =
project.policy_github_repository_name
- policy_form.github_compose_workflow_path.data =
"\n".join(project.policy_github_compose_workflow_path)
- policy_form.github_vote_workflow_path.data =
"\n".join(project.policy_github_vote_workflow_path)
- policy_form.github_finish_workflow_path.data =
"\n".join(project.policy_github_finish_workflow_path)
- policy_form.preserve_download_files.data =
project.policy_preserve_download_files
-
- # Set the hashes and value of the current defaults
- policy_form.default_start_vote_template_hash.data = util.compute_sha3_256(
- project.policy_start_vote_default.encode()
- )
- policy_form.default_announce_release_template_hash.data =
util.compute_sha3_256(
- project.policy_announce_release_default.encode()
- )
- policy_form.default_min_hours_value_at_render.data =
str(project.policy_default_min_hours)
- return policy_form
-
-
-async def _project_add(form: AddForm, session: route.CommitterSession) ->
response.Response:
- form_values = await _project_add_validate(form)
- if form_values is None:
- return quart.redirect(util.as_url(add_project,
committee_name=form.committee_name.data))
- committee_name, display_name, label = form_values
-
- async with storage.write(session) as write:
- wacm = await write.as_project_committee_member(committee_name)
- try:
- await wacm.project.create(committee_name, display_name, label)
- except storage.AccessError as e:
- await quart.flash(f"Error adding project: {e}", "error")
- return quart.redirect(util.as_url(add_project,
committee_name=committee_name))
-
- return quart.redirect(util.as_url(view, name=label))
-
-
-async def _project_add_validate(form: AddForm) -> tuple[str, str, str] | None:
- committee_name = str(form.committee_name.data)
- # Normalise spaces in the display name, then validate
- display_name = str(form.display_name.data).strip()
- display_name = re.sub(r" +", " ", display_name)
- if not await _project_add_validate_display_name(display_name):
- return None
- # Hidden criterion!
- # $ sqlite3 state/atr.db 'select full_name from project;' | grep --
'[^A-Za-z0-9 ]'
- # Apache .NET Ant Library
- # Apache Oltu - Parent
- # Apache Commons Chain (Dormant)
- # Apache Commons Functor (Dormant)
- # Apache Commons OGNL (Dormant)
- # Apache Commons Proxy (Dormant)
- # Apache Empire-db
- # Apache mod_ftp
- # Apache Lucene.Net
- # Apache mod_perl
- # Apache Xalan for C++ XSLT Processor
- # Apache Xerces for C++ XML Parser
- if not display_name.replace(" ", "").replace(".", "").replace("+",
"").isalnum():
- await quart.flash("Display name must be alphanumeric and may include
spaces or dots or plus signs", "error")
- return None
-
- label = str(form.label.data).strip()
- if not (label.startswith(committee_name + "-") or (label ==
committee_name)):
- await quart.flash(f"Label must start with '{committee_name}-'",
"error")
- return None
- if not label.islower():
- await quart.flash("Label must be all lower case", "error")
- return None
- # Hidden criterion!
- if not label.replace("-", "").isalnum():
- await quart.flash("Label must be alphanumeric and may include
hyphens", "error")
- return None
-
- return (committee_name, display_name, label)
-
-
-async def _project_add_validate_display_name(display_name: str) -> bool:
- # We have three criteria for display names
- must_start_apache = "The first display name word must be 'Apache'."
- must_have_two_words = "The display name must have at least two words."
- must_use_correct_case = "Display name words must be in PascalCase,
camelCase, or mod_ case."
-
- # First criterion, the first word must be "Apache"
- display_name_words = display_name.split(" ")
- if display_name_words[0] != "Apache":
- await quart.flash(must_start_apache, "error")
- return False
-
- # Second criterion, the display name must have two or more words
- if not display_name_words[1:]:
- await quart.flash(must_have_two_words, "error")
- return False
-
- # Third criterion, the display name must use the correct case
- allowed_irregular_words = {".NET", "C++", "Empire-db", "Lucene.NET",
"for", "jclouds"}
- r_pascal_case = re.compile(r"^([A-Z][0-9a-z]*)+$")
- r_camel_case = re.compile(r"^[a-z]*([A-Z][0-9a-z]*)+$")
- r_mod_case = re.compile(r"^mod(_[0-9a-z]+)+$")
- for display_name_word in display_name_words[1:]:
- if display_name_word in allowed_irregular_words:
- continue
- is_pascal_case = r_pascal_case.match(display_name_word)
- is_camel_case = r_camel_case.match(display_name_word)
- is_mod_case = r_mod_case.match(display_name_word)
- if not (is_pascal_case or is_camel_case or is_mod_case):
- await quart.flash(must_use_correct_case, "error")
- return False
- return True
diff --git a/atr/sbom/__init__.py b/atr/sbom/__init__.py
index 8326a4d..b560995 100644
--- a/atr/sbom/__init__.py
+++ b/atr/sbom/__init__.py
@@ -15,8 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
from . import cli, conformance, constants, cyclonedx, licenses, maven, models,
osv, sbomqs, spdx, utilities
__all__ = [
diff --git a/atr/sbom/__main__.py b/atr/sbom/__main__.py
index 03d908d..3f1c454 100644
--- a/atr/sbom/__main__.py
+++ b/atr/sbom/__main__.py
@@ -15,8 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
from . import cli
if __name__ == "__main__":
diff --git a/atr/sbom/cli.py b/atr/sbom/cli.py
index cd80cab..4c8721f 100644
--- a/atr/sbom/cli.py
+++ b/atr/sbom/cli.py
@@ -15,8 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
import asyncio
import pathlib
import sys
diff --git a/atr/server.py b/atr/server.py
index 54c3808..1129ed6 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -129,10 +129,10 @@ def app_setup_context(app: base.QuartApp) -> None:
async def app_wide() -> dict[str, Any]:
import atr.admin as admin
import atr.get as get
+ import atr.mapping as mapping
import atr.metadata as metadata
import atr.post as post
import atr.routes as routes
- import atr.routes.mapping as mapping
return {
"admin": admin,
diff --git a/atr/shared/finish.py b/atr/shared/finish.py
index 9cc227f..607ce98 100644
--- a/atr/shared/finish.py
+++ b/atr/shared/finish.py
@@ -33,9 +33,8 @@ import atr.analysis as analysis
import atr.db as db
import atr.forms as forms
import atr.log as log
+import atr.mapping as mapping
import atr.models.sql as sql
-import atr.routes.mapping as mapping
-import atr.routes.root as root
import atr.storage as storage
import atr.template as template
import atr.util as util
@@ -137,6 +136,8 @@ async def selected(
try:
source_files_rel, target_dirs = await
_sources_and_targets(latest_revision_dir)
except FileNotFoundError:
+ import atr.routes.root as root
+
await quart.flash("Preview revision directory not found.", "error")
return await session.redirect(root.index)
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index b8243d7..8c98552 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
import atr.storage.writers.announce as announce
import atr.storage.writers.cache as cache
import atr.storage.writers.checks as checks
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 0f3abcd..9506aef 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -27,7 +27,6 @@ import aiofiles.os
import aioshutil
import sqlmodel
-import atr.construct as construct
import atr.db as db
import atr.models.sql as sql
import atr.storage as storage
@@ -112,6 +111,8 @@ class CommitteeMember(CommitteeParticipant):
asf_uid: str,
fullname: str,
) -> None:
+ import atr.construct as construct
+
if recipient not in util.permitted_announce_recipients(asf_uid):
raise storage.AccessError(f"You are not permitted to send
announcements to {recipient}")
diff --git a/atr/templates/file-selected-path.html
b/atr/templates/file-selected-path.html
index 38d56ea..6e27df7 100644
--- a/atr/templates/file-selected-path.html
+++ b/atr/templates/file-selected-path.html
@@ -17,7 +17,7 @@
{% elif phase_key == "preview" %}
{% set back_url = as_url(get.preview.view,
project_name=release.project.name, version_name=release.version) %}
{% elif phase_key == "release" %}
- {% set back_url = as_url(routes.release.view,
project_name=release.project.name, version_name=release.version) %}
+ {% set back_url = as_url(get.release.view,
project_name=release.project.name, version_name=release.version) %}
{% endif %}
<a href="{{ back_url }}" class="atr-back-link">← Back to {{ phase_key }}</a>
diff --git a/atr/templates/includes/sidebar.html
b/atr/templates/includes/sidebar.html
index 210465c..882bd8a 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -38,7 +38,7 @@
{% if current_user %}
<li>
<i class="bi bi-view-list"></i>
- <a href="{{ as_url(routes.release.releases) }}">Browse catalog</a>
+ <a href="{{ as_url(get.release.releases) }}">Browse catalog</a>
</li>
{% endif %}
<li>
diff --git a/atr/templates/index-committer.html
b/atr/templates/index-committer.html
index 7bcbb94..74e7138 100644
--- a/atr/templates/index-committer.html
+++ b/atr/templates/index-committer.html
@@ -94,7 +94,7 @@
class="text-decoration-none me-2">Create a sibling project</a>
{% if completed_releases %}
<span class="text-muted me-2">/</span>
- <a href="{{ as_url(routes.release.finished,
project_name=project.name) }}"
+ <a href="{{ as_url(get.release.finished,
project_name=project.name) }}"
class="text-decoration-none">Finished releases</a>
{% endif %}
</p>
diff --git a/atr/templates/phase-view.html b/atr/templates/phase-view.html
index 375e785..946a676 100644
--- a/atr/templates/phase-view.html
+++ b/atr/templates/phase-view.html
@@ -45,7 +45,7 @@
<span class="atr-phase-three atr-phase-label">FINISH</span>
</span>
{% else %}
- <a href="{{ as_url(routes.release.releases) }}" class="atr-back-link">←
Back to Releases</a>
+ <a href="{{ as_url(get.release.releases) }}" class="atr-back-link">←
Back to Releases</a>
{% endif %}
</p>
@@ -105,7 +105,7 @@
{% elif phase_key == "preview" %}
{% set file_url = as_url(get.preview.view_path,
project_name=release.project.name, version_name=release.version,
file_path=stat.path) %}
{% elif phase_key == "release" %}
- {% set file_url = as_url(routes.release.view_path,
project_name=release.project.name, version_name=release.version,
file_path=stat.path) %}
+ {% set file_url = as_url(get.release.view_path,
project_name=release.project.name, version_name=release.version,
file_path=stat.path) %}
{% else %}
{# TODO: Should probably disable the link here #}
{% set file_url = "#" %}
diff --git a/atr/templates/project-select.html
b/atr/templates/project-select.html
index 63abb38..5830db9 100644
--- a/atr/templates/project-select.html
+++ b/atr/templates/project-select.html
@@ -11,11 +11,11 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for project in user_projects %}
<div class="col">
- <a href="{{ as_url(routes.release.select, project_name=project.name)
}}"
+ <a href="{{ as_url(get.release.select, project_name=project.name) }}"
class="text-decoration-none">
{# TODO: We're only setting data-project-url for style #}
<div class="card h-100 shadow-sm atr-cursor-pointer"
- data-project-url="{{ as_url(routes.release.select,
project_name=project.name) }}">
+ data-project-url="{{ as_url(get.release.select,
project_name=project.name) }}">
<div class="card-body">
<h3 class="card-title fs-5 mb-2">{{ project.display_name
}}</h3>
<h4 class="card-subtitle mb-2 text-muted fs-6">{{ project.name
}}</h4>
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 633153f..a57a426 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -472,7 +472,7 @@
<h2>Full releases</h2>
<div class="d-flex flex-wrap gap-2 mb-4">
{% for release in full_releases %}
- <a href="{{ as_url(routes.release.view, project_name=project.name,
version_name=release.version) }}"
+ <a href="{{ as_url(get.release.view, project_name=project.name,
version_name=release.version) }}"
class="btn btn-sm btn-outline-success py-2 px-3"
title="View release {{ project.name }} {{ release.version }}">
{{ project.name }} {{ release.version }}
diff --git a/atr/templates/releases-finished.html
b/atr/templates/releases-finished.html
index 606faa1..6841d74 100644
--- a/atr/templates/releases-finished.html
+++ b/atr/templates/releases-finished.html
@@ -29,7 +29,7 @@
class="btn btn-outline-primary w-100 mb-2">
<i class="bi bi-download me-1"></i> Download files
</a>
- <a href="{{ as_url(routes.release.view,
project_name=release.project.name, version_name=release.version) }}"
+ <a href="{{ as_url(get.release.view,
project_name=release.project.name, version_name=release.version) }}"
class="btn btn-outline-secondary w-100">
<i class="bi bi-folder2-open me-1"></i> View files
</a>
diff --git a/atr/templates/releases.html b/atr/templates/releases.html
index 86ea1cb..2a09235 100644
--- a/atr/templates/releases.html
+++ b/atr/templates/releases.html
@@ -33,7 +33,7 @@
<div class="card-body">
<h3 class="card-title mb-3">{{ project_display_name }}</h3>
<p class="card-text">
- <a href="{{ as_url(routes.release.finished,
project_name=project.name) }}"
+ <a href="{{ as_url(get.release.finished,
project_name=project.name) }}"
class="btn btn-outline-primary">
<i class="bi bi-folder2-open me-1"></i> Finished releases
(<code>{{ count }}</code>)
</a>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]