diff --git a/.github/scripts/cross_version_link_linter.py b/.github/scripts/cross_version_link_linter.py new file mode 100644 index 00000000000..c4e73f43e33 --- /dev/null +++ b/.github/scripts/cross_version_link_linter.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +cross_version_link_linter.py + +Detects and prevents cross-version links in CockroachDB documentation. +This linter runs on PRs to ensure documentation links stay within the same version. + +Usage: + python cross_version_link_linter.py ... +""" + +import re +import sys +import json +import os +from pathlib import Path +from typing import List, Dict, Optional, Tuple + + +class CrossVersionLinkLinter: + """Linter to detect cross-version links in CockroachDB documentation.""" + + # Regex patterns to detect different types of cross-version links + # Note: Version capture group should always be the first group for consistency + PATTERNS = { + 'liquid_link': r'{%\s*link\s+(v\d+\.\d+)/[^%]*%}', + 'include_cached': r'{%\s*include_cached\s+(v\d+\.\d+)/[^%]*%}', + 'include': r'{%\s*include\s+(v\d+\.\d+)/[^%]*%}', + 'image_ref': r"{{\s*'images/(v\d+\.\d+)/[^']*'\s*\|\s*relative_url\s*}}", + 'markdown_relative': r'\[[^\]]+\]\((?:\.\./)+(v\d+\.\d+)/[^\)]+\)', + 'markdown_absolute': r'\[[^\]]+\]\(/docs/(v\d+\.\d+)/[^\)]+\)', + 'html_link': r']*href=["\']/?(?:docs/)?(v\d+\.\d+)/[^"\']+["\'][^>]*>', + } + + # Patterns that are allowed (using dynamic version variables) + ALLOWED_PATTERNS = [ + r'page\.version\.version', + r'site\.versions(?:\.|\[)', + r'include\.version', + r'page\.release_info', + ] + + def __init__(self, verbose: bool = False): + """Initialize the linter.""" + self.verbose = verbose + self.violations = [] + + def extract_file_version(self, filepath: Path) -> Optional[str]: + """ + Extract the version from a file path. + + Args: + filepath: Path to the file + + Returns: + Version string (e.g., 'v25.4') or None if no version found + """ + parts = filepath.parts + for part in parts: + if re.match(r'^v\d+\.\d+$', part): + return part + return None + + def is_allowed_pattern(self, text: str) -> bool: + """ + Check if the text contains an allowed dynamic version pattern. + + Args: + text: The text to check + + Returns: + True if the pattern is allowed (uses dynamic variables) + """ + for pattern in self.ALLOWED_PATTERNS: + if re.search(pattern, text): + return True + return False + + def generate_fix(self, pattern_type: str, original: str, source_version: str) -> str: + """ + Generate a fix suggestion for the violation. + + Args: + pattern_type: Type of the pattern that was matched + original: The original problematic text + source_version: The version the file belongs to + + Returns: + Suggested fix for the violation + """ + if pattern_type == 'liquid_link': + return re.sub(r'v\d+\.\d+', '{{ page.version.version }}', original) + elif pattern_type in ['include_cached', 'include']: + # For includes, we need to be careful about the path structure + return re.sub(r'v\d+\.\d+', '{{ page.version.version }}', original) + elif pattern_type == 'image_ref': + # Fix image references to use dynamic version + return re.sub(r'(images/)(v\d+\.\d+)(/)', r'\1{{ page.version.version }}\3', original) + elif pattern_type in ['markdown_relative', 'markdown_absolute']: + # For markdown links, suggest using Jekyll link syntax + link_match = re.search(r'\[([^\]]+)\]\([^\)]+\)', original) + if link_match: + link_text = link_match.group(1) + # Extract the full path after the version token + path_match = re.search(r'v\d+\.\d+/([^\)]+)', original) + if path_match: + page_path = path_match.group(1) # e.g. 'admin/restore.md' + return f'[{link_text}]({{% link {{{{ page.version.version }}}}/{page_path} %}})' + return "Use {% link {{ page.version.version }}/page.md %} syntax" + else: + return "Use appropriate version variable or relative path within same version" + + def find_violations(self, filepath: Path, content: str) -> List[Dict]: + """ + Find all cross-version link violations in a file. + + Args: + filepath: Path to the file + content: File content + + Returns: + List of violation dictionaries + """ + source_version = self.extract_file_version(filepath) + if not source_version: + # File doesn't have a version in its path, skip checking + if self.verbose: + print(f"Skipping {filepath}: No version in path") + return [] + + violations = [] + lines = content.split('\n') + + for pattern_name, pattern in self.PATTERNS.items(): + for match in re.finditer(pattern, content, re.MULTILINE | re.IGNORECASE): + # Check if this is an allowed pattern (uses dynamic variables) + if self.is_allowed_pattern(match.group(0)): + continue + + # Extract target version from the match + target_version = match.group(1) + + # Check if it's a cross-version link + if target_version != source_version: + # Calculate line number + line_num = content[:match.start()].count('\n') + 1 + + # Get the actual line content for context + line_content = lines[line_num - 1].strip() if line_num <= len(lines) else "" + + # Generate fix suggestion + fix = self.generate_fix(pattern_name, match.group(0), source_version) + + violations.append({ + 'file': str(filepath), + 'line': line_num, + 'source_version': source_version, + 'target_version': target_version, + 'link': match.group(0), + 'line_content': line_content, + 'fix': fix, + 'type': pattern_name + }) + + return violations + + def check_file(self, filepath: str) -> List[Dict]: + """ + Check a single file for cross-version link violations. + + Args: + filepath: Path to the file to check + + Returns: + List of violations found + """ + path = Path(filepath) + + # Skip non-markdown files + if not path.suffix in ['.md', '.markdown']: + if self.verbose: + print(f"Skipping non-markdown file: {filepath}") + return [] + + # Skip if file doesn't exist + if not path.exists(): + if self.verbose: + print(f"File not found: {filepath}") + return [] + + try: + with open(path, 'r', encoding='utf-8') as f: + content = f.read() + except Exception as e: + print(f"Error reading {filepath}: {e}", file=sys.stderr) + return [] + + violations = self.find_violations(path, content) + self.violations.extend(violations) + return violations + + def format_violations_for_github(self) -> str: + """ + Format violations as a GitHub comment. + + Returns: + Formatted markdown string for GitHub comment + """ + if not self.violations: + return "✅ **Cross-Version Link Check Passed**\n\nNo cross-version links detected." + + # Group violations by file + violations_by_file = {} + for v in self.violations: + file_path = v['file'] + if file_path not in violations_by_file: + violations_by_file[file_path] = [] + violations_by_file[file_path].append(v) + + # Build the comment + lines = [ + "❌ **Cross-Version Link Check Failed**", + "", + f"Found {len(self.violations)} cross-version link violation{'s' if len(self.violations) > 1 else ''} that must be fixed:", + "" + ] + + for file_path, file_violations in violations_by_file.items(): + lines.append("---") + lines.append("") + lines.append(f"### File: `{file_path}`") + lines.append("") + + for v in file_violations: + lines.append(f"**Line {v['line']}**: Link from {v['source_version']} to {v['target_version']} detected") + lines.append(f"```") + lines.append(f"{v['link']}") + lines.append(f"```") + lines.append(f"**Suggested fix:**") + lines.append(f"```") + lines.append(f"{v['fix']}") + lines.append(f"```") + lines.append("") + + lines.extend([ + "---", + "", + "**Action Required**: Please update all cross-version links to use version variables or ensure links stay within the same version.", + "", + "For more information about proper link formatting, see the [CockroachDB Docs Style Guide](https://github.com/cockroachdb/docs/blob/main/StyleGuide.md#links)." + ]) + + return "\n".join(lines) + + def print_violations(self): + """Print violations to stderr in a human-readable format.""" + if not self.violations: + return + + print("\n❌ Cross-version link violations found:\n", file=sys.stderr) + + for v in self.violations: + print(f"File: {v['file']}", file=sys.stderr) + print(f" Line {v['line']}: Link from {v['source_version']} to {v['target_version']}", file=sys.stderr) + print(f" Problematic link: {v['link']}", file=sys.stderr) + print(f" Fix: {v['fix']}", file=sys.stderr) + print(file=sys.stderr) + + +def main(): + """Main entry point for the linter.""" + if len(sys.argv) < 2: + print("Usage: python cross_version_link_linter.py [file2] ...", file=sys.stderr) + sys.exit(1) + + # Parse command line arguments + verbose = os.environ.get('VERBOSE', '').lower() in ['true', '1', 'yes'] + + # Get list of files to check + # Files can be passed as separate arguments or as a single space-separated string + files = [] + for arg in sys.argv[1:]: + if ' ' in arg: + # Split space-separated list + files.extend(arg.split()) + else: + files.append(arg) + + # Initialize linter + linter = CrossVersionLinkLinter(verbose=verbose) + + # Check each file + for filepath in files: + if verbose: + print(f"Checking {filepath}...") + linter.check_file(filepath) + + # Print violations to stderr + linter.print_violations() + + # If running in GitHub Actions, write comment to file + if os.environ.get('GITHUB_ACTIONS'): + comment = linter.format_violations_for_github() + comment_file = os.environ.get('GITHUB_STEP_SUMMARY') + if comment_file: + with open(comment_file, 'w') as f: + f.write(comment) + + # Also write to a file for the PR comment action + with open('pr-comment.md', 'w') as f: + f.write(comment) + + # Exit with appropriate code + if linter.violations: + print(f"\n❌ Found {len(linter.violations)} cross-version link violation(s)", file=sys.stderr) + sys.exit(1) + else: + print("✅ No cross-version link violations found") + sys.exit(0) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/.github/scripts/test_cross_version_linter.py b/.github/scripts/test_cross_version_linter.py new file mode 100644 index 00000000000..557b67e2442 --- /dev/null +++ b/.github/scripts/test_cross_version_linter.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +""" +test_cross_version_linter.py + +Unit tests for the cross-version link linter. +""" + +import unittest +import tempfile +import os +from pathlib import Path +import sys + +# Add the script directory to path to import the linter +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cross_version_link_linter import CrossVersionLinkLinter + + +class TestCrossVersionLinkLinter(unittest.TestCase): + """Test cases for the CrossVersionLinkLinter.""" + + def setUp(self): + """Set up test fixtures.""" + self.linter = CrossVersionLinkLinter(verbose=False) + + def test_extract_file_version(self): + """Test version extraction from file paths.""" + test_cases = [ + (Path('src/current/v25.4/backup.md'), 'v25.4'), + (Path('src/current/v26.1/restore.md'), 'v26.1'), + (Path('src/current/_includes/v23.1/header.md'), 'v23.1'), + (Path('src/current/index.md'), None), # No version + (Path('README.md'), None), # No version + ] + + for path, expected in test_cases: + result = self.linter.extract_file_version(path) + self.assertEqual(result, expected, f"Failed for path: {path}") + + def test_liquid_link_detection(self): + """Test detection of hard-coded liquid link tags.""" + content = """ + Here's a valid link: {% link {{ page.version.version }}/backup.md %} + Here's a problematic link: {% link v21.2/take-full-and-incremental-backups.md %} + Another bad one: {% link v23.1/restore.md %}#section + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find 2 violations (v21.2 and v23.1) + self.assertEqual(len(violations), 2) + self.assertEqual(violations[0]['target_version'], 'v21.2') + self.assertEqual(violations[1]['target_version'], 'v23.1') + + def test_include_detection(self): + """Test detection of include statements with wrong versions.""" + content = """ + Good include: {% include_cached {{ page.version.version }}/prod-deployment/provision.md %} + Bad include: {% include_cached v22.1/prod-deployment/provision-storage.md %} + Another bad: {% include v20.1/sidebar.md %} + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find 2 violations + self.assertEqual(len(violations), 2) + self.assertEqual(violations[0]['target_version'], 'v22.1') + self.assertEqual(violations[0]['type'], 'include_cached') + self.assertEqual(violations[1]['target_version'], 'v20.1') + self.assertEqual(violations[1]['type'], 'include') + + def test_image_ref_detection(self): + """Test detection of image references with wrong versions.""" + content = """ + Good image: {{ 'images/{{ page.version.version }}/diagram.png' | relative_url }} + Bad image: {{ 'images/v22.2/hasura-ca-cert.png' | relative_url }} + Another bad: {{ 'images/v19.1/old-diagram.png' | relative_url }} + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find 2 violations + self.assertEqual(len(violations), 2) + self.assertEqual(violations[0]['target_version'], 'v22.2') + self.assertEqual(violations[1]['target_version'], 'v19.1') + + def test_markdown_links_detection(self): + """Test detection of markdown links with cross-version references.""" + content = """ + [Link to same version](backup.md) + [Cross-version link](../v23.1/restore.md) + [Another cross-version](/docs/v20.1/migration.md) + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find 2 violations + self.assertEqual(len(violations), 2) + self.assertEqual(violations[0]['target_version'], 'v23.1') + self.assertEqual(violations[0]['type'], 'markdown_relative') + self.assertEqual(violations[1]['target_version'], 'v20.1') + self.assertEqual(violations[1]['type'], 'markdown_absolute') + + def test_allowed_patterns(self): + """Test that allowed patterns are not flagged.""" + content = """ + {% link {{ page.version.version }}/backup.md %} + {% link {{ site.versions["stable"] }}/restore.md %} + {% include_cached {{ page.version.version }}/header.md %} + {{ 'images/{{ page.version.version }}/diagram.png' | relative_url }} + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find no violations + self.assertEqual(len(violations), 0) + + def test_same_version_links(self): + """Test that links within the same version are not flagged.""" + content = """ + {% link v26.1/backup.md %} + {% include_cached v26.1/header.md %} + {{ 'images/v26.1/diagram.png' | relative_url }} + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find no violations (all links are to the same version) + self.assertEqual(len(violations), 0) + + def test_fix_generation(self): + """Test that fix suggestions are generated correctly.""" + test_cases = [ + ('liquid_link', '{% link v21.2/backup.md %}', '{% link {{ page.version.version }}/backup.md %}'), + ('include_cached', '{% include_cached v22.1/header.md %}', '{% include_cached {{ page.version.version }}/header.md %}'), + ('include', '{% include v20.1/sidebar.md %}', '{% include {{ page.version.version }}/sidebar.md %}'), + ('image_ref', "{{ 'images/v19.1/diagram.png' | relative_url }}", "{{ 'images/{{ page.version.version }}/diagram.png' | relative_url }}"), + ] + + for pattern_type, original, expected_fix in test_cases: + fix = self.linter.generate_fix(pattern_type, original, 'v26.1') + self.assertIn('{{ page.version.version }}', fix, f"Fix for {pattern_type} doesn't contain version variable") + + def test_html_link_detection(self): + """Test detection of HTML anchor tags with cross-version references.""" + content = """ + Old backup docs + Restore guide + Migration + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find 2 violations (v20.1 and v21.2) + self.assertEqual(len(violations), 2) + self.assertEqual(violations[0]['target_version'], 'v20.1') + self.assertEqual(violations[1]['target_version'], 'v21.2') + + def test_line_number_accuracy(self): + """Test that line numbers are reported correctly.""" + content = """Line 1 +Line 2 +Line 3 with {% link v21.2/backup.md %} +Line 4 +Line 5 with {% include v20.1/header.md %} +Line 6""" + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + self.assertEqual(len(violations), 2) + self.assertEqual(violations[0]['line'], 3) + self.assertEqual(violations[1]['line'], 5) + + def test_file_without_version(self): + """Test that files without version in path are skipped.""" + content = """ + {% link v21.2/backup.md %} + {% include v20.1/header.md %} + """ + + violations = self.linter.find_violations( + Path('src/current/README.md'), # No version in path + content + ) + + # Should find no violations (file skipped) + self.assertEqual(len(violations), 0) + + def test_case_insensitive_detection(self): + """Test that patterns are detected case-insensitively.""" + content = """ + {% LINK v21.2/backup.md %} + {% Include_Cached v20.1/header.md %} + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + # Should find 2 violations despite different casing + self.assertEqual(len(violations), 2) + + def test_complex_real_world_example(self): + """Test with a complex real-world example.""" + content = """--- +title: Backup and Restore +--- + +# Backup and Restore + +For information about backups in v21.2, see {% link v21.2/take-full-and-incremental-backups.md %}#backup-collections. + +## Configuration + +Make sure to provision storage as described in {% include_cached v22.1/prod-deployment/provision-storage.md %}. + +## UI Screenshots + +Here's the backup UI: + +Backup UI + +## Related Documentation + +- [Restore Guide](../v20.1/restore.md) +- [Migration from v19.1](/docs/v19.1/migration.html) +- Legacy Documentation +""" + + violations = self.linter.find_violations( + Path('src/current/v26.1/backup.md'), + content + ) + + # Should find 6 violations + self.assertEqual(len(violations), 6) + + # Verify each violation + versions_found = [v['target_version'] for v in violations] + self.assertIn('v21.2', versions_found) + self.assertIn('v22.1', versions_found) + self.assertIn('v22.2', versions_found) + self.assertIn('v20.1', versions_found) + self.assertIn('v19.1', versions_found) + self.assertIn('v18.2', versions_found) + + def test_github_comment_formatting(self): + """Test that GitHub comment is formatted correctly.""" + # Create some violations + content = """ + {% link v21.2/backup.md %} + {% include v20.1/header.md %} + """ + + violations = self.linter.find_violations( + Path('src/current/v26.1/test.md'), + content + ) + + self.linter.violations = violations + comment = self.linter.format_violations_for_github() + + # Check that comment contains expected elements + self.assertIn('❌ **Cross-Version Link Check Failed**', comment) + self.assertIn('Found 2 cross-version link violations', comment) + self.assertIn('v21.2', comment) + self.assertIn('v20.1', comment) + self.assertIn('Suggested fix:', comment) + self.assertIn('{{ page.version.version }}', comment) + + def test_no_violations_message(self): + """Test message when no violations are found.""" + self.linter.violations = [] + comment = self.linter.format_violations_for_github() + self.assertIn('✅ **Cross-Version Link Check Passed**', comment) + + def test_markdown_links_multi_level_parent(self): + """Test detection of markdown links with multiple ../ levels.""" + content = "[Multi](../../v23.1/restore.md)" + violations = self.linter.find_violations(Path('src/current/v26.1/test.md'), content) + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0]['target_version'], 'v23.1') + + def test_markdown_links_three_level_parent(self): + """Test detection of markdown links with three ../ levels.""" + content = "[Deep](../../../v22.2/backup.md)" + violations = self.linter.find_violations(Path('src/current/v26.1/deep/nested/test.md'), content) + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0]['target_version'], 'v22.2') + + def test_site_versions_bracket_notation_allowed(self): + """Test that site.versions bracket notation is not flagged.""" + content = '{% link {{ site.versions["stable"] }}/backup.md %}' + violations = self.linter.find_violations(Path('src/current/v26.1/test.md'), content) + self.assertEqual(len(violations), 0) + + def test_site_versions_dot_notation_allowed(self): + """Test that site.versions dot notation is not flagged.""" + content = '{% link {{ site.versions.stable }}/backup.md %}' + violations = self.linter.find_violations(Path('src/current/v26.1/test.md'), content) + self.assertEqual(len(violations), 0) + + def test_image_ref_fix_generation(self): + """Test that image_ref fix generation works correctly.""" + original = "{{ 'images/v22.2/diagram.png' | relative_url }}" + fix = self.linter.generate_fix('image_ref', original, 'v26.1') + self.assertIn('images/{{ page.version.version }}/', fix) + self.assertNotIn('v22.2', fix) + + def test_markdown_fix_nested_paths(self): + """Test that markdown fix preserves nested directory paths.""" + original = "[Restore](../v23.1/admin/restore.md)" + fix = self.linter.generate_fix('markdown_relative', original, 'v26.1') + self.assertIn('admin/restore.md', fix) + self.assertIn('{{ page.version.version }}', fix) + + def test_markdown_fix_complex_filename(self): + """Test that markdown fix handles filenames with dots.""" + original = "[Guide](../v23.1/backup-and-restore.overview.md)" + fix = self.linter.generate_fix('markdown_relative', original, 'v26.1') + self.assertIn('backup-and-restore.overview.md', fix) + self.assertIn('{{ page.version.version }}', fix) + + def test_markdown_fix_deeply_nested_path(self): + """Test that markdown fix handles deeply nested paths.""" + original = "[Config](/docs/v20.1/cockroachcloud/production/settings.md)" + fix = self.linter.generate_fix('markdown_absolute', original, 'v26.1') + self.assertIn('cockroachcloud/production/settings.md', fix) + self.assertIn('{{ page.version.version }}', fix) + + +class TestIntegration(unittest.TestCase): + """Integration tests for the linter.""" + + def test_check_file_integration(self): + """Test checking an actual file end-to-end.""" + # Create a temporary file with violations + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: + # Use a path that contains a version + test_path = Path(f.name) + # We need to mock the path to include a version + mock_path = Path('src/current/v26.1/test.md') + + f.write(""" +# Test Document + +Here's a cross-version link: {% link v21.2/backup.md %} +And an include: {% include v20.1/header.md %} +And an image: {{ 'images/v19.1/diagram.png' | relative_url }} +""") + f.flush() + + linter = CrossVersionLinkLinter() + + # Read the content + with open(test_path, 'r') as content_file: + content = content_file.read() + + # Find violations using the mock path + violations = linter.find_violations(mock_path, content) + + # Should find 3 violations + self.assertEqual(len(violations), 3) + + # Clean up + os.unlink(f.name) + + +if __name__ == '__main__': + # Run tests with verbose output + unittest.main(verbosity=2) \ No newline at end of file diff --git a/.github/workflows/cross-version-links.yml b/.github/workflows/cross-version-links.yml new file mode 100644 index 00000000000..06d68d552a7 --- /dev/null +++ b/.github/workflows/cross-version-links.yml @@ -0,0 +1,132 @@ +name: Cross-Version Link Check + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/current/**/*.md' + - 'src/current/_includes/**/*.md' + +jobs: + check-cross-version-links: + name: Check for cross-version links + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 # Need full history to get all changed files + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v35 + with: + files: | + src/current/**/*.md + src/current/_includes/**/*.md + separator: ' ' + + - name: Debug - List changed files + if: steps.changed-files.outputs.any_changed == 'true' + run: | + echo "Changed files:" + echo "${{ steps.changed-files.outputs.all_changed_files }}" + + - name: Run cross-version link linter + if: steps.changed-files.outputs.any_changed == 'true' + id: lint + run: | + echo "Running cross-version link linter on changed files..." + python .github/scripts/cross_version_link_linter.py ${{ steps.changed-files.outputs.all_changed_files }} + continue-on-error: true + + - name: Post PR comment with violations + if: steps.changed-files.outputs.any_changed == 'true' && steps.lint.outcome == 'failure' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + // Read the comment from the file created by the linter + let comment = ''; + try { + comment = fs.readFileSync('pr-comment.md', 'utf8'); + } catch (error) { + comment = '❌ **Cross-Version Link Check Failed**\n\nCross-version link violations were detected, but the detailed report could not be generated.'; + } + + // Check if we've already commented on this PR + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Cross-Version Link Check') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + + - name: Post success comment if previously failed + if: steps.changed-files.outputs.any_changed == 'true' && steps.lint.outcome == 'success' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Check if we've previously commented with failures + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Cross-Version Link Check Failed') + ); + + if (botComment) { + // Update to success message + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: '✅ **Cross-Version Link Check Passed**\n\nAll cross-version link issues have been resolved. Good job!' + }); + } + + - name: Fail if violations found + if: steps.changed-files.outputs.any_changed == 'true' && steps.lint.outcome == 'failure' + run: | + echo "❌ Cross-version link violations were found. Please fix them before merging." + exit 1 + + - name: Success message + if: steps.changed-files.outputs.any_changed == 'true' && steps.lint.outcome == 'success' + run: | + echo "✅ No cross-version link violations found!" \ No newline at end of file