Revise open delegated issues from mobile Filed #888
|
|
@ -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%; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -645,9 +645,7 @@
|
|||
</div>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<section class="issue-content-editor" aria-label="Issue content">
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
|
|
@ -662,6 +660,10 @@
|
|||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
<div class="issue-label-list" id="issue-label-list"></div>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user