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 4887c85 Allow greater flexibility to create new projects
4887c85 is described below
commit 4887c85c746f44352d451b089a915af93cd37bdc
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jun 10 20:36:11 2025 +0100
Allow greater flexibility to create new projects
---
atr/routes/projects.py | 167 ++++++++++++++++++---------------
atr/templates/committee-directory.html | 31 +++---
atr/templates/index-committer.html | 9 +-
atr/templates/project-add-project.html | 114 +++-------------------
atr/templates/project-view.html | 9 +-
5 files changed, 130 insertions(+), 200 deletions(-)
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index b79a07e..42fb06e 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -19,7 +19,6 @@
import datetime
import http.client
-import re
from typing import Protocol
import asfquart.base as base
@@ -36,8 +35,9 @@ import atr.util as util
class AddFormProtocol(Protocol):
- project_name: wtforms.HiddenField
- derived_project_name: wtforms.StringField
+ committee_name: wtforms.HiddenField
+ display_name: wtforms.StringField
+ label: wtforms.StringField
submit: wtforms.SubmitField
@@ -113,33 +113,39 @@ class ReleasePolicyForm(util.QuartFormTyped):
submit_policy = wtforms.SubmitField("Save")
[email protected]("/project/add/<project_name>", methods=["GET", "POST"])
-async def add_project(session: routes.CommitterSession, project_name: str) ->
response.Response | str:
- await session.check_access(project_name)
[email protected]("/project/add/<committee_name>", methods=["GET", "POST"])
+async def add_project(session: routes.CommitterSession, committee_name: str)
-> response.Response | str:
+ await session.check_access_committee(committee_name)
async with db.session() as data:
- project = await data.project(name=project_name).demand(
- base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ committee = await data.committee(name=committee_name).demand(
+ base.ASFQuartException(f"Committee {committee_name} not found",
errorcode=404)
)
class AddForm(util.QuartFormTyped):
- project_name = wtforms.HiddenField("project_name")
- derived_project_name = wtforms.StringField(
- "Derived project",
- validators=[
- wtforms.validators.InputRequired("Please provide a derived
project name."),
- wtforms.validators.Length(min=1, max=100),
- ],
- description="The desired suffix for the full project name.",
+ committee_name = wtforms.HiddenField("committee_name")
+ display_name = wtforms.StringField(
+ "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.
+""",
+ )
+ label = wtforms.StringField(
+ "Label",
+ description=f"""\
+For example, "{committee.name}" or "{committee.name}-components".
+You must start with your committee label, and you must use lower case.
+""",
)
submit = wtforms.SubmitField("Add project")
- form = await AddForm.create_form(data={"project_name": project_name})
+ form = await AddForm.create_form(data={"committee_name": committee_name})
if await form.validate_on_submit():
return await _project_add(form, session.uid)
- return await template.render("project-add-project.html", form=form,
project_name=project.display_name)
+ return await template.render("project-add-project.html", form=form,
committee_name=committee.display_name)
@routes.committer("/project/delete", methods=["POST"])
@@ -396,65 +402,36 @@ async def _policy_form_create(project: models.Project) ->
ReleasePolicyForm:
async def _project_add(form: AddFormProtocol, asf_id: str) ->
response.Response:
- base_project_name = str(form.project_name.data)
- derived_project_name = str(form.derived_project_name.data).strip()
-
- def _generate_label(text: str) -> str:
- # TODO: We should probably add long name validation
- text = text.lower()
- text = re.sub(r" +", "-", text)
- text = re.sub(r"[^a-z0-9-]", "", text)
- return text
+ 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
+ super_project = None
async with db.session() as data:
# Get the base project to derive from
- base_project = await data.project(name=base_project_name).get()
- if not base_project:
- # This should not happen
- raise RuntimeError(f"Base project {base_project_name} not found")
- if base_project.super_project_name:
- await quart.flash(f"Project {base_project.name} is already a
derived project", "error")
- return quart.redirect(util.as_url(add_project,
project_name=base_project.name))
-
- # Construct the new label
- derived_label = _generate_label(derived_project_name)
- if not derived_label:
- await quart.flash("Derived project name must contain valid
characters for label generation", "error")
- return quart.redirect(util.as_url(add_project,
project_name=base_project.name))
- new_project_label = f"{base_project.name}-{derived_label}"
-
- # Construct the new full name
- # We ensure that parenthesised suffixes like "(Incubating)" are
preserved
- base_name = base_project.display_name
- match = re.match(r"^(.*?) *(\(.*\))?$", base_name)
- if match:
- main_part = match.group(1).strip()
- suffix_part = match.group(2)
- else:
- main_part = base_name.strip()
- suffix_part = None
-
- if suffix_part:
- new_project_full_name = f"{main_part} {derived_project_name}
{suffix_part}"
- else:
- new_project_full_name = f"{main_part} {derived_project_name}"
- new_project_full_name = re.sub(r" +", " ",
new_project_full_name).strip()
-
- # Check whether the derived project already exists by its constructed
label
- if await data.project(name=new_project_label).get():
- await quart.flash(f"Derived project {new_project_label} already
exists", "error")
- return quart.redirect(util.as_url(add_project,
project_name=base_project.name))
-
+ committee_projects = await data.project(committee_name=committee_name,
_committee=True).all()
+ for committee_project in committee_projects:
+ if label.startswith(committee_project.name + "-"):
+ if (super_project is None) or (len(super_project.name) <
len(committee_project.name)):
+ super_project = committee_project
+
+ # Check whether the project already exists
+ if await data.project(name=label).get():
+ await quart.flash(f"Project {label} already exists", "error")
+ return quart.redirect(util.as_url(add_project,
committee_name=committee_name))
+
+ # TODO: Fix the potential race condition here
project = models.Project(
- name=new_project_label,
- full_name=new_project_full_name,
- is_retired=base_project.is_retired,
- super_project_name=base_project.name,
- description=base_project.description,
- category=base_project.category,
- programming_languages=base_project.programming_languages,
- committee_name=base_project.committee_name,
- release_policy_id=base_project.release_policy_id,
+ name=label,
+ full_name=display_name,
+ is_retired=False,
+ super_project_name=super_project.name if super_project else None,
+ description=super_project.description if super_project else None,
+ category=super_project.category if super_project else None,
+ programming_languages=super_project.programming_languages if
super_project else None,
+ committee_name=committee_name,
+ release_policy_id=super_project.release_policy_id if super_project
else None,
created=datetime.datetime.now(datetime.UTC),
created_by=asf_id,
)
@@ -462,7 +439,49 @@ async def _project_add(form: AddFormProtocol, asf_id: str)
-> response.Response:
data.add(project)
await data.commit()
- return quart.redirect(util.as_url(view, name=new_project_label))
+ return quart.redirect(util.as_url(view, name=label))
+
+
+async def _project_add_validate(form: AddFormProtocol) -> tuple[str, str, str]
| None:
+ committee_name = str(form.committee_name.data)
+ display_name = str(form.display_name.data).strip()
+ if not display_name.startswith("Apache "):
+ await quart.flash("Display name must start with 'Apache '", "error")
+ return None
+ if not display_name.istitle():
+ await quart.flash("Display name must start with a capital letter",
"error")
+ 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)
def _set_default_fields(form: ReleasePolicyForm, project: models.Project,
release_policy: models.ReleasePolicy) -> None:
diff --git a/atr/templates/committee-directory.html
b/atr/templates/committee-directory.html
index ae7dcf8..7401509 100644
--- a/atr/templates/committee-directory.html
+++ b/atr/templates/committee-directory.html
@@ -139,23 +139,7 @@
</div>
</div>
</div>
- {% if current_user and not project.super_project_name and
is_part %}
- <a href="{{ as_url(routes.projects.add_project,
project_name=project.name) }}"
- title="Create a sub-project for {{ project.display_name
}}"
- class="text-decoration-none d-block mt-2 mb-3 {% if
loop.index > max_initial_projects %}page-project-extra d-none{% endif %}">
- <div class="card h-100 shadow-sm atr-cursor-pointer
page-project-subcard">
- <div class="card-body d-flex align-items-center
text-secondary p-3">
- <div>
- <i class="bi bi-plus-circle me-2"></i>Create
sub-project
- <br />
- <small class="text-muted">from {{
project.display_name }}</small>
- </div>
- </div>
- </div>
- </a>
- {% endif %}
{% endfor %}
-
{% if committee.projects|length > max_initial_projects %}
<button type="button"
class="btn btn-sm btn-outline-secondary mt-2
page-toggle-committee-projects"
@@ -168,6 +152,21 @@
{# Add an else clause here if we decide to show an alternative
to an empty card #}
{% endif %}
</div>
+ {% if current_user and is_part %}
+ <a href="{{ as_url(routes.projects.add_project,
committee_name=committee.name) }}"
+ title="Create a project for {{ committee.display_name }}"
+ class="text-decoration-none d-block mt-4 mb-3">
+ <div class="card h-100 shadow-sm atr-cursor-pointer
page-project-subcard">
+ <div class="card-body d-flex align-items-center
text-secondary p-3">
+ <div>
+ <i class="bi bi-plus-circle me-2"></i>Create project
+ <br />
+ <small class="text-muted">for {{ committee.display_name
}}</small>
+ </div>
+ </div>
+ </div>
+ </a>
+ {% endif %}
</div>
</div>
</div>
diff --git a/atr/templates/index-committer.html
b/atr/templates/index-committer.html
index 6e7dff1..a0532bc 100644
--- a/atr/templates/index-committer.html
+++ b/atr/templates/index-committer.html
@@ -90,13 +90,8 @@
<a href="{{ as_url(routes.projects.view, name=project.name) }}"
class="text-decoration-none me-2">About this project</a>
<span class="text-muted me-2">/</span>
- {% if project.super_project_name %}
- <a href="{{ as_url(routes.projects.view,
name=project.super_project_name) }}"
- class="text-decoration-none me-2">View super-project</a>
- {% else %}
- <a href="{{ as_url(routes.projects.add_project,
project_name=project.name) }}"
- class="text-decoration-none me-2">Create a sub-project</a>
- {% endif %}
+ <a href="{{ as_url(routes.projects.add_project,
committee_name=project.committee.name) }}"
+ 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) }}"
diff --git a/atr/templates/project-add-project.html
b/atr/templates/project-add-project.html
index e53ec2d..43afe4d 100644
--- a/atr/templates/project-add-project.html
+++ b/atr/templates/project-add-project.html
@@ -10,21 +10,19 @@
{% block content %}
<h1>
- Add a new <strong>{{ project_name.removesuffix(" (Incubating)")
}}</strong> sub-project
+ Add a new <strong>{{ committee_name }}</strong> project
</h1>
- <p>New projects can only be derived from existing projects, by adding a
suffix.</p>
-
{{ forms.errors_summary(form) }}
<form method="post" class="atr-canary py-4" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(form.derived_project_name, col="sm3") }}
+ {{ forms.label(form.display_name, col="sm3") }}
<div class="col-sm-8">
- {{ forms.widget(form.derived_project_name,
id=form.derived_project_name.id) }}
- {{ forms.errors(form.derived_project_name, classes="text-danger
small") }}
- {{ forms.description(form.derived_project_name, classes="text-muted
mt-1") }}
+ {{ forms.widget(form.display_name, id=form.display_name.id) }}
+ {{ forms.errors(form.display_name, classes="text-danger small") }}
+ {{ forms.description(form.display_name, classes="text-muted mt-1") }}
<p id="capitalisation-warning" class="text-danger small mt-1 d-none">
<i class="bi bi-exclamation-triangle"></i>
Warning: Ensure all words in the derived name start with a capital
for proper display.
@@ -33,24 +31,15 @@
</div>
<div class="mb-3 pb-3 row border-bottom">
- <label id="new-project-name-label"
- for="new-project-name-display"
- class="col-sm-3 col-form-label text-sm-end">Project name
preview</label>
- <div class="col-sm-8">
- <code id="new-project-name-display"
- class="form-control-plaintext bg-light p-2 rounded
d-block"></code>
- <p class="text-muted small mt-1">This will be the full display name
for the derived project.</p>
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- <label id="new-project-label-label"
- for="new-project-label-display"
- class="col-sm-3 col-form-label text-sm-end">Project label
preview</label>
+ {{ forms.label(form.label, col="sm3") }}
<div class="col-sm-8">
- <code id="new-project-label-display"
- class="form-control-plaintext bg-light p-2 rounded
d-block"></code>
- <p class="text-muted small mt-1">This will be the short label used in
URLs and identifiers.</p>
+ {{ forms.widget(form.label, id=form.label.id) }}
+ {{ forms.errors(form.label, classes="text-danger small") }}
+ {{ forms.description(form.label, classes="text-muted mt-1") }}
+ <p id="capitalisation-warning" class="text-danger small mt-1 d-none">
+ <i class="bi bi-exclamation-triangle"></i>
+ Warning: Ensure all words in the derived name start with a capital
for proper display.
+ </p>
</div>
</div>
@@ -59,80 +48,3 @@
</div>
</form>
{% endblock content %}
-
-{% block javascripts %}
- {{ super() }}
- <script>
- document.addEventListener("DOMContentLoaded", () => {
- const projectLabel = document.getElementById("{{
form.project_name.id }}");
- const projectSelect = "{{ project_name }}";
- const derivedNameInput = document.getElementById("{{
form.derived_project_name.id }}");
- const newNameDisplay =
document.getElementById("new-project-name-display");
- const newLabelDisplay =
document.getElementById("new-project-label-display");
- const capitalisationWarning =
document.getElementById("capitalisation-warning");
-
- if (!projectSelect || !derivedNameInput || !newNameDisplay ||
!newLabelDisplay || !capitalisationWarning) return;
-
- function generateSlug(text) {
- return text.toLowerCase().replace(/\s+/g,
"-").replace(/[^a-z0-9-]/g, "");
- }
-
- function updatePreview() {
- const selectedOption = projectSelect;
- const baseLabel = projectLabel.value;
- const baseFullName = selectedOption;
- const derivedNameValue = derivedNameInput.value.trim();
-
- let hasCapitalisationIssue = false;
- if (derivedNameValue) {
- const words = derivedNameValue.split(/\s+/);
- for (const word of words) {
- if (word.length > 0 && !/^[A-Z]/.test(word)) {
- hasCapitalisationIssue = true;
- break;
- }
- }
- }
-
- if (hasCapitalisationIssue) {
- capitalisationWarning.classList.remove("d-none");
- } else {
- capitalisationWarning.classList.add("d-none");
- }
-
- let newFullName = baseFullName;
- if (derivedNameValue) {
- const match = baseFullName.match(/^(.*?)\s*(\(.*\))?$/);
- let mainPart = baseFullName.trim();
- let suffixPart = null;
-
- if (match) {
- mainPart = match[1] ? match[1].trim() : mainPart;
- suffixPart = match[2];
- }
-
- if (suffixPart) {
- newFullName = `${mainPart} ${derivedNameValue}
${suffixPart}`;
- } else {
- newFullName = `${mainPart} ${derivedNameValue}`;
- }
- newFullName = newFullName.replace(/\s{2,}/g, " ").trim();
- }
- newNameDisplay.textContent = newFullName || "(Select base
project)";
-
- let newLabel = baseLabel;
- if (derivedNameValue) {
- const derivedSlug = generateSlug(derivedNameValue);
- if (derivedSlug) {
- newLabel = `${baseLabel}-${derivedSlug}`;
- }
- }
- newLabelDisplay.textContent = newLabel || "(Enter derived
project name)";
- }
-
- derivedNameInput.addEventListener("input", updatePreview);
-
- updatePreview();
- });
- </script>
-{% endblock javascripts %}
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 5b21567..f89862f 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -303,7 +303,7 @@
{% if project.created_by == current_user.uid %}
<h2>Actions</h2>
- <div class="mt-3">
+ <div class="my-3">
<form method="post"
action="{{ as_url(routes.projects.delete) }}"
class="d-inline-block m-0"
@@ -319,7 +319,12 @@
</form>
</div>
{% endif %}
-
+ {% if (is_committee_member or is_admin) %}
+ <p>
+ <a href="{{ as_url(routes.projects.add_project,
committee_name=project.committee.name) }}"
+ class="btn btn-sm btn-outline-primary">Create a sibling project</a>
+ </p>
+ {% endif %}
{% if can_edit and metadata_form %}
<div class="card mb-4">
<div class="card-header bg-light">
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]