From ec86bf55a8252af80ac79a233f9ff7c438b29e0f Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 03:11:40 +0000 Subject: [PATCH] Scope mobile Search by repository --- README.md | 12 +++--- frontend/commands.js | 7 +++- frontend/dashboard.css | 3 ++ frontend/dashboard.js | 54 +++++++++++++++++++++++++- frontend/index.html | 4 ++ frontend/login.js | 13 ++++++- frontend/task-overlay-history.js | 9 ++++- src/gitea_proxy.py | 4 ++ src/main.py | 22 +++++++++-- tests/test_api_paths.py | 1 + tests/test_command_palette.py | 36 +++++++++++++++++ tests/test_dashboard_auth.py | 3 +- tests/test_global_search.py | 62 ++++++++++++++++++++++++++++++ tests/test_login_frontend.py | 4 +- tests/test_task_overlay_history.py | 25 ++++++++---- 15 files changed, 234 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 79f3df8..9d4d60f 100644 --- a/README.md +++ b/README.md @@ -408,11 +408,13 @@ filter, preserves the selected release lane, shows the current draft count, resp the device safe area, and moves out of the way while a full-screen task is open. Desktop layout is unchanged. -Mobile **Search** provides touch-sized **Type** and **Status** controls. Operators can -scope results to issues, pull requests, open work, closed work, or all accessible work; -the server applies that scope before pagination. The selected scope is bounded and -addressable, survives preview/back, reload, sharing, and sign-in continuation, and a -scope change cancels obsolete requests before restarting at the first page. +Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls. +Operators can find and select a repository visible to their Gitea account, then scope results +to that exact repository, issues, pull requests, open work, closed work, or all accessible work; +the server applies every scope before pagination. Clearing Repository returns to organization-wide +Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing, +and sign-in continuation, and a scope change cancels obsolete requests before restarting at the +first page. Repository lookup failure leaves organization-wide Search usable. My Work also has an account-synced **Later** queue. **Later today** defers an item for four hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time** diff --git a/frontend/commands.js b/frontend/commands.js index 5a693f2..8ae3879 100644 --- a/frontend/commands.js +++ b/frontend/commands.js @@ -74,11 +74,16 @@ timer = setTimeout(() => requestPage(query, 1, current, false), delay); }, setScope(value) { + const repository = typeof value?.repository === 'string' && + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim()) + ? value.repository.trim() : ''; const next = { kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all', state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all', + ...(repository ? { repository } : {}), }; - if (next.kind === scope.kind && next.state === scope.state) return; + if (next.kind === scope.kind && next.state === scope.state && + (next.repository || '') === (scope.repository || '')) return; const query = state.query; scope = next; this.setQuery(query); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 06f806f..0f92001 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -118,6 +118,8 @@ textarea { resize: vertical; min-height: 120px; } .cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; } .cmd-search-scope label { font-size:12px; color:#cbd5e1; } .cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; } +.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; } +.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; } #cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; } .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; } @@ -691,6 +693,7 @@ textarea { resize: vertical; min-height: 120px; } #cmd-input { flex:0 0 auto; min-height:44px; } .cmd-search-scope { grid-template-columns:auto minmax(0,1fr); } #cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; } + #cmd-search-repository { min-height:44px; width:100%; } #cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); } .create-issue-panel { width:100%; border-left:0; padding:14px; } .pull-sheet-panel { width:100%; border-left:0; padding:14px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index b099e8a..b14a8d3 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4287,7 +4287,8 @@ let commandSelection = -1; async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}) { const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page + - '&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state), { + '&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) + + (scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : ''), { headers: { Accept:'application/json' }, signal, }); @@ -4303,11 +4304,13 @@ }, }); function currentSearchScope() { - return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value }; + return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value, + repository:qs('#cmd-search-repository').value.trim() }; } function applySearchScope(scope = {kind:'all', state:'all'}) { qs('#cmd-search-kind').value = scope.kind; qs('#cmd-search-state').value = scope.state; + qs('#cmd-search-repository').value = scope.repository || ''; commandSearch.setScope(scope); } function safeSearchUrl(value) { @@ -4407,6 +4410,7 @@ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number, search_kind:scope.kind, search_state:scope.state, }); + if (scope.repository) params.set('search_repository', scope.repository); return new URL('?' + params, window.location.origin + window.location.pathname).href; } function createSearchStart(claim) { @@ -4584,6 +4588,52 @@ } qs('#cmd-search-kind').addEventListener('change', changeSearchScope); qs('#cmd-search-state').addEventListener('change', changeSearchScope); + let repositoryLookupController = null; + let repositoryLookupTimer = null; + async function suggestSearchRepositories() { + const input = qs('#cmd-search-repository'); + const query = input.value.trim(); + const status = qs('#cmd-repository-status'); + if (repositoryLookupTimer !== null) clearTimeout(repositoryLookupTimer); + if (repositoryLookupController) repositoryLookupController.abort(); + repositoryLookupController = null; + if (!query) { + qs('#cmd-search-repositories').innerHTML = ''; + status.textContent = 'Searching all accessible repositories.'; + changeSearchScope(); + return; + } + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(query)) changeSearchScope(); + if (query.length < 2) { + status.textContent = 'Enter at least 2 characters to find a repository.'; + return; + } + status.textContent = 'Finding accessible repositories…'; + repositoryLookupTimer = setTimeout(async () => { + const controller = new AbortController(); + repositoryLookupController = controller; + try { + const response = await fetch('api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20', { + headers:{ Accept:'application/json' }, signal:controller.signal, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Repository lookup unavailable.'); + if (repositoryLookupController !== controller || input.value.trim() !== query) return; + const items = Array.isArray(payload.items) ? payload.items : []; + qs('#cmd-search-repositories').innerHTML = items.map(item => + '' + ).join(''); + status.textContent = items.length ? 'Choose an accessible repository.' : 'No accessible repositories found.'; + } catch (error) { + if (error?.name !== 'AbortError') status.textContent = 'Repository lookup unavailable; global Search still works.'; + } finally { + if (repositoryLookupController === controller) repositoryLookupController = null; + } + }, 200); + } + qs('#cmd-search-repository').addEventListener('input', suggestSearchRepositories); + qs('#cmd-search-repository').addEventListener('change', changeSearchScope); + qs('#cmd-search-repository').addEventListener('search', suggestSearchRepositories); qs('#cmd-input').addEventListener('input', (e) => { commandSelection = -1; taskOverlayHistory.update({ query:e.target.value }); diff --git a/frontend/index.html b/frontend/index.html index e3ccad6..06591d8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -443,6 +443,10 @@ + + + +
diff --git a/frontend/login.js b/frontend/login.js index 9dd4b1c..da67bac 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -4,7 +4,8 @@ }(typeof self !== 'undefined' ? self : this, function createLoginController(options) { function validShareContinuation(value) { if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './'; - const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200, preview: 200 }; + const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200, + preview: 200, search_kind: 5, search_state: 6, search_repository: 161 }; const params = new URLSearchParams(value.slice(3)); const entries = Array.from(params.entries()); if (!entries.length) return './'; @@ -18,9 +19,17 @@ const search = params.get('search'); const preview = params.get('preview'); if (search !== null || preview !== null) { - if (entries.some(([name]) => !['search', 'preview'].includes(name))) return './'; + if (entries.some(([name]) => ![ + 'search', 'preview', 'search_kind', 'search_state', 'search_repository' + ].includes(name))) return './'; if (search === null || preview === null) return './'; if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './'; + const kind = params.get('search_kind'); + const state = params.get('search_state'); + const repository = params.get('search_repository'); + if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './'; + if (state !== null && !['all', 'open', 'closed'].includes(state)) return './'; + if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './'; return value; } const launch = params.get('launch'); diff --git a/frontend/task-overlay-history.js b/frontend/task-overlay-history.js index 988ce14..72353a5 100644 --- a/frontend/task-overlay-history.js +++ b/frontend/task-overlay-history.js @@ -15,9 +15,13 @@ } function cleanScope(value) { + const repository = typeof value?.repository === 'string' && + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim()) + ? value.repository.trim() : ''; return { kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all', state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all', + repository, }; } @@ -64,9 +68,10 @@ const query = cleanQuery(rawQuery); if (rawQuery.trim() && !query) return { kind:null }; const preview = parsePreview(params.get('preview')); - const hasScope = params.has('search_kind') || params.has('search_state'); + const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository'); const scope = hasScope ? cleanScope({ kind:params.get('search_kind'), state:params.get('search_state'), + repository:params.get('search_repository'), }) : null; return { kind:preview ? 'search-preview' : 'search', @@ -96,11 +101,13 @@ params.delete('preview'); params.delete('search_kind'); params.delete('search_state'); + params.delete('search_repository'); if (searchKinds.has(detail.kind)) { params.set('search', detail.query || ''); if (detail.scope) { params.set('search_kind', detail.scope.kind); params.set('search_state', detail.scope.state); + if (detail.scope.repository) params.set('search_repository', detail.scope.repository); } if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview)); } diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 851b308..a51974c 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -469,6 +469,7 @@ async def global_search( page: int = 1, kind: str = "all", state: str = "all", + repository: str | None = None, ) -> dict: """Search accessible issues and pulls concurrently with balanced pagination.""" item_types = ("issues", "pulls") if kind == "all" else ( @@ -480,6 +481,9 @@ async def global_search( "q": query, "type": item_type, "state": state, "limit": stream_limit, "page": page, } + if repository: + owner, repo = repository.split("/", 1) + params.update({"owner": owner, "repo": repo}) response = await _get_client().get( "/api/v1/repos/issues/search", headers=_auth(), diff --git a/src/main.py b/src/main.py index 7c93e37..182386b 100644 --- a/src/main.py +++ b/src/main.py @@ -1006,15 +1006,17 @@ def _share_target_login_redirect(request: Request) -> str: search_values = request.query_params.getlist("search") preview_values = request.query_params.getlist("preview") if search_values or preview_values: - allowed = {"search", "preview", "search_kind", "search_state"} + allowed = {"search", "preview", "search_kind", "search_state", "search_repository"} kind_values = request.query_params.getlist("search_kind") state_values = request.query_params.getlist("search_state") + repository_values = request.query_params.getlist("search_repository") if ( set(request.query_params.keys()) - allowed or len(search_values) != 1 or len(preview_values) != 1 or len(kind_values) > 1 or len(state_values) > 1 + or len(repository_values) > 1 or len(search_values[0]) > 200 or len(preview_values[0]) > 200 or not re.fullmatch( @@ -1030,6 +1032,10 @@ def _share_target_login_redirect(request: Request) -> str: continuation_values.append(("search_kind", kind_values[0])) if state_values and state_values[0] in {"all", "open", "closed"}: continuation_values.append(("search_state", state_values[0])) + if repository_values and re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository_values[0] + ): + continuation_values.append(("search_repository", repository_values[0])) continuation = urlencode(continuation_values) return f"login?{urlencode({'continue': f'./?{continuation}'})}" limits = {"title": 200, "text": 8000, "url": 2048} @@ -2723,13 +2729,20 @@ async def global_search( page: int = Query(default=1, ge=1, le=100), kind: Literal["all", "issue", "pull"] = Query(default="all"), state: Literal["all", "open", "closed"] = Query(default="all"), + repository: str | None = Query( + default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", max_length=161 + ), ) -> JSONResponse: query = q.strip() if len(query) < 2: raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters") try: + arguments = (query, limit, page, kind, state) + operation = gitea_proxy.global_search(*arguments, repository) if repository else ( + gitea_proxy.global_search(*arguments) + ) result = await asyncio.wait_for( - gitea_proxy.global_search(query, limit, page, kind, state), + operation, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, ) except Exception: @@ -2738,7 +2751,10 @@ async def global_search( status_code=503, headers={"Retry-After": "1"}, ) - return JSONResponse({"query": query, "scope": {"kind": kind, "state": state}, **result}) + scope = {"kind": kind, "state": state} + if repository: + scope["repository"] = repository + return JSONResponse({"query": query, "scope": scope, **result}) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview") diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index d09d461..aaf3e4d 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -18,6 +18,7 @@ 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/repositories/search?q=", "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 b1b944b..fec4c8a 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -71,6 +71,42 @@ def test_mobile_search_renders_touch_sized_type_and_status_scope_controls(): assert "'&state=' + encodeURIComponent(scope.state)" in html +def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it(): + html = dashboard_bundle_text() + css = (FRONTEND / "dashboard.css").read_text() + + assert '' in html + assert '' in html + assert "api/v1/repositories/search?q=" in html + assert "repository:qs('#cmd-search-repository').value" in html + assert "'&repository=' + encodeURIComponent(scope.repository)" in html + assert '#cmd-search-repository' in css + + +def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search(): + script = f""" +const filterCommands = require({json.dumps(str(COMMANDS))}); +(async () => {{ +const pending = []; +const controller = filterCommands.createGlobalSearchController({{ + delay: 0, + search: (query, signal, page, scope) => new Promise(resolve => pending.push({{signal, scope, resolve}})), + onState() {{}}, +}}); +controller.setQuery('mobile'); +await new Promise(resolve => setTimeout(resolve, 0)); +controller.setScope({{kind:'all', state:'all', repository:'stackchain/api'}}); +await new Promise(resolve => setTimeout(resolve, 0)); +if (!pending[0].signal.aborted) throw new Error('repository change did not abort old request'); +if (pending[1].scope.repository !== 'stackchain/api') throw new Error('repository was not forwarded'); +pending[1].resolve({{items:[],has_more:false,next_page:2}}); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_remote_command_search_ignores_stale_responses(): script = f""" const filterCommands = require({json.dumps(str(COMMANDS))}); diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index a28ed1b..5206462 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -925,6 +925,7 @@ async def test_anonymous_search_preview_preserves_only_valid_search_scope(access valid = await client.get("/", params={ "search": "release blocker", "preview": "pull:stackchain/api:42", "search_kind": "pull", "search_state": "open", + "search_repository": "stackchain/api", }) invalid = await client.get("/", params={ "search": "release blocker", "preview": "pull:stackchain/api:42", @@ -935,7 +936,7 @@ async def test_anonymous_search_preview_preserves_only_valid_search_scope(access invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0] assert valid_continue.endswith( "search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&" - "search_kind=pull&search_state=open" + "search_kind=pull&search_state=open&search_repository=stackchain%2Fapi" ) assert invalid_continue == ( "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42" diff --git a/tests/test_global_search.py b/tests/test_global_search.py index b966f1e..b8b73b8 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -6,6 +6,68 @@ import pytest from src import gitea_proxy, main +@pytest.mark.anyio +async def test_global_search_endpoint_scopes_every_page_to_an_exact_repository(monkeypatch): + requested = [] + + async def search(query, limit, page, kind, state, repository): + requested.append((query, limit, page, kind, state, repository)) + return {"items": [], "partial": False, "has_more": False, "next_page": 4} + + 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&page=3&kind=pull&state=open&repository=stackchain%2Fapi" + ) + + assert response.status_code == 200 + assert requested == [("mobile", 7, 3, "pull", "open", "stackchain/api")] + assert response.json()["scope"] == { + "kind": "pull", "state": "open", "repository": "stackchain/api" + } + + +@pytest.mark.anyio +async def test_global_search_rejects_malformed_repository_scope_before_upstream(monkeypatch): + called = False + + async def search(*args): + nonlocal called + called = True + + 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&repository=stackchain%2Fapi%2Fsecrets" + ) + + assert response.status_code == 422 + assert called is False + + +@pytest.mark.anyio +async def test_global_search_repository_scope_reaches_issue_and_pull_streams(): + requests = [] + + async def handler(request): + requests.append(dict(request.url.params)) + return httpx.Response(200, json=[]) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + await gitea_proxy.global_search( + "mobile", limit=6, page=2, repository="stackchain/api" + ) + finally: + await gitea_proxy.stop_client() + + assert len(requests) == 2 + assert all(request["owner"] == "stackchain" and request["repo"] == "api" for request in requests) + assert all(request["page"] == "2" for request in requests) + + @pytest.mark.anyio async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch): requested = [] diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index d96a877..209d203 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -257,7 +257,7 @@ async function destination(continuation) {{ return replaced; }} (async () => process.stdout.write(JSON.stringify({{ - valid:await destination('./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'), + valid:await destination('./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi'), malformed:await destination('./?search=release&preview=issue%3Astackchain%2Fapi%3A0'), unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'), }})))().catch(error => {{ console.error(error); process.exit(1); }}); @@ -266,7 +266,7 @@ async function destination(continuation) {{ assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { - "valid": "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9", + "valid": "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi", "malformed": "./", "unknown": "./", } diff --git a/tests/test_task_overlay_history.py b/tests/test_task_overlay_history.py index 1271733..f1ed096 100644 --- a/tests/test_task_overlay_history.py +++ b/tests/test_task_overlay_history.py @@ -290,7 +290,7 @@ process.stdout.write(JSON.stringify({{ assert payload["oversized"]["state"] == {"kind": None} -def test_search_history_round_trips_only_bounded_type_and_status_scope(): +def test_search_history_round_trips_bounded_type_status_and_repository_scope(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); function restore(search) {{ @@ -306,11 +306,11 @@ const history = {{state:null, pushState(state,title,url) {{this.state=state; thi const controller = createTaskOverlayHistory({{ history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, }}); -controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open'}}}}); +controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open',repository:'stackchain/api'}}}}); process.stdout.write(JSON.stringify({{ opened:controller.currentState(), url:history.url, - valid:restore('?search=mobile&search_kind=issue&search_state=closed'), - invalid:restore('?search=mobile&search_kind=script&search_state=secret'), + valid:restore('?search=mobile&search_kind=issue&search_state=closed&search_repository=stackchain%2Fapi'), + invalid:restore('?search=mobile&search_kind=script&search_state=secret&search_repository=stackchain%2Fapi%2Fprivate'), }})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) @@ -318,11 +318,20 @@ process.stdout.write(JSON.stringify({{ assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) assert payload["opened"] == { - "kind": "search", "query": "mobile", "scope": {"kind": "pull", "state": "open"} + "kind": "search", "query": "mobile", "scope": { + "kind": "pull", "state": "open", "repository": "stackchain/api" + } + } + assert payload["url"] == ( + "/dashboard/?search=mobile&search_kind=pull&search_state=open&" + "search_repository=stackchain%2Fapi" + ) + assert payload["valid"]["state"]["scope"] == { + "kind": "issue", "state": "closed", "repository": "stackchain/api" + } + assert payload["invalid"]["state"]["scope"] == { + "kind": "all", "state": "all", "repository": "" } - assert payload["url"] == "/dashboard/?search=mobile&search_kind=pull&search_state=open" - assert payload["valid"]["state"]["scope"] == {"kind": "issue", "state": "closed"} - assert payload["invalid"]["state"]["scope"] == {"kind": "all", "state": "all"} def test_dashboard_persists_search_query_and_restores_canonical_preview(): -- 2.43.0