From 7651e5b09050db3aab24bf5e1ca15c0be4549364 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 12:27:53 +0000 Subject: [PATCH] feat: add global work search (#195) --- README.md | 8 ++- frontend/commands.js | 41 ++++++++++++- frontend/index.html | 106 +++++++++++++++++++++++++++++----- src/gitea_proxy.py | 54 +++++++++++++++++ src/main.py | 25 +++++++- tests/test_api_paths.py | 1 + tests/test_command_palette.py | 58 +++++++++++++++++++ tests/test_global_search.py | 83 ++++++++++++++++++++++++++ 8 files changed, 359 insertions(+), 17 deletions(-) create mode 100644 tests/test_global_search.py diff --git a/README.md b/README.md index 70b7bf3..9430840 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,12 @@ uvicorn src.main:app --host 127.0.0.1 --port 8000 Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its Gitea connection directly, request `http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing -`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place -it in a tracked configuration file. +`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard +to search commands plus issues and pull requests across every repository visible to +the configured Gitea token. Remote search starts after two characters, is debounced, +and keeps local commands usable if Gitea search is unavailable. The bounded API is +also available at `GET /api/v1/search?q=&limit=<1-25>`. Never commit the token +or place it in a tracked configuration file. For service monitoring, GET `/healthz` is a liveness check that confirms the API process is running and does not contact Gitea. GET `/readyz` is the diff --git a/frontend/commands.js b/frontend/commands.js index 9b56de4..c815df9 100644 --- a/frontend/commands.js +++ b/frontend/commands.js @@ -3,8 +3,47 @@ if (typeof module === 'object' && module.exports) module.exports = filterCommands; if (root) root.filterCommands = filterCommands; })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function filterCommands(commands, filter) { + function filterCommands(commands, filter) { const term = String(filter || '').toLowerCase(); return commands.filter(command => command.name.toLowerCase().includes(term)); + } + + filterCommands.nextSelection = function nextSelection(current, key, count) { + if (count < 1) return -1; + if (key === 'ArrowDown') return (current + 1 + count) % count; + if (key === 'ArrowUp') return (current - 1 + count) % count; + return current; }; + + filterCommands.createGlobalSearchController = function createGlobalSearchController(options) { + const search = options.search; + const onState = options.onState; + const delay = options.delay === undefined ? 250 : options.delay; + let timer = null; + let generation = 0; + + return { + setQuery(value) { + const query = String(value || '').trim(); + generation += 1; + const current = generation; + if (timer !== null) clearTimeout(timer); + if (query.length < 2) { + onState({ status: 'idle', query, items: [] }); + return; + } + onState({ status: 'loading', query, items: [] }); + timer = setTimeout(async () => { + try { + const items = await search(query); + if (current === generation) onState({ status: 'ready', query, items }); + } catch (error) { + if (current === generation) onState({ status: 'error', query, items: [], error }); + } + }, delay); + }, + }; + }; + + return filterCommands; }); diff --git a/frontend/index.html b/frontend/index.html index 71da5d5..10c457d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -40,8 +40,11 @@ input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; textarea { resize: vertical; min-height: 120px; } #cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); } #cmd-palette.open { display: block; } -.cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; } -.cmd-item:hover { background: #10233a; } +.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; } +.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; } +.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; } +.cmd-status { padding:10px; color:#93a4b8; font-size:13px; } +.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; } #whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); } #whiteboard-modal.open, #markdown-modal.open { display: flex; } .modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; } @@ -332,8 +335,8 @@ textarea { resize: vertical; min-height: 120px; }
@@ -1759,17 +1762,95 @@ textarea { resize: vertical; min-height: 120px; } { name: 'Refresh now', run: load }, { name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) }, ]; + let commandSearchState = { status:'idle', query:'', items:[] }; + let commandItems = []; + let commandSelection = -1; + async function searchGlobalWork(query) { + const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', { + headers: { Accept:'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.'); + return Array.isArray(payload.items) ? payload.items : []; + } + const commandSearch = filterCommands.createGlobalSearchController({ + search: searchGlobalWork, + onState: state => { + commandSearchState = state; + renderCommands(state.query); + }, + }); + function safeSearchUrl(value) { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch (_) { return ''; } + } + function runCommandItem(item) { + if (item.command) item.command.run(); + else { + const url = safeSearchUrl(item.result.url); + if (url) window.location.assign(url); + } + qs('#cmd-palette').classList.remove('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'false'); + qs('#cmd-input').value = ''; + } function renderCommands(filter) { const el = qs('#cmd-results'); - const items = filterCommands(commands, filter); - el.innerHTML = items.map((c, idx) => '
' + escapeHtml(c.name) + '
').join(''); + const local = filterCommands(commands, filter).map(command => ({ command })); + const remote = commandSearchState.query === String(filter || '').trim() + ? commandSearchState.items.map(result => ({ result })) : []; + commandItems = local.concat(remote); + if (commandSelection >= commandItems.length) commandSelection = -1; + let html = local.length ? '
Commands
' : ''; + html += local.map((item, idx) => '
' + escapeHtml(item.command.name) + 'Command
').join(''); + if (remote.length) html += '
Issues and pull requests
'; + html += remote.map((item, remoteIdx) => { + const idx = local.length + remoteIdx; + const result = item.result; + return '
' + escapeHtml(result.title) + '' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '
'; + }).join(''); + if (commandSearchState.status === 'loading') html += '
Searching accessible work…
'; + else if (commandSearchState.status === 'error') html += '
Search unavailable. Keep typing or retry.
'; + else if (String(filter || '').trim().length >= 2 && !remote.length) html += '
No matching issues or pull requests.
'; + el.innerHTML = html; el.querySelectorAll('.cmd-item').forEach((item) => { - item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; }); + item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)])); }); } - qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); }); - qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value)); + function openCommandPalette() { + qs('#cmd-palette').classList.add('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'true'); + qs('#cmd-input').focus(); + commandSelection = -1; + renderCommands(qs('#cmd-input').value); + } + qs('#open-palette').addEventListener('click', openCommandPalette); + qs('#cmd-input').addEventListener('input', (e) => { + commandSelection = -1; + renderCommands(e.target.value); + commandSearch.setQuery(e.target.value); + }); + qs('#cmd-input').addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length); + renderCommands(event.currentTarget.value); + const selected = qs('#cmd-results .selected'); + if (selected) selected.scrollIntoView({ block:'nearest' }); + } else if (event.key === 'Enter' && commandSelection >= 0) { + event.preventDefault(); + runCommandItem(commandItems[commandSelection]); + } + }); document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) { + e.preventDefault(); + qs('#cmd-palette').classList.remove('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'false'); + return; + } if (e.key === 'Escape' && findingWork) { e.preventDefault(); closeFindWorkSheet(); @@ -1793,11 +1874,10 @@ textarea { resize: vertical; min-height: 120px; } } if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); - qs('#cmd-palette').classList.toggle('open'); if (qs('#cmd-palette').classList.contains('open')) { - qs('#cmd-input').focus(); - renderCommands(''); - } + qs('#cmd-palette').classList.remove('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'false'); + } else openCommandPalette(); } }); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index b1a19dc..78c80dd 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -183,6 +183,60 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict: } +def _normalize_global_search_item(item: Any, kind: str) -> dict | None: + if not isinstance(item, dict): + return None + repository = item.get("repository") + repository = repository if isinstance(repository, dict) else {} + url = _safe_web_url(item.get("html_url")) + if not ( + isinstance(item.get("number"), int) + and isinstance(item.get("title"), str) + and isinstance(item.get("state"), str) + and isinstance(repository.get("full_name"), str) + and repository.get("full_name") + and url + ): + return None + return { + "kind": kind, + "repository": repository["full_name"], + "number": item["number"], + "title": item["title"], + "state": item["state"], + "url": url, + } + + +async def global_search(query: str, limit: int = 10) -> list[dict]: + """Search accessible issues and pulls concurrently with a bounded result set.""" + async def load(item_type: str) -> Any: + response = await _get_client().get( + "/api/v1/repos/issues/search", + headers=_auth(), + params={"q": query, "type": item_type, "limit": limit, "page": 1}, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise ValueError("Gitea global search response was not a list") + return payload + + issues_payload, pulls_payload = await asyncio.gather(load("issues"), load("pulls")) + results = [] + seen: set[tuple[str, str, int]] = set() + for payload, kind in ((issues_payload, "issue"), (pulls_payload, "pull")): + for item in payload: + normalized = _normalize_global_search_item(item, kind) + if normalized is not None: + identity = (kind, normalized["repository"], normalized["number"]) + if identity in seen: + continue + seen.add(identity) + results.append(normalized) + return results + + def _normalize_available_issue(item: Any) -> dict | None: if ( not isinstance(item, dict) diff --git a/src/main.py b/src/main.py index 4fb4d32..e4babf9 100644 --- a/src/main.py +++ b/src/main.py @@ -66,6 +66,7 @@ AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256 NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0 NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0 WORK_PAGE_TIMEOUT_SECONDS = 5.0 +GLOBAL_SEARCH_TIMEOUT_SECONDS = 3.0 NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0 BULK_NOTIFICATION_CONCURRENCY = 5 BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0 @@ -364,7 +365,7 @@ app.include_router(frontend_router) @app.middleware("http") async def prevent_live_api_caching(request, call_next): response = await call_next(request) - if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") or ( + if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search"} or request.url.path.startswith("/api/v1/work/") or ( request.url.path.startswith("/api/v1/repos/") and request.url.path.endswith("/review") ) or request.url.path.startswith("/api/v1/notifications") or ( @@ -471,6 +472,28 @@ async def context() -> JSONResponse: return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data)) +@app.get("/api/v1/search") +async def global_search( + q: str = Query(min_length=2, max_length=100), + limit: int = Query(default=10, ge=1, le=25), +) -> JSONResponse: + query = q.strip() + if len(query) < 2: + raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters") + try: + items = await asyncio.wait_for( + gitea_proxy.global_search(query, limit), + timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, + ) + except Exception: + return JSONResponse( + {"error": "Search is temporarily unavailable. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse({"query": query, "items": items}) + + @app.get("/api/v1/work/{stream}") async def paged_work( stream: Literal["issue", "pull", "review"], diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index 567e23c..85f9fee 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -19,5 +19,6 @@ def test_api_requests_resolve_inside_dashboard_subpath(): "https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/", "https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=", "https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read", + "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=", "https://forge.alexanderwhitestone.com/dashboard/api/v1/work/", } diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 2c60aac..ce58a9c 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -46,3 +46,61 @@ def test_command_script_resolves_inside_dashboard_subpath(): assert urljoin( "https://forge.alexanderwhitestone.com/dashboard/", command_source ) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js" + + +def test_remote_command_search_ignores_stale_responses(): + script = f""" +const filterCommands = require({json.dumps(str(COMMANDS))}); +(async () => {{ +const pending = new Map(); +const states = []; +const controller = filterCommands.createGlobalSearchController({{ + delay: 0, + search: query => new Promise(resolve => pending.set(query, resolve)), + onState: state => states.push(state), +}}); +controller.setQuery('mobile'); +await new Promise(resolve => setTimeout(resolve, 0)); +controller.setQuery('release'); +await new Promise(resolve => setTimeout(resolve, 0)); +pending.get('release')([{{ title: 'Current result' }}]); +await new Promise(resolve => setTimeout(resolve, 0)); +pending.get('mobile')([{{ title: 'Stale result' }}]); +await new Promise(resolve => setTimeout(resolve, 0)); +const ready = states.filter(state => state.status === 'ready'); +if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`); +if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{ + throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`); +}} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + + subprocess.run( + ["node", "-e", script], + check=True, capture_output=True, text=True, + ) + + +def test_command_selection_wraps_for_arrow_keys(): + script = f""" +const commands = require({json.dumps(str(COMMANDS))}); +if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first'); +if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down'); +if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up'); +if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move'); +""" + + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + +def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath(): + html = (FRONTEND / "index.html").read_text() + + assert 'role="combobox"' in html + assert 'aria-controls="cmd-results"' in html + assert 'role="listbox"' in html + assert "fetch('api/v1/search?q='" in html + assert urljoin( + "https://forge.alexanderwhitestone.com/dashboard/", + "api/v1/search?q=mobile", + ) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile" diff --git a/tests/test_global_search.py b/tests/test_global_search.py new file mode 100644 index 0000000..1ab3fe9 --- /dev/null +++ b/tests/test_global_search.py @@ -0,0 +1,83 @@ +import httpx +import pytest + +from src import gitea_proxy, main + + +@pytest.mark.anyio +async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch): + requested = [] + + async def search(query, limit): + requested.append((query, limit)) + return [{ + "kind": "issue", + "repository": "stackchain/api", + "number": 42, + "title": "Repair mobile queue", + "state": "open", + "url": "https://forge.example/stackchain/api/issues/42", + }] + + monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/search?q=mobile&limit=7") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert requested == [("mobile", 7)] + assert response.json() == {"query": "mobile", "items": [{ + "kind": "issue", + "repository": "stackchain/api", + "number": 42, + "title": "Repair mobile queue", + "state": "open", + "url": "https://forge.example/stackchain/api/issues/42", + }]} + + +@pytest.mark.anyio +async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results(): + requests = [] + + async def handler(request): + requests.append(dict(request.url.params)) + kind = request.url.params["type"] + if kind == "issues": + return httpx.Response(200, json=[{ + "id": 4, "number": 42, "title": "Repair queue", "state": "open", + "repository": {"full_name": "stackchain/api"}, + "html_url": "https://forge.example/stackchain/api/issues/42", + }, { + "id": 4, "number": 42, "title": "Repair queue", "state": "open", + "repository": {"full_name": "stackchain/api"}, + "html_url": "https://forge.example/stackchain/api/issues/42", + }, { + "id": 5, "number": 43, "title": "Unsafe", "state": "open", + "repository": {"full_name": "stackchain/api"}, + "html_url": "javascript:alert(1)", + }]) + return httpx.Response(200, json=[{ + "id": 8, "number": 9, "title": "Improve search", "state": "closed", + "repository": {"full_name": "stackchain/web"}, + "html_url": "https://forge.example/stackchain/web/pulls/9", + }]) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + results = await gitea_proxy.global_search("mobile queue", 7) + finally: + await gitea_proxy.stop_client() + + assert {request["type"] for request in requests} == {"issues", "pulls"} + assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests) + assert results == [{ + "kind": "issue", "repository": "stackchain/api", "number": 42, + "title": "Repair queue", "state": "open", + "url": "https://forge.example/stackchain/api/issues/42", + }, { + "kind": "pull", "repository": "stackchain/web", "number": 9, + "title": "Improve search", "state": "closed", + "url": "https://forge.example/stackchain/web/pulls/9", + }]