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 fce674b  Add a form to remove RC tags from paths in bulk
fce674b is described below

commit fce674bde643c5edf4dde100ef2eb5789cac9c72
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed May 28 20:43:54 2025 +0100

    Add a form to remove RC tags from paths in bulk
---
 atr/analysis.py                    |  13 +
 atr/routes/announce.py             |   5 +-
 atr/routes/finish.py               | 516 +++++++++++++++++++++++--------------
 atr/templates/finish-selected.html |  35 ++-
 atr/util.py                        |  14 +-
 5 files changed, 372 insertions(+), 211 deletions(-)

diff --git a/atr/analysis.py b/atr/analysis.py
index 8620c73..3526649 100755
--- a/atr/analysis.py
+++ b/atr/analysis.py
@@ -177,6 +177,19 @@ def architecture_pattern() -> str:
     return "(" + "|".join(architectures) + ")(?=[_.-])"
 
 
+def candidate_highlight(path: pathlib.Path, prefix: str = "<strong>", suffix: 
str = "</strong>") -> str:
+    parts = []
+    for part in path.parts:
+        if ("<" in part) or (">" in part) or ("&" in part):
+            # TODO: Should perhaps check for ' and " too for attribute value 
safety
+            raise ValueError(f"Invalid path segment: {part}")
+        if _CANDIDATE_WHOLE.match(part):
+            parts.append(f"{prefix}{part}{suffix}")
+            continue
+        parts.append(_CANDIDATE_PARTIAL.sub(rf"{prefix}\g<0>{suffix}", part))
+    return str(pathlib.Path(*parts))
+
+
 def candidate_match(segment: str) -> re.Match[str] | None:
     return _CANDIDATE_WHOLE.match(segment) or 
_CANDIDATE_PARTIAL.search(segment)
 
