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 804185b  Move artifact and metadata files together as a bundle, and 
report any errors
804185b is described below

commit 804185b696413a7b15792f7892040b1664bbb2d9
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon May 19 15:44:21 2025 +0100

    Move artifact and metadata files together as a bundle, and report any errors
---
 atr/routes/finish.py                  | 184 ++++++++++++++++++++++------------
 atr/static/js/finish-selected-move.js |  33 +++---
 atr/static/ts/finish-selected-move.ts |  33 +++---
 atr/templates/finish-selected.html    |   4 +
 4 files changed, 155 insertions(+), 99 deletions(-)

diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 151863e..22648df 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -21,6 +21,7 @@ from typing import Final
 
 import aiofiles.os
 import quart
+import quart.wrappers.response as quart_response
 import werkzeug.wrappers.response as response
 import wtforms
 
@@ -31,6 +32,8 @@ import atr.routes as routes
 import atr.routes.root as root
 import atr.util as util
 
+SPECIAL_SUFFIXES: Final[frozenset[str]] = frozenset({".asc", ".sha256", 
".sha512"})
+
 _LOGGER: Final = logging.getLogger(__name__)
 
 
@@ -53,7 +56,9 @@ class MoveFileForm(util.QuartFormTyped):
 
 
 @routes.committer("/finish/<project_name>/<version_name>", methods=["GET", 
"POST"])
-async def selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
+async def selected(
+    session: routes.CommitterSession, project_name: str, version_name: str
+) -> tuple[quart_response.Response, int] | response.Response | str:
     """Finish a release preview."""
     await session.check_access(project_name)
 
@@ -64,24 +69,8 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
         user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
 
     latest_revision_dir = util.release_directory(release)
-    source_files_rel: list[pathlib.Path] = []
-    target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
-
     try:
-        async for item_rel_path in 
util.paths_recursive_all(latest_revision_dir):
-            current_parent = item_rel_path.parent
-            while True:
-                target_dirs.add(current_parent)
-                if current_parent == pathlib.Path("."):
-                    break
-                current_parent = current_parent.parent
-
-            item_abs_path = latest_revision_dir / item_rel_path
-            if await aiofiles.os.path.isfile(item_abs_path):
-                source_files_rel.append(item_rel_path)
-            elif await aiofiles.os.path.isdir(item_abs_path):
-                target_dirs.add(item_rel_path)
-
+        source_files_rel, target_dirs = await 
_sources_and_targets(latest_revision_dir)
     except FileNotFoundError:
         await quart.flash("Preview revision directory not found.", "error")
         return await session.redirect(root.index)
@@ -94,11 +83,13 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
     can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
 
     if (quart.request.method == "POST") and can_move:
-        match r := await _move_file(form, session, project_name, version_name):
+        match await _move_file(form, session, project_name, version_name):
             case None:
                 pass
-            case response.Response():
-                return r
+            case tuple() as resp_tuple:
+                return resp_tuple
+            case resp_obj if isinstance(resp_obj, response.Response):
+                return resp_obj
 
     # resp = await quart.current_app.make_response(template_rendered)
     # resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -118,53 +109,118 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
     )
 
 
+def _related_files(path: pathlib.Path) -> list[pathlib.Path]:
+    base_path = path.with_suffix("") if (path.suffix in SPECIAL_SUFFIXES) else 
path
+    parent_dir = base_path.parent
+    name_without_ext = base_path.name
+    return [
+        parent_dir / name_without_ext,
+        parent_dir / f"{name_without_ext}.asc",
+        parent_dir / f"{name_without_ext}.sha256",
+        parent_dir / f"{name_without_ext}.sha512",
+    ]
+
+
 async def _move_file(
     form: MoveFileForm, session: routes.CommitterSession, project_name: str, 
version_name: str
-) -> response.Response | None:
+) -> 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_file_rel = pathlib.Path(form.source_file.data)
         target_dir_rel = pathlib.Path(form.target_directory.data)
