diff --git a/frontend/dashboard.css b/frontend/dashboard.css index bca02a6..c000ce3 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -488,6 +488,7 @@ textarea { resize: vertical; min-height: 120px; } .issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; } #manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; } .today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; } +.issue-content-editor { max-width:100%; overflow-x:hidden; margin-top:16px; padding:12px; border:1px solid #2a496e; border-radius:12px; } .issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; } .issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; } .issue-planning-retry { min-height:44px; width:100%; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d0730b4..f4231a2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -194,6 +194,7 @@ let issueBlockerSearchTimer = null; let issueConversation = null; let issueTrigger = null; + let issueEditHistoryActive = false; let selectedPull = null; let pullTrigger = null; let selectedPullDetail = null; @@ -3437,7 +3438,9 @@ qs('#issue-sheet-status').textContent = item.is_completed ? 'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' : 'Issue ready · ' + (detail.state || 'open'); - qs('#edit-issue-content').disabled = false; + const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open'; + qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue'; + qs('#edit-issue-content').disabled = readOnly && !revisableFiling; const dueDraft = issueController.loadDueDateDraft(item); qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10); qs('#issue-due-date').disabled = false; @@ -3462,6 +3465,10 @@ } function closeIssueSheet(navigate = true) { + if (navigate && issueEditHistoryActive) { + history.back(); + return; + } if (navigate && createWorkRoute.parse(window.location.hash)) { workRoute.close(); return; @@ -3476,6 +3483,14 @@ if (issueTrigger?.isConnected) issueTrigger.focus(); } + function closeIssueEditorFromHistory() { + if (!issueEditHistoryActive) return; + issueEditHistoryActive = false; + qs('#issue-edit-form').hidden = true; + if (selectedIssue) qs('#edit-issue-content').focus(); + } + window.addEventListener('popstate', closeIssueEditorFromHistory); + qs('#acknowledge-completed-filed').addEventListener('click', () => { if (!selectedIssue?.is_completed) return; if (!completedFiledReview.acknowledge(selectedIssue)) { @@ -4958,6 +4973,10 @@ } if (e.key === 'Escape' && selectedIssue) { e.preventDefault(); + if (!qs('#issue-edit-form').hidden) { + history.back(); + return; + } closeIssueSheet(); return; } @@ -5479,6 +5498,10 @@ qs('#issue-edit-title').value = draft.title; qs('#issue-edit-body').value = draft.body; qs('#issue-edit-form').hidden = false; + if (!issueEditHistoryActive) { + history.pushState({ ...history.state, stackchainIssueEdit:true }, '', window.location.href); + issueEditHistoryActive = true; + } qs('#issue-edit-status').textContent = 'Edit the issue, then save.'; qs('#issue-edit-title').focus(); }); @@ -5493,8 +5516,11 @@ }) ); qs('#cancel-issue-content').addEventListener('click', () => { - qs('#issue-edit-form').hidden = true; - qs('#edit-issue-content').focus(); + if (issueEditHistoryActive) history.back(); + else { + qs('#issue-edit-form').hidden = true; + qs('#edit-issue-content').focus(); + } }); qs('#issue-edit-form').addEventListener('submit', async event => { event.preventDefault(); @@ -5526,7 +5552,8 @@ qs('#issue-sheet-title').textContent = confirmed.title; qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.'); paintMyWork(lastContextSnapshot); - qs('#issue-edit-form').hidden = true; + if (issueEditHistoryActive) history.back(); + else qs('#issue-edit-form').hidden = true; qs('#issue-sheet-status').textContent = 'Issue saved.'; qs('#edit-issue-content').focus(); } catch (error) { diff --git a/frontend/index.html b/frontend/index.html index 7573e2c..7c4568b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -645,9 +645,7 @@
-
- Plan & edit - +
+
+ Plan & edit +
Labels
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 275d872..1292cb3 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -117,7 +117,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global updateContent(item, draft) { if (editRequest) return editRequest; this.saveEditDraft(item, draft); - editRequest = fetchJson(issuePath(item) + '/content', { + const access = this.readOnly(item) ? '?access=filed' : ''; + editRequest = fetchJson(issuePath(item) + '/content' + access, { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 4afa07b..c29a619 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -2292,15 +2292,47 @@ async def update_assigned_issue( title: str, body: str, expected_updated_at: str, +) -> dict: + return await _update_issue_content( + repository, number, title, body, expected_updated_at, require_author=False + ) + + +async def update_authored_issue( + repository: str, + number: int, + title: str, + body: str, + expected_updated_at: str, +) -> dict: + return await _update_issue_content( + repository, number, title, body, expected_updated_at, require_author=True + ) + + +async def _update_issue_content( + repository: str, + number: int, + title: str, + body: str, + expected_updated_at: str, + *, + require_author: bool, ) -> dict: path = f"repos/{repository}/issues/{number}" login, issue = await _current_login_and_target(path) + author = issue.get("user") if isinstance(issue.get("user"), dict) else {} + authorized = ( + author.get("login") == login + if require_author + else _login_in_users(login, issue.get("assignees")) + ) if ( issue.get("state") != "open" or isinstance(issue.get("pull_request"), dict) - or not _login_in_users(login, issue.get("assignees")) + or not authorized ): - raise IssueNotAvailableError("assigned issue not found") + raise IssueNotAvailableError("issue not found") if issue.get("updated_at") != expected_updated_at: raise IssueEditConflictError("issue changed upstream") diff --git a/src/main.py b/src/main.py index df89325..b2e39a6 100644 --- a/src/main.py +++ b/src/main.py @@ -4549,11 +4549,17 @@ async def update_assigned_issue_content( owner: str, repo: str, number: int = PathParam(gt=0), + access: Literal["assigned", "filed"] = Query(default="assigned"), ): repository = f"{owner}/{repo}" try: + update_issue = ( + gitea_proxy.update_authored_issue + if access == "filed" + else gitea_proxy.update_assigned_issue + ) result = await asyncio.wait_for( - gitea_proxy.update_assigned_issue( + update_issue( repository, number, update.title, diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 128e14f..ab6615b 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -570,6 +570,66 @@ async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(m )] +@pytest.mark.anyio +async def test_edit_filed_issue_uses_open_author_authorization(monkeypatch): + calls = [] + + async def update(repository, number, title, body, expected_updated_at): + calls.append((repository, number, title, body, expected_updated_at)) + return { + "repository": repository, "number": number, "title": title, "body": body, + "state": "open", "updated_at": "2026-08-07T10:01:00Z", + } + + monkeypatch.setattr(main.gitea_proxy, "update_authored_issue", 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/17/content?access=filed", + json={ + "title": "Clarified filing", "body": "Canonical criteria", + "expected_updated_at": "2026-08-07T10:00:00Z", + }, + ) + + assert response.status_code == 200 + assert calls == [( + "stackchain/api", 17, "Clarified filing", "Canonical criteria", + "2026-08-07T10:00:00Z", + )] + + +@pytest.mark.anyio +@pytest.mark.parametrize("target", [ + {"state": "closed", "user": {"login": "timmy"}, "pull_request": None}, + {"state": "open", "user": {"login": "alex"}, "pull_request": None}, + {"state": "open", "user": {"login": "timmy"}, "pull_request": {}}, +]) +async def test_gitea_edit_filed_issue_rejects_ineligible_target_without_patch(target): + requests = [] + + async def handler(request): + requests.append(request) + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "number": 17, "title": "Old", "body": "Old body", + "updated_at": "2026-08-07T10:00:00Z", **target, + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(gitea_proxy.IssueNotAvailableError): + await gitea_proxy.update_authored_issue( + "stackchain/api", 17, "Clarified", "New body", + "2026-08-07T10:00:00Z", + ) + finally: + await gitea_proxy.stop_client() + + assert [request.method for request in requests] == ["GET", "GET"] + + @pytest.mark.anyio async def test_gitea_edit_issue_rejects_stale_revision_without_patch(): requests = [] diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 154eeb0..a8ec0d7 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2184,6 +2184,43 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z" +def test_filed_issue_content_edit_uses_author_scoped_endpoint_and_keeps_draft_on_failure(): + 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), +}}; +let call; +const controller = createIssueSheet({{ + storage, + fetchJson:(url, options) => {{ call={{url,options}}; return Promise.reject(new Error('conflict')); }}, +}}); +const item = {{repository:'stackchain/api', number:17, is_filed:true, is_assigned:false, state:'open'}}; +const draft = {{title:'Clarified filing', body:'Canonical body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}}; +controller.updateContent(item, draft).catch(() => process.stdout.write(JSON.stringify({{ + url:call.url, body:JSON.parse(call.options.body), draft:controller.loadEditDraft(item) +}}))); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output == { + "url": "api/v1/repos/stackchain/api/issues/17/content?access=filed", + "body": { + "title": "Clarified filing", "body": "Canonical body", + "expected_updated_at": "2026-08-07T10:00:00Z", + }, + "draft": { + "title": "Clarified filing", "body": "Canonical body", + "expectedUpdatedAt": "2026-08-07T10:00:00Z", + }, + } + + def test_issue_due_date_update_is_single_flight_and_keeps_draft_until_confirmed(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -4761,6 +4798,23 @@ async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor(): assert 'buildMyWork.replaceIssueContent(' in html +@pytest.mark.anyio +async def test_mobile_filed_issue_exposes_only_open_author_revision_controls(): + html = await dashboard() + + assert 'class="issue-content-editor"' in html + assert "const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open';" in html + assert "qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue';" in html + assert "qs('#edit-issue-content').disabled = readOnly && !revisableFiling;" in html + assert '#issue-sheet.read-only .issue-content-editor' not in html + assert '.issue-content-editor { max-width:100%; overflow-x:hidden;' in html + assert '.issue-edit-form textarea { min-height:132px;' in html + assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html + assert "history.pushState({ ...history.state, stackchainIssueEdit:true }, '', window.location.href);" in html + assert "window.addEventListener('popstate', closeIssueEditorFromHistory);" in html + assert "if (!qs('#issue-edit-form').hidden)" in html + + @pytest.mark.anyio async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe(): html = await dashboard()