This is an automated email from the ASF dual-hosted git repository.

arm pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit f6a4841cc6916704a47c4a7d59186470a91da623
Author: Alastair McFarlane <[email protected]>
AuthorDate: Fri Mar 27 17:22:56 2026 +0000

    #931 - tidy up file planner code
---
 atr/get/compose.py                                 | 293 ++++++++++++++++++-
 atr/get/finish.py                                  | 108 +------
 atr/post/compose.py                                |   9 +-
 atr/post/finish.py                                 |  46 ---
 atr/shared/__init__.py                             |   2 +
 atr/shared/{finish.py => compose.py}               |  19 +-
 atr/shared/finish.py                               |  23 +-
 atr/shared/web.py                                  | 316 ---------------------
 atr/static/js/ts/create-a-jwt.js                   |   1 +
 atr/static/js/ts/create-a-jwt.js.map               |   2 +-
 atr/static/js/ts/finish-selected-move.js.map       |   1 -
 .../ts/{finish-selected-move.js => move-files.js}  |   2 +-
 atr/static/js/ts/move-files.js.map                 |   1 +
 .../ts/{finish-selected-move.ts => move-files.ts}  |   0
 tests/e2e/announce/test_get.py                     |  13 -
 tests/e2e/compose/conftest.py                      |   2 +-
 tests/e2e/compose/test_get.py                      |  13 +
 tests/e2e/sbom/test_post.py                        |   8 +-
 18 files changed, 326 insertions(+), 533 deletions(-)

diff --git a/atr/get/compose.py b/atr/get/compose.py
index e5d552d6..906ba499 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -14,19 +14,35 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import pathlib
+from typing import TYPE_CHECKING, Literal
 
-from typing import Literal
-
+import aiofiles.os
 import asfquart.base as base
+import htpy
+from quart_wtf import utils
 
 import atr.blueprints.get as get
 import atr.db as db
+import atr.db.interaction as interaction
+import atr.form as form
+import atr.htm as htm
 import atr.mapping as mapping
+import atr.models.results as results
 import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.paths as paths
+import atr.post as post
 import atr.shared as shared
+import atr.shared.draft as draft
+import atr.storage as storage
+import atr.template as template
+import atr.util as util
 import atr.web as web
 
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+
 
 @get.typed
 async def selected(
@@ -49,4 +65,275 @@ async def selected(
         ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
     if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
         return await mapping.release_as_redirect(session, release)
-    return await shared.web.check(session, release)
+
+    base_path = paths.release_directory(release)
+
+    # TODO: This takes 180ms for providers
+    # We could cache it
+    all_paths = [path async for path in util.paths_recursive(base_path)]
+    all_paths.sort()
+
+    async with storage.read(session) as read:
+        ragp = read.as_general_public()
+        info = await ragp.releases.path_info(release, all_paths)
+
+    user_ssh_keys: Sequence[sql.SSHKey] = []
+    asf_id: str | None = None
+    server_domain: str | None = None
+    server_host: str | None = None
+
+    asf_id = session.uid
+    server_domain = session.app_host.split(":", 1)[0]
+    server_host = session.app_host
+    async with db.session() as data:
+        user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
+
+    async with db.session() as data:
+        quarantined_pending = await data.quarantined(release_key=release.key, 
status=sql.QuarantineStatus.PENDING).all()
+        quarantined_failed = await data.quarantined(release_key=release.key, 
status=sql.QuarantineStatus.FAILED).all()
+
+    clear_quarantine_forms: dict[int, htm.Element] = {}
+    for q in quarantined_failed:
+        if q.id is None:
+            continue
+        clear_quarantine_forms[q.id] = form.render(
+            model_cls=draft.ClearQuarantineForm,
+            action=util.as_url(
+                post.draft.quarantine_clear,
+                project_key=release.safe_project_key,
+                version_key=release.safe_version_key,
+            ),
+            form_classes=".d-inline-block.m-0",
+            submit_classes="btn-sm btn-outline-secondary",
+            submit_label="Dismiss",
+            empty=True,
+            defaults={"quarantined_id": str(q.id)},
+        )
+
+    # Get the number of ongoing tasks for the current revision
+    ongoing_tasks_count = 0
+    match await interaction.latest_info(release.safe_project_key, 
release.safe_version_key):
+        case (revision_number, revision_editor, revision_timestamp):
+            ongoing_tasks_count = await interaction.tasks_ongoing(
+                release.safe_project_key,
+                release.safe_version_key,
+                revision_number,
+            )
+        case None:
+            revision_number = None
+            revision_editor = None
+            revision_timestamp = None
+
+    delete_form = form.render(
+        model_cls=form.Empty,
+        action=util.as_url(selected, project_key=release.safe_project_key, 
version_key=release.safe_version_key),
+        submit_label="Delete this draft",
+        submit_classes="btn btn-danger",
+        empty=True,
+        confirm="Are you sure you want to delete this draft? This cannot be 
undone.",
+    )
+
+    delete_file_forms: dict[str, htm.Element] = {}
+    for path in all_paths:
+        delete_file_forms[str(path)] = form.render(
+            model_cls=draft.DeleteFileForm,
+            action=util.as_url(
+                post.draft.delete_file, project_key=release.safe_project_key, 
version_key=release.safe_version_key
+            ),
+            form_classes=".d-inline-block.m-0",
+            submit_classes="btn-sm btn-outline-danger",
+            submit_label="Delete",
+            empty=True,
+            defaults={"file_path": str(path)},
+            # TODO: Add a static check for the confirm syntax
+            confirm=(
+                "Are you sure you want to delete this file? "
+                "This will also delete any associated metadata files. "
+                "This cannot be undone."
+            ),
+        )
+
+    recheck_form = form.render(
+        model_cls=form.Empty,
+        action=util.as_url(
+            post.draft.recheck, project_key=release.safe_project_key, 
version_key=release.safe_version_key
+        ),
+        submit_label="Recheck with fresh cache",
+        submit_classes="btn btn-primary",
+    )
+    cache_reset_form = form.render(
+        model_cls=form.Empty,
+        action=util.as_url(
+            post.draft.cache_reset, project_key=release.safe_project_key, 
version_key=release.safe_version_key
+        ),
+        submit_label="Recheck with global cache",
+        submit_classes="btn btn-primary",
+        submit_disabled=release.check_cache_key is None,
+    )
+
+    has_files = await util.has_files(release)
+
+    blocker_errors = False
+    if revision_number is not None:
+        blocker_errors = await interaction.has_blocker_checks(release, 
revision_number)
+
+    checks_summary_html = shared.web.render_checks_summary(info, 
release.safe_project_key, release.safe_version_key)
+    move_file_html = _render_move_section(10)
+
+    csrf_token = utils.generate_csrf()
+    # Should be already validated, but check again
+    latest_revision_dir = paths.release_directory(release)
+    source_files_rel, target_dirs = await 
_sources_and_targets(latest_revision_dir)
+    safe_source_files_rel = [util.validate_path(f).as_posix() for f in 
sorted(source_files_rel)]
+    safe_target_dirs = [util.validate_path(d).as_posix() for d in 
sorted(target_dirs)]
+    scripts = htpy.fragment[
+        htpy.script(id="file-data", 
type="application/json")[util.json_for_script_element(safe_source_files_rel)],
+        htpy.script(id="dir-data", 
type="application/json")[util.json_for_script_element(safe_target_dirs)],
+        htpy.script(
+            id="main-script-data",
+            src=util.static_url("js/ts/move-files.js"),
+            **{"data-csrf-token": csrf_token},
+        )[""],
+    ]
+
+    return await template.render(
+        "check-selected.html",
+        project_key=release.project.key,
+        version_key=release.version,
+        release=release,
+        paths=all_paths,
+        info=info,
+        revision_editor=revision_editor,
+        revision_time=revision_timestamp,
+        revision_number=revision_number,
+        ongoing_tasks_count=ongoing_tasks_count,
+        quarantined_pending=quarantined_pending,
+        quarantined_failed=quarantined_failed,
+        clear_quarantine_forms=clear_quarantine_forms,
+        delete_form=delete_form,
+        delete_file_forms=delete_file_forms,
+        asf_id=asf_id,
+        server_domain=server_domain,
+        server_host=server_host,
+        user_ssh_keys=user_ssh_keys,
+        format_datetime=util.format_datetime,
+        models=sql,
+        recheck_form=recheck_form,
+        cache_reset_form=cache_reset_form,
+        csrf_input=str(form.csrf_input()),
+        has_files=has_files,
+        blocker_errors=blocker_errors,
+        checks_summary_html=checks_summary_html,
+        move_file_html=move_file_html,
+        scripts=str(scripts),
+    )
+
+
+def _render_move_section(max_files_to_show: int = 10) -> htm.Element:
+    """Render the move files section with JavaScript interaction."""
+    section = htm.Block()
+
+    section.h2["Move items to a different directory"]
+    section.p[
+        "You may ",
+        htm.strong["optionally"],
+        " move files between your directories here if you want change their 
location for the final release. "
+        "Note that files with associated metadata (e.g. ",
+        htm.code[".asc"],
+        " or ",
+        htm.code[".sha512"],
+        " files) are treated as a single unit and will be moved together if 
any one of them is selected for movement.",
+    ]
+
+    section.append(htm.div("#move-error-alert.alert.alert-danger.d-none", 
role="alert", **{"aria-live": "assertive"}))
+
+    left_card = htm.Block(htm.div, classes=".card.mb-4")
+    left_card.div(".card-header.bg-light")[htm.h3(".mb-0")["Select items to 
move"]]
+    left_card.div(".card-body")[
+        htpy.input(
+            "#file-filter.form-control.mb-2",
+            type="text",
+            placeholder="Search for an item to move...",
+        ),
+        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#file-list-table-body")],
+        htm.div("#file-list-more-info.text-muted.small.mt-1"),
+        htpy.button(
+            "#select-files-toggle-button.btn.btn-outline-secondary.w-100.mt-2",
+            type="button",
+        )["Select these files"],
+    ]
+
+    right_card = htm.Block(htm.div, classes=".card.mb-4")
+    right_card.div(".card-header.bg-light")[
+        htm.h3(".mb-0")[
+            htm.span("#selected-file-name-title")["Select a destination for 
the file"],
+            htm.span(".text-muted.small")[" (enter a new directory name to 
create it)"],
+        ]
+    ]
+    right_card.div(".card-body")[
+        htpy.input(
+            "#dir-filter-input.form-control.mb-2",
+            type="text",
+            placeholder="Search for a directory to move to...",
+        ),
+        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#dir-list-table-body")],
+        htm.div("#dir-list-more-info.text-muted.small.mt-1"),
+    ]
+
+    section.form(".atr-canary")[
+        htm.div(".row")[
+            htm.div(".col-lg-6")[left_card.collect()],
+            htm.div(".col-lg-6")[right_card.collect()],
+        ],
+        htm.div(".mb-3")[
+            htpy.label(".form-label", for_="maxFilesInput")["Items to show per 
list:"],
+            htpy.input(
+                "#max-files-input.form-control.form-control-sm.w-25",
+                type="number",
+                value=str(max_files_to_show),
+                min="1",
+            ),
+        ],
+        htm.div("#current-move-selection-info.text-muted")["Please select a 
file and a destination."],
+        htm.div[htpy.button("#confirm-move-button.btn.btn-success.mt-2", 
type="button")["Move to selected directory"]],
+    ]
+
+    return section.collect()
+
+
+async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> 
tuple[list[pathlib.Path], set[pathlib.Path]]:
+    source_items_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
+        source_items_rel.append(item_rel_path)
+
+        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):
+            pass
+        elif await aiofiles.os.path.isdir(item_abs_path):
+            target_dirs.add(item_rel_path)
+
+    return source_items_rel, target_dirs
+
+
+def _warnings_from_vote_result(vote_task: sql.Task | None) -> list[str]:
+    # TODO: Replace this with a schema.Strict model
+    # But we'd still need to do some of this parsing and validation
+    # We should probably rethink how to send data through tasks
+
+    if (not vote_task) or (not vote_task.result):
+        return ["No vote task result found."]
+
+    vote_task_result = vote_task.result
+    if not isinstance(vote_task_result, results.VoteInitiate):
+        return ["Vote task result is not a results.VoteInitiate instance."]
+
+    return vote_task_result.mail_send_warnings
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 70b2c07c..831b7d42 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -23,10 +23,8 @@ from typing import Literal
 
 import aiofiles.os
 import asfquart.base as base
-import htpy
 import markupsafe
 import quart
-import quart_wtf.utils as utils
 
 import atr.analysis as analysis
 import atr.blueprints.get as get
@@ -71,9 +69,7 @@ async def selected(
     """
     await session.check_access(project_key)
     try:
-        (release, source_files_rel, target_dirs, deletable_dirs, rc_analysis, 
tasks) = await _get_page_data(
-            project_key, version_key
-        )
+        (release, deletable_dirs, rc_analysis, tasks) = await 
_get_page_data(project_key, version_key)
     except ValueError:
         async with db.session() as data:
             release_fallback = await data.release(
@@ -105,8 +101,6 @@ async def selected(
 
     return await _render_page(
         release=release,
-        source_files_rel=source_files_rel,
-        target_dirs=target_dirs,
         deletable_dirs=deletable_dirs,
         rc_analysis=rc_analysis,
         distribution_tasks=tasks,
@@ -156,9 +150,7 @@ async def _deletable_choices(
 
 async def _get_page_data(
     project_key: safe.ProjectKey, version_key: safe.VersionKey
-) -> tuple[
-    sql.Release, list[pathlib.Path], set[pathlib.Path], list[tuple[str, str]], 
RCTagAnalysisResult, Sequence[sql.Task]
-]:
+) -> tuple[sql.Release, list[tuple[str, str]], RCTagAnalysisResult, 
Sequence[sql.Task]]:
     """Get all the data needed to render the finish page."""
     async with db.session() as data:
         via = sql.validate_instrumented_attribute
@@ -189,11 +181,11 @@ async def _get_page_data(
         raise ValueError("Release is not in preview phase")
 
     latest_revision_dir = paths.release_directory(release)
-    source_files_rel, target_dirs = await 
_sources_and_targets(latest_revision_dir)
+    _, target_dirs = await _sources_and_targets(latest_revision_dir)
     deletable_dirs = await _deletable_choices(latest_revision_dir, target_dirs)
     rc_analysis_result = await _analyse_rc_tags(latest_revision_dir)
 
-    return release, source_files_rel, target_dirs, deletable_dirs, 
rc_analysis_result, tasks
+    return release, deletable_dirs, rc_analysis_result, tasks
 
 
 def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> 
htm.Element:
@@ -293,79 +285,8 @@ def _render_distribution_tasks(release: sql.Release, 
tasks: Sequence[sql.Task])
     return block.collect()
 
 
-def _render_move_section(max_files_to_show: int = 10) -> htm.Element:
-    """Render the move files section with JavaScript interaction."""
-    section = htm.Block()
-
-    section.h2["Move items to a different directory"]
-    section.p[
-        "You may ",
-        htm.strong["optionally"],
-        " move files between your directories here if you want change their 
location for the final release. "
-        "Note that files with associated metadata (e.g. ",
-        htm.code[".asc"],
-        " or ",
-        htm.code[".sha512"],
-        " files) are treated as a single unit and will be moved together if 
any one of them is selected for movement.",
-    ]
-
-    section.append(htm.div("#move-error-alert.alert.alert-danger.d-none", 
role="alert", **{"aria-live": "assertive"}))
-
-    left_card = htm.Block(htm.div, classes=".card.mb-4")
-    left_card.div(".card-header.bg-light")[htm.h3(".mb-0")["Select items to 
move"]]
-    left_card.div(".card-body")[
-        htpy.input(
-            "#file-filter.form-control.mb-2",
-            type="text",
-            placeholder="Search for an item to move...",
-        ),
-        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#file-list-table-body")],
-        htm.div("#file-list-more-info.text-muted.small.mt-1"),
-        htpy.button(
-            "#select-files-toggle-button.btn.btn-outline-secondary.w-100.mt-2",
-            type="button",
-        )["Select these files"],
-    ]
-
-    right_card = htm.Block(htm.div, classes=".card.mb-4")
-    right_card.div(".card-header.bg-light")[
-        htm.h3(".mb-0")[htm.span("#selected-file-name-title")["Select a 
destination for the file"]]
-    ]
-    right_card.div(".card-body")[
-        htpy.input(
-            "#dir-filter-input.form-control.mb-2",
-            type="text",
-            placeholder="Search for a directory to move to...",
-        ),
-        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#dir-list-table-body")],
-        htm.div("#dir-list-more-info.text-muted.small.mt-1"),
-    ]
-
-    section.form(".atr-canary")[
-        htm.div(".row")[
-            htm.div(".col-lg-6")[left_card.collect()],
-            htm.div(".col-lg-6")[right_card.collect()],
-        ],
-        htm.div(".mb-3")[
-            htpy.label(".form-label", for_="maxFilesInput")["Items to show per 
list:"],
-            htpy.input(
-                "#max-files-input.form-control.form-control-sm.w-25",
-                type="number",
-                value=str(max_files_to_show),
-                min="1",
-            ),
-        ],
-        htm.div("#current-move-selection-info.text-muted")["Please select a 
file and a destination."],
-        htm.div[htpy.button("#confirm-move-button.btn.btn-success.mt-2", 
type="button")["Move to selected directory"]],
-    ]
-
-    return section.collect()
-
-
 async def _render_page(
     release: sql.Release,
-    source_files_rel: list,
-    target_dirs: set,
     deletable_dirs: list[tuple[str, str]],
     rc_analysis: RCTagAnalysisResult,
     distribution_tasks: Sequence[sql.Task],
@@ -405,9 +326,6 @@ async def _render_page(
     page.append(_render_dist_warning())
     page.append(_render_distribution_buttons(release))
 
-    # Move files section
-    page.append(_render_move_section(max_files_to_show=10))
-
     # Delete directory form
     if deletable_dirs:
         page.append(_render_delete_directory_form(deletable_dirs))
@@ -442,24 +360,6 @@ async def _render_page(
     """
     page.style[markupsafe.Markup(page_styles)]
 
-    # JavaScript data
-    # TODO: Add htm.script
-    csrf_token = utils.generate_csrf()
-    # Should be already validated, but check again
-    safe_source_files_rel = [util.validate_path(f).as_posix() for f in 
sorted(source_files_rel)]
-    safe_target_dirs = [util.validate_path(d).as_posix() for d in 
sorted(target_dirs)]
-    page.append(
-        htpy.script(id="file-data", 
type="application/json")[util.json_for_script_element(safe_source_files_rel)]
-    )
-    page.append(htpy.script(id="dir-data", 
type="application/json")[util.json_for_script_element(safe_target_dirs)])
-    page.append(
-        htpy.script(
-            id="main-script-data",
-            src=util.static_url("js/ts/finish-selected-move.js"),
-            **{"data-csrf-token": csrf_token},
-        )[""]
-    )
-
     content = page.collect()
 
     return await template.blank(
diff --git a/atr/post/compose.py b/atr/post/compose.py
index 640a5e6e..5c4aaca4 100644
--- a/atr/post/compose.py
+++ b/atr/post/compose.py
@@ -15,7 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from collections.abc import Awaitable, Callable
 from typing import Literal
 
 import quart
@@ -34,7 +33,7 @@ async def selected(
     _compose: Literal["compose"],
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
-    move_form: shared.finish.MoveFileForm,
+    move_form: shared.compose.MoveFileForm,
 ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
     """
     URL: /compose/<project_key>/<version_key>
@@ -46,11 +45,11 @@ async def selected(
 
 
 async def _move_file_to_revision(
-    move_form: shared.finish.MoveFileForm,
+    move_form: shared.compose.MoveFileForm,
     session: web.Committer,
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
-    respond: Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | 
web.WerkzeugResponse]],
+    respond: shared.compose.Respond,
 ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
     source_files_rel = move_form.source_files
     target_dir_rel = move_form.target_directory
@@ -91,7 +90,7 @@ async def _move_file_to_revision(
 
 def _respond_helper(
     session: web.Committer, project_key: safe.ProjectKey, version_key: 
safe.VersionKey, wants_json: bool
-) -> Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | 
web.WerkzeugResponse]]:
+) -> shared.compose.Respond:
     """Create a response helper function for the compose route."""
     import atr.get as get
 
diff --git a/atr/post/finish.py b/atr/post/finish.py
index afabcc1f..f370486e 100644
--- a/atr/post/finish.py
+++ b/atr/post/finish.py
@@ -45,8 +45,6 @@ async def selected(
     match finish_form:
         case shared.finish.DeleteEmptyDirectoryForm() as delete_form:
             return await _delete_empty_directory(delete_form, session, 
project_key, version_key, respond)
-        case shared.finish.MoveFileForm() as move_form:
-            return await _move_file_to_revision(move_form, session, 
project_key, version_key, respond)
         case shared.finish.RemoveRCTagsForm():
             return await _remove_rc_tags(session, project_key, version_key, 
respond)
 
@@ -74,50 +72,6 @@ async def _delete_empty_directory(
     return await respond(200, f"Deleted empty directory 
'{dir_to_delete_rel}'.")
 
 
-async def _move_file_to_revision(
-    move_form: shared.finish.MoveFileForm,
-    session: web.Committer,
-    project_key: safe.ProjectKey,
-    version_key: safe.VersionKey,
-    respond: shared.finish.Respond,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
-    source_files_rel = move_form.source_files
-    target_dir_rel = move_form.target_directory
-    try:
-        async with storage.write(session) as write:
-            wacp = await write.as_project_committee_member(project_key)
-            creation_error, moved_files_names, skipped_files_names = await 
wacp.release.move_file(
-                project_key, version_key, source_files_rel, target_dir_rel
-            )
-
-        if creation_error is not None:
-            return await respond(409, creation_error)
-
-        response_messages = []
-        if moved_files_names:
-            response_messages.append(f"Moved {', '.join(moved_files_names)}")
-        if skipped_files_names:
-            response_messages.append(f"Skipped {', 
'.join(skipped_files_names)} (already in target directory)")
-
-        if not response_messages:
-            if not source_files_rel:
-                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(200, msg)
-
-        return await respond(200, ". ".join(response_messages) + ".")
-
-    except FileNotFoundError:
-        log.exception("File not found during move operation in new revision")
-        return await respond(400, "Error: Source file not found during move 
operation.")
-    except OSError as e:
-        log.exception("Error moving file in new revision")
-        return await respond(500, f"Error moving file: {e}")
-    except Exception as e:
-        log.exception("Unexpected error during file move")
-        return await respond(500, f"ERROR: {e!s}")
-
-
 async def _remove_rc_tags(
     session: web.Committer,
     project_key: safe.ProjectKey,
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index 4090d591..2e639a6b 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -18,6 +18,7 @@
 from typing import Final
 
 import atr.shared.announce as announce
+import atr.shared.compose as compose
 import atr.shared.distribution as distribution
 import atr.shared.draft as draft
 import atr.shared.finish as finish
@@ -66,6 +67,7 @@ algorithms: Final[dict[int, str]] = {
 __all__ = [
     "algorithms",
     "announce",
+    "compose",
     "distribution",
     "draft",
     "finish",
diff --git a/atr/shared/finish.py b/atr/shared/compose.py
similarity index 77%
copy from atr/shared/finish.py
copy to atr/shared/compose.py
index 1a7f835f..eb0daf72 100644
--- a/atr/shared/finish.py
+++ b/atr/shared/compose.py
@@ -16,7 +16,7 @@
 # under the License.
 
 from collections.abc import Awaitable, Callable
-from typing import Annotated, Literal
+from typing import Literal
 
 import pydantic
 
@@ -26,14 +26,7 @@ import atr.web as web
 
 type Respond = Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | 
web.WerkzeugResponse]]
 
-type DELETE_DIR = Literal["DELETE_DIR"]
 type MOVE_FILE = Literal["MOVE_FILE"]
-type REMOVE_RC_TAGS = Literal["REMOVE_RC_TAGS"]
-
-
-class DeleteEmptyDirectoryForm(form.Form):
-    variant: DELETE_DIR = form.value(DELETE_DIR)
-    directory_to_delete: safe.RelPath = form.label("Directory to delete", 
widget=form.Widget.SELECT)
 
 
 class MoveFileForm(form.Form):
@@ -52,13 +45,3 @@ class MoveFileForm(form.Form):
             if source.parent == target_dir_path:
                 raise ValueError(f"Target directory cannot be the same as the 
source directory for {source.name}.")
         return self
-
-
-class RemoveRCTagsForm(form.Empty):
-    variant: REMOVE_RC_TAGS = form.value(REMOVE_RC_TAGS)
-
-
-type FinishForm = Annotated[
-    DeleteEmptyDirectoryForm | MoveFileForm | RemoveRCTagsForm,
-    form.DISCRIMINATOR,
-]
diff --git a/atr/shared/finish.py b/atr/shared/finish.py
index 1a7f835f..7b511368 100644
--- a/atr/shared/finish.py
+++ b/atr/shared/finish.py
@@ -18,8 +18,6 @@
 from collections.abc import Awaitable, Callable
 from typing import Annotated, Literal
 
-import pydantic
-
 import atr.form as form
 import atr.models.safe as safe
 import atr.web as web
@@ -27,7 +25,6 @@ import atr.web as web
 type Respond = Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | 
web.WerkzeugResponse]]
 
 type DELETE_DIR = Literal["DELETE_DIR"]
-type MOVE_FILE = Literal["MOVE_FILE"]
 type REMOVE_RC_TAGS = Literal["REMOVE_RC_TAGS"]
 
 
@@ -36,29 +33,11 @@ class DeleteEmptyDirectoryForm(form.Form):
     directory_to_delete: safe.RelPath = form.label("Directory to delete", 
widget=form.Widget.SELECT)
 
 
-class MoveFileForm(form.Form):
-    variant: MOVE_FILE = form.value(MOVE_FILE)
-    source_files: form.RelPathList = form.label("Files to move")
-    target_directory: safe.RelPath = form.label("Target directory")
-
-    @pydantic.model_validator(mode="after")
-    def validate_move(self) -> "MoveFileForm":
-        if not self.source_files:
-            raise ValueError("Please select at least one file to move.")
-
-        target_dir_path = self.target_directory.as_path()
-        for source_path in self.source_files:
-            source = source_path.as_path()
-            if source.parent == target_dir_path:
-                raise ValueError(f"Target directory cannot be the same as the 
source directory for {source.name}.")
-        return self
-
-
 class RemoveRCTagsForm(form.Empty):
     variant: REMOVE_RC_TAGS = form.value(REMOVE_RC_TAGS)
 
 
 type FinishForm = Annotated[
-    DeleteEmptyDirectoryForm | MoveFileForm | RemoveRCTagsForm,
+    DeleteEmptyDirectoryForm | RemoveRCTagsForm,
     form.DISCRIMINATOR,
 ]
diff --git a/atr/shared/web.py b/atr/shared/web.py
index 1e87d1d2..49c11f7e 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -14,219 +14,13 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import pathlib
-from typing import TYPE_CHECKING
 
-import aiofiles.os
 import htpy
-import quart_wtf.utils as utils
 
-import atr.db as db
-import atr.db.interaction as interaction
-import atr.form as form
-import atr.get as get
 import atr.htm as htm
-import atr.models.results as results
 import atr.models.safe as safe
-import atr.models.sql as sql
-import atr.paths as paths
-import atr.post as post
-import atr.shared.draft as draft
-import atr.storage as storage
 import atr.storage.types as types
-import atr.template as template
 import atr.util as util
-import atr.web as web
-
-if TYPE_CHECKING:
-    from collections.abc import Sequence
-
-
-async def check(
-    session: web.Committer | None,
-    release: sql.Release,
-    task_mid: str | None = None,
-    vote_form: htm.Element | None = None,
-    resolve_form: htm.Element | None = None,
-    archive_url: str | None = None,
-    vote_task: sql.Task | None = None,
-    can_vote: bool = False,
-    can_resolve: bool = False,
-) -> web.WerkzeugResponse | str:
-    base_path = paths.release_directory(release)
-
-    # TODO: This takes 180ms for providers
-    # We could cache it
-    all_paths = [path async for path in util.paths_recursive(base_path)]
-    all_paths.sort()
-
-    async with storage.read(session) as read:
-        ragp = read.as_general_public()
-        info = await ragp.releases.path_info(release, all_paths)
-
-    user_ssh_keys: Sequence[sql.SSHKey] = []
-    asf_id: str | None = None
-    server_domain: str | None = None
-    server_host: str | None = None
-
-    if session is not None:
-        asf_id = session.uid
-        server_domain = session.app_host.split(":", 1)[0]
-        server_host = session.app_host
-        async with db.session() as data:
-            user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
-
-    async with db.session() as data:
-        quarantined_pending = await data.quarantined(release_key=release.key, 
status=sql.QuarantineStatus.PENDING).all()
-        quarantined_failed = await data.quarantined(release_key=release.key, 
status=sql.QuarantineStatus.FAILED).all()
-
-    clear_quarantine_forms: dict[int, htm.Element] = {}
-    if session is not None:
-        for q in quarantined_failed:
-            if q.id is None:
-                continue
-            clear_quarantine_forms[q.id] = form.render(
-                model_cls=draft.ClearQuarantineForm,
-                action=util.as_url(
-                    post.draft.quarantine_clear,
-                    project_key=release.safe_project_key,
-                    version_key=release.safe_version_key,
-                ),
-                form_classes=".d-inline-block.m-0",
-                submit_classes="btn-sm btn-outline-secondary",
-                submit_label="Dismiss",
-                empty=True,
-                defaults={"quarantined_id": str(q.id)},
-            )
-
-    # Get the number of ongoing tasks for the current revision
-    ongoing_tasks_count = 0
-    match await interaction.latest_info(release.safe_project_key, 
release.safe_version_key):
-        case (revision_number, revision_editor, revision_timestamp):
-            ongoing_tasks_count = await interaction.tasks_ongoing(
-                release.safe_project_key,
-                release.safe_version_key,
-                revision_number,
-            )
-        case None:
-            revision_number = None
-            revision_editor = None
-            revision_timestamp = None
-
-    delete_form = form.render(
-        model_cls=form.Empty,
-        action=util.as_url(
-            get.compose.selected, project_key=release.safe_project_key, 
version_key=release.safe_version_key
-        ),
-        submit_label="Delete this draft",
-        submit_classes="btn btn-danger",
-        empty=True,
-        confirm="Are you sure you want to delete this draft? This cannot be 
undone.",
-    )
-
-    delete_file_forms: dict[str, htm.Element] = {}
-    for path in all_paths:
-        delete_file_forms[str(path)] = form.render(
-            model_cls=draft.DeleteFileForm,
-            action=util.as_url(
-                post.draft.delete_file, project_key=release.safe_project_key, 
version_key=release.safe_version_key
-            ),
-            form_classes=".d-inline-block.m-0",
-            submit_classes="btn-sm btn-outline-danger",
-            submit_label="Delete",
-            empty=True,
-            defaults={"file_path": str(path)},
-            # TODO: Add a static check for the confirm syntax
-            confirm=(
-                "Are you sure you want to delete this file? "
-                "This will also delete any associated metadata files. "
-                "This cannot be undone."
-            ),
-        )
-
-    recheck_form = form.render(
-        model_cls=form.Empty,
-        action=util.as_url(
-            post.draft.recheck, project_key=release.safe_project_key, 
version_key=release.safe_version_key
-        ),
-        submit_label="Recheck with fresh cache",
-        submit_classes="btn btn-primary",
-    )
-    cache_reset_form = form.render(
-        model_cls=form.Empty,
-        action=util.as_url(
-            post.draft.cache_reset, project_key=release.safe_project_key, 
version_key=release.safe_version_key
-        ),
-        submit_label="Recheck with global cache",
-        submit_classes="btn btn-primary",
-        submit_disabled=release.check_cache_key is None,
-    )
-
-    vote_task_warnings = _warnings_from_vote_result(vote_task)
-    has_files = await util.has_files(release)
-
-    blocker_errors = False
-    if revision_number is not None:
-        blocker_errors = await interaction.has_blocker_checks(release, 
revision_number)
-
-    checks_summary_html = render_checks_summary(info, 
release.safe_project_key, release.safe_version_key)
-    move_file_html = _render_move_section(10)
-
-    csrf_token = utils.generate_csrf()
-    # Should be already validated, but check again
-    latest_revision_dir = paths.release_directory(release)
-    source_files_rel, target_dirs = await 
_sources_and_targets(latest_revision_dir)
-    safe_source_files_rel = [util.validate_path(f).as_posix() for f in 
sorted(source_files_rel)]
-    safe_target_dirs = [util.validate_path(d).as_posix() for d in 
sorted(target_dirs)]
-    scripts = htpy.fragment[
-        htpy.script(id="file-data", 
type="application/json")[util.json_for_script_element(safe_source_files_rel)],
-        htpy.script(id="dir-data", 
type="application/json")[util.json_for_script_element(safe_target_dirs)],
-        htpy.script(
-            id="main-script-data",
-            src=util.static_url("js/ts/finish-selected-move.js"),
-            **{"data-csrf-token": csrf_token},
-        )[""],
-    ]
-
-    return await template.render(
-        "check-selected.html",
-        project_key=release.project.key,
-        version_key=release.version,
-        release=release,
-        paths=all_paths,
-        info=info,
-        revision_editor=revision_editor,
-        revision_time=revision_timestamp,
-        revision_number=revision_number,
-        ongoing_tasks_count=ongoing_tasks_count,
-        quarantined_pending=quarantined_pending,
-        quarantined_failed=quarantined_failed,
-        clear_quarantine_forms=clear_quarantine_forms,
-        delete_form=delete_form,
-        delete_file_forms=delete_file_forms,
-        asf_id=asf_id,
-        server_domain=server_domain,
-        server_host=server_host,
-        user_ssh_keys=user_ssh_keys,
-        format_datetime=util.format_datetime,
-        models=sql,
-        task_mid=task_mid,
-        vote_form=vote_form,
-        vote_task=vote_task,
-        archive_url=archive_url,
-        vote_task_warnings=vote_task_warnings,
-        recheck_form=recheck_form,
-        cache_reset_form=cache_reset_form,
-        csrf_input=str(form.csrf_input()),
-        resolve_form=resolve_form,
-        has_files=has_files,
-        blocker_errors=blocker_errors,
-        can_vote=can_vote,
-        can_resolve=can_resolve,
-        checks_summary_html=checks_summary_html,
-        move_file_html=move_file_html,
-        scripts=str(scripts),
-    )
 
 
 def render_checks_summary(
@@ -284,113 +78,3 @@ def render_checks_summary(
 
 def _checker_display_name(checker: str) -> str:
     return checker.removeprefix("atr.tasks.checks.").replace("_", " 
").replace(".", " ").title()
-
-
-def _render_move_section(max_files_to_show: int = 10) -> htm.Element:
-    """Render the move files section with JavaScript interaction."""
-    section = htm.Block()
-
-    section.h2["Move items to a different directory"]
-    section.p[
-        "You may ",
-        htm.strong["optionally"],
-        " move files between your directories here if you want change their 
location for the final release. "
-        "Note that files with associated metadata (e.g. ",
-        htm.code[".asc"],
-        " or ",
-        htm.code[".sha512"],
-        " files) are treated as a single unit and will be moved together if 
any one of them is selected for movement.",
-    ]
-
-    section.append(htm.div("#move-error-alert.alert.alert-danger.d-none", 
role="alert", **{"aria-live": "assertive"}))
-
-    left_card = htm.Block(htm.div, classes=".card.mb-4")
-    left_card.div(".card-header.bg-light")[htm.h3(".mb-0")["Select items to 
move"]]
-    left_card.div(".card-body")[
-        htpy.input(
-            "#file-filter.form-control.mb-2",
-            type="text",
-            placeholder="Search for an item to move...",
-        ),
-        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#file-list-table-body")],
-        htm.div("#file-list-more-info.text-muted.small.mt-1"),
-        htpy.button(
-            "#select-files-toggle-button.btn.btn-outline-secondary.w-100.mt-2",
-            type="button",
-        )["Select these files"],
-    ]
-
-    right_card = htm.Block(htm.div, classes=".card.mb-4")
-    right_card.div(".card-header.bg-light")[
-        htm.h3(".mb-0")[
-            htm.span("#selected-file-name-title")["Select a destination for 
the file"],
-            htm.span(".text-muted.small")[" (enter a new directory name to 
create it)"],
-        ]
-    ]
-    right_card.div(".card-body")[
-        htpy.input(
-            "#dir-filter-input.form-control.mb-2",
-            type="text",
-            placeholder="Search for a directory to move to...",
-        ),
-        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#dir-list-table-body")],
-        htm.div("#dir-list-more-info.text-muted.small.mt-1"),
-    ]
-
-    section.form(".atr-canary")[
-        htm.div(".row")[
-            htm.div(".col-lg-6")[left_card.collect()],
-            htm.div(".col-lg-6")[right_card.collect()],
-        ],
-        htm.div(".mb-3")[
-            htpy.label(".form-label", for_="maxFilesInput")["Items to show per 
list:"],
-            htpy.input(
-                "#max-files-input.form-control.form-control-sm.w-25",
-                type="number",
-                value=str(max_files_to_show),
-                min="1",
-            ),
-        ],
-        htm.div("#current-move-selection-info.text-muted")["Please select a 
file and a destination."],
-        htm.div[htpy.button("#confirm-move-button.btn.btn-success.mt-2", 
type="button")["Move to selected directory"]],
-    ]
-
-    return section.collect()
-
-
-async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> 
tuple[list[pathlib.Path], set[pathlib.Path]]:
-    source_items_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
-        source_items_rel.append(item_rel_path)
-
-        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):
-            pass
-        elif await aiofiles.os.path.isdir(item_abs_path):
-            target_dirs.add(item_rel_path)
-
-    return source_items_rel, target_dirs
-
-
-def _warnings_from_vote_result(vote_task: sql.Task | None) -> list[str]:
-    # TODO: Replace this with a schema.Strict model
-    # But we'd still need to do some of this parsing and validation
-    # We should probably rethink how to send data through tasks
-
-    if (not vote_task) or (not vote_task.result):
-        return ["No vote task result found."]
-
-    vote_task_result = vote_task.result
-    if not isinstance(vote_task_result, results.VoteInitiate):
-        return ["Vote task result is not a results.VoteInitiate instance."]
-
-    return vote_task_result.mail_send_warnings
diff --git a/atr/static/js/ts/create-a-jwt.js b/atr/static/js/ts/create-a-jwt.js
index caf57e24..8f9b6bfb 100644
--- a/atr/static/js/ts/create-a-jwt.js
+++ b/atr/static/js/ts/create-a-jwt.js
@@ -43,6 +43,7 @@ document.addEventListener("DOMContentLoaded", () => {
             timeoutObj = setTimeout(() => {
                 output.textContent = "";
                 outputContainer.classList.add("d-none");
+                clearInterval(intervalObj);
             }, 60000);
             intervalObj = setInterval(() => {
                 time = time - 1;
diff --git a/atr/static/js/ts/create-a-jwt.js.map 
b/atr/static/js/ts/create-a-jwt.js.map
index 2ebe6f4b..72e5e216 100644
--- a/atr/static/js/ts/create-a-jwt.js.map
+++ b/atr/static/js/ts/create-a-jwt.js.map
@@ -1 +1 @@
-{"version":3,"file":"create-a-jwt.js","sourceRoot":"","sources":["../../ts/create-a-jwt.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,GAAS,EAAE;IACvD,MAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAA2B,CAAC;IACjF,MAAM,eAAe,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAA;IAChE,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;IACrD,MAAM,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,CAAC;IAC5D,IAAI,UAAkB,EAAE,WAAmB,CAAC;IAE5C,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,I
 [...]
\ No newline at end of file
+{"version":3,"file":"create-a-jwt.js","sourceRoot":"","sources":["../../ts/create-a-jwt.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,GAAS,EAAE;IACvD,MAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAA2B,CAAC;IACjF,MAAM,eAAe,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAA;IAChE,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;IACrD,MAAM,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,CAAC;IAC5D,IAAI,UAAkB,EAAE,WAAmB,CAAC;IAE5C,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,I
 [...]
\ No newline at end of file
diff --git a/atr/static/js/ts/finish-selected-move.js.map 
b/atr/static/js/ts/finish-selected-move.js.map
deleted file mode 100644
index efa6a0a6..00000000
--- a/atr/static/js/ts/finish-selected-move.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,YAAY,CAAC;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IACrB,iBAAiB,EAAE,qBAAqB;IACxC,wBAAwB,EAAE,6BAA6B;IACvD,OAAO,EAAE,UAAU;IACnB,cAAc,EAAE,kBAAkB;IAClC,eAAe,EAAE,oBAAoB;IACrC,gBAAgB,EAAE,qBAAqB;IACvC,UAAU,EAAE,kBAAkB;IAC9B,QAAQ,EAAE,
 [...]
\ No newline at end of file
diff --git a/atr/static/js/ts/finish-selected-move.js 
b/atr/static/js/ts/move-files.js
similarity index 99%
rename from atr/static/js/ts/finish-selected-move.js
rename to atr/static/js/ts/move-files.js
index 56aebc7f..13d5c5d9 100644
--- a/atr/static/js/ts/finish-selected-move.js
+++ b/atr/static/js/ts/move-files.js
@@ -486,4 +486,4 @@ document.addEventListener("DOMContentLoaded", () => {
     });
     renderAllLists();
 });
-//# sourceMappingURL=finish-selected-move.js.map
\ No newline at end of file
+//# sourceMappingURL=move-files.js.map
\ No newline at end of file
diff --git a/atr/static/js/ts/move-files.js.map 
b/atr/static/js/ts/move-files.js.map
new file mode 100644
index 00000000..1d8810d5
--- /dev/null
+++ b/atr/static/js/ts/move-files.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"move-files.js","sourceRoot":"","sources":["../../ts/move-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,YAAY,CAAC;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IACrB,iBAAiB,EAAE,qBAAqB;IACxC,wBAAwB,EAAE,6BAA6B;IACvD,OAAO,EAAE,UAAU;IACnB,cAAc,EAAE,kBAAkB;IAClC,eAAe,EAAE,oBAAoB;IACrC,gBAAgB,EAAE,qBAAqB;IACvC,UAAU,EAAE,kBAAkB;IAC9B,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE
 [...]
\ No newline at end of file
diff --git a/atr/static/ts/finish-selected-move.ts b/atr/static/ts/move-files.ts
similarity index 100%
rename from atr/static/ts/finish-selected-move.ts
rename to atr/static/ts/move-files.ts
diff --git a/tests/e2e/announce/test_get.py b/tests/e2e/announce/test_get.py
index 72872f86..56cf8000 100644
--- a/tests/e2e/announce/test_get.py
+++ b/tests/e2e/announce/test_get.py
@@ -19,19 +19,6 @@ import e2e.announce.helpers as helpers  # type: 
ignore[reportMissingImports]
 from playwright.sync_api import Page, expect
 
 
-def test_finish_move_form_populates_from_json(page_finish: Page) -> None:
-    """The finish move form should populate rows from script JSON data."""
-    file_option = page_finish.locator("#file-list-table-body 
input[type='checkbox'][data-item-path]").first
-    dir_option = page_finish.locator("#dir-list-table-body 
input[type='radio'][name='target-directory-radio']").first
-    expect(file_option).to_be_visible()
-    expect(dir_option).to_be_visible()
-
-    toggle_button = page_finish.locator("#select-files-toggle-button")
-    expect(toggle_button).to_have_text("Select these files")
-    toggle_button.click()
-    expect(toggle_button).to_have_text("Unselect all")
-
-
 def test_path_adds_leading_slash(page_announce: Page) -> None:
     """Paths without a leading '/' should have one added."""
     help_text = helpers.fill_path_suffix(page_announce, "apple/banana")
diff --git a/tests/e2e/compose/conftest.py b/tests/e2e/compose/conftest.py
index 3ab325f8..4962ca42 100644
--- a/tests/e2e/compose/conftest.py
+++ b/tests/e2e/compose/conftest.py
@@ -75,6 +75,6 @@ def page_compose(compose_context: BrowserContext) -> 
Generator[Page]:
     """Navigate to the compose page with a fresh page for each test."""
     page = compose_context.new_page()
     helpers.visit(page, COMPOSE_URL)
-    page.get_by_role("cell", name=FILE_NAME, 
exact=True).wait_for(timeout=60000)
+    page.locator("#files-table-container").get_by_role("cell", name=FILE_NAME, 
exact=True).wait_for(timeout=60000)
     yield page
     page.close()
diff --git a/tests/e2e/compose/test_get.py b/tests/e2e/compose/test_get.py
index 2c0fcda9..f25ce7e2 100644
--- a/tests/e2e/compose/test_get.py
+++ b/tests/e2e/compose/test_get.py
@@ -111,3 +111,16 @@ def test_start_vote_button_has_title(page_compose: Page) 
-> None:
     """The start vote button should have a descriptive title."""
     vote_button = page_compose.locator("#start-vote-button")
     expect(vote_button).to_have_attribute("title", "Start a vote on this 
draft")
+
+
+def test_move_form_populates_from_json(page_compose: Page) -> None:
+    """The move form should populate rows from script JSON data."""
+    file_option = page_compose.locator("#file-list-table-body 
input[type='checkbox'][data-item-path]").first
+    dir_option = page_compose.locator("#dir-list-table-body 
input[type='radio'][name='target-directory-radio']").first
+    expect(file_option).to_be_visible()
+    expect(dir_option).to_be_visible()
+
+    toggle_button = page_compose.locator("#select-files-toggle-button")
+    expect(toggle_button).to_have_text("Select these files")
+    toggle_button.click()
+    expect(toggle_button).to_have_text("Unselect all")
diff --git a/tests/e2e/sbom/test_post.py b/tests/e2e/sbom/test_post.py
index dac26578..ebceedfe 100644
--- a/tests/e2e/sbom/test_post.py
+++ b/tests/e2e/sbom/test_post.py
@@ -22,7 +22,9 @@ from playwright.sync_api import Page, expect
 
 def test_sbom_generate(page_release_with_file: Page) -> None:
     # Make sure that the test file exists
-    file_cell = page_release_with_file.get_by_role("cell", 
name=sbom_helpers.FILE_NAME, exact=True)
+    file_cell = 
page_release_with_file.locator("#files-table-container").get_by_role(
+        "cell", name=sbom_helpers.FILE_NAME, exact=True
+    )
     expect(file_cell).to_be_visible()
 
     # Generate an SBOM for the file
@@ -38,5 +40,7 @@ def test_sbom_generate(page_release_with_file: Page) -> None:
     page_release_with_file.wait_for_selector("#ongoing-tasks-banner", 
state="hidden")
     page_release_with_file.reload()
 
-    sbom_cell = page_release_with_file.get_by_role("cell", 
name=f"{sbom_helpers.FILE_NAME}.cdx.json")
+    sbom_cell = 
page_release_with_file.locator("#files-table-container").get_by_role(
+        "cell", name=f"{sbom_helpers.FILE_NAME}.cdx.json"
+    )
     expect(sbom_cell).to_be_visible()


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

Reply via email to