Skip to content

Commit 0e101ec

Browse files
committed
Add weekly drift and Transifex activity reports
1 parent 0e46515 commit 0e101ec

3 files changed

Lines changed: 339 additions & 0 deletions

File tree

‎.github/workflows/branch-drift.yml‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Meldet, wenn 3.14 und 3.15 inhaltlich auseinanderlaufen.
2+
#
3+
# Gearbeitet wird in 3.15, von dort wird nach 3.14 gespiegelt. Solange das so
4+
# bleibt, kann es keine Konflikte geben: ein Konflikt entsteht nur, wenn sich
5+
# BEIDE Seiten seit ihrem gemeinsamen Vorfahren bewegt haben. Dieser Lauf ist
6+
# das Frühwarnsystem dafür -- und dafür, dass msgmerge in 3.14 Übersetzungen
7+
# entfernt hat, die in 3.15 noch stehen.
8+
#
9+
# Der Zeitplan liegt bewusst nach "Sync Check with Upstream" (montags 3 Uhr),
10+
# denn genau dessen msgmerge-Lauf erzeugt die Abweichungen.
11+
12+
name: Branch-Abgleich 3.15 nach 3.14
13+
14+
on:
15+
push:
16+
branches: ['3.14', '3.15']
17+
paths: ['**.po']
18+
schedule:
19+
- cron: '0 9 * * 1' # montags 9 Uhr, sechs Stunden nach dem Upstream-Sync
20+
workflow_dispatch:
21+
22+
permissions:
23+
contents: read
24+
25+
jobs:
26+
abgleich:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: 3.14 auschecken (Zielbranch), mit voller Historie
30+
uses: actions/checkout@v5
31+
with:
32+
ref: '3.14'
33+
fetch-depth: 0
34+
35+
- uses: actions/setup-python@v6
36+
with:
37+
python-version: '3.x'
38+
39+
- name: polib installieren
40+
run: pip install polib
41+
42+
- name: Trockenlauf 3.14 <- 3.15
43+
id: trockenlauf
44+
shell: bash
45+
run: |
46+
python tools/sync_branches.py \
47+
--von origin/3.15 --leere-ziele --streng \
48+
-o konflikte.md 2>/dev/null | tee ergebnis.txt
49+
50+
- name: Bericht in die Zusammenfassung schreiben
51+
if: always()
52+
shell: bash
53+
run: |
54+
{
55+
echo "## Abgleich 3.14 <- 3.15"
56+
echo
57+
echo '```'
58+
cat ergebnis.txt 2>/dev/null || echo "(kein Ergebnis)"
59+
echo '```'
60+
if [ -s konflikte.md ]; then
61+
echo
62+
echo "Beide Branches haben dieselben Einträge geändert."
63+
echo "Diese Stellen wurden NICHT angefasst:"
64+
echo
65+
head -c 60000 konflikte.md
66+
fi
67+
} >> "$GITHUB_STEP_SUMMARY"
68+
69+
- name: Konfliktbericht als Artefakt ablegen
70+
if: failure()
71+
uses: actions/upload-artifact@v4
72+
with:
73+
name: konflikte
74+
path: konflikte.md
75+
if-no-files-found: ignore
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Wöchentlicher Bericht: wer hat in Transifex was bearbeitet?
2+
#
3+
# Fragt die Transifex-API ab und listet pro Datei auf, welcher Benutzer dort
4+
# Übersetzungen geändert hat. --ohne-upload blendet die eigenen tx-push-Läufe
5+
# aus, sodass nur echte Bearbeitungen übrig bleiben. Das ist die Grundlage
6+
# dafür, Nutzeränderungen gezielt ins Repo zu holen, statt blind zu pushen.
7+
#
8+
# Der Lauf ändert nichts -- weder im Repo noch in Transifex.
9+
10+
name: Transifex-Aktivität
11+
12+
on:
13+
schedule:
14+
- cron: '0 8 * * 1' # montags 8 Uhr
15+
workflow_dispatch:
16+
inputs:
17+
tage:
18+
description: 'Zeitraum in Tagen'
19+
required: false
20+
default: '7'
21+
22+
permissions:
23+
contents: read
24+
25+
jobs:
26+
aktivitaet:
27+
runs-on: ubuntu-latest
28+
strategy:
29+
fail-fast: false
30+
matrix:
31+
version: ['3.14', '3.15']
32+
steps:
33+
- uses: actions/checkout@v5
34+
with:
35+
ref: ${{ matrix.version }}
36+
37+
- uses: actions/setup-python@v6
38+
with:
39+
python-version: '3.x'
40+
41+
- name: certifi installieren (Wurzelzertifikate)
42+
run: pip install certifi
43+
44+
- name: Aktivität abfragen
45+
env:
46+
TX_TOKEN: ${{ secrets.TX_TOKEN }}
47+
TAGE: ${{ github.event.inputs.tage || '7' }}
48+
shell: bash
49+
run: |
50+
if [ -z "${TX_TOKEN:-}" ]; then
51+
{
52+
echo "## Transifex ${{ matrix.version }}"
53+
echo
54+
echo "Kein \`TX_TOKEN\`-Secret gesetzt -- Schritt übersprungen."
55+
} >> "$GITHUB_STEP_SUMMARY"
56+
exit 0
57+
fi
58+
python tools/tx_activity.py --tage "$TAGE" --ohne-upload \
59+
> bericht.md 2> fehler.txt || true
60+
{
61+
echo "## Transifex ${{ matrix.version }} -- letzte $TAGE Tage"
62+
echo
63+
if [ -s bericht.md ]; then
64+
cat bericht.md
65+
else
66+
echo "Keine Ausgabe. Meldungen des Laufs:"
67+
echo '```'
68+
tail -20 fehler.txt 2>/dev/null
69+
echo '```'
70+
fi
71+
} >> "$GITHUB_STEP_SUMMARY"
72+
73+
- name: Bericht als Artefakt ablegen
74+
if: always()
75+
uses: actions/upload-artifact@v4
76+
with:
77+
name: transifex-aktivitaet-${{ matrix.version }}
78+
path: bericht.md
79+
if-no-files-found: ignore

