Scope mobile Search by repository
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 03:11:40 +00:00
parent 7636e30482
commit ec86bf55a8
15 changed files with 234 additions and 25 deletions

View File

@ -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. the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged. Desktop layout is unchanged.
Mobile **Search** provides touch-sized **Type** and **Status** controls. Operators can Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
scope results to issues, pull requests, open work, closed work, or all accessible work; Operators can find and select a repository visible to their Gitea account, then scope results
the server applies that scope before pagination. The selected scope is bounded and to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
addressable, survives preview/back, reload, sharing, and sign-in continuation, and a the server applies every scope before pagination. Clearing Repository returns to organization-wide
scope change cancels obsolete requests before restarting at the first page. 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 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** hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**

View File

@ -74,11 +74,16 @@
timer = setTimeout(() => requestPage(query, 1, current, false), delay); timer = setTimeout(() => requestPage(query, 1, current, false), delay);
}, },
setScope(value) { 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 = { const next = {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all', kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : '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; const query = state.query;
scope = next; scope = next;
this.setQuery(query); this.setQuery(query);

View File

@ -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 legend { padding:0 4px; color:#93a4b8; font-size:12px; }
.cmd-search-scope label { font-size:12px; color:#cbd5e1; } .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 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-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 { 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-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-input { flex:0 0 auto; min-height:44px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); } .cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; } #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); } #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; } .create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; } .pull-sheet-panel { width:100%; border-left:0; padding:14px; }

View File

@ -4287,7 +4287,8 @@
let commandSelection = -1; let commandSelection = -1;
async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}) { 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 + 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' }, headers: { Accept:'application/json' },
signal, signal,
}); });
@ -4303,11 +4304,13 @@
}, },
}); });
function currentSearchScope() { 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'}) { function applySearchScope(scope = {kind:'all', state:'all'}) {
qs('#cmd-search-kind').value = scope.kind; qs('#cmd-search-kind').value = scope.kind;
qs('#cmd-search-state').value = scope.state; qs('#cmd-search-state').value = scope.state;
qs('#cmd-search-repository').value = scope.repository || '';
commandSearch.setScope(scope); commandSearch.setScope(scope);
} }
function safeSearchUrl(value) { function safeSearchUrl(value) {
@ -4407,6 +4410,7 @@
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number, search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
search_kind:scope.kind, search_state:scope.state, 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; return new URL('?' + params, window.location.origin + window.location.pathname).href;
} }
function createSearchStart(claim) { function createSearchStart(claim) {
@ -4584,6 +4588,52 @@
} }
qs('#cmd-search-kind').addEventListener('change', changeSearchScope); qs('#cmd-search-kind').addEventListener('change', changeSearchScope);
qs('#cmd-search-state').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 =>
'<option value="' + escapeHtml(item.full_name) + '"></option>'
).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) => { qs('#cmd-input').addEventListener('input', (e) => {
commandSelection = -1; commandSelection = -1;
taskOverlayHistory.update({ query:e.target.value }); taskOverlayHistory.update({ query:e.target.value });

View File

@ -443,6 +443,10 @@
<option value="open">Open</option> <option value="open">Open</option>
<option value="closed">Closed</option> <option value="closed">Closed</option>
</select> </select>
<label for="cmd-search-repository">Repository</label>
<input id="cmd-search-repository" type="search" list="cmd-search-repositories" autocomplete="off" placeholder="All repositories" aria-describedby="cmd-repository-status" />
<datalist id="cmd-search-repositories"></datalist>
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset> </fieldset>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div> <div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
<button id="cmd-load-more" hidden>More results</button> <button id="cmd-load-more" hidden>More results</button>

View File

@ -4,7 +4,8 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) { }(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) { function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './'; 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 params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries()); const entries = Array.from(params.entries());
if (!entries.length) return './'; if (!entries.length) return './';
@ -18,9 +19,17 @@
const search = params.get('search'); const search = params.get('search');
const preview = params.get('preview'); const preview = params.get('preview');
if (search !== null || preview !== null) { 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 (search === null || preview === null) return './';
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) 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; return value;
} }
const launch = params.get('launch'); const launch = params.get('launch');

