diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f06c51d..47bb3e9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -371,6 +371,33 @@ authoredOutbox, onStatus: message => { qs('#update-reply-status').textContent = message; }, }); + const updateOwnership = createUpdateOwnership({ + claim: item => fetchReviewJson( + 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + + '/issues/' + encodeURIComponent(item.number) + '/claim', + { method:'PATCH', headers:{ Accept:'application/json' } } + ), + addToday: item => { + const result = todayWork.add(item); + refreshMyWorkView(); + return result; + }, + onClaimed: item => { + if (!lastContextSnapshot) return; + const issues = (lastContextSnapshot.issues || []).filter(candidate => + candidate.repository !== item.repository || candidate.number !== item.number + ); + lastContextSnapshot = { ...lastContextSnapshot, issues:issues.concat(item) }; + paintMyWork(lastContextSnapshot); + }, + onState: state => { + const button = qs('#update-ownership-action'); + button.hidden = state.action === 'hidden'; + button.disabled = state.busy; + button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership'; + if (state.message) qs('#update-sheet-status').textContent = state.message; + }, + }); const notificationReader = createNotificationReader({ load: fetchNotificationDetail, loadConversation: fetchNotificationConversation, @@ -390,6 +417,7 @@ qs('#update-reply').value = notificationReplier.loadDraft(item); qs('#update-reply-status').textContent = ''; qs('#send-update-reply').disabled = false; + qs('#update-ownership-action').hidden = true; qs('#retry-update-load').hidden = true; qs('#keep-update-unread').focus(); }, @@ -399,6 +427,7 @@ qs('#update-subject-state').textContent = detail.state || ''; qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.'; qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; + updateOwnership.open(detail, selectedUpdate); qs('#retry-update-load').hidden = true; }, onConversation: renderUpdateConversation, @@ -2700,6 +2729,7 @@ } }); qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); + qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act()); qs('#retry-update-load').addEventListener('click', () => { if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); }); diff --git a/frontend/index.html b/frontend/index.html index 76d0191..2e6aa8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -378,6 +378,7 @@
+ Open in Gitea @@ -519,6 +520,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 376ef88..97376b1 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v34'; +const CACHE = 'stackchain-dashboard-shell-v35'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -22,6 +22,7 @@ const SHELL = [ BASE + 'static/offline-work.js', BASE + 'static/my-work.js', BASE + 'static/today-work.js', + BASE + 'static/update-ownership.js', BASE + 'static/later-work.js', BASE + 'static/detail-defer.js', BASE + 'static/pick-work.js', diff --git a/frontend/update-ownership.js b/frontend/update-ownership.js new file mode 100644 index 0000000..fb18872 --- /dev/null +++ b/frontend/update-ownership.js @@ -0,0 +1,78 @@ +function createUpdateOwnership({ claim, addToday, onClaimed, onState }) { + let detail = null; + let update = null; + let claimedItem = null; + let request = null; + + function state(value) { + onState?.(value); + } + + function open(nextDetail, nextUpdate) { + detail = nextDetail; + update = nextUpdate; + claimedItem = null; + request = null; + state({ + action: nextDetail?.issue?.claimable ? 'claim' : 'hidden', + busy: false, + message: '', + }); + } + + function act() { + if (request) return request; + if (claimedItem) { + const result = addToday(claimedItem); + const messages = { + added: 'Added to Today.', + exists: 'This issue is already in Today.', + full: 'Today is limited to 5 items. Remove one before adding more.', + unavailable: 'Could not save Today on this device.', + }; + state({ action: 'today', busy: false, message: messages[result] || messages.unavailable }); + return Promise.resolve(result); + } + if (!detail?.issue?.claimable) return Promise.resolve('hidden'); + + state({ action: 'claim', busy: true, message: 'Assigning…' }); + request = Promise.resolve(claim({ + repository: detail.repository, + number: detail.issue.number, + })).then(result => { + claimedItem = { + ...result, + repository: detail.repository, + kind: 'issue', + notification_id: update?.notification_id, + updated_at: update?.updated_at || result.updated_at, + has_update: true, + }; + detail.issue.claimable = false; + onClaimed?.(claimedItem); + state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' }); + return claimedItem; + }).catch(error => { + if (error?.status === 409) { + detail.issue.claimable = false; + state({ + action: 'hidden', + busy: false, + message: 'Someone else claimed or closed this issue. The update is still unread.', + }); + return 'conflict'; + } + state({ + action: 'claim', + busy: false, + message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.', + }); + return 'retry'; + }).finally(() => { request = null; }); + return request; + } + + return { open, act }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index fbd3592..6f392f8 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -680,6 +680,21 @@ async def notification_detail(thread_id: int) -> dict: latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url( subject.get("latest_comment_html_url") ) + assignee_values = subject_detail.get("assignees") + assignees = [ + value.get("login") + for value in (assignee_values if isinstance(assignee_values, list) else []) + if isinstance(value, dict) and isinstance(value.get("login"), str) + ] + issue = { + "number": subject_detail.get("number"), + "assignees": assignees, + "claimable": ( + subject.get("type") == "Issue" + and subject_detail.get("state", subject.get("state")) == "open" + and not assignees + ), + } if supported_conversation and subject.get("type") == "Issue" else None return { "id": thread_id, "repository": repository.get("full_name", "") @@ -710,6 +725,7 @@ async def notification_detail(thread_id: int) -> dict: else "", "url": latest_url, }, + "issue": issue, "conversation": conversation, } diff --git a/src/main.py b/src/main.py index 1243375..0cd2a0f 100644 --- a/src/main.py +++ b/src/main.py @@ -1587,7 +1587,7 @@ async def claim_available_issue( ) except gitea_proxy.IssueNotAvailableError: return JSONResponse( - {"error": "This issue was already claimed or is no longer open. Refresh Find Work."}, + {"error": "This issue was already claimed or is no longer open."}, status_code=409, ) except Exception: diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py index 255826c..7d7134e 100644 --- a/tests/test_gitea_notifications.py +++ b/tests/test_gitea_notifications.py @@ -133,7 +133,10 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re }, }) if request.url.path.endswith("/issues/7"): - return httpx.Response(200, json={"body": "Deploy fails after **three** retries."}) + return httpx.Response(200, json={ + "number": 7, "state": "open", "assignees": [], + "body": "Deploy fails after **three** retries.", + }) if request.url.path.endswith("/issues/comments/9"): return httpx.Response(200, json={ "id": 9, @@ -178,6 +181,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re "created_at": "2026-08-06T12:30:00Z", "url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", }, + "issue": {"number": 7, "assignees": [], "claimable": True}, "conversation": { "comments": [{ "id": 9, diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 4d97f85..c20c4c5 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -915,7 +915,7 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic assert response.status_code == 409 assert response.headers["cache-control"] == "no-store" assert response.json() == { - "error": "This issue was already claimed or is no longer open. Refresh Find Work." + "error": "This issue was already claimed or is no longer open." } diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 6f6fe82..de8071d 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -18,6 +18,94 @@ PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js" CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js" PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js" WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js" +UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js" + + +def test_unassigned_issue_update_claims_once_and_becomes_today_ready(): + script = f""" +const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))}); +let claims = 0; +let finishClaim; +const states = []; +const controller = createUpdateOwnership({{ + claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }}, + addToday: () => 'added', + onClaimed: item => states.push({{status:'reconciled', item}}), + onState: state => states.push(state), +}}); +controller.open({{ + repository:'stackchain/api', title:'Retry deploy', + issue:{{number:7, assignees:[], claimable:true}}, +}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}}); +const first = controller.act(); +const second = controller.act(); +if (first !== second || claims !== 1) throw new Error('claim was not single-flight'); +finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}}); +(async () => {{ + await first; + const added = await controller.act(); + process.stdout.write(JSON.stringify({{claims, added, states}})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + assert output["claims"] == 1 + assert output["added"] == "added" + assert output["states"][0] == {"action": "claim", "busy": False, "message": ""} + assert {"action": "claim", "busy": True, "message": "Assigning…"} in output["states"] + reconciled = next(state for state in output["states"] if state.get("status") == "reconciled") + assert reconciled["item"]["notification_id"] == 42 + assert reconciled["item"]["repository"] == "stackchain/api" + assert reconciled["item"]["kind"] == "issue" + assert output["states"][-1] == { + "action": "today", "busy": False, "message": "Added to Today." + } + + +def test_update_ownership_conflict_removes_stale_action_without_reconciling(): + script = f""" +const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))}); +const states = []; +let reconciled = 0; +const conflict = new Error('server wording'); conflict.status = 409; +const controller = createUpdateOwnership({{ + claim: () => Promise.reject(conflict), addToday: () => 'added', + onClaimed: () => {{ reconciled += 1; }}, onState: state => states.push(state), +}}); +controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}}); +(async () => {{ + const result = await controller.act(); + process.stdout.write(JSON.stringify({{result, reconciled, states}})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + assert output["result"] == "conflict" + assert output["reconciled"] == 0 + assert output["states"][-1] == { + "action": "hidden", + "busy": False, + "message": "Someone else claimed or closed this issue. The update is still unread.", + } + + +@pytest.mark.anyio +async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today(): + html = await dashboard() + + assert '' in html + assert 'id="update-ownership-action"' in html + assert 'hidden aria-describedby="update-sheet-status"' in html + assert 'const updateOwnership = createUpdateOwnership({' in html + assert 'updateOwnership.open(detail, selectedUpdate)' in html + assert "qs('#update-ownership-action').addEventListener('click'" in html + assert "lastContextSnapshot.issues" in html + assert "todayWork.add(item)" in html + assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments(): diff --git a/tests/test_notification_detail.py b/tests/test_notification_detail.py index d2974a5..9850920 100644 --- a/tests/test_notification_detail.py +++ b/tests/test_notification_detail.py @@ -36,7 +36,12 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol comments = [comment(i) for i in (range(1, 21) if page == 1 else range(41, 48))] return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"}) if request.url.path.endswith("/repos/stackchain/api/issues/7"): - return httpx.Response(200, json={"body": "Deploy fails after retries."}) + return httpx.Response(200, json={ + "number": 7, + "state": "open", + "assignees": [], + "body": "Deploy fails after retries.", + }) if request.url.path.endswith("/repos/stackchain/api/issues/comments/47"): return httpx.Response(200, json=comment(47)) raise AssertionError(f"unexpected request: {request.url}") @@ -56,6 +61,11 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol "older_page": 2, "total": 47, } + assert result["issue"] == { + "number": 7, + "assignees": [], + "claimable": True, + } @pytest.mark.anyio diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index ac7287f..9fa03c3 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v34" in source + assert "stackchain-dashboard-shell-v35" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -106,10 +106,17 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v34" in source + assert "stackchain-dashboard-shell-v35" in source assert "BASE + 'static/mobile-search-viewport.js'" in source +def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v35" in source + assert "BASE + 'static/update-ownership.js'" in source + + def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): result = run_worker_scenario( """ @@ -279,6 +286,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-work.js", "/dashboard/static/my-work.js", "/dashboard/static/today-work.js", + "/dashboard/static/update-ownership.js", "/dashboard/static/later-work.js", "/dashboard/static/detail-defer.js", "/dashboard/static/pick-work.js",