from __future__ import annotations
import argparse
import difflib
import fnmatch
import json
import os
import re
import subprocess
import sys
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
ROOT_CHANGELOG = 'CHANGELOG.md'
EXTENSION_DIR = 'editors/vscode'
EXTENSION_CHANGELOG = f'{EXTENSION_DIR}/CHANGELOG.md'
UNRELEASED = 'unreleased'
EXTENSION_MANIFEST = f'{EXTENSION_DIR}/package.json'
EXTENSION_NOT_USER_VISIBLE = (
'CHANGELOG.md', 'README.md', 'LICENSE*', 'package-lock.json',
'tsconfig*.json', 'esbuild*', '*.config.*', '.*',
)
MANIFEST_DEV_KEYS = ('devDependencies', 'scripts', 'version')
REWORD_SIMILARITY = 0.8
TEST_FILE = re.compile(r'\.(test|spec)\.[cm]?[jt]sx?$')
TEST_DIRS = frozenset({'test', 'tests', '__tests__'})
HEADING = re.compile(r'^##\s+(?P<text>.+?)\s*$')
BRACKETED = re.compile(r'^\[(?P<label>[^\]]+)\]')
ITEM = re.compile(r'^\s*[-*+]\s+(?P<text>\S.*?)\s*$')
FENCE = re.compile(r'^\s*(?P<run>`{3,}|~{3,})(?P<rest>.*)$')
TRAILER = re.compile(r'^Changelog:[ \t]*(?P<kind>none|amend-released)(?:[ \t].*)?$', re.IGNORECASE)
KIND_RELEASED = 'released'
KIND_EXTENSION = 'extension'
WAIVER = {KIND_RELEASED: 'amend-released', KIND_EXTENSION: 'none'}
class GitError(Exception):
class ChangelogError(Exception):
@dataclass
class Item:
text: str
line: int
@dataclass
class Section:
key: str
heading: str
items: list[Item] = field(default_factory=list)
def texts(self) -> Counter:
return Counter(item.text for item in self.items)
@dataclass(frozen=True)
class Finding:
kind: str
path: str
line: int
message: str
@dataclass
class Outcome:
since: str
commits: int
findings: list[Finding]
def section_key(heading: str) -> str:
bracketed = BRACKETED.match(heading)
label = bracketed.group('label') if bracketed else heading.split()[0]
return label.strip().lower()
def parse(text: str, source: str = 'changelog') -> dict[str, Section]:
sections: dict[str, Section] = {}
current: Section | None = None
fence: tuple[str, int, int] | None = None for number, line in enumerate(text.splitlines(), start=1):
marker = FENCE.match(line)
if marker:
run, rest = marker.group('run'), marker.group('rest')
if fence is None:
fence = (run[0], len(run), number)
elif run[0] == fence[0] and len(run) >= fence[1] and not rest.strip():
fence = None
continue
if fence is not None:
continue
heading = HEADING.match(line)
if heading:
key = section_key(heading.group('text'))
current = sections.setdefault(key, Section(key, heading.group('text')))
continue
item = ITEM.match(line)
if item and current is not None:
current.items.append(Item(' '.join(item.group('text').split()), number))
if fence is not None:
raise ChangelogError(f'{source}: the code fence opened on line {fence[2]} is never closed, '
'so everything after it would be skipped')
return sections
def preview(text: str, limit: int = 110) -> str:
return text if len(text) <= limit else text[:limit - 1].rstrip() + '…'
def new_bullets(old: Section, new: Section) -> list[Item]:
unmatched = new.texts() - old.texts()
lost = list((old.texts() - new.texts()).elements())
added = []
for item in new.items:
if unmatched[item.text] <= 0:
continue
unmatched[item.text] -= 1
best, best_ratio = None, REWORD_SIMILARITY
for candidate in lost:
matcher = difflib.SequenceMatcher(None, candidate, item.text, autojunk=False)
if matcher.real_quick_ratio() >= best_ratio and matcher.quick_ratio() >= best_ratio:
ratio = matcher.ratio()
if ratio >= best_ratio:
best, best_ratio = candidate, ratio
if best is None:
added.append(item)
else:
lost.remove(best)
return added
def added_to_released(path: str, base: dict[str, Section], head: dict[str, Section]) -> list[Finding]:
return [
Finding(KIND_RELEASED, path, item.line,
f'`## {old.heading}` was already released, but this change adds a bullet to it: '
f'{preview(item.text)}')
for key, old in base.items()
if key != UNRELEASED and key in head
for item in new_bullets(old, head[key])
]
def has_new_entry(base: dict[str, Section], head: dict[str, Section]) -> bool:
if any(key != UNRELEASED and key not in base and section.items for key, section in head.items()):
return True
old = base[UNRELEASED].texts() if UNRELEASED in base else Counter()
new = head[UNRELEASED].texts() if UNRELEASED in head else Counter()
return bool(new - old)
def is_user_visible(path: str) -> bool:
prefix = EXTENSION_DIR + '/'
if not path.startswith(prefix):
return False
parts = path[len(prefix):].split('/')
if any(part.startswith('.') for part in parts):
return False
if len(parts) == 1 and any(fnmatch.fnmatchcase(parts[0], pattern) for pattern in EXTENSION_NOT_USER_VISIBLE):
return False
return not (TEST_FILE.search(parts[-1]) or TEST_DIRS.intersection(parts[:-1]))
def manifest_ships(before: str | None, after: str | None) -> bool:
try:
trimmed = [{k: v for k, v in json.loads(text or '{}').items() if k not in MANIFEST_DEV_KEYS}
for text in (before, after)]
except (ValueError, AttributeError):
return True
return trimmed[0] != trimmed[1]
def extension_entry_missing(visible: list[str], base: dict[str, Section], head: dict[str, Section]) -> list[Finding]:
if not visible or has_new_entry(base, head):
return []
shown = ', '.join(sorted(visible)[:3]) + (f' and {len(visible) - 3} more' if len(visible) > 3 else '')
return [Finding(
KIND_EXTENSION, EXTENSION_CHANGELOG, 1,
f'this change touches the extension ({shown}) but adds no bullet under `## [Unreleased]`')]
def waivers(messages: list[str]) -> set[str]:
found = set()
for message in messages:
paragraphs = [p for p in re.split(r'\n[ \t]*\n', message.strip().replace('\r\n', '\n')) if p.strip()]
if len(paragraphs) < 2:
continue
for line in paragraphs[-1].split('\n'):
trailer = TRAILER.match(line.strip())
if trailer:
found.add(trailer.group('kind').lower())
return found
def git(repo: str, *args: str) -> str:
proc = subprocess.run(
['git', '-C', repo, *args], capture_output=True, text=True, encoding='utf-8', check=False)
if proc.returncode != 0:
raise GitError(f'git {" ".join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}')
return proc.stdout
def read_at(repo: str, rev: str | None, path: str) -> str | None:
if rev is None:
target = Path(repo, path)
return target.read_text(encoding='utf-8') if target.is_file() else None
proc = subprocess.run(['git', '-C', repo, 'show', f'{rev}:{path}'], capture_output=True, check=False)
if proc.returncode != 0:
absent = (b'does not exist', b'exists on disk, but not in')
if any(marker in proc.stderr for marker in absent):
return None
raise GitError(f'git show {rev}:{path} failed: {proc.stderr.decode(errors="replace").strip()}')
return proc.stdout.decode('utf-8')
def merge_base(repo: str, base: str, head: str) -> str:
try:
return git(repo, 'merge-base', base, head).strip()
except GitError as err:
raise GitError(
f'cannot find a common ancestor of {base} and {head}: {err}. '
'A shallow checkout hides it; fetch the full history (`fetch-depth: 0`).') from err
def changed_files(repo: str, since: str, head: str | None) -> list[str]:
names = git(repo, 'diff', '--name-only', '--no-renames', since, *([head] if head else []),
'--', EXTENSION_DIR).splitlines()
if head is None:
names += git(repo, 'ls-files', '--others', '--exclude-standard', '--', EXTENSION_DIR).splitlines()
return names
def check(repo: str, base: str, head: str | None, extension_check: bool = True) -> Outcome:
tip = head or 'HEAD'
since = merge_base(repo, base, tip)
messages = git(repo, 'log', '--format=%B%x00', f'{since}..{tip}').split('\0')
allowed = waivers(messages)
findings: list[Finding] = []
sections = {}
for path in (ROOT_CHANGELOG, EXTENSION_CHANGELOG):
before, after = read_at(repo, since, path), read_at(repo, head, path)
sections[path] = (parse(before or '', f'{path} at {since[:10]}'),
parse(after or '', f'{path} at {head or "the working tree"}'))
findings += added_to_released(path, *sections[path])
if extension_check:
visible = [path for path in changed_files(repo, since, head) if is_user_visible(path)]
if EXTENSION_MANIFEST in visible and not manifest_ships(
read_at(repo, since, EXTENSION_MANIFEST), read_at(repo, head, EXTENSION_MANIFEST)):
visible.remove(EXTENSION_MANIFEST)
findings += extension_entry_missing(visible, *sections[EXTENSION_CHANGELOG])
commits = int(git(repo, 'rev-list', '--count', f'{since}..{tip}').strip())
return Outcome(since, commits, [f for f in findings if WAIVER[f.kind] not in allowed])
def escape(value: str, *, property_value: bool = False) -> str:
value = value.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
return value.replace(':', '%3A').replace(',', '%2C') if property_value else value
def report(findings: list[Finding]) -> None:
on_github = os.environ.get('GITHUB_ACTIONS') == 'true'
for finding in findings:
if on_github:
print(f'::error file={escape(finding.path, property_value=True)},line={finding.line}'
f'::{escape(finding.message)}')
else:
print(f'error: {finding.path}:{finding.line}: {finding.message}')
kinds = {finding.kind for finding in findings}
if KIND_RELEASED in kinds:
print('\nA released section is closed. Move the bullet under `## [Unreleased]`. If you mean to '
'amend released notes, add a `Changelog: amend-released <reason>` trailer to a commit.')
if KIND_EXTENSION in kinds:
print(f'\nAdd an entry for the change to {EXTENSION_CHANGELOG}. If it is not user-visible after all, '
'add a `Changelog: none <reason>` trailer to a commit.')
def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__.split('\n\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='Opt-outs are commit trailers: `Changelog: none <reason>` waives the extension entry, '
'`Changelog: amend-released <reason>` waives the released-section rule.')
parser.add_argument('--base', default='origin/main',
help='revision to compare against (default: origin/main)')
parser.add_argument('--head', help='revision to check (default: the working tree)')
parser.add_argument('--repo', default='.', help='repository root (default: the current directory)')
parser.add_argument('--no-extension-check', action='store_true',
help='skip the extension entry requirement (the crate-release audit)')
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
outcome = check(args.repo, args.base, args.head, extension_check=not args.no_extension_check)
except (GitError, ChangelogError, OSError) as err:
print(f'check_changelog: {err}', file=sys.stderr)
return 2
print(f'check_changelog: {outcome.commits} commit(s) since {outcome.since[:10]} '
f'({args.base} .. {args.head or "the working tree"})')
if outcome.findings:
report(outcome.findings)
return 1
print('check_changelog: ok')
return 0
if __name__ == '__main__':
sys.exit(main())