1+ #!/usr/bin/env python3
2+ """
3+ scripts/review_report.py
4+
5+ Builds the markdown body of the "translation review needed" issue from the
6+ changes currently STAGED in git (run it after `git add --all` and
7+ `unstage_cosmetic.py`). Writes an empty file if there is nothing to report.
8+
9+ Sections:
10+ - Summary
11+ - New PO files (counts only; every string in them is untranslated)
12+ - Newly fuzzy strings (old msgid -> new msgid, plus the current msgstr)
13+ - New untranslated strings (new msgids that matched nothing, not fuzzy)
14+
15+ "New" always means "not present in HEAD", so re-running never re-reports
16+ strings from earlier syncs.
17+
18+ Requires: polib (pip install polib)
19+ """
20+ from __future__ import annotations
21+
22+ import argparse
23+ import subprocess
24+ from pathlib import Path
25+
26+ import polib
27+
28+ # GitHub rejects issue bodies over 65,536 characters.
29+ MAX_BODY = 60_000
30+ FUZZY_PER_FILE = 10
31+ UNTRANSLATED_PER_FILE = 25
32+ MAX_LINE = 160
33+
34+
35+ def git (* args : str ) -> str :
36+ return subprocess .run (
37+ ["git" , * args ], capture_output = True , text = True , check = True
38+ ).stdout
39+
40+
41+ def staged (diff_filter : str ) -> list :
42+ out = git (
43+ "diff" , "--staged" , "--no-renames" , "-z" , "--name-only" ,
44+ f"--diff-filter={ diff_filter } " , "--" , "*.po" ,
45+ )
46+ return [p for p in out .split ("\0 " ) if p ]
47+
48+
49+ def key (entry : polib .POEntry ) -> tuple :
50+ return (entry .msgctxt , entry .msgid )
51+
52+
53+ def live (po : polib .POFile ) -> list :
54+ return [e for e in po if not e .obsolete ]
55+
56+
57+ def is_fuzzy (entry : polib .POEntry ) -> bool :
58+ return "fuzzy" in entry .flags
59+
60+
61+ def is_untranslated (entry : polib .POEntry ) -> bool :
62+ if entry .msgid_plural :
63+ return not any (entry .msgstr_plural .values ())
64+ return not entry .msgstr
65+
66+
67+ def clip (text : str , limit : int = MAX_LINE ) -> str :
68+ # Keep everything on one line and make sure we can't close a code fence.
69+ text = text .replace ("\n " , "\\ n" ).replace ("```" , "'''" )
70+ return text if len (text ) <= limit else text [: limit - 1 ] + "…"
71+
72+
73+ # ---------------------------------------------------------------------------
74+ # Collect
75+ # ---------------------------------------------------------------------------
76+
77+
78+ def collect ():
79+ new_files = [] # (path, number of strings)
80+ changes = [] # (path, newly_fuzzy, new_untranslated)
81+
82+ for path in staged ("A" ):
83+ po = polib .pofile (path )
84+ new_files .append ((path , len (live (po ))))
85+
86+ for path in staged ("M" ):
87+ old = polib .pofile (git ("show" , f"HEAD:{ path } " ))
88+ new = polib .pofile (path )
89+ old_live = live (old )
90+ old_keys = {key (e ) for e in old_live }
91+ old_fuzzy = {key (e ) for e in old_live if is_fuzzy (e )}
92+
93+ newly_fuzzy = []
94+ new_untranslated = []
95+ for e in live (new ):
96+ if is_fuzzy (e ):
97+ if key (e ) not in old_fuzzy :
98+ newly_fuzzy .append (e )
99+ elif is_untranslated (e ) and key (e ) not in old_keys :
100+ new_untranslated .append (e )
101+
102+ if newly_fuzzy or new_untranslated :
103+ changes .append ((path , newly_fuzzy , new_untranslated ))
104+
105+ return new_files , changes
106+
107+
108+ # ---------------------------------------------------------------------------
109+ # Render
110+ # ---------------------------------------------------------------------------
111+
112+
113+ def fuzzy_block (path : str , entries : list ) -> str :
114+ lines = [f"### `{ path } ` ({ len (entries )} new fuzzy)" , "" , "```diff" ]
115+ for e in entries [:FUZZY_PER_FILE ]:
116+ if e .previous_msgid :
117+ lines .append ("- " + clip (e .previous_msgid ))
118+ else :
119+ lines .append (" (previous msgid unavailable)" )
120+ lines .append ("+ " + clip (e .msgid ))
121+ if e .msgstr :
122+ lines .append (" msgstr: " + clip (e .msgstr ))
123+ lines .append ("" )
124+ if len (entries ) > FUZZY_PER_FILE :
125+ lines .append (f" … and { len (entries ) - FUZZY_PER_FILE } more" )
126+ lines .append ("```" )
127+ return "\n " .join (lines ) + "\n "
128+
129+
130+ def untranslated_block (path : str , entries : list ) -> str :
131+ lines = [f"### `{ path } ` ({ len (entries )} new untranslated)" , "" , "```text" ]
132+ lines += [clip (e .msgid ) for e in entries [:UNTRANSLATED_PER_FILE ]]
133+ if len (entries ) > UNTRANSLATED_PER_FILE :
134+ lines .append (f"… and { len (entries ) - UNTRANSLATED_PER_FILE } more" )
135+ lines .append ("```" )
136+ return "\n " .join (lines ) + "\n "
137+
138+
139+ class Body :
140+ """Accumulates blocks until the size budget runs out."""
141+
142+ def __init__ (self , limit : int ):
143+ self .parts : list = []
144+ self .size = 0
145+ self .limit = limit
146+ self .omitted = 0
147+
148+ def add (self , text : str ) -> None :
149+ if self .size + len (text ) > self .limit :
150+ self .omitted += 1
151+ return
152+ self .parts .append (text )
153+ self .size += len (text ) + 1
154+
155+
156+ def render (new_files : list , changes : list ) -> str :
157+ fuzzy_total = sum (len (f ) for _ , f , _ in changes )
158+ untr_total = sum (len (u ) for _ , _ , u in changes )
159+ if not (new_files or fuzzy_total or untr_total ):
160+ return ""
161+
162+ body = Body (MAX_BODY )
163+
164+ summary = ["## Summary" , "" ]
165+ if new_files :
166+ strings = sum (n for _ , n in new_files )
167+ summary .append (
168+ f"- 🆕 **{ len (new_files )} ** new PO files ({ strings } strings to translate)"
169+ )
170+ if fuzzy_total :
171+ n = sum (1 for _ , f , _ in changes if f )
172+ summary .append (f"- 🔍 **{ fuzzy_total } ** newly fuzzy strings in { n } files" )
173+ if untr_total :
174+ n = sum (1 for _ , _ , u in changes if u )
175+ summary .append (f"- ✏️ **{ untr_total } ** new untranslated strings in { n } files" )
176+ body .add ("\n " .join (summary ) + "\n " )
177+
178+ if new_files :
179+ lines = [f"## 🆕 New PO files ({ len (new_files )} )" ]
180+ lines += [f"- `{ p } ` ({ n } strings)" for p , n in new_files ]
181+ body .add ("\n " .join (lines ) + "\n " )
182+
183+ if fuzzy_total :
184+ body .add (
185+ "## 🔍 Newly fuzzy strings\n \n "
186+ "The source text changed slightly, so the existing translation "
187+ "(`msgstr`) may need updating. `-` is the old source, `+` the new.\n "
188+ )
189+ for path , fuzzy , _ in changes :
190+ if fuzzy :
191+ body .add (fuzzy_block (path , fuzzy ))
192+
193+ if untr_total :
194+ body .add (
195+ "## ✏️ New untranslated strings\n \n "
196+ "Brand-new source strings with no close match to an existing "
197+ "translation.\n "
198+ )
199+ for path , _ , untr in changes :
200+ if untr :
201+ body .add (untranslated_block (path , untr ))
202+
203+ if body .omitted :
204+ body .parts .append (
205+ f"_{ body .omitted } section(s) omitted to fit GitHub's issue size "
206+ f"limit — see the sync commit's diff for the full list._\n "
207+ )
208+ return "\n " .join (body .parts )
209+
210+
211+ def main () -> None :
212+ parser = argparse .ArgumentParser (description = __doc__ )
213+ parser .add_argument ("--output" , required = True , type = Path )
214+ args = parser .parse_args ()
215+
216+ new_files , changes = collect ()
217+ report = render (new_files , changes )
218+ args .output .write_text (report , encoding = "utf-8" )
219+ print (f"Report: { len (report )} chars, { len (new_files )} new files, "
220+ f"{ len (changes )} changed files with new strings" )
221+
222+
223+ if __name__ == "__main__" :
224+ main ()
0 commit comments