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(