diff --git a/frontend/index.html b/frontend/index.html index dfd8579..ae24f81 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -134,6 +134,11 @@ textarea { resize: vertical; min-height: 120px; } .issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; } .issue-comment-composer { display:grid; gap:8px; margin-top:16px; } .issue-comment-composer button { min-height:44px; width:100%; } +.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; } +.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; } +.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; } +.issue-label-option input { width:20px; height:20px; flex:0 0 auto; } +.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; } .issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } .issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } @@ -315,6 +320,12 @@ textarea { resize: vertical; min-height: 120px; }
Choose an issue.
+
+ Labels +
+
Load an issue to edit labels.
+ +

Recent discussion

@@ -947,6 +958,36 @@ textarea { resize: vertical; min-height: 120px; } escapeHtml(comment.body || 'No comment body provided.') + ''; } + async function loadIssueLabelEditor(item, confirmedNames) { + const list = qs('#issue-label-list'); + const status = qs('#issue-label-status'); + list.textContent = ''; + qs('#save-issue-labels').disabled = true; + status.textContent = 'Loading labels…'; + try { + const labels = await issueController.loadLabels(item); + if (selectedIssue !== item) return; + const draftIds = issueController.loadLabelDraft(item); + const selectedIds = draftIds.length ? new Set(draftIds) : new Set( + labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id)) + ); + list.innerHTML = labels.map(label => + '' + ).join(''); + status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.'; + qs('#save-issue-labels').disabled = false; + } catch (_error) { + status.textContent = 'Labels could not be loaded. Retry by reopening this issue.'; + } + } + + function selectedEditIssueLabelIds() { + return Array.from(document.querySelectorAll('input[name="issue-label"]:checked')) + .map(input => Number(input.value)).filter(Number.isInteger); + } + async function openIssueSheet(item, trigger) { if (!item) return; selectedIssue = item; @@ -961,6 +1002,9 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-comments').textContent = ''; qs('#issue-comment').value = issueController.loadDraft(item); qs('#issue-comment-status').textContent = ''; + qs('#issue-label-list').textContent = ''; + qs('#issue-label-status').textContent = 'Loading labels…'; + qs('#save-issue-labels').disabled = true; qs('#retry-issue-load').hidden = true; qs('#open-issue-gitea').href = item.url || '#'; qs('#send-issue-comment').disabled = false; @@ -974,6 +1018,7 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-labels').innerHTML = (detail.labels || []).map(label => '' + escapeHtml(label) + '' ).join(' '); + loadIssueLabelEditor(item, detail.labels || []); qs('#issue-assignees').textContent = (detail.assignees || []).length ? 'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported'; qs('#issue-comments').innerHTML = (detail.comments || []).length ? @@ -1487,6 +1532,29 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-comment').addEventListener('input', event => { if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value); }); + qs('#save-issue-labels').addEventListener('click', async () => { + if (!selectedIssue || !lastContextSnapshot) return; + const editing = selectedIssue; + const button = qs('#save-issue-labels'); + button.disabled = true; + qs('#issue-label-status').textContent = 'Saving labels…'; + try { + const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds()); + lastContextSnapshot = buildMyWork.replaceIssueLabels( + lastContextSnapshot, editing.repository, editing.number, confirmed.labels + ); + selectedIssue = { ...editing, labels: confirmed.labels }; + qs('#issue-labels').innerHTML = confirmed.labels.map(label => + '' + escapeHtml(label) + '' + ).join(' '); + paintMyWork(lastContextSnapshot); + qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.'; + } catch (error) { + qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.'; + } finally { + button.disabled = false; + } + }); qs('#send-issue-comment').addEventListener('click', async () => { if (!selectedIssue) return; const body = qs('#issue-comment').value.trim(); diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 688b59b..93bd21f 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -1,9 +1,11 @@ function createIssueSheet({ fetchJson, storage }) { let commentRequest = null; let closeRequest = null; + let labelRequest = null; const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(item.number); const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number; + const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number; return { load(item) { @@ -11,6 +13,11 @@ function createIssueSheet({ fetchJson, storage }) { headers: { Accept: 'application/json' }, }); }, + loadLabels(item) { + return fetchJson(issuePath(item) + '/labels', { + headers: { Accept: 'application/json' }, + }); + }, loadDraft(item) { try { return storage?.getItem(draftKey(item)) || ''; } catch (_error) { return ''; } @@ -19,6 +26,30 @@ function createIssueSheet({ fetchJson, storage }) { try { storage?.setItem(draftKey(item), body); } catch (_error) { /* The textarea remains the fallback. */ } }, + loadLabelDraft(item) { + try { + const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]'); + return Array.isArray(value) ? value.filter(Number.isInteger) : []; + } catch (_error) { return []; } + }, + updateLabels(item, labelIds) { + if (labelRequest) return labelRequest; + try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); } + catch (_error) { /* The checked controls remain the fallback. */ } + labelRequest = fetchJson(issuePath(item) + '/labels', { + method: 'PATCH', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ label_ids: labelIds }), + }).then(result => { + if (result?.number !== item.number || !Array.isArray(result.labels)) { + throw new Error('Issue labels were not confirmed.'); + } + try { storage?.removeItem(labelDraftKey(item)); } + catch (_error) { /* The upstream label set is authoritative. */ } + return result; + }).finally(() => { labelRequest = null; }); + return labelRequest; + }, close(item) { if (closeRequest) return closeRequest; closeRequest = fetchJson(issuePath(item) + '/close', { diff --git a/frontend/my-work.js b/frontend/my-work.js index ebfe436..5b67fd4 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -326,6 +326,15 @@ function filterMyWork(items, selectedFilter) { return items.filter((item) => item.kind === selectedFilter); } +function replaceIssueLabels(data, repository, number, labels) { + return { + ...data, + issues: (data.issues || []).map(item => + item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item + ), + }; +} + function summarizeMyWork(items) { const updates = items.filter((item) => item.has_update).length; const reviews = items.filter((item) => item.is_review).length; @@ -348,6 +357,7 @@ function countMyWork(items) { if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; + buildMyWork.replaceIssueLabels = replaceIssueLabels; buildMyWork.summarizeMyWork = summarizeMyWork; buildMyWork.countMyWork = countMyWork; buildMyWork.acknowledgeNotification = acknowledgeNotification; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 1216cd3..0d46c27 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -508,6 +508,33 @@ async def create_issue( } +async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict: + response = await _get_client().patch( + f"/api/v1/repos/{repository}/issues/{number}", + headers=_auth(), + json={"labels": label_ids}, + ) + response.raise_for_status() + issue = response.json() + if not isinstance(issue, dict) or issue.get("number") != number: + raise ValueError("Gitea did not confirm the label update") + labels_value = issue.get("labels") + labels = labels_value if isinstance(labels_value, list) else [] + confirmed_ids = { + item["id"] for item in labels + if isinstance(item, dict) and isinstance(item.get("id"), int) + } + if confirmed_ids != set(label_ids): + raise ValueError("Gitea did not confirm the requested label set") + return { + "number": number, + "labels": [ + item["name"] for item in labels + if isinstance(item, dict) and isinstance(item.get("name"), str) + ], + } + + async def issue_detail(repository: str, number: int) -> dict: base = f"repos/{repository}/issues/{number}" issue, comments = await asyncio.gather( diff --git a/src/main.py b/src/main.py index 61f96bf..4dd8692 100644 --- a/src/main.py +++ b/src/main.py @@ -137,6 +137,10 @@ class IssueCreation(BaseModel): return value.strip() +class IssueLabelUpdate(BaseModel): + label_ids: list[PositiveInt] = Field(max_length=20) + + class PullReviewComment(BaseModel): path: str = Field(min_length=1, max_length=1_000) body: str = Field(min_length=1, max_length=10_000) @@ -995,6 +999,64 @@ async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt ) +@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels") +async def assigned_issue_label_options( + owner: str, + repo: str, + number: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + + async def load_labels(): + if not await gitea_proxy.is_assigned_issue(repository, number): + raise HTTPException(status_code=404, detail="Assigned issue not found") + return await gitea_proxy.repo_labels(repository) + + try: + return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS) + except HTTPException: + raise + except Exception: + return JSONResponse( + {"error": "Labels could not be loaded. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + + +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/labels") +async def update_assigned_issue_labels( + update: IssueLabelUpdate, + owner: str, + repo: str, + number: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + + async def update_labels(): + if not await gitea_proxy.is_assigned_issue(repository, number): + raise HTTPException(status_code=404, detail="Assigned issue not found") + available = await gitea_proxy.repo_labels(repository) + valid_ids = { + item.get("id") for item in available + if isinstance(item, dict) and isinstance(item.get("id"), int) + } + if any(label_id not in valid_ids for label_id in update.label_ids): + raise HTTPException(status_code=422, detail="Unknown repository label") + return await gitea_proxy.update_issue_labels(repository, number, update.label_ids) + + try: + return await asyncio.wait_for(update_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS) + except HTTPException: + raise + except Exception: + return JSONResponse( + {"error": "Labels could not be updated. Your selection is safe; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + + @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review") async def review_detail(owner: str, repo: str, number: int): async def load_requested_review(): diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index f4210b6..e246b29 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -479,3 +479,107 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments } ], } + + +@pytest.mark.anyio +async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch): + calls = [] + + async def assigned(repository, number): + return (repository, number) == ("stackchain/api", 7) + + async def labels(repository): + assert repository == "stackchain/api" + return [ + {"id": 3, "name": "P0", "color": "d73a4a"}, + {"id": 8, "name": "frontend", "color": "1d76db"}, + ] + + async def update(repository, number, label_ids): + calls.append((repository, number, label_ids)) + return {"number": number, "labels": ["P0", "frontend"]} + + monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) + monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels) + monkeypatch.setattr(main.gitea_proxy, "update_issue_labels", update, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + "/api/v1/repos/stackchain/api/issues/7/labels", + json={"label_ids": [3, 8]}, + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == {"number": 7, "labels": ["P0", "frontend"]} + assert calls == [("stackchain/api", 7, [3, 8])] + + +@pytest.mark.anyio +async def test_assigned_issue_label_options_do_not_depend_on_repository_listing(monkeypatch): + calls = [] + + async def assigned(repository, number): + calls.append(("assigned", repository, number)) + return True + + async def labels(repository): + calls.append(("labels", repository)) + return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}] + + monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) + monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels) + 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/7/labels") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()[0]["name"] == "P0" + assert calls == [ + ("assigned", "stackchain/api", 7), + ("labels", "stackchain/api"), + ] + + +@pytest.mark.anyio +async def test_gitea_update_issue_labels_patches_ids_and_normalizes_confirmation(): + requests = [] + + async def handler(request): + requests.append(request) + return httpx.Response(200, json={ + "number": 7, + "labels": [ + {"id": 3, "name": "P0"}, + {"id": 8, "name": "frontend"}, + ], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.update_issue_labels("stackchain/api", 7, [3, 8]) + finally: + await gitea_proxy.stop_client() + + assert len(requests) == 1 + assert requests[0].method == "PATCH" + assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7" + assert requests[0].content == b'{"labels":[3,8]}' + assert result == {"number": 7, "labels": ["P0", "frontend"]} + + +@pytest.mark.anyio +async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set(): + async def handler(_request): + return httpx.Response(200, json={ + "number": 7, + "labels": [{"id": 8, "name": "frontend"}], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(ValueError, match="confirm"): + await gitea_proxy.update_issue_labels("stackchain/api", 7, [3]) + finally: + await gitea_proxy.stop_client() diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 0c14400..3695d90 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -77,6 +77,38 @@ process.stdout.write(JSON.stringify(queue)); assert queue[2]["reason"] == "Assigned to you" +def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue(): + payload = { + "user": {"login": "timmy"}, + "issues": [ + {"number": 1, "title": "Older", "repository": "stackchain/api", + "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"}, + {"number": 2, "title": "Newer", "repository": "stackchain/api", + "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T12:00:00Z"}, + ], + "pull_requests": [], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const original = {json.dumps(payload)}; +const updated = buildMyWork.replaceIssueLabels(original, 'stackchain/api', 1, ['P0']); +process.stdout.write(JSON.stringify({{ + titles: buildMyWork(updated).map(item => item.title), + labels: updated.issues[0].labels, + original: original.issues[0].labels, +}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "titles": ["Older", "Newer"], + "labels": ["P0"], + "original": [], + } + + def test_my_work_reviews_filter_and_summary_are_actionable(): items = [ {"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True}, @@ -746,6 +778,30 @@ controller.load({{repository:'stackchain/api', number:7}}).then(detail => } +def test_issue_sheet_loads_labels_through_assigned_issue_boundary(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +let request; +const controller = createIssueSheet({{ fetchJson: async (url, options) => {{ + request = {{url, accept:options.headers.Accept}}; + return [{{id:3, name:'P0'}}]; +}} }}); +controller.loadLabels({{repository:'stackchain/api', number:7}}).then(labels => + process.stdout.write(JSON.stringify({{request, labels}})) +); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == { + "request": { + "url": "api/v1/repos/stackchain/api/issues/7/labels", + "accept": "application/json", + }, + "labels": [{"id": 3, "name": "P0"}], + } + + def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -785,6 +841,51 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}] +def test_issue_sheet_label_save_is_single_flight_and_keeps_selection_until_confirmed(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const values = new Map(); +const storage = {{ + getItem:key => values.get(key) || null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +const calls = []; +let release; +const controller = createIssueSheet({{ + storage, + fetchJson: (url, options={{}}) => {{ + calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}}); + return new Promise(resolve => {{ release = () => resolve({{number:7, labels:['P0']}}); }}); + }}, +}}); +const item = {{repository:'stackchain/api', number:7}}; +const first = controller.updateLabels(item, [3]); +const duplicate = controller.updateLabels(item, [3]); +const during = controller.loadLabelDraft(item); +release(); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls, during, after:controller.loadLabelDraft(item), results +}}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/api/issues/7/labels", + "method": "PATCH", + "body": {"label_ids": [3]}, + }] + assert output["during"] == [3] + assert output["after"] == [] + assert output["results"] == [ + {"number": 7, "labels": ["P0"]}, + {"number": 7, "labels": ["P0"]}, + ] + + def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -920,6 +1021,21 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut assert "e.key === 'Escape' && selectedIssue" in html +@pytest.mark.anyio +async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority(): + html = await dashboard() + + assert 'id="issue-label-editor"' in html + assert 'aria-describedby="issue-label-status"' in html + assert 'id="issue-label-list"' in html + assert 'id="save-issue-labels"' in html + assert '.issue-label-option { min-height:44px;' in html + assert 'max-width:100%;' in html + assert "issueController.updateLabels(selectedIssue" in html + assert "buildMyWork.replaceIssueLabels" in html + assert "paintMyWork(lastContextSnapshot)" in html + + @pytest.mark.anyio async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(): html = await dashboard()