You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/05/07 15:52:15 UTC
(tooling-trusted-releases) 01/01: #912/#914 - cycle and version-scheme editing, release events on announce
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch project_cycle_model
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 4554cc562e93f1569b4cdd9f695da8cf66660536
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu May 7 16:51:55 2026 +0100
#912/#914 - cycle and version-scheme editing, release events on announce
---
atr/form.py | 26 ++++
atr/get/projects.py | 232 +++++++++++++++++++++++++++++----
atr/htm.py | 50 +++++++-
atr/post/projects.py | 88 +++++++++----
atr/shared/projects.py | 50 ++++++++
atr/storage/writers/announce.py | 16 ++-
atr/storage/writers/policy.py | 112 ++++++++++++++++
atr/storage/writers/release.py | 8 ++
tests/unit/test_policy_writer.py | 270 +++++++++++++++++++++++++++++++++++++++
9 files changed, 799 insertions(+), 53 deletions(-)
diff --git a/atr/form.py b/atr/form.py
index 06faf8c3..77f87695 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -17,6 +17,7 @@
from __future__ import annotations
+import datetime
import enum
import pathlib
import re
@@ -63,6 +64,7 @@ class Widget(enum.Enum):
CHECKBOX = "checkbox"
CHECKBOXES = "checkboxes"
CUSTOM = "custom"
+ DATE = "date"
EMAIL = "email"
FILE = "file"
FILES = "files"
@@ -499,6 +501,12 @@ Bool = Annotated[
pydantic.Field(default=False),
]
+OptionalDate = Annotated[
+ datetime.date | None,
+ functional_validators.BeforeValidator(lambda v: None if v == "" else v),
+ pydantic.Field(default=None),
+]
+
Email = pydantic.EmailStr
@@ -713,6 +721,9 @@ def _get_widget_type(field_info: pydantic.fields.FieldInfo) -> Widget: # noqa:
if annotation is bool:
return Widget.CHECKBOX
+ if annotation is datetime.date:
+ return Widget.DATE
+
if annotation is pydantic.EmailStr:
return Widget.EMAIL
@@ -810,6 +821,12 @@ def _render_field_value(
field_value = flash_error_data[field_name]["original"]
elif has_flash_data:
field_value = flash_error_data[f"!{field_name}"]["original"]
+ elif flash_error_data and (_get_widget_type(field_info) == Widget.CHECKBOX):
+ # An unticked checkbox is absent from form data, so doesn't appear in
+ # flash_error_data either. In flash-rerender mode treat as unticked
+ # rather than falling back to the default - otherwise the checkbox
+ # appears ticked even though the user submitted it unticked.
+ field_value = False
elif defaults:
field_value = defaults.get(field_name)
elif not field_info.is_required():
@@ -971,6 +988,15 @@ def _render_widget( # noqa: C901
else:
widget = htm.div[f"Custom widget for {field_name} not provided"]
+ case Widget.DATE:
+ attrs = {**base_attrs, "type": "date"}
+ if isinstance(field_value, datetime.date):
+ attrs["value"] = field_value.isoformat()
+ elif field_value:
+ # Already a string (eg from form-flash data round-tripped via JSON).
+ attrs["value"] = str(field_value)
+ widget = htpy.input(**attrs)
+
case Widget.EMAIL:
attrs = {**base_attrs, "type": "email"}
if field_value:
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 6c219a7e..d6be4099 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -21,6 +21,7 @@ from typing import Literal
import asfquart.base as base
import htpy
+import quart
import strictyaml
import atr.blueprints.get as get
@@ -158,8 +159,9 @@ async def view(
project = await data.project(
key=str(project_key), _committee=True, _committee_public_signing_keys=True, _release_policy=True
).demand(base.ASFQuartException(f"Project {project_key} not found", errorcode=404))
+ cycles = list(await data.project_cycle(project_key=str(project_key)).all())
- is_committee_member = project.committee and (user.is_committee_member(project.committee, session.uid))
+ is_committee_member = bool(project.committee and user.is_committee_member(project.committee, session.uid))
is_privileged = session.is_admin
can_edit = is_committee_member or is_privileged
@@ -186,33 +188,59 @@ async def view(
else "",
]
page.append(title_row)
-
- page.p(".mb-4")[
- htm.a(".btn.btn-sm.btn-outline-primary", href=util.as_url(start.selected, project_key=str(project.key)))[
- "Start a new release"
- ]
- ]
-
page.append(_render_project_label_card(project))
page.append(_render_pmc_card(project))
- page.append(_render_description_card(project))
+
+ tab_items: list[htm.Tab] = []
+
+ if is_committee_member or is_privileged:
+ tab_items.append(
+ htm.Tab(
+ key="releases",
+ label="Releases",
+ render=lambda: _render_releases_tab(
+ project,
+ cycles,
+ candidate_drafts,
+ candidates,
+ previews,
+ full_releases,
+ can_edit=can_edit,
+ ),
+ )
+ )
+
+ tab_items.append(
+ htm.Tab(
+ key="metadata",
+ label="Metadata",
+ render=lambda: _render_metadata_tab(project, can_edit=can_edit),
+ )
+ )
if project.status == sql.ProjectStatus.ACTIVE:
if can_edit:
- page.append(await _render_compose_form(project))
- page.append(await _render_vote_form(project))
- page.append(await _render_finish_form(project))
- page.append(await _render_trusted_publishing_form(project))
+ tab_items.extend(
+ [
+ htm.Tab("compose", "Compose", lambda: _render_compose_form(project)),
+ htm.Tab("vote", "Vote", lambda: _render_vote_form(project)),
+ htm.Tab("finish", "Finish", lambda: _render_finish_form(project)),
+ htm.Tab(
+ "trusted-publishing",
+ "Trusted publishing",
+ lambda: _render_trusted_publishing_form(project),
+ ),
+ ]
+ )
else:
- page.append(_render_policy_readonly(project))
+ tab_items.append(htm.Tab("policy", "Policy", lambda: _render_policy_readonly(project)))
- if can_edit:
- page.append(_render_categories_section(project))
- page.append(_render_languages_section(project))
+ active_tab = quart.request.args.get("tab", tab_items[0].key)
+ base_url = util.as_url(view, project_key=str(project.key))
+ page.append(await htm.tabs(tab_items, active_key=active_tab, base_url=base_url))
+ # Below the tabs: admin-only project actions.
if is_committee_member or is_privileged:
- page.append(await _render_releases_sections(project, candidate_drafts, candidates, previews, full_releases))
-
if project.created_by == session.uid:
page.append(await _render_delete_section(project))
@@ -236,6 +264,10 @@ async def view(
)
+def _cycle_has_dates(cycle: sql.ProjectCycle) -> bool:
+ return any(getattr(cycle, attr) is not None for attr in ("start", "begin", "latest", "eod", "eos", "eol"))
+
+
def _input_with_variables(
field_name: str,
default_value: str,
@@ -367,6 +399,72 @@ async def _render_compose_form(project: sql.Project) -> htm.Element:
return card.collect()
+def _cycle_display_name(cycle: sql.ProjectCycle) -> str:
+ # The "default" cycle is what every project gets when it has no cycle_match
+ # set, so showing the literal "default" in headings is jargon. Render it as
+ # "No cycle" instead; named cycles render as their name.
+ return "No cycle" if cycle.cycle == "default" else f"Cycle {cycle.cycle}"
+
+
+def _render_cycle_dates_card(cycle: sql.ProjectCycle) -> htm.Element:
+ card = htm.Block(htm.details, classes=".card.mb-4")
+ card.summary(".card-header.bg-light")[htm.h3(".mb-0.d-inline-block")["Cycle dates"]]
+ rows = []
+ # Order matches the rough lifecycle reading: when did things start, when
+ # do they wind down, and is this an LTS line.
+ # Labels match the column semantics in #912.
+ date_fields: list[tuple[str, str]] = [
+ ("First release candidate", "begin"),
+ ("First release", "start"),
+ ("Latest release", "latest"),
+ ("End of development", "eod"),
+ ("End of support", "eos"),
+ ("End of life", "eol"),
+ ]
+ for label, attr in date_fields:
+ value = getattr(cycle, attr)
+ value_cell = (
+ htm.td[value.strftime("%Y-%m-%d")] if value is not None else htm.td(".text-muted.fst-italic")["Not set"]
+ )
+ rows.append(
+ htm.tr[
+ htm.td(".pe-3.text-muted")[label],
+ value_cell,
+ ]
+ )
+ if cycle.lts:
+ rows.append(
+ htm.tr[
+ htm.td(".pe-3.text-muted")["Long-term support"],
+ htm.td[htm.span(".badge.text-bg-info")["LTS"]],
+ ]
+ )
+ with card.block(htm.div, classes=".card-body") as body:
+ body.append(htm.table(".table.table-sm.mb-0")[*rows])
+ return card.collect()
+
+
+async def _render_cycle_dates_form(project: sql.Project, cycle: sql.ProjectCycle) -> htm.Element:
+ card = htm.Block(htm.details, classes=".card.mb-4")
+ card.summary(".card-header.bg-light")[htm.h3(".mb-0.d-inline-block")["Cycle dates"]]
+ with card.block(htm.div, classes=".card-body") as body:
+ await form.render_block(
+ body,
+ model_cls=shared.projects.EditCycleDatesForm,
+ action=util.as_url(post.projects.view, name=str(project.key)),
+ submit_label="Save cycle dates",
+ defaults={
+ "project_key": str(project.key),
+ "cycle_key": cycle.cycle_key,
+ "eod": cycle.eod.date() if cycle.eod is not None else None,
+ "eos": cycle.eos.date() if cycle.eos is not None else None,
+ "eol": cycle.eol.date() if cycle.eol is not None else None,
+ "lts": cycle.lts,
+ },
+ )
+ return card.collect()
+
+
async def _render_delete_section(project: sql.Project) -> htm.Element:
section = htm.Block(htm.div)
section.h2["Actions"]
@@ -480,6 +578,16 @@ def _render_languages_section(project: sql.Project) -> htm.Element:
return card.collect()
+async def _render_metadata_tab(project: sql.Project, *, can_edit: bool) -> htm.Element:
+ block = htm.Block()
+ block.append(_render_description_card(project))
+ if can_edit:
+ block.append(await _render_version_scheme_form(project))
+ block.append(_render_categories_section(project))
+ block.append(_render_languages_section(project))
+ return block.collect()
+
+
def _render_pmc_card(project: sql.Project) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light")[htm.h3(".mb-2")["PMC"]]
@@ -535,11 +643,17 @@ async def _render_releases_sections(
candidates: list[sql.Release],
previews: list[sql.Release],
full_releases: list[sql.Release],
+ *,
+ nested: bool = False,
) -> htm.Element:
+ # When `nested` is true the section sits beneath a cycle heading (h2), so
+ # we demote the section titles to h3. Otherwise keep them as h2 so the
+ # tab content has top-level structure.
+ heading = htm.h3 if nested else htm.h2
sections = htm.Block(htm.div)
if candidate_drafts:
- sections.h2["Draft candidate releases"]
+ sections.append(heading["Draft candidate releases"])
draft_buttons = []
for drf in candidate_drafts:
file_count = await util.number_of_release_files(drf)
@@ -556,7 +670,7 @@ async def _render_releases_sections(
sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*draft_buttons]
if candidates:
- sections.h2["Candidate releases"]
+ sections.append(heading["Candidate releases"])
candidate_buttons = []
for cnd in candidates:
file_count = await util.number_of_release_files(cnd)
@@ -573,7 +687,7 @@ async def _render_releases_sections(
sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*candidate_buttons]
if previews:
- sections.h2["Preview releases"]
+ sections.append(heading["Preview releases"])
preview_buttons = []
for prv in previews:
file_count = await util.number_of_release_files(prv)
@@ -590,7 +704,7 @@ async def _render_releases_sections(
sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*preview_buttons]
if full_releases:
- sections.h2["Full releases"]
+ sections.append(heading["Full releases"])
release_buttons = []
for rel in full_releases:
file_count = await util.number_of_release_files(rel)
@@ -609,6 +723,58 @@ async def _render_releases_sections(
return sections.collect()
+async def _render_releases_tab(
+ project: sql.Project,
+ cycles: list[sql.ProjectCycle],
+ candidate_drafts: list[sql.Release],
+ candidates: list[sql.Release],
+ previews: list[sql.Release],
+ full_releases: list[sql.Release],
+ *,
+ can_edit: bool,
+) -> htm.Element:
+ block = htm.Block()
+ block.p(".mb-4")[
+ htm.a(
+ ".btn.btn-sm.btn-outline-primary",
+ href=util.as_url(start.selected, project_key=str(project.key)),
+ )["Start a new release"]
+ ]
+
+ # Stay flat for projects with only the implicit "default" cycle and no
+ # cycle dates set. Once cycles get used or dates get filled in, headings
+ # surface automatically. The card / form surfaces whenever can_edit, so a
+ # PMC of a simple-default project can still set eod/eos/eol/lts.
+ show_cycle_heading = (len(cycles) > 1) or any(c.cycle != "default" for c in cycles)
+
+ for cycle in cycles:
+ cycle_drafts = [r for r in candidate_drafts if r.cycle_key == cycle.cycle_key]
+ cycle_candidates = [r for r in candidates if r.cycle_key == cycle.cycle_key]
+ cycle_previews = [r for r in previews if r.cycle_key == cycle.cycle_key]
+ cycle_full = [r for r in full_releases if r.cycle_key == cycle.cycle_key]
+
+ cycle_has_dates = _cycle_has_dates(cycle)
+ cycle_has_releases = bool(cycle_drafts or cycle_candidates or cycle_previews or cycle_full)
+ if not (cycle_has_dates or cycle_has_releases or show_cycle_heading or can_edit):
+ continue
+
+ if show_cycle_heading:
+ block.h2(".mt-4.mb-3")[_cycle_display_name(cycle)]
+
+ if can_edit:
+ block.append(await _render_cycle_dates_form(project, cycle))
+ elif cycle_has_dates:
+ block.append(_render_cycle_dates_card(cycle))
+
+ block.append(
+ await _render_releases_sections(
+ project, cycle_drafts, cycle_candidates, cycle_previews, cycle_full, nested=show_cycle_heading
+ )
+ )
+
+ return block.collect()
+
+
async def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
@@ -636,6 +802,26 @@ async def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
return card.collect()
+async def _render_version_scheme_form(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-0")["Version scheme"]]
+ with card.block(htm.div, classes=".card-body") as body:
+ await form.render_block(
+ body,
+ model_cls=shared.projects.EditVersionSchemeForm,
+ action=util.as_url(post.projects.view, name=str(project.key)),
+ submit_label="Save",
+ defaults={
+ "project_key": str(project.key),
+ "version_method": project.version_method,
+ "version_pattern": project.version_pattern or "",
+ "cycle_match": project.cycle_match or "",
+ "branch_template": project.branch_template or "",
+ },
+ )
+ return card.collect()
+
+
async def _render_vote_form(project: sql.Project) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
diff --git a/atr/htm.py b/atr/htm.py
index f8355cd3..6a559dcb 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -18,6 +18,8 @@
from __future__ import annotations
import contextlib
+import dataclasses
+import inspect
from typing import TYPE_CHECKING, Any
import htpy
@@ -26,7 +28,7 @@ import markupsafe
from . import log
if TYPE_CHECKING:
- from collections.abc import Callable, Generator
+ from collections.abc import Awaitable, Callable, Generator
type Element = htpy.Element
type VoidElement = htpy.VoidElement
@@ -288,3 +290,49 @@ def icon(name: str, classes="") -> Element:
def ul_links(*items: tuple[str, str]) -> Element:
li_items = [li[a(href=item[0])[item[1]]] for item in items]
return ul[*li_items]
+
+
+@dataclasses.dataclass(frozen=True)
+class Tab:
+ """One tab in a tab strip rendered by `tabs()`.
+
+ `render` is a thunk - sync or async - returning the tab's body. Only
+ the active tab's render is invoked, so inactive tabs are cheap.
+ """
+
+ key: str
+ label: str
+ render: Callable[[], Element | Awaitable[Element]]
+
+
+async def tabs(items: list[Tab], *, active_key: str, base_url: str) -> Element:
+ """Render a Bootstrap nav-tabs strip plus the active tab's content.
+
+ Tab navigation is via full page load: each tab link targets `base_url`
+ with `?tab=<key>` appended, so every tab is its own bookmarkable and
+ history-tracked URL. No client-side JS is used.
+
+ If `active_key` doesn't match any tab the first item is rendered as a
+ fallback.
+ """
+ if not items:
+ raise ValueError("tabs() requires at least one tab")
+ if not any(item.key == active_key for item in items):
+ active_key = items[0].key
+
+ nav_items = []
+ for item in items:
+ link_classes = ".nav-link.active" if item.key == active_key else ".nav-link"
+ nav_items.append(
+ li(".nav-item", role="presentation")[
+ a(link_classes, href=f"{base_url}?tab={item.key}", role="tab")[item.label]
+ ]
+ )
+ nav = ul(".nav.nav-tabs.mb-3", role="tablist")[*nav_items]
+
+ active_item = next(item for item in items if item.key == active_key)
+ content = active_item.render()
+ if inspect.isawaitable(content):
+ content = await content
+
+ return div[nav, content]
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 505f9da1..edc5c297 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -17,7 +17,7 @@
from __future__ import annotations
-from typing import Literal
+from typing import TYPE_CHECKING, Any, Final, Literal
import quart
@@ -29,6 +29,9 @@ import atr.shared as shared
import atr.storage as storage
import atr.web as web
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+
@post.typed
async def add_project(
@@ -94,33 +97,7 @@ async def view(
"""
URL: /projects/<name>
"""
- match project_form:
- case shared.projects.AddCategoryForm() as add_category_form:
- return await _process_add_category(session, add_category_form)
-
- case shared.projects.AddLanguageForm() as add_language_form:
- return await _process_add_language(session, add_language_form)
-
- case shared.projects.ComposePolicyForm() as compose_form:
- return await _process_compose_form(session, compose_form)
-
- case shared.projects.DeleteProjectForm() as delete_form:
- return await _process_delete_project(session, delete_form)
-
- case shared.projects.FinishPolicyForm() as finish_form:
- return await _process_finish_form(session, finish_form)
-
- case shared.projects.TrustedPublishingPolicyForm() as tp_form:
- return await _process_trusted_publishing_form(session, tp_form)
-
- case shared.projects.RemoveCategoryForm() as remove_form:
- return await _process_remove_category(session, remove_form)
-
- case shared.projects.RemoveLanguageForm() as remove_form:
- return await _process_remove_language(session, remove_form)
-
- case shared.projects.VotePolicyForm() as vote_form:
- return await _process_vote_form(session, vote_form)
+ return await _VIEW_HANDLERS[type(project_form)](session, project_form)
async def _metadata_category_add(
@@ -235,6 +212,46 @@ async def _process_delete_project(
return await session.redirect(get.projects.projects, success=f"Project '{project_key}' deleted successfully.")
+async def _process_edit_cycle_dates_form(
+ session: web.Committer, edit_form: shared.projects.EditCycleDatesForm
+) -> web.WerkzeugResponse:
+ project_key = edit_form.project_key
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_key)
+ try:
+ await wacm.policy.edit_cycle_dates(edit_form)
+ except storage.AccessError as e:
+ return await session.redirect(
+ get.projects.view, project_key=str(project_key), error=f"Error saving cycle dates: {e}"
+ )
+ except ValueError as e:
+ return await session.redirect(get.projects.view, project_key=str(project_key), error=str(e))
+
+ return await session.redirect(get.projects.view, project_key=str(project_key), success="Cycle dates saved.")
+
+
+async def _process_edit_version_scheme_form(
+ session: web.Committer, edit_form: shared.projects.EditVersionSchemeForm
+) -> web.WerkzeugResponse:
+ project_key = edit_form.project_key
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_key)
+ try:
+ await wacm.policy.edit_version_scheme(edit_form)
+ except storage.AccessError as e:
+ return await session.redirect(
+ get.projects.view,
+ project_key=str(project_key),
+ error=f"Error saving version scheme: {e}",
+ )
+ except ValueError as e:
+ return await session.redirect(get.projects.view, project_key=str(project_key), error=str(e))
+
+ return await session.redirect(get.projects.view, project_key=str(project_key), success="Version scheme saved.")
+
+
async def _process_finish_form(
session: web.Committer, finish_form: shared.projects.FinishPolicyForm
) -> web.WerkzeugResponse:
@@ -326,3 +343,18 @@ async def _process_vote_form(session: web.Committer, vote_form: shared.projects.
return await session.redirect(
get.projects.view, project_key=project_key, success="Vote options saved successfully."
)
+
+
+_VIEW_HANDLERS: Final[dict[type, Callable[[web.Committer, Any], Awaitable[web.WerkzeugResponse]]]] = {
+ shared.projects.AddCategoryForm: _process_add_category,
+ shared.projects.AddLanguageForm: _process_add_language,
+ shared.projects.ComposePolicyForm: _process_compose_form,
+ shared.projects.DeleteProjectForm: _process_delete_project,
+ shared.projects.EditCycleDatesForm: _process_edit_cycle_dates_form,
+ shared.projects.EditVersionSchemeForm: _process_edit_version_scheme_form,
+ shared.projects.FinishPolicyForm: _process_finish_form,
+ shared.projects.TrustedPublishingPolicyForm: _process_trusted_publishing_form,
+ shared.projects.RemoveCategoryForm: _process_remove_category,
+ shared.projects.RemoveLanguageForm: _process_remove_language,
+ shared.projects.VotePolicyForm: _process_vote_form,
+}
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index cc4576f4..04b2d851 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -29,6 +29,8 @@ import atr.models.validation as validation
import atr.util as util
type COMPOSE = Literal["compose"]
+type EDIT_CYCLE_DATES = Literal["edit_cycle_dates"]
+type EDIT_VERSION_SCHEME = Literal["edit_version_scheme"]
type FINISH = Literal["finish"]
type TRUSTED_PUBLISHING = Literal["trusted_publishing"]
type VOTE = Literal["vote"]
@@ -178,6 +180,52 @@ class VotePolicyForm(form.Form):
return self
+class EditCycleDatesForm(form.Form):
+ variant: EDIT_CYCLE_DATES = form.value(EDIT_CYCLE_DATES)
+ project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
+ cycle_key: str = form.label("Cycle key", widget=form.Widget.HIDDEN)
+ eod: form.OptionalDate = form.label(
+ "End of development",
+ "When active development on this cycle is planned to stop.",
+ )
+ eos: form.OptionalDate = form.label(
+ "End of support",
+ "When this cycle is planned to stop receiving support.",
+ )
+ eol: form.OptionalDate = form.label(
+ "End of life",
+ "When this cycle is planned to be retired entirely.",
+ )
+ lts: form.Bool = form.label(
+ "Long-term support",
+ "Mark this cycle as a long-term support line.",
+ )
+
+
+class EditVersionSchemeForm(form.Form):
+ variant: EDIT_VERSION_SCHEME = form.value(EDIT_VERSION_SCHEME)
+ project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
+ version_method: form.Enum[sql.VersionMethod] = form.label(
+ "Version method",
+ "How this project numbers releases. Determines how cycles are derived from versions.",
+ widget=form.Widget.RADIO,
+ )
+ version_pattern: str = form.label(
+ "Version pattern",
+ "Optional regex that release versions must match. Leave empty to accept any version.",
+ )
+ cycle_match: str = form.label(
+ "Cycle match",
+ "Regex applied to the version string; capture group 1 is the cycle name."
+ r" For example, (\d+)\..* maps version 2.0.1 into cycle 2."
+ " Leave empty for projects that keep a single default cycle.",
+ )
+ branch_template: str = form.label(
+ "Branch template",
+ "Optional naming hint for source branches per cycle. Not currently enforced.",
+ )
+
+
class FinishPolicyForm(form.Form):
variant: FINISH = form.value(FINISH)
project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
@@ -280,6 +328,8 @@ class DeleteSelectedProject(form.Form):
type ProjectViewForm = Annotated[
ComposePolicyForm
+ | EditCycleDatesForm
+ | EditVersionSchemeForm
| FinishPolicyForm
| TrustedPublishingPolicyForm
| VotePolicyForm
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 6171e0f1..0abff67f 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -251,6 +251,17 @@ class CommitteeMember(CommitteeParticipant):
self.__data.add(task)
await self.__promote_in_database(release, preview_revision_number, release_date)
+ self.__data.add(
+ sql.LifecycleEvent(
+ project_key=release.project_key,
+ cycle_key=release.cycle_key,
+ version_key=release.key,
+ event=sql.LifecycleEventType.RELEASE,
+ effective=release_date,
+ published=release_date,
+ reference_urls=[f"https://lists.apache.org/list.html?{email_to}"],
+ )
+ )
await self.__data.commit()
except storage.AccessError:
raise
@@ -292,7 +303,10 @@ class CommitteeMember(CommitteeParticipant):
return predicted_finished_release
async def __promote_in_database(
- self, release: sql.Release, preview_revision_number: safe.RevisionNumber, release_date: datetime.datetime
+ self,
+ release: sql.Release,
+ preview_revision_number: safe.RevisionNumber,
+ release_date: datetime.datetime,
) -> None:
"""Promote a release preview to a release and delete its old revisions."""
via = sql.validate_instrumented_attribute
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index 30f47dea..8b63fc8d 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -18,8 +18,11 @@
# Removing this will cause circular imports
from __future__ import annotations
+import datetime
+import re
from typing import TYPE_CHECKING, Any, Final
+import sqlmodel
import strictyaml
import strictyaml.ruamel.error as error
@@ -139,6 +142,80 @@ class CommitteeMember(CommitteeParticipant):
await self.__commit_and_log(str(project_key))
+ async def edit_cycle_dates(self, form: shared.projects.EditCycleDatesForm) -> None:
+ project_key = form.project_key
+ cycle = await self.__data.project_cycle(cycle_key=form.cycle_key).demand(
+ storage.AccessError(f"Cycle {form.cycle_key} not found")
+ )
+ if cycle.project_key != str(project_key):
+ raise storage.AccessError(f"Cycle {form.cycle_key} does not belong to project {project_key}")
+
+ via = models.sql.validate_instrumented_attribute
+ now = datetime.datetime.now(datetime.UTC)
+ # Each entry: form/column attribute name and the matching event type.
+ date_fields: list[tuple[str, models.sql.LifecycleEventType]] = [
+ ("eod", models.sql.LifecycleEventType.EOD),
+ ("eos", models.sql.LifecycleEventType.EOS),
+ ("eol", models.sql.LifecycleEventType.EOL),
+ ]
+ for attr, event_type in date_fields:
+ new_value: datetime.date | None = getattr(form, attr)
+ old_value: datetime.datetime | None = getattr(cycle, attr)
+ old_date = old_value.date() if old_value is not None else None
+ if new_value == old_date:
+ continue
+ if new_value is None:
+ # Forward-only: clearing a once-set date is not supported in v1.
+ raise ValueError(f"{attr} cannot be cleared once set. Set a new date instead.")
+
+ # Changing an existing date pairs a withdraw of the prior event
+ # with the new event, per the lifecycle event design. The cache
+ # rule (most recent of kind X) means the cache picks the new
+ # event regardless, but the withdraw row carries the audit signal
+ # that the prior plan no longer applies.
+ if old_value is not None:
+ prior_event_id = (
+ await self.__data.execute(
+ sqlmodel.select(via(models.sql.LifecycleEvent.id))
+ .where(
+ via(models.sql.LifecycleEvent.cycle_key) == cycle.cycle_key,
+ via(models.sql.LifecycleEvent.event) == event_type,
+ )
+ .order_by(via(models.sql.LifecycleEvent.published).desc())
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if prior_event_id is not None:
+ self.__data.add(
+ models.sql.LifecycleEvent(
+ project_key=cycle.project_key,
+ cycle_key=cycle.cycle_key,
+ version_key=None,
+ event=models.sql.LifecycleEventType.WITHDRAW,
+ effective=now,
+ published=now,
+ target_event_id=prior_event_id,
+ )
+ )
+
+ new_datetime = datetime.datetime.combine(new_value, datetime.time.min, tzinfo=datetime.UTC)
+ setattr(cycle, attr, new_datetime)
+ self.__data.add(
+ models.sql.LifecycleEvent(
+ project_key=cycle.project_key,
+ cycle_key=cycle.cycle_key,
+ version_key=None,
+ event=event_type,
+ effective=new_datetime,
+ published=now,
+ )
+ )
+
+ if cycle.lts != form.lts:
+ cycle.lts = form.lts
+
+ await self.__commit_and_log(str(project_key))
+
async def edit_policy(
self,
project_key: models.safe.ProjectKey,
@@ -193,6 +270,41 @@ class CommitteeMember(CommitteeParticipant):
await self.__commit_and_log(str(project_key))
+ async def edit_version_scheme(self, form: shared.projects.EditVersionSchemeForm) -> None:
+ project_key = form.project_key
+ project = await self.__data.project(key=str(project_key), status=models.sql.ProjectStatus.ACTIVE).demand(
+ storage.AccessError(f"Project {project_key} not found")
+ )
+
+ # Validate cycle_match by trying to compile it. Empty becomes None
+ # so we don't store empty strings in nullable columns.
+ cycle_match = form.cycle_match.strip() or None
+ if cycle_match is not None:
+ try:
+ compiled = re.compile(cycle_match)
+ except re.error as exc:
+ raise ValueError(f"Invalid cycle_match regex: {exc}") from exc
+ if compiled.groups < 1:
+ raise ValueError("cycle_match must contain at least one capture group")
+
+ version_pattern = form.version_pattern.strip() or None
+ if version_pattern is not None:
+ try:
+ re.compile(version_pattern)
+ except re.error as exc:
+ raise ValueError(f"Invalid version_pattern regex: {exc}") from exc
+
+ try:
+ version_method = models.sql.VersionMethod(form.version_method)
+ except ValueError as exc:
+ raise ValueError(f"Unsupported version method: {form.version_method}") from exc
+ project.version_method = version_method
+ project.version_pattern = version_pattern
+ project.cycle_match = cycle_match
+ project.branch_template = form.branch_template.strip() or None
+
+ await self.__commit_and_log(str(project_key))
+
async def edit_vote(self, form: shared.projects.VotePolicyForm) -> None:
project_key = form.project_key
project, release_policy = await self.__get_or_create_policy(project_key)
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index a5664158..06f23800 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -22,6 +22,7 @@ import asyncio
import base64
import contextlib
import datetime
+import re
from typing import TYPE_CHECKING, Any, Final
import aiofiles.os
@@ -657,6 +658,13 @@ class CommitteeParticipant(FoundationCommitter):
# We have the packaging library as a dependency, but it is Python specific
if version_key_error := util.version_key_error(str(version)):
raise storage.AccessError(f'Invalid version name "{version!s}": {version_key_error}', status=400)
+
+ if (project.version_pattern is not None) and (not re.fullmatch(project.version_pattern, str(version))):
+ raise storage.AccessError(
+ f'Version "{version!s}" does not match the project\'s version pattern',
+ status=400,
+ )
+
try:
cycle_name = cycles.cycle_name_for_version(project, str(version))
except ValueError as exc:
diff --git a/tests/unit/test_policy_writer.py b/tests/unit/test_policy_writer.py
new file mode 100644
index 00000000..aaf6c022
--- /dev/null
+++ b/tests/unit/test_policy_writer.py
@@ -0,0 +1,270 @@
+# 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.
+
+import datetime
+import unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.shared.projects as shared_projects
+import atr.storage as storage
+import atr.storage.writers.policy as policy_writer
+
+
+class _Query:
+ def __init__(self, value):
+ self._value = value
+
+ async def get(self):
+ return self._value
+
+ async def demand(self, error):
+ if self._value is None:
+ raise error
+ return self._value
+
+
+class _MockData:
+ def __init__(self, *, project=None, cycle=None, prior_event_id=None):
+ self._project = project
+ self._cycle = cycle
+ self.added: list[object] = []
+ self.commit = mock.AsyncMock()
+ self.execute = mock.AsyncMock()
+ result = mock.MagicMock()
+ result.scalar_one_or_none.return_value = prior_event_id
+ self.execute.return_value = result
+
+ def add(self, item):
+ self.added.append(item)
+
+ def project(self, **_kwargs):
+ return _Query(self._project)
+
+ def project_cycle(self, **_kwargs):
+ return _Query(self._cycle)
+
+
+def _make_committee_member(data):
+ write = mock.MagicMock()
+ write.authorisation.asf_uid = "alice"
+ write_as = mock.MagicMock()
+ return policy_writer.CommitteeMember(write, write_as, data, "alpha")
+
+
+def _cycle(project_key="example", cycle_key="example-default", eod=None, eos=None, eol=None, lts=False):
+ return SimpleNamespace(
+ project_key=project_key,
+ cycle_key=cycle_key,
+ cycle=cycle_key.removeprefix(f"{project_key}-"),
+ eod=eod,
+ eos=eos,
+ eol=eol,
+ lts=lts,
+ )
+
+
+def _project(
+ key="example", version_method=sql.VersionMethod.SIMPLE, version_pattern=None, cycle_match=None, branch_template=None
+):
+ return SimpleNamespace(
+ key=key,
+ version_method=version_method,
+ version_pattern=version_pattern,
+ cycle_match=cycle_match,
+ branch_template=branch_template,
+ )
+
+
+def _cycle_dates_form(*, project_key="example", cycle_key="example-default", eod=None, eos=None, eol=None, lts=False):
+ return shared_projects.EditCycleDatesForm(
+ variant="edit_cycle_dates",
+ csrf_token="test",
+ project_key=safe.ProjectKey(project_key),
+ cycle_key=cycle_key,
+ eod=eod,
+ eos=eos,
+ eol=eol,
+ lts=lts,
+ )
+
+
+def _version_scheme_form(
+ *,
+ project_key="example",
+ version_method=sql.VersionMethod.SIMPLE,
+ version_pattern="",
+ cycle_match="",
+ branch_template="",
+):
+ return shared_projects.EditVersionSchemeForm(
+ variant="edit_version_scheme",
+ csrf_token="test",
+ project_key=safe.ProjectKey(project_key),
+ version_method=version_method,
+ version_pattern=version_pattern,
+ cycle_match=cycle_match,
+ branch_template=branch_template,
+ )
+
+
+async def test_edit_cycle_dates_writes_event_when_setting_first_eod():
+ cycle = _cycle()
+ data = _MockData(cycle=cycle)
+ writer = _make_committee_member(data)
+ form = _cycle_dates_form(eod=datetime.date(2026, 6, 1))
+
+ await writer.edit_cycle_dates(form)
+
+ events = [e for e in data.added if isinstance(e, sql.LifecycleEvent)]
+ assert len(events) == 1
+ assert events[0].event is sql.LifecycleEventType.EOD
+ assert events[0].cycle_key == "example-default"
+ assert events[0].project_key == "example"
+ assert events[0].effective.date() == datetime.date(2026, 6, 1)
+ assert cycle.eod.date() == datetime.date(2026, 6, 1)
+ data.commit.assert_awaited_once()
+
+
+async def test_edit_cycle_dates_pairs_withdraw_when_changing_existing_date():
+ existing = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC)
+ cycle = _cycle(eod=existing)
+ data = _MockData(cycle=cycle, prior_event_id=42)
+ writer = _make_committee_member(data)
+ form = _cycle_dates_form(eod=datetime.date(2026, 7, 1))
+
+ await writer.edit_cycle_dates(form)
+
+ events = [e for e in data.added if isinstance(e, sql.LifecycleEvent)]
+ assert len(events) == 2
+ withdraw, new_event = events
+ assert withdraw.event is sql.LifecycleEventType.WITHDRAW
+ assert withdraw.target_event_id == 42
+ assert new_event.event is sql.LifecycleEventType.EOD
+ assert new_event.effective.date() == datetime.date(2026, 7, 1)
+
+
+async def test_edit_cycle_dates_skips_unchanged_date():
+ existing = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC)
+ cycle = _cycle(eod=existing)
+ data = _MockData(cycle=cycle)
+ writer = _make_committee_member(data)
+ form = _cycle_dates_form(eod=datetime.date(2026, 6, 1))
+
+ await writer.edit_cycle_dates(form)
+
+ events = [e for e in data.added if isinstance(e, sql.LifecycleEvent)]
+ assert events == []
+ data.execute.assert_not_awaited()
+
+
+async def test_edit_cycle_dates_rejects_cycle_belonging_to_other_project():
+ cycle = _cycle(project_key="otherproject")
+ data = _MockData(cycle=cycle)
+ writer = _make_committee_member(data)
+ form = _cycle_dates_form(project_key="example", eod=datetime.date(2026, 6, 1))
+
+ with pytest.raises(storage.AccessError, match="does not belong to project"):
+ await writer.edit_cycle_dates(form)
+
+
+async def test_edit_cycle_dates_raises_when_cycle_missing():
+ data = _MockData(cycle=None)
+ writer = _make_committee_member(data)
+ form = _cycle_dates_form(eod=datetime.date(2026, 6, 1))
+
+ with pytest.raises(storage.AccessError, match="not found"):
+ await writer.edit_cycle_dates(form)
+
+
+async def test_edit_version_scheme_saves_all_fields_normalised():
+ project = _project()
+ data = _MockData(project=project)
+ writer = _make_committee_member(data)
+ form = _version_scheme_form(
+ version_method=sql.VersionMethod.SEMVER,
+ version_pattern=r"^\d+\.\d+\.\d+$",
+ cycle_match=r"^(\d+)\.\d+\.\d+$",
+ branch_template="release-{cycle}",
+ )
+
+ await writer.edit_version_scheme(form)
+
+ assert project.version_method is sql.VersionMethod.SEMVER
+ assert project.version_pattern == r"^\d+\.\d+\.\d+$"
+ assert project.cycle_match == r"^(\d+)\.\d+\.\d+$"
+ assert project.branch_template == "release-{cycle}"
+ data.commit.assert_awaited_once()
+
+
+async def test_edit_version_scheme_normalises_empty_strings_to_none():
+ project = _project(
+ version_pattern=r"^\d+$",
+ cycle_match=r"(\d+)",
+ branch_template="release-{cycle}",
+ )
+ data = _MockData(project=project)
+ writer = _make_committee_member(data)
+ form = _version_scheme_form(version_pattern="", cycle_match="", branch_template="")
+
+ await writer.edit_version_scheme(form)
+
+ assert project.version_pattern is None
+ assert project.cycle_match is None
+ assert project.branch_template is None
+
+
+async def test_edit_version_scheme_rejects_invalid_cycle_match_regex():
+ project = _project()
+ data = _MockData(project=project)
+ writer = _make_committee_member(data)
+ form = _version_scheme_form(cycle_match="(unclosed")
+
+ with pytest.raises(ValueError, match="Invalid cycle_match regex"):
+ await writer.edit_version_scheme(form)
+
+
+async def test_edit_version_scheme_rejects_cycle_match_without_capture_group():
+ project = _project()
+ data = _MockData(project=project)
+ writer = _make_committee_member(data)
+ form = _version_scheme_form(cycle_match=r"^\d+\.\d+\.\d+$")
+
+ with pytest.raises(ValueError, match="at least one capture group"):
+ await writer.edit_version_scheme(form)
+
+
+async def test_edit_version_scheme_rejects_invalid_version_pattern_regex():
+ project = _project()
+ data = _MockData(project=project)
+ writer = _make_committee_member(data)
+ form = _version_scheme_form(version_pattern="(unclosed")
+
+ with pytest.raises(ValueError, match="Invalid version_pattern regex"):
+ await writer.edit_version_scheme(form)
+
+
+async def test_edit_version_scheme_raises_when_project_missing():
+ data = _MockData(project=None)
+ writer = _make_committee_member(data)
+ form = _version_scheme_form()
+
+ with pytest.raises(storage.AccessError, match="not found"):
+ await writer.edit_version_scheme(form)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org