Preview and claim global search results without leaving the dashboard #198
|
|
@ -48,8 +48,13 @@ Gitea connection directly, request
|
|||
`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
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
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.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
|
|
|
|||
|
|
@ -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-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; }
|
||||
.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%; } }
|
||||
.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; }
|
||||
|
|
@ -221,6 +229,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.review-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; }
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
<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 class="modal">
|
||||
<div class="modal-actions">
|
||||
|
|
@ -600,6 +628,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
|
|
@ -1784,17 +1813,95 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
try {
|
||||
const url = new URL(value);
|
||||
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) {
|
||||
if (item.command) item.command.run();
|
||||
else {
|
||||
const url = safeSearchUrl(item.result.url);
|
||||
if (url) window.location.assign(url);
|
||||
if (item.command) {
|
||||
item.command.run();
|
||||
qs('#cmd-input').value = '';
|
||||
} else {
|
||||
searchPreview.open(item.result).catch(() => {});
|
||||
}
|
||||
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');
|
||||
|
|
@ -1851,6 +1958,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
closeSearchPreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && findingWork) {
|
||||
e.preventDefault();
|
||||
closeFindWorkSheet();
|
||||
|
|
@ -1880,6 +1992,21 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
} 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('#find-work').addEventListener('click', openFindWorkSheet);
|
||||
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||||
|
|
|
|||
48
frontend/search-preview.js
Normal file
48
frontend/search-preview.js
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
|
@ -237,6 +237,45 @@ async def global_search(query: str, limit: int = 10) -> list[dict]:
|
|||
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:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
|
|
|
|||
21
src/main.py
21
src/main.py
|
|
@ -494,6 +494,27 @@ async def global_search(
|
|||
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}")
|
||||
async def paged_work(
|
||||
stream: Literal["issue", "pull", "review"],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from urllib.parse import urljoin
|
|||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
|
|
@ -104,3 +105,76 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
|
|||
"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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
|
|
@ -81,3 +115,65 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
|||
"title": "Improve search", "state": "closed",
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -19,3 +19,17 @@ async def test_dashboard_viewport_allows_mobile_zoom():
|
|||
assert 'name="viewport"' in html
|
||||
assert "width=device-width" 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user