From b6390d3298562a23bb45e7955804a7a0dbd77856 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 03:55:51 +0000 Subject: [PATCH] feat: triage mobile issue capture with labels (#163) --- frontend/create-issue-sheet.js | 25 +++++++- frontend/index.html | 47 +++++++++++++- src/gitea_proxy.py | 47 +++++++++++++- src/main.py | 42 +++++++++++- tests/test_issue_api.py | 113 +++++++++++++++++++++++++++++++-- tests/test_my_work.py | 42 ++++++++++-- 6 files changed, 297 insertions(+), 19 deletions(-) diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 0b5b18e..324e37d 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -1,13 +1,17 @@ function createIssueCapture({ fetchJson, storage }) { const storageKey = 'stackchain.issue-capture.v1'; let pending = null; - const emptyDraft = () => ({ repository: '', title: '', body: '' }); + const safeLabelIds = value => Array.from(new Set( + (Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0) + )).slice(0, 20); + const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] }); function saveDraft(draft) { const safe = { repository: String(draft?.repository || ''), title: String(draft?.title || ''), body: String(draft?.body || ''), + labelIds: safeLabelIds(draft?.labelIds), }; try { storage.setItem(storageKey, JSON.stringify(safe)); } catch (_error) { /* Keep the form as the in-memory fallback. */ } @@ -21,6 +25,7 @@ function createIssueCapture({ fetchJson, storage }) { repository: String(parsed.repository || ''), title: String(parsed.title || ''), body: String(parsed.body || ''), + labelIds: safeLabelIds(parsed.labelIds), } : emptyDraft(); } catch (_error) { return emptyDraft(); @@ -32,6 +37,18 @@ function createIssueCapture({ fetchJson, storage }) { catch (_error) { /* Confirmed creation remains authoritative. */ } } + function loadLabels(repository) { + const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/'); + const priorities = new Set(['p0', 'priority-high', 'critical']); + return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels => + (Array.isArray(labels) ? labels : []).slice().sort((left, right) => { + const leftPriority = priorities.has(String(left?.name || '').toLowerCase()); + const rightPriority = priorities.has(String(right?.name || '').toLowerCase()); + return Number(rightPriority) - Number(leftPriority); + }) + ); + } + function submit(draft) { if (pending) return pending; const saved = saveDraft(draft); @@ -39,7 +56,9 @@ function createIssueCapture({ fetchJson, storage }) { pending = fetchJson('api/v1/repos/' + repository + '/issues', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: saved.title, body: saved.body }), + body: JSON.stringify({ + title: saved.title, body: saved.body, label_ids: saved.labelIds, + }), }).then(issue => { clearDraft(); return issue; @@ -47,7 +66,7 @@ function createIssueCapture({ fetchJson, storage }) { return pending; } - return { saveDraft, loadDraft, submit }; + return { saveDraft, loadDraft, loadLabels, submit }; } if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; diff --git a/frontend/index.html b/frontend/index.html index 91064b0..f65304b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -138,6 +138,10 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-form { display:grid; gap:12px; } .create-issue-form label { display:grid; gap:6px; } .create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } +.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; } +.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; } +.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } +.create-issue-label-option input { width:20px; height:20px; margin:0; } .create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .pull-sheet.open { display:flex; } @@ -332,6 +336,11 @@ textarea { resize: vertical; min-height: 120px; } +
+ Labels Optional +
Choose a repository to load labels.
+
+
The issue will be assigned to you.
@@ -948,14 +957,43 @@ textarea { resize: vertical; min-height: 120px; } if (pullTrigger?.isConnected) pullTrigger.focus(); } + function selectedIssueLabelIds() { + return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked')) + .map(input => Number(input.value)).filter(Number.isInteger); + } + function saveIssueCaptureDraft() { issueCapture.saveDraft({ repository: qs('#create-issue-repository').value, title: qs('#create-issue-title').value, body: qs('#create-issue-body').value, + labelIds: selectedIssueLabelIds(), }); } + async function loadIssueLabels(repository, selectedIds = []) { + const list = qs('#create-issue-label-list'); + const status = qs('#create-issue-label-status'); + list.innerHTML = ''; + if (!repository) { + status.textContent = 'Choose a repository to load labels.'; + return; + } + status.textContent = 'Loading labels…'; + try { + const labels = await issueCapture.loadLabels(repository); + const selected = new Set(selectedIds.map(Number)); + list.innerHTML = labels.map(label => + '' + ).join(''); + status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.'; + } catch (error) { + status.textContent = 'Labels could not be loaded. You can still create the issue without labels.'; + } + } + function openCreateIssueSheet() { const captureDraft = issueCapture.loadDraft(); const repositories = lastContextSnapshot?.repos || []; @@ -967,6 +1005,7 @@ textarea { resize: vertical; min-height: 120px; } } qs('#create-issue-title').value = captureDraft.title; qs('#create-issue-body').value = captureDraft.body; + loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.'; qs('#submit-new-issue').disabled = !repositories.length; qs('#create-issue-sheet').classList.add('open'); @@ -1256,15 +1295,21 @@ textarea { resize: vertical; min-height: 120px; } saveIssueCaptureDraft(); closeCreateIssueSheet(); }); - ['#create-issue-repository', '#create-issue-title', '#create-issue-body'].forEach(selector => + ['#create-issue-title', '#create-issue-body'].forEach(selector => qs(selector).addEventListener('input', saveIssueCaptureDraft) ); + qs('#create-issue-repository').addEventListener('change', event => { + loadIssueLabels(event.target.value); + saveIssueCaptureDraft(); + }); + qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft); qs('#create-issue-form').addEventListener('submit', async event => { event.preventDefault(); const captureDraft = { repository: qs('#create-issue-repository').value, title: qs('#create-issue-title').value.trim(), body: qs('#create-issue-body').value.trim(), + labelIds: selectedIssueLabelIds(), }; if (!captureDraft.repository || !captureDraft.title) { qs('#create-issue-status').textContent = 'Choose a repository and add a title.'; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 9ea8a83..6ac6560 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -372,11 +372,46 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict: return _normalize_issue_comment(comment) -async def create_issue(repository: str, title: str, body: str, assignee: str) -> dict: +async def repo_labels(repository: str) -> list[dict]: + response = await _get_client().get( + f"/api/v1/repos/{repository}/labels", + headers=_auth(), + params={"limit": 50, "page": 1}, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise ValueError("Gitea labels response was not a list") + return [ + { + "id": item["id"], + "name": item["name"], + "color": item.get("color", "") if isinstance(item.get("color"), str) else "", + "description": item.get("description", "") + if isinstance(item.get("description"), str) + else "", + } + for item in payload + if isinstance(item, dict) + and isinstance(item.get("id"), int) + and isinstance(item.get("name"), str) + ] + + +async def create_issue( + repository: str, + title: str, + body: str, + assignee: str, + label_ids: list[int] | None = None, +) -> dict: + payload: dict = {"title": title, "body": body, "assignee": assignee} + if label_ids: + payload["labels"] = label_ids response = await _get_client().post( f"/api/v1/repos/{repository}/issues", headers=_auth(), - json={"title": title, "body": body, "assignee": assignee}, + json=payload, ) response.raise_for_status() issue = response.json() @@ -391,6 +426,8 @@ async def create_issue(repository: str, title: str, body: str, assignee: str) -> ] if assignee not in confirmed_assignees: raise ValueError("Gitea did not confirm issue self-assignment") + labels_value = issue.get("labels") + labels = labels_value if isinstance(labels_value, list) else [] return { "id": issue.get("id"), "number": issue["number"], @@ -401,7 +438,11 @@ async def create_issue(repository: str, title: str, body: str, assignee: str) -> if isinstance(issue.get("state"), str) else "", "repository": repository, - "labels": [], + "labels": [ + item["name"] + for item in labels + if isinstance(item, dict) and isinstance(item.get("name"), str) + ], "assignees": confirmed_assignees, "updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) diff --git a/src/main.py b/src/main.py index 0da7b4c..1de0786 100644 --- a/src/main.py +++ b/src/main.py @@ -109,6 +109,7 @@ class IssueComment(BaseModel): class IssueCreation(BaseModel): title: str = Field(min_length=1, max_length=255) body: str = Field(default="", max_length=10_000) + label_ids: list[int] = Field(default_factory=list, max_length=20) @field_validator("title") @classmethod @@ -202,6 +203,9 @@ async def prevent_live_api_caching(request, call_next): ) or ( request.url.path.startswith("/api/v1/repos/") and request.url.path.endswith("/issues") + ) or ( + request.url.path.startswith("/api/v1/repos/") + and request.url.path.endswith("/labels") ): response.headers["Cache-Control"] = "no-store" return response @@ -734,6 +738,33 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g ) +@app.get("/api/v1/repos/{owner}/{repo}/labels") +async def repository_labels(owner: str, repo: str): + repository = f"{owner}/{repo}" + + async def load_labels(): + available = await gitea_proxy.repos() + accessible = { + item.get("full_name") + for item in (available if isinstance(available, list) else []) + if isinstance(item, dict) + } + if repository not in accessible: + raise HTTPException(status_code=404, detail="Repository 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. Issue creation is still available."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + + @app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201) async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str): repository = f"{owner}/{repo}" @@ -750,8 +781,17 @@ async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str): } if not login or repository not in accessible: raise HTTPException(status_code=404, detail="Repository not found") + if creation.label_ids: + available_labels = await gitea_proxy.repo_labels(repository) + valid_label_ids = { + item.get("id") + for item in available_labels + if isinstance(item, dict) and isinstance(item.get("id"), int) + } + if any(label_id not in valid_label_ids for label_id in creation.label_ids): + raise HTTPException(status_code=422, detail="Unknown repository label") return await gitea_proxy.create_issue( - repository, creation.title, creation.body, login + repository, creation.title, creation.body, login, creation.label_ids ) try: diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 7080c7d..f4210b6 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -16,15 +16,22 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm async def available_repos(): return [{"full_name": "stackchain/api"}] - async def create(repository, title, body, assignee): - calls.append((repository, title, body, assignee)) + async def labels(repository): + assert repository == "stackchain/api" + return [ + {"id": 3, "name": "P0", "color": "d73a4a"}, + {"id": 8, "name": "frontend", "color": "1d76db"}, + ] + + async def create(repository, title, body, assignee, label_ids): + calls.append((repository, title, body, assignee, label_ids)) return { "id": 81, "number": 17, "title": title, "state": "open", "repository": repository, - "labels": [], + "labels": ["P0"], "assignees": [assignee], "updated_at": "2026-08-07T03:00:00Z", "url": "https://forge.example/stackchain/api/issues/17", @@ -32,19 +39,25 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False) monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/v1/repos/stackchain/api/issues", - json={"title": " Capture mobile work ", "body": " Context "}, + json={ + "title": " Capture mobile work ", + "body": " Context ", + "label_ids": [3], + }, ) assert response.status_code == 201 assert response.headers["cache-control"] == "no-store" assert response.json()["number"] == 17 assert response.json()["assignees"] == ["timmy"] - assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy")] + assert response.json()["labels"] == ["P0"] + assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])] @pytest.mark.anyio @@ -58,12 +71,13 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat "updated_at": "2026-08-07T03:00:00Z", "html_url": "https://forge.example/stackchain/api/issues/17", "assignees": [{"login": "timmy"}], + "labels": [{"id": 3, "name": "P0"}], }) gitea_proxy.start_client(transport=httpx.MockTransport(handler)) try: result = await gitea_proxy.create_issue( - "stackchain/api", "Capture mobile work", "Context", "timmy" + "stackchain/api", "Capture mobile work", "Context", "timmy", [3] ) finally: await gitea_proxy.stop_client() @@ -72,11 +86,63 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat assert requests[0].method == "POST" assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues" assert requests[0].content == ( - b'{"title":"Capture mobile work","body":"Context","assignee":"timmy"}' + b'{"title":"Capture mobile work","body":"Context","assignee":"timmy","labels":[3]}' ) assert result["repository"] == "stackchain/api" assert result["number"] == 17 assert result["assignees"] == ["timmy"] + assert result["labels"] == ["P0"] + + +@pytest.mark.anyio +async def test_gitea_repo_labels_returns_safe_touch_picker_options(): + requests = [] + + async def handler(request): + requests.append(request) + return httpx.Response(200, json=[ + {"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}, + {"id": "bad", "name": "invalid", "color": "000000"}, + ]) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.repo_labels("stackchain/api") + finally: + await gitea_proxy.stop_client() + + assert requests[0].url.path == "/api/v1/repos/stackchain/api/labels" + assert requests[0].url.params["limit"] == "50" + assert result == [{ + "id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent" + }] + + +@pytest.mark.anyio +async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(monkeypatch): + calls = [] + + async def available_repos(): + return [{"full_name": "stackchain/api"}] + + async def labels(repository): + calls.append(repository) + return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}] + + monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + 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/labels") + missing = await client.get("/api/v1/repos/other/private/labels") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == [{ + "id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent" + }] + assert missing.status_code == 404 + assert calls == ["stackchain/api"] @pytest.mark.anyio @@ -100,6 +166,39 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch): assert called is False +@pytest.mark.anyio +async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch): + created = False + + async def user(): + return {"login": "timmy"} + + async def available_repos(): + return [{"full_name": "stackchain/api"}] + + async def labels(_repository): + return [{"id": 3, "name": "P0"}] + + async def create(*_args): + nonlocal created + created = True + + monkeypatch.setattr(main.gitea_proxy, "current_user", user) + monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels) + monkeypatch.setattr(main.gitea_proxy, "create_issue", create) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/repos/stackchain/api/issues", + json={"title": "Urgent work", "label_ids": [999]}, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "Unknown repository label" + assert created is False + + @pytest.mark.anyio async def test_gitea_create_issue_requires_confirmed_self_assignment(): async def handler(_request): diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 979a556..b324518 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -140,7 +140,7 @@ const capture = createIssueCapture({{ }}); }}); }}, }}); -const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context'}}; +const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}}; capture.saveDraft(draft); const first = capture.submit(draft); const duplicate = capture.submit(draft); @@ -159,16 +159,45 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string assert output["calls"] == [{ "url": "api/v1/repos/stackchain/api/issues", "method": "POST", - "body": {"title": "Capture work", "body": "Context"}, + "body": {"title": "Capture work", "body": "Context", "label_ids": [3]}, }] assert output["during"] == { - "repository": "stackchain/api", "title": "Capture work", "body": "Context" + "repository": "stackchain/api", "title": "Capture work", "body": "Context", + "labelIds": [3], + } + assert output["after"] == { + "repository": "", "title": "", "body": "", "labelIds": [] } - assert output["after"] == {"repository": "", "title": "", "body": ""} assert output["results"][0]["number"] == 17 assert output["results"][1]["number"] == 17 +def test_issue_capture_loads_repository_labels_with_priorities_first(): + script = f""" +const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const calls = []; +const capture = createIssueCapture({{ + storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}}, + fetchJson: url => {{ + calls.push(url); + return Promise.resolve([ + {{id:8,name:'frontend',color:'1d76db'}}, + {{id:3,name:'P0',color:'d73a4a'}}, + {{id:5,name:'critical',color:'b60205'}} + ]); + }}, +}}); +capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.stringify({{calls,labels}}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["calls"] == ["api/v1/repos/stackchain/api/labels"] + assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"] + + def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable(): payload = { "user": {"login": "timmy"}, @@ -804,6 +833,8 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet( assert 'id="create-issue-repository"' in html assert 'id="create-issue-title"' in html and 'maxlength="255"' in html assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html + assert 'id="create-issue-labels"' in html and 'aria-describedby="create-issue-label-status"' in html + assert 'id="create-issue-label-status"' in html and 'aria-live="polite"' in html assert 'id="submit-new-issue"' in html assert '.create-issue-sheet.open { display:flex; }' in html assert 'height:100dvh;' in html @@ -812,6 +843,9 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet( assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html assert 'openIssueSheet(created' in html + assert 'issueCapture.loadLabels(repository)' in html + assert "input[name=\"create-issue-label\"]:checked" in html + assert '.create-issue-label-option' in html and 'min-height:44px' in html @pytest.mark.anyio -- 2.43.0