feat: watch issues from mobile search (Closes #1290)
All checks were successful
CI / lint (pull_request) Successful in 4m17s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 01:55:53 +00:00
parent 7fbbc2f942
commit 9c187b5ae5
8 changed files with 297 additions and 3 deletions

View File

@ -5617,6 +5617,7 @@
const queueButton = qs('#queue-search-result');
const planButton = qs('#plan-search-result');
const startButton = qs('#start-search-result');
const watchButton = qs('#watch-search-result');
const shareButton = qs('#share-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
@ -5636,6 +5637,8 @@
planButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
watchButton.hidden = true;
watchButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document);
@ -5690,6 +5693,7 @@
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
renderSearchPreviewWatch(detail, state, watchButton);
const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
@ -5698,13 +5702,15 @@
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state);
else if (detail.claimable) status.textContent = 'Open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.';
}
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
headers:{Accept:'application/json'},
}),
@ -5713,6 +5719,7 @@
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
watch:searchSubscription.watch,
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item;
@ -6078,6 +6085,7 @@
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail);
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');

View File

@ -957,7 +957,7 @@
<button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button>
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
<button id="watch-search-result" type="button" hidden>Watch issue</button>
</div>
<button id="share-search-result" type="button" disabled>Share result</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>

View File

@ -32,6 +32,33 @@
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => ({
load:async detail => {
if (!(detail.kind === 'issue' && detail.state === 'open' && detail.claimable)) return detail;
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
return {...detail, watching:result.watching === true};
},
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
}),
});
root.searchPreviewWatchStatus = state => ({
watching:'Starting watch…', unwatching:'Stopping watch…',
watched:'Watching. Future activity will appear in Updates.',
unwatched:'Stopped watching. Assignment and planning are unchanged.',
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
})[state.status] || '';
root.renderSearchPreviewWatch = (detail, state, button) => {
button.hidden = !(detail.kind === 'issue' && detail.state === 'open' && detail.claimable);
button.textContent = detail.watching ? 'Stop watching' : 'Watch issue';
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
const detail = getDetail();
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
});
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
@ -92,7 +119,7 @@
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@ -105,6 +132,7 @@
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let watchRequest = null;
let conversation = null;
function sameItem(left, right) {
@ -348,6 +376,23 @@
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
setWatching(watching) {
if (watchRequest) return watchRequest;
if (!current || typeof watch !== 'function') {
return Promise.reject(new Error('Watching is unavailable.'));
}
const detail = current;
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
watchRequest = watch(detail, watching).then(result => {
current = { ...current, watching:result?.watching === true };
publish({ status:watching ? 'watched' : 'unwatched', item:current, detail:current, result });
return result;
}).catch(error => {
publish({ status:'watch-error', item:current, detail, error });
throw error;
}).finally(() => { watchRequest = null; });
return watchRequest;
},
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));

View File

@ -1007,6 +1007,39 @@ async def _notification_subscription(repository: str, number: str) -> dict:
return value if isinstance(value, dict) else {}
async def issue_subscription(repository: str, number: int) -> dict:
"""Return the current operator's authoritative issue subscription state."""
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
if not isinstance(value, dict):
raise ValueError("Gitea subscription response was not an object")
return {
"watching": value.get("subscribed") is True and value.get("ignored") is not True
}
async def set_issue_subscription(repository: str, number: int, watching: bool) -> dict:
"""Set and then confirm the current operator's issue subscription state."""
user = await fetch("user")
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Gitea did not identify the current operator")
path = (
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/"
f"{quote(login, safe='')}"
)
client = _get_client()
response = (
await client.put(path, headers=_auth())
if watching
else await client.delete(path, headers=_auth())
)
response.raise_for_status()
confirmed = await issue_subscription(repository, number)
if confirmed["watching"] is not watching:
raise ValueError("Gitea did not confirm the requested subscription state")
return confirmed
async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):

View File

