From fb5e1fc259f2b8ca2a19db69b08016e1e6f92c7c Mon Sep 17 00:00:00 2001
From: barredterra <14891507+barredterra@users.noreply.github.com>
Date: Wed, 12 Aug 2026 18:49:40 +0200
Subject: [PATCH 1/5] feat: add Edit on GitHub link to documentation pages
---
compendium/compendium/page/docs/docs.css | 6 +-
compendium/compendium/page/docs/docs.js | 34 +++--
compendium/docs.py | 129 +++++++++++++++++-
.../docs/de/compendium/docs/authoring.md | 11 ++
.../docs/en/compendium/docs/authoring.md | 11 ++
compendium/install.py | 12 ++
compendium/locale/de.po | 36 ++---
compendium/locale/main.pot | 36 ++---
compendium/patches.txt | 3 +-
.../create_compendium_contributor_role.py | 5 +
compendium/permissions.py | 2 +
compendium/tests/test_docs.py | 100 +++++++++++++-
cypress/integration/docs.js | 13 +-
pyproject.toml | 4 +
14 files changed, 353 insertions(+), 49 deletions(-)
create mode 100644 compendium/patches/create_compendium_contributor_role.py
diff --git a/compendium/compendium/page/docs/docs.css b/compendium/compendium/page/docs/docs.css
index b930d05..e6cdc7a 100644
--- a/compendium/compendium/page/docs/docs.css
+++ b/compendium/compendium/page/docs/docs.css
@@ -385,7 +385,7 @@
font-size: var(--text-sm);
}
-.docs-page-roles {
+.docs-page-footer {
margin-top: var(--margin-xl);
padding-top: var(--padding-md);
border-top: 1px solid var(--border-color);
@@ -393,6 +393,10 @@
font-size: var(--text-sm);
}
+.docs-page-footer .docs-edit-link {
+ color: var(--text-muted);
+}
+
@media (min-width: 1200px) {
.docs-browser.has-toc {
display: grid;
diff --git a/compendium/compendium/page/docs/docs.js b/compendium/compendium/page/docs/docs.js
index 40209a4..a12d7fa 100644
--- a/compendium/compendium/page/docs/docs.js
+++ b/compendium/compendium/page/docs/docs.js
@@ -410,7 +410,7 @@ frappe.ui.DocsBrowser = class DocsBrowser {
this.$content.toggleClass("has-toc", Boolean(toc_html));
this.render_heading_anchors();
this.render_fallback_notice(doc);
- this.render_roles(doc.roles);
+ this.render_page_footer(doc);
// Mermaid replaces fences with SVG asynchronously and shifts later headings;
// fragment scrolls wait on layout_ready so they use the final layout.
this.layout_ready = this.render_mermaid();
@@ -533,17 +533,33 @@ frappe.ui.DocsBrowser = class DocsBrowser {
).prependTo(this.$reading);
}
- render_roles(roles) {
- if (!roles?.length) {
+ render_page_footer(doc) {
+ const roles = doc.roles || [];
+ const parts = [];
+
+ if (roles.length) {
+ const labels = roles.map((role) => frappe.utils.escape_html(role)).join(", ");
+ parts.push(
+ roles.length === 1
+ ? __("You can see this page because you have the role {0}.", [labels])
+ : __("You can see this page because you have the roles {0}.", [labels])
+ );
+ }
+
+ if (doc.edit_url) {
+ const label = frappe.utils.escape_html(__("Edit on GitHub"));
+ parts.push(
+ `${label}`
+ );
+ }
+
+ if (!parts.length) {
return;
}
- const labels = roles.map((role) => frappe.utils.escape_html(role)).join(", ");
- const message =
- roles.length === 1
- ? __("You can see this page because you have the role {0}.", [labels])
- : __("You can see this page because you have the roles {0}.", [labels]);
- this.$reading.append(``);
+ this.$reading.append(``);
}
render_mermaid() {
diff --git a/compendium/docs.py b/compendium/docs.py
index 101af4f..304d940 100644
--- a/compendium/docs.py
+++ b/compendium/docs.py
@@ -4,15 +4,19 @@
import importlib.util
import os
import re
+import subprocess
from collections import Counter
-from urllib.parse import urlencode
+from urllib.parse import quote, urlencode
import frappe
from frappe import _
from frappe.translate import get_parent_language
from frappe.utils import cint, get_url, has_common, sanitize_html
+from frappe.utils.change_log import parse_github_url
from frappe.website.utils import extract_title, get_frontmatter
+from compendium.permissions import CONTRIBUTOR_ROLE
+
DOCS_FOLDER = "docs"
DEFAULT_LANG = "en"
DEFAULT_ROLE = "Desk User"
@@ -108,6 +112,7 @@ def build_page_payload(page, locale):
"content": content,
"toc_html": render_toc(page.body),
"roles": [_(role) for role in matching_roles],
+ "edit_url": get_edit_url(page),
}
@@ -536,6 +541,128 @@ def is_permitted(page):
return has_common(get_user_roles(), page.roles)
+def can_edit_on_github():
+ """Only contributors see the Edit on GitHub control."""
+ if frappe.session.user == "Administrator":
+ return True
+
+ return CONTRIBUTOR_ROLE in get_user_roles()
+
+
+def get_edit_url(page):
+ """GitHub edit URL for the Markdown file currently rendered, or None."""
+ if not can_edit_on_github():
+ return None
+
+ repository = get_app_repository_url(page.app)
+ if not repository:
+ return None
+
+ try:
+ owner, repo = parse_github_url(repository)
+ except ValueError:
+ return None
+
+ if not owner or not repo:
+ return None
+
+ branch = get_app_git_branch(page.app)
+ if not branch or branch == "HEAD":
+ return None
+
+ relative_path = get_repo_relative_path(page)
+ if not relative_path:
+ return None
+
+ # Branch names may contain slashes (e.g. feat/foo); encode so GitHub can
+ # tell the ref apart from the file path.
+ return f"https://github.com/{owner}/{repo}/edit/{quote(branch, safe='')}/{relative_path}"
+
+
+@frappe.request_cache
+def get_app_repository_url(app):
+ """Repository URL from the app's pyproject.toml `[project.urls]` Repository key."""
+ from tomli import load
+
+ pyproject_path = os.path.join(os.path.dirname(get_app_path(app)), "pyproject.toml")
+ if not os.path.isfile(pyproject_path):
+ return None
+
+ with open(pyproject_path, "rb") as f:
+ data = load(f)
+
+ url = (data.get("project") or {}).get("urls", {}).get("Repository")
+ return url.rstrip("/") if url else None
+
+
+def get_app_git_branch(app):
+ """Git branch to use in GitHub links for this app.
+
+ Prefers a ref that exists on the remote so local-only feature branches do not
+ produce 404s. Order: local branch if on origin → upstream → origin default → local.
+ """
+ repo_root = os.path.dirname(get_app_path(app))
+ local = _git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
+ if local and local != "HEAD" and _git_ref_exists(repo_root, f"refs/remotes/origin/{local}"):
+ return local
+
+ upstream = _git_output(repo_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
+ if upstream and "/" in upstream:
+ return upstream.split("/", 1)[1]
+
+ default = _git_output(repo_root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
+ if default and "/" in default:
+ return default.split("/", 1)[1]
+
+ return local if local and local != "HEAD" else ""
+
+
+def _git_output(repo_root, *args):
+ try:
+ with open(os.devnull, "wb") as null_stream:
+ result = subprocess.check_output(
+ ["git", "-C", repo_root, *args],
+ shell=False,
+ stdin=null_stream,
+ stderr=null_stream,
+ )
+ except (OSError, subprocess.CalledProcessError):
+ return ""
+
+ return result.decode().strip()
+
+
+def _git_ref_exists(repo_root, ref):
+ try:
+ with open(os.devnull, "wb") as null_stream:
+ subprocess.check_call(
+ ["git", "-C", repo_root, "show-ref", "--verify", "--quiet", ref],
+ shell=False,
+ stdin=null_stream,
+ stdout=null_stream,
+ stderr=null_stream,
+ )
+ except (OSError, subprocess.CalledProcessError):
+ return False
+
+ return True
+
+
+def get_repo_relative_path(page):
+ """Path of the page's Markdown file relative to the app repository root."""
+ repo_root = os.path.realpath(os.path.dirname(page.app_path))
+ filepath = os.path.realpath(page.filepath)
+ try:
+ relative = os.path.relpath(filepath, repo_root)
+ except ValueError:
+ return None
+
+ if relative.startswith(".."):
+ return None
+
+ return relative.replace(os.sep, "/")
+
+
def build_navigation_tree(locale=None):
locale = normalize_locale(locale or frappe.local.lang or DEFAULT_LANG)
permitted_pages = {path: page for path, page in discover_pages(locale).items() if is_permitted(page)}
diff --git a/compendium/docs/de/compendium/docs/authoring.md b/compendium/docs/de/compendium/docs/authoring.md
index 63a20e2..8e908b6 100644
--- a/compendium/docs/de/compendium/docs/authoring.md
+++ b/compendium/docs/de/compendium/docs/authoring.md
@@ -79,6 +79,17 @@ Verweisen Sie im Markdown mit relativen Pfaden auf Bilder. Compendium liefert si
über `compendium.docs.get_asset` aus, sodass Assets im `docs/`-Baum der jeweiligen
App bleiben.
+## Auf GitHub bearbeiten
+
+Wenn eine App unter `[project.urls]` in ihrer `pyproject.toml` eine GitHub-
+`Repository`-URL angibt, sehen Nutzer mit der Rolle **Compendium Contributor**
+einen Link **Edit on GitHub**, der die Markdown-Datei der aktuellen Seite öffnet.
+
+```toml
+[project.urls]
+Repository = "https://github.com/alyf-de/compendium.git"
+```
+
## Mermaid-Diagramme
`mermaid`-Codeblöcke werden im Lesebereich als Diagramm dargestellt:
diff --git a/compendium/docs/en/compendium/docs/authoring.md b/compendium/docs/en/compendium/docs/authoring.md
index e98bb20..f82a940 100644
--- a/compendium/docs/en/compendium/docs/authoring.md
+++ b/compendium/docs/en/compendium/docs/authoring.md
@@ -76,6 +76,17 @@ that owns the canonical page or a later installed app.
Reference images with relative paths in Markdown. Compendium serves them through
`compendium.docs.get_asset`, so assets stay inside the owning app's `docs/` tree.
+## Edit on GitHub
+
+When an app declares a GitHub `Repository` URL under `[project.urls]` in its
+`pyproject.toml`, contributors with the **Compendium Contributor** role see an
+**Edit on GitHub** link that opens the Markdown file for the current page.
+
+```toml
+[project.urls]
+Repository = "https://github.com/alyf-de/compendium.git"
+```
+
## Mermaid diagrams
Fenced `mermaid` blocks render as diagrams in the reading pane:
diff --git a/compendium/install.py b/compendium/install.py
index dc306a8..91dc787 100644
--- a/compendium/install.py
+++ b/compendium/install.py
@@ -1,11 +1,14 @@
import frappe
+from compendium.permissions import CONTRIBUTOR_ROLE
+
DOCUMENTATION_ROUTE = "/app/docs"
COMPENDIUM_LABEL = "Compendium"
def after_install():
add_documentation_help_item()
+ ensure_contributor_role()
def add_documentation_help_item():
@@ -25,3 +28,12 @@ def add_documentation_help_item():
},
)
navbar_settings.save()
+
+
+def ensure_contributor_role():
+ if frappe.db.exists("Role", CONTRIBUTOR_ROLE):
+ return
+
+ frappe.get_doc({"doctype": "Role", "role_name": CONTRIBUTOR_ROLE, "desk_access": 1}).insert(
+ ignore_permissions=True
+ )
diff --git a/compendium/locale/de.po b/compendium/locale/de.po
index d66ef03..c6dea50 100644
--- a/compendium/locale/de.po
+++ b/compendium/locale/de.po
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Compendium VERSION\n"
"Report-Msgid-Bugs-To: hallo@alyf.de\n"
-"POT-Creation-Date: 2026-08-12 18:04+0053\n"
-"PO-Revision-Date: 2026-08-06 18:56+0053\n"
+"POT-Creation-Date: 2026-08-12 18:42+0053\n"
+"PO-Revision-Date: 2026-08-12 18:40+0053\n"
"Last-Translator: hallo@alyf.de\n"
"Language: de\n"
"Language-Team: hallo@alyf.de\n"
@@ -24,59 +24,63 @@ msgstr ""
msgid "Compendium"
msgstr ""
-#: compendium/compendium/page/docs/docs.js:421
+#: compendium/compendium/page/docs/docs.js:443
msgid "Copy link"
msgstr "Link kopieren"
-#: compendium/docs.py:587
+#: compendium/docs.py:734
msgid "Documentation asset not found"
msgstr "Dokumentationsressource nicht gefunden"
-#: compendium/docs.py:461
+#: compendium/docs.py:524
msgid "Documentation page not found"
msgstr "Dokumentationsseite nicht gefunden"
-#: compendium/docs.py:584 compendium/docs.py:594 compendium/docs.py:598
+#: compendium/compendium/page/docs/docs.js:432
+msgid "Edit on GitHub"
+msgstr "Auf GitHub bearbeiten"
+
+#: compendium/docs.py:731 compendium/docs.py:741 compendium/docs.py:745
msgid "Invalid asset path"
msgstr "Ungültiger Ressourcenpfad"
-#: compendium/docs.py:391
+#: compendium/docs.py:454
msgid "Invalid documentation locale"
msgstr "Ungültige Dokumentationssprache"
-#: compendium/docs.py:451
+#: compendium/docs.py:514
msgid "Invalid documentation path"
msgstr "Ungültiger Dokumentationspfad"
-#: compendium/compendium/page/docs/docs.js:598
+#: compendium/compendium/page/docs/docs.js:623
msgid "No documentation is available for your account."
msgstr "Für Ihr Konto ist keine Dokumentation verfügbar."
-#: compendium/docs.py:464
+#: compendium/docs.py:527
msgid "No read permission for documentation page {0}"
msgstr "Keine Leseberechtigung für Dokumentationsseite {0}"
-#: compendium/compendium/page/docs/docs.js:51
+#: compendium/compendium/page/docs/docs.js:52
msgid "On this page"
msgstr "Auf dieser Seite"
-#: compendium/compendium/page/docs/docs.js:519
+#: compendium/compendium/page/docs/docs.js:544
msgid "This page is only available in {0}."
msgstr "Diese Seite ist nur auf {0} verfügbar."
-#: compendium/compendium/page/docs/docs.js:277
+#: compendium/compendium/page/docs/docs.js:269
msgid "Toggle {0}"
msgstr "{0} umschalten"
-#: compendium/compendium/page/docs/docs.js:620
+#: compendium/compendium/page/docs/docs.js:647
msgid "Unable to load documentation page {0}"
msgstr "Dokumentationsseite {0} konnte nicht geladen werden"
-#: compendium/compendium/page/docs/docs.js:533
+#: compendium/compendium/page/docs/docs.js:558
msgid "You can see this page because you have the role {0}."
msgstr "Sie können diese Seite sehen, weil Sie die Rolle {0} haben."
-#: compendium/compendium/page/docs/docs.js:534
+#: compendium/compendium/page/docs/docs.js:559
msgid "You can see this page because you have the roles {0}."
msgstr "Sie können diese Seite sehen, weil Sie die Rollen {0} haben."
diff --git a/compendium/locale/main.pot b/compendium/locale/main.pot
index 61ba101..3b13095 100644
--- a/compendium/locale/main.pot
+++ b/compendium/locale/main.pot
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Compendium VERSION\n"
"Report-Msgid-Bugs-To: hallo@alyf.de\n"
-"POT-Creation-Date: 2026-08-12 18:04+0053\n"
-"PO-Revision-Date: 2026-08-12 18:04+0053\n"
+"POT-Creation-Date: 2026-08-12 18:42+0053\n"
+"PO-Revision-Date: 2026-08-12 18:42+0053\n"
"Last-Translator: hallo@alyf.de\n"
"Language-Team: hallo@alyf.de\n"
"MIME-Version: 1.0\n"
@@ -22,59 +22,63 @@ msgstr ""
msgid "Compendium"
msgstr ""
-#: compendium/compendium/page/docs/docs.js:421
+#: compendium/compendium/page/docs/docs.js:443
msgid "Copy link"
msgstr ""
-#: compendium/docs.py:587
+#: compendium/docs.py:734
msgid "Documentation asset not found"
msgstr ""
-#: compendium/docs.py:461
+#: compendium/docs.py:524
msgid "Documentation page not found"
msgstr ""
-#: compendium/docs.py:584 compendium/docs.py:594 compendium/docs.py:598
+#: compendium/compendium/page/docs/docs.js:432
+msgid "Edit on GitHub"
+msgstr ""
+
+#: compendium/docs.py:731 compendium/docs.py:741 compendium/docs.py:745
msgid "Invalid asset path"
msgstr ""
-#: compendium/docs.py:391
+#: compendium/docs.py:454
msgid "Invalid documentation locale"
msgstr ""
-#: compendium/docs.py:451
+#: compendium/docs.py:514
msgid "Invalid documentation path"
msgstr ""
-#: compendium/compendium/page/docs/docs.js:598
+#: compendium/compendium/page/docs/docs.js:623
msgid "No documentation is available for your account."
msgstr ""
-#: compendium/docs.py:464
+#: compendium/docs.py:527
msgid "No read permission for documentation page {0}"
msgstr ""
-#: compendium/compendium/page/docs/docs.js:51
+#: compendium/compendium/page/docs/docs.js:52
msgid "On this page"
msgstr ""
-#: compendium/compendium/page/docs/docs.js:519
+#: compendium/compendium/page/docs/docs.js:544
msgid "This page is only available in {0}."
msgstr ""
-#: compendium/compendium/page/docs/docs.js:277
+#: compendium/compendium/page/docs/docs.js:269
msgid "Toggle {0}"
msgstr ""
-#: compendium/compendium/page/docs/docs.js:620
+#: compendium/compendium/page/docs/docs.js:647
msgid "Unable to load documentation page {0}"
msgstr ""
-#: compendium/compendium/page/docs/docs.js:533
+#: compendium/compendium/page/docs/docs.js:558
msgid "You can see this page because you have the role {0}."
msgstr ""
-#: compendium/compendium/page/docs/docs.js:534
+#: compendium/compendium/page/docs/docs.js:559
msgid "You can see this page because you have the roles {0}."
msgstr ""
diff --git a/compendium/patches.txt b/compendium/patches.txt
index 50007d1..c982b8f 100644
--- a/compendium/patches.txt
+++ b/compendium/patches.txt
@@ -4,4 +4,5 @@
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
-compendium.patches.update_documentation_menu_label
\ No newline at end of file
+compendium.patches.update_documentation_menu_label
+compendium.patches.create_compendium_contributor_role
\ No newline at end of file
diff --git a/compendium/patches/create_compendium_contributor_role.py b/compendium/patches/create_compendium_contributor_role.py
new file mode 100644
index 0000000..eef2cfe
--- /dev/null
+++ b/compendium/patches/create_compendium_contributor_role.py
@@ -0,0 +1,5 @@
+from compendium.install import ensure_contributor_role
+
+
+def execute():
+ ensure_contributor_role()
diff --git a/compendium/permissions.py b/compendium/permissions.py
index 083ce07..1dc44e9 100644
--- a/compendium/permissions.py
+++ b/compendium/permissions.py
@@ -1,5 +1,7 @@
import frappe
+CONTRIBUTOR_ROLE = "Compendium Contributor"
+
def has_app_permission():
"""Show Compendium on the apps screen for Desk users."""
diff --git a/compendium/tests/test_docs.py b/compendium/tests/test_docs.py
index 6a17f03..e622e1a 100644
--- a/compendium/tests/test_docs.py
+++ b/compendium/tests/test_docs.py
@@ -584,13 +584,98 @@ def test_get_view_resolve_first_selects_opening_page(self):
self.assertEqual(view["path"], "a")
self.assertEqual(view["page"]["title"], "A")
- def docs_environment(self, files):
- return DocsTestEnvironment(files)
+ def test_edit_url_for_contributor_with_repository(self):
+ with self.docs_environment(
+ {
+ "en/guide.md": "---\ntitle: Guide\n---\n# Guide",
+ },
+ repository="https://github.com/alyf-de/example.git",
+ git_branch="version-15",
+ ):
+ with patch("compendium.docs.get_user_roles", return_value=["Compendium Contributor"]):
+ doc = get_page("guide", locale="en")
+
+ self.assertEqual(
+ doc["edit_url"],
+ "https://github.com/alyf-de/example/edit/version-15/frappe/docs/en/guide.md",
+ )
+
+ def test_edit_url_encodes_branch_slashes(self):
+ with self.docs_environment(
+ {
+ "en/guide.md": "---\ntitle: Guide\n---\n# Guide",
+ },
+ repository="https://github.com/alyf-de/example.git",
+ git_branch="feat/edit-on-github",
+ ):
+ with patch("compendium.docs.get_user_roles", return_value=["Compendium Contributor"]):
+ doc = get_page("guide", locale="en")
+
+ self.assertEqual(
+ doc["edit_url"],
+ "https://github.com/alyf-de/example/edit/feat%2Fedit-on-github/frappe/docs/en/guide.md",
+ )
+
+ def test_git_branch_falls_back_to_origin_default(self):
+ from compendium.docs import get_app_git_branch
+
+ with tempfile.TemporaryDirectory() as tmp:
+ app_root = os.path.join(tmp, "pkg")
+ os.makedirs(app_root)
+
+ def git_output(repo_root, *args):
+ key = args
+ if key == ("rev-parse", "--abbrev-ref", "HEAD"):
+ return "feat/local-only"
+ if key == ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"):
+ return ""
+ if key == ("symbolic-ref", "--short", "refs/remotes/origin/HEAD"):
+ return "origin/version-15"
+ return ""
+
+ with (
+ patch("compendium.docs.get_app_path", return_value=app_root),
+ patch("compendium.docs._git_output", side_effect=git_output),
+ patch("compendium.docs._git_ref_exists", return_value=False),
+ ):
+ self.assertEqual(get_app_git_branch("pkg"), "version-15")
+
+
+ def test_edit_url_hidden_without_contributor_role(self):
+ with self.docs_environment(
+ {
+ "en/guide.md": "---\ntitle: Guide\n---\n# Guide",
+ },
+ repository="https://github.com/alyf-de/example.git",
+ git_branch="version-15",
+ ):
+ frappe.set_user("test@example.com")
+ with patch("compendium.docs.get_user_roles", return_value=["Desk User"]):
+ doc = get_page("guide", locale="en")
+
+ self.assertIsNone(doc["edit_url"])
+
+ def test_edit_url_hidden_without_repository(self):
+ with self.docs_environment(
+ {
+ "en/guide.md": "---\ntitle: Guide\n---\n# Guide",
+ },
+ git_branch="version-15",
+ ):
+ with patch("compendium.docs.get_user_roles", return_value=["Compendium Contributor"]):
+ doc = get_page("guide", locale="en")
+
+ self.assertIsNone(doc["edit_url"])
+
+ def docs_environment(self, files, repository=None, git_branch=None):
+ return DocsTestEnvironment(files, repository=repository, git_branch=git_branch)
class DocsTestEnvironment:
- def __init__(self, files):
+ def __init__(self, files, repository=None, git_branch=None):
self.files = files
+ self.repository = repository
+ self.git_branch = git_branch
self.tmpdir = None
self.docs_root = None
self._patches = []
@@ -609,10 +694,19 @@ def __enter__(self):
with open(filepath, mode, encoding=encoding) as f:
f.write(content)
+ if self.repository:
+ with open(os.path.join(self.tmpdir.name, "pyproject.toml"), "w", encoding="utf-8") as f:
+ f.write(
+ "[project]\nname = \"example\"\n\n[project.urls]\n"
+ f'Repository = "{self.repository}"\n'
+ )
+
self._patches = [
patch("compendium.docs.get_installed_apps", return_value=["frappe"]),
patch("compendium.docs.get_app_path", return_value=app_root),
]
+ if self.git_branch is not None:
+ self._patches.append(patch("compendium.docs.get_app_git_branch", return_value=self.git_branch))
for patcher in self._patches:
patcher.start()
if hasattr(frappe.local, "request_cache"):
diff --git a/cypress/integration/docs.js b/cypress/integration/docs.js
index 42fcd35..978eb20 100644
--- a/cypress/integration/docs.js
+++ b/cypress/integration/docs.js
@@ -51,8 +51,8 @@ context("Documentation Browser", () => {
it("shows which of the user's roles grant access", () => {
cy.visit("/app/docs/en/compendium/docs/authoring");
- cy.get(".docs-page-roles").should("contain", "System Manager");
- cy.get(".docs-page-roles").should("contain", "because you have");
+ cy.get(".docs-page-footer").should("contain", "System Manager");
+ cy.get(".docs-page-footer").should("contain", "because you have");
});
it("renders a TOC and follows heading deep links", () => {
@@ -86,6 +86,15 @@ context("Documentation Browser", () => {
cy.get("@clipboardWrite").should("have.been.calledWithMatch", /#code-blocks$/);
});
+ it("shows Edit on GitHub for Administrator when repository is set", () => {
+ cy.visit("/app/docs/en/compendium/docs/authoring");
+ cy.get(".docs-page-footer .docs-edit-link")
+ .should("be.visible")
+ .and("contain", "Edit on GitHub")
+ .and("have.attr", "href")
+ .and("include", "github.com/alyf-de/compendium");
+ });
+
it("shows not-found state for missing pages", () => {
cy.visit("/app/docs/en/does-not-exist-page");
cy.get(".docs-state").should("not.have.class", "hide");
diff --git a/pyproject.toml b/pyproject.toml
index cb92ed0..defc1fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,10 @@ dependencies = [
# "frappe~=15.0.0" # Installed and managed by bench.
]
+[project.urls]
+Homepage = "https://github.com/alyf-de/compendium"
+Repository = "https://github.com/alyf-de/compendium.git"
+
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
From 648460976ed0309cd507b8ef8b1e01899295b89d Mon Sep 17 00:00:00 2001
From: barredterra <14891507+barredterra@users.noreply.github.com>
Date: Wed, 12 Aug 2026 18:56:56 +0200
Subject: [PATCH 2/5] style: format with ruff
---
compendium/tests/test_docs.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/compendium/tests/test_docs.py b/compendium/tests/test_docs.py
index e622e1a..b6da397 100644
--- a/compendium/tests/test_docs.py
+++ b/compendium/tests/test_docs.py
@@ -640,7 +640,6 @@ def git_output(repo_root, *args):
):
self.assertEqual(get_app_git_branch("pkg"), "version-15")
-
def test_edit_url_hidden_without_contributor_role(self):
with self.docs_environment(
{
@@ -697,8 +696,7 @@ def __enter__(self):
if self.repository:
with open(os.path.join(self.tmpdir.name, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write(
- "[project]\nname = \"example\"\n\n[project.urls]\n"
- f'Repository = "{self.repository}"\n'
+ '[project]\nname = "example"\n\n[project.urls]\n' f'Repository = "{self.repository}"\n'
)
self._patches = [
From 851129759f334ded3079cdf303b1f8ab52c40f17 Mon Sep 17 00:00:00 2001
From: barredterra <14891507+barredterra@users.noreply.github.com>
Date: Thu, 13 Aug 2026 13:23:35 +0200
Subject: [PATCH 3/5] fix: hide GitHub edit link for unpushed local branches
---
compendium/docs.py | 5 +++--
compendium/tests/test_docs.py | 19 +++++++++++++++++++
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/compendium/docs.py b/compendium/docs.py
index 304d940..a900fa5 100644
--- a/compendium/docs.py
+++ b/compendium/docs.py
@@ -599,7 +599,8 @@ def get_app_git_branch(app):
"""Git branch to use in GitHub links for this app.
Prefers a ref that exists on the remote so local-only feature branches do not
- produce 404s. Order: local branch if on origin → upstream → origin default → local.
+ produce 404s. Order: local branch if on origin → upstream → origin default.
+ Returns empty if none of those are available.
"""
repo_root = os.path.dirname(get_app_path(app))
local = _git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
@@ -614,7 +615,7 @@ def get_app_git_branch(app):
if default and "/" in default:
return default.split("/", 1)[1]
- return local if local and local != "HEAD" else ""
+ return ""
def _git_output(repo_root, *args):
diff --git a/compendium/tests/test_docs.py b/compendium/tests/test_docs.py
index b6da397..69c6adb 100644
--- a/compendium/tests/test_docs.py
+++ b/compendium/tests/test_docs.py
@@ -640,6 +640,25 @@ def git_output(repo_root, *args):
):
self.assertEqual(get_app_git_branch("pkg"), "version-15")
+ def test_git_branch_skips_unverified_local_branch(self):
+ from compendium.docs import get_app_git_branch
+
+ with tempfile.TemporaryDirectory() as tmp:
+ app_root = os.path.join(tmp, "pkg")
+ os.makedirs(app_root)
+
+ def git_output(repo_root, *args):
+ if args == ("rev-parse", "--abbrev-ref", "HEAD"):
+ return "feat/local-only"
+ return ""
+
+ with (
+ patch("compendium.docs.get_app_path", return_value=app_root),
+ patch("compendium.docs._git_output", side_effect=git_output),
+ patch("compendium.docs._git_ref_exists", return_value=False),
+ ):
+ self.assertEqual(get_app_git_branch("pkg"), "")
+
def test_edit_url_hidden_without_contributor_role(self):
with self.docs_environment(
{
From 575a931ec56158273bc51facafaccd9bebf3713a Mon Sep 17 00:00:00 2001
From: barredterra <14891507+barredterra@users.noreply.github.com>
Date: Thu, 13 Aug 2026 16:33:47 +0200
Subject: [PATCH 4/5] fix: ignore non-origin upstream in GitHub edit URLs
---
compendium/docs.py | 11 +++++----
compendium/tests/test_docs.py | 44 +++++++++++++++++++++++++++++++++++
2 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/compendium/docs.py b/compendium/docs.py
index a900fa5..79a4727 100644
--- a/compendium/docs.py
+++ b/compendium/docs.py
@@ -598,9 +598,9 @@ def get_app_repository_url(app):
def get_app_git_branch(app):
"""Git branch to use in GitHub links for this app.
- Prefers a ref that exists on the remote so local-only feature branches do not
- produce 404s. Order: local branch if on origin → upstream → origin default.
- Returns empty if none of those are available.
+ Prefers a ref that exists on origin so local-only or other-remote branches do
+ not produce 404s. Order: local branch if on origin → origin upstream →
+ origin default. Returns empty if none of those are available.
"""
repo_root = os.path.dirname(get_app_path(app))
local = _git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
@@ -608,8 +608,9 @@ def get_app_git_branch(app):
return local
upstream = _git_output(repo_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
- if upstream and "/" in upstream:
- return upstream.split("/", 1)[1]
+ remote, _, branch = upstream.partition("/")
+ if remote == "origin" and branch:
+ return branch
default = _git_output(repo_root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if default and "/" in default:
diff --git a/compendium/tests/test_docs.py b/compendium/tests/test_docs.py
index 69c6adb..40c871e 100644
--- a/compendium/tests/test_docs.py
+++ b/compendium/tests/test_docs.py
@@ -640,6 +640,50 @@ def git_output(repo_root, *args):
):
self.assertEqual(get_app_git_branch("pkg"), "version-15")
+ def test_git_branch_uses_origin_upstream(self):
+ from compendium.docs import get_app_git_branch
+
+ with tempfile.TemporaryDirectory() as tmp:
+ app_root = os.path.join(tmp, "pkg")
+ os.makedirs(app_root)
+
+ def git_output(repo_root, *args):
+ if args == ("rev-parse", "--abbrev-ref", "HEAD"):
+ return "feat/local-only"
+ if args == ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"):
+ return "origin/version-15"
+ return ""
+
+ with (
+ patch("compendium.docs.get_app_path", return_value=app_root),
+ patch("compendium.docs._git_output", side_effect=git_output),
+ patch("compendium.docs._git_ref_exists", return_value=False),
+ ):
+ self.assertEqual(get_app_git_branch("pkg"), "version-15")
+
+ def test_git_branch_skips_non_origin_upstream(self):
+ from compendium.docs import get_app_git_branch
+
+ with tempfile.TemporaryDirectory() as tmp:
+ app_root = os.path.join(tmp, "pkg")
+ os.makedirs(app_root)
+
+ def git_output(repo_root, *args):
+ if args == ("rev-parse", "--abbrev-ref", "HEAD"):
+ return "feat/local-only"
+ if args == ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"):
+ return "fork/feat/local-only"
+ if args == ("symbolic-ref", "--short", "refs/remotes/origin/HEAD"):
+ return "origin/version-15"
+ return ""
+
+ with (
+ patch("compendium.docs.get_app_path", return_value=app_root),
+ patch("compendium.docs._git_output", side_effect=git_output),
+ patch("compendium.docs._git_ref_exists", return_value=False),
+ ):
+ self.assertEqual(get_app_git_branch("pkg"), "version-15")
+
def test_git_branch_skips_unverified_local_branch(self):
from compendium.docs import get_app_git_branch
From b60e73cba80e1265a0125e204d837d212565a2c1 Mon Sep 17 00:00:00 2001
From: barredterra <14891507+barredterra@users.noreply.github.com>
Date: Thu, 13 Aug 2026 17:06:21 +0200
Subject: [PATCH 5/5] fix: resolve GitHub edit branch from the canonical remote
---
compendium/docs.py | 48 +++++++++--
compendium/tests/test_docs.py | 151 +++++++++++++++++++---------------
2 files changed, 123 insertions(+), 76 deletions(-)
diff --git a/compendium/docs.py b/compendium/docs.py
index 79a4727..44efc22 100644
--- a/compendium/docs.py
+++ b/compendium/docs.py
@@ -598,23 +598,29 @@ def get_app_repository_url(app):
def get_app_git_branch(app):
"""Git branch to use in GitHub links for this app.
- Prefers a ref that exists on origin so local-only or other-remote branches do
- not produce 404s. Order: local branch if on origin → origin upstream →
- origin default. Returns empty if none of those are available.
+ Uses a ref from the git remote that matches `[project.urls].Repository`, so
+ forks and local-only branches do not produce 404s. Order: current branch if
+ on that remote → its upstream if on that remote → that remote's default.
+ Returns empty if none of those are available.
"""
repo_root = os.path.dirname(get_app_path(app))
+ remote = _remote_for_repository(repo_root, get_app_repository_url(app))
+ if not remote:
+ return ""
+
local = _git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
- if local and local != "HEAD" and _git_ref_exists(repo_root, f"refs/remotes/origin/{local}"):
+ if local and local != "HEAD" and _git_ref_exists(repo_root, f"refs/remotes/{remote}/{local}"):
return local
upstream = _git_output(repo_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
- remote, _, branch = upstream.partition("/")
- if remote == "origin" and branch:
+ tracking_remote, _, branch = upstream.partition("/")
+ if tracking_remote == remote and branch:
return branch
- default = _git_output(repo_root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
- if default and "/" in default:
- return default.split("/", 1)[1]
+ prefix = f"{remote}/"
+ default = _git_output(repo_root, "symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD")
+ if default.startswith(prefix):
+ return default[len(prefix) :]
return ""
@@ -650,6 +656,30 @@ def _git_ref_exists(repo_root, ref):
return True
+def _remote_for_repository(repo_root, repository):
+ """Git remote whose URL points at the same GitHub owner/repo as `repository`."""
+ target = _github_repo(repository)
+ if not target:
+ return ""
+
+ for remote in _git_output(repo_root, "remote").split():
+ if _github_repo(_git_output(repo_root, "remote", "get-url", remote)) == target:
+ return remote
+ return ""
+
+
+def _github_repo(url):
+ if not url:
+ return None
+ try:
+ owner, repo = parse_github_url(url)
+ except ValueError:
+ return None
+ if not owner or not repo:
+ return None
+ return (owner.lower(), repo.lower())
+
+
def get_repo_relative_path(page):
"""Path of the page's Markdown file relative to the app repository root."""
repo_root = os.path.realpath(os.path.dirname(page.app_path))
diff --git a/compendium/tests/test_docs.py b/compendium/tests/test_docs.py
index 40c871e..52bd21a 100644
--- a/compendium/tests/test_docs.py
+++ b/compendium/tests/test_docs.py
@@ -616,75 +616,93 @@ def test_edit_url_encodes_branch_slashes(self):
"https://github.com/alyf-de/example/edit/feat%2Fedit-on-github/frappe/docs/en/guide.md",
)
- def test_git_branch_falls_back_to_origin_default(self):
- from compendium.docs import get_app_git_branch
-
- with tempfile.TemporaryDirectory() as tmp:
- app_root = os.path.join(tmp, "pkg")
- os.makedirs(app_root)
-
- def git_output(repo_root, *args):
- key = args
- if key == ("rev-parse", "--abbrev-ref", "HEAD"):
- return "feat/local-only"
- if key == ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"):
- return ""
- if key == ("symbolic-ref", "--short", "refs/remotes/origin/HEAD"):
- return "origin/version-15"
- return ""
-
- with (
- patch("compendium.docs.get_app_path", return_value=app_root),
- patch("compendium.docs._git_output", side_effect=git_output),
- patch("compendium.docs._git_ref_exists", return_value=False),
- ):
- self.assertEqual(get_app_git_branch("pkg"), "version-15")
-
- def test_git_branch_uses_origin_upstream(self):
- from compendium.docs import get_app_git_branch
-
- with tempfile.TemporaryDirectory() as tmp:
- app_root = os.path.join(tmp, "pkg")
- os.makedirs(app_root)
-
- def git_output(repo_root, *args):
- if args == ("rev-parse", "--abbrev-ref", "HEAD"):
- return "feat/local-only"
- if args == ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"):
- return "origin/version-15"
- return ""
+ def test_git_branch_uses_local_when_on_canonical_remote(self):
+ self.assert_git_branch(
+ "feat/on-origin",
+ {
+ ("remote",): "origin",
+ ("remote", "get-url", "origin"): "git@github.com:alyf-de/example.git",
+ ("rev-parse", "--abbrev-ref", "HEAD"): "feat/on-origin",
+ },
+ ref_exists=True,
+ )
- with (
- patch("compendium.docs.get_app_path", return_value=app_root),
- patch("compendium.docs._git_output", side_effect=git_output),
- patch("compendium.docs._git_ref_exists", return_value=False),
- ):
- self.assertEqual(get_app_git_branch("pkg"), "version-15")
+ def test_git_branch_falls_back_to_canonical_default(self):
+ self.assert_git_branch(
+ "version-15",
+ {
+ ("remote",): "origin",
+ ("remote", "get-url", "origin"): "https://github.com/alyf-de/example.git",
+ ("rev-parse", "--abbrev-ref", "HEAD"): "feat/local-only",
+ ("symbolic-ref", "--short", "refs/remotes/origin/HEAD"): "origin/version-15",
+ },
+ )
- def test_git_branch_skips_non_origin_upstream(self):
- from compendium.docs import get_app_git_branch
+ def test_git_branch_uses_canonical_upstream(self):
+ self.assert_git_branch(
+ "version-15",
+ {
+ ("remote",): "origin",
+ ("remote", "get-url", "origin"): "https://github.com/alyf-de/example.git",
+ ("rev-parse", "--abbrev-ref", "HEAD"): "feat/local-only",
+ ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"): "origin/version-15",
+ },
+ )
- with tempfile.TemporaryDirectory() as tmp:
- app_root = os.path.join(tmp, "pkg")
- os.makedirs(app_root)
+ def test_git_branch_skips_non_canonical_upstream(self):
+ self.assert_git_branch(
+ "version-15",
+ {
+ ("remote",): "origin",
+ ("remote", "get-url", "origin"): "https://github.com/alyf-de/example.git",
+ ("rev-parse", "--abbrev-ref", "HEAD"): "feat/local-only",
+ ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"): "fork/feat/local-only",
+ ("symbolic-ref", "--short", "refs/remotes/origin/HEAD"): "origin/version-15",
+ },
+ )
- def git_output(repo_root, *args):
- if args == ("rev-parse", "--abbrev-ref", "HEAD"):
- return "feat/local-only"
- if args == ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"):
- return "fork/feat/local-only"
- if args == ("symbolic-ref", "--short", "refs/remotes/origin/HEAD"):
- return "origin/version-15"
- return ""
+ def test_git_branch_skips_origin_when_it_is_a_fork(self):
+ self.assert_git_branch(
+ "version-15",
+ {
+ ("remote",): "origin\nupstream",
+ ("remote", "get-url", "origin"): "https://github.com/jane/example.git",
+ ("remote", "get-url", "upstream"): "https://github.com/alyf-de/example.git",
+ ("rev-parse", "--abbrev-ref", "HEAD"): "feat/on-fork",
+ ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"): "origin/feat/on-fork",
+ ("symbolic-ref", "--short", "refs/remotes/upstream/HEAD"): "upstream/version-15",
+ },
+ )
- with (
- patch("compendium.docs.get_app_path", return_value=app_root),
- patch("compendium.docs._git_output", side_effect=git_output),
- patch("compendium.docs._git_ref_exists", return_value=False),
- ):
- self.assertEqual(get_app_git_branch("pkg"), "version-15")
+ def test_git_branch_hidden_when_no_canonical_remote(self):
+ self.assert_git_branch(
+ "",
+ {
+ ("remote",): "origin",
+ ("remote", "get-url", "origin"): "https://github.com/jane/example.git",
+ ("rev-parse", "--abbrev-ref", "HEAD"): "feat/on-fork",
+ },
+ ref_exists=True,
+ )
def test_git_branch_skips_unverified_local_branch(self):
+ self.assert_git_branch(
+ "",
+ {
+ ("remote",): "origin",
+ ("remote", "get-url", "origin"): "https://github.com/alyf-de/example.git",
+ ("rev-parse", "--abbrev-ref", "HEAD"): "feat/local-only",
+ },
+ )
+
+ def assert_git_branch(
+ self,
+ expected,
+ commands,
+ *,
+ ref_exists=False,
+ repository="https://github.com/alyf-de/example.git",
+ ):
from compendium.docs import get_app_git_branch
with tempfile.TemporaryDirectory() as tmp:
@@ -692,16 +710,15 @@ def test_git_branch_skips_unverified_local_branch(self):
os.makedirs(app_root)
def git_output(repo_root, *args):
- if args == ("rev-parse", "--abbrev-ref", "HEAD"):
- return "feat/local-only"
- return ""
+ return commands.get(args, "")
with (
patch("compendium.docs.get_app_path", return_value=app_root),
+ patch("compendium.docs.get_app_repository_url", return_value=repository),
patch("compendium.docs._git_output", side_effect=git_output),
- patch("compendium.docs._git_ref_exists", return_value=False),
+ patch("compendium.docs._git_ref_exists", return_value=ref_exists),
):
- self.assertEqual(get_app_git_branch("pkg"), "")
+ self.assertEqual(get_app_git_branch("pkg"), expected)
def test_edit_url_hidden_without_contributor_role(self):
with self.docs_environment(