View File

@ -15,9 +15,13 @@
} }
function cleanScope(value) { 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 { return {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all', kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all', state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
repository,
}; };
} }
@ -64,9 +68,10 @@
const query = cleanQuery(rawQuery); const query = cleanQuery(rawQuery);
if (rawQuery.trim() && !query) return { kind:null }; if (rawQuery.trim() && !query) return { kind:null };
const preview = parsePreview(params.get('preview')); 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({ const scope = hasScope ? cleanScope({
kind:params.get('search_kind'), state:params.get('search_state'), kind:params.get('search_kind'), state:params.get('search_state'),
repository:params.get('search_repository'),
}) : null; }) : null;
return { return {
kind:preview ? 'search-preview' : 'search', kind:preview ? 'search-preview' : 'search',
@ -96,11 +101,13 @@
params.delete('preview'); params.delete('preview');
params.delete('search_kind'); params.delete('search_kind');
params.delete('search_state'); params.delete('search_state');
params.delete('search_repository');
if (searchKinds.has(detail.kind)) { if (searchKinds.has(detail.kind)) {
params.set('search', detail.query || ''); params.set('search', detail.query || '');
if (detail.scope) { if (detail.scope) {
params.set('search_kind', detail.scope.kind); params.set('search_kind', detail.scope.kind);
params.set('search_state', detail.scope.state); 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)); if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
} }

View File

@ -469,6 +469,7 @@ async def global_search(
page: int = 1, page: int = 1,
kind: str = "all", kind: str = "all",
state: str = "all", state: str = "all",
repository: str | None = None,
) -> dict: ) -> dict:
"""Search accessible issues and pulls concurrently with balanced pagination.""" """Search accessible issues and pulls concurrently with balanced pagination."""
item_types = ("issues", "pulls") if kind == "all" else ( item_types = ("issues", "pulls") if kind == "all" else (
@ -480,6 +481,9 @@ async def global_search(
"q": query, "type": item_type, "state": state, "q": query, "type": item_type, "state": state,
"limit": stream_limit, "page": page, "limit": stream_limit, "page": page,
} }
if repository:
owner, repo = repository.split("/", 1)
params.update({"owner": owner, "repo": repo})
response = await _get_client().get( response = await _get_client().get(
"/api/v1/repos/issues/search", "/api/v1/repos/issues/search",
headers=_auth(), headers=_auth(),

View File

@ -1006,15 +1006,17 @@ def _share_target_login_redirect(request: Request) -> str:
search_values = request.query_params.getlist("search") search_values = request.query_params.getlist("search")
preview_values = request.query_params.getlist("preview") preview_values = request.query_params.getlist("preview")
if search_values or preview_values: 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") kind_values = request.query_params.getlist("search_kind")
state_values = request.query_params.getlist("search_state") state_values = request.query_params.getlist("search_state")
repository_values = request.query_params.getlist("search_repository")
if ( if (
set(request.query_params.keys()) - allowed set(request.query_params.keys()) - allowed
or len(search_values) != 1 or len(search_values) != 1
or len(preview_values) != 1 or len(preview_values) != 1
or len(kind_values) > 1 or len(kind_values) > 1
or len(state_values) > 1 or len(state_values) > 1
or len(repository_values) > 1
or len(search_values[0]) > 200 or len(search_values[0]) > 200
or len(preview_values[0]) > 200 or len(preview_values[0]) > 200
or not re.fullmatch( or not re.fullmatch(
@ -1030,6 +1032,10 @@ def _share_target_login_redirect(request: Request) -> str:
continuation_values.append(("search_kind", kind_values[0])) continuation_values.append(("search_kind", kind_values[0]))
if state_values and state_values[0] in {"all", "open", "closed"}: if state_values and state_values[0] in {"all", "open", "closed"}:
continuation_values.append(("search_state", state_values[0])) 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) continuation = urlencode(continuation_values)
return f"login?{urlencode({'continue': f'./?{continuation}'})}" return f"login?{urlencode({'continue': f'./?{continuation}'})}"
limits = {"title": 200, "text": 8000, "url": 2048} limits = {"title": 200, "text": 8000, "url": 2048}
@ -2723,13 +2729,20 @@ async def global_search(
page: int = Query(default=1, ge=1, le=100), page: int = Query(default=1, ge=1, le=100),
kind: Literal["all", "issue", "pull"] = Query(default="all"), kind: Literal["all", "issue", "pull"] = Query(default="all"),
state: Literal["all", "open", "closed"] = 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: ) -> JSONResponse:
query = q.strip() query = q.strip()
if len(query) < 2: if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters") raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try: 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( result = await asyncio.wait_for(
gitea_proxy.global_search(query, limit, page, kind, state), operation,
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
) )
except Exception: except Exception:
@ -2738,7 +2751,10 @@ async def global_search(
status_code=503, status_code=503,
headers={"Retry-After": "1"}, 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") @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")

View File

@ -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/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=", "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/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/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/", "https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
} }

View File

@ -71,6 +71,42 @@ def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
assert "'&state=' + encodeURIComponent(scope.state)" in html 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 '<label for="cmd-search-repository">Repository</label>' in html
assert '<input id="cmd-search-repository"' in html
assert 'list="cmd-search-repositories"' in html
assert '<datalist id="cmd-search-repositories">' 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(): def test_remote_command_search_ignores_stale_responses():
script = f""" script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))}); const filterCommands = require({json.dumps(str(COMMANDS))});

View File

@ -925,6 +925,7 @@ async def test_anonymous_search_preview_preserves_only_valid_search_scope(access
valid = await client.get("/", params={ valid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42", "search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "pull", "search_state": "open", "search_kind": "pull", "search_state": "open",
"search_repository": "stackchain/api",
}) })
invalid = await client.get("/", params={ invalid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42", "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] invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
assert valid_continue.endswith( assert valid_continue.endswith(
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&" "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 == ( assert invalid_continue == (
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42" "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"

View File

@ -6,6 +6,68 @@ import pytest
from src import gitea_proxy, main 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 @pytest.mark.anyio
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch): async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
requested = [] requested = []

View File

@ -257,7 +257,7 @@ async function destination(continuation) {{
return replaced; return replaced;
}} }}
(async () => process.stdout.write(JSON.stringify({{ (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'), malformed:await destination('./?search=release&preview=issue%3Astackchain%2Fapi%3A0'),
unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'), unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'),
}})))().catch(error => {{ console.error(error); process.exit(1); }}); }})))().catch(error => {{ console.error(error); process.exit(1); }});
@ -266,7 +266,7 @@ async function destination(continuation) {{
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == { 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": "./", "malformed": "./",
"unknown": "./", "unknown": "./",
} }

View File

@ -290,7 +290,7 @@ process.stdout.write(JSON.stringify({{
assert payload["oversized"]["state"] == {"kind": None} 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""" script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{ function restore(search) {{
@ -306,11 +306,11 @@ const history = {{state:null, pushState(state,title,url) {{this.state=state; thi
const controller = createTaskOverlayHistory({{ const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, 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({{ process.stdout.write(JSON.stringify({{
opened:controller.currentState(), url:history.url, opened:controller.currentState(), url:history.url,
valid:restore('?search=mobile&search_kind=issue&search_state=closed'), valid:restore('?search=mobile&search_kind=issue&search_state=closed&search_repository=stackchain%2Fapi'),
invalid:restore('?search=mobile&search_kind=script&search_state=secret'), 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) 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 assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
assert payload["opened"] == { 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(): def test_dashboard_persists_search_query_and_restores_canonical_preview():