[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:
parent
33cf04c7f9
commit
599401b6c4
8 changed files with 840 additions and 7 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
334
script/analyse/check_manifest_gaps.py
Executable file
334
script/analyse/check_manifest_gaps.py
Executable 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())
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
391
test/test_check_manifest_gaps.py
Normal file
391
test/test_check_manifest_gaps.py
Normal 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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue