diff --git a/README.md b/README.md
index 7d69dfd..7bfd625 100644
--- a/README.md
+++ b/README.md
@@ -2,23 +2,23 @@

-Here you will find the repositories and versions of every apps available in YunoHost's default catalog.
+This repository contains the default YunoHost app catalog, as well as tools
+that can be run manually or automatically.
-It is browsable here: https://yunohost.org/apps
+The catalog is stored in [**`apps.toml`**](./apps.toml) and is browsable here:
+
-The main file of the catalog is [**apps.toml**](./apps.toml) which contains
-references to the corresponding Git repositories for each application, along
-with a few metadata about them such as its category or maintenance state. This
-file regularly read by `list_builder.py` which publish the results on
-https://app.yunohost.org/default/.
+It contains refences to the apps' repositories, along with a few metadata about
+them such as its category or maintenance state. This file is regularly read by
+`tools/list_builder.py` which publish the results on .
-### Where can I learn about app packaging in YunoHost?
+## Where can I learn about app packaging in YunoHost?
-- You can browse the contributor documentation : https://yunohost.org/contributordoc
+- You can browse [the contributor documentation](https://yunohost.org/contributordoc)
- If you are not familiar with Git/GitHub, you can have a look at our [homemade guide](https://yunohost.org/#/packaging_apps_git)
- Don't hesitate to reach for help on the dedicated [application packaging chatroom](https://yunohost.org/chat_rooms) ... we can even schedule an audio meeting to help you get started!
-### How to add your app to the application catalog
+## How to add your app to the application catalog
> **Note**
> The YunoHost project will **NOT** integrate in its catalog applications that are not
@@ -30,16 +30,20 @@ https://app.yunohost.org/default/.
> with keeping your app working and up to date with packaging evolutions on the long run.
To add your application to the catalog:
-* Fork this repository and edit the [apps.toml](https://github.com/YunoHost/apps/tree/master/apps.toml) file
-* Add your app's ID and git information at the right alphabetical place
-* Indicate the app's functioning state: `notworking`, `inprogress`, or `working`
-* Indicate the app category, which you can pick from `categories.toml`
-* Indicate any anti-feature that your app may be subject to, see `antifeatures.toml` (or remove the `antifeatures` key if there's none)
-* Indicate if your app can be thought of as an alternative to popular proprietary services (or remove the `potential_alternative_to` key if there's none)
-* *Do not* add the `level` entry by yourself. Our automatic test suite ("the CI") will handle it.
+
+* Fork [this repository](https://github.com/YunoHost/apps)
+* Edit the [`apps.toml`](/apps.toml) file
+ * Add your app's ID and git information at the right alphabetical place
+ * Indicate the app's functioning state: `notworking`, `inprogress`, or `working`
+ * Indicate the app category, which you can pick from `categories.toml`
+ * Indicate any anti-feature that your app may be subject to, see `antifeatures.toml` (or remove the `antifeatures` key if there's none)
+ * Indicate if your app can be thought of as an alternative to popular proprietary services (or remove the `potential_alternative_to` key if there's none)
+ * *Do not* add the `level` entry by yourself. Our automatic test suite ("the CI") will handle it.
+* Commit and push your modifications to your repository
* Create a [Pull Request](https://github.com/YunoHost/apps/pulls/)
App example addition:
+
```toml
[your_app]
antifeatures = [ "deprecated-software" ] # Remove if no relevant antifeature applies
@@ -58,17 +62,23 @@ url = "https://github.com/YunoHost-Apps/your_app_ynh"
> obtain an access to the developer CI where you'll be able to test your app
> easily.
-### Updating apps levels in the catalog
+## Updating apps levels in the catalog
-App packagers should *not* manually set their apps' level. The levels of all the apps are automatically updated once per week on Friday, according to the results from the official app CI.
+App packagers should *not* manually set their apps' level. The levels of all
+the apps are automatically updated once per week on Friday, according to the
+results from the official app CI.
-### Apps flagged as not-maintained
+## Apps flagged as not-maintained
-Applications with no recent activity and no active sign from maintainer may be flagged in `apps.toml` with the `package-not-maintained` antifeature tag to signify that the app is inactive and may slowly become outdated with respect to the upstream, or with respect to good packaging practices. It does **not** mean that the app is not working anymore.
+Applications with no recent activity and no active sign from maintainer may be
+flagged in `apps.toml` with the `package-not-maintained` antifeature tag to
+signify that the app is inactive and may slowly become outdated with respect to
+the upstream, or with respect to good packaging practices. It does **not** mean
+that the app is not working anymore.
-Feel free to contact the app group if you feel like taking over the maintenance of a currently unmaintained app!
+Feel free to contact the app group if you feel like taking over the maintenance
+of a currently unmaintained app!
-### `graveyard.toml`
-
-This file is for apps that are long-term not-working and unlikely to be ever revived
+## `graveyard.toml`
+This file is for apps that are long-term not-working and unlikely to be ever revived.
diff --git a/rebuild.sh b/rebuild.sh
index bcbfb86..7c4780f 100644
--- a/rebuild.sh
+++ b/rebuild.sh
@@ -7,4 +7,4 @@ cd $workdir
date >> $log
git pull &>/dev/null
-./list_builder.py &>> $log || sendxmpppy "[listbuilder] Rebuilding the application list failed miserably"
+./tools/list_builder.py &>> $log || sendxmpppy "[listbuilder] Rebuilding the application list failed miserably"
diff --git a/store/README.md b/store/README.md
index b60700b..6222f4e 100644
--- a/store/README.md
+++ b/store/README.md
@@ -4,7 +4,7 @@ This is a Flask app interfacing with YunoHost's app catalog for a cool browsing
## Developement
-```
+```bash
python3 -m venv venv
source venv/bin/activate
pip3 install -r requirements.txt
@@ -19,22 +19,22 @@ curl https://app.yunohost.org/default/v3/apps.json > ../builds/default/v3/apps.j
# You will also want to run list_builder.py to initialize the .apps_cache (at least for a few apps, you can Ctrl+C after a while)
pushd ..
- python3 list_builder.py
+ ./tools/list_builder.py
popd
```
And then start the dev server:
-```
+```bash
source venv/bin/activate
FLASK_APP=app.py FLASK_ENV=development flask run
```
## Translation
-It's based on Flask-Babel : https://python-babel.github.io/
+It's based on Flask-Babel :
-```
+```bash
source venv/bin/activate
pybabel extract --ignore-dirs venv -F babel.cfg -o messages.pot .
diff --git a/tools/README-generator/__init__.py b/tools/README-generator/__init__.py
index e69de29..e5a0d9b 100644
--- a/tools/README-generator/__init__.py
+++ b/tools/README-generator/__init__.py
@@ -0,0 +1 @@
+#!/usr/bin/env python3
diff --git a/tools/README-generator/make_readme.py b/tools/README-generator/make_readme.py
index 5f004ea..d71f973 100755
--- a/tools/README-generator/make_readme.py
+++ b/tools/README-generator/make_readme.py
@@ -2,12 +2,13 @@
import argparse
import json
-import toml
import os
from pathlib import Path
+import toml
from jinja2 import Environment, FileSystemLoader
+
def value_for_lang(values, lang):
if not isinstance(values, dict):
return values
diff --git a/tools/README-generator/webhook.py b/tools/README-generator/webhook.py
index af220d8..7a76eab 100755
--- a/tools/README-generator/webhook.py
+++ b/tools/README-generator/webhook.py
@@ -1,15 +1,16 @@
-import os
-import hmac
-import shlex
-import hashlib
+#!/usr/bin/env python3
+
import asyncio
+import hashlib
+import hmac
+import os
+import shlex
import tempfile
+from make_readme import generate_READMEs
from sanic import Sanic, response
from sanic.response import text
-from make_readme import generate_READMEs
-
app = Sanic(__name__)
github_webhook_secret = open("github_webhook_secret", "r").read().strip()
diff --git a/tools/__init__.py b/tools/__init__.py
index e69de29..e5a0d9b 100644
--- a/tools/__init__.py
+++ b/tools/__init__.py
@@ -0,0 +1 @@
+#!/usr/bin/env python3
diff --git a/tools/app_caches.py b/tools/app_caches.py
new file mode 100755
index 0000000..f3e35fd
--- /dev/null
+++ b/tools/app_caches.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+
+import argparse
+import shutil
+import logging
+from multiprocessing import Pool
+from pathlib import Path
+from typing import Any
+
+import tqdm
+
+from appslib.utils import (REPO_APPS_ROOT, # pylint: disable=import-error
+ get_catalog, git_repo_age)
+from git import Repo
+
+
+def app_cache_folder(app: str) -> Path:
+ return REPO_APPS_ROOT / ".apps_cache" / app
+
+
+def app_cache_clone(app: str, infos: dict[str, str]) -> None:
+ logging.info("Cloning %s...", app)
+ git_depths = {
+ "notworking": 5,
+ "inprogress": 20,
+ "default": 40,
+ }
+ if app_cache_folder(app).exists():
+ shutil.rmtree(app_cache_folder(app))
+ Repo.clone_from(
+ infos["url"],
+ to_path=app_cache_folder(app),
+ depth=git_depths.get(infos["state"], git_depths["default"]),
+ single_branch=True, branch=infos.get("branch", "master"),
+ )
+
+
+def app_cache_clone_or_update(app: str, infos: dict[str, str]) -> None:
+ app_path = app_cache_folder(app)
+
+ # Don't refresh if already refreshed during last hour
+ age = git_repo_age(app_path)
+ if age is False:
+ app_cache_clone(app, infos)
+ return
+
+ # if age < 3600:
+ # logging.info(f"Skipping {app}, it's been updated recently.")
+ # return
+
+ logging.info("Updating %s...", app)
+ repo = Repo(app_path)
+ repo.remote("origin").set_url(infos["url"])
+
+ branch = infos.get("branch", "master")
+ if repo.active_branch != branch:
+ all_branches = [str(b) for b in repo.branches]
+ if branch in all_branches:
+ repo.git.checkout(branch, "--force")
+ else:
+ repo.git.remote("set-branches", "--add", "origin", branch)
+ repo.remote("origin").fetch(f"{branch}:{branch}")
+
+ repo.remote("origin").fetch(refspec=branch, force=True)
+ repo.git.reset("--hard", f"origin/{branch}")
+
+
+def __app_cache_clone_or_update_mapped(data):
+ name, info = data
+ try:
+ app_cache_clone_or_update(name, info)
+ except Exception as err:
+ logging.error("Error while updating %s: %s", name, err)
+
+
+def apps_cache_update_all(apps: dict[str, dict[str, Any]], parallel: int = 8) -> None:
+ with Pool(processes=parallel) as pool:
+ tasks = pool.imap_unordered(__app_cache_clone_or_update_mapped, apps.items())
+ for _ in tqdm.tqdm(tasks, total=len(apps.keys())):
+ pass
+
+
+def __run_for_catalog():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-v", "--verbose", action="store_true")
+ parser.add_argument("-j", "--processes", type=int, default=8)
+ args = parser.parse_args()
+ if args.verbose:
+ logging.getLogger().setLevel(logging.INFO)
+
+ apps_cache_update_all(get_catalog(), parallel=args.processes)
+
+
+if __name__ == "__main__":
+ __run_for_catalog()
diff --git a/tools/appslib/apps_cache.py b/tools/appslib/apps_cache.py
new file mode 100644
index 0000000..b8fd1e4
--- /dev/null
+++ b/tools/appslib/apps_cache.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+
+import logging
+from pathlib import Path
+
+import utils
+from git import Repo
+
+
+def apps_cache_path() -> Path:
+ path = apps_repo_root() / ".apps_cache"
+ path.mkdir()
+ return path
+
+
+def app_cache_path(app: str) -> Path:
+ path = apps_cache_path() / app
+ path.mkdir()
+ return path
+
+
+# def refresh_all_caches(catalog: dict[str, dict[str, str]]):
+# for app, infos
+# pass
+
+
+def app_cache_clone(app: str, infos: dict[str, str]) -> None:
+ git_depths = {
+ "notworking": 5,
+ "inprogress": 20,
+ "default": 40,
+ }
+
+ Repo.clone_from(
+ infos["url"],
+ to_path=app_cache_path(app),
+ depth=git_depths.get(infos["state"], git_depths["default"]),
+ single_branch=True, branch=infos.get("branch", "master"),
+ )
+
+
+def app_cache_update(app: str, infos: dict[str, str]) -> None:
+ app_path = app_cache_path(app)
+ age = utils.git_repo_age(app_path)
+ if age is False:
+ return app_cache_clone(app, infos)
+
+ if age < 3600:
+ logging.info(f"Skipping {app}, it's been updated recently.")
+ return
+
+ repo = Repo(app_path)
+ repo.remote("origin").set_url(infos["url"])
+
+ branch = infos.get("branch", "master")
+ if repo.active_branch != branch:
+ all_branches = [str(b) for b in repo.branches]
+ if branch in all_branches:
+ repo.git.checkout(branch, "--force")
+ else:
+ repo.git.remote("set-branches", "--add", "origin", branch)
+ repo.remote("origin").fetch(f"{branch}:{branch}")
+
+ repo.remote("origin").fetch(refspec=branch, force=True)
+ repo.git.reset("--hard", f"origin/{branch}")
+
+
+def cache_all_apps(catalog: dict[str, dict[str, str]]) -> None:
diff --git a/tools/appslib/utils.py b/tools/appslib/utils.py
new file mode 100644
index 0000000..f80650e
--- /dev/null
+++ b/tools/appslib/utils.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+import sys
+import subprocess
+from typing import Any, TextIO, Generator
+import time
+from functools import cache
+from pathlib import Path
+from git import Repo
+
+import toml
+
+REPO_APPS_ROOT = Path(Repo(__file__, search_parent_directories=True).working_dir)
+
+
+@cache
+def apps_repo_root() -> Path:
+ return Path(__file__).parent.parent.parent
+
+
+def git(cmd: list[str], cwd: Path | None = None) -> str:
+ full_cmd = ["git"]
+ if cwd:
+ full_cmd.extend(["-C", str(cwd)])
+ full_cmd.extend(cmd)
+ return subprocess.check_output(
+ full_cmd,
+ # env=my_env,
+ ).strip().decode("utf-8")
+
+
+def git_repo_age(path: Path) -> bool | int:
+ for file in [path / ".git" / "FETCH_HEAD", path / ".git" / "HEAD"]:
+ if file.exists():
+ return int(time.time() - file.stat().st_mtime)
+ return False
+
+
+# Progress bar helper, stolen from https://stackoverflow.com/a/34482761
+def progressbar(
+ it: list[Any],
+ prefix: str = "",
+ size: int = 60,
+ file: TextIO = sys.stdout) -> Generator[Any, None, None]:
+ count = len(it)
+
+ def show(j, name=""):
+ name += " "
+ x = int(size * j / count)
+ file.write(
+ "%s[%s%s] %i/%i %s\r" % (prefix, "#" * x, "." * (size - x), j, count, name)
+ )
+ file.flush()
+
+ show(0)
+ for i, item in enumerate(it):
+ yield item
+ show(i + 1, item[0])
+ file.write("\n")
+ file.flush()
+
+
+@cache
+def get_catalog(working_only=False):
+ """Load the app catalog and filter out the non-working ones"""
+ catalog = toml.load((REPO_APPS_ROOT / "apps.toml").open("r", encoding="utf-8"))
+ if working_only:
+ catalog = {
+ app: infos for app, infos in catalog.items()
+ if infos.get("state") != "notworking"
+ }
+ return catalog
diff --git a/tools/autopatches/autopatch.py b/tools/autopatches/autopatch.py
index 746ee51..6f52d30 100755
--- a/tools/autopatches/autopatch.py
+++ b/tools/autopatches/autopatch.py
@@ -1,9 +1,11 @@
#!/usr/bin/python3
+
import json
-import sys
-import requests
import os
import subprocess
+import sys
+
+import requests
catalog = requests.get("https://raw.githubusercontent.com/YunoHost/apps/master/apps.json").json()
diff --git a/tools/autopatches/patches/add-cpe/patch.sh b/tools/autopatches/patches/add-cpe/patch.sh
index 02a697c..2b6b2d6 100644
--- a/tools/autopatches/patches/add-cpe/patch.sh
+++ b/tools/autopatches/patches/add-cpe/patch.sh
@@ -1,7 +1,8 @@
#!/usr/bin/python3
-import json
import csv
+import json
+
def find_cpe(app_id):
with open("../../patches/add-cpe/cpe.csv", newline='') as f:
diff --git a/tools/autoupdate_app_sources/autoupdate_app_sources.py b/tools/autoupdate_app_sources/autoupdate_app_sources.py
index dfd15b7..b42dc78 100644
--- a/tools/autoupdate_app_sources/autoupdate_app_sources.py
+++ b/tools/autoupdate_app_sources/autoupdate_app_sources.py
@@ -1,13 +1,15 @@
-import time
+#!/usr/bin/env python3
+
+import glob
import hashlib
+import os
import re
import sys
-import requests
-import toml
-import os
-import glob
+import time
from datetime import datetime
+import requests
+import toml
from rest_api import GithubAPI, GitlabAPI, RefType
STRATEGIES = [
diff --git a/tools/autoupdate_app_sources/rest_api.py b/tools/autoupdate_app_sources/rest_api.py
index a76821a..ccc6b4d 100644
--- a/tools/autoupdate_app_sources/rest_api.py
+++ b/tools/autoupdate_app_sources/rest_api.py
@@ -1,8 +1,11 @@
-from enum import Enum
+#!/usr/bin/env python3
+
import re
-import requests
+from enum import Enum
from typing import List
+import requests
+
class RefType(Enum):
tags = 1
diff --git a/tools/autoupdater-upgrader/autoupdater-upgrader.py b/tools/autoupdater-upgrader/autoupdater-upgrader.py
index 7252042..3e9c1a2 100755
--- a/tools/autoupdater-upgrader/autoupdater-upgrader.py
+++ b/tools/autoupdater-upgrader/autoupdater-upgrader.py
@@ -1,14 +1,17 @@
-#!venv/bin/python3
+#!/usr/bin/env python3
-import sys, os, time
-import urllib.request, json
+import json
+import os
import re
+import sys
+import time
+import urllib.request
-from github import Github
import github
-
+from github import Github
# Debug
from rich.traceback import install
+
install(width=150, show_locals=True, locals_max_length=None, locals_max_string=None)
#####
diff --git a/tools/bot-repo-cleanup/cleanup.py b/tools/bot-repo-cleanup/cleanup.py
index f2275c6..2a9fa98 100644
--- a/tools/bot-repo-cleanup/cleanup.py
+++ b/tools/bot-repo-cleanup/cleanup.py
@@ -1,4 +1,4 @@
-#!venv/bin/python3
+#!/usr/bin/env python3
# Obtained with `pip install PyGithub`, better within a venv
from github import Github
diff --git a/tools/catalog_linter.py b/tools/catalog_linter.py
index 4c383a9..6ce7ef9 100755
--- a/tools/catalog_linter.py
+++ b/tools/catalog_linter.py
@@ -2,10 +2,10 @@
import json
import sys
+from difflib import SequenceMatcher
from functools import cache
from pathlib import Path
from typing import Any, Dict, Generator, List, Tuple
-from difflib import SequenceMatcher
import jsonschema
import toml
diff --git a/list_builder.py b/tools/list_builder.py
similarity index 98%
rename from list_builder.py
rename to tools/list_builder.py
index 996dd05..1994f01 100755
--- a/list_builder.py
+++ b/tools/list_builder.py
@@ -1,24 +1,26 @@
#!/usr/bin/python3
import copy
-import sys
+import json
import os
import re
-import json
-from shutil import which
-import toml
import subprocess
+import sys
import time
-from typing import TextIO, Generator, Any
+from collections import OrderedDict
from pathlib import Path
+from shutil import which
+from typing import Any, Generator, TextIO
+
+import toml
from git import Repo
-from collections import OrderedDict
-from tools.packaging_v2.convert_v1_manifest_to_v2_for_catalog import convert_v1_manifest_to_v2_for_catalog
+from packaging_v2.convert_v1_manifest_to_v2_for_catalog import \
+ convert_v1_manifest_to_v2_for_catalog # pylint: disable=import-error
now = time.time()
-REPO_APPS_PATH = Path(__file__).parent
+REPO_APPS_PATH = Path(__file__).parent.parent
# Load categories and reformat the structure to have a list with an "id" key
categories = toml.load((REPO_APPS_PATH / "categories.toml").open("r", encoding="utf-8"))
diff --git a/tools/packaging_v2/__init__.py b/tools/packaging_v2/__init__.py
index e69de29..e5a0d9b 100644
--- a/tools/packaging_v2/__init__.py
+++ b/tools/packaging_v2/__init__.py
@@ -0,0 +1 @@
+#!/usr/bin/env python3
diff --git a/tools/packaging_v2/convert_app_to_packaging_v2.py b/tools/packaging_v2/convert_app_to_packaging_v2.py
index 7887cdb..2fa166c 100644
--- a/tools/packaging_v2/convert_app_to_packaging_v2.py
+++ b/tools/packaging_v2/convert_app_to_packaging_v2.py
@@ -1,7 +1,9 @@
+#!/usr/bin/env python3
+
import argparse
+import json
import os
import re
-import json
import subprocess
from glob import glob
@@ -226,7 +228,8 @@ def _convert_v1_manifest_to_v2(app_path):
def _dump_v2_manifest_as_toml(manifest):
import re
- from tomlkit import document, nl, table, dumps, comment
+
+ from tomlkit import comment, document, dumps, nl, table
toml_manifest = document()
toml_manifest.add("packaging_format", 2)
diff --git a/tools/packaging_v2/convert_v1_manifest_to_v2_for_catalog.py b/tools/packaging_v2/convert_v1_manifest_to_v2_for_catalog.py
index 0130c29..9fb3790 100644
--- a/tools/packaging_v2/convert_v1_manifest_to_v2_for_catalog.py
+++ b/tools/packaging_v2/convert_v1_manifest_to_v2_for_catalog.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python3
+
import copy
diff --git a/tools/update_app_levels/update_app_levels.py b/tools/update_app_levels/update_app_levels.py
old mode 100644
new mode 100755
index b8df4a1..ee98a66
--- a/tools/update_app_levels/update_app_levels.py
+++ b/tools/update_app_levels/update_app_levels.py
@@ -1,106 +1,226 @@
-import time
-import toml
-import requests
+#!/usr/bin/env python3
+"""
+Update app catalog: commit, and create a merge request
+"""
+
+import argparse
+import logging
import tempfile
-import os
-import sys
-import json
+import textwrap
+import time
from collections import OrderedDict
+from typing import Any
-token = open(os.path.dirname(__file__) + "/../../.github_token").read().strip()
+from pathlib import Path
+import jinja2
+import requests
+import toml
+from git import Repo
-tmpdir = tempfile.mkdtemp(prefix="update_app_levels_")
-os.system(f"git clone 'https://oauth2:{token}@github.com/yunohost/apps' {tmpdir}")
-os.system(f"git -C {tmpdir} checkout -b update_app_levels")
+# APPS_REPO = "YunoHost/apps"
+APPS_REPO = "Salamandar/apps"
-# Load the app catalog and filter out the non-working ones
-catalog = toml.load(open(f"{tmpdir}/apps.toml"))
-
-# Fetch results from the CI
CI_RESULTS_URL = "https://ci-apps.yunohost.org/ci/api/results"
-ci_results = requests.get(CI_RESULTS_URL).json()
-comment = {
- "major_regressions": [],
- "minor_regressions": [],
- "improvements": [],
- "outdated": [],
- "missing": [],
-}
+REPO_APPS_ROOT = Path(Repo(__file__, search_parent_directories=True).working_dir)
-for app, infos in catalog.items():
- if infos.get("state") != "working":
- continue
+def github_token() -> str | None:
+ github_token_path = REPO_APPS_ROOT.parent / ".github_token"
+ if github_token_path.exists():
+ return github_token_path.open("r", encoding="utf-8").read().strip()
+ return None
- if app not in ci_results:
- comment["missing"].append(app)
- continue
+def get_ci_results() -> dict[str, dict[str, Any]]:
+ return requests.get(CI_RESULTS_URL, timeout=10).json()
+
+
+def ci_result_is_outdated(result) -> bool:
# 3600 * 24 * 60 = ~2 months
- if (int(time.time()) - ci_results[app].get("timestamp", 0)) > 3600 * 24 * 60:
- comment["outdated"].append(app)
- continue
+ return (int(time.time()) - result.get("timestamp", 0)) > 3600 * 24 * 60
- ci_level = ci_results[app]["level"]
- current_level = infos.get("level")
- if ci_level == current_level:
- continue
- elif current_level is None or ci_level > current_level:
- comment["improvements"].append((app, current_level, ci_level))
- elif ci_level < current_level:
- if ci_level <= 4 and current_level > 4:
- comment["major_regressions"].append((app, current_level, ci_level))
+def update_catalog(catalog, ci_results) -> dict:
+ """
+ Actually change the catalog data
+ """
+ # Re-sort the catalog keys / subkeys
+ for app, infos in catalog.items():
+ catalog[app] = OrderedDict(sorted(infos.items()))
+ catalog = OrderedDict(sorted(catalog.items()))
+
+ def app_level(app):
+ if app not in ci_results:
+ return 0
+ if ci_result_is_outdated(ci_results[app]):
+ return 0
+ return ci_results[app]["level"]
+
+ for app, info in catalog.items():
+ info["level"] = app_level(app)
+
+ return catalog
+
+
+def list_changes(catalog, ci_results) -> dict[str, list[tuple[str, int, int]]]:
+ """
+ Lists changes for a pull request
+ """
+
+ changes = {
+ "major_regressions": [],
+ "minor_regressions": [],
+ "improvements": [],
+ "outdated": [],
+ "missing": [],
+ }
+
+ for app, infos in catalog.items():
+ if infos.get("state") != "working":
+ continue
+
+ if app not in ci_results:
+ changes["missing"].append(app)
+ continue
+
+ if ci_result_is_outdated(ci_results[app]):
+ changes["outdated"].append(app)
+ continue
+
+ ci_level = ci_results[app]["level"]
+ current_level = infos.get("level")
+
+ if ci_level == current_level:
+ continue
+
+ if current_level is None or ci_level > current_level:
+ changes["improvements"].append((app, current_level, ci_level))
+ continue
+
+ if ci_level < current_level:
+ if ci_level <= 4 < current_level:
+ changes["major_regressions"].append((app, current_level, ci_level))
+ else:
+ changes["minor_regressions"].append((app, current_level, ci_level))
+
+ return changes
+
+
+def pretty_changes(changes: dict[str, list[tuple[str, int, int]]]) -> str:
+ pr_body_template = textwrap.dedent("""
+ {%- if changes["major_regressions"] %}
+ ### Major regressions 😭
+ {% for app in changes["major_regressions"] %}
+ - [ ] [{{app.0}}: {{app.1}} → {{app.2}}](https://ci-apps.yunohost.org/ci/apps/{{app.0}}/latestjob)
+ {%- endfor %}
+ {% endif %}
+ {%- if changes["minor_regressions"] %}
+ ### Minor regressions 😬
+ {% for app in changes["minor_regressions"] %}
+ - [ ] [{{app.0}}: {{app.1}} → {{app.2}}](https://ci-apps.yunohost.org/ci/apps/{{app.0}}/latestjob)
+ {%- endfor %}
+ {% endif %}
+ {%- if changes["improvements"] %}
+ ### Improvements 🥳
+ {% for app in changes["improvements"] %}
+ - [{{app.0}}: {{app.1}} → {{app.2}}](https://ci-apps.yunohost.org/ci/apps/{{app.0}}/latestjob)
+ {%- endfor %}
+ {% endif %}
+ {%- if changes["missing"] %}
+ ### Missing 🫠
+ {% for app in changes["missing"] %}
+ - [{{app}} (See latest job if it exists)](https://ci-apps.yunohost.org/ci/apps/{{app.0}}/latestjob)
+ {%- endfor %}
+ {% endif %}
+ {%- if changes["outdated"] %}
+ ### Outdated ⏰
+ {% for app in changes["outdated"] %}
+ - [ ] [{{app}} (See latest job if it exists)](https://ci-apps.yunohost.org/ci/apps/{{app.0}}/latestjob)
+ {%- endfor %}
+ {% endif %}
+ """)
+
+ return jinja2.Environment().from_string(pr_body_template).render(changes=changes)
+
+
+def make_pull_request(pr_body: str) -> None:
+ pr_data = {
+ "title": "Update app levels according to CI results",
+ "body": pr_body,
+ "head": "update_app_levels",
+ "base": "master"
+ }
+
+ with requests.Session() as s:
+ s.headers.update({"Authorization": f"token {github_token()}"})
+ response = s.post(f"https://api.github.com/repos/{APPS_REPO}/pulls", json=pr_data)
+
+ if response.status_code == 422:
+ response = s.get(f"https://api.github.com/repos/{APPS_REPO}/pulls", data={"head": "update_app_levels"})
+ response.raise_for_status()
+ pr_number = response.json()[0]["number"]
+
+ # head can't be updated
+ del pr_data["head"]
+ response = s.patch(f"https://api.github.com/repos/{APPS_REPO}/pulls/{pr_number}", json=pr_data)
+ response.raise_for_status()
+ existing_url = response.json()["html_url"]
+ logging.warning(f"An existing Pull Request has been updated at {existing_url} !")
else:
- comment["minor_regressions"].append((app, current_level, ci_level))
+ response.raise_for_status()
- infos["level"] = ci_level
+ new_url = response.json()["html_url"]
+ logging.info(f"Opened a Pull Request at {new_url} !")
-# Also re-sort the catalog keys / subkeys
-for app, infos in catalog.items():
- catalog[app] = OrderedDict(sorted(infos.items()))
-catalog = OrderedDict(sorted(catalog.items()))
-updated_catalog = toml.dumps(catalog)
-updated_catalog = updated_catalog.replace(",]", " ]")
-open(f"{tmpdir}/apps.toml", "w").write(updated_catalog)
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--commit", action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("--pr", action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction)
+ args = parser.parse_args()
-os.system(f"git -C {tmpdir} commit apps.toml -m 'Update app levels according to CI results'")
-os.system(f"git -C {tmpdir} push origin update_app_levels --force")
-os.system(f"rm -rf {tmpdir}")
+ logging.getLogger().setLevel(logging.INFO)
+ if args.verbose:
+ logging.getLogger().setLevel(logging.DEBUG)
-PR_body = ""
-if comment["major_regressions"]:
- PR_body += "\n### Major regressions\n\n"
- for app, current_level, new_level in comment['major_regressions']:
- PR_body += f"- [ ] {app} | {current_level} -> {new_level} | https://ci-apps.yunohost.org/ci/apps/{app}/latestjob\n"
-if comment["minor_regressions"]:
- PR_body += "\n### Minor regressions\n\n"
- for app, current_level, new_level in comment['minor_regressions']:
- PR_body += f"- [ ] {app} | {current_level} -> {new_level} | https://ci-apps.yunohost.org/ci/apps/{app}/latestjob\n"
-if comment["improvements"]:
- PR_body += "\n### Improvements\n\n"
- for app, current_level, new_level in comment['improvements']:
- PR_body += f"- {app} | {current_level} -> {new_level} | https://ci-apps.yunohost.org/ci/apps/{app}/latestjob\n"
-if comment["missing"]:
- PR_body += "\n### Missing results\n\n"
- for app in comment['missing']:
- PR_body += f"- {app} | https://ci-apps.yunohost.org/ci/apps/{app}/latestjob\n"
-if comment["outdated"]:
- PR_body += "\n### Outdated results\n\n"
- for app in comment['outdated']:
- PR_body += f"- [ ] {app} | https://ci-apps.yunohost.org/ci/apps/{app}/latestjob\n"
+ with tempfile.TemporaryDirectory(prefix="update_app_levels_") as tmpdir:
+ logging.info("Cloning the repository...")
+ apps_repo = Repo.clone_from(f"git@github.com:{APPS_REPO}", to_path=tmpdir)
-PR = {"title": "Update app levels according to CI results",
- "body": PR_body,
- "head": "update_app_levels",
- "base": "master"}
+ # Load the app catalog and filter out the non-working ones
+ catalog = toml.load((Path(apps_repo.working_tree_dir) / "apps.toml").open("r", encoding="utf-8"))
-with requests.Session() as s:
- s.headers.update({"Authorization": f"token {token}"})
-r = s.post("https://api.github.com/repos/yunohost/apps/pulls", json.dumps(PR))
+ new_branch = apps_repo.create_head("update_app_levels", apps_repo.refs.master)
+ apps_repo.head.reference = new_branch
-if r.status_code != 200:
- print(r.text)
- sys.exit(1)
+ logging.info("Retrieving the CI results...")
+ ci_results = get_ci_results()
+
+ # Now compute changes, then update the catalog
+ changes = list_changes(catalog, ci_results)
+ pr_body = pretty_changes(changes)
+ catalog = update_catalog(catalog, ci_results)
+
+ # Save the new catalog
+ updated_catalog = toml.dumps(catalog)
+ updated_catalog = updated_catalog.replace(",]", " ]")
+ (Path(apps_repo.working_tree_dir) / "apps.toml").open("w", encoding="utf-8").write(updated_catalog)
+
+ if args.commit:
+ logging.info("Committing and pushing the new catalog...")
+ apps_repo.index.add("apps.toml")
+ apps_repo.index.commit("Update app levels according to CI results")
+ apps_repo.remote().push(force=True)
+
+ if args.verbose:
+ print(pr_body)
+
+ if args.pr:
+ logging.info("Opening a pull request...")
+ make_pull_request(pr_body)
+
+
+if __name__ == "__main__":
+ main()