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