diff options
Diffstat (limited to 'scripts/build-changelog.py')
| -rw-r--r-- | scripts/build-changelog.py | 207 |
1 files changed, 188 insertions, 19 deletions
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py index 8973761..391581b 100644 --- a/scripts/build-changelog.py +++ b/scripts/build-changelog.py @@ -54,20 +54,44 @@ def git(*args): def commits(): - """Oldest first: [{sha, date, subject, files:[...]}]""" + """Oldest first: [{sha, date, subject, body, files:[...]}] + + `%b` is read, not just `%s`: the shipped fixes carry their symptom -> cause -> fix -> + verified prose in the commit BODY (5b55af9 is a typical one), so a subject-only changelog + rendered "one-line summaries" for every entry and made README's claim about this file + false as served. The body is terminated by %x02 because it is multi-line and the parser + below is line-based; without a terminator a body line is indistinguishable from a path. + """ raw = git('log', '--reverse', '--date=iso-strict', - '--format=%x01%H%x01%aI%x01%s', '--name-only', '--', APP_REL) - out, cur = [], None + '--format=%x01%H%x01%aI%x01%s%x01%b%x02', '--name-only', '--', APP_REL) + out, cur, where = [], None, 'body' for line in raw.split('\n'): if line.startswith('\x01'): - _, sha, iso, subject = line.split('\x01', 3) - cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, 'files': []} + parts = line.split('\x01', 4) + if len(parts) < 5: + continue + _, sha, iso, subject, line = parts + cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, + 'body': [], 'files': []} out.append(cur) - elif line.strip() and cur is not None: + where = 'body' + if cur is None: + continue + if where == 'body': + if '\x02' in line: + head = line.split('\x02', 1)[0] + if head.strip(): + cur['body'].append(head) + where = 'files' + else: + # Blank lines are kept: they are the paragraph boundary the renderer splits on. + cur['body'].append(line.rstrip()) + elif line.strip(): cur['files'].append(line.strip()) + for row in out: + row['body'] = '\n'.join(row['body']).strip() return out - _TAGMAP = None @@ -132,8 +156,84 @@ def clean(subject): """ body = subject.split(':', 1)[1].strip() if ':' in subject else subject body = body[0].upper() + body[1:] if body else subject - return re.sub(r'`([^`]+)`', lambda m: '<code>' + html.escape(m.group(1)) + '</code>', - html.escape(body)) + return inline(body) + + +def inline(text): + """Escape, then backticks -> <code>. Shared by subjects and body prose. + + Escape ONCE, before wrapping: escaping the captured group again double-encodes any + `&`/`<`/`>` that sits inside a code span (measured: the "run `git show <sha>`" marker + shipped as &lt;sha&gt; and reached CHANGELOG.md as <sha>). + """ + return re.sub(r'`([^`]+)`', lambda m: '<code>' + m.group(1) + '</code>', html.escape(text)) + + +# A commit body can run to 2 KB and this page lists every build, so body detail is capped and +# the cut is MARKED: a reader must be able to tell a summary from a full transcript. +DETAIL_MAX_CHARS = 900 +DETAIL_MAX_BLOCKS = 8 + + +def detail_blocks(text): + """Commit body -> [('p'|'li', plain text)], blank-line separated. + + Bullet runs become list items (they read as the symptom/cause/fix/verified lines this + project writes); wrapped prose lines are rejoined into one paragraph. Without %b the + changelog could only ever show a subject, whatever the commit actually documented. + """ + blocks = [] + for chunk in re.split(r'\n\s*\n', (text or '').strip()): + lines = [ln.strip() for ln in chunk.splitlines() if ln.strip()] + if not lines: + continue + if all(re.match(r'^[-*\u2022]\s+', ln) for ln in lines): + for ln in lines: + blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln))) + continue + prose = [] + for ln in lines: + if re.match(r'^[-*\u2022]\s+', ln): + if prose: + blocks.append(('p', ' '.join(prose))) + prose = [] + blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln))) + else: + prose.append(ln) + if prose: + blocks.append(('p', ' '.join(prose))) + + out, used = [], 0 + for kind, body in blocks: + if len(out) >= DETAIL_MAX_BLOCKS or used + len(body) > DETAIL_MAX_CHARS: + out.append(('p', '\u2026 run `git show <sha>` for the rest')) + break + out.append((kind, body)) + used += len(body) + return out + + +def detail_html(blocks): + """Collapsible detail inside the list item: native <details>, so the page's renderer + (item.innerHTML = html) needs no change and a reader can ignore it.""" + if not blocks: + return '' + out, in_ul = ['<details><summary>detail</summary>'], False + for kind, body in blocks: + if kind == 'li': + if not in_ul: + out.append('<ul>') + in_ul = True + out.append('<li>%s</li>' % inline(body)) + else: + if in_ul: + out.append('</ul>') + in_ul = False + out.append('<p>%s</p>' % inline(body)) + if in_ul: + out.append('</ul>') + out.append('</details>') + return ''.join(out) def build(): @@ -168,11 +268,16 @@ def build(): entries = [] for ver, group in releases.items(): items = [] - for row in group[:MAX_ITEMS]: - items.append('<strong>%s</strong> <code>%s</code>' % ( - clean(row['subject']), html.escape(row['sha'][:7]))) + # The NEWEST commits of a build, not the oldest: group is chronological, so group[:N] + # showed the beginning of a 17-commit build and dropped the fixes that shipped last — + # measured 2026-11-06 when this fix's own commit was cut off its own entry. + shown = group[-MAX_ITEMS:] if len(group) > MAX_ITEMS: - items.append('…and %d more commit(s) in this build' % (len(group) - MAX_ITEMS)) + items.append('…and %d earlier commit(s) in this build' % (len(group) - MAX_ITEMS)) + for row in shown: + items.append('<strong>%s</strong> <code>%s</code>%s' % ( + clean(row['subject']), html.escape(row['sha'][:7]), + detail_html(detail_blocks(row.get('body'))))) kinds = Counter(kind_of(r['subject']) for r in group) # The dominant kind makes the chip honest: a build that was 9 fixes and 1 # feature is a fix build, not a feature release. @@ -189,7 +294,7 @@ def build(): 'note': 'Touches ' + ', '.join('%s (%d)' % (a, n) for a, n in areas.most_common(4)) + ' · ' + ', '.join('%s %d' % (k, n) for k, n in kinds.most_common(3)), 'sources': sorted({f for r in group for f in r['files'][:1]})[:4], - 'commits': [r['sha'][:7] for r in group[:MAX_ITEMS]], + 'commits': [r['sha'][:7] for r in shown], }) entries.reverse() # newest build first @@ -206,9 +311,11 @@ def build(): 'builds whose tree reached a commit can appear here. Read a jump in the ' 'version chip as "those bumps were superseded before commit".'}) gaps.append({'range': 'every build', - 'why': 'Entries summarise commit subjects, not diffs. A build with one squashed ' - 'commit shows one line even if it changed ten files; run ' - '`git show <sha>` for the change itself.'}) + 'why': 'Entries carry the commit subject and, where the commit body has ' + 'substance, its symptom / cause / fix / verification text (folded open ' + 'per line on the page, nested under the line in CHANGELOG.md). A build ' + 'whose work was squashed into one commit still shows one line; the diff ' + 'itself is always `git show <sha>`.'}) now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') prev = existing_island() @@ -232,7 +339,8 @@ def build(): DEK = ('Every change that has a commit behind it. Each line names the build it landed in and the ' - 'commit it came from, so any claim here can be checked with <code>git show</code>. Where ' + 'commit it came from, and folds open to the commit body — the symptom, cause, fix and how ' + 'it was verified — so any claim here can be checked with <code>git show</code>. Where ' 'the repository has no record, the gap is written down instead of filled in.') GAP_INTRO = ('This page is generated from the repository\'s own history for ' '<code>spaces/newos4</code> — the same source the running build comes from, so ' @@ -257,6 +365,27 @@ def existing_island(): return None +# Rendered as native <details> inside each list item, so the page's own JS needs no change. +# Injected once (sentinel-guarded) because the page is a static document: a builder that only +# rewrites its JSON island cannot restyle anything else. +DETAIL_CSS = """ /* wx11-changelog-detail:BEGIN */ + .entry li>details{margin:.3rem 0 0} + .entry li>details>summary{ + cursor:pointer; font-family:var(--mono); font-size:.7rem; letter-spacing:.08em; + text-transform:uppercase; color:var(--ink-3); padding:.45rem 0; + } + .entry li>details>summary:hover{color:var(--cyan)} + .entry li>details p{margin:.35rem 0 0; font-size:.88rem; color:var(--ink-2); line-height:1.55; overflow-wrap:anywhere} + .entry li>details ul{margin:.3rem 0 0; padding-left:1rem; font-size:.88rem; color:var(--ink-2); line-height:1.55; overflow-wrap:anywhere} + /* wx11-changelog-detail:END */ +""" +DETAIL_CSS_RX = re.compile(r'/\* wx11-changelog-detail:BEGIN \*/[\s\S]*?/\* wx11-changelog-detail:END \*/\n?') +# The first version shipped a single-line sentinel and no end marker; strip it or the page +# would keep both copies forever. +LEGACY_CSS_RX = re.compile(r'\s*/\* wx11-changelog-detail: collapsible commit body \*/' + r'[\s\S]*?\.entry li>details ul\{[^}]*\}\n') + + def write_page(page): with open(PAGE, encoding='utf-8') as fh: doc = fh.read() @@ -271,6 +400,14 @@ def write_page(page): new = re.sub(r'(<section class="panel" aria-labelledby="gaps-h">[\s\S]*?<h2 id="gaps-h">[^<]*</h2>\s*<p>)[\s\S]*?(</p>)', lambda m: m.group(1) + GAP_INTRO + m.group(2), new, count=1) new = re.sub(r'(<b id="cur-ver">)[^<]*(</b>)', lambda m: m.group(1) + html.escape(page['currentVersion']) + m.group(2), new, count=1) + new = LEGACY_CSS_RX.sub('\n', new, count=1) + if DETAIL_CSS_RX.search(new): + # Owned block: re-emitted every run, so the rules can be edited here (the page is + # static; a stale copy on disk would silently win). + new = DETAIL_CSS_RX.sub(lambda m: DETAIL_CSS, new, count=1) + else: + new, n = re.subn(r'(\n\s*</style>)', lambda m: '\n' + DETAIL_CSS + m.group(1), new, count=1) + assert n == 1, 'no </style> to inject the detail CSS before - page layout changed' write_atomic(PAGE, new) return len(new) @@ -286,6 +423,38 @@ PREAMBLE_RE = re.compile(re.escape('# newOS4 changelog') + r'do not edit by hand between the markers\.\n+') +DETAIL_RX = re.compile(r'<details[^>]*>\s*<summary>[^<]*</summary>(.*?)</details>\s*$', re.S) + + +def md_inline(markup): + """Our own generated item markup -> markdown. + + The old line replaced EVERY tag with a backtick, so `<strong>` became a stray tick and a + body's paragraph tags would have shredded the file. Explicit rules beat a generic strip: + <code>/<strong> survive as markdown, everything else becomes a space. + """ + text = re.sub(r'<code>(.*?)</code>', lambda m: '`' + m.group(1) + '`', markup, flags=re.S) + text = re.sub(r'<strong>(.*?)</strong>', lambda m: '**' + m.group(1) + '**', text, flags=re.S) + text = re.sub(r'<[^>]+>', ' ', text) + return re.sub(r'\s+', ' ', html.unescape(text)).strip() + + +def md_item(item): + """One changelog line, with the commit body as nested bullets. + + This is the file the public mirror serves, so the README claim "symptom -> cause -> fix -> + verified" has to be true HERE, not only on the rendered page. + """ + m = DETAIL_RX.search(item) + lines = ['- ' + md_inline(item[:m.start()] if m else item)] + if m: + for _tag, body in re.findall(r'<(p|li)>(.*?)</\1>', m.group(1), re.S): + body = md_inline(body) + if body: + lines.append(' - ' + body) + return lines + + def write_md(page, rows): """App-side Keep-a-Changelog history; only the region between the markers is generated.""" region = ['<!-- generated:start -->', ''] @@ -295,7 +464,7 @@ def write_md(page, rows): if entry['note']: region.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', '']) for item in entry['items']: - region.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`')) + region.extend(md_item(item)) region.append('') region.extend(['<!-- generated:end -->', '']) region_text = '\n'.join(region) |