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)
334 lines
11 KiB
Python
Executable file
334 lines
11 KiB
Python
Executable file
#!/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())
|