atr/routes/revisions.py (156 lines of code) (raw):

# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. import asyncio import contextlib import datetime import logging import pathlib from collections.abc import Callable import aiofiles.os import asfquart.base as base import quart import sqlmodel import werkzeug.wrappers.response as response import atr.db as db import atr.db.models as models import atr.routes as routes import atr.util as util @routes.committer("/revisions/<project_name>/<version_name>") async def selected(session: routes.CommitterSession, project_name: str, version_name: str) -> str: """Show the revision history for a release candidate draft or release preview.""" await session.check_access(project_name) try: release = await session.release(project_name, version_name) phase_key = "draft" except base.ASFQuartException: release = await session.release(project_name, version_name, phase=models.ReleasePhase.RELEASE_PREVIEW) phase_key = "preview" release_dir = util.release_directory_base(release) revision_dirs: list[str] = [] with contextlib.suppress(FileNotFoundError): for entry in await aiofiles.os.listdir(str(release_dir)): # Match pattern like "user@YYYY-MM-DDTHH.MM.SS.fffZ" if "@" in entry and entry.endswith("Z"): if await aiofiles.os.path.isdir(release_dir / entry): revision_dirs.append(entry) # Sort revisions by timestamp def sort_key(rev_name: str) -> datetime.datetime: try: # Remove trailing Z, though we could just put it in the template pattern timestamp_str = rev_name.split("@", 1)[1][:-1] return datetime.datetime.strptime(timestamp_str, "%Y-%m-%dT%H.%M.%S.%f") except (IndexError, ValueError): # Should not happen for valid names, put invalid ones last return datetime.datetime.min # Sort revisions by timestamp, newest first revision_dirs.sort(key=sort_key, reverse=True) async with db.session() as data: # Get parent links using a direct query due to the use of in_(...) query = sqlmodel.select(models.TextValue).where( models.TextValue.ns == release.name + f" {phase_key}", db.validate_instrumented_attribute(models.TextValue.key).in_(revision_dirs), ) parent_links_result = await data.execute(query) parent_map = {link.key: link.value for link in parent_links_result.scalars().all()} # Determine the current revision current_revision_name = release.revision revision_history = [] prev_revision_files: set[pathlib.Path] | None = None prev_revision_name: str | None = None # Oldest to newest, to build diffs relative to previous revision for rev_name in reversed(revision_dirs): revision_data, current_revision_files = await _revisions_process( rev_name, release_dir, parent_map, prev_revision_files, prev_revision_name, sort_key, ) revision_history.append(revision_data) prev_revision_files = current_revision_files prev_revision_name = rev_name return await quart.render_template( "revisions-selected.html", project_name=project_name, version_name=version_name, release=release, phase_key=phase_key, revision_history=list(reversed(revision_history)), current_revision_name=current_revision_name, ) @routes.committer("/revisions/<project_name>/<version_name>", methods=["POST"]) async def selected_post(session: routes.CommitterSession, project_name: str, version_name: str) -> response.Response: """Set a specific revision as the latest for a candidate draft or release preview.""" await session.check_access(project_name) form_data = await quart.request.form revision_name = form_data.get("revision_name") if not revision_name: raise base.ASFQuartException("Missing revision name", errorcode=400) try: # Target must be relative for the symlink # TODO: We should probably log who is doing this, to create an audit trail async with db.session() as data: try: release = await session.release(project_name, version_name, data=data) except base.ASFQuartException: release = await session.release( project_name, version_name, phase=models.ReleasePhase.RELEASE_PREVIEW, data=data ) release_dir = util.release_directory_base(release) # Check that the target revision directory exists target_revision_dir = release_dir / revision_name if not await aiofiles.os.path.isdir(target_revision_dir): raise base.ASFQuartException("Target revision directory not found", errorcode=404) release.revision = revision_name await data.commit() except base.ASFQuartException as e: raise e except Exception as e: logging.exception("Error setting revision:") return await session.redirect( selected, error=f"Failed to set revision {revision_name} as latest: {e!s}", project_name=project_name, version_name=version_name, ) return await session.redirect( selected, success=f"Revision {revision_name} set as latest", project_name=project_name, version_name=version_name, ) async def _revisions_process( rev_name: str, release_dir: pathlib.Path, parent_map: dict[str, str], prev_revision_files: set[pathlib.Path] | None, prev_revision_name: str | None, sort_key: Callable[[str], datetime.datetime], ) -> tuple[dict, set[pathlib.Path]]: """Process a single revision and calculate its diff from the previous.""" current_revision_dir = release_dir / rev_name current_revision_files = {path async for path in util.paths_recursive(current_revision_dir)} parent_name = parent_map.get(rev_name) added_files: set[pathlib.Path] = set() removed_files: set[pathlib.Path] = set() modified_files: set[pathlib.Path] = set() if (prev_revision_files is not None) and (prev_revision_name is not None): added_files = current_revision_files - prev_revision_files removed_files = prev_revision_files - current_revision_files common_files = current_revision_files & prev_revision_files # Check modification times for common files parent_revision_dir = release_dir / prev_revision_name mtime_tasks = [] for common_file in common_files: async def check_mtime(file_path: pathlib.Path) -> tuple[pathlib.Path, bool]: try: parent_mtime = await aiofiles.os.path.getmtime(parent_revision_dir / file_path) current_mtime = await aiofiles.os.path.getmtime(current_revision_dir / file_path) return file_path, parent_mtime != current_mtime except OSError: # Treat errors as modified return file_path, True mtime_tasks.append(check_mtime(common_file)) results = await asyncio.gather(*mtime_tasks) modified_files = {f for f, modified in results if modified} else: # First revision, all files are considered added added_files = current_revision_files try: editor = rev_name.split("@", 1)[0] timestamp = sort_key(rev_name) except (ValueError, IndexError): editor = "Unknown" timestamp = None revision_data = { "name": rev_name, "editor": editor, "timestamp": timestamp, "parent": parent_name, "added": sorted(list(added_files)), "removed": sorted(list(removed_files)), "modified": sorted(list(modified_files)), } return revision_data, current_revision_files