From 599401b6c489735602e02b4ba12ed0bb43dfd93a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 02:15:45 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20manifestes=20:=20d=C3=A9tecter=20un=20d?= =?UTF-8?q?=C3=A9p=C3=B4t=20absent=20d'un=20palier=20de=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une migration 12 → 18 traverse six paliers, et le checkout bascule de manifeste à chaque fois. Un dépôt d'addons absent du manifeste du 17 n'existe pas sur disque pendant l'étape 17 : Odoo déclare ses modules introuvables, le pilote propose de les effacer, on répond oui, et la fonctionnalité part sans qu'aucun échec ne soit signalé. Seul le trou compte — présent avant et après, absent au milieu — et la branche en amont le confirme : sur 35 trous, 19 sont de vraies omissions, quinze déclarées en 16 et 18 mais pas en 17 ; les 35 auraient fait 46 % de bruit. development.git est rétabli en 13 et en 15. --- EN --- A 12 → 18 migration crosses six steps, and the checkout switches manifest each time. An addons repository missing from the 17 manifest does not exist on disk during step 17: Odoo reports its modules as missing, the driver offers to delete them, you answer yes, and the feature is gone without a single failure being reported. Only the hole counts — present before and after, absent in between — and the upstream branch confirms it: of 35 holes, 19 are real omissions, fifteen declared in 16 and 18 but not in 17; all 35 would have been 46% noise. development.git is restored in 13 and in 15. Assisted-by: Claude Opus 5 (cherry picked from commit d7613ea930d45153e9fb7c08460690a95e3dca0e) --- manifest/git_manifest_odoo13.0.xml | 8 + manifest/git_manifest_odoo15.0.xml | 8 + script/analyse/check_manifest_gaps.py | 334 +++++++++++++++ script/analyse/check_migration_quality.py | 5 + script/analyse/check_migration_quality_tui.py | 4 +- script/todo/todo_i18n.py | 49 +++ test/test_check_manifest_gaps.py | 391 ++++++++++++++++++ test/test_check_migration_quality.py | 48 ++- 8 files changed, 840 insertions(+), 7 deletions(-) create mode 100755 script/analyse/check_manifest_gaps.py create mode 100644 test/test_check_manifest_gaps.py diff --git a/manifest/git_manifest_odoo13.0.xml b/manifest/git_manifest_odoo13.0.xml index b7c0b2f..be38851 100644 --- a/manifest/git_manifest_odoo13.0.xml +++ b/manifest/git_manifest_odoo13.0.xml @@ -2,6 +2,7 @@ + @@ -240,6 +241,13 @@ revision="13.0" groups="addons,odoo13.0" /> + + @@ -244,6 +245,13 @@ revision="15.0" groups="addons,odoo15.0" /> + str: + return key + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) +DOSSIER = os.path.join(REPO_ROOT, "manifest") +MOTIF_FICHIER = re.compile(r"git_manifest_odoo(\d+\.\d+)\.xml$") +MOTIF_BRANCHE = re.compile(r"refs/heads/(\d+\.\d+)$") +DELAI_RESEAU = 60 +PARALLELE = 8 + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +def rang(version): + """« 9.0 » avant « 10.0 » : trier des versions comme du texte ment.""" + return tuple(int(x) for x in version.split(".")) + + +def versions(dossier=DOSSIER): + """Les versions qui ont un manifeste, lues sur le disque. + + Les DÉDUIRE plutôt que les écrire : une version ajoutée au dépôt doit + entrer dans l'analyse sans qu'on pense à toucher ici. Les manifestes + `_dev` sont écartés — ils complètent le principal, et un dépôt qui + n'est que dans le `_dev` n'est pas censé être partout. + """ + trouves = {} + for chemin in glob.glob(os.path.join(dossier, "git_manifest_odoo*.xml")): + found = MOTIF_FICHIER.search(os.path.basename(chemin)) + if found: + trouves[found.group(1)] = chemin + return [(v, trouves[v]) for v in sorted(trouves, key=rang)] + + +def declarations(dossier=DOSSIER): + """{dépôt: {version: url}} — qui est déclaré où, et d'où il vient. + + L'URL se recompose du `fetch` de son remote et du nom du projet ; + c'est elle qui permettra d'aller demander à l'amont s'il a la branche. + """ + par_depot = {} + for version, chemin in versions(dossier): + try: + racine = ET.parse(chemin).getroot() + except ET.ParseError: + continue + fetch = { + r.get("name"): r.get("fetch") or "" for r in racine.iter("remote") + } + for projet in racine.iter("project"): + nom = projet.get("name") + if not nom: + continue + base = fetch.get(projet.get("remote"), "") + url = base.rstrip("/") + "/" + nom if base else "" + par_depot.setdefault(nom, {})[version] = url + return par_depot + + +def gaps(par_depot, toutes_versions=None): + """[(dépôt, présentes, manquantes, url)] — les trous, et eux seuls. + + Un trou est un manque ENTRE deux présences. Ce qui manque avant la + première ou après la dernière n'en est pas un : un dépôt né en 16 n'a + rien à faire en 12, et l'exiger crierait sur presque tout le fichier. + """ + ordre = toutes_versions or [v for v, _ in versions()] + lst = [] + for nom, par_version in sorted(par_depot.items()): + presentes = sorted(par_version, key=rang) + debut, fin = ordre.index(presentes[0]), ordre.index(presentes[-1]) + manquantes = [ + v for v in ordre[debut : fin + 1] if v not in par_version + ] + if manquantes: + url = par_version[presentes[-1]] or par_version[presentes[0]] + lst.append((nom, presentes, manquantes, url)) + return lst + + +def branches(url, delai=DELAI_RESEAU): + """Les branches de version publiées en amont, ou None si injoignable. + + None et l'ensemble vide ne disent PAS la même chose : « je n'ai pas + pu demander » n'est pas « il n'y en a pas », et confondre les deux + transformerait une coupure réseau en absolution générale. + """ + if not url: + return None + try: + done = subprocess.run( + ["git", "ls-remote", "--heads", url], + capture_output=True, + text=True, + timeout=delai, + ) + except (OSError, subprocess.SubprocessError): + return None + if done.returncode: + return None + trouvees = set() + for ligne in done.stdout.splitlines(): + found = MOTIF_BRANCHE.search(ligne.strip()) + if found: + trouvees.add(found.group(1)) + return trouvees + + +def judge(lst_gaps, verifier=False, lecteur=branches, parallele=PARALLELE): + """[(dépôt, manquantes, omissions, etat)] — trancher, ou avouer. + + `etat` vaut "omission" quand l'amont a la branche, "legitime" quand + il ne l'a pas, "inconnu" quand on n'a pas demandé ou pas pu. + """ + if not verifier: + return [(n, m, [], "inconnu") for n, _p, m, _u in lst_gaps] + + with ThreadPoolExecutor(max_workers=parallele) as pool: + amonts = list(pool.map(lambda g: lecteur(g[3]), lst_gaps)) + + resultat = [] + for (nom, _presentes, manquantes, _url), amont in zip(lst_gaps, amonts): + if amont is None: + resultat.append((nom, manquantes, [], "inconnu")) + continue + omissions = [v for v in manquantes if v in amont] + resultat.append( + ( + nom, + manquantes, + omissions, + "omission" if omissions else "legitime", + ) + ) + return resultat + + +def render(juges, verifie, colour=True): + """Le rapport lisible. Les omissions d'abord, le reste en sourdine.""" + lignes = [f"🗺 {t('Repositories missing from a step')}", ""] + omissions = [j for j in juges if j[3] == "omission"] + inconnus = [j for j in juges if j[3] == "inconnu"] + legitimes = [j for j in juges if j[3] == "legitime"] + + if not juges: + lignes.append( + paint( + f"✅ {t('Every repository is declared without a hole.')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + for nom, _manq, omis, _etat in omissions: + lignes.append(paint(f"❌ {nom}", "broken", colour)) + lignes.append( + paint( + f" {t('missing from')} {', '.join(omis)}" + f" — {t('upstream has the branch')}", + "dim", + colour, + ) + ) + + if omissions: + lignes.append("") + lignes.append( + paint( + f" {t('Add the entry to')}" + f" manifest/git_manifest_odoo.xml", + "dim", + colour, + ) + ) + + if inconnus: + lignes.append("") + mot = ( + t("not asked: run with --upstream") + if not verifie + else t("upstream unreachable") + ) + lignes.append( + paint( + f"❔ {len(inconnus)} {t('holes not settled')} — {mot}", + "watch", + colour, + ) + ) + for nom, manq, _o, _e in inconnus: + lignes.append( + paint(f" {nom} — {', '.join(manq)}", "dim", colour) + ) + + if legitimes: + lignes.append("") + lignes.append( + paint( + f"✅ {len(legitimes)} {t('holes are legitimate: no such branch upstream')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("Repositories declared for some steps but not others."), + ) + parser.add_argument( + "--upstream", + action="store_true", + help=t("ask each remote whether the branch exists (network)"), + ) + parser.add_argument("--manifest-dir", default=DOSSIER) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + if not os.path.isdir(args.manifest_dir): + print(f"❌ {args.manifest_dir}", file=sys.stderr) + return 2 + + lst = gaps( + declarations(args.manifest_dir), + [v for v, _ in versions(args.manifest_dir)], + ) + juges = judge(lst, verifier=args.upstream) + + if args.json: + print( + json.dumps( + { + "checked_upstream": args.upstream, + "gaps": [ + { + "repository": n, + "missing": m, + "omissions": o, + "state": e, + } + for n, m, o, e in juges + ], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(juges, args.upstream, colour)) + return 1 if any(j[3] == "omission" for j in juges) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index d9c4c17..69e9850 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -248,6 +248,11 @@ REVUE = ( "script/odoo/migration/database_cleanup.py -d {db}", "cleanup", ), + ( + "Will every step find its addons?", + "script/analyse/check_manifest_gaps.py --upstream", + "manifest", + ), ) diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index c56a1da..d0b8d31 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -204,7 +204,9 @@ def extra_rows(presents, dct=None): "detail": "▶" if clef else "", "question": question, "command": ( - commande.format(db=cible) if cible and clef else "" + commande.format(db=cible) + if clef and (cible or "{db}" not in commande) + else "" ), } ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 43e644a..980588a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6978,6 +6978,55 @@ TRANSLATIONS = { "en": "Migration quality, step by step", }, # --- Écran de qualité : Verdicts, Validation, Revue --- + # --- Trous dans les manifestes Google Repo --- + "Will every step find its addons?": { + "fr": "Chaque palier trouvera-t-il ses addons ?", + "en": "Will every step find its addons?", + }, + "Repositories missing from a step": { + "fr": "Dépôts absents d'un palier", + "en": "Repositories missing from a step", + }, + "Every repository is declared without a hole.": { + "fr": "Chaque dépôt est déclaré sans trou.", + "en": "Every repository is declared without a hole.", + }, + "missing from": { + "fr": "absent de", + "en": "missing from", + }, + "upstream has the branch": { + "fr": "la branche existe en amont", + "en": "upstream has the branch", + }, + "Add the entry to": { + "fr": "Ajouter l'entrée dans", + "en": "Add the entry to", + }, + "holes not settled": { + "fr": "trous non départagés", + "en": "holes not settled", + }, + "not asked: run with --upstream": { + "fr": "pas demandé : relancer avec --upstream", + "en": "not asked: run with --upstream", + }, + "upstream unreachable": { + "fr": "amont injoignable", + "en": "upstream unreachable", + }, + "holes are legitimate: no such branch upstream": { + "fr": "trous légitimes : pas de telle branche en amont", + "en": "holes are legitimate: no such branch upstream", + }, + "Repositories declared for some steps but not others.": { + "fr": "Dépôts déclarés pour certains paliers et pas d'autres.", + "en": "Repositories declared for some steps but not others.", + }, + "ask each remote whether the branch exists (network)": { + "fr": "demander à chaque amont si la branche existe (réseau)", + "en": "ask each remote whether the branch exists (network)", + }, "Verdicts the migration recorded": { "fr": "Verdicts enregistrés par la migration", "en": "Verdicts the migration recorded", diff --git a/test/test_check_manifest_gaps.py b/test/test_check_manifest_gaps.py new file mode 100644 index 0000000..6dd3d94 --- /dev/null +++ b/test/test_check_manifest_gaps.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le trou dans un manifeste, et ce qui le distingue d'une absence normale. + +Beaucoup de dépôts n'ont pas de branche pour toutes les versions d'Odoo. +Les signaler tous ferait 46 % de bruit — mesuré : 35 trous, 19 vraies +omissions. L'outil ne vaut que par ce tri, et c'est donc lui qu'on teste. + +Aucun test ne touche au réseau : `judge` reçoit son lecteur d'amont, et +les tests lui en donnent un qui répond de mémoire. Un test qui appelle +GitHub échoue dans un train, et l'on finit par ne plus le lancer. +""" + +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_manifest_gaps as gaps # noqa: E402 + +MANIFESTE = """ + + + +%s + +""" + +PROJET = """ """ + + +def projet(nom, version, remote="OCA"): + court = nom.replace(".git", "") + return PROJET % (nom, version, court, remote, version, version) + + +class Base(unittest.TestCase): + def setUp(self): + self.dossier = tempfile.mkdtemp() + self.addCleanup(__import__("shutil").rmtree, self.dossier) + + def ecrire(self, version, noms, remote="OCA"): + corps = "\n".join(projet(n, version, remote) for n in noms) + chemin = os.path.join( + self.dossier, "git_manifest_odoo%s.xml" % version + ) + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(MANIFESTE % corps) + return chemin + + def trous(self): + ordre = [v for v, _ in gaps.versions(self.dossier)] + return gaps.gaps(gaps.declarations(self.dossier), ordre) + + +class TestReadingTheManifests(Base): + def test_the_versions_come_from_the_files_not_a_list(self): + # Une version ajoutée au dépôt doit entrer dans l'analyse sans + # qu'on pense à toucher le code. + for v in ("12.0", "14.0", "18.0"): + self.ecrire(v, ["a.git"]) + self.assertEqual( + ["12.0", "14.0", "18.0"], + [v for v, _ in gaps.versions(self.dossier)], + ) + + def test_ten_comes_after_nine_not_before(self): + # Trier des versions comme du texte met « 10.0 » avant « 9.0 ». + self.assertLess(gaps.rang("9.0"), gaps.rang("10.0")) + + def test_the_dev_manifests_are_left_out(self): + # Ils COMPLÈTENT le principal : un dépôt qui n'est que là n'a + # aucune raison d'être partout, et le compter créerait des trous + # imaginaires dans toutes les versions. + self.ecrire("14.0", ["a.git"]) + with io.open( + os.path.join(self.dossier, "git_manifest_odoo14.0_dev.xml"), + "w", + encoding="utf-8", + ) as handle: + handle.write(MANIFESTE % projet("b.git", "14.0")) + self.assertEqual(["14.0"], [v for v, _ in gaps.versions(self.dossier)]) + + def test_the_url_is_rebuilt_from_its_remote(self): + self.ecrire("14.0", ["donation.git"]) + dct = gaps.declarations(self.dossier) + self.assertEqual( + "https://github.com/OCA/donation.git", + dct["donation.git"]["14.0"], + ) + + def test_an_unparsable_manifest_does_not_stop_the_others(self): + self.ecrire("14.0", ["a.git"]) + with io.open( + os.path.join(self.dossier, "git_manifest_odoo16.0.xml"), + "w", + encoding="utf-8", + ) as handle: + handle.write("