Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions tests/test_branch_prefix_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os
import re
import unittest
from tests.util_changed_files import repo_abspath

DOCS = [
"docs/branch-prefixes-client-delivery-v1.md",
"docs/branch-prefixes-product-development-v1.md",
]

class TestBranchPrefixDocs(unittest.TestCase):
def test_contains_enforcement_regex(self):
for rel in DOCS:
path = repo_abspath(rel)
if not os.path.exists(path):
# Skip missing files rather than failing (e.g. if only one variant in this repo)
continue
with self.subTest(doc=rel):
text = open(path, "r", encoding="utf-8", errors="replace").read()
# Find a line that looks like a branch enforcement regex
# Match a fenced code block with optional spaces and backticks
code_block_pattern = re.compile(r"""
^\s* # Start of line, optional leading whitespace
`\s* # First backtick, optional spaces
`? # Optional second backtick
\s*` # Optional spaces, third backtick
\s*\n # Optional spaces, newline
(.*?) # Non-greedy capture of code block contents
\n\s* # Newline, optional spaces
`\s* # Closing backtick, optional spaces
`? # Optional second closing backtick
\s*` # Optional spaces, third closing backtick
""", re.S | re.VERBOSE)
m = code_block_pattern.search(text)
# If generic fence search fails, search for the specific regex example
rx = re.search(r"^\^\((feat\|fix\|.*)\)\\?\/\[a-z0-9._-]\+\$$", text, flags=re.M)
candidate = None
if rx:
candidate = rx.group(0)
elif m:
block = m.group(1).strip()
# Heuristic: pick the line containing |feat|fix|hotfix|release|
for line in block.splitlines():
if "feat" in line and "fix" in line and "/" in line:
candidate = line.strip()
break
self.assertIsNotNone(candidate, f"Unable to locate branch name regex in {rel}")
# Validate it can be compiled as a Python regex (tolerate minor differences)
try:
re.compile(candidate)
except re.error as e:
self.fail(f"Invalid regex in {rel}: {e}")

if __name__ == "__main__":
unittest.main()
34 changes: 34 additions & 0 deletions tests/test_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import os
import re
import unittest
from tests.util_changed_files import repo_abspath

UNRELEASED_SECTIONS = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]

class TestChangelog(unittest.TestCase):
def setUp(self):
self.path = repo_abspath("CHANGELOG.md")
self.skipTestIfMissing()

def skipTestIfMissing(self):
if not os.path.exists(self.path):
self.skipTest("No CHANGELOG.md present")

def test_top_header(self):
text = open(self.path, "r", encoding="utf-8", errors="replace").read()
self.assertTrue(text.lstrip().startswith("# Changelog"), "CHANGELOG.md must start with '# Changelog'")

def test_unreleased_section_and_categories(self):
text = open(self.path, "r", encoding="utf-8", errors="replace").read()
m = re.search(r"^##\s+\[Unreleased\](.*?)(?=^##\s+\[|\Z)", text, flags=re.S | re.M)
self.assertIsNotNone(m, "Missing 'Unreleased' section")
block = m.group(1)
for sec in UNRELEASED_SECTIONS:
self.assertRegex(block, rf"^###\s+{sec}\s*$", f"Missing '### {sec}' under Unreleased")

def test_has_versioned_release_with_date(self):
text = open(self.path, "r", encoding="utf-8", errors="replace").read()
self.assertRegex(text, r"^##\s+\[\d+\.\d+\.\d+\]\s+-\s+\d{4}-\d{2}-\d{2}", "Expected a versioned release with date")

if __name__ == "__main__":
unittest.main()
42 changes: 42 additions & 0 deletions tests/test_docs_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import os
import re
import unittest
from urllib.parse import urlparse
from tests.util_changed_files import filter_changed_markdown, repo_abspath

LINK_RX = re.compile(r"\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")

class TestDocsLinks(unittest.TestCase):
def setUp(self):
self.changed_md = filter_changed_markdown()
self.md_files = [p for p in self.changed_md if os.path.exists(repo_abspath(p))]

