Scope mobile Search by repository
This commit is contained in:
parent
7636e30482
commit
ec86bf55a8
12
README.md
12
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.
|
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**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
22
src/main.py
22
src/main.py
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))});
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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": "./",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user