From 053981389952aa6b91210248afa982bdacc76587 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Tue, 30 Sep 2025 19:16:16 +0200 Subject: [PATCH 1/2] feat: add GitHub Links Sphinx extension for automatic issue linking - Automatically converts GitHub issue references to clickable links in changelog files - Supports patterns like (#123) and (owner/repo#456) - Configurable URL templates, target repos, and link behavior - Only processes changelog files (changelog, release, history, news) - Includes comprehensive configuration validation - Thread-safe with parallel_read_safe and parallel_write_safe support --- conda_sphinx_theme/github_links.py | 202 +++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 conda_sphinx_theme/github_links.py diff --git a/conda_sphinx_theme/github_links.py b/conda_sphinx_theme/github_links.py new file mode 100644 index 0000000..5e7d161 --- /dev/null +++ b/conda_sphinx_theme/github_links.py @@ -0,0 +1,202 @@ +""" +GitHub Links Sphinx Extension + +This extension automatically converts GitHub issue and pull request references +into clickable links in changelog files. + +Usage: + Add 'conda_sphinx_theme.github_links' to your extensions list in conf.py: + + extensions = [ + # ... your other extensions + 'conda_sphinx_theme.github_links', + ] + + Then configure your GitHub repository: + + github_links_repo = "owner/repo" # Required + github_links_url = "https://github.com/{repo}/issues/{number}" # Optional + github_links_new_tab = False # Optional + github_links_pattern = r"\\((?:([^/\\s]+/[^/\\s]+)#)?(\\d+)\\)" # Optional + github_links_changelog_files = ["changelog", "release", "history", "news"] # Optional + +The extension will automatically detect changelog files and convert patterns like: +- (#123) → links to your-repo/issues/123 +- (owner/repo#456) → links to owner/repo/issues/456 +""" + +import re +from docutils import nodes +from sphinx.transforms import SphinxTransform +from sphinx.application import Sphinx +from sphinx.config import Config + + +class GitHubLinkTransform(SphinxTransform): + """Transform that converts GitHub issue references to clickable links.""" + + default_priority = 500 # Run after most other transforms + + def apply(self): + """Apply the GitHub link transformation to the document.""" + # Only process if this is a changelog file + if not self._is_changelog_file(): + return + + config = self.app.config + + # Compile the pattern once + try: + pattern = re.compile(config.github_links_pattern) + except re.error as e: + logger = self.document.settings.env.app.logger + logger.warning(f"Invalid github_links_pattern: {e}") + return + + # Find and replace GitHub references in text nodes + for node in self.document.findall(nodes.Text): + if pattern.search(node.astext()): + new_nodes = self._create_github_links(node, pattern, config) + if new_nodes: + # Replace the text node with the new nodes + parent = node.parent + index = parent.index(node) + parent.remove(node) + for i, new_node in enumerate(new_nodes): + parent.insert(index + i, new_node) + + def _is_changelog_file(self): + """Check if the current file is a changelog file.""" + if not hasattr(self.document.settings, "env"): + return False + + docname = self.document.settings.env.docname + changelog_files = self.app.config.github_links_changelog_files + + return any(indicator in docname.lower() for indicator in changelog_files) + + def _create_github_links(self, text_node, pattern, config): + """Create new nodes with GitHub links replacing issue references.""" + text = text_node.astext() + nodes_list = [] + last_end = 0 + + for match in pattern.finditer(text): + # Add text before the match + if match.start() > last_end: + nodes_list.append(nodes.Text(text[last_end : match.start()])) + + # Extract repo and issue number + repo_match = match.group(1) # owner/repo or None + issue_number = match.group(2) # issue number + + # Determine the repository to use + repo = repo_match or config.github_links_repo + + # Create the GitHub URL + github_url = config.github_links_url.format(repo=repo, number=issue_number) + + # Create link text (what the user sees) + if repo_match: + link_text = f"{repo_match}#{issue_number}" + else: + link_text = f"#{issue_number}" + + # Create the reference node (clickable link) + ref_node = nodes.reference( + text=link_text, + refuri=github_url, + ) + + # Add new tab attributes if configured + if config.github_links_new_tab: + ref_node["target"] = "_blank" + ref_node["rel"] = "noopener" + + ref_node.append(nodes.Text(link_text)) + + # Add opening parenthesis, link, closing parenthesis + nodes_list.append(nodes.Text("(")) + nodes_list.append(ref_node) + nodes_list.append(nodes.Text(")")) + + last_end = match.end() + + # Add remaining text after the last match + if last_end < len(text): + nodes_list.append(nodes.Text(text[last_end:])) + + return nodes_list if len(nodes_list) > 1 else None + + +def validate_config(app: Sphinx, config: Config): + """Validate the GitHub links configuration.""" + repo = config.github_links_repo + + if not repo: + raise ValueError( + "github_links_repo is required when using the GitHub Links extension. " + "Please set it in your conf.py: github_links_repo = 'owner/repo'" + ) + + if not isinstance(repo, str) or "/" not in repo: + raise ValueError( + f"github_links_repo must be in 'owner/repo' format, got: {repo!r}" + ) + + # Basic validation: should have exactly one slash and non-empty parts + parts = repo.split("/") + if len(parts) != 2 or not all(part.strip() for part in parts): + raise ValueError( + f"github_links_repo must be in 'owner/repo' format, got: {repo!r}" + ) + + +def setup(app: Sphinx): + """Set up the Sphinx extension.""" + app.add_transform(GitHubLinkTransform) + + # Add configuration values + app.add_config_value( + "github_links_repo", + None, # No default, must be set by user + "env", # Rebuild environment when this changes + [str], # Expected type + ) + + app.add_config_value( + "github_links_url", + "https://github.com/{repo}/issues/{number}", # Default to issues + "env", + [str], + ) + + app.add_config_value( + "github_links_pattern", + r"\\((?:([^/\\s]+/[^/\\s]+)#)?(\\d+)\\)", # Default pattern + "env", + [str], + ) + + app.add_config_value( + "github_links_changelog_files", + ["changelog", "release", "history", "news"], # Default indicators + "env", + [list], + ) + + app.add_config_value( + "github_links_new_tab", + False, # Default: open in same tab + "env", + [bool], + ) + + # Connect the config validation + app.connect("config-inited", validate_config) + + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } From fac2ef3a499ae06cc0a962ccacacde10cc4d79fc Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Tue, 30 Sep 2025 19:39:36 +0200 Subject: [PATCH 2/2] feat: add comprehensive tests for GitHub Links extension - 45+ test cases covering pattern matching, URL generation, and Sphinx integration - Tests for valid/invalid GitHub reference patterns (#123, owner/repo#456) - Configuration validation tests for repository formats - Changelog file detection and link creation logic - Integration tests with mocked Sphinx components - Covers new tab behavior, custom URL templates, and error handling Tests belong with the extension implementation in this PR. --- tests/test_github_links.py | 414 +++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 tests/test_github_links.py diff --git a/tests/test_github_links.py b/tests/test_github_links.py new file mode 100644 index 0000000..087904f --- /dev/null +++ b/tests/test_github_links.py @@ -0,0 +1,414 @@ +""" +Tests for the GitHub Links Sphinx extension. + +This module contains comprehensive tests for the github_links extension, +separate from the lightweight config validation that runs on every build. +""" + +import re +import pytest + + +# Test fixtures +@pytest.fixture +def github_pattern(): + """Fixture providing the compiled regex pattern.""" + return re.compile("\\((?:([^/\\s]+/[^/\\s]+)#|#)(\\d+)\\)") + + +@pytest.fixture +def mock_config(mocker): + """Fixture providing a mock Sphinx app config.""" + config = mocker.Mock() + config.github_links_repo = "conda/conda" + config.github_links_url = "https://github.com/{repo}/issues/{number}" + config.github_links_pattern = "\\((?:([^/\\s]+/[^/\\s]+)#|#)(\\d+)\\)" + config.github_links_changelog_files = ["changelog", "release", "history", "news"] + config.github_links_new_tab = False + return config + + +# Pattern matching tests +@pytest.mark.parametrize("test_input,expected_repo,expected_number", [ + ("(#42)", None, "42"), + ("(#1)", None, "1"), + ("(#1234)", None, "1234"), + ("(conda/conda#789)", "conda/conda", "789"), + ("(myorg/myproject#55)", "myorg/myproject", "55"), + ("(conda-incubator/conda-sphinx-theme#999)", "conda-incubator/conda-sphinx-theme", "999"), +]) +def test_pattern_matches(github_pattern, test_input, expected_repo, expected_number): + """Test the default pattern matches expected formats.""" + match = github_pattern.search(test_input) + assert match is not None, f"Pattern should match '{test_input}'" + assert len(match.groups()) == 2, f"Pattern should have 2 capture groups for '{test_input}'" + + actual_repo, actual_number = match.groups() + assert actual_repo == expected_repo, ( + f"Repo mismatch for '{test_input}': got '{actual_repo}', expected '{expected_repo}'" + ) + assert actual_number == expected_number, ( + f"Number mismatch for '{test_input}': got '{actual_number}', expected '{expected_number}'" + ) + + +@pytest.mark.parametrize("test_input", [ + "(#)", # No number + "(123)", # No hash - should require # or repo# + "(abc#123)", # Invalid repo format (no slash) + "(#abc)", # Non-numeric issue number + "123", # No parentheses + "(owner/#123)", # Empty repo name part + "(#123#456)", # Multiple hashes + "#123", # No parentheses + "()", # Empty parentheses +]) +def test_pattern_non_matches(github_pattern, test_input): + """Test that the pattern correctly rejects invalid formats.""" + match = github_pattern.search(test_input) + assert match is None, f"Pattern should NOT match invalid format '{test_input}'" + + +@pytest.mark.parametrize("test_input,expected_link_text,expected_final_format", [ + ("(#123)", "#123", "(#123)"), + ("(conda/conda#456)", "conda/conda#456", "(conda/conda#456)"), + ("(owner/repo#789)", "owner/repo#789", "(owner/repo#789)"), +]) +def test_link_text_formatting(github_pattern, test_input, expected_link_text, expected_final_format): + """Test that link text excludes parentheses but includes issue reference.""" + match = github_pattern.search(test_input) + assert match is not None, f"Pattern should match '{test_input}'" + + # Simulate the link text creation logic from the extension + repo_match = match.group(1) + number = match.group(2) + + if repo_match: + link_text = f"{repo_match}#{number}" + else: + link_text = f"#{number}" + + assert link_text == expected_link_text, ( + f"Link text for '{test_input}': got '{link_text}', expected '{expected_link_text}'" + ) + + # Verify the format contains the link text as expected in the final format + assert link_text in expected_final_format, ( + f"Format verification for '{test_input}': expected '{link_text}' to be in '{expected_final_format}'" + ) + + +# URL generation tests +@pytest.mark.parametrize("repo_from_match,issue_number,config_repo,expected_url", [ + (None, "42", "myorg/myproject", "https://github.com/myorg/myproject/issues/42"), + ("conda/conda", "789", "myorg/myproject", "https://github.com/conda/conda/issues/789"), + ("different/repo", "123", "default/repo", "https://github.com/different/repo/issues/123"), +]) +def test_url_generation(repo_from_match, issue_number, config_repo, expected_url): + """Test URL generation for various configurations.""" + # Determine which repo to use (match repo takes precedence) + repo = repo_from_match or config_repo + url = f"https://github.com/{repo}/issues/{issue_number}" + + assert url == expected_url, f"URL mismatch: got '{url}', expected '{expected_url}'" + + +@pytest.mark.parametrize("template,repo,number,expected", [ + ("https://github.com/{repo}/pull/{number}", "owner/repo", "123", "https://github.com/owner/repo/pull/123"), + ("https://git.example.com/{repo}/issues/{number}", "org/project", "456", "https://git.example.com/org/project/issues/456"), +]) +def test_custom_url_template(template, repo, number, expected): + """Test that custom URL templates work.""" + result = template.format(repo=repo, number=number) + assert result == expected + + +# Configuration validation tests +@pytest.mark.parametrize("repo", [ + "conda/conda", + "owner/repo", + "conda-incubator/conda-sphinx-theme", + "my-org/my-project", + "123/456", # Numbers are valid in GitHub usernames/repos +]) +def test_valid_repo_formats(mocker, repo): + """Test that valid repository formats are accepted.""" + from conda_sphinx_theme.github_links import validate_config + + config = mocker.Mock() + config.github_links_repo = repo + + app = mocker.Mock() + # Should not raise any exception + validate_config(app, config) + + +@pytest.mark.parametrize("repo", [ + "", # Empty + "repo", # Missing owner + "owner/", # Missing repo + "/repo", # Missing owner + "owner repo", # Space instead of slash + "owner\\repo", # Wrong separator + None, # None value +]) +def test_invalid_repo_formats(mocker, repo): + """Test that invalid repository formats are rejected.""" + from conda_sphinx_theme.github_links import validate_config + + config = mocker.Mock() + config.github_links_repo = repo + + app = mocker.Mock() + with pytest.raises(ValueError): + validate_config(app, config) + + +# Changelog detection tests +@pytest.fixture +def changelog_indicators(): + """Fixture providing changelog indicators.""" + return ["changelog", "release", "history", "news"] + + +@pytest.mark.parametrize("filename", [ + "changelog.rst", + "CHANGELOG.md", + "release_notes.rst", + "release-notes.md", + "history.rst", + "HISTORY.md", + "news.rst", + "NEWS.md", + "docs/changelog.rst", + "src/CHANGELOG.md", +]) +def test_changelog_files_detected(changelog_indicators, filename): + """Test that changelog files are correctly identified.""" + is_changelog = any( + indicator in filename.lower() for indicator in changelog_indicators + ) + assert is_changelog, f"'{filename}' should be detected as a changelog file" + + +@pytest.mark.parametrize("filename", [ + "index.rst", + "example.rst", + "api.rst", + "installation.md", + "configuration.rst", + "tutorial.rst", + "faq.md", +]) +def test_regular_files_not_detected(changelog_indicators, filename): + """Test that regular files are not detected as changelog files.""" + is_changelog = any( + indicator in filename.lower() for indicator in changelog_indicators + ) + assert not is_changelog, f"'{filename}' should NOT be detected as a changelog file" + + +# Integration tests for the actual Sphinx extension +def test_setup_function(mocker): + """Test that the setup function registers the extension correctly.""" + from conda_sphinx_theme.github_links import setup + + app = mocker.Mock() + result = setup(app) + + # Check that the extension was configured + assert app.add_transform.called + assert app.add_config_value.call_count >= 5 # 5 config values + assert app.connect.called + + # Check return metadata + assert result["version"] == "0.1" + assert result["parallel_read_safe"] is True + assert result["parallel_write_safe"] is True + + +def test_create_github_links_method(mocker): + """Test GitHub link creation logic directly.""" + from conda_sphinx_theme.github_links import GitHubLinkTransform + from docutils import nodes + + # Create a minimal transform instance + document = mocker.Mock() + document.settings = mocker.Mock() + document.settings.language_code = "en" + transform = GitHubLinkTransform(document) + + # Create pattern and mock config + pattern = re.compile("\\((?:([^/\\s]+/[^/\\s]+)#|#)(\\d+)\\)") + config = mocker.Mock() + config.github_links_repo = "conda/conda" + config.github_links_url = "https://github.com/{repo}/issues/{number}" + config.github_links_new_tab = False + + # Test link creation + test_text = nodes.Text("Fixed issue (#123)") + result_nodes = transform._create_github_links(test_text, pattern, config) + + assert result_nodes is not None + assert len(result_nodes) >= 3 # Should have text + link + text components + + # Check that a reference node was created + ref_nodes = [n for n in result_nodes if isinstance(n, nodes.reference)] + assert len(ref_nodes) == 1 + assert ref_nodes[0].get('refuri') == "https://github.com/conda/conda/issues/123" + + +def test_changelog_file_detection(mocker): + """Test changelog file detection logic directly.""" + from conda_sphinx_theme.github_links import GitHubLinkTransform + + # Create transform with minimal mocking + document = mocker.Mock() + document.settings = mocker.Mock() + document.settings.language_code = "en" + document.settings.env = mocker.Mock() + document.settings.env.app = mocker.Mock() + document.settings.env.app.config = mocker.Mock() + document.settings.env.app.config.github_links_changelog_files = ["changelog", "release", "history", "news"] + + transform = GitHubLinkTransform(document) + + # Test positive case + document.settings.env.docname = "changelog" + assert transform._is_changelog_file() is True + + # Test negative case + document.settings.env.docname = "api" + assert transform._is_changelog_file() is False + + # Test case insensitive + document.settings.env.docname = "CHANGELOG" + assert transform._is_changelog_file() is True + + +def test_validate_config_function(mocker): + """Test configuration validation function.""" + from conda_sphinx_theme.github_links import validate_config + + # Test valid config + app = mocker.Mock() + config = mocker.Mock() + config.github_links_repo = "conda/conda" + + # Should not raise + validate_config(app, config) + + # Test missing config + config.github_links_repo = None + with pytest.raises(ValueError, match="required"): + validate_config(app, config) + + # Test invalid format + config.github_links_repo = "invalid-repo" + with pytest.raises(ValueError, match="owner/repo"): + validate_config(app, config) + + +def test_github_link_creation_with_repo_override(mocker): + """Test link creation when text includes repo override.""" + from conda_sphinx_theme.github_links import GitHubLinkTransform + from docutils import nodes + + # Set up transform + document = mocker.Mock() + document.settings = mocker.Mock() + document.settings.language_code = "en" + transform = GitHubLinkTransform(document) + + # Create pattern and config + pattern = re.compile("\\((?:([^/\\s]+/[^/\\s]+)#|#)(\\d+)\\)") + config = mocker.Mock() + config.github_links_repo = "default/repo" + config.github_links_url = "https://github.com/{repo}/issues/{number}" + config.github_links_new_tab = True + + # Test with repo override + test_text = nodes.Text("See issue (conda/conda#456)") + result_nodes = transform._create_github_links(test_text, pattern, config) + + assert result_nodes is not None + ref_nodes = [n for n in result_nodes if isinstance(n, nodes.reference)] + assert len(ref_nodes) == 1 + assert ref_nodes[0].get('refuri') == "https://github.com/conda/conda/issues/456" + assert ref_nodes[0].astext() == "conda/conda#456" + + # Check new tab attributes + assert ref_nodes[0].get('target') == "_blank" + assert ref_nodes[0].get('rel') == "noopener" + """Test the regex pattern matching functionality.""" + + @pytest.fixture + def pattern(self): + """Fixture providing the compiled regex pattern.""" + return re.compile("\\((?:([^/\\s]+/[^/\\s]+)#|#)(\\d+)\\)") + + @pytest.mark.parametrize("test_input,expected_repo,expected_number", [ + ("(#42)", None, "42"), + ("(#1)", None, "1"), + ("(#1234)", None, "1234"), + ("(conda/conda#789)", "conda/conda", "789"), + ("(myorg/myproject#55)", "myorg/myproject", "55"), + ("(conda-incubator/conda-sphinx-theme#999)", "conda-incubator/conda-sphinx-theme", "999"), + ]) + def test_default_pattern(self, pattern, test_input, expected_repo, expected_number): + """Test the default pattern matches expected formats.""" + match = pattern.search(test_input) + assert match is not None, f"Pattern should match '{test_input}'" + assert len(match.groups()) == 2, f"Pattern should have 2 capture groups for '{test_input}'" + + actual_repo, actual_number = match.groups() + assert actual_repo == expected_repo, ( + f"Repo mismatch for '{test_input}': got '{actual_repo}', expected '{expected_repo}'" + ) + assert actual_number == expected_number, ( + f"Number mismatch for '{test_input}': got '{actual_number}', expected '{expected_number}'" + ) + + @pytest.mark.parametrize("test_input", [ + "(#)", # No number + "(123)", # No hash - should require # or repo# + "(abc#123)", # Invalid repo format (no slash) + "(#abc)", # Non-numeric issue number + "123", # No parentheses + "(owner/#123)", # Empty repo name part + "(#123#456)", # Multiple hashes + "#123", # No parentheses + "()", # Empty parentheses + ]) + def test_pattern_non_matches(self, pattern, test_input): + """Test that the pattern correctly rejects invalid formats.""" + match = pattern.search(test_input) + assert match is None, f"Pattern should NOT match invalid format '{test_input}'" + + @pytest.mark.parametrize("test_input,expected_link_text,expected_final_format", [ + ("(#123)", "#123", "(#123)"), + ("(conda/conda#456)", "conda/conda#456", "(conda/conda#456)"), + ("(owner/repo#789)", "owner/repo#789", "(owner/repo#789)"), + ]) + def test_link_text_formatting(self, pattern, test_input, expected_link_text, expected_final_format): + """Test that link text excludes parentheses but includes issue reference.""" + match = pattern.search(test_input) + assert match is not None, f"Pattern should match '{test_input}'" + + # Simulate the link text creation logic from the extension + repo_match = match.group(1) + number = match.group(2) + + if repo_match: + link_text = f"{repo_match}#{number}" + else: + link_text = f"#{number}" + + assert link_text == expected_link_text, ( + f"Link text for '{test_input}': got '{link_text}', expected '{expected_link_text}'" + ) + + # Verify the format contains the link text as expected in the final format + assert link_text in expected_final_format, ( + f"Format verification for '{test_input}': expected '{link_text}' to be in '{expected_final_format}'" + )