diff --git a/atr/routes/announce.py b/atr/routes/announce.py
index 404985d..40b6e10 100644
--- a/atr/routes/announce.py
+++ b/atr/routes/announce.py
@@ -175,7 +175,8 @@ async def selected_post(
 
             # Prepare paths for file operations
             source_base = util.release_directory_base(release)
-            source = str(source_base / release.unwrap_revision_number)
+            source_path = source_base / release.unwrap_revision_number
+            source = str(source_path)
 
             await _promote(release, data, preview_revision_number)
             await data.commit()
@@ -200,7 +201,7 @@ async def selected_post(
             raise routes.FlashError("Release already exists")
 
     # Ensure that the permissions of every directory are 755
-    await asyncio.to_thread(util.chmod_directories, target_path)
+    await asyncio.to_thread(util.chmod_directories, source_path)
 
     try:
         await aioshutil.move(source, target)
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 979a745..db252e6 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -15,9 +15,11 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import dataclasses
 import logging
 import pathlib
-from typing import Final
+from collections.abc import Awaitable, Callable
+from typing import Any, Final
 
 import aiofiles.os
 import quart
@@ -27,6 +29,7 @@ import werkzeug.wrappers.response as response
 import wtforms
 import wtforms.fields as fields
 
+import atr.analysis as analysis
 import atr.db as db
 import atr.db.models as models
 import atr.revision as revision
@@ -40,13 +43,16 @@ SPECIAL_SUFFIXES: Final[frozenset[str]] = 
frozenset({".asc", ".sha256", ".sha512
 _LOGGER: Final = logging.getLogger(__name__)
 
 
+Respond = Callable[[int, str], Awaitable[tuple[quart_response.Response, int] | 
response.Response]]
+
+
 class DeleteEmptyDirectoryForm(util.QuartFormTyped):
     """Form for deleting an empty directory within a preview revision."""
 
     directory_to_delete = wtforms.SelectField(
         "Directory to delete", choices=[], 
validators=[wtforms.validators.DataRequired()]
     )
-    submit = wtforms.SubmitField("Delete directory")
+    submit_delete_empty_dir = wtforms.SubmitField("Delete directory")
 
 
 class MoveFileForm(util.QuartFormTyped):
@@ -78,6 +84,31 @@ class MoveFileForm(util.QuartFormTyped):
                     )
 
 
+class RemoveRCTagsForm(util.QuartFormTyped):
+    submit_remove_rc_tags = wtforms.SubmitField("Remove RC tags")
+
+
[email protected]
+class ProcessFormDataArgs:
+    formdata: datastructures.MultiDict
+    session: routes.CommitterSession
+    project_name: str
+    version_name: str
+    move_form: MoveFileForm
+    delete_dir_form: DeleteEmptyDirectoryForm
+    remove_rc_tags_form: RemoveRCTagsForm
+    can_move: bool
+    wants_json: bool
+    respond: Respond
+
+
[email protected]
+class RCTagAnalysisResult:
+    affected_paths_preview: list[tuple[str, str]]
+    affected_count: int
+    total_paths: int
+
+
 @routes.committer("/finish/<project_name>/<version_name>", methods=["GET", 
"POST"])
 async def selected(
     session: routes.CommitterSession, project_name: str, version_name: str
@@ -85,6 +116,24 @@ async def selected(
     """Finish a release preview."""
     await session.check_access(project_name)
 
+    wants_json = 
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json"
+
+    async def respond(
+        http_status: int,
+        msg: str,
+    ) -> tuple[quart_response.Response, int] | response.Response:
+        """Helper to respond with JSON or flash message and redirect."""
+        nonlocal session
+        nonlocal project_name
+        nonlocal version_name
+        nonlocal wants_json
+
+        ok = http_status < 300
+        if wants_json:
+            return quart.jsonify(ok=ok, message=msg), http_status
+        await quart.flash(msg, "success" if ok else "error")
+        return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+
     async with db.session() as data:
         release = await session.release(
             project_name, version_name, 
phase=models.ReleasePhase.RELEASE_PREVIEW, data=data
@@ -106,37 +155,36 @@ async def selected(
         data=formdata if (formdata and formdata.get("form_action") != 
"create_dir") else None
     )
     delete_dir_form = await DeleteEmptyDirectoryForm.create_form(
-        data=formdata if (formdata and formdata.get("form_action") == 
"delete_empty_dir") else None
+        data=formdata if (formdata and ("submit_delete_empty_dir" in 
formdata)) else None
+    )
+    remove_rc_tags_form = await RemoveRCTagsForm.create_form(
+        data=formdata if (formdata and ("submit_remove_rc_tags" in formdata)) 
else None
     )
 
     # Populate choices dynamically for both GET and POST
     move_form.source_files.choices = sorted([(str(p), str(p)) for p in 
source_files_rel])
     move_form.target_directory.choices = sorted([(str(d), str(d)) for d in 
target_dirs])
     can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
-
-    empty_deletable_dirs: list[pathlib.Path] = []
-    if latest_revision_dir.exists():
-        for d_rel in target_dirs:
-            if d_rel == pathlib.Path("."):
-                # Disallow deletion of the root directory
-                continue
-            d_full = latest_revision_dir / d_rel
-            if await aiofiles.os.path.isdir(d_full) and not await 
aiofiles.os.listdir(d_full):
-                empty_deletable_dirs.append(d_rel)
-    delete_dir_form.directory_to_delete.choices = sorted([(str(p), str(p)) for 
p in empty_deletable_dirs])
+    delete_dir_form.directory_to_delete.choices = await 
_deletable_choices(latest_revision_dir, target_dirs)
 
     if formdata:
-        result = await _process_formdata(
-            formdata, session, project_name, version_name, move_form, 
delete_dir_form, can_move
+        pfd_args = ProcessFormDataArgs(
+            formdata=formdata,
+            session=session,
+            project_name=project_name,
+            version_name=version_name,
+            move_form=move_form,
+            delete_dir_form=delete_dir_form,
+            remove_rc_tags_form=remove_rc_tags_form,
+            can_move=can_move,
+            wants_json=wants_json,
+            respond=respond,
         )
+        result = await _submission_process(pfd_args)
         if result is not None:
             return result
 
-    # resp = await quart.current_app.make_response(template_rendered)
-    # resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
-    # resp.headers["Pragma"] = "no-cache"
-    # resp.headers["Expires"] = "0"
-    # return resp
+    rc_analysis_result = await _analyse_rc_tags(latest_revision_dir)
     return await template.render(
         "finish-selected.html",
         asf_id=session.uid,
@@ -148,122 +196,95 @@ async def selected(
         user_ssh_keys=user_ssh_keys,
         target_dirs=sorted(list(target_dirs)),
         max_files_to_show=10,
+        remove_rc_tags_form=remove_rc_tags_form,
+        rc_affected_paths_preview=rc_analysis_result.affected_paths_preview,
+        rc_affected_count=rc_analysis_result.affected_count,
+        rc_total_paths=rc_analysis_result.total_paths,
     )
 
 
-async def _process_formdata(
-    formdata: datastructures.MultiDict,
-    session: routes.CommitterSession,
-    project_name: str,
-    version_name: str,
-    move_form: MoveFileForm,
-    delete_dir_form: DeleteEmptyDirectoryForm,
-    can_move: bool,
-) -> tuple[quart_response.Response, int] | response.Response | str | None:
-    form_action = formdata.get("form_action")
-
-    if (
-        (quart.request.method == "POST")
-        and ("source_files" in formdata)
-        and ("target_directory" in formdata)
-        and (not form_action)
-    ):
-        source_files_data = formdata.getlist("source_files")
-        target_dir_data = formdata.get("target_directory")
-        wants_json = 
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json"
-
-        if not source_files_data or not target_dir_data:
-            return await _respond(
-                session,
-                project_name,
-                version_name,
-                wants_json,
-                False,
-                "Missing source file(s) or target directory.",
-                400,
-            )
-
-        source_files_rel = [pathlib.Path(sf) for sf in source_files_data]
-        target_dir_rel = pathlib.Path(target_dir_data)
-
-        if not source_files_rel:
-            return await _respond(
-                session, project_name, version_name, wants_json, False, "No 
source files selected.", 400
-            )
-        return await _move_file_to_revision(
-            source_files_rel, target_dir_rel, session, project_name, 
version_name, wants_json
-        )
+async def _analyse_rc_tags(latest_revision_dir: pathlib.Path) -> 
RCTagAnalysisResult:
+    r = RCTagAnalysisResult(
+        affected_paths_preview=[],
+        affected_count=0,
+        total_paths=0,
+    )
 
-    elif form_action == "delete_empty_dir":
-        wants_json = 
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json"
-        if await delete_dir_form.validate_on_submit():
-            dir_to_delete_str = delete_dir_form.directory_to_delete.data
-            return await _delete_empty_dir_action(
-                pathlib.Path(dir_to_delete_str), session, project_name, 
version_name, wants_json
-            )
-        elif wants_json:
-            error_messages = []
-            for field_name_str, error_list in delete_dir_form.errors.items():
-                field_obj = getattr(delete_dir_form, field_name_str, None)
-                label_text = field_name_str.replace("_", " ").title()
-                if field_obj and hasattr(field_obj, "label") and 
field_obj.label:
-                    label_text = field_obj.label.text
-                error_messages.append(f"{label_text}: {', '.join(error_list)}")
-            error_msg = "; ".join(error_messages)
-            return await _respond(session, project_name, version_name, True, 
False, error_msg or "Invalid input.", 400)
-
-    elif ((form_action != "create_dir") or (form_action is None)) and can_move:
-        return await _move_file(move_form, session, project_name, version_name)
-    return None
+    if not latest_revision_dir.exists():
+        return r
+
+    async for p_rel in util.paths_recursive_all(latest_revision_dir):
+        r.total_paths += 1
+        original_path_str = str(p_rel)
+        stripped_path_str = str(analysis.candidate_removed(p_rel))
+        if original_path_str == stripped_path_str:
+            continue
+        r.affected_count += 1
+        if len(r.affected_paths_preview) >= 5:
+            # Can't break here, because we need to update the counts
+            continue
+        highlighted_preview = analysis.candidate_highlight(p_rel)
+        r.affected_paths_preview.append((highlighted_preview, 
stripped_path_str))
+
+    return r
+
+
+async def _current_paths(creating: revision.Creating) -> list[pathlib.Path]:
+    all_current_paths_interim: list[pathlib.Path] = []
+    async for p_rel_interim in util.paths_recursive_all(creating.interim_path):
+        all_current_paths_interim.append(p_rel_interim)
+
+    # This manner of sorting is necessary to ensure that directories are 
removed after their contents
+    all_current_paths_interim.sort(key=lambda p: (-len(p.parts), str(p)))
+    return all_current_paths_interim
+
+
+async def _deletable_choices(latest_revision_dir: pathlib.Path, target_dirs: 
set[pathlib.Path]) -> Any:
+    # This should be -> list[tuple[str, str]], but that causes pyright to 
complain incorrectly
+    # Details in 
pyright/dist/dist/typeshed-fallback/stubs/WTForms/wtforms/fields/choices.pyi
+    # _Choice: TypeAlias = tuple[Any, str] | tuple[Any, str, dict[str, Any]]
+    # Then it wants us to use list[_Choice] (= list[tuple[Any, str]])
+    # But it says, incorrectly, that list[tuple[str, str]] is not a 
list[_Choice]
+    # This mistake is not made by mypy
+    empty_deletable_dirs: list[pathlib.Path] = []
+    if latest_revision_dir.exists():
+        for d_rel in target_dirs:
+            if d_rel == pathlib.Path("."):
+                # Disallow deletion of the root directory
+                continue
+            d_full = latest_revision_dir / d_rel
+            if await aiofiles.os.path.isdir(d_full) and not await 
aiofiles.os.listdir(d_full):
+                empty_deletable_dirs.append(d_rel)
+    return sorted([(str(p), str(p)) for p in empty_deletable_dirs])
 
 
-async def _move_file(
-    form: MoveFileForm,
+async def _delete_empty_directory(
+    dir_to_delete_rel: pathlib.Path,
     session: routes.CommitterSession,
     project_name: str,
     version_name: str,
-) -> tuple[quart_response.Response, int] | response.Response | None:
-    wants_json = "application/json" in quart.request.headers.get("Accept", "")
-
-    if await form.validate_on_submit():
-        source_files_rel_str_list = form.source_files.data
-        target_dir_rel_str = form.target_directory.data
-        if not source_files_rel_str_list or not target_dir_rel_str:
-            return await _respond(
-                session,
-                project_name,
-                version_name,
-                wants_json,
-                False,
-                "Source file(s) or target directory missing.",
-                400,
-            )
+    respond: Respond,
+) -> tuple[quart_response.Response, int] | response.Response:
+    try:
+        description = f"Delete empty directory {dir_to_delete_rel} via web 
interface"
+        async with revision.create_and_manage(
+            project_name, version_name, session.uid, description=description
+        ) as creating:
+            path_to_remove = creating.interim_path / dir_to_delete_rel
+            
path_to_remove.resolve().relative_to(creating.interim_path.resolve())
+            if not await aiofiles.os.path.isdir(path_to_remove):
+                raise revision.FailedError(f"Path '{dir_to_delete_rel}' is not 
a directory.")
+            if await aiofiles.os.listdir(path_to_remove):
+                raise revision.FailedError(f"Directory '{dir_to_delete_rel}' 
is not empty.")
+            await aiofiles.os.rmdir(path_to_remove)
 
-        source_files_rel = [pathlib.Path(sf_str) for sf_str in 
source_files_rel_str_list]
-        target_dir_rel = pathlib.Path(target_dir_rel_str)
-        return await _move_file_to_revision(
-            source_files_rel, target_dir_rel, session, project_name, 
version_name, wants_json
-        )
-    else:
-        if wants_json:
-            error_messages = []
-            for field_name, field_errors in form.errors.items():
-                field_object = getattr(form, field_name, None)
-                label_text = field_name.replace("_", " ").title()
-                if field_object and hasattr(field_object, "label") and 
field_object.label:
-                    label_text = field_object.label.text
-                error_messages.append(f"{label_text}: {', 
'.join(field_errors)}")
-            error_string = "; ".join(error_messages)
-            return await _respond(session, project_name, version_name, True, 
False, error_string, 400)
-        else:
-            for field_name, field_errors in form.errors.items():
-                field_object = getattr(form, field_name, None)
-                label_text = field_name.replace("_", " ").title()
-                if field_object and hasattr(field_object, "label") and 
field_object.label:
-                    label_text = field_object.label.text
-                for error_message_text in field_errors:
-                    await quart.flash(f"{label_text}: {error_message_text}", 
"warning")
-            return None
+    except Exception:
+        _LOGGER.exception(f"Unexpected error deleting directory 
{dir_to_delete_rel} for {project_name}/{version_name}")
+        return await respond(500, "An unexpected error occurred.")
+
+    if creating.failed is not None:
+        return await respond(400, str(creating.failed))
+    return await respond(200, f"Deleted empty directory 
'{dir_to_delete_rel}'.")
 
 
 async def _move_file_to_revision(
@@ -272,7 +293,7 @@ async def _move_file_to_revision(
     session: routes.CommitterSession,
     project_name: str,
     version_name: str,
-    wants_json: bool,
+    respond: Respond,
 ) -> tuple[quart_response.Response, int] | response.Response:
     try:
         description = "File move through web interface"
@@ -291,15 +312,7 @@ async def _move_file_to_revision(
             )
 
         if creating.failed is not None:
-            return await _respond(
-                session,
-                project_name,
-                version_name,
-                wants_json,
-                False,
-                str(creating.failed),
-                409,
-            )
+            return await respond(409, str(creating.failed))
 
         response_messages = []
         if moved_files_names:
@@ -309,32 +322,88 @@ async def _move_file_to_revision(
 
         if not response_messages:
             if not source_files_rel:
-                return await _respond(
-                    session, project_name, version_name, wants_json, False, 
"No source files specified for move.", 400
-                )
+                return await respond(400, "No source files specified for 
move.")
             msg = f"No files were moved. {', '.join(skipped_files_names)} 
already in '{target_dir_rel}'."
-            return await _respond(session, project_name, version_name, 
wants_json, True, msg, 200)
+            return await respond(200, msg)
 
-        final_msg = ". ".join(response_messages) + "."
-        return await _respond(session, project_name, version_name, wants_json, 
True, final_msg, 200)
+        return await respond(200, ". ".join(response_messages) + ".")
 
     except FileNotFoundError:
         _LOGGER.exception("File not found during move operation in new 
revision")
-        return await _respond(
-            session,
-            project_name,
-            version_name,
-            wants_json,
-            False,
-            "Error: Source file not found during move operation.",
-            400,
-        )
+        return await respond(400, "Error: Source file not found during move 
operation.")
     except OSError as e:
         _LOGGER.exception("Error moving file in new revision")
-        return await _respond(session, project_name, version_name, wants_json, 
False, f"Error moving file: {e}", 500)
+        return await respond(500, f"Error moving file: {e}")
     except Exception as e:
         _LOGGER.exception("Unexpected error during file move")
-        return await _respond(session, project_name, version_name, wants_json, 
False, f"ERROR: {e!s}", 500)
+        return await respond(500, f"ERROR: {e!s}")
+
+
+async def _submission_process(
+    args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+    delete_empty_directory = "submit_delete_empty_dir" in args.formdata
+    remove_rc_tags = "submit_remove_rc_tags" in args.formdata
+    move_file = ("source_files" in args.formdata) and ("target_directory" in 
args.formdata)
+
+    if delete_empty_directory:
+        return await _submission_process_delete_empty_directory(args)
+
+    if remove_rc_tags:
+        return await _submission_process_remove_rc_tags(args)
+
+    if move_file:
+        return await _submission_process_move_file(args)
+
+    return None
+
+
+async def _submission_process_delete_empty_directory(
+    args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+    if await args.delete_dir_form.validate_on_submit():
+        dir_to_delete_str = args.delete_dir_form.directory_to_delete.data
+        return await _delete_empty_directory(
+            pathlib.Path(dir_to_delete_str), args.session, args.project_name, 
args.version_name, args.respond
+        )
+    elif args.wants_json:
+        error_messages = []
+        for field_name_str, error_list in args.delete_dir_form.errors.items():
+            field_obj = getattr(args.delete_dir_form, field_name_str, None)
+            label_text = field_name_str.replace("_", " ").title()
+            if field_obj and hasattr(field_obj, "label") and field_obj.label:
+                label_text = field_obj.label.text
+            error_messages.append(f"{label_text}: {', '.join(error_list)}")
+        error_msg = "; ".join(error_messages)
+        return await args.respond(400, error_msg or "Invalid input.")
+    return None
+
+
+async def _submission_process_move_file(
+    args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+    source_files_data = args.formdata.getlist("source_files")
+    target_dir_data = args.formdata.get("target_directory")
+
+    if not source_files_data or not target_dir_data:
+        return await args.respond(400, "Missing source file(s) or target 
directory.")
+    source_files_rel = [pathlib.Path(sf) for sf in source_files_data]
+    target_dir_rel = pathlib.Path(target_dir_data)
+    if not source_files_rel:
+        return await args.respond(400, "No source files selected.")
+    return await _move_file_to_revision(
+        source_files_rel, target_dir_rel, args.session, args.project_name, 
args.version_name, args.respond
+    )
+
+
+async def _submission_process_remove_rc_tags(
+    args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+    if await args.remove_rc_tags_form.validate_on_submit():
+        return await _remove_rc_tags(args.session, args.project_name, 
args.version_name, args.respond)
+    elif args.wants_json:
+        return await args.respond(400, "Invalid request for RC tag removal.")
+    return None
 
 
 def _related_files(path: pathlib.Path) -> list[pathlib.Path]:
@@ -349,20 +418,106 @@ def _related_files(path: pathlib.Path) -> 
list[pathlib.Path]:
     ]
 
 
-async def _respond(
+async def _remove_rc_tags(
     session: routes.CommitterSession,
     project_name: str,
     version_name: str,
-    wants_json: bool,
-    ok: bool,
-    msg: str,
-    http_status: int = 200,
+    respond: Respond,
 ) -> tuple[quart_response.Response, int] | response.Response:
-    """Helper to respond with JSON or flash message and redirect."""
-    if wants_json:
-        return quart.jsonify(ok=ok, message=msg), http_status
-    await quart.flash(msg, "success" if ok else "error")
-    return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+    description = "Remove RC tags from paths via web interface"
+    error_messages: list[str] = []
+
+    try:
+        async with revision.create_and_manage(
+            project_name, version_name, session.uid, description=description
+        ) as creating:
+            renamed_count = await _remove_rc_tags_revision(creating, 
error_messages)
+
+        if creating.failed is not None:
+            return await respond(409, str(creating.failed))
+
+        if error_messages:
+            status_ok = renamed_count > 0
+            # TODO: Ideally HTTP would have a general mixed status, like 207 
but for anything
+            http_status = 200 if status_ok else 500
+            msg = f"RC tags removed for {renamed_count} item(s) with some 
errors: {'; '.join(error_messages)}"
+            return await respond(http_status, msg)
+
+        if renamed_count > 0:
+            return await respond(200, f"Successfully removed RC tags from 
{renamed_count} item(s).")
+
+        return await respond(200, "No items required RC tag removal or no 
changes were made.")
+
+    except Exception as e:
+        return await respond(500, f"Unexpected error: {e!s}")
+
+
+async def _remove_rc_tags_revision(
+    creating: revision.Creating,
+    error_messages: list[str],
+) -> int:
+    all_current_paths_interim = await _current_paths(creating)
+    renamed_count_local = 0
+    for path_rel_original_interim in all_current_paths_interim:
+        path_rel_stripped_interim = 
analysis.candidate_removed(path_rel_original_interim)
+
+        if path_rel_original_interim != path_rel_stripped_interim:
+            # Absolute paths of the source and destination
+            full_original_path = creating.interim_path / 
path_rel_original_interim
+            full_stripped_path = creating.interim_path / 
path_rel_stripped_interim
+
+            skip, renamed_count_local = await _remove_rc_tags_revision_item(
+                path_rel_original_interim,
+                full_original_path,
+                full_stripped_path,
+                error_messages,
+                renamed_count_local,
+            )
+            if skip:
+                continue
+
+            try:
+                if not await 
aiofiles.os.path.exists(full_stripped_path.parent):
+                    # This could happen if e.g. a file is in an RC tagged 
directory
+                    await aiofiles.os.makedirs(full_stripped_path.parent, 
exist_ok=True)
+
+                if await aiofiles.os.path.exists(full_stripped_path):
+                    error_messages.append(
+                        f"Skipped '{path_rel_original_interim}': target 
'{path_rel_stripped_interim}' already exists."
+                    )
+                    continue
+
+                await aiofiles.os.rename(full_original_path, 
full_stripped_path)
+                renamed_count_local += 1
+            except Exception as e:
+                error_messages.append(f"Error renaming 
'{path_rel_original_interim}': {e}")
+    return renamed_count_local
+
+
+async def _remove_rc_tags_revision_item(
+    path_rel_original_interim: pathlib.Path,
+    full_original_path: pathlib.Path,
+    full_stripped_path: pathlib.Path,
+    error_messages: list[str],
+    renamed_count_local: int,
+) -> tuple[bool, int]:
+    if await aiofiles.os.path.isdir(full_original_path):
+        # If moving an RC tagged directory to an existing directory...
+        is_target_dir_and_exists = await 
aiofiles.os.path.isdir(full_stripped_path)
+        if is_target_dir_and_exists and (full_stripped_path != 
full_original_path):
+            try:
+                # And the source directory is empty...
+                if not await aiofiles.os.listdir(full_original_path):
+                    # This means we probably moved files out of the RC tagged 
directory
+                    # In any case, we can't move it, so we have to delete it
+                    await aiofiles.os.rmdir(full_original_path)
+                    renamed_count_local += 1
+                else:
+                    error_messages.append(f"Source RC directory 
'{path_rel_original_interim}' is not empty, skipping.")
+            except OSError as e:
+                error_messages.append(f"Error removing source RC directory 
'{path_rel_original_interim}': {e}")
+            return True, renamed_count_local
+    return False, renamed_count_local
 
 
 async def _setup_revision(
@@ -464,36 +619,3 @@ async def _sources_and_targets(latest_revision_dir: 
pathlib.Path) -> tuple[list[
             target_dirs.add(item_rel_path)
 
     return source_items_rel, target_dirs
-
-
-async def _delete_empty_dir_action(
-    dir_to_delete_rel: pathlib.Path,
-    session: routes.CommitterSession,
-    project_name: str,
-    version_name: str,
-    wants_json: bool,
-) -> tuple[quart_response.Response, int] | response.Response:
-    try:
-        description = f"Delete empty directory {dir_to_delete_rel} via web 
interface"
-        async with revision.create_and_manage(
-            project_name, version_name, session.uid, description=description
-        ) as creating:
-            path_to_remove = creating.interim_path / dir_to_delete_rel
-            
path_to_remove.resolve().relative_to(creating.interim_path.resolve())
-            if not await aiofiles.os.path.isdir(path_to_remove):
-                raise revision.FailedError(f"Path '{dir_to_delete_rel}' is not 
a directory.")
-            if await aiofiles.os.listdir(path_to_remove):
-                raise revision.FailedError(f"Directory '{dir_to_delete_rel}' 
is not empty.")
-            await aiofiles.os.rmdir(path_to_remove)
-
-    except Exception:
-        _LOGGER.exception(f"Unexpected error deleting directory 
{dir_to_delete_rel} for {project_name}/{version_name}")
-        return await _respond(
-            session, project_name, version_name, wants_json, False, "An 
unexpected error occurred.", 500
-        )
-
-    if creating.failed is not None:
-        return await _respond(session, project_name, version_name, wants_json, 
False, str(creating.failed), 400)
-    return await _respond(
-        session, project_name, version_name, wants_json, True, f"Deleted empty 
directory '{dir_to_delete_rel}'.", 200
-    )
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index 60c1255..1b14f92 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -103,7 +103,7 @@
   <div class="alert alert-warning mb-4" role="alert">
     <p class="fw-semibold mb-1">TODO</p>
     <p class="mb-1">
-      We plan to add tools to help release managers to distribute release 
artifacts on distribution networks. Currently you must do this manually. Other 
planned enhancements include removing "RC" tags from filename, and allowing the 
creation and deletion of directories.
+      We plan to add tools to help release managers to distribute release 
artifacts on distribution networks. Currently you must do this manually.
     </p>
   </div>
 
@@ -176,14 +176,39 @@
       {{ delete_dir_form.hidden_tag() }}
       <div class="input-group">
         {{ delete_dir_form.directory_to_delete(class="form-select") }}
-        <button type="submit"
-                class="btn btn-danger"
-                name="form_action"
-                value="delete_empty_dir">Delete directory</button>
+        {{ delete_dir_form.submit_delete_empty_dir(class="btn btn-danger") }}
       </div>
       {{ forms.errors(delete_dir_form.directory_to_delete, 
classes="text-danger small mt-1") }}
     </form>
   {% endif %}
+
+  <h2>Remove release candidate tags</h2>
+  {% if rc_affected_count > 0 %}
+    <div class="alert alert-info mb-3">
+      <p class="mb-3 fw-semibold">
+        {{ rc_affected_count }} / {{ rc_total_paths }} paths would be affected 
by RC tag removal.
+      </p>
+      {% if rc_affected_paths_preview %}
+        <p class="mb-2">Preview of first {{ rc_affected_paths_preview | length 
}} changes:</p>
+        <table class="table table-sm table-striped border mt-3">
+          <tbody>
+            {% for original, stripped in rc_affected_paths_preview %}
+              <tr>
+                <td>{{ original | safe }}</td>
+                <td>{{ stripped }}</td>
+              </tr>
+            {% endfor %}
+          </tbody>
+        </table>
+      {% endif %}
+    </div>
+    <form method="post" class="mb-4 atr-canary">
+      {{ remove_rc_tags_form.hidden_tag() }}
+      {{ remove_rc_tags_form.submit_remove_rc_tags(class="btn btn-warning") }}
+    </form>
+  {% else %}
+    <p>No paths with RC tags found to remove.</p>
+  {% endif %}
 {% endblock content %}
 
 {% block javascripts %}
diff --git a/atr/util.py b/atr/util.py
index a97b49f..0326d88 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -147,6 +147,13 @@ async def async_temporary_directory(
             pass
 
 
+def chmod_directories(path: pathlib.Path, permissions: int = 0o755) -> None:
+    os.chmod(path, permissions)
+    for dir_path in path.rglob("*"):
+        if dir_path.is_dir():
+            os.chmod(dir_path, permissions)
+
+
 def compute_sha3_256(file_data: bytes) -> str:
     """Compute SHA3-256 hash of file data."""
     return hashlib.sha3_256(file_data).hexdigest()
@@ -161,13 +168,6 @@ async def compute_sha512(file_path: pathlib.Path) -> str:
     return sha512.hexdigest()
 
 
-def chmod_directories(path: pathlib.Path, permissions: int = 0o755) -> None:
-    os.chmod(path, permissions)
-    for dir_path in path.rglob("*"):
-        if dir_path.is_dir():
-            os.chmod(dir_path, permissions)
-
-
 async def content_list(
     phase_subdir: pathlib.Path, project_name: str, version_name: str, 
revision_name: str | None = None
 ) -> AsyncGenerator[FileStat]:


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to