[ADD] manifestes : détecter un dépôt absent d'un palier de migration

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)
This commit is contained in:
Mathieu Benoit 2026-08-27 02:15:45 -04:00
parent 33cf04c7f9
commit 599401b6c4
8 changed files with 840 additions and 7 deletions

View file

@ -2,6 +2,7 @@
<manifest>
<remote name="CybroOdoo" fetch="https://github.com/CybroOdoo/" />
<remote name="ERPLibre" fetch="https://github.com/ERPLibre/" />
<remote name="ERPLibre_origin_MathBenTech" fetch="https://github.com/ERPLibre/" />
<remote name="MathBenTech" fetch="https://github.com/MathBenTech/" />
<remote name="ERPLibre_origin_TechnoLibre" fetch="https://github.com/ERPLibre/" />
<remote name="Numigi" fetch="https://github.com/Numigi/" />
@ -240,6 +241,13 @@
revision="13.0"
groups="addons,odoo13.0"
/>
<project
name="development.git"
path="odoo13.0/addons/MathBenTech_development"
remote="ERPLibre_origin_MathBenTech"
revision="13.0"
groups="addons,odoo13.0,base"
/>
<project
name="donation.git"
path="odoo13.0/addons/OCA_donation"

View file

@ -2,6 +2,7 @@
<manifest>
<remote name="CybroOdoo" fetch="https://github.com/CybroOdoo/" />
<remote name="ERPLibre" fetch="https://github.com/ERPLibre/" />
<remote name="ERPLibre_origin_MathBenTech" fetch="https://github.com/ERPLibre/" />
<remote name="MathBenTech" fetch="https://github.com/MathBenTech/" />
<remote name="ERPLibre_origin_TechnoLibre" fetch="https://github.com/ERPLibre/" />
<remote name="Numigi" fetch="https://github.com/Numigi/" />
@ -244,6 +245,13 @@
revision="15.0"
groups="addons,odoo15.0"
/>
<project
name="development.git"
path="odoo15.0/addons/MathBenTech_development"
remote="ERPLibre_origin_MathBenTech"
revision="15.0"
groups="addons,odoo15.0,base"
/>
<project
name="donation.git"
path="odoo15.0/addons/OCA_donation"

View file

