From ed1c2ac3557b7df9600cf9a3bd1453f2caa7e5ca Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 03:32:44 +0000 Subject: [PATCH 1/4] CodeRabbit Generated Unit Tests: Add tests for docs, PR templates, and CHANGELOG; remove examples/ dir --- tests/test_branch_prefix_docs.py | 42 ++++++++++++++++ tests/test_changelog.py | 34 +++++++++++++ tests/test_docs_links.py | 42 ++++++++++++++++ tests/test_markdown_structure.py | 59 +++++++++++++++++++++++ tests/test_pr_templates.py | 37 ++++++++++++++ tests/test_removed_examples_references.py | 19 ++++++++ tests/util_changed_files.py | 55 +++++++++++++++++++++ 7 files changed, 288 insertions(+) create mode 100644 tests/test_branch_prefix_docs.py create mode 100644 tests/test_changelog.py create mode 100644 tests/test_docs_links.py create mode 100644 tests/test_markdown_structure.py create mode 100644 tests/test_pr_templates.py create mode 100644 tests/test_removed_examples_references.py create mode 100644 tests/util_changed_files.py diff --git a/tests/test_branch_prefix_docs.py b/tests/test_branch_prefix_docs.py new file mode 100644 index 000000000..7caca5b0c --- /dev/null +++ b/tests/test_branch_prefix_docs.py @@ -0,0 +1,42 @@ +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 + m = re.search(r"^\s*`\s*`?\s*`\s*\n(.*?)\n\s*`\s*`?\s*`", text, flags=re.S) + # 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() \ No newline at end of file diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 000000000..1e2c51602 --- /dev/null +++ b/tests/test_changelog.py @@ -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() \ No newline at end of file diff --git a/tests/test_docs_links.py b/tests/test_docs_links.py new file mode 100644 index 000000000..cb2adf7bd --- /dev/null +++ b/tests/test_docs_links.py @@ -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() \ No newline at end of file diff --git a/tests/test_markdown_structure.py b/tests/test_markdown_structure.py new file mode 100644 index 000000000..0f49e0b51 --- /dev/null +++ b/tests/test_markdown_structure.py @@ -0,0 +1,59 @@ +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() + first_line = text.splitlines()[0] if text 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() \ No newline at end of file diff --git a/tests/test_pr_templates.py b/tests/test_pr_templates.py new file mode 100644 index 000000000..ef00b8c4b --- /dev/null +++ b/tests/test_pr_templates.py @@ -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() \ No newline at end of file diff --git a/tests/test_removed_examples_references.py b/tests/test_removed_examples_references.py new file mode 100644 index 000000000..b544ddf80 --- /dev/null +++ b/tests/test_removed_examples_references.py @@ -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() \ No newline at end of file diff --git a/tests/util_changed_files.py b/tests/util_changed_files.py new file mode 100644 index 000000000..18628a92b --- /dev/null +++ b/tests/util_changed_files.py @@ -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 Exception: + 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)) \ No newline at end of file From c28aeea2e4a7a027be8763aabf1db07525880377 Mon Sep 17 00:00:00 2001 From: Ash Shaw Date: Sun, 12 Oct 2025 11:02:20 +0700 Subject: [PATCH 2/4] Update tests/test_branch_prefix_docs.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ash Shaw --- tests/test_branch_prefix_docs.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_branch_prefix_docs.py b/tests/test_branch_prefix_docs.py index 7caca5b0c..f00da9c69 100644 --- a/tests/test_branch_prefix_docs.py +++ b/tests/test_branch_prefix_docs.py @@ -18,7 +18,20 @@ def test_contains_enforcement_regex(self): 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 - m = re.search(r"^\s*`\s*`?\s*`\s*\n(.*?)\n\s*`\s*`?\s*`", text, flags=re.S) + # 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 From b1d432cd121245541ff55038ec5db762a3e4079e Mon Sep 17 00:00:00 2001 From: Ash Shaw Date: Sun, 12 Oct 2025 11:02:35 +0700 Subject: [PATCH 3/4] Update tests/util_changed_files.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ash Shaw --- tests/util_changed_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/util_changed_files.py b/tests/util_changed_files.py index 18628a92b..4e7304db7 100644 --- a/tests/util_changed_files.py +++ b/tests/util_changed_files.py @@ -8,7 +8,7 @@ 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 Exception: + except (subprocess.CalledProcessError, FileNotFoundError, OSError): return "" def list_changed_paths_vs_main() -> List[str]: From 64745a9359f89f180162939315722ac0c032bebb Mon Sep 17 00:00:00 2001 From: Ash Shaw Date: Sun, 12 Oct 2025 11:02:43 +0700 Subject: [PATCH 4/4] Update tests/test_markdown_structure.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ash Shaw --- tests/test_markdown_structure.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_markdown_structure.py b/tests/test_markdown_structure.py index 0f49e0b51..a7ea1dc38 100644 --- a/tests/test_markdown_structure.py +++ b/tests/test_markdown_structure.py @@ -45,7 +45,8 @@ def test_docs_start_with_h1(self): for p in self.docs: with self.subTest(p=p): text = open(repo_abspath(p), "r", encoding="utf-8", errors="replace").read().lstrip() - first_line = text.splitlines()[0] if text else "" + 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):