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        date, sha1 = date_and_sha1.split(" ", 1)
215        date_object = datetime.fromtimestamp(int(date))
216        last_update_fmt = app.config.html_last_updated_fmt
217        if last_update_fmt is not None:
218            date = format_date(last_update_fmt, date=date_object, language=app.config.language)
219
220        return (date, sha1)
221    except subprocess.CalledProcessError:
222        return None
223
224def add_jinja_filter(app: Sphinx):
225    if app.builder.format != "html":
226        return
227
228    app.builder.templates.environment.filters["gh_link_get_blob_url"] = partial(
229        gh_link_get_url, app, mode="blob"
230    )
231
232    app.builder.templates.environment.filters["gh_link_get_edit_url"] = partial(
233        gh_link_get_url, app, mode="edit"
234    )
235
236    app.builder.templates.environment.filters["gh_link_get_open_issue_url"] = partial(
237        gh_link_get_open_issue_url, app
238    )
239
240    app.builder.templates.environment.filters["git_info"] = partial(git_info_filter, app)
241
242
243def setup(app: Sphinx):
244    app.add_config_value("gh_link_version", "", "")
245    app.add_config_value("gh_link_base_url", "", "")
246    app.add_config_value("gh_link_prefixes", {}, "")
247    app.add_config_value("gh_link_exclude", [], "")
248
249    app.connect("builder-inited", add_jinja_filter)
250
251    return {
252        "version": __version__,
253        "parallel_read_safe": True,
254        "parallel_write_safe": True,
255    }
256