Source code for repomatic.broken_links

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Broken links detection and reporting.

Combines Lychee and Sphinx linkcheck results into a single "Broken links"
GitHub issue. Sphinx linkcheck parsing detects broken auto-generated links
(intersphinx, autodoc, type annotations) that Lychee cannot see because they
only exist in the rendered HTML output.

Issue lifecycle management is delegated to {mod}`~repomatic.github.issue`.
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from itertools import groupby
from operator import attrgetter
from pathlib import Path

from click_extra import TableFormat, render_table

from .github.issue import manage_issue_lifecycle
from .github.pr_body import render_template, sanitize_markdown_mentions
from .metadata import Metadata

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterable


ISSUE_TITLE = "Broken links"
"""Issue title used for the combined broken links report."""

LYCHEE_BROKEN_LINKS_EXIT = 2
"""The one lychee exit code that reports on the links rather than on the run.

Lychee exits 0 on success, 1 on an unexpected failure, 2 when it found broken
links, and 3 on a config error. Only 2 is a verdict about the links; 1 and 3 say
the run itself did not complete, so neither "broken links found" nor "no broken
links" can be claimed from them.
"""

LYCHEE_DEFAULT_BODY = Path("./lychee/out.md")
"""Default output path used by the lychee-action GitHub Action."""

SPHINX_DEFAULT_OUTPUT = Path("./docs/_linkcheck/output.json")
"""Default Sphinx linkcheck output path produced by the `docs.yaml` workflow."""


# ---------------------------------------------------------------------------
# Sphinx linkcheck parsing, filtering, and report generation.
# ---------------------------------------------------------------------------


[docs] @dataclass(frozen=True) class LinkcheckResult: """A single result entry from Sphinx linkcheck `output.json`. Each line in the JSON-lines file corresponds to one checked URI. """ filename: str lineno: int status: str code: int uri: str info: str
[docs] def parse_output_json(output_json: Path) -> list[LinkcheckResult]: """Parse the Sphinx linkcheck `output.json` file. The file uses JSON-lines format: one JSON object per line. Blank lines are skipped. :param output_json: Path to the `output.json` file. :return: List of parsed linkcheck results. """ results: list[LinkcheckResult] = [] content = output_json.read_text(encoding="UTF-8") for line in content.splitlines(): stripped = line.strip() if not stripped: continue entry = json.loads(stripped) results.append( LinkcheckResult( filename=entry["filename"], lineno=entry["lineno"], status=entry["status"], code=entry["code"], uri=entry["uri"], info=entry.get("info", ""), ) ) logging.info(f"Parsed {len(results)} linkcheck entries from {output_json}") return results
[docs] def filter_broken(results: Iterable[LinkcheckResult]) -> list[LinkcheckResult]: """Filter results to only broken and timed-out links. :param results: Iterable of linkcheck results. :return: List of results with `status` of `"broken"` or `"timeout"`. """ broken = [r for r in results if r.status in ("broken", "timeout")] logging.info(f"Found {len(broken)} broken/timed-out links") return broken
[docs] def generate_markdown_report( broken: list[LinkcheckResult], source_url: str | None = None, ) -> str: """Generate a Markdown report of broken links grouped by source file. The report starts with H2 file headings, suitable for embedding as a section in the combined broken links issue body. :param broken: List of broken linkcheck results. :param source_url: Base URL for linking filenames and line numbers. When provided, file headers become clickable links and line numbers deep-link to the specific line. :return: Markdown-formatted report string. """ if not broken: return "" lines: list[str] = [] # Group by filename, sorted alphabetically. sorted_results = sorted(broken, key=attrgetter("filename", "lineno")) for filename, group_iter in groupby(sorted_results, key=attrgetter("filename")): group_list = list(group_iter) if source_url: file_url = f"{source_url}/{filename}" lines.append(f"## [`{filename}`]({file_url})\n") else: lines.append(f"## `{filename}`\n") table_rows = [] for result in group_list: # Escape pipe characters in info to avoid breaking the table. escaped_info = result.info.replace("|", "\\|") if source_url: file_url = f"{source_url}/{result.filename}" line_cell = f"[{result.lineno}]({file_url}?plain=1#L{result.lineno})" else: line_cell = str(result.lineno) table_rows.append([line_cell, result.uri, escaped_info]) lines.append( render_table( table_rows, headers=["Line", "URI", "Info"], table_format=TableFormat.GITHUB, colalign=("right", "left", "left"), ) ) lines.append("") return "\n".join(lines)
# --------------------------------------------------------------------------- # Label selection. # ---------------------------------------------------------------------------
[docs] def get_label(repo_name: str) -> str: """Return the appropriate label based on repository name. :param repo_name: The repository name. :return: `"🩹 fix link"` for `awesome-*` repos, else `"πŸ“š documentation"`. """ if repo_name.startswith("awesome-"): return "🩹 fix link" return "πŸ“š documentation"
# --------------------------------------------------------------------------- # Combined broken links issue (Lychee + Sphinx linkcheck). # ---------------------------------------------------------------------------