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