@ -3660,6 +3660,75 @@ async def global_search_preview(
return JSONResponse(preview)
async def _search_preview_subscription_target(
owner: str, repo: str, number: int, kind: str
) -> str:
repository = f"{owner}/{repo}"
preview = await gitea_proxy.work_preview(repository, kind, number)
if (
kind != "issue"
or preview.get("repository") != repository
or preview.get("kind") != "issue"
or preview.get("number") != number
or preview.get("state") != "open"
):
raise HTTPException(status_code=404, detail="Watchable search result not found")
return repository
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription")
async def global_search_preview_subscription(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse:
try:
repository = await _search_preview_subscription_target(owner, repo, number, kind)
result = await asyncio.wait_for(
gitea_proxy.issue_subscription(repository, number),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Watch status is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.api_route(
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription",
methods=["PUT", "DELETE"],
)
async def mutate_global_search_preview_subscription(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse:
watching = request.method == "PUT"
try:
repository = await _search_preview_subscription_target(owner, repo, number, kind)
result = await asyncio.wait_for(
gitea_proxy.set_issue_subscription(repository, number, watching),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Watch status was not changed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
async def global_search_preview_conversation(
owner: str,

View File

@ -32,8 +32,14 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
page.locator("#watch-search-result").evaluate("node => { node.hidden = false; node.textContent = 'Watch issue'; }")
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
watch = page.locator("#watch-search-result")
expect(watch).to_be_visible()
expect(watch).to_have_text("Watch issue")
watch_bounds = watch.bounding_box()
assert watch_bounds and watch_bounds["height"] >= 44
navigation = page.locator(".mobile-search-preview-nav")
expect(navigation).to_be_visible()
buttons = navigation.locator("button")

View File

@ -806,6 +806,49 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0; let rejectWatch; const states = [];
const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}};
const preview = createSearchPreview({{
fetchJson:async()=>detail, mutate:async()=>{{}},
watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}},
onState:state=>states.push(state),
}});
await preview.open(detail);
const first=preview.setWatching(true); const second=preview.setWatching(true);
if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight');
rejectWatch(new Error('offline')); await Promise.allSettled([first,second]);
const final=states.at(-1);
process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number,
watching:final.detail.watching,error:final.error.message}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 1,
"status": "watch-error",
"number": 42,
"watching": False,
"error": "offline",
}
def test_mobile_search_preview_exposes_touch_safe_watch_action():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert '<button id="watch-search-result" type="button" hidden>Watch issue</button>' in html
assert "wireSearchPreviewWatch" in dashboard
assert "preview.setWatching" in SEARCH_PREVIEW.read_text()
assert ".search-preview-actions button" in css and "min-height:44px" in css
def test_search_preview_shares_canonical_url_without_closing_the_preview():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});

View File

@ -218,6 +218,96 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
assert response.json()["claimable"] is True
@pytest.mark.anyio
async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch):
calls = []
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"state": "open", "claimable": True}
async def subscription(repository, number):
calls.append(("subscription", repository, number))
return {"watching": True}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, 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/subscription?kind=issue"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"watching": True}
assert calls == [
("preview", "stackchain/api", "issue", 42),
("subscription", "stackchain/api", 42),
]
@pytest.mark.anyio
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
monkeypatch, method, watching
):
calls = []
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"state": "open", "claimable": True}
async def set_subscription(repository, number, desired):
calls.append(("set", repository, number, desired))
return {"watching": desired}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.request(
method,
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"watching": watching}
assert calls == [
("preview", "stackchain/api", "issue", 42),
("set", "stackchain/api", 42, watching),
]
@pytest.mark.anyio
async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/subscriptions/check"):
return httpx.Response(200, json={"subscribed": True, "ignored": False})
return httpx.Response(201, json={})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True)
finally:
await gitea_proxy.stop_client()
assert result == {"watching": True}
assert requests == [
("GET", "/api/v1/user"),
("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"),
("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"),
]
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["issue", "pull"])
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(