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]

Reply via email to