def test_links_well_formed_and_local_targets_exist(self):
for rel in self.md_files:
with self.subTest(markdown=rel):
text = open(repo_abspath(rel), "r", encoding="utf-8", errors="replace").read()
basedir = os.path.dirname(repo_abspath(rel))
for m in LINK_RX.finditer(text):
label, target = m.group(1), m.group(2)
# Skip anchors, mailto, and external http(s) links
if target.startswith("#") or target.startswith("mailto:") or target.startswith("tel:"):
continue
parsed = urlparse(target)
if parsed.scheme in ("http", "https", ""):
if parsed.scheme in ("http", "https"):
# Can't validate external links offline — only basic sanity
self.assertNotIn(" ", target, f"External link contains spaces: {rel} -> {target}")
continue
# Relative path — validate existence
local = target
if "#" in local:
local = local.split("#", 1)[0]
if not local:
# pure anchor in same doc handled above
continue
# Resolve relative to the current file
candidate = os.path.normpath(os.path.join(basedir, local))
self.assertTrue(os.path.exists(candidate), f"Broken relative link in {rel}: {target}")

if __name__ == "__main__":
unittest.main()
60 changes: 60 additions & 0 deletions tests/test_markdown_structure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import os
import re
import unittest
from tests.util_changed_files import filter_changed_markdown, repo_abspath

class TestMarkdownStructure(unittest.TestCase):
def setUp(self):
self.changed_md = filter_changed_markdown()
# Only test docs/ markdowns here; PR templates are handled separately
self.docs = [p for p in self.changed_md if p.startswith("docs/") and os.path.exists(repo_abspath(p))]

def test_files_exist(self):
for p in self.docs:
with self.subTest(p=p):
self.assertTrue(os.path.exists(repo_abspath(p)), f"Missing file: {p}")

def test_no_tabs_or_crlf(self):
for p in self.docs:
with self.subTest(p=p):
content = open(repo_abspath(p), "rb").read()
self.assertNotIn(b"\t", content, "Tabs found (prefer spaces)")
self.assertNotIn(b"\r\n", content, "CRLF found (prefer LF)")

def test_no_trailing_whitespace(self):
for p in self.docs:
with self.subTest(p=p):
lines = open(repo_abspath(p), "r", encoding="utf-8", errors="replace").read().splitlines()
for i, line in enumerate(lines, start=1):
self.assertFalse(len(line) > 0 and line.endswith(" "), f"Trailing space at {p}:{i}")

def test_max_line_length_ignoring_code_blocks(self):
# Allow long lines in fenced code blocks; enforce <= 120 elsewhere
for p in self.docs:
with self.subTest(p=p):
lines = open(repo_abspath(p), "r", encoding="utf-8", errors="replace").read().splitlines()
in_code = False
for i, line in enumerate(lines, start=1):
if line.strip().startswith("```"):
in_code = not in_code
if not in_code and len(line) > 120:
self.fail(f"Line too long (>120) at {p}:{i}")

def test_docs_start_with_h1(self):
# Most docs should begin with a top-level heading
for p in self.docs:
with self.subTest(p=p):
text = open(repo_abspath(p), "r", encoding="utf-8", errors="replace").read().lstrip()
lines = text.splitlines()
first_line = lines[0] if lines else ""
self.assertTrue(first_line.startswith("#"), f"Expected H1 heading at top of {p}")

def test_eof_newline(self):
for p in self.docs:
with self.subTest(p=p):
with open(repo_abspath(p), "rb") as f:
data = f.read()
self.assertTrue(len(data) == 0 or data.endswith(b"\n"), f"File should end with newline: {p}")

if __name__ == "__main__":
unittest.main()
37 changes: 37 additions & 0 deletions tests/test_pr_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os
import re
import unittest
from tests.util_changed_files import list_changed_paths_vs_main, filter_pr_templates, repo_abspath

REQUIRED_HEADINGS = [
re.compile(r"^##\s+Linked issues", re.I | re.M),
re.compile(r"^##\s+Changelog", re.I | re.M),
]
REQUIRED_CHECKLIST_BULLET = re.compile(r"CI green; linked issues closed; release notes prepared", re.I)

