From 19dfc93b855728199dd1690feadc4bf9cc1c4436 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 19:18:27 +0000 Subject: [PATCH] feat: plan issues during mobile capture (#221) --- README.md | 7 ++- frontend/create-issue-sheet.js | 27 ++++++++-- frontend/index.html | 45 ++++++++++++++++- src/gitea_proxy.py | 24 +++++++++ src/main.py | 37 ++++++++++++++ tests/test_issue_api.py | 90 ++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 57 +++++++++++++++++++++ 7 files changed, 280 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ccfc3f7..db19336 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,15 @@ python3 -m pip install -r requirements.txt Point the dashboard at the Gitea server root (without `/api/v1`) and provide a token that can read dashboard data, update the authenticated user's notification threads, create and self-assign issues, discover, claim, and release issue assignments, -set or clear due dates on assigned issues, create issue comments, close assigned issues, +list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues, inspect/comment on assigned pull requests, merge assigned pull requests, and submit pull-request reviews. Pull-request replies and mobile My Work issue and PR comments use Gitea's issue-comment API; mobile issue capture requires issue -creation and assignment permission. Issue capture and authored mobile actions (issue +creation and assignment permission. The New issue sheet can optionally select an open +repository milestone and due date; the dashboard validates both and sends them with +self-assignment in the single create request, so planned work appears in its release +lane immediately. Issue capture and authored mobile actions (issue comments, pull-request comments, notification replies, and reviews) persist per-draft idempotency keys, so retrying after a timeout, reload, process restart, or handoff to another worker replays a confirmed result instead of posting duplicate content. Results diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index d9fac54..6bc2662 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -15,18 +15,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp (Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0) )).slice(0, 20); const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] }); + const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null; + const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : ''; function loadStored() { try { const parsed = JSON.parse(storage.getItem(storageKey) || 'null'); if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''}; - return { + const draft = { repository: String(parsed.repository || ''), title: String(parsed.title || ''), body: String(parsed.body || ''), labelIds: safeLabelIds(parsed.labelIds), operationId: String(parsed.operationId || '').slice(0, 128), }; + const milestoneId = safeMilestoneId(parsed.milestoneId); + const dueDate = safeDueDate(parsed.dueDate); + if (milestoneId !== null) draft.milestoneId = milestoneId; + if (dueDate) draft.dueDate = dueDate; + return draft; } catch (_error) { return {...emptyDraft(), operationId: ''}; } @@ -45,7 +52,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp body: String(draft?.body || ''), labelIds: safeLabelIds(draft?.labelIds), }; - const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) && + const milestoneId = safeMilestoneId(draft?.milestoneId); + const dueDate = safeDueDate(draft?.dueDate); + if (milestoneId !== null) safe.milestoneId = milestoneId; + if (dueDate) safe.dueDate = dueDate; + const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate'] + .every(key => (previous[key] || '') === (safe[key] || '')) && JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds); writeStored({...safe, operationId: unchanged ? previous.operationId : ''}); return safe; @@ -73,6 +85,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp ); } + function loadMilestones(repository) { + const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/'); + return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones => + Array.isArray(milestones) ? milestones : [] + ); + } + function submit(draft) { if (pending) return pending; const saved = saveDraft(draft); @@ -88,6 +107,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp }, body: JSON.stringify({ title: saved.title, body: saved.body, label_ids: saved.labelIds, + ...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}), + ...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}), }), }).then(issue => { clearDraft(); @@ -96,7 +117,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp return pending; } - return { saveDraft, loadDraft, loadLabels, submit }; + return { saveDraft, loadDraft, loadLabels, loadMilestones, submit }; } if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; diff --git a/frontend/index.html b/frontend/index.html index ee9cece..536757e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -206,7 +206,7 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-header button, .create-issue-actions button { min-height:44px; } .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-form select, .create-issue-form input[type="date"] { 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; } @@ -519,6 +519,15 @@ textarea { resize: vertical; min-height: 120px; } + +
Labels Optional
Choose a repository to load labels.
@@ -1757,6 +1766,8 @@ textarea { resize: vertical; min-height: 120px; } title: qs('#create-issue-title').value, body: qs('#create-issue-body').value, labelIds: selectedIssueLabelIds(), + milestoneId: Number(qs('#create-issue-milestone').value) || null, + dueDate: qs('#create-issue-due-date').value, }); } @@ -1783,6 +1794,30 @@ textarea { resize: vertical; min-height: 120px; } } } + async function loadIssueMilestones(repository, selectedId = null) { + const select = qs('#create-issue-milestone'); + const status = qs('#create-issue-milestone-status'); + select.innerHTML = ''; + if (!repository) { + status.textContent = 'Choose a repository to load milestones.'; + return; + } + status.textContent = 'Loading milestones…'; + try { + const milestones = await issueCapture.loadMilestones(repository); + if (qs('#create-issue-repository').value !== repository) return; + select.innerHTML += milestones.map(milestone => + '' + ).join(''); + if (selectedId) select.value = String(selectedId); + status.textContent = milestones.length ? + 'Choose the release lane for this issue.' : 'This repository has no open milestones.'; + } catch (_error) { + if (qs('#create-issue-repository').value !== repository) return; + status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.'; + } + } + function openCreateIssueSheet() { const captureDraft = issueCapture.loadDraft(); const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); @@ -1795,7 +1830,9 @@ textarea { resize: vertical; min-height: 120px; } if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository; qs('#create-issue-title').value = captureDraft.title; qs('#create-issue-body').value = captureDraft.body; + qs('#create-issue-due-date').value = captureDraft.dueDate || ''; loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); + loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId); 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'); @@ -2367,14 +2404,16 @@ textarea { resize: vertical; min-height: 120px; } saveIssueCaptureDraft(); closeCreateIssueSheet(); }); - ['#create-issue-title', '#create-issue-body'].forEach(selector => + ['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector => qs(selector).addEventListener('input', saveIssueCaptureDraft) ); qs('#create-issue-repository').addEventListener('change', event => { loadIssueLabels(event.target.value); + loadIssueMilestones(event.target.value); saveIssueCaptureDraft(); }); qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft); + qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft); qs('#create-issue-form').addEventListener('submit', async event => { event.preventDefault(); const captureDraft = { @@ -2382,6 +2421,8 @@ textarea { resize: vertical; min-height: 120px; } title: qs('#create-issue-title').value.trim(), body: qs('#create-issue-body').value.trim(), labelIds: selectedIssueLabelIds(), + milestoneId: Number(qs('#create-issue-milestone').value) || null, + dueDate: qs('#create-issue-due-date').value, }; 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 5a7f9a5..d74e1c0 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -751,10 +751,16 @@ async def create_issue( body: str, assignee: str, label_ids: list[int] | None = None, + milestone_id: int | None = None, + due_date: str | None = None, ) -> dict: payload: dict = {"title": title, "body": body, "assignee": assignee} if label_ids: payload["labels"] = label_ids + if milestone_id is not None: + payload["milestone"] = milestone_id + if due_date is not None: + payload["due_date"] = due_date response = await _get_client().post( f"/api/v1/repos/{repository}/issues", headers=_auth(), @@ -775,6 +781,22 @@ async def create_issue( raise ValueError("Gitea did not confirm issue self-assignment") labels_value = issue.get("labels") labels = labels_value if isinstance(labels_value, list) else [] + milestone_value = issue.get("milestone") + milestone = ( + {"id": milestone_value["id"], "title": milestone_value["title"]} + if isinstance(milestone_value, dict) + and isinstance(milestone_value.get("id"), int) + and isinstance(milestone_value.get("title"), str) + else None + ) + confirmed_due_date = ( + issue.get("due_date") if isinstance(issue.get("due_date"), str) else None + ) + if ( + (milestone_id is not None and (milestone or {}).get("id") != milestone_id) + or (due_date is not None and confirmed_due_date != due_date) + ): + raise ValueError("Gitea did not confirm issue release plan") return { "id": issue.get("id"), "number": issue["number"], @@ -791,6 +813,8 @@ async def create_issue( if isinstance(item, dict) and isinstance(item.get("name"), str) ], "assignees": confirmed_assignees, + "milestone": milestone, + "due_date": confirmed_due_date, "updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "", diff --git a/src/main.py b/src/main.py index cace81e..158de3f 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,7 @@ import os import time from collections.abc import Awaitable, Coroutine from contextlib import asynccontextmanager +from datetime import datetime from pathlib import Path from typing import Any, Literal @@ -151,6 +152,12 @@ 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) + milestone_id: PositiveInt | None = None + due_date: str | None = Field( + default=None, + pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", + max_length=20, + ) @field_validator("title") @classmethod @@ -165,6 +172,13 @@ class IssueCreation(BaseModel): def strip_issue_body(cls, value: str) -> str: return value.strip() + @field_validator("due_date") + @classmethod + def validate_due_date(cls, value: str | None) -> str | None: + if value is not None: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + return value + class IssueContentUpdate(BaseModel): title: str = Field(min_length=1, max_length=255) @@ -1439,6 +1453,27 @@ async def create_assigned_issue( } 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") + if creation.milestone_id is not None: + available_milestones = await gitea_proxy.repo_milestones(repository) + valid_milestone_ids = { + item.get("id") + for item in available_milestones + if isinstance(item, dict) and isinstance(item.get("id"), int) + } + if creation.milestone_id not in valid_milestone_ids: + raise HTTPException( + status_code=422, detail="Unknown open repository milestone" + ) + if creation.milestone_id is not None or creation.due_date is not None: + return await gitea_proxy.create_issue( + repository, + creation.title, + creation.body, + login, + creation.label_ids, + creation.milestone_id, + creation.due_date, + ) return await gitea_proxy.create_issue( repository, creation.title, creation.body, login, creation.label_ids ) @@ -1453,6 +1488,8 @@ async def create_assigned_issue( creation.title, creation.body, tuple(creation.label_ids), + creation.milestone_id, + creation.due_date, ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 165e828..484ea51 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -373,6 +373,55 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])] +@pytest.mark.anyio +async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch): + calls = [] + + async def user(): + return {"login": "timmy"} + + async def available_repos(): + return [{"full_name": "stackchain/api"}] + + async def milestones(repository): + assert repository == "stackchain/api" + return [{"id": 9, "title": "August RC"}] + + async def create(repository, title, body, assignee, label_ids, milestone_id, due_date): + calls.append((repository, title, body, assignee, label_ids, milestone_id, due_date)) + return { + "id": 221, "number": 221, "title": title, "state": "open", + "repository": repository, "labels": [], "assignees": [assignee], + "milestone": {"id": 9, "title": "August RC"}, + "due_date": "2026-08-31T23:59:59Z", + "updated_at": "2026-08-07T20:00:00Z", + "url": "https://forge.example/stackchain/api/issues/221", + } + + monkeypatch.setattr(main.gitea_proxy, "current_user", user) + monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones) + 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": "Ship mobile plan", + "milestone_id": 9, + "due_date": "2026-08-31T23:59:59Z", + }, + ) + + assert response.status_code == 201 + assert response.json()["milestone"] == {"id": 9, "title": "August RC"} + assert response.json()["due_date"] == "2026-08-31T23:59:59Z" + assert calls == [( + "stackchain/api", "Ship mobile plan", "", "timmy", [], 9, + "2026-08-31T23:59:59Z", + )] + + @pytest.mark.anyio async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch): calls = 0 @@ -630,6 +679,26 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat assert result["labels"] == ["P0"] +@pytest.mark.anyio +async def test_gitea_create_issue_requires_confirmed_release_plan(): + async def handler(_request): + return httpx.Response(201, json={ + "id": 221, "number": 221, "title": "Planned work", "state": "open", + "assignees": [{"login": "timmy"}], "labels": [], + "milestone": None, "due_date": "2026-09-01T23:59:59Z", + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(ValueError, match="release plan"): + await gitea_proxy.create_issue( + "stackchain/api", "Planned work", "", "timmy", [], 9, + "2026-08-31T23:59:59Z", + ) + finally: + await gitea_proxy.stop_client() + + @pytest.mark.anyio async def test_gitea_repo_labels_returns_safe_touch_picker_options(): requests = [] @@ -702,6 +771,27 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch): assert called is False +@pytest.mark.anyio +async def test_create_issue_rejects_nonexistent_due_date_before_upstream(monkeypatch): + called = False + + async def user(): + nonlocal called + called = True + return {"login": "timmy"} + + monkeypatch.setattr(main.gitea_proxy, "current_user", user) + 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": "Impossible plan", "due_date": "2026-02-31T23:59:59Z"}, + ) + + assert response.status_code == 422 + assert called is False + + @pytest.mark.anyio async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch): created = False diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 95fef44..8fd09aa 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -521,6 +521,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html +@pytest.mark.anyio +async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls(): + html = await dashboard() + + assert 'id="create-issue-milestone"' in html + assert 'id="create-issue-due-date" type="date"' in html + assert 'id="create-issue-milestone-status" aria-live="polite"' in html + assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html + assert 'issueCapture.loadMilestones(repository)' in html + assert "milestoneId: Number(qs('#create-issue-milestone').value) || null" in html + assert "dueDate: qs('#create-issue-due-date').value" in html + + @pytest.mark.anyio async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor(): html = await dashboard() @@ -1245,6 +1258,50 @@ capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.st assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"] +def test_issue_capture_persists_and_submits_milestone_and_due_date(): + script = f""" +const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const values = new Map(); +const calls = []; +const storage = {{ + getItem:key => values.get(key) || null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +const capture = createIssueCapture({{ + storage, + createOperationId: () => 'planned-operation', + fetchJson: (url, options) => {{ + calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}}); + if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]); + return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}}); + }}, +}}); +const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}}; +capture.saveDraft(draft); +Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results => + process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}})) +); +""" + 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/milestones", "body": None}, + { + "url": "api/v1/repos/stackchain/api/issues", + "body": { + "title": "Ship plan", "body": "", "label_ids": [], + "milestone_id": 9, "due_date": "2026-08-31T23:59:59Z", + }, + }, + ] + assert output["milestones"] == [{"id": 9, "title": "August RC"}] + assert output["stored"]["number"] == 221 + + def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable(): payload = { "user": {"login": "timmy"}, -- 2.43.0