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 textwrap import dedent
44from typing import Final
45from urllib.parse import quote
46
47from sphinx.application import Sphinx
48from sphinx.util.i18n import format_date
49
50sys.path.insert(0, str(Path(__file__).parents[3] / "scripts"))
51
52from get_maintainer import Maintainers
53
54ZEPHYR_BASE : Final[str] = Path(__file__).parents[3]
55MAINTAINERS : Final[Maintainers] = Maintainers(filename=f"{ZEPHYR_BASE}/MAINTAINERS.yml")
56
57
58__version__ = "0.1.0"
59
60
61def get_page_prefix(app: Sphinx, pagename: str) -> str:
62    """Return the prefix that needs to be added to the page path to get its location in the
63    repository.
64
65    If pagename refers to a page that is automatically generated by Sphinx or if it matches one of
66    the patterns in ``gh_link_exclude`` configuration option, return None.
67
68    Args:
69        app: Sphinx instance.
70        pagename: Page name (path).
71
72    Returns:
73        Prefix if applicable, None otherwise.
74    """
75
76    if not os.path.isfile(app.env.doc2path(pagename)):
77        return None
78
79    for exclude in app.config.gh_link_exclude:
80        if re.match(exclude, pagename):
81            return None
82
83    found_prefix = ""
84    for pattern, prefix in app.config.gh_link_prefixes.items():
85        if re.match(pattern, pagename):
86            found_prefix = prefix
87            break
88
89    return found_prefix
90
91
92def gh_link_get_url(app: Sphinx, pagename: str, mode: str = "blob") -> str | None:
93    """Obtain GitHub URL for the given page.
94
95    Args:
96        app: Sphinx instance.
97        mode: Typically "edit", or "blob".
98        pagename: Page name (path).
99
100    Returns:
101        GitHub URL if applicable, None otherwise.
102    """
103
104    page_prefix = get_page_prefix(app, pagename)
105    if page_prefix is None:
106        return None
107
108    return "/".join(
109        [
110            app.config.gh_link_base_url,
111            mode,
112            app.config.gh_link_version,
113            page_prefix,
114            str(app.env.doc2path(pagename, False)),
115        ]
116    )
117
118
119def gh_link_get_open_issue_url(app: Sphinx, pagename: str, sha1: str) -> str | None:
120    """Link to open a new Github issue regarding "pagename" with title, body, and
121    labels already pre-filled with useful information.
122
123    Args:
124        app: Sphinx instance.
125        pagename: Page name (path).
126
127    Returns:
128        URL to open a new issue if applicable, None otherwise.
129    """
130
131    page_prefix = get_page_prefix(app, pagename)
132    if page_prefix is None:
133        return None
134
135    rel_path = os.path.join(
136        os.path.relpath(ZEPHYR_BASE),
137        page_prefix,
138        app.env.doc2path(pagename, False),
139    )
140
141    title = quote(f"doc: Documentation issue in '{pagename}'")
142    labels = quote("area: Documentation")
143    areas = MAINTAINERS.path2areas(rel_path)
144    if areas:
145        labels += "," + ",".join([label for area in areas for label in area.labels])
146    body = quote(
147        dedent(
148            f"""\
149    **Describe the bug**
150
151    << Please describe the issue here >>
152    << You may also want to update the automatically generated issue title above. >>
153
154    **Environment**
155
156    * Page: `{pagename}`
157    * Version: {app.config.gh_link_version}
158    * SHA-1: {sha1}
159    """
160        )
161    )
162
163    return f"{app.config.gh_link_base_url}/issues/new?title={title}&labels={labels}&body={body}"
164
165
166def git_info_filter(app: Sphinx, pagename) -> tuple[str, str] | None:
167    """Return a tuple with the date and SHA1 of the last commit made to a page.
168
169    Arguments:
170        app {Sphinx} -- Sphinx application object
171        pagename {str} -- Page name
172
173    Returns:
174        Optional[Tuple[str, str]] -- Tuple with the date and SHA1 of the last commit made to the
175        page, or None if the page is not in the repo (generated file, or manually authored file not
176        yet tracked by git).
177    """
178
179    page_prefix = get_page_prefix(app, pagename)
180    if page_prefix is None:
181        return None
182
183    orig_path = os.path.join(
184        ZEPHYR_BASE,
185        page_prefix,
186        app.env.doc2path(pagename, False),
187    )
188
189    # Check if the file is tracked by git
190    try:
191        subprocess.check_output(
192            ["git", "ls-files", "--error-unmatch", orig_path],
193            stderr=subprocess.STDOUT,
194        )
195    except subprocess.CalledProcessError:
196        return None
197
198    try:
199        date_and_sha1 = (
200            subprocess.check_output(
201                [
202                    "git",
203                    "log",
204                    "-1",
205                    "--format=%ad %H",
206                    "--date=unix",
207                    orig_path,
208                ],
209                stderr=subprocess.STDOUT,
210            )
211            .decode("utf-8")
212            .strip()
213        )
214        if not date_and_sha1: # added but not committed
215            return None
216        date, sha1 = date_and_sha1.split(" ", 1)
217        date_object = datetime.fromtimestamp(int(date))
218        last_update_fmt = app.config.html_last_updated_fmt
219        if last_update_fmt is not None:
220            date = format_date(last_update_fmt, date=date_object, language=app.config.language)
221
222        return (date, sha1)
223    except subprocess.CalledProcessError:
224        return None
225
226def add_jinja_filter(app: Sphinx):
227    if app.builder.format != "html":
228        return
229
230    app.builder.templates.environment.filters["gh_link_get_blob_url"] = partial(
231        gh_link_get_url, app, mode="blob"
232    )
233
234    app.builder.templates.environment.filters["gh_link_get_edit_url"] = partial(
235        gh_link_get_url, app, mode="edit"
236    )
237
238    app.builder.templates.environment.filters["gh_link_get_open_issue_url"] = partial(
239        gh_link_get_open_issue_url, app
240    )
241
242    app.builder.templates.environment.filters["git_info"] = partial(git_info_filter, app)
243
244
245def setup(app: Sphinx):
246    app.add_config_value("gh_link_version", "", "")
247    app.add_config_value("gh_link_base_url", "", "")
248    app.add_config_value("gh_link_prefixes", {}, "")
249    app.add_config_value("gh_link_exclude", [], "")
250
251    app.connect("builder-inited", add_jinja_filter)
252
253    return {
254        "version": __version__,
255        "parallel_read_safe": True,
256        "parallel_write_safe": True,
257    }
258