mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge remote-tracking branch 'origin/main' into rust-rewrite
# Conflicts: # headroom/proxy/server.py
This commit is contained in:
commit
4429a11166
133 changed files with 36396 additions and 21709 deletions
|
|
@ -1,203 +1,203 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate changelog from conventional commits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
COMMIT_PATTERN = re.compile(
|
||||
r"^(feat|fix|ci|chore|perf|refactor|docs|style|test)(\(.+\))?(!)?:\s*(.+)$"
|
||||
)
|
||||
BREAKING_CHANGE_PATTERN = re.compile(r"^BREAKING CHANGE:\s*(.+)$", re.MULTILINE)
|
||||
COMMIT_ENTRY_PATTERN = re.compile(r"^(.+?)(?:\n(.+))?\|(\w+)$", re.MULTILINE)
|
||||
FIELD_SEP = "\x1f"
|
||||
RECORD_SEP = "\x1e"
|
||||
GIT_LOG_FORMAT = "%s%x1f%b%x1f%h%x1e"
|
||||
|
||||
TYPE_LABELS: dict[str, str] = {
|
||||
"feat": "Features",
|
||||
"fix": "Bug Fixes",
|
||||
"ci": "CI/CD",
|
||||
"chore": "Chores",
|
||||
"perf": "Performance",
|
||||
"refactor": "Refactors",
|
||||
"docs": "Documentation",
|
||||
"style": "Styles",
|
||||
"test": "Tests",
|
||||
"other": "Other Changes",
|
||||
}
|
||||
|
||||
|
||||
class ParsedCommit(NamedTuple):
|
||||
type: str
|
||||
scope: str | None
|
||||
breaking: bool
|
||||
message: str
|
||||
hash: str
|
||||
|
||||
|
||||
def iter_commit_entries(log_output: str) -> list[tuple[str, str, str]]:
|
||||
"""Split raw git log output into (subject, body, hash) tuples."""
|
||||
|
||||
if not log_output.strip():
|
||||
return []
|
||||
|
||||
if RECORD_SEP in log_output and FIELD_SEP in log_output:
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for raw_entry in log_output.split(RECORD_SEP):
|
||||
if not raw_entry:
|
||||
continue
|
||||
if FIELD_SEP not in raw_entry:
|
||||
continue
|
||||
subject, body_and_hash = raw_entry.split(FIELD_SEP, 1)
|
||||
if FIELD_SEP not in body_and_hash:
|
||||
continue
|
||||
body, commit_hash = body_and_hash.rsplit(FIELD_SEP, 1)
|
||||
entries.append((subject.strip(), body.strip(), commit_hash.strip()))
|
||||
return entries
|
||||
|
||||
return [
|
||||
(
|
||||
match.group(1).strip(),
|
||||
(match.group(2) or "").strip(),
|
||||
match.group(3).strip(),
|
||||
)
|
||||
for match in COMMIT_ENTRY_PATTERN.finditer(log_output)
|
||||
]
|
||||
|
||||
|
||||
def get_merge_summary(subject: str, body: str) -> str:
|
||||
"""Return the first meaningful summary line for a merge commit."""
|
||||
|
||||
if not subject.startswith("Merge "):
|
||||
return ""
|
||||
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def parse_commits(log_output: str) -> list[ParsedCommit]:
|
||||
"""Parse git log output into structured commits."""
|
||||
|
||||
commits: list[ParsedCommit] = []
|
||||
|
||||
for subject, body, commit_hash in iter_commit_entries(log_output):
|
||||
is_breaking = bool(BREAKING_CHANGE_PATTERN.search(body))
|
||||
merge_summary = get_merge_summary(subject, body)
|
||||
candidates = [subject]
|
||||
if merge_summary:
|
||||
candidates.insert(0, merge_summary)
|
||||
|
||||
for candidate in candidates:
|
||||
commit_match = COMMIT_PATTERN.match(candidate)
|
||||
if not commit_match:
|
||||
continue
|
||||
|
||||
scope = commit_match.group(2)
|
||||
if scope:
|
||||
scope = scope[1:-1]
|
||||
commits.append(
|
||||
ParsedCommit(
|
||||
type=commit_match.group(1),
|
||||
scope=scope,
|
||||
breaking=is_breaking or bool(commit_match.group(3)),
|
||||
message=commit_match.group(4),
|
||||
hash=commit_hash,
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
fallback_message = merge_summary or subject
|
||||
if not fallback_message or fallback_message.startswith("Merge "):
|
||||
continue
|
||||
commits.append(
|
||||
ParsedCommit(
|
||||
type="other",
|
||||
scope=None,
|
||||
breaking=is_breaking,
|
||||
message=fallback_message,
|
||||
hash=commit_hash,
|
||||
)
|
||||
)
|
||||
|
||||
return commits
|
||||
|
||||
|
||||
def generate_changelog(version: str, commits: list[ParsedCommit]) -> str:
|
||||
"""Generate markdown changelog from parsed commits."""
|
||||
today = date.today().isoformat()
|
||||
lines = [f"## [{version}] - {today}", ""]
|
||||
|
||||
# Collect breaking changes
|
||||
breaking_commits = [c for c in commits if c.breaking]
|
||||
if breaking_commits:
|
||||
lines.append("### Breaking Changes")
|
||||
for commit in breaking_commits:
|
||||
if commit.scope:
|
||||
lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})")
|
||||
else:
|
||||
lines.append(f"- {commit.message} ({commit.hash})")
|
||||
lines.append("")
|
||||
|
||||
# Group by type
|
||||
by_type: dict[str, list[ParsedCommit]] = {}
|
||||
for commit in commits:
|
||||
by_type.setdefault(commit.type, []).append(commit)
|
||||
|
||||
for commit_type, label in TYPE_LABELS.items():
|
||||
type_commits = by_type.get(commit_type, [])
|
||||
if not type_commits:
|
||||
continue
|
||||
lines.append(f"### {label}")
|
||||
for commit in type_commits:
|
||||
if commit.scope:
|
||||
lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})")
|
||||
else:
|
||||
lines.append(f"- {commit.message} ({commit.hash})")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run_git_log(since: str | None, cwd: Path) -> str:
|
||||
"""Run git log command and return output."""
|
||||
cmd = ["git", "log", "--first-parent", f"--pretty=format:{GIT_LOG_FORMAT}"]
|
||||
if since:
|
||||
cmd.append(f"{since}..HEAD")
|
||||
else:
|
||||
cmd.append("HEAD")
|
||||
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate changelog from conventional commits")
|
||||
parser.add_argument("--version", required=True, help="Version number (e.g., 0.6.0)")
|
||||
parser.add_argument("--since", help="Starting tag (exclusive)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print to stdout instead of writing")
|
||||
args = parser.parse_args()
|
||||
|
||||
log_output = run_git_log(args.since, ROOT)
|
||||
commits = parse_commits(log_output)
|
||||
changelog = generate_changelog(args.version, commits)
|
||||
|
||||
if args.dry_run:
|
||||
print(changelog)
|
||||
else:
|
||||
output_path = ROOT / ".changelog.md"
|
||||
output_path.write_text(changelog, encoding="utf-8")
|
||||
print(f"Changelog written to {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
#!/usr/bin/env python3
|
||||
"""Generate changelog from conventional commits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
COMMIT_PATTERN = re.compile(
|
||||
r"^(feat|fix|ci|chore|perf|refactor|docs|style|test)(\(.+\))?(!)?:\s*(.+)$"
|
||||
)
|
||||
BREAKING_CHANGE_PATTERN = re.compile(r"^BREAKING CHANGE:\s*(.+)$", re.MULTILINE)
|
||||
COMMIT_ENTRY_PATTERN = re.compile(r"^(.+?)(?:\n(.+))?\|(\w+)$", re.MULTILINE)
|
||||
FIELD_SEP = "\x1f"
|
||||
RECORD_SEP = "\x1e"
|
||||
GIT_LOG_FORMAT = "%s%x1f%b%x1f%h%x1e"
|
||||
|
||||
TYPE_LABELS: dict[str, str] = {
|
||||
"feat": "Features",
|
||||
"fix": "Bug Fixes",
|
||||
"ci": "CI/CD",
|
||||
"chore": "Chores",
|
||||
"perf": "Performance",
|
||||
"refactor": "Refactors",
|
||||
"docs": "Documentation",
|
||||
"style": "Styles",
|
||||
"test": "Tests",
|
||||
"other": "Other Changes",
|
||||
}
|
||||
|
||||
|
||||
class ParsedCommit(NamedTuple):
|
||||
type: str
|
||||
scope: str | None
|
||||
breaking: bool
|
||||
message: str
|
||||
hash: str
|
||||
|
||||
|
||||
def iter_commit_entries(log_output: str) -> list[tuple[str, str, str]]:
|
||||
"""Split raw git log output into (subject, body, hash) tuples."""
|
||||
|
||||
if not log_output.strip():
|
||||
return []
|
||||
|
||||
if RECORD_SEP in log_output and FIELD_SEP in log_output:
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for raw_entry in log_output.split(RECORD_SEP):
|
||||
if not raw_entry:
|
||||
continue
|
||||
if FIELD_SEP not in raw_entry:
|
||||
continue
|
||||
subject, body_and_hash = raw_entry.split(FIELD_SEP, 1)
|
||||
if FIELD_SEP not in body_and_hash:
|
||||
continue
|
||||
body, commit_hash = body_and_hash.rsplit(FIELD_SEP, 1)
|
||||
entries.append((subject.strip(), body.strip(), commit_hash.strip()))
|
||||
return entries
|
||||
|
||||
return [
|
||||
(
|
||||
match.group(1).strip(),
|
||||
(match.group(2) or "").strip(),
|
||||
match.group(3).strip(),
|
||||
)
|
||||
for match in COMMIT_ENTRY_PATTERN.finditer(log_output)
|
||||
]
|
||||
|
||||
|
||||
def get_merge_summary(subject: str, body: str) -> str:
|
||||
"""Return the first meaningful summary line for a merge commit."""
|
||||
|
||||
if not subject.startswith("Merge "):
|
||||
return ""
|
||||
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def parse_commits(log_output: str) -> list[ParsedCommit]:
|
||||
"""Parse git log output into structured commits."""
|
||||
|
||||
commits: list[ParsedCommit] = []
|
||||
|
||||
for subject, body, commit_hash in iter_commit_entries(log_output):
|
||||
is_breaking = bool(BREAKING_CHANGE_PATTERN.search(body))
|
||||
merge_summary = get_merge_summary(subject, body)
|
||||
candidates = [subject]
|
||||
if merge_summary:
|
||||
candidates.insert(0, merge_summary)
|
||||
|
||||
for candidate in candidates:
|
||||
commit_match = COMMIT_PATTERN.match(candidate)
|
||||
if not commit_match:
|
||||
continue
|
||||
|
||||
scope = commit_match.group(2)
|
||||
if scope:
|
||||
scope = scope[1:-1]
|
||||
commits.append(
|
||||
ParsedCommit(
|
||||
type=commit_match.group(1),
|
||||
scope=scope,
|
||||
breaking=is_breaking or bool(commit_match.group(3)),
|
||||
message=commit_match.group(4),
|
||||
hash=commit_hash,
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
fallback_message = merge_summary or subject
|
||||
if not fallback_message or fallback_message.startswith("Merge "):
|
||||
continue
|
||||
commits.append(
|
||||
ParsedCommit(
|
||||
type="other",
|
||||
scope=None,
|
||||
breaking=is_breaking,
|
||||
message=fallback_message,
|
||||
hash=commit_hash,
|
||||
)
|
||||
)
|
||||
|
||||
return commits
|
||||
|
||||
|
||||
def generate_changelog(version: str, commits: list[ParsedCommit]) -> str:
|
||||
"""Generate markdown changelog from parsed commits."""
|
||||
today = date.today().isoformat()
|
||||
lines = [f"## [{version}] - {today}", ""]
|
||||
|
||||
# Collect breaking changes
|
||||
breaking_commits = [c for c in commits if c.breaking]
|
||||
if breaking_commits:
|
||||
lines.append("### Breaking Changes")
|
||||
for commit in breaking_commits:
|
||||
if commit.scope:
|
||||
lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})")
|
||||
else:
|
||||
lines.append(f"- {commit.message} ({commit.hash})")
|
||||
lines.append("")
|
||||
|
||||
# Group by type
|
||||
by_type: dict[str, list[ParsedCommit]] = {}
|
||||
for commit in commits:
|
||||
by_type.setdefault(commit.type, []).append(commit)
|
||||
|
||||
for commit_type, label in TYPE_LABELS.items():
|
||||
type_commits = by_type.get(commit_type, [])
|
||||
if not type_commits:
|
||||
continue
|
||||
lines.append(f"### {label}")
|
||||
for commit in type_commits:
|
||||
if commit.scope:
|
||||
lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})")
|
||||
else:
|
||||
lines.append(f"- {commit.message} ({commit.hash})")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run_git_log(since: str | None, cwd: Path) -> str:
|
||||
"""Run git log command and return output."""
|
||||
cmd = ["git", "log", "--first-parent", f"--pretty=format:{GIT_LOG_FORMAT}"]
|
||||
if since:
|
||||
cmd.append(f"{since}..HEAD")
|
||||
else:
|
||||
cmd.append("HEAD")
|
||||
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate changelog from conventional commits")
|
||||
parser.add_argument("--version", required=True, help="Version number (e.g., 0.6.0)")
|
||||
parser.add_argument("--since", help="Starting tag (exclusive)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print to stdout instead of writing")
|
||||
args = parser.parse_args()
|
||||
|
||||
log_output = run_git_log(args.since, ROOT)
|
||||
commits = parse_commits(log_output)
|
||||
changelog = generate_changelog(args.version, commits)
|
||||
|
||||
if args.dry_run:
|
||||
print(changelog)
|
||||
else:
|
||||
output_path = ROOT / ".changelog.md"
|
||||
output_path.write_text(changelog, encoding="utf-8")
|
||||
print(f"Changelog written to {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
"""Sync plugin manifest versions to the repo's computed release semver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from headroom.release_version import ( # noqa: E402
|
||||
compute_release_version,
|
||||
determine_bump_level,
|
||||
find_latest_release_tag,
|
||||
get_canonical_version,
|
||||
list_release_commits,
|
||||
list_release_tags,
|
||||
)
|
||||
|
||||
|
||||
def compute_repo_semver(root: Path) -> str:
|
||||
"""Return the npm-style semver for the repo's next release."""
|
||||
tags = list_release_tags(root)
|
||||
previous_tag = find_latest_release_tag(tags) or ""
|
||||
level = determine_bump_level(list_release_commits(root, previous_tag))
|
||||
info = compute_release_version(
|
||||
canonical_version=get_canonical_version(root),
|
||||
level=level,
|
||||
tags=tags,
|
||||
)
|
||||
return info.npm_version
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = ROOT
|
||||
version = compute_repo_semver(root)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(root / "scripts" / "version-sync.py"),
|
||||
"--root",
|
||||
str(root),
|
||||
"--version",
|
||||
version,
|
||||
"--plugin-manifests-only",
|
||||
],
|
||||
cwd=root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""Sync plugin manifest versions to the repo's computed release semver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from headroom.release_version import ( # noqa: E402
|
||||
compute_release_version,
|
||||
determine_bump_level,
|
||||
find_latest_release_tag,
|
||||
get_canonical_version,
|
||||
list_release_commits,
|
||||
list_release_tags,
|
||||
)
|
||||
|
||||
|
||||
def compute_repo_semver(root: Path) -> str:
|
||||
"""Return the npm-style semver for the repo's next release."""
|
||||
tags = list_release_tags(root)
|
||||
previous_tag = find_latest_release_tag(tags) or ""
|
||||
level = determine_bump_level(list_release_commits(root, previous_tag))
|
||||
info = compute_release_version(
|
||||
canonical_version=get_canonical_version(root),
|
||||
level=level,
|
||||
tags=tags,
|
||||
)
|
||||
return info.npm_version
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = ROOT
|
||||
version = compute_repo_semver(root)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(root / "scripts" / "version-sync.py"),
|
||||
"--root",
|
||||
str(root),
|
||||
"--version",
|
||||
version,
|
||||
"--plugin-manifests-only",
|
||||
],
|
||||
cwd=root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,302 +1,302 @@
|
|||
"""Tests for changelog-gen.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Load changelog_gen module from scripts directory (filename has hyphen)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"changelog_gen", ROOT / "scripts" / "changelog-gen.py"
|
||||
)
|
||||
if _spec is None:
|
||||
raise ImportError("Could not load changelog_gen module")
|
||||
_changelog_gen = importlib.util.module_from_spec(_spec)
|
||||
if _spec.loader is None:
|
||||
raise ImportError("Could not load changelog_gen module")
|
||||
_spec.loader.exec_module(_changelog_gen)
|
||||
|
||||
COMMIT_PATTERN = _changelog_gen.COMMIT_PATTERN
|
||||
BREAKING_CHANGE_PATTERN = _changelog_gen.BREAKING_CHANGE_PATTERN
|
||||
FIELD_SEP = _changelog_gen.FIELD_SEP
|
||||
RECORD_SEP = _changelog_gen.RECORD_SEP
|
||||
ParsedCommit = _changelog_gen.ParsedCommit
|
||||
generate_changelog = _changelog_gen.generate_changelog
|
||||
iter_commit_entries = _changelog_gen.iter_commit_entries
|
||||
parse_commits = _changelog_gen.parse_commits
|
||||
|
||||
|
||||
def make_log_entry(subject: str, commit_hash: str, body: str = "") -> str:
|
||||
return f"{subject}{FIELD_SEP}{body}{FIELD_SEP}{commit_hash}{RECORD_SEP}"
|
||||
|
||||
|
||||
class TestParseCommits:
|
||||
"""Tests for parse_commits function."""
|
||||
|
||||
def test_parses_feat_commit(self) -> None:
|
||||
log_output = make_log_entry("feat(core): add feature", "abc1234")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "feat"
|
||||
assert commits[0].scope == "core"
|
||||
assert commits[0].message == "add feature"
|
||||
assert commits[0].hash == "abc1234"
|
||||
assert commits[0].breaking is False
|
||||
|
||||
def test_parses_fix_commit(self) -> None:
|
||||
log_output = make_log_entry("fix(ui): fix bug", "def5678")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "fix"
|
||||
assert commits[0].scope == "ui"
|
||||
assert commits[0].message == "fix bug"
|
||||
assert commits[0].hash == "def5678"
|
||||
|
||||
def test_parses_ci_commit(self) -> None:
|
||||
log_output = make_log_entry("ci: update github actions", "xyz789")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "ci"
|
||||
assert commits[0].scope is None
|
||||
assert commits[0].message == "update github actions"
|
||||
assert commits[0].hash == "xyz789"
|
||||
|
||||
def test_parses_chore_commit(self) -> None:
|
||||
log_output = make_log_entry("chore: cleanup", "xyz999")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "chore"
|
||||
assert commits[0].scope is None
|
||||
|
||||
def test_parses_perf_commit(self) -> None:
|
||||
log_output = make_log_entry("perf(dashboard): improve performance", "abc111")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "perf"
|
||||
assert commits[0].scope == "dashboard"
|
||||
|
||||
def test_parses_refactor_commit(self) -> None:
|
||||
log_output = make_log_entry("refactor(api): refactor endpoint", "abc222")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "refactor"
|
||||
assert commits[0].scope == "api"
|
||||
|
||||
def test_parses_docs_commit(self) -> None:
|
||||
log_output = make_log_entry("docs: update readme", "abc333")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "docs"
|
||||
|
||||
def test_parses_style_commit(self) -> None:
|
||||
log_output = make_log_entry("style: format code", "abc444")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "style"
|
||||
|
||||
def test_parses_test_commit(self) -> None:
|
||||
log_output = make_log_entry("test: add tests for feature", "abc555")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "test"
|
||||
|
||||
def test_detects_breaking_change_in_body(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"feat(core): add feature",
|
||||
"abc666",
|
||||
"BREAKING CHANGE: api changed",
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].breaking is True
|
||||
|
||||
def test_detects_breaking_change_exclamation(self) -> None:
|
||||
log_output = make_log_entry("feat(core)!: api changed", "abc777")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].breaking is True
|
||||
|
||||
def test_no_scope_no_problem(self) -> None:
|
||||
log_output = make_log_entry("feat: simple feature", "abc888")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].scope is None
|
||||
assert commits[0].message == "simple feature"
|
||||
|
||||
def test_uses_pr_title_from_merge_commit_body(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"Merge pull request #173 from JerrettDavis/fix/pipeline-permissions-and-docs",
|
||||
"73f6673",
|
||||
"fix: repair release and docs pipelines",
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "fix"
|
||||
assert commits[0].message == "repair release and docs pipelines"
|
||||
|
||||
def test_falls_back_to_other_changes_for_non_conventional_merge(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"Merge pull request #186 from skorokithakis/patch-1",
|
||||
"1e80ee3",
|
||||
"Add support for custom Anthropic API URL",
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "other"
|
||||
assert commits[0].message == "Add support for custom Anthropic API URL"
|
||||
|
||||
def test_iter_commit_entries_parses_real_git_log_delimiters(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"fix: patch release flow", "abc1234", "BREAKING CHANGE: no"
|
||||
) + make_log_entry("docs: update readme", "def5678")
|
||||
assert iter_commit_entries(log_output) == [
|
||||
("fix: patch release flow", "BREAKING CHANGE: no", "abc1234"),
|
||||
("docs: update readme", "", "def5678"),
|
||||
]
|
||||
|
||||
def test_iter_commit_entries_keeps_field_separator_inside_body(self) -> None:
|
||||
body = f"line one{FIELD_SEP}line two"
|
||||
log_output = make_log_entry("fix: patch release flow", "abc1234", body)
|
||||
assert iter_commit_entries(log_output) == [
|
||||
("fix: patch release flow", body, "abc1234"),
|
||||
]
|
||||
|
||||
|
||||
class TestGenerateChangelog:
|
||||
"""Tests for generate_changelog function."""
|
||||
|
||||
def test_generates_version_header(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(type="feat", scope=None, breaking=False, message="test", hash="abc123")
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "## [0.6.0]" in result
|
||||
|
||||
def test_includes_date(self) -> None:
|
||||
commits = []
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
import re
|
||||
|
||||
date_match = re.search(r"\d{4}-\d{2}-\d{2}", result)
|
||||
assert date_match is not None
|
||||
|
||||
def test_groups_by_type(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope=None, breaking=False, message="add feature", hash="abc123"
|
||||
),
|
||||
ParsedCommit(type="fix", scope=None, breaking=False, message="fix bug", hash="def456"),
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "### Features" in result
|
||||
assert "### Bug Fixes" in result
|
||||
assert "- add feature (abc123)" in result
|
||||
assert "- fix bug (def456)" in result
|
||||
|
||||
def test_includes_scope_in_bullet(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope="core", breaking=False, message="add feature", hash="abc123"
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "- **core**: add feature (abc123)" in result
|
||||
|
||||
def test_breaking_change_section_when_present(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope="core", breaking=True, message="api changed", hash="abc123"
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "### Breaking Changes" in result
|
||||
assert "**core**" in result
|
||||
|
||||
def test_no_breaking_change_section_when_none(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope=None, breaking=False, message="add feature", hash="abc123"
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "Breaking Changes" not in result
|
||||
|
||||
def test_includes_other_changes_section(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="other",
|
||||
scope=None,
|
||||
breaking=False,
|
||||
message="Add support for custom Anthropic API URL",
|
||||
hash="abc123",
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "### Other Changes" in result
|
||||
assert "- Add support for custom Anthropic API URL (abc123)" in result
|
||||
|
||||
|
||||
class TestIntegrationWithMock:
|
||||
"""Integration tests with mocked subprocess.run."""
|
||||
|
||||
def test_full_flow_with_mocked_git(self) -> None:
|
||||
log_output = (
|
||||
make_log_entry("feat(core): add new feature", "abc1234")
|
||||
+ make_log_entry("fix(ui): fix bug", "def5678")
|
||||
+ make_log_entry("ci: update github actions", "xyz789")
|
||||
+ make_log_entry(
|
||||
"feat(outer): breaking change", "bbb111", "BREAKING CHANGE: this is breaking"
|
||||
)
|
||||
+ make_log_entry("chore: cleanup", "yyy999")
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
|
||||
assert len(commits) == 5
|
||||
assert any(c.type == "feat" and c.scope == "core" for c in commits)
|
||||
assert any(c.type == "fix" and c.scope == "ui" for c in commits)
|
||||
assert any(c.type == "ci" for c in commits)
|
||||
assert any(c.type == "feat" and c.breaking for c in commits)
|
||||
assert any(c.type == "chore" for c in commits)
|
||||
|
||||
changelog = generate_changelog("0.7.0", commits)
|
||||
assert "## [0.7.0]" in changelog
|
||||
assert "### Features" in changelog
|
||||
assert "### Bug Fixes" in changelog
|
||||
assert "### CI/CD" in changelog
|
||||
assert "### Breaking Changes" in changelog
|
||||
|
||||
|
||||
class TestCommitPattern:
|
||||
"""Tests for the commit regex pattern."""
|
||||
|
||||
def test_feat_with_scope(self) -> None:
|
||||
match = COMMIT_PATTERN.match("feat(core): add feature")
|
||||
assert match is not None
|
||||
assert match.group(1) == "feat"
|
||||
assert match.group(2) == "(core)"
|
||||
assert match.group(4) == "add feature"
|
||||
|
||||
def test_fix_without_scope(self) -> None:
|
||||
match = COMMIT_PATTERN.match("fix: fix bug")
|
||||
assert match is not None
|
||||
assert match.group(1) == "fix"
|
||||
assert match.group(2) is None
|
||||
assert match.group(4) == "fix bug"
|
||||
|
||||
def test_with_exclamation(self) -> None:
|
||||
match = COMMIT_PATTERN.match("feat(core)!: api changed")
|
||||
assert match is not None
|
||||
assert match.group(3) == "!"
|
||||
|
||||
def test_without_exclamation(self) -> None:
|
||||
match = COMMIT_PATTERN.match("feat(core): add feature")
|
||||
assert match is not None
|
||||
assert match.group(3) is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
"""Tests for changelog-gen.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Load changelog_gen module from scripts directory (filename has hyphen)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"changelog_gen", ROOT / "scripts" / "changelog-gen.py"
|
||||
)
|
||||
if _spec is None:
|
||||
raise ImportError("Could not load changelog_gen module")
|
||||
_changelog_gen = importlib.util.module_from_spec(_spec)
|
||||
if _spec.loader is None:
|
||||
raise ImportError("Could not load changelog_gen module")
|
||||
_spec.loader.exec_module(_changelog_gen)
|
||||
|
||||
COMMIT_PATTERN = _changelog_gen.COMMIT_PATTERN
|
||||
BREAKING_CHANGE_PATTERN = _changelog_gen.BREAKING_CHANGE_PATTERN
|
||||
FIELD_SEP = _changelog_gen.FIELD_SEP
|
||||
RECORD_SEP = _changelog_gen.RECORD_SEP
|
||||
ParsedCommit = _changelog_gen.ParsedCommit
|
||||
generate_changelog = _changelog_gen.generate_changelog
|
||||
iter_commit_entries = _changelog_gen.iter_commit_entries
|
||||
parse_commits = _changelog_gen.parse_commits
|
||||
|
||||
|
||||
def make_log_entry(subject: str, commit_hash: str, body: str = "") -> str:
|
||||
return f"{subject}{FIELD_SEP}{body}{FIELD_SEP}{commit_hash}{RECORD_SEP}"
|
||||
|
||||
|
||||
class TestParseCommits:
|
||||
"""Tests for parse_commits function."""
|
||||
|
||||
def test_parses_feat_commit(self) -> None:
|
||||
log_output = make_log_entry("feat(core): add feature", "abc1234")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "feat"
|
||||
assert commits[0].scope == "core"
|
||||
assert commits[0].message == "add feature"
|
||||
assert commits[0].hash == "abc1234"
|
||||
assert commits[0].breaking is False
|
||||
|
||||
def test_parses_fix_commit(self) -> None:
|
||||
log_output = make_log_entry("fix(ui): fix bug", "def5678")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "fix"
|
||||
assert commits[0].scope == "ui"
|
||||
assert commits[0].message == "fix bug"
|
||||
assert commits[0].hash == "def5678"
|
||||
|
||||
def test_parses_ci_commit(self) -> None:
|
||||
log_output = make_log_entry("ci: update github actions", "xyz789")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "ci"
|
||||
assert commits[0].scope is None
|
||||
assert commits[0].message == "update github actions"
|
||||
assert commits[0].hash == "xyz789"
|
||||
|
||||
def test_parses_chore_commit(self) -> None:
|
||||
log_output = make_log_entry("chore: cleanup", "xyz999")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "chore"
|
||||
assert commits[0].scope is None
|
||||
|
||||
def test_parses_perf_commit(self) -> None:
|
||||
log_output = make_log_entry("perf(dashboard): improve performance", "abc111")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "perf"
|
||||
assert commits[0].scope == "dashboard"
|
||||
|
||||
def test_parses_refactor_commit(self) -> None:
|
||||
log_output = make_log_entry("refactor(api): refactor endpoint", "abc222")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "refactor"
|
||||
assert commits[0].scope == "api"
|
||||
|
||||
def test_parses_docs_commit(self) -> None:
|
||||
log_output = make_log_entry("docs: update readme", "abc333")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "docs"
|
||||
|
||||
def test_parses_style_commit(self) -> None:
|
||||
log_output = make_log_entry("style: format code", "abc444")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "style"
|
||||
|
||||
def test_parses_test_commit(self) -> None:
|
||||
log_output = make_log_entry("test: add tests for feature", "abc555")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "test"
|
||||
|
||||
def test_detects_breaking_change_in_body(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"feat(core): add feature",
|
||||
"abc666",
|
||||
"BREAKING CHANGE: api changed",
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].breaking is True
|
||||
|
||||
def test_detects_breaking_change_exclamation(self) -> None:
|
||||
log_output = make_log_entry("feat(core)!: api changed", "abc777")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].breaking is True
|
||||
|
||||
def test_no_scope_no_problem(self) -> None:
|
||||
log_output = make_log_entry("feat: simple feature", "abc888")
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].scope is None
|
||||
assert commits[0].message == "simple feature"
|
||||
|
||||
def test_uses_pr_title_from_merge_commit_body(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"Merge pull request #173 from JerrettDavis/fix/pipeline-permissions-and-docs",
|
||||
"73f6673",
|
||||
"fix: repair release and docs pipelines",
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "fix"
|
||||
assert commits[0].message == "repair release and docs pipelines"
|
||||
|
||||
def test_falls_back_to_other_changes_for_non_conventional_merge(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"Merge pull request #186 from skorokithakis/patch-1",
|
||||
"1e80ee3",
|
||||
"Add support for custom Anthropic API URL",
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].type == "other"
|
||||
assert commits[0].message == "Add support for custom Anthropic API URL"
|
||||
|
||||
def test_iter_commit_entries_parses_real_git_log_delimiters(self) -> None:
|
||||
log_output = make_log_entry(
|
||||
"fix: patch release flow", "abc1234", "BREAKING CHANGE: no"
|
||||
) + make_log_entry("docs: update readme", "def5678")
|
||||
assert iter_commit_entries(log_output) == [
|
||||
("fix: patch release flow", "BREAKING CHANGE: no", "abc1234"),
|
||||
("docs: update readme", "", "def5678"),
|
||||
]
|
||||
|
||||
def test_iter_commit_entries_keeps_field_separator_inside_body(self) -> None:
|
||||
body = f"line one{FIELD_SEP}line two"
|
||||
log_output = make_log_entry("fix: patch release flow", "abc1234", body)
|
||||
assert iter_commit_entries(log_output) == [
|
||||
("fix: patch release flow", body, "abc1234"),
|
||||
]
|
||||
|
||||
|
||||
class TestGenerateChangelog:
|
||||
"""Tests for generate_changelog function."""
|
||||
|
||||
def test_generates_version_header(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(type="feat", scope=None, breaking=False, message="test", hash="abc123")
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "## [0.6.0]" in result
|
||||
|
||||
def test_includes_date(self) -> None:
|
||||
commits = []
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
import re
|
||||
|
||||
date_match = re.search(r"\d{4}-\d{2}-\d{2}", result)
|
||||
assert date_match is not None
|
||||
|
||||
def test_groups_by_type(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope=None, breaking=False, message="add feature", hash="abc123"
|
||||
),
|
||||
ParsedCommit(type="fix", scope=None, breaking=False, message="fix bug", hash="def456"),
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "### Features" in result
|
||||
assert "### Bug Fixes" in result
|
||||
assert "- add feature (abc123)" in result
|
||||
assert "- fix bug (def456)" in result
|
||||
|
||||
def test_includes_scope_in_bullet(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope="core", breaking=False, message="add feature", hash="abc123"
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "- **core**: add feature (abc123)" in result
|
||||
|
||||
def test_breaking_change_section_when_present(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope="core", breaking=True, message="api changed", hash="abc123"
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "### Breaking Changes" in result
|
||||
assert "**core**" in result
|
||||
|
||||
def test_no_breaking_change_section_when_none(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="feat", scope=None, breaking=False, message="add feature", hash="abc123"
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "Breaking Changes" not in result
|
||||
|
||||
def test_includes_other_changes_section(self) -> None:
|
||||
commits = [
|
||||
ParsedCommit(
|
||||
type="other",
|
||||
scope=None,
|
||||
breaking=False,
|
||||
message="Add support for custom Anthropic API URL",
|
||||
hash="abc123",
|
||||
)
|
||||
]
|
||||
result = generate_changelog("0.6.0", commits)
|
||||
assert "### Other Changes" in result
|
||||
assert "- Add support for custom Anthropic API URL (abc123)" in result
|
||||
|
||||
|
||||
class TestIntegrationWithMock:
|
||||
"""Integration tests with mocked subprocess.run."""
|
||||
|
||||
def test_full_flow_with_mocked_git(self) -> None:
|
||||
log_output = (
|
||||
make_log_entry("feat(core): add new feature", "abc1234")
|
||||
+ make_log_entry("fix(ui): fix bug", "def5678")
|
||||
+ make_log_entry("ci: update github actions", "xyz789")
|
||||
+ make_log_entry(
|
||||
"feat(outer): breaking change", "bbb111", "BREAKING CHANGE: this is breaking"
|
||||
)
|
||||
+ make_log_entry("chore: cleanup", "yyy999")
|
||||
)
|
||||
commits = parse_commits(log_output)
|
||||
|
||||
assert len(commits) == 5
|
||||
assert any(c.type == "feat" and c.scope == "core" for c in commits)
|
||||
assert any(c.type == "fix" and c.scope == "ui" for c in commits)
|
||||
assert any(c.type == "ci" for c in commits)
|
||||
assert any(c.type == "feat" and c.breaking for c in commits)
|
||||
assert any(c.type == "chore" for c in commits)
|
||||
|
||||
changelog = generate_changelog("0.7.0", commits)
|
||||
assert "## [0.7.0]" in changelog
|
||||
assert "### Features" in changelog
|
||||
assert "### Bug Fixes" in changelog
|
||||
assert "### CI/CD" in changelog
|
||||
assert "### Breaking Changes" in changelog
|
||||
|
||||
|
||||
class TestCommitPattern:
|
||||
"""Tests for the commit regex pattern."""
|
||||
|
||||
def test_feat_with_scope(self) -> None:
|
||||
match = COMMIT_PATTERN.match("feat(core): add feature")
|
||||
assert match is not None
|
||||
assert match.group(1) == "feat"
|
||||
assert match.group(2) == "(core)"
|
||||
assert match.group(4) == "add feature"
|
||||
|
||||
def test_fix_without_scope(self) -> None:
|
||||
match = COMMIT_PATTERN.match("fix: fix bug")
|
||||
assert match is not None
|
||||
assert match.group(1) == "fix"
|
||||
assert match.group(2) is None
|
||||
assert match.group(4) == "fix bug"
|
||||
|
||||
def test_with_exclamation(self) -> None:
|
||||
match = COMMIT_PATTERN.match("feat(core)!: api changed")
|
||||
assert match is not None
|
||||
assert match.group(3) == "!"
|
||||
|
||||
def test_without_exclamation(self) -> None:
|
||||
match = COMMIT_PATTERN.match("feat(core): add feature")
|
||||
assert match is not None
|
||||
assert match.group(3) is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
|
|||
|
|
@ -1,68 +1,68 @@
|
|||
"""Tests for sync-plugin-versions.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_module():
|
||||
script = Path(__file__).parent.parent / "sync-plugin-versions.py"
|
||||
spec = importlib.util.spec_from_file_location("sync_plugin_versions", script)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_compute_repo_semver_uses_release_helpers(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(module, "list_release_tags", lambda root: ["v0.9.0"])
|
||||
monkeypatch.setattr(module, "find_latest_release_tag", lambda tags: "v0.9.0")
|
||||
monkeypatch.setattr(module, "list_release_commits", lambda root, tag: ["feat: add init"])
|
||||
monkeypatch.setattr(module, "determine_bump_level", lambda commits: "minor")
|
||||
monkeypatch.setattr(module, "get_canonical_version", lambda root: "0.5.25")
|
||||
|
||||
def fake_compute_release_version(*, canonical_version: str, level: str, tags: list[str]):
|
||||
calls["canonical_version"] = canonical_version
|
||||
calls["level"] = level
|
||||
calls["tags"] = tags
|
||||
return type("Info", (), {"npm_version": "0.10.0"})()
|
||||
|
||||
monkeypatch.setattr(module, "compute_release_version", fake_compute_release_version)
|
||||
|
||||
assert module.compute_repo_semver(Path("repo")) == "0.10.0"
|
||||
assert calls == {
|
||||
"canonical_version": "0.5.25",
|
||||
"level": "minor",
|
||||
"tags": ["v0.9.0"],
|
||||
}
|
||||
|
||||
|
||||
def test_main_runs_plugin_only_version_sync(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
commands: list[list[str]] = []
|
||||
|
||||
monkeypatch.setattr(module, "compute_repo_semver", lambda root: "0.10.0")
|
||||
monkeypatch.setattr(
|
||||
module.subprocess,
|
||||
"run",
|
||||
lambda command, cwd, check: commands.append(command),
|
||||
)
|
||||
|
||||
module.main()
|
||||
|
||||
assert commands == [
|
||||
[
|
||||
module.sys.executable,
|
||||
str(module.ROOT / "scripts" / "version-sync.py"),
|
||||
"--root",
|
||||
str(module.ROOT),
|
||||
"--version",
|
||||
"0.10.0",
|
||||
"--plugin-manifests-only",
|
||||
]
|
||||
]
|
||||
"""Tests for sync-plugin-versions.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_module():
|
||||
script = Path(__file__).parent.parent / "sync-plugin-versions.py"
|
||||
spec = importlib.util.spec_from_file_location("sync_plugin_versions", script)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_compute_repo_semver_uses_release_helpers(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(module, "list_release_tags", lambda root: ["v0.9.0"])
|
||||
monkeypatch.setattr(module, "find_latest_release_tag", lambda tags: "v0.9.0")
|
||||
monkeypatch.setattr(module, "list_release_commits", lambda root, tag: ["feat: add init"])
|
||||
monkeypatch.setattr(module, "determine_bump_level", lambda commits: "minor")
|
||||
monkeypatch.setattr(module, "get_canonical_version", lambda root: "0.5.25")
|
||||
|
||||
def fake_compute_release_version(*, canonical_version: str, level: str, tags: list[str]):
|
||||
calls["canonical_version"] = canonical_version
|
||||
calls["level"] = level
|
||||
calls["tags"] = tags
|
||||
return type("Info", (), {"npm_version": "0.10.0"})()
|
||||
|
||||
monkeypatch.setattr(module, "compute_release_version", fake_compute_release_version)
|
||||
|
||||
assert module.compute_repo_semver(Path("repo")) == "0.10.0"
|
||||
assert calls == {
|
||||
"canonical_version": "0.5.25",
|
||||
"level": "minor",
|
||||
"tags": ["v0.9.0"],
|
||||
}
|
||||
|
||||
|
||||
def test_main_runs_plugin_only_version_sync(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
commands: list[list[str]] = []
|
||||
|
||||
monkeypatch.setattr(module, "compute_repo_semver", lambda root: "0.10.0")
|
||||
monkeypatch.setattr(
|
||||
module.subprocess,
|
||||
"run",
|
||||
lambda command, cwd, check: commands.append(command),
|
||||
)
|
||||
|
||||
module.main()
|
||||
|
||||
assert commands == [
|
||||
[
|
||||
module.sys.executable,
|
||||
str(module.ROOT / "scripts" / "version-sync.py"),
|
||||
"--root",
|
||||
str(module.ROOT),
|
||||
"--version",
|
||||
"0.10.0",
|
||||
"--plugin-manifests-only",
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,27 @@ set -euo pipefail
|
|||
|
||||
actionlint .github/workflows/*.yml
|
||||
|
||||
act workflow_dispatch -W .github/workflows/release.yml -e .github/act/dry-run.json -n
|
||||
act push -W .github/workflows/release.yml -e .github/act/push-feat.json -n
|
||||
act workflow_dispatch -W .github/workflows/docker.yml -e .github/act/docker-version.json -n
|
||||
run_act() {
|
||||
local attempt=1
|
||||
local max_attempts=3
|
||||
local delay_seconds=5
|
||||
|
||||
while true; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if (( attempt >= max_attempts )); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "act dry-run failed on attempt ${attempt}/${max_attempts}; retrying in ${delay_seconds}s..." >&2
|
||||
sleep "${delay_seconds}"
|
||||
attempt=$((attempt + 1))
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
}
|
||||
|
||||
run_act act workflow_dispatch -W .github/workflows/release.yml -e .github/act/dry-run.json -n
|
||||
run_act act push -W .github/workflows/release.yml -e .github/act/push-feat.json -n
|
||||
run_act act workflow_dispatch -W .github/workflows/docker.yml -e .github/act/docker-version.json -n
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue