Merge pull request 'Preview and claim global search results without leaving the dashboard' (#198)
All checks were successful
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s

This commit is contained in:
timmy 2026-08-07 13:45:47 +00:00
commit 2243b9453c
8 changed files with 432 additions and 8 deletions

View File

@ -48,8 +48,13 @@ Gitea connection directly, request
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard `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 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, 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 and keeps local commands usable if Gitea search is unavailable. Selecting a remote
also available at `GET /api/v1/search?q=<query>&limit=<1-25>`. Never commit the token result opens a mobile-safe, read-only preview without discarding the search query;
open unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. Closed work and pull requests remain read-only with a safe
canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>` and
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
or place it in a tracked configuration file. or place it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the For service monitoring, GET `/healthz` is a liveness check that confirms the

View File

@ -176,6 +176,14 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; } .find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } .find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.search-preview.open { display:flex; }
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } @media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; } .create-issue-sheet.open { display:flex; }
@ -221,6 +229,7 @@ textarea { resize: vertical; min-height: 120px; }
.review-sheet-panel { width:100%; border-left:0; padding:14px; } .review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; } .update-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; } .issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
.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; }
} }
@ -339,6 +348,25 @@ textarea { resize: vertical; min-height: 120px; }
<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 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>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
<section class="search-preview-panel">
<div class="search-preview-header">
<div>
<div class="small" id="search-preview-key"></div>
<h3 id="search-preview-title">Work preview</h3>
</div>
<button id="close-search-preview" type="button">Back to search</button>
</div>
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
<div id="search-preview-meta" class="row"></div>
<p id="search-preview-body" class="search-preview-body"></p>
<div class="search-preview-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
</div>
<div id="whiteboard-modal"> <div id="whiteboard-modal">
<div class="modal"> <div class="modal">
<div class="modal-actions"> <div class="modal-actions">
@ -600,6 +628,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/markdown.js"></script> <script src="static/markdown.js"></script>
<script src="static/commands.js"></script> <script src="static/commands.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>
<script src="static/pick-work.js"></script> <script src="static/pick-work.js"></script>
@ -1784,17 +1813,95 @@ textarea { resize: vertical; min-height: 120px; }
try { try {
const url = new URL(value); const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; } } catch (_) { return '';
}
}
let searchPreviewDetail = null;
function searchPreviewPath(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
}
function renderSearchPreview(state) {
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
if (state.status === 'closed') {
sheet.classList.remove('open');
return;
}
sheet.classList.add('open');
claimButton.hidden = true;
claimButton.disabled = false;
if (state.status === 'loading') {
searchPreviewDetail = null;
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
qs('#search-preview-meta').textContent = '';
qs('#search-preview-body').textContent = '';
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
status.textContent = 'Loading preview…';
return;
}
if (state.status === 'error') {
status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.';
return;
}
const detail = state.detail;
if (!detail) return;
searchPreviewDetail = detail;
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
qs('#search-preview-meta').textContent =
(detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') +
(detail.author ? ' · by ' + detail.author : '') +
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').textContent = detail.body || 'No description provided.';
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
(detail.claimable ? 'This issue is open and unassigned.' :
(detail.assigned_to_me ? 'This issue is already in My Work.' : 'Read-only preview.')));
}
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
claim: detail => fetchReviewJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/claim',
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
onState: renderSearchPreview,
});
function closeSearchPreview() {
searchPreview.close();
qs('#cmd-palette').classList.add('open');
qs('#cmd-input').setAttribute('aria-expanded', 'true');
renderCommands(qs('#cmd-input').value);
qs('#cmd-input').focus();
}
async function openPreviewIssueInMyWork(detail) {
await load();
const item = lastMyWork.find(candidate =>
candidate.kind === 'issue' && candidate.key === detail.repository + '#' + detail.number
);
if (!item) return false;
searchPreview.close();
await openIssueSheet(item, qs('#find-work'));
return true;
} }
function runCommandItem(item) { function runCommandItem(item) {
if (item.command) item.command.run(); if (item.command) {
else { item.command.run();
const url = safeSearchUrl(item.result.url); qs('#cmd-input').value = '';
if (url) window.location.assign(url); } else {
searchPreview.open(item.result).catch(() => {});
} }
qs('#cmd-palette').classList.remove('open'); qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false'); qs('#cmd-input').setAttribute('aria-expanded', 'false');
qs('#cmd-input').value = '';
} }
function renderCommands(filter) { function renderCommands(filter) {
const el = qs('#cmd-results'); const el = qs('#cmd-results');
@ -1851,6 +1958,11 @@ textarea { resize: vertical; min-height: 120px; }
qs('#cmd-input').setAttribute('aria-expanded', 'false'); qs('#cmd-input').setAttribute('aria-expanded', 'false');
return; return;
} }
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
e.preventDefault();
closeSearchPreview();
return;
}
if (e.key === 'Escape' && findingWork) { if (e.key === 'Escape' && findingWork) {
e.preventDefault(); e.preventDefault();
closeFindWorkSheet(); closeFindWorkSheet();
@ -1880,6 +1992,21 @@ textarea { resize: vertical; min-height: 120px; }
} else openCommandPalette(); } else openCommandPalette();
} }
}); });
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
qs('#claim-search-result').addEventListener('click', async () => {
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
try {
const claimed = searchPreviewDetail;
if (claimed.claimable) await searchPreview.claim(claimed);
const opened = await openPreviewIssueInMyWork(claimed);
if (!opened) {
qs('#search-preview-status').textContent =
'Assignment confirmed. Refresh My Work to open the issue.';
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet); qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet); qs('#close-find-work').addEventListener('click', closeFindWorkSheet);

View File

@ -0,0 +1,48 @@
(function (root, factory) {
const createSearchPreview = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, claim, onState }) {
let generation = 0;
let current = null;
let claimRequest = null;
return {
open(item) {
generation += 1;
const requestGeneration = generation;
current = { ...item };
onState({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
onState({ status: 'ready', item: current, detail });
}
return detail;
}).catch(error => {
if (requestGeneration === generation) {
onState({ status: 'error', item: current, error });
}
throw error;
});
},
close() {
generation += 1;
current = null;
onState({ status: 'closed' });
},
claim(detail) {
if (claimRequest) return claimRequest;
onState({ status: 'claiming', item: current, detail });
claimRequest = claim(detail).then(result => {
onState({ status: 'claimed', item: current, detail, result });
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { claimRequest = null; });
return claimRequest;
},
};
};
});

View File

@ -237,6 +237,45 @@ async def global_search(query: str, limit: int = 10) -> list[dict]:
return results return results
async def work_preview(repository: str, kind: str, number: int) -> dict:
"""Load bounded, read-only context for a global search result."""
issue, user = await asyncio.gather(
fetch(f"repos/{repository}/issues/{number}"), current_user()
)
if not isinstance(issue, dict):
raise ValueError("Gitea work preview response was not an object")
labels_value = issue.get("labels")
assignees_value = issue.get("assignees")
labels = labels_value if isinstance(labels_value, list) else []
assignees = assignees_value if isinstance(assignees_value, list) else []
assignee_names = [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
]
author = issue.get("user")
author = author if isinstance(author, dict) else {}
login = user.get("login") if isinstance(user, dict) else ""
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
return {
"kind": actual_kind,
"repository": repository,
"number": number,
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"state": state,
"author": author.get("login", "") if isinstance(author.get("login"), str) else "",
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": assignee_names,
"url": _safe_web_url(issue.get("html_url")),
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"assigned_to_me": bool(login and login in assignee_names),
}
def _normalize_available_issue(item: Any) -> dict | None: def _normalize_available_issue(item: Any) -> dict | None:
if ( if (
not isinstance(item, dict) not isinstance(item, dict)

View File

@ -494,6 +494,27 @@ async def global_search(
return JSONResponse({"query": query, "items": items}) return JSONResponse({"query": query, "items": items})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
async def global_search_preview(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse:
try:
preview = await asyncio.wait_for(
gitea_proxy.work_preview(f"{owner}/{repo}", kind, number),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "This work item is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(preview)
@app.get("/api/v1/work/{stream}") @app.get("/api/v1/work/{stream}")
async def paged_work( async def paged_work(
stream: Literal["issue", "pull", "review"], stream: Literal["issue", "pull", "review"],

View File

@ -7,6 +7,7 @@ from urllib.parse import urljoin
FRONTEND = Path(__file__).parents[1] / "frontend" FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js" COMMANDS = FRONTEND / "commands.js"
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
class ScriptSourceParser(HTMLParser): class ScriptSourceParser(HTMLParser):
@ -104,3 +105,76 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
"https://forge.alexanderwhitestone.com/dashboard/", "https://forge.alexanderwhitestone.com/dashboard/",
"api/v1/search?q=mobile", "api/v1/search?q=mobile",
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile" ) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
def test_search_preview_ignores_stale_result_details():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const pending = new Map();
const states = [];
const preview = createSearchPreview({{
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
claim: () => Promise.resolve(),
onState: state => states.push(state),
}});
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
preview.open({{ repository:'stackchain/api', number:2, kind:'issue' }});
pending.get(2)({{ number:2, title:'Current' }});
await new Promise(resolve => setTimeout(resolve, 0));
pending.get(1)({{ number:1, title:'Stale' }});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready');
if (ready.length !== 1 || ready[0].detail.number !== 2) {{
throw new Error('stale detail replaced current preview: ' + 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_search_preview_claim_is_single_flight_and_reports_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let claims = 0;
let resolveClaim;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item),
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
onState: state => states.push(state),
}});
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
const detail = {{ repository:'stackchain/api', number:42, claimable:true }};
const first = preview.claim(detail);
const second = preview.claim(detail);
if (claims !== 1 || first !== second) throw new Error('claim was not single-flight');
resolveClaim({{ assignees:['timmy'] }});
await first;
if (!states.some(state => state.status === 'claimed')) throw new Error('claim confirmation missing');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_search_selection_opens_native_preview_without_navigation():
html = (FRONTEND / "index.html").read_text()
run_item = html.split("function runCommandItem(item)", 1)[1].split(
"function renderCommands", 1
)[0]
assert "searchPreview.open(item.result)" in run_item
assert "window.location.assign" not in run_item
remote_branch = run_item.split("} else {", 1)[1]
assert "qs('#cmd-input').value = ''" not in remote_branch
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
html = (FRONTEND / "index.html").read_text()
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
assert "openPreviewIssueInMyWork" in html

View File

@ -37,6 +37,40 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
}]} }]}
@pytest.mark.anyio
async def test_global_search_preview_returns_normalized_action_context(monkeypatch):
requested = []
async def preview(repository, kind, number):
requested.append((repository, kind, number))
return {
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"body": "Restore the queue before release.",
"state": "open",
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
"assigned_to_me": False,
}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview, 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/repos/stackchain/api/issues/42/preview?kind=issue"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("stackchain/api", "issue", 42)]
assert response.json()["claimable"] is True
@pytest.mark.anyio @pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results(): async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = [] requests = []
@ -81,3 +115,65 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
"title": "Improve search", "state": "closed", "title": "Improve search", "state": "closed",
"url": "https://forge.example/stackchain/web/pulls/9", "url": "https://forge.example/stackchain/web/pulls/9",
}] }]
@pytest.mark.anyio
async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_issues():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 42,
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"html_url": "https://forge.example/stackchain/api/issues/42",
"user": {"login": "alex"},
"labels": [{"name": "P1"}, None],
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
finally:
await gitea_proxy.stop_client()
assert preview == {
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
"assigned_to_me": False,
}
@pytest.mark.anyio
async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 9,
"title": "Improve search",
"state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9",
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
finally:
await gitea_proxy.stop_client()
assert preview["kind"] == "pull"
assert preview["claimable"] is False

View File

@ -19,3 +19,17 @@ async def test_dashboard_viewport_allows_mobile_zoom():
assert 'name="viewport"' in html assert 'name="viewport"' in html
assert "width=device-width" in html assert "width=device-width" in html
assert "user-scalable=no" not in html assert "user-scalable=no" not in html
@pytest.mark.anyio
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
html = await dashboard()
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="search-preview-title"' in html
assert 'id="claim-search-result"' in html
assert 'id="open-search-result-gitea"' in html
assert 'src="static/search-preview.js"' in html
assert ".search-preview-panel" in html
assert "height:100dvh" in html
assert "env(safe-area-inset-bottom)" in html