‎tools/sync_branches.py‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
"""Gleicht Uebersetzungen zwischen zwei Branches ab -- Eintrag fuer Eintrag.
3+
4+
Unser Arbeitsmodell: gearbeitet wird in 3.15, von dort wird nach 3.14
5+
zurueckgespiegelt. Dieses Skript macht den Abgleich nachvollziehbar, statt
6+
eine Datei pauschal zu ueberschreiben.
7+
8+
Warum nicht einfach pomerge? pomerge kennt nur Quelle und Ziel. Es sieht
9+
nicht, WER einen Eintrag zuletzt angefasst hat, und ueberschreibt deshalb
10+
auch Arbeit, die nur im Zielbranch existiert. Dieses Skript vergleicht
11+
beide Seiten gegen ihren gemeinsamen Vorfahren und unterscheidet damit:
12+
13+
nur die Quelle hat sich bewegt -> wird uebertragen
14+
nur das Ziel hat sich bewegt -> bleibt unangetastet
15+
beide haben sich bewegt -> Konflikt, wird gemeldet
16+
Quelle ist leer oder fuzzy -> nie uebertragen
17+
18+
Der letzte Punkt ist wichtig: ein msgmerge kann eine Uebersetzung im
19+
Zielbranch entfernt haben. Mit --leere-ziele werden genau diese Luecken
20+
aus der Quelle wieder gefuellt.
21+
22+
Aufruf (im Repo, das ZIEL muss ausgecheckt sein):
23+
24+
python tools/sync_branches.py --von 3.15 # Trockenlauf
25+
python tools/sync_branches.py --von 3.15 --apply
26+
python tools/sync_branches.py --von 3.15 --leere-ziele --apply
27+
python tools/sync_branches.py --von 3.15 --nur reference,library
28+
python tools/sync_branches.py --von 3.15 -o konflikte.md
29+
30+
Ohne --apply wird nichts geschrieben. Konflikte werden nie automatisch
31+
entschieden -- sie landen im Bericht und gehoeren von Hand geprueft.
32+
"""
33+
34+
import argparse
35+
import os
36+
import subprocess
37+
import sys
38+
import tempfile
39+
40+
try:
41+
import polib
42+
except ImportError:
43+
sys.exit("polib fehlt. Im aktiven venv installieren: pip install polib")
44+
45+
46+
def git(*args, repo="."):
47+
e = subprocess.run(["git", "--no-optional-locks", *args], cwd=repo,
48+
capture_output=True, text=True)
49+
return e.stdout if e.returncode == 0 else None
50+
51+
52+
def karte(text):
53+
"""{msgid: msgstr} aus einem PO-Text; fuzzy zaehlt als unuebersetzt."""
54+
with tempfile.NamedTemporaryFile("w", suffix=".po", encoding="utf-8",
55+
delete=False) as f:
56+
f.write(text)
57+
tmp = f.name
58+
try:
59+
po = polib.pofile(tmp)
60+
return {e.msgid: ("" if "fuzzy" in e.flags else e.msgstr)
61+
for e in po if not e.obsolete and e.msgid}
62+
finally:
63+
os.unlink(tmp)
64+
65+
66+
def po_dateien(ref):
67+
aus = git("ls-tree", "-r", "--name-only", ref) or ""
68+
return {z for z in aus.split("\n") if z.endswith(".po")}
69+
70+
71+
def main() -> None:
72+
p = argparse.ArgumentParser(
73+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
74+
p.add_argument("--von", required=True, metavar="BRANCH",
75+
help="Quellbranch, aus dem uebertragen wird (z. B. 3.15)")
76+
p.add_argument("--apply", action="store_true",
77+
help="tatsaechlich schreiben (ohne dies nur ein Trockenlauf)")
78+
p.add_argument("--leere-ziele", action="store_true", dest="leere_ziele",
79+
help="auch Eintraege fuellen, die im Ziel leer sind, "
80+
"obwohl sich die Quelle nicht bewegt hat")
81+
p.add_argument("--nur", help="nur diese Pfadanfaenge, kommagetrennt")
82+
p.add_argument("--streng", action="store_true",
83+
help="mit Rueckgabewert 1 enden, wenn Konflikte gefunden wurden "
84+
"(fuer die CI; Luecken allein gelten nicht als Fehler)")
85+
p.add_argument("-o", "--ausgabe", metavar="DATEI",
86+
help="Konfliktbericht als Markdown schreiben")
87+
args = p.parse_args()
88+
89+
ziel = (git("rev-parse", "--abbrev-ref", "HEAD") or "").strip()
90+
if not ziel:
91+
sys.exit("Kein Git-Repository -- bitte im python-docs-de-Verzeichnis starten.")
92+
if ziel == args.von:
93+
sys.exit(f"Quelle und Ziel sind derselbe Branch ({ziel}).")
94+
# Nur geaenderte .po-Dateien sind gefaehrlich: sie wuerden mit den
95+
# uebertragenen Eintraegen vermischt. Unversionierte Dateien und
96+
# geaenderte Skripte stoeren nicht.
97+
schmutzig = [z[3:] for z in (git("status", "--porcelain", "--untracked-files=no")
98+
or "").splitlines() if z[3:].endswith(".po")]
99+
if schmutzig and args.apply:
100+
sys.exit(f"{len(schmutzig)} .po-Datei(en) sind ungespeichert geaendert, "
101+
f"z. B. {schmutzig[0]}.\nBitte erst committen oder stashen.")
102+
103+
vorfahr = (git("merge-base", ziel, args.von) or "").strip()
104+
if not vorfahr:
105+
sys.exit(f"Kein gemeinsamer Vorfahr von {ziel} und {args.von} gefunden.")
106+
107+
dateien = sorted(po_dateien(ziel) & po_dateien(args.von))
108+
if args.nur:
109+
anfaenge = tuple(a.strip() for a in args.nur.split(",") if a.strip())
110+
dateien = [d for d in dateien if d.startswith(anfaenge)]
111+
112+
print(f"{ziel} <- {args.von} (Vorfahr {vorfahr[:8]}, {len(dateien)} Dateien)",
113+
file=sys.stderr)
114+
115+
uebertragen, gefuellt, konflikte = [], [], []
116+
for nr, pfad in enumerate(dateien, 1):
117+
if nr % 100 == 0:
118+
print(f" {nr}/{len(dateien)}", file=sys.stderr)
119+
t_quelle = git("show", f"{args.von}:{pfad}")
120+
if t_quelle is None or not os.path.isfile(pfad):
121+
continue
122+
quelle = karte(t_quelle)
123+
t_basis = git("show", f"{vorfahr}:{pfad}")
124+
basis = karte(t_basis) if t_basis else {}
125+
126+
po = polib.pofile(pfad, wrapwidth=79)
127+
n = 0
128+
for e in po:
129+
if e.obsolete or not e.msgid or e.msgid not in quelle:
130+
continue
131+
hier = "" if "fuzzy" in e.flags else e.msgstr
132+
dort = quelle[e.msgid]
133+
if not dort or dort == hier:
134+
continue
135+
urzustand = basis.get(e.msgid, "")
136+
hier_neu, dort_neu = hier != urzustand, dort != urzustand
137+
if dort_neu and hier_neu:
138+
konflikte.append((pfad, e.msgid, hier, dort))
139+
continue
140+
if not dort_neu and not (args.leere_ziele and not hier):
141+
continue
142+
if args.apply:
143+
e.msgstr = dort
144+
if "fuzzy" in e.flags:
145+
e.flags.remove("fuzzy")
146+
(gefuellt if not hier else uebertragen).append(pfad)
147+
n += 1
148+
if n and args.apply:
149+
if po.metadata_is_fuzzy:
150+
po.metadata_is_fuzzy = False
151+
po.save(pfad)
152+
153+
kopf = "GESCHRIEBEN" if args.apply else "TROCKENLAUF"
154+
print(f"\n{kopf}: {len(uebertragen)} aktualisiert, {len(gefuellt)} Luecken gefuellt, "
155+
f"{len(konflikte)} Konflikte")
156+
if not args.apply:
157+
print("Mit --apply wird tatsaechlich geschrieben.")
158+
159+
if konflikte:
160+
zeilen = [f"# Konflikte beim Abgleich {ziel} <- {args.von}\n",
161+
"Beide Seiten haben sich seit dem gemeinsamen Vorfahren bewegt.",
162+
"Diese Eintraege wurden NICHT angefasst.\n"]
163+
aktuell = None
164+
for pfad, mid, hier, dort in konflikte:
165+
if pfad != aktuell:
166+
zeilen.append(f"\n## {pfad}\n"); aktuell = pfad
167+
zeilen.append(f"- msgid: `{mid[:100]}`")
168+
zeilen.append(f" - {ziel}: {hier[:160]}")
169+
zeilen.append(f" - {args.von}: {dort[:160]}")
170+
bericht = "\n".join(zeilen)
171+
if args.ausgabe:
172+
open(args.ausgabe, "w", encoding="utf-8").write(bericht + "\n")
173+
print(f"Konfliktbericht geschrieben: {args.ausgabe}", file=sys.stderr)
174+
else:
175+
print("\n" + bericht[:3000])
176+
177+
if args.apply:
178+
print("\nJetzt noch: powrap -m und ./pre_push_check.sh --quick")
179+
180+
if args.streng and konflikte:
181+
sys.exit(1)
182+
183+
184+
if __name__ == "__main__":
185+
main()

0 commit comments

Comments
 (0)