From 9afeae5d446e4227570b85b8db129fddb03d4435 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 29 Apr 2026 22:24:52 +0100 Subject: [PATCH] bcf_api: add topic filtering; documents_api: add GET /document-metadata BCF list_topics now maps topic_status, assigned_to, label, modified_after, modified_before query params to Forgejo issue API params. documents_api adds GET /document-metadata/{document_id} which decodes the document_id and proxies Forgejo's contents endpoint for file metadata. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 6 ++--- ifcurl/bcf_api.py | 14 +++++++++- ifcurl/documents_api.py | 20 +++++++++++++- tests/test_bcf_api.py | 54 +++++++++++++++++++++++++++++++++++++ tests/test_documents_api.py | 44 ++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6cf3399..29faa50 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -48,8 +48,8 @@ {"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-xfi","title":"Implement OpenCDE document picker (select-documents flow)","description":"The OpenCDE Documents API defines a browser-redirect flow for document discovery. Implement POST /documents/1.0/select-documents which returns a select_documents_url. That URL serves a small HTML page: owner/repo selector then file-tree browser. When user selects an IFC file, generate the document_id and redirect to the callback_url with document_ids[] params.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:16Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-bil","title":"Implement OpenCDE OAuth2 proxy routes","description":"The OpenCDE Foundation API requires OAuth2 endpoints. Forgejo already has OAuth2 support — proxy through to it. Routes needed: GET /foundation/1.0/oauth2/auth_url (return Forgejo OAuth2 auth URL), POST /foundation/1.0/oauth2/token (proxy code exchange to Forgejo token endpoint), POST /foundation/1.0/oauth2/token_refresh (proxy refresh). Requires a one-time step of registering an OAuth2 app in Forgejo admin and storing client_id/client_secret as env vars.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:15Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-k3v","title":"Add GET /documents/1.0/document-metadata/{document_id}","description":"The OpenCDE Documents API spec includes a GET endpoint for individual file metadata. Implement it: decode the document_id, fetch the file's latest info from Forgejo (name, size, last-modified), return as a metadata response.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:13Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-ye6","title":"Add BCF topic filtering query params","description":"The BCF 3.0 REST spec defines query parameters for GET /topics (topic_status, topic_type, priority, assignee, etc.). Currently we return all topics unfiltered. Translate BCF filter params to Forgejo issues API params (state, labels, assignee) and pass them through.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:00Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-k3v","title":"Add GET /documents/1.0/document-metadata/{document_id}","description":"The OpenCDE Documents API spec includes a GET endpoint for individual file metadata. Implement it: decode the document_id, fetch the file's latest info from Forgejo (name, size, last-modified), return as a metadata response.","status":"in_progress","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:13Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:22:34Z","started_at":"2026-04-29T21:22:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-ye6","title":"Add BCF topic filtering query params","description":"The BCF 3.0 REST spec defines query parameters for GET /topics (topic_status, topic_type, priority, assignee, etc.). Currently we return all topics unfiltered. Translate BCF filter params to Forgejo issues API params (state, labels, assignee) and pass them through.","status":"in_progress","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:00Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:22:21Z","started_at":"2026-04-29T21:22:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-lwl","title":"Add OpenCDE Foundation API discovery endpoint","description":"Expose GET /foundation/versions on the Forgejo instance so OpenCDE clients can auto-discover which APIs the server supports. Returns JSON declaring BCF API 3.0 and Documents API with their base URLs and OAuth2 configuration.\n\n## Architecture\n\nStandalone Python daemon, proxied at /foundation/ on the Forgejo hostname. Static or config-driven JSON response — no Forgejo internals needed, no auth required (Foundation API discovery endpoint is public by spec). Likely a small route in the existing preview service.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:06Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:21:36Z","started_at":"2026-04-29T18:13:51Z","closed_at":"2026-04-29T18:21:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"ifcurl-3td","title":"Add status hint when clip mode is active","description":"When the user enters clip mode the cursor changes to a crosshair but there is no other feedback explaining that a double-click is needed to place a plane. Add a short hint (e.g. in the status bar or as a tooltip) saying 'Double-click to place clip plane' while clip mode is active, and clear it when clip mode is deactivated.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-27T19:45:47Z","created_by":"Bruno Postle","updated_at":"2026-04-27T19:45:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-5u6","title":"Evaluate replacing ifc_url.go markdown extension with JS injection","description":"ifc_url.go is a Goldmark AST extension that replaces ifc:// URLs in rendered markdown with preview figure elements (img + link). This requires patching and recompiling Forgejo. A JS alternative in footer.tmpl could handle the [title](ifc://...) markdown link case by scanning document.querySelectorAll('a[href^=\"ifc://\"]') and replacing matched anchors with figure elements at runtime — no Go changes needed. The limitation: bare ifc://... text in markdown won't be auto-linked by Goldmark (unknown scheme renders as plain text), so bare URLs would need text-node scanning in JS, which is fiddly and causes a render flash. Decision: replace with JS if users can commit to always using [title](ifc://...) link syntax; keep Go extension if bare URLs in markdown are a real use case.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:05:32Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:40:02Z","started_at":"2026-04-27T06:39:11Z","closed_at":"2026-04-27T06:40:02Z","close_reason":"Keep Go extension. Issue button (viewer.js:261) emits bare ifc:// URLs into markdown, making bare-URL rendering essential. JS text-node scanning works but causes a render flash. A clean alternative would be registering ifc:// as a Goldmark autolink scheme so bare URLs become \u003ca\u003e elements JS can pick up — worth revisiting if upgrade burden grows, but not worth doing now.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -112,5 +112,5 @@ {"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."} {"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."} +{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."} diff --git a/ifcurl/bcf_api.py b/ifcurl/bcf_api.py index 245efb8..f755453 100644 --- a/ifcurl/bcf_api.py +++ b/ifcurl/bcf_api.py @@ -246,7 +246,19 @@ def get_extensions(owner: str, repo: str) -> JSONResponse: @router.get("/projects/{owner}/{repo}/topics") def list_topics(owner: str, repo: str, request: Request) -> JSONResponse: auth = _auth(request) - issues = _fget(f"/api/v1/repos/{owner}/{repo}/issues", auth, params={"type": "issues", "state": "open", "limit": 50}) + qp = request.query_params + forgejo_params: dict = {"type": "issues", "limit": 50} + status = qp.get("topic_status") + forgejo_params["state"] = _STATUS_REVERSE.get(status, "open") if status else "open" + if assigned_to := qp.get("assigned_to"): + forgejo_params["assignee"] = assigned_to + if label := qp.get("label"): + forgejo_params["label"] = label + if since := qp.get("modified_after"): + forgejo_params["since"] = since + if before := qp.get("modified_before"): + forgejo_params["before"] = before + issues = _fget(f"/api/v1/repos/{owner}/{repo}/issues", auth, params=forgejo_params) return JSONResponse([_issue_to_topic(i, owner, repo) for i in (issues if isinstance(issues, list) else [])]) diff --git a/ifcurl/documents_api.py b/ifcurl/documents_api.py index c28762b..2b8c4b1 100644 --- a/ifcurl/documents_api.py +++ b/ifcurl/documents_api.py @@ -41,7 +41,7 @@ from __future__ import annotations import base64 -from fastapi import APIRouter, Body, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from ifcurl.bcf_api import _auth, _fget @@ -134,6 +134,24 @@ def _versions_for_file(owner: str, repo: str, path: str, auth: str | None, forge # Route # --------------------------------------------------------------------------- +@router.get("/document-metadata/{document_id}") +def get_document_metadata(document_id: str, request: Request) -> JSONResponse: + """Return metadata for a single document_id.""" + auth = _auth(request) + try: + owner, repo, path = decode_document_id(document_id) + except ValueError: + raise HTTPException(status_code=404, detail="Invalid document_id") + info = _fget(f"/api/v1/repos/{owner}/{repo}/contents/{path}", auth) + if not isinstance(info, dict): + raise HTTPException(status_code=404, detail="File not found") + return JSONResponse({ + "document_id": document_id, + "name": info.get("name", path.rsplit("/", 1)[-1]), + "description": f"{owner}/{repo}/{path}", + }) + + @router.post("/document-versions") def document_versions(request: Request, body: dict = Body(default={})) -> JSONResponse: """Return tagged releases + HEAD for each requested document_id. diff --git a/tests/test_bcf_api.py b/tests/test_bcf_api.py index f2de378..7b0f4f0 100644 --- a/tests/test_bcf_api.py +++ b/tests/test_bcf_api.py @@ -196,6 +196,60 @@ class TestTopicRoutes: assert len(topics) == 2 assert topics[0]["title"] == "Crack in wall" + def test_list_topics_default_state_is_open(self): + captured = {} + def mock_fget(path, auth, params=None): + captured.update(params or {}) + return [ISSUE_1] + with patch("ifcurl.bcf_api._fget", side_effect=mock_fget): + client.get(f"/bcf/3.0/projects/{OWNER}/{REPO}/topics", headers={"Authorization": AUTH}) + assert captured.get("state") == "open" + + def test_list_topics_filter_closed(self): + captured = {} + def mock_fget(path, auth, params=None): + captured.update(params or {}) + return [ISSUE_2] + with patch("ifcurl.bcf_api._fget", side_effect=mock_fget): + client.get(f"/bcf/3.0/projects/{OWNER}/{REPO}/topics?topic_status=Closed", headers={"Authorization": AUTH}) + assert captured.get("state") == "closed" + + def test_list_topics_filter_open(self): + captured = {} + def mock_fget(path, auth, params=None): + captured.update(params or {}) + return [ISSUE_1] + with patch("ifcurl.bcf_api._fget", side_effect=mock_fget): + client.get(f"/bcf/3.0/projects/{OWNER}/{REPO}/topics?topic_status=Open", headers={"Authorization": AUTH}) + assert captured.get("state") == "open" + + def test_list_topics_filter_assigned_to(self): + captured = {} + def mock_fget(path, auth, params=None): + captured.update(params or {}) + return [ISSUE_1] + with patch("ifcurl.bcf_api._fget", side_effect=mock_fget): + client.get(f"/bcf/3.0/projects/{OWNER}/{REPO}/topics?assigned_to=alice", headers={"Authorization": AUTH}) + assert captured.get("assignee") == "alice" + + def test_list_topics_filter_label(self): + captured = {} + def mock_fget(path, auth, params=None): + captured.update(params or {}) + return [ISSUE_1] + with patch("ifcurl.bcf_api._fget", side_effect=mock_fget): + client.get(f"/bcf/3.0/projects/{OWNER}/{REPO}/topics?label=urgent", headers={"Authorization": AUTH}) + assert captured.get("label") == "urgent" + + def test_list_topics_filter_modified_after(self): + captured = {} + def mock_fget(path, auth, params=None): + captured.update(params or {}) + return [ISSUE_1] + with patch("ifcurl.bcf_api._fget", side_effect=mock_fget): + client.get(f"/bcf/3.0/projects/{OWNER}/{REPO}/topics?modified_after=2026-01-01T00:00:00Z", headers={"Authorization": AUTH}) + assert captured.get("since") == "2026-01-01T00:00:00Z" + def test_get_topic(self): tguid = make_topic_guid(OWNER, REPO, 1) with patch("ifcurl.bcf_api._fget", return_value=ISSUE_1): diff --git a/tests/test_documents_api.py b/tests/test_documents_api.py index 1c88c86..115de61 100644 --- a/tests/test_documents_api.py +++ b/tests/test_documents_api.py @@ -176,3 +176,47 @@ class TestDocumentVersions: doc_id = encode_document_id(OWNER, REPO, PATH) r = _post([doc_id], tags=[], head_commits=[]) assert r.json() == [] + + +# --------------------------------------------------------------------------- +# GET /documents/1.0/document-metadata/{document_id} +# --------------------------------------------------------------------------- + +FORGEJO_FILE_INFO = { + "name": "building.ifc", + "path": PATH, + "sha": "abcdef1234567890", + "size": 98765, + "type": "file", +} + + +class TestDocumentMetadata: + def test_returns_200(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + with patch("ifcurl.documents_api._fget", return_value=FORGEJO_FILE_INFO): + r = client.get(f"/documents/1.0/document-metadata/{doc_id}") + assert r.status_code == 200 + + def test_returns_document_id(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + with patch("ifcurl.documents_api._fget", return_value=FORGEJO_FILE_INFO): + r = client.get(f"/documents/1.0/document-metadata/{doc_id}") + assert r.json()["document_id"] == doc_id + + def test_returns_filename(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + with patch("ifcurl.documents_api._fget", return_value=FORGEJO_FILE_INFO): + r = client.get(f"/documents/1.0/document-metadata/{doc_id}") + assert r.json()["name"] == "building.ifc" + + def test_invalid_document_id_returns_404(self): + r = client.get("/documents/1.0/document-metadata/not-valid!!!!") + assert r.status_code == 404 + + def test_forgejo_404_propagates(self): + from fastapi import HTTPException + doc_id = encode_document_id(OWNER, REPO, PATH) + with patch("ifcurl.documents_api._fget", side_effect=HTTPException(status_code=404)): + r = client.get(f"/documents/1.0/document-metadata/{doc_id}") + assert r.status_code == 404