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]