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 c563585  Add draft revision history pages
c563585 is described below

commit c563585c0acab67e6172935bb3c31a3d2c9214b4
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Apr 9 16:44:12 2025 +0100

    Add draft revision history pages
---
 atr/routes/draft.py                | 137 ++++++++++++++++++++++++++++++++++++-
 atr/templates/draft-revisions.html |  74 ++++++++++++++++++++
 2 files changed, 210 insertions(+), 1 deletion(-)

diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 6700d22..375d566 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -20,6 +20,7 @@
 from __future__ import annotations
 
 import asyncio
+import contextlib
 import datetime
 import hashlib
 import logging
@@ -31,6 +32,7 @@ import aiofiles.os
 import aioshutil
 import asfquart.base as base
 import quart
+import sqlmodel
 import wtforms
 
 import atr.analysis as analysis
@@ -42,7 +44,7 @@ import atr.tasks.sbom as sbom
 import atr.util as util
 
 if TYPE_CHECKING:
-    from collections.abc import Sequence
+    from collections.abc import Callable, Sequence
 
     import werkzeug.datastructures as datastructures
     import werkzeug.wrappers.response as response
@@ -612,6 +614,76 @@ async def promote(session: routes.CommitterSession) -> str 
| response.Response:
     )
 
 
[email protected]("/draft/revisions/<project_name>/<version_name>")
+async def revisions(session: routes.CommitterSession, project_name: str, 
version_name: str) -> str:
+    """Show the revision history for a release candidate draft."""
+    if not any((p.name == project_name) for p in (await 
session.user_projects)):
+        raise base.ASFQuartException("You do not have access to this project", 
errorcode=403)
+
+    async with db.session() as data:
+        release = await data.release(name=models.release_name(project_name, 
version_name), _project=True).demand(
+            base.ASFQuartException("Release does not exist", errorcode=404)
+        )
+        if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+            raise base.ASFQuartException("Revisions are only available for 
candidate drafts", errorcode=400)
+
+        release_dir = util.get_release_candidate_draft_dir() / project_name / 
version_name
+        revision_dirs: list[str] = []
+        with contextlib.suppress(FileNotFoundError):
+            for entry in await aiofiles.os.listdir(str(release_dir)):
+                # Match pattern like "[email protected]"
+                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)
+
+        # Get parent links using a direct query due to the use of in_(...)
+        query = sqlmodel.select(models.TextValue).where(
+            models.TextValue.ns == "draft_parent",
+            
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()}
+
+        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(
+        "draft-revisions.html",
+        project_name=project_name,
+        version_name=version_name,
+        release=release,
+        revision_history=list(reversed(revision_history)),
+    )
+
+
 
@routes.committer("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
 async def sbomgen(
     session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
@@ -932,6 +1004,69 @@ async def _promote(
     return await session.redirect(promote, success=success_message)
 
 
+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 = set(await 
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
+
+
 async def _upload_files(
     project_name: str,
     version_name: str,
diff --git a/atr/templates/draft-revisions.html 
b/atr/templates/draft-revisions.html
new file mode 100644
index 0000000..d7a22a0
--- /dev/null
+++ b/atr/templates/draft-revisions.html
@@ -0,0 +1,74 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Revisions for {{ project_name }} {{ version_name }} ~ ATR
+{% endblock title %}
+
+{% block description %}
+  View the revision history for the {{ project_name }} {{ version_name }} 
candidate draft.
+{% endblock description %}
+
+{% block content %}
+  <a href="{{ as_url(routes.draft.evaluate, project_name=project_name, 
version_name=version_name) }}"
+     class="back-link">← Back to draft evaluation</a>
+
+  <h1>Revision history for {{ release.project.display_name }} {{ 
release.version }}</h1>
+
+  {% if revision_history %}
+    {% for revision in revision_history %}
+      <div class="card mb-3">
+        <div class="card-header d-flex justify-content-between 
align-items-center">
+          <h5 class="mb-0">{{ revision.name }}</h5>
+          <span class="text-muted small">
+            {% if revision.timestamp %}
+              {{ revision.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") }}
+            {% else %}
+              Invalid timestamp
+            {% endif %}
+            by {{ revision.editor }}
+          </span>
+        </div>
+        <div class="card-body">
+          {% if revision.parent %}
+            <p class="small text-muted mb-2">Changes from parent revision: {{ 
revision.parent }}</p>
+          {% else %}
+            <p class="small text-muted mb-2">Initial revision</p>
+          {% endif %}
+
+          {% if not revision.added and not revision.removed and not 
revision.modified %}
+            <p class="fst-italic text-muted">No file changes detected in this 
revision.</p>
+          {% else %}
+            {% if revision.added %}
+              <p class="fs-6 fw-bold mt-3">Added files</p>
+              <ul class="list-group list-group-flush mb-2">
+                {% for file in revision.added %}
+                  <li class="list-group-item list-group-item-success py-1 px-3 
small rounded-2">{{ file }}</li>
+                {% endfor %}
+              </ul>
+            {% endif %}
+
+            {% if revision.removed %}
+              <p class="fs-6 fw-bold mt-3">Removed files</p>
+              <ul class="list-group list-group-flush mb-2">
+                {% for file in revision.removed %}
+                  <li class="list-group-item list-group-item-danger py-1 px-3 
small rounded-2">{{ file }}</li>
+                {% endfor %}
+              </ul>
+            {% endif %}
+
+            {% if revision.modified %}
+              <p class="fs-6 fw-bold mt-3">Modified files</p>
+              <ul class="list-group list-group-flush mb-2">
+                {% for file in revision.modified %}
+                  <li class="list-group-item list-group-item-warning py-1 px-3 
small rounded-2">{{ file }}</li>
+                {% endfor %}
+              </ul>
+            {% endif %}
+          {% endif %}
+        </div>
+      </div>
+    {% endfor %}
+  {% else %}
+    <div class="alert alert-info">No revision history found for this candidate 
draft.</div>
+  {% endif %}
+{% endblock content %}


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

Reply via email to