From d6217eeb4b82e2c3a93f869c6a49fbafb1e70f0c Mon Sep 17 00:00:00 2001
From: "Antoine R. Dumont (@ardumont)" <ardumont@softwareheritage.org>
Date: Wed, 18 Jan 2023 15:21:41 +0100
Subject: [PATCH] scripts/app_manager: Add subcommand to manipulate tags

```
$ app_manager tag --help
Usage: app_manager.py tag [OPTIONS] COMMAND [ARGS]...

  Manipulate application tag, for example either determine the last tag or
  compute the next one. Without any parameters this lists the current
  application tags.

Options:
  --help  Show this message and exit.

Commands:
  latest
  list
  next
```

This will allow to:
- retrieve the last known tag for an application
- determine the next tag for an application (be it known or not)
- current list of tags of an application

This will raise when:
- providing both --next-tag and --last-tag for an application
- listing tags for an unknown application
- retrieving the last tag on an unknown application

Refs. swh/infra/sysadm-environment#4724
---
 scripts/Dockerfile     |   1 +
 scripts/app_manager.py | 107 ++++++++++++++++++++++++++++++++++++++---
 2 files changed, 102 insertions(+), 6 deletions(-)

diff --git a/scripts/Dockerfile b/scripts/Dockerfile
index d52e9790a..963e70728 100644
--- a/scripts/Dockerfile
+++ b/scripts/Dockerfile
@@ -17,6 +17,7 @@ RUN apt-get -y update && \
       python3-click \
       python3-venv \
       python3-yaml \
+      python3-dulwich \
     && \
     apt clean
 
diff --git a/scripts/app_manager.py b/scripts/app_manager.py
index 6edb05b55..fcef6e005 100755
--- a/scripts/app_manager.py
+++ b/scripts/app_manager.py
@@ -5,22 +5,28 @@
 # License: GNU General Public License v3 or later
 # See top-level LICENSE file for more information
 
-"""Cli to list applications (with/without filters) or generate the
-requirements-frozen.txt file for app(s) provided as parameters.
+"""Cli to manipulate applications (with/without filters), generate/update the
+requirements-frozen.txt file for app(s) provided as parameters, list or generate tags,
+...
 
 """
 
-import click
+from __future__ import annotations
+
+from collections import defaultdict
 import os
 import pathlib
 import subprocess
 import sys
-import yaml
-from collections import defaultdict
 import tempfile
+from typing import TYPE_CHECKING, Dict, Iterator, List, Set, Tuple
 from venv import EnvBuilder
-from typing import Dict, Iterator, Set
 
+import click
+import yaml
+
+if TYPE_CHECKING:
+    from dulwich.repo import Repo
 
 APPS_DIR = pathlib.Path(__file__).absolute().parent.parent / "apps"
 
@@ -215,6 +221,95 @@ def compute_yaml(updated_information: Dict[str, Dict[str, str]]) -> Dict[str, st
     return yaml_dict
 
 
+def application_tags(repo: Repo, application: str) -> List[Tuple[str, str, str]]:
+    """Returns list of tuple application (tag, date, increment) (from most recent to
+    oldest)."""
+    import re
+
+    pattern = re.compile(
+        fr"refs/tags/{re.escape(application)}-(?P<date>[0-9]+)\.(?P<inc>[0-9]+)"
+    )
+
+    tags = []
+    for current_ref in repo.get_refs():
+        ref = current_ref.decode()
+        is_match = pattern.fullmatch(ref)
+        if not is_match:
+            continue
+        mdata = is_match.groupdict()
+        tag = ref.replace("refs/tags/", "")
+        tags.append((tag, mdata["date"], mdata["inc"]))
+
+    return sorted(tags, reverse=True)
+
+
+@app.group("tag")
+@click.pass_context
+def tag(ctx):
+    """Manipulate application tag, for example either determine the last tag or compute
+    the next one. Without any parameters this lists the current application tags.
+
+    """
+    from dulwich.repo import Repo
+    repo_dirpath = ctx.obj["apps_dir"] / '..'
+
+    ctx.obj["repo"] = Repo(repo_dirpath)
+
+
+@tag.command("list")
+@click.argument("application", required=True)
+@click.pass_context
+def tag_list(ctx, application: str):
+    """List all tags for the application provided."""
+    repo = ctx.obj["repo"]
+    tags = application_tags(repo, application)
+
+    if not tags:
+        raise ValueError(f"No tag to display for application '{application}'")
+
+    # else, with no params, just print the application tags
+    for tag in tags:
+        print(tag[0])
+
+
+@tag.command("latest")
+@click.argument("application", required=True)
+@click.pass_context
+def tag_latest(ctx, application: str):
+    """Determine the latest tag for the application provided."""
+    repo = ctx.obj["repo"]
+    tags = application_tags(repo, application)
+
+    if not tags:
+        raise ValueError(f"No tag to display for application '{application}'")
+
+    # Determine the application's latest tag if any
+    print(tags[0][0])
+
+
+@tag.command("next")
+@click.argument("application", required=True)
+@click.pass_context
+def tag_next(ctx, application: str):
+    """Compute the next tag for the application provided."""
+    from datetime import datetime, timezone
+
+    repo = ctx.obj["repo"]
+    tags = application_tags(repo, application)
+
+    now = datetime.now(tz=timezone.utc)
+    current_date = now.strftime("%Y%m%d")
+    if tags:
+        _, previous_date, previous_tag_date_inc = tags[0]
+        inc = int(previous_tag_date_inc) + 1 if current_date == previous_date else 1
+    else:
+        # First time we ask a tag for that application
+        inc = 1
+
+    tag = f"{application}-{current_date}.{inc}"
+    print(tag)
+
+
 @app.command("update-values")
 @click.option(
     "-v",
-- 
GitLab