-
-        try:
-            description = "File move through web interface"
-            async with revision.create_and_manage(project_name, version_name, 
session.uid, description=description) as (
-                new_revision_dir,
-                new_revision_number,
-            ):
-                source_path_in_new = new_revision_dir / source_file_rel
-                target_path_in_new = new_revision_dir / target_dir_rel / 
source_file_rel.name
-
-                if await aiofiles.os.path.exists(target_path_in_new):
-                    await quart.flash(
-                        f"File '{source_file_rel.name}' already exists in 
'{target_dir_rel}' in new revision.",
-                        "error",
-                    )
-                    return await session.redirect(selected, 
project_name=project_name, version_name=version_name)
-
-                _LOGGER.info(
-                    f"Moving {source_path_in_new} to {target_path_in_new} in 
new revision {new_revision_number}"
-                )
-                await aiofiles.os.rename(source_path_in_new, 
target_path_in_new)
-
-            await quart.flash(
-                f"File '{source_file_rel.name}' moved successfully to 
'{target_dir_rel}' in new revision.", "success"
-            )
-            return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
-
-        except FileNotFoundError:
-            _LOGGER.exception("File not found during move operation in new 
revision")
-            await quart.flash("Error: Source file not found during move 
operation.", "error")
-        except OSError as e:
-            _LOGGER.exception("Error moving file in new revision")
-            await quart.flash(f"Error moving file: {e}", "error")
-        except Exception as e:
-            _LOGGER.exception("Unexpected error during file move")
-            await quart.flash(f"An unexpected error occurred: {e!s}", "error")
-            return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+        return await _move_file_to_revision(
+            source_file_rel, target_dir_rel, session, project_name, 
version_name, wants_json
+        )
     else:
-        for field, errors in form.errors.items():
-            field_label = getattr(getattr(form, field, None), "label", None)
-            label_text = field_label.text if field_label else 
field.replace("_", " ").title()
-            for error in errors:
-                await quart.flash(f"{label_text}: {error}", "warning")
+        if wants_json:
+            error_messages = []
+            for field_name, field_errors in form.errors.items():
+                field_object = getattr(form, field_name, None)
+                label_object = getattr(field_object, "label", None)
+                label_text = label_object.text if label_object else 
field_name.replace("_", " ").title()
+                error_messages.append(f"{label_text}: {', 
'.join(field_errors)}")
+            error_string = "; ".join(error_messages)
+            return quart.jsonify(error=error_string), 400
+        else:
+            for field_name, field_errors in form.errors.items():
+                field_object = getattr(form, field_name, None)
+                label_object = getattr(field_object, "label", None)
+                label_text = label_object.text if label_object else 
field_name.replace("_", " ").title()
+                for error_message_text in field_errors:
+                    await quart.flash(f"{label_text}: {error_message_text}", 
"warning")
     return None
+
+
+async def _move_file_to_revision(
+    source_file_rel: pathlib.Path,
+    target_dir_rel: pathlib.Path,
+    session: routes.CommitterSession,
+    project_name: str,
+    version_name: str,
+    wants_json: bool,
+) -> tuple[quart_response.Response, int] | response.Response | None:
+    try:
+        description = "File move through web interface"
+        async with revision.create_and_manage(project_name, version_name, 
session.uid, description=description) as (
+            new_revision_dir,
+            _new_revision_number,
+        ):
+            related_files = _related_files(source_file_rel)
+            bundle = [f for f in related_files if await 
aiofiles.os.path.exists(new_revision_dir / f)]
+            collisions = [
+                f.name for f in bundle if await 
aiofiles.os.path.exists(new_revision_dir / target_dir_rel / f.name)
+            ]
+            if collisions:
+                msg = f"Files already exist in '{target_dir_rel}': {', 
'.join(collisions)}"
+                if wants_json:
+                    return quart.jsonify(error=msg), 400
+                await quart.flash(msg, "error")
+                return await session.redirect(selected, 
project_name=project_name, version_name=version_name)
+
+            for f in bundle:
+                await aiofiles.os.rename(new_revision_dir / f, 
new_revision_dir / target_dir_rel / f.name)
+
+        await quart.flash(f"Moved {', '.join(f.name for f in bundle)}", 
"success")
+        return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+
+    except FileNotFoundError:
+        _LOGGER.exception("File not found during move operation in new 
revision")
+        msg = "Error: Source file not found during move operation."
+        if wants_json:
+            return quart.jsonify(error=msg), 400
+        await quart.flash(msg, "error")
+    except OSError as e:
+        _LOGGER.exception("Error moving file in new revision")
+        msg = f"Error moving file: {e}"
+        if wants_json:
+            return quart.jsonify(error=msg), 500
+        await quart.flash(msg, "error")
+    except Exception as e:
+        _LOGGER.exception("Unexpected error during file move")
+        msg = f"An unexpected error occurred: {e!s}"
+        if wants_json:
+            return quart.jsonify(error=msg), 500
+        await quart.flash(msg, "error")
+
+    return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+
+
+async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> 
tuple[list[pathlib.Path], set[pathlib.Path]]:
+    source_files_rel: list[pathlib.Path] = []
+    target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
+
+    async for item_rel_path in util.paths_recursive_all(latest_revision_dir):
+        current_parent = item_rel_path.parent
+        while True:
+            target_dirs.add(current_parent)
+            if current_parent == pathlib.Path("."):
+                break
+            current_parent = current_parent.parent
+
+        item_abs_path = latest_revision_dir / item_rel_path
+        if await aiofiles.os.path.isfile(item_abs_path):
+            source_files_rel.append(item_rel_path)
+        elif await aiofiles.os.path.isdir(item_abs_path):
+            target_dirs.add(item_rel_path)
+
+    return source_files_rel, target_dirs
diff --git a/atr/static/js/finish-selected-move.js 
b/atr/static/js/finish-selected-move.js
index 875604c..6f96fc6 100644
--- a/atr/static/js/finish-selected-move.js
+++ b/atr/static/js/finish-selected-move.js
@@ -19,6 +19,7 @@ const DIR_DATA_ID = "dir-data";
 const DIR_FILTER_INPUT_ID = "dir-filter-input";
 const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
 const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
+const ERROR_ALERT_ID = "move-error-alert";
 const FILE_DATA_ID = "file-data";
 const FILE_FILTER_ID = "file-filter";
 const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
@@ -39,6 +40,7 @@ let dirFilterInput;
 let dirListTableBody;
 let confirmMoveButton;
 let currentMoveSelectionInfoElement;
+let errorAlert;
 let uiState;
 function getParentPath(filePathString) {
     if (!filePathString || typeof filePathString !== "string")
@@ -170,16 +172,10 @@ function handleFileSelection(filePath) {
     }
     uiState.currentlySelectedFilePath = filePath;
     renderAllLists();
-    if (!confirmMoveButton.disabled) {
-        confirmMoveButton.focus();
-    }
 }
 function handleDirSelection(dirPath) {
     uiState.currentlyChosenDirectoryPath = dirPath;
     renderAllLists();
-    if (!confirmMoveButton.disabled) {
-        confirmMoveButton.focus();
-    }
 }
 function onFileListClick(event) {
     const targetElement = event.target;
@@ -217,6 +213,7 @@ function onMaxFilesChange(event) {
 }
 function onConfirmMoveClick() {
     return __awaiter(this, void 0, void 0, function* () {
+        errorAlert.classList.add("d-none");
         if (uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
             const formData = new FormData();
             formData.append("csrf_token", uiState.csrfToken);
@@ -236,29 +233,30 @@ function onConfirmMoveClick() {
                 }
                 else {
                     let errorMsg = `An error occurred while moving the file 
(Status: ${response.status})`;
-                    if (response.status === 403) {
+                    if (response.status === 403)
                         errorMsg = "Permission denied to move the file.";
-                    }
-                    else if (response.status === 400) {
-                        errorMsg = "Invalid request to move the file (e.g. 
source or target invalid).";
-                    }
+                    if (response.status === 400)
+                        errorMsg = "Invalid request to move the file.";
                     try {
                         const errorData = yield response.json();
-                        if (errorData && errorData.error) {
+                        if (errorData && typeof errorData.error === "string")
                             errorMsg = errorData.error;
-                        }
                     }
-                    catch (e) { }
-                    alert(errorMsg);
+                    catch (_) { }
+                    errorAlert.textContent = errorMsg;
+                    errorAlert.classList.remove("d-none");
+                    return;
                 }
             }
             catch (error) {
                 console.error("Network or fetch error:", error);
-                alert("A network error occurred. Please check your connection 
and try again.");
+                errorAlert.textContent = "A network error occurred. Please 
check your connection and try again.";
+                errorAlert.classList.remove("d-none");
             }
         }
         else {
-            alert("Please select both a file to move and a destination 
directory.");
+            errorAlert.textContent = "Please select both a file to move and a 
destination directory.";
+            errorAlert.classList.remove("d-none");
         }
     });
 }
@@ -272,6 +270,7 @@ document.addEventListener("DOMContentLoaded", function () {
     confirmMoveButton = 
assertElementPresent(document.querySelector(`#${CONFIRM_MOVE_BUTTON_ID}`), 
CONFIRM_MOVE_BUTTON_ID);
     currentMoveSelectionInfoElement = 
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID), 
CURRENT_MOVE_SELECTION_INFO_ID);
     currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
+    errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID), 
ERROR_ALERT_ID);
     let initialFilePaths = [];
     let initialTargetDirs = [];
     try {
diff --git a/atr/static/ts/finish-selected-move.ts 
b/atr/static/ts/finish-selected-move.ts
index 2b27c94..f969642 100644
--- a/atr/static/ts/finish-selected-move.ts
+++ b/atr/static/ts/finish-selected-move.ts
@@ -11,6 +11,7 @@ const DIR_DATA_ID = "dir-data";
 const DIR_FILTER_INPUT_ID = "dir-filter-input";
 const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
 const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
+const ERROR_ALERT_ID = "move-error-alert";
 const FILE_DATA_ID = "file-data";
 const FILE_FILTER_ID = "file-filter";
 const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
@@ -75,6 +76,7 @@ let dirFilterInput: HTMLInputElement;
 let dirListTableBody: HTMLTableSectionElement;
 let confirmMoveButton: HTMLButtonElement;
 let currentMoveSelectionInfoElement: HTMLElement;
+let errorAlert: HTMLElement;
 
 let uiState: UIState;
 
@@ -232,17 +234,11 @@ function handleFileSelection(filePath: string | null): 
void {
     }
     uiState.currentlySelectedFilePath = filePath;
     renderAllLists();
-    if (!confirmMoveButton.disabled) {
-        confirmMoveButton.focus();
-    }
 }
 
 function handleDirSelection(dirPath: string | null): void {
     uiState.currentlyChosenDirectoryPath = dirPath;
     renderAllLists();
-    if (!confirmMoveButton.disabled) {
-        confirmMoveButton.focus();
-    }
 }
 
 function onFileListClick(event: Event): void {
@@ -284,6 +280,7 @@ function onMaxFilesChange(event: FilterInputEvent): void {
 }
 
 async function onConfirmMoveClick(): Promise<void> {
+    errorAlert.classList.add("d-none");
     if (uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
         const formData = new FormData();
         formData.append("csrf_token", uiState.csrfToken);
@@ -304,25 +301,24 @@ async function onConfirmMoveClick(): Promise<void> {
                 window.location.reload();
             } else {
                 let errorMsg = `An error occurred while moving the file 
(Status: ${response.status})`;
-                if (response.status === 403) {
-                    errorMsg = "Permission denied to move the file.";
-                } else if (response.status === 400) {
-                    errorMsg = "Invalid request to move the file (e.g. source 
or target invalid).";
-                }
+                if (response.status === 403) errorMsg = "Permission denied to 
move the file.";
+                if (response.status === 400) errorMsg = "Invalid request to 
move the file.";
                 try {
                     const errorData = await response.json();
-                    if (errorData && errorData.error) {
-                         errorMsg = errorData.error;
-                    }
-                } catch (e) {  }
-                alert(errorMsg);
+                    if (errorData && typeof errorData.error === "string") 
errorMsg = errorData.error;
+                } catch (_) { }
+                errorAlert.textContent = errorMsg;
+                errorAlert.classList.remove("d-none");
+                return;
             }
         } catch (error) {
             console.error("Network or fetch error:", error);
-            alert("A network error occurred. Please check your connection and 
try again.");
+            errorAlert.textContent = "A network error occurred. Please check 
your connection and try again.";
+            errorAlert.classList.remove("d-none");
         }
     } else {
-        alert("Please select both a file to move and a destination 
directory.");
+        errorAlert.textContent = "Please select both a file to move and a 
destination directory.";
+        errorAlert.classList.remove("d-none");
     }
 }
 
@@ -336,6 +332,7 @@ document.addEventListener("DOMContentLoaded", function () {
     confirmMoveButton = 
assertElementPresent(document.querySelector<HTMLButtonElement>(`#${CONFIRM_MOVE_BUTTON_ID}`),
 CONFIRM_MOVE_BUTTON_ID);
     currentMoveSelectionInfoElement = 
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID) as 
HTMLElement, CURRENT_MOVE_SELECTION_INFO_ID);
     currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
+    errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID) 
as HTMLElement, ERROR_ALERT_ID);
 
     let initialFilePaths: string[] = [];
     let initialTargetDirs: string[] = [];
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index d70ca36..b8d28fc 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -101,6 +101,10 @@
 
   {% if can_move %}
     <h2>Move a file to a different directory</h2>
+    <div id="move-error-alert"
+         class="alert alert-danger d-none"
+         role="alert"
+         aria-live="assertive"></div>
     <form class="atr-canary">
       <div class="row">
         <div class="col-lg-6">


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

Reply via email to