1"""
2Git/GitHub utilities for Sphinx
3###############################
4
5Copyright (c) 2021 Nordic Semiconductor ASA
6Copyright (c) 2023 The Linux Foundation
7
8SPDX-License-Identifier: Apache-2.0
9
10Introduction
11============
12
13This Sphinx extension can be used to obtain various Git and GitHub related metadata for a page.
14This is useful, for example, when adding features like "Open on GitHub" on top
15of pages, direct links to open a GitHub issue regarding a page, or date of the most recent commit
16to a page.
17
18The extension installs the following Jinja filter:
19
20* ``gh_link_get_blob_url``: Returns a URL to the source of a page on GitHub.
21* ``gh_link_get_edit_url``: Returns a URL to edit the given page on GitHub.
22* ``gh_link_get_open_issue_url``: Returns a URL to open a new issue regarding the given page.
23* ``git_info``: Returns the date and SHA1 of the last commit made to a page (if this page is
24    managed by Git).
25
26Configuration options
27=====================
28
29- ``gh_link_version``: GitHub version to use in the URL (e.g. "main")
30- ``gh_link_base_url``: Base URL used as a prefix for generated URLs.
31- ``gh_link_prefixes``: Mapping of pages (regex) <> GitHub prefix.
32- ``gh_link_exclude``: List of pages (regex) that will not report a URL. Useful
33  for, e.g., auto-generated pages not in Git.
34"""
35
36import os
37import re
38import subprocess
39import sys
40from datetime import datetime
41from functools import partial
42from pathlib import Path
43from typing import Final
44from urllib.parse import urlencode
45
46from sphinx.application import Sphinx
47from sphinx.util.i18n import format_date
48
49sys.path.insert(0, str(Path(__file__).parents[3] / "scripts"))
50
51from get_maintainer import Maintainers
52
53ZEPHYR_BASE: Final[str] = Path(__file__).parents[3]
54MAINTAINERS: Final[Maintainers] = Maintainers(filename=f"{ZEPHYR_BASE}/MAINTAINERS.yml")
55
56
57__version__ = "0.1.0"
58
59
60def get_page_prefix(app: Sphinx, pagename: str) -> str:
61    """Return the prefix that needs to be added to the page path to get its location in the
62    repository.
63
64    If pagename refers to a page that is automatically generated by Sphinx or if it matches one of
65    the patterns in ``gh_link_exclude`` configuration option, return None.
66
67    Args:
68        app: Sphinx instance.
69        pagename: Page name (path).
70
71    Returns:
72        Prefix if applicable, None otherwise.
73    """
74
75    if not os.path.isfile(app.env.doc2path(pagename)):
76        return None
77
78    for exclude in app.config.gh_link_exclude:
79        if re.match(exclude, pagename):
80            return None
81
82    found_prefix = ""
83    for pattern, prefix in app.config.gh_link_prefixes.items():
84        if re.match(pattern, pagename):
85            found_prefix = prefix
86            break
87
88    return found_prefix
89
90
91def gh_link_get_url(app: Sphinx, pagename: str, mode: str = "blob") -> str | None:
92    """Obtain GitHub URL for the given page.
93
94    Args:
95        app: Sphinx instance.
96        mode: Typically "edit", or "blob".
97        pagename: Page name (path).
98
99    Returns:
100        GitHub URL if applicable, None otherwise.
101    """
102
103    page_prefix = get_page_prefix(app, pagename)
104    if page_prefix is None:
105        return None
106
107    return "/".join(
108        [
109            app.config.gh_link_base_url,
110            mode,
111            app.config.gh_link_version,
112            page_prefix,
113            str(app.env.doc2path(pagename, False)),
114        ]
115    )
116
117
118def gh_link_get_open_issue_url(app: Sphinx, pagename: str, sha1: str) -> str | None:
119    """Link to open a new Github issue regarding "pagename" using the bug report template.
120
121    Args:
122        app: Sphinx instance.
123        pagename: Page name (path).
124        sha1: SHA1 of the last commit to the page.
125
126    Returns:
127        URL to open a new issue if applicable, None otherwise.
128    """
129
130    page_prefix = get_page_prefix(app, pagename)
131    if page_prefix is None:
132        return None
133
134    rel_path = os.path.join(
135        os.path.relpath(ZEPHYR_BASE),
136        page_prefix,
137        app.env.doc2path(pagename, False),
138    )
139
140    form_data = {
141        "template": "001_bug_report.yml",
142        "title": f"doc: Documentation issue in '{pagename}'",
143        "labels": ["bug", "area: Documentation"],
144        "env": (f"- Page: {pagename}\n- Version: {app.config.gh_link_version}\n- SHA-1: {sha1}"),
145        "context": (
146            "This issue was reported from the online documentation page using the "
147            "'Report an issue' button."
148        ),
149    }
150
151    areas = MAINTAINERS.path2areas(rel_path)
152    if areas:
153        for area in areas:
154            form_data["labels"].extend(area.labels)
155    form_data["labels"] = ",".join(form_data.get("labels", []))
156
157    base_url = f"{app.config.gh_link_base_url}/issues/new"
158    return f"{base_url}?{urlencode(form_data)}"
159
160
161def git_info_filter(app: Sphinx, pagename) -> tuple[str, str] | None:
162    """Return a tuple with the date and SHA1 of the last commit made to a page.
163
164    Arguments:
165        app {Sphinx} -- Sphinx application object
166        pagename {str} -- Page name
167
168    Returns:
169        Optional[Tuple[str, str]] -- Tuple with the date and SHA1 of the last commit made to the
170        page, or None if the page is not in the repo (generated file, or manually authored file not
171        yet tracked by git).
172    """
173
174    page_prefix = get_page_prefix(app, pagename)
175    if page_prefix is None:
176        return None
177
178    orig_path = os.path.join(
179        ZEPHYR_BASE,
180        page_prefix,
181        app.env.doc2path(pagename, False),
182    )
183
184    # Check if the file is tracked by git
185    try:
186        subprocess.check_output(
187            ["git", "ls-files", "--error-unmatch", orig_path],
188            stderr=subprocess.STDOUT,
189        )
190    except subprocess.CalledProcessError:
191        return None
192
193    try:
194        date_and_sha1 = (
195            subprocess.check_output(
196                [
197                    "git",
198                    "log",
199                    "-1",
200                    "--format=%ad %H",
201                    "--date=unix",
202                    orig_path,
203                ],
204                stderr=subprocess.STDOUT,
205            )
206            .decode("utf-8")
207            .strip()
208        )
209        if not date_and_sha1:  # added but not committed
210            return None
211        date, sha1 = date_and_sha1.split(" ", 1)
212        date_object = datetime.fromtimestamp(int(date))
213        last_update_fmt = app.config.html_last_updated_fmt
214        if last_update_fmt is not None:
215            date = format_date(last_update_fmt, date=date_object, language=app.config.language)
216
217        return (date, sha1)
218    except subprocess.CalledProcessError:
219        return None
220
221
222def add_jinja_filter(app: Sphinx):
223    if app.builder.format != "html":
224        return
225
226    app.builder.templates.environment.filters["gh_link_get_blob_url"] = partial(
227        gh_link_get_url, app, mode="blob"
228    )
229
230    app.builder.templates.environment.filters["gh_link_get_edit_url"] = partial(
231        gh_link_get_url, app, mode="edit"
232    )
233
234    app.builder.templates.environment.filters["gh_link_get_open_issue_url"] = partial(
235        gh_link_get_open_issue_url, app
236    )
237
238    app.builder.templates.environment.filters["git_info"] = partial(git_info_filter, app)
239
240
241def setup(app: Sphinx):
242    app.add_config_value("gh_link_version", "", "")
243    app.add_config_value("gh_link_base_url", "", "")
244    app.add_config_value("gh_link_prefixes", {}, "")
245    app.add_config_value("gh_link_exclude", [], "")
246
247    app.connect("builder-inited", add_jinja_filter)
248
249    return {
250        "version": __version__,
251        "parallel_read_safe": True,
252        "parallel_write_safe": True,
253    }
254