@ -0,0 +1,334 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un dépôt absent d'un palier fait disparaître ses modules en chemin.
Une migration 12 → 18 traverse SIX paliers, et à chacun le checkout
bascule sur le manifeste de cette version. Un dépôt d'addons qui manque
au manifeste du 17 n'existe pas sur disque pendant l'étape 17 : Odoo
déclare ses modules introuvables, et le pilote propose alors de les
effacer de la base. La question « veux-tu supprimer le module manquant »
arrive au milieu de six paliers, on répond oui, et la fonctionnalité du
client part sans que rien n'ait échoué.
Ce qui distingue un trou d'une absence légitime
-----------------------------------------------
Beaucoup de dépôts n'ont tout simplement pas de branche pour une
version : les déclarer serait une erreur. Le seul signal qui vaille est
donc le TROU — présent avant, présent après, absent au milieu — CONFIRMÉ
par l'existence de la branche en amont.
Mesuré sur ce dépôt : 35 trous, dont 19 sont de vraies omissions. Les
seize autres n'ont pas la branche en amont et sont donc corrects. Un
outil qui aurait signalé les 35 aurait eu 46 % de bruit, et un rapport
qui fait peur pour rien finit ignoré en entier.
Quinze des dix-neuf partagent le même motif : déclarés en 16 et en 18,
absents du 17, la branche 17.0 existant en amont. Un lot ajouté pour la
18 sans rétro-portage — exactement le genre d'oubli qu'aucune relecture
ne voit et qu'un compte de trous rend évident.
Pourquoi le réseau est OPTIONNEL
--------------------------------
Confirmer demande un `git ls-remote` par dépôt. Sans `--upstream`,
l'outil liste les trous et dit qu'il ne les a pas départagés — c'est
honnête et instantané. Avec, il tranche.
"""
from __future__ import annotations
import glob
import os
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> 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 <project> entry to')}"
f" manifest/git_manifest_odoo<version>.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())

View file

@ -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",
),
)

View file

@ -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 ""
),
}
)

View file

@ -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 <project> entry to": {
"fr": "Ajouter l'entrée <project> dans",
"en": "Add the <project> 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",

View file

@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<remote name="OCA" fetch="https://github.com/OCA/" />
<remote name="autre" fetch="https://example.org/eq/" />
%s
</manifest>
"""
PROJET = """ <project
name="%s"
path="odoo%s/addons/%s"
remote="%s"
revision="%s"
groups="addons,odoo%s"
/>"""
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("<manifest><project")
self.assertIn("a.git", gaps.declarations(self.dossier))
class TestWhatCountsAsAHole(Base):
def test_absent_in_the_middle_is_a_hole(self):
self.ecrire("16.0", ["sale-channel.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["sale-channel.git"])
trous = self.trous()
self.assertEqual(1, len(trous))
self.assertEqual(["17.0"], trous[0][2])
def test_absent_before_the_first_is_not_a_hole(self):
# Un dépôt né en 16 n'a rien à faire en 12 ; l'exiger crierait
# sur presque tout le fichier.
self.ecrire("12.0", [])
self.ecrire("16.0", ["neuf.git"])
self.ecrire("18.0", ["neuf.git"])
self.assertEqual([], self.trous())
def test_absent_after_the_last_is_not_a_hole(self):
# Un dépôt abandonné après la 14 non plus.
self.ecrire("12.0", ["vieux.git"])
self.ecrire("14.0", ["vieux.git"])
self.ecrire("18.0", [])
self.assertEqual([], self.trous())
def test_a_single_appearance_is_never_a_hole(self):
self.ecrire("12.0", [])
self.ecrire("14.0", ["seul.git"])
self.ecrire("18.0", [])
self.assertEqual([], self.trous())
def test_several_missing_steps_are_all_named(self):
self.ecrire("12.0", ["long.git"])
self.ecrire("13.0", [])
self.ecrire("14.0", [])
self.ecrire("15.0", [])
self.ecrire("16.0", ["long.git"])
self.assertEqual(["13.0", "14.0", "15.0"], self.trous()[0][2])
class TestSettlingAHole(Base):
"""Le tri qui fait tout l'intérêt de l'outil."""
def setUp(self):
super().setUp()
self.ecrire("16.0", ["a.git", "b.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["a.git", "b.git"])
self.appels = []
def lecteur(self, reponses):
def lire(url):
self.appels.append(url)
return reponses.get(url.rsplit("/", 1)[-1])
return lire
def test_without_asking_nothing_is_settled(self):
juges = gaps.judge(self.trous(), verifier=False)
self.assertEqual({"inconnu"}, {j[3] for j in juges})
self.assertEqual([], self.appels)
def test_a_branch_upstream_makes_it_an_omission(self):
juges = gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": {"17.0"}, "b.git": {"17.0"}}),
)
self.assertEqual({"omission"}, {j[3] for j in juges})
def test_no_branch_upstream_makes_it_legitimate(self):
juges = gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": {"16.0", "18.0"}, "b.git": set()}),
)
self.assertEqual({"legitime"}, {j[3] for j in juges})
def test_unreachable_is_not_absolution(self):
# None dit « je n'ai pas pu demander », l'ensemble vide dit « il
# n'y en a pas ». Les confondre transformerait une coupure réseau
# en rapport tout vert.
juges = gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": None, "b.git": None}),
)
self.assertEqual({"inconnu"}, {j[3] for j in juges})
def test_each_repository_is_asked_once(self):
gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": set(), "b.git": set()}),
)
self.assertEqual(len(self.appels), len(set(self.appels)))
self.assertEqual(2, len(self.appels))
class TestAskingTheRemote(unittest.TestCase):
"""La seule fonction qui parle au réseau — bouchonnée, jamais appelée."""
def setUp(self):
self.vrai = gaps.subprocess
self.addCleanup(setattr, gaps, "subprocess", self.vrai)
def repondre(self, sortie="", code=0, leve=None):
essai = self
class Faux:
@staticmethod
def run(argv, capture_output=None, text=None, timeout=None):
essai.argv = argv
if leve:
raise leve
return type("R", (), {"returncode": code, "stdout": sortie})
Faux.SubprocessError = self.vrai.SubprocessError
Faux.TimeoutExpired = self.vrai.TimeoutExpired
gaps.subprocess = Faux
def test_no_url_asks_nothing_and_settles_nothing(self):
# Sans URL on ne peut pas demander ; rendre un ensemble vide
# dirait « l'amont n'a pas la branche », ce qu'on ignore.
self.repondre()
self.assertIsNone(gaps.branches(""))
self.assertFalse(hasattr(self, "argv"))
def test_only_version_branches_are_kept(self):
# « master » et « main » ne sont pas des paliers d'Odoo.
self.repondre(
"abc\trefs/heads/16.0\n"
"def\trefs/heads/master\n"
"012\trefs/heads/17.0\n"
"345\trefs/heads/feature/x\n"
)
self.assertEqual({"16.0", "17.0"}, gaps.branches("https://x/y.git"))
def test_a_failed_call_is_not_settled(self):
self.repondre(code=128)
self.assertIsNone(gaps.branches("https://x/y.git"))
def test_a_timeout_is_not_settled(self):
self.repondre(leve=self.vrai.TimeoutExpired("git", 1))
self.assertIsNone(gaps.branches("https://x/y.git"))
def test_a_missing_git_is_not_settled(self):
self.repondre(leve=OSError("git introuvable"))
self.assertIsNone(gaps.branches("https://x/y.git"))
def test_a_repository_with_no_version_branch_is_settled_as_empty(self):
# Là, on a DEMANDÉ et la réponse est « aucune » : c'est un
# verdict, pas une ignorance.
self.repondre("abc\trefs/heads/master\n")
self.assertEqual(set(), gaps.branches("https://x/y.git"))
def test_it_only_reads_never_clones(self):
self.repondre("")
gaps.branches("https://x/y.git")
self.assertEqual(
["git", "ls-remote", "--heads", "https://x/y.git"], self.argv
)
class TestTheReport(Base):
def rapport(self, juges, verifie=True):
return gaps.render(juges, verifie, colour=False)
def test_an_omission_names_the_repository_and_the_step(self):
texte = self.rapport(
[("sale-channel.git", ["17.0"], ["17.0"], "omission")]
)
self.assertIn("sale-channel.git", texte)
self.assertIn("17.0", texte)
def test_an_omission_says_what_to_do(self):
texte = self.rapport([("a.git", ["17.0"], ["17.0"], "omission")])
self.assertIn("git_manifest_odoo", texte)
def test_a_legitimate_hole_is_counted_not_listed(self):
# Seize lignes de « tout va bien » noieraient les dix-neuf qui
# comptent.
texte = self.rapport([("a.git", ["13.0"], [], "legitime")])
self.assertNotIn("a.git", texte)
self.assertIn("1", texte)
def test_offline_says_it_did_not_ask(self):
texte = self.rapport(
[("a.git", ["17.0"], [], "inconnu")], verifie=False
)
self.assertIn(gaps.t("not asked: run with --upstream"), texte)
def test_unreachable_does_not_read_like_offline(self):
texte = self.rapport(
[("a.git", ["17.0"], [], "inconnu")], verifie=True
)
self.assertIn(gaps.t("upstream unreachable"), texte)
def test_nothing_at_all_is_stated(self):
self.assertIn(
gaps.t("Every repository is declared without a hole."),
self.rapport([]),
)
class TestTheCommand(Base):
def lancer(self, argv):
tampon = io.StringIO()
with redirect_stdout(tampon):
code = gaps.main(argv)
return code, tampon.getvalue()
def test_a_missing_directory_is_a_tool_failure(self):
code, _ = self.lancer(
["--manifest-dir", os.path.join(self.dossier, "absent")]
)
self.assertEqual(2, code)
def test_no_hole_exits_zero(self):
self.ecrire("16.0", ["a.git"])
self.ecrire("18.0", ["a.git"])
code, _ = self.lancer(["--manifest-dir", self.dossier, "--no-color"])
self.assertEqual(0, code)
def test_an_unsettled_hole_alone_does_not_exit_one(self):
# Sans --upstream on ne SAIT pas : sortir 1 ferait échouer une
# chaîne d'intégration sur une question qu'on n'a pas posée.
self.ecrire("16.0", ["a.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["a.git"])
code, texte = self.lancer(
["--manifest-dir", self.dossier, "--no-color"]
)
self.assertEqual(0, code)
self.assertIn("a.git", texte)
def test_the_json_carries_the_state_of_each_hole(self):
import json
self.ecrire("16.0", ["a.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["a.git"])
_code, texte = self.lancer(["--manifest-dir", self.dossier, "--json"])
dct = json.loads(texte)
self.assertFalse(dct["checked_upstream"])
self.assertEqual("a.git", dct["gaps"][0]["repository"])
self.assertEqual("inconnu", dct["gaps"][0]["state"])
class TestAgainstTheRealManifests(unittest.TestCase):
"""Sur le vrai dossier — sans réseau, donc sans trancher."""
def test_the_repository_manifests_parse(self):
lst = gaps.versions()
self.assertGreaterEqual(len(lst), 7)
dct = gaps.declarations()
self.assertGreater(len(dct), 100)
def test_development_is_declared_at_every_step_it_spans(self):
# Les paliers 13 et 15 lui manquaient, et une migration 12 → 18
# les traverse tous les deux.
dct = gaps.declarations()
presentes = set(dct.get("development.git", {}))
for version in (
"12.0",
"13.0",
"14.0",
"15.0",
"16.0",
"17.0",
"18.0",
):
self.assertIn(version, presentes, version)
if __name__ == "__main__":
unittest.main()

View file

@ -1779,10 +1779,30 @@ class TestTheReviewChecklist(Base):
for question, _commande, _clef in quality.REVUE:
self.assertTrue(question.endswith("?"), question)
def test_a_runnable_step_carries_the_database_placeholder(self):
def test_a_runnable_step_either_names_a_database_or_reads_the_checkout(
self,
):
# Une seule étape lit le CHECKOUT et non une base ; toutes les
# autres doivent nommer la leur, sinon elles s'exécuteraient sur
# celle du fichier de configuration, qui n'est pas la migrée.
sans_base = 0
for _q, commande, clef in quality.REVUE:
if clef:
self.assertIn("{db}", commande, commande)
if not clef:
continue
if "{db}" in commande:
continue
sans_base += 1
self.assertIn("script/analyse/", commande, commande)
self.assertEqual(1, sans_base)
def test_the_checkout_step_needs_no_database(self):
# C'est l'angle mort des six autres : elles lisent toutes la BASE
# et ne peuvent pas voir un dépôt d'addons absent d'un palier.
sans_base = [
(q, c) for q, c, k in quality.REVUE if k and "{db}" not in c
]
self.assertEqual(1, len(sans_base))
self.assertIn("check_manifest_gaps", sans_base[0][1])
def test_the_first_step_has_nothing_to_run(self):
# « La migration est-elle allée jusqu'au bout » se lit dans le
@ -1852,14 +1872,30 @@ class TestTheThreeExtraSections(Base):
r["command"] for r in lst if r["kind"] == "review" and r["command"]
]
self.assertTrue(commandes)
for commande in commandes:
nommant_une_base = [c for c in commandes if " -d " in c]
self.assertTrue(nommant_une_base)
for commande in nommant_une_base:
self.assertIn("base_upgrade_18", commande)
for commande in commandes:
self.assertNotIn("{db}", commande)
def test_without_a_database_no_review_step_can_be_run(self):
def test_without_a_database_only_the_db_free_step_can_be_run(self):
# La revue du checkout — les manifestes — se lance justement AVANT
# qu'une migration existe. Exiger une base la rendait inerte au
# seul moment où elle sert.
lst = qtui.extra_rows([], {})
lancables = [r for r in lst if r["kind"] == "review" and r["command"]]
self.assertEqual(1, len(lancables))
self.assertNotIn("{db}", lancables[0]["command"])
self.assertNotIn(" -d ", lancables[0]["command"])
def test_a_step_that_names_a_database_stays_silent_without_one(self):
lst = qtui.extra_rows([], {})
for ligne in [r for r in lst if r["kind"] == "review"]:
self.assertEqual("", ligne["command"])
if "{db}" in dict((q, c) for q, c, _k in quality.REVUE).get(
ligne["question"], ""
):
self.assertEqual("", ligne["command"], ligne["question"])
class TestTheVerdictPanel(Base):