class TestPullRequestTemplates(unittest.TestCase):
def setUp(self):
changed = list_changed_paths_vs_main()
candidates = filter_pr_templates(changed)
# If no changed PR templates, still validate all templates existing in repo
if not candidates:
for path in (".github/PULL_REQUEST_TEMPLATE",):
absd = repo_abspath(path)
if os.path.isdir(absd):
for f in os.listdir(absd):
if f.endswith(".md"):
candidates.append(os.path.join(".github/PULL_REQUEST_TEMPLATE", f))
if os.path.exists(repo_abspath(".github/pull_request_template.md")):
candidates.append(".github/pull_request_template.md")
self.templates = [p for p in candidates if os.path.exists(repo_abspath(p))]

def test_templates_have_required_sections(self):
for p in self.templates:
with self.subTest(p=p):
text = open(repo_abspath(p), "r", encoding="utf-8", errors="replace").read()
for rx in REQUIRED_HEADINGS:
self.assertRegex(text, rx, f"Missing required heading in {p}: {rx.pattern}")
self.assertRegex(text, REQUIRED_CHECKLIST_BULLET, f"Missing checklist bullet in {p}")

if __name__ == "__main__":
unittest.main()
19 changes: 19 additions & 0 deletions tests/test_removed_examples_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import os
import re
import unittest
from tests.util_changed_files import filter_changed_markdown, repo_abspath

class TestRemovedExamplesReferences(unittest.TestCase):
def test_no_examples_path_references_in_changed_docs(self):
# The examples/ directory was removed; ensure docs don't point to it anymore
for rel in filter_changed_markdown():
if not os.path.exists(repo_abspath(rel)):
continue
if not rel.endswith(".md"):
continue
with self.subTest(doc=rel):
text = open(repo_abspath(rel), "r", encoding="utf-8", errors="replace").read()
self.assertNotRegex(text, r"\bexamples/", f"Stale 'examples/' reference in {rel}")

if __name__ == "__main__":
unittest.main()
55 changes: 55 additions & 0 deletions tests/util_changed_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os
import subprocess
from typing import List, Iterable

REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))

def _run_git(*args) -> str:
try:
out = subprocess.check_output(["git", *args], cwd=REPO_ROOT, stderr=subprocess.STDOUT)
return out.decode("utf-8", errors="replace")
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return ""

def list_changed_paths_vs_main() -> List[str]:
"""
Return a list of paths changed vs main..HEAD.
Falls back to scanning docs/ and .github/ if git info is not available.
"""
# Allow override via env var (e.g., CI can set CHANGED_FILES as newline-separated list)
env_files = os.environ.get("CHANGED_FILES")
if env_files:
return [p.strip() for p in env_files.splitlines() if p.strip()]

diff = _run_git("diff", "main..HEAD", "--name-only")
paths = [p.strip() for p in diff.splitlines() if p.strip()]
if paths:
return paths

# Fallback: scan docs and PR templates
fallback = []
for base in ("docs", ".github"):
base_dir = os.path.join(REPO_ROOT, base)
if os.path.isdir(base_dir):
for root, _dirs, files in os.walk(base_dir):
for f in files:
fallback.append(os.path.relpath(os.path.join(root, f), REPO_ROOT))
return fallback

def filter_markdown(paths: Iterable[str]) -> List[str]:
return [p for p in paths if p.endswith(".md")]

def filter_changed_markdown() -> List[str]:
return filter_markdown(list_changed_paths_vs_main())

def filter_pr_templates(paths: Iterable[str]) -> List[str]:
res = []
for p in paths:
if p.startswith(".github/PULL_REQUEST_TEMPLATE/") and p.endswith(".md"):
res.append(p)
elif p == ".github/pull_request_template.md":
res.append(p)
return res

def repo_abspath(relpath: str) -> str:
return os.path.abspath(os.path.join(REPO_ROOT, relpath))
Loading