From a5d14222a5497d171bd3a63175dbdbd98cabe50c Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 25 Apr 2026 19:19:37 +0100 Subject: [PATCH] viewer: show commit-pin badge + branch-switch button when ref is a hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an ifc:// URL uses a bare commit hash as the ref the toolbar now shows an amber badge with the abbreviated hash and (after a Forgejo API lookup) a '→ heads/main' button that reloads with the branch-tip ref. Closes ifcurl-gy8. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 4 +- forgejo/custom/public/assets/viewer.html | 64 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b79c4f7..046643b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,7 +2,7 @@ {"id":"ifcurl-p60","title":"Viewer: no element click-to-identify — can't get GlobalId or properties","description":"There is no way to click on a mesh in the viewer and see which IFC element it represents. Reviewers can see geometry but cannot reference specific elements — they can't discover a GlobalId to put in a selector URL, can't see the element name/type/Psets, and can't say 'this specific wall' in a BCF or Forgejo comment. This breaks the collaboration workflow at its foundation: every useful feedback comment needs to identify specific elements. Need: click or hover on a surface to highlight it and show a properties panel (name, type, GlobalId, key Psets). The GlobalId should be copyable into the selector field or URL. The ThatOpen components library provides the tools for this (OBC.IfcRelationsIndexer, element property queries).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:02:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:57Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-dkz","title":"Viewer bundle: upgrade @thatopen/components 3.3.1 → 3.4.2 to fix fast-xml-parser CVEs","description":"npm audit reports 2 critical CVEs in fast-xml-parser (a transitive dep of @thatopen/components 3.3.1). These are DoS/injection issues in XML parsing. Risk in the viewer context is low (client-side, user's own files) but should be addressed. Fix: bump @thatopen/components to 3.4.2 in forgejo/package.json, run npm install \u0026\u0026 npm run build, and commit the regenerated bundle.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T17:12:37Z","created_by":"Bruno Postle","updated_at":"2026-04-25T17:31:59Z","started_at":"2026-04-25T17:31:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-dkz","title":"Viewer bundle: upgrade @thatopen/components 3.3.1 → 3.4.2 to fix fast-xml-parser CVEs","description":"npm audit reports 2 critical CVEs in fast-xml-parser (a transitive dep of @thatopen/components 3.3.1). These are DoS/injection issues in XML parsing. Risk in the viewer context is low (client-side, user's own files) but should be addressed. Fix: bump @thatopen/components to 3.4.2 in forgejo/package.json, run npm install \u0026\u0026 npm run build, and commit the regenerated bundle.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T17:12:37Z","created_by":"Bruno Postle","updated_at":"2026-04-25T17:59:50Z","started_at":"2026-04-25T17:31:59Z","closed_at":"2026-04-25T17:59:50Z","close_reason":"Upgraded to @thatopen/components 3.4.2 + three 0.184.0 + web-ifc 0.0.77. Severity reduced from 2 critical to 1 high + 1 low. Remaining fast-xml-parser CVEs are upstream in @thatopen \u003e= 3.3.3 with no fix released yet.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-bue","title":"Split service into ifcurl-api and ifcurl-render for RCE isolation","description":"The ifcopenshell C++ bindings are the main RCE attack surface (memory corruption on malformed IFC). The git/token code is not at risk but currently shares the same process and systemd security profile.\n\nSplit the service into two systemd units with different security profiles:\n\nifcurl-api (renamed from ifcurl-preview): handles HTTP, git fetch, caching, auth token lookup. Needs execve (git CLI via GitPython) and AF_INET/AF_INET6. No ifcopenshell.\n\nifcurl-render: handles IFC parsing and PNG rendering only. Listens on Unix socket /run/ifcurl-render/render.sock. Has SystemCallFilter ~execve ~execveat (confirmed safe: strace shows pyvista and ifcopenshell geom iterator use fork/clone only, never exec). Has RestrictAddressFamilies=AF_UNIX only. No access to /etc/ifcurl tokens. Runs as separate user ifcurl-render.\n\nCommunication: API service calls render service via httpx over the Unix socket. Both service users belong to a shared ifcurl group for socket access.\n\nInterface (three endpoints on render service):\n POST /render multipart(ifc, params JSON) → JSON {png: base64, guids: list|null}\n POST /select multipart(ifc, params JSON) → JSON {guids: list}\n POST /render_diff multipart(head_ifc, base_ifc, params JSON) → image/png binary\n\nsandbox.py crash isolation is kept inside the render service for per-request segfault containment.\n\nBackward compat: if IFCURL_RENDER_SOCKET is unset, API service falls back to direct in-process rendering (current sandbox.py path).\n\nImplementation steps:\n1. ifcurl/render_service.py — FastAPI app with the three endpoints wrapping sandboxed functions\n2. ifcurl/service.py — delegate to render service when IFCURL_RENDER_SOCKET is set\n3. ifcurl/__main__.py — add 'render-service' CLI subcommand\n4. pyproject.toml — add httpx to service optional deps\n5. forgejo/server-config/ifcurl-api.service (rename + update)\n6. forgejo/server-config/ifcurl-render.service (new, hardened unit)\n7. forgejo/README.md — update deployment docs for two-service setup\n8. tests/test_render_service.py — integration tests for the render service endpoints","acceptance_criteria":"render service cannot exec /bin/sh even with RCE (verified by strace showing no execve during normal render); normal renders still produce correct PNG; API service correctly delegates to render service when socket is configured; fallback to in-process rendering works when socket is unset","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T08:32:27Z","created_by":"Bruno Postle","updated_at":"2026-04-25T08:57:22Z","started_at":"2026-04-25T08:32:57Z","closed_at":"2026-04-25T08:57:22Z","close_reason":"Two-service split implemented: render_service.py with /render /select /render_diff endpoints, service.py delegates via httpx when IFCURL_RENDER_SOCKET is set, render-service CLI subcommand added, two systemd units created","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-ndf","title":"gitconfig-ifcmerge: update to use ifcmerge --prioritise-local instead of swapping LOCAL/REMOTE","description":"The current gitconfig-ifcmerge uses the old technique of swapping the order of %A and %B arguments to preserve the base branch's STEP ID space:\n\n driver = ifcmerge %O %A %B %L (standard)\n driver = ifcmerge %O %B %A %L (ours variant)\n\nBonsai's current ifcgit code (tool/ifcgit.py, config_ifcmerge) uses the newer --prioritise-local flag instead:\n\n ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED\n\nThe --prioritise-local flag tells ifcmerge to preserve LOCAL's STEP ID space without needing to swap argument order. This is cleaner, more readable, and follows the canonical ifcmerge interface.\n\nThe gitconfig-ifcmerge needs updating to:\n driver = ifcmerge --prioritise-local %O %A %B %A\n\n(where %A is both LOCAL and the output file — git merge drivers write their result to %A)\n\nThe ifcmerge_ours variant (for rebase direction where base branch arrives as %B) would become:\n driver = ifcmerge --prioritise-local %O %B %A %B\n\nVerify that the --prioritise-local flag exists in the version of ifcmerge being used before merging.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:41:59Z","closed_at":"2026-04-25T06:41:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-qjd","title":"render_diff: auto-fit camera to changed elements, not whole model","description":"When rendering a diff of a large building model, the auto-fit camera fits to the entire head model. A wall added to one corner of a multi-storey building would appear as a tiny green speck. The camera should instead zoom to the bounding box of just the added + modified + removed elements so the changed area fills the viewport.\n\nImplementation sketch:\n- In render_diff(), after adding all shapes to plotter1, call plotter1.reset_camera() as now, but also compute the bounds of only the diff-coloured meshes (added + modified) and re-fit to those bounds.\n- For pass 2 (removed elements), the elements are already isolated in the iterator, so reset_camera() naturally fits to them. The pass 1 camera capture then needs to consider that pass 2 might be out of that frustum.\n- A combined approach: compute the bounding box union of added + modified + removed element meshes, then set the camera to fit that box with a small margin. This is the camera used for both passes.\n- pyvista Plotter has plotter.reset_camera(bounds=(xmin,xmax,ymin,ymax,zmin,zmax)) which can fit to an explicit box.\n- The bounds can be accumulated during the _add_shape loop for diff-coloured elements.\n\nAcceptance: a single modified wall in a large building renders with the wall clearly visible and filling most of the image.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:39Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:57:06Z","closed_at":"2026-04-25T06:57:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -44,7 +44,7 @@ {"id":"ifcurl-i1s","title":"Test Phase 3b web viewer end-to-end","description":"After deploying the footer template (ifcurl-ach), test the full flow: open an .ifc file in Forgejo at localhost:3000, click 'View in 3D', verify the viewer loads the model. CDN versions (@thatopen/components 2.4.0, three 0.160.0, web-ifc 0.0.66) may need adjustment if the API has changed.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:01Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:04Z","started_at":"2026-04-23T19:54:56Z","closed_at":"2026-04-23T20:05:04Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-49o","type":"blocks","created_at":"2026-04-23T07:14:12Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} {"id":"ifcurl-ach","title":"Deploy Phase 3b footer template to Forgejo","description":"The footer.tmpl file that injects a 'View in 3D' button on .ifc file pages has been written but not yet deployed. Deploy it to /etc/forgejo/templates/custom/footer.tmpl and verify the button appears when viewing an .ifc file on localhost:3000.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:47:53Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:35Z","closed_at":"2026-04-23T06:14:35Z","close_reason":"Split into atomic tasks: ifcurl-ohq, ifcurl-49o","dependencies":[{"issue_id":"ifcurl-ach","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:40Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"ifcurl-6ys","title":"Research alternative URL schemes before ifc:// is committed to","description":"The ifc:// scheme name is not yet set in stone. It's worth researching whether a different scheme would be more appropriate or less likely to conflict with existing tooling.\\n\\nLook at how other tools handle similar problems:\\n- pip uses git+https:// and git+ssh:// (prefix on top of existing schemes)\\n- Are there existing ifc:// registrations or tools that would conflict?\\n- What scheme patterns are used by other domain-specific version-controlled asset tools?\\n- Alternatives to consider: ifcgit://, git+ifc://, ifc+git://, or a vendor-prefixed form\\n- Check IANA registered schemes and common-use unregistered schemes for conflicts\\n\\nGoal: make a confident decision to keep ifc:// or switch, before the scheme is baked into any external integrations.","notes":"Research complete. Summary:\n\n- ifc is NOT registered with IANA. bcf, bim also unregistered. Clean namespace.\n- No existing tool or buildingSMART standard uses ifc:// — no conflict risk.\n- BCF (bcf://) would be confusing — BCF is a live buildingSMART standard, don't use it.\n- pip-style compound schemes (ifc+git://, ifc+https://) look appealing but hurt more than they help: they fragment the transport-inference design, improve nothing for OS handlers or terminal clickability, and are harder to explain.\n- Forgejo: CUSTOM_URL_SCHEMES defaults to allowing all schemes — ifc:// links render fine out of the box.\n- GitHub/terminals: bare custom URLs are never auto-linked regardless of scheme choice. Explicitly formatted markdown links work fine.\n\nDecision: keep ifc://\n\nAction to add to roadmap: submit a provisional IANA registration (First Come First Served, ~1 hour, just an email to iana@iana.org with scheme name, syntax summary, security notes, and a spec URL). Do this when Phase 1 spec stabilises.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T09:08:13Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:44Z","started_at":"2026-04-25T11:04:50Z","closed_at":"2026-04-25T11:17:44Z","close_reason":"Keep ifc://. Namespace is clean (no IANA registration, no prior art). Compound schemes (ifc+git://) would break transport-inference design with no practical benefit. Action item: provisional IANA registration when Phase 1 spec stabilises.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-gy8","title":"Viewer: 'View in 3D' uses commit hash but no UI affordance to switch to branch tip","description":"The 'View in 3D' button in footer.tmpl always generates an ifc:// URL with a commit hash (@abc123...). This is correct for reproducibility, but users who want to track 'the latest version of main' must manually change the URL from @\u003chash\u003e to @heads/main. There is no indication in the viewer that the model is pinned to a specific commit. Add a visual indicator (e.g. 'commit abc1234 · switch to heads/main') in the viewer toolbar when the ref is a commit hash, offering a one-click switch to the branch tip. The branch name could be inferred from Forgejo's API given the commit hash.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:58Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:04:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-gy8","title":"Viewer: 'View in 3D' uses commit hash but no UI affordance to switch to branch tip","description":"The 'View in 3D' button in footer.tmpl always generates an ifc:// URL with a commit hash (@abc123...). This is correct for reproducibility, but users who want to track 'the latest version of main' must manually change the URL from @\u003chash\u003e to @heads/main. There is no indication in the viewer that the model is pinned to a specific commit. Add a visual indicator (e.g. 'commit abc1234 · switch to heads/main') in the viewer toolbar when the ref is a commit hash, offering a one-click switch to the branch tip. The branch name could be inferred from Forgejo's API given the commit hash.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:58Z","created_by":"Bruno Postle","updated_at":"2026-04-25T18:12:57Z","started_at":"2026-04-25T18:02:24Z","closed_at":"2026-04-25T18:12:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-dag","title":"Viewer: no model metadata panel — disciplines, spatial structure, element counts","description":"Beyond individual element selection, reviewers and designers need a model overview: what disciplines/types are present, how many elements, what building storeys exist, what's the spatial structure (site/building/storey/space hierarchy). A collapsible side panel showing IFC spatial tree and type breakdown would give immediate context and allow navigating by storey or discipline. Without this, reviewers must already know the model structure before opening the viewer.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:55Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:04:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-ov6","title":"Viewer selector only supports type names — property filters require server round-trip","description":"The viewer's applySelector() only supports IFC type names and their unions ('IfcWall+IfcSlab'). Full IfcOpenShell selector syntax — filter by property value (Pset_WallCommon.FireRating=2HR), by GlobalId, by name (.Name='Core Wall'), by spatial containment — is not supported. For collaboration, reviewers need to select specific elements ('all walls with fire rating 2HR that aren't compliant') not just all walls. The service's /preview endpoint already handles full selector syntax server-side. The viewer should forward complex selectors to the service and use the rendered image, or fetch the matching GlobalIds from a new /resolve-selector service endpoint and apply them in the viewer.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:38Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:04:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-f3p","title":"footer.tmpl: 'View in 3D' button absent from file history (git log) page","description":"The 'View in 3D' button is injected on the file view page (showing one commit of a file). It is not shown on the file history page (the list of commits that touched a file), which is where users naturally go to see how a model evolved over time. Each entry in the history list already has a commit hash in its href. Injecting a 'View in 3D' link or button alongside each history entry would let reviewers quickly compare model states across commits — which is a primary collaboration use case.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:23Z","created_by":"Bruno Postle","updated_at":"2026-04-24T14:04:56Z","started_at":"2026-04-24T13:21:51Z","closed_at":"2026-04-24T14:04:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/forgejo/custom/public/assets/viewer.html b/forgejo/custom/public/assets/viewer.html index d843c8c..081d9f2 100644 --- a/forgejo/custom/public/assets/viewer.html +++ b/forgejo/custom/public/assets/viewer.html @@ -50,6 +50,12 @@ .tb-btn:hover { background: rgba(255,255,255,0.14); } .tb-btn.active { color: #a8c8ff; background: rgba(80,120,255,0.2); border-color: rgba(80,120,255,0.5); } + #ref-badge { + font-size: 10px; color: #f0a050; font-family: monospace; + background: rgba(255,160,80,0.12); border: 1px solid rgba(255,160,80,0.3); + border-radius: 4px; padding: 2px 6px; white-space: nowrap; flex-shrink: 0; + cursor: default; + } /* BCF panel */ #bcf-comment { flex: 3; } #bcf-title { flex: 2; } @@ -127,6 +133,8 @@ + +
{ + const targetRef = `heads/${branchName}`; + refSwitchBtn.textContent = `→ ${targetRef}`; + refSwitchBtn.style.display = ""; + refSwitchBtn.addEventListener("click", () => { + const qI = ifcUrl.indexOf("?"); + const base = qI < 0 ? ifcUrl : ifcUrl.slice(0, qI); + const tail = qI < 0 ? "" : ifcUrl.slice(qI); + loadUrl(base.slice(0, base.lastIndexOf("@") + 1) + targetRef + tail); + }); + }) + .catch(() => { /* silent — badge alone is sufficient */ }); + } + } + // ----------------------------------------------------------------------- // Navigate to the viewer with a different ifc:// URL. // ----------------------------------------------------------------------- @@ -802,6 +833,39 @@ propsPanel.style.display = ""; } + // ----------------------------------------------------------------------- + // Commit-pin indicator helpers. + // ----------------------------------------------------------------------- + function isCommitHash(ref) { + return /^[0-9a-f]{7,40}$/.test(ref); + } + + async function fetchBranchForCommit(host, repoSuffix, hash) { + const proto = host.startsWith("localhost") ? "http" : "https"; + const apiBase = `${proto}://${host}/api/v1/repos/${repoSuffix}`; + const sig = AbortSignal.timeout(3000); + const [branchRes, repoRes] = await Promise.allSettled([ + fetch(`${apiBase}/branches?limit=50`, { signal: sig }), + fetch(apiBase, { signal: sig }), + ]); + let defaultBranch = "main"; + if (repoRes.status === "fulfilled" && repoRes.value.ok) { + const info = await repoRes.value.json().catch(() => null); + if (info?.default_branch) defaultBranch = info.default_branch; + } + if (branchRes.status === "fulfilled" && branchRes.value.ok) { + const branches = await branchRes.value.json().catch(() => []); + const h = hash.toLowerCase(); + for (const b of (branches ?? [])) { + const id = (b.commit?.id ?? "").toLowerCase(); + if (id.startsWith(h) || h.startsWith(id.slice(0, Math.max(h.length, 7)))) { + return b.name; + } + } + } + return defaultBranch; + } + // ----------------------------------------------------------------------- // Main // -----------------------------------------------------------------------