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,
+ }
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}'"
+ )