Withdraw open delegated issues from mobile Filed #885
|
|
@ -535,7 +535,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
#issue-sheet.read-only #issue-planning,
|
#issue-sheet.read-only #issue-planning,
|
||||||
#issue-sheet.read-only #issue-handoff,
|
#issue-sheet.read-only #issue-handoff,
|
||||||
#issue-sheet.read-only #release-issue,
|
#issue-sheet.read-only #release-issue,
|
||||||
#issue-sheet.read-only #close-issue,
|
|
||||||
#issue-sheet.read-only .detail-defer,
|
#issue-sheet.read-only .detail-defer,
|
||||||
#issue-sheet.read-only #issue-blockers,
|
#issue-sheet.read-only #issue-blockers,
|
||||||
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
||||||
|
|
|
||||||
|
|
@ -3352,6 +3352,7 @@
|
||||||
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const readOnly = issueController.readOnly(item);
|
const readOnly = issueController.readOnly(item);
|
||||||
|
const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';
|
||||||
qs('#issue-sheet').classList.toggle('read-only', readOnly);
|
qs('#issue-sheet').classList.toggle('read-only', readOnly);
|
||||||
issueDetailPosition.open(workDetailIdentity('issue', item));
|
issueDetailPosition.open(workDetailIdentity('issue', item));
|
||||||
qs('#issue-planning').inert = false;
|
qs('#issue-planning').inert = false;
|
||||||
|
|
@ -3411,7 +3412,8 @@
|
||||||
qs('#issue-edit-form').hidden = true;
|
qs('#issue-edit-form').hidden = true;
|
||||||
qs('#issue-edit-status').textContent = '';
|
qs('#issue-edit-status').textContent = '';
|
||||||
qs('#close-issue').disabled = false;
|
qs('#close-issue').disabled = false;
|
||||||
qs('#close-issue').textContent = offlineDetail ?
|
qs('#close-issue').hidden = readOnly && !withdrawable;
|
||||||
|
qs('#close-issue').textContent = withdrawable ? 'Withdraw issue' : offlineDetail ?
|
||||||
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
|
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
|
||||||
(workSession.active() ? 'Close & next' : 'Close issue');
|
(workSession.active() ? 'Close & next' : 'Close issue');
|
||||||
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
|
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||||
|
|
@ -5914,7 +5916,12 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#close-issue').addEventListener('click', async () => {
|
qs('#close-issue').addEventListener('click', async () => {
|
||||||
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
if (!selectedIssue) return;
|
||||||
|
const withdrawing = selectedIssue.is_filed && !selectedIssue.is_assigned;
|
||||||
|
const confirmation = withdrawing ?
|
||||||
|
'Withdraw ' + selectedIssue.key + '? This closes the delegated request.' :
|
||||||
|
'Close ' + selectedIssue.key + '?';
|
||||||
|
if (!window.confirm(confirmation)) return;
|
||||||
const closing = selectedIssue;
|
const closing = selectedIssue;
|
||||||
const button = qs('#close-issue');
|
const button = qs('#close-issue');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
@ -5940,11 +5947,13 @@
|
||||||
}
|
}
|
||||||
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
||||||
try {
|
try {
|
||||||
await issueController.close(selectedIssue);
|
const result = await issueController.close(selectedIssue);
|
||||||
closeIssueSheet();
|
closeIssueSheet();
|
||||||
lastMyWork = lastMyWork.filter(item =>
|
if (withdrawing) Object.assign(closing, {
|
||||||
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
|
state:'closed',is_completed:true,updated_at:result.updated_at || closing.updated_at,
|
||||||
);
|
});
|
||||||
|
else lastMyWork = lastMyWork.filter(item =>
|
||||||
|
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number));
|
||||||
refreshMyWorkView({ reconcileSession:false });
|
refreshMyWorkView({ reconcileSession:false });
|
||||||
const continuingSession = workSession.active();
|
const continuingSession = workSession.active();
|
||||||
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
|
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
|
||||||
|
|
@ -5953,7 +5962,7 @@
|
||||||
} else if (transitionResult === 'gated') {
|
} else if (transitionResult === 'gated') {
|
||||||
qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
|
qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
|
||||||
} else if (!continuingSession) {
|
} else if (!continuingSession) {
|
||||||
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
qs('#my-work-action-status').textContent = closing.key + (withdrawing ? ' withdrawn.' : ' closed.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||||
},
|
},
|
||||||
close(item) {
|
close(item) {
|
||||||
if (closeRequest) return closeRequest;
|
if (closeRequest) return closeRequest;
|
||||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
const access = item?.is_filed && !item?.is_assigned ? '?access=filed' : '';
|
||||||
|
closeRequest = fetchJson(issuePath(item) + '/close' + access, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
}).then(result => {
|
}).then(result => {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ FEATURE_SOURCES = {
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||||
"static/issue-attachment.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
"static/issue-attachment.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
|
|
|
||||||
|
|
@ -1253,13 +1253,16 @@ async def close_issue(repository: str, number: int) -> dict:
|
||||||
issue = response.json()
|
issue = response.json()
|
||||||
if not isinstance(issue, dict) or issue.get("state") != "closed":
|
if not isinstance(issue, dict) or issue.get("state") != "closed":
|
||||||
raise ValueError("Gitea did not confirm issue closure")
|
raise ValueError("Gitea did not confirm issue closure")
|
||||||
return {
|
result = {
|
||||||
"number": issue.get("number"),
|
"number": issue.get("number"),
|
||||||
"state": "closed",
|
"state": "closed",
|
||||||
"closed_at": issue.get("closed_at", "")
|
"closed_at": issue.get("closed_at", "")
|
||||||
if isinstance(issue.get("closed_at"), str)
|
if isinstance(issue.get("closed_at"), str)
|
||||||
else "",
|
else "",
|
||||||
}
|
}
|
||||||
|
if isinstance(issue.get("updated_at"), str):
|
||||||
|
result["updated_at"] = issue["updated_at"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _normalize_issue_comment(comment: dict) -> dict:
|
def _normalize_issue_comment(comment: dict) -> dict:
|
||||||
|
|
@ -2423,6 +2426,18 @@ async def is_authored_issue(repository: str, number: int) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_open_authored_issue(repository: str, number: int) -> bool:
|
||||||
|
login, issue = await _current_login_and_target(
|
||||||
|
f"repos/{repository}/issues/{number}"
|
||||||
|
)
|
||||||
|
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
||||||
|
return (
|
||||||
|
issue.get("state") == "open"
|
||||||
|
and not isinstance(issue.get("pull_request"), dict)
|
||||||
|
and author.get("login") == login
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def pull_requests() -> WorkItems:
|
async def pull_requests() -> WorkItems:
|
||||||
assigned, review_requested = await asyncio.gather(
|
assigned, review_requested = await asyncio.gather(
|
||||||
work_page("pull"),
|
work_page("pull"),
|
||||||
|
|
|
||||||
18
src/main.py
18
src/main.py
|
|
@ -5296,6 +5296,7 @@ async def close_assigned_issue(
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
number: int = PathParam(gt=0),
|
number: int = PathParam(gt=0),
|
||||||
|
access: Literal["assigned", "filed"] = "assigned",
|
||||||
step_up_grant: str | None = Header(
|
step_up_grant: str | None = Header(
|
||||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||||
),
|
),
|
||||||
|
|
@ -5310,12 +5311,21 @@ async def close_assigned_issue(
|
||||||
|
|
||||||
target = f"{repository}#{number}"
|
target = f"{repository}#{number}"
|
||||||
try:
|
try:
|
||||||
assigned = await asyncio.wait_for(
|
authorized = await asyncio.wait_for(
|
||||||
gitea_proxy.is_assigned_issue(repository, number),
|
(
|
||||||
|
gitea_proxy.is_open_authored_issue(repository, number)
|
||||||
|
if access == "filed"
|
||||||
|
else gitea_proxy.is_assigned_issue(repository, number)
|
||||||
|
),
|
||||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
if not assigned:
|
if not authorized:
|
||||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
detail = (
|
||||||
|
"Open authored issue not found"
|
||||||
|
if access == "filed"
|
||||||
|
else "Assigned issue not found"
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=404, detail=detail)
|
||||||
journal = _security_event_store()
|
journal = _security_event_store()
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
|
|
|
||||||
|
|
@ -2245,6 +2245,60 @@ async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmati
|
||||||
assert calls == [("stackchain/api", 7)]
|
assert calls == [("stackchain/api", 7)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_issue_close_endpoint_withdraws_only_open_authored_filed_issue(monkeypatch):
|
||||||
|
checks = []
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def authored(repository, number):
|
||||||
|
checks.append((repository, number))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(repository, number):
|
||||||
|
calls.append((repository, number))
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"state": "closed",
|
||||||
|
"updated_at": "2026-08-15T10:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||||
|
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/close?access=filed"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["state"] == "closed"
|
||||||
|
assert checks == [("stackchain/api", 7)]
|
||||||
|
assert calls == [("stackchain/api", 7)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_issue_close_endpoint_does_not_withdraw_non_authored_or_closed_filed_issue(monkeypatch):
|
||||||
|
close_calls = []
|
||||||
|
|
||||||
|
async def authored(_repository, _number):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def close(repository, number):
|
||||||
|
close_calls.append((repository, number))
|
||||||
|
return {"number": number, "state": "closed"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||||
|
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/close?access=filed"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert close_calls == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
|
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
|
||||||
|
|
@ -1557,7 +1557,7 @@ async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilitie
|
||||||
assert "#issue-sheet.read-only #issue-planning" in html
|
assert "#issue-sheet.read-only #issue-planning" in html
|
||||||
assert "#issue-sheet.read-only #issue-handoff" in html
|
assert "#issue-sheet.read-only #issue-handoff" in html
|
||||||
assert "#issue-sheet.read-only #release-issue" in html
|
assert "#issue-sheet.read-only #release-issue" in html
|
||||||
assert "#issue-sheet.read-only #close-issue" in html
|
assert "qs('#close-issue').hidden = readOnly && !withdrawable" in html
|
||||||
assert "#issue-sheet.read-only .detail-defer" in html
|
assert "#issue-sheet.read-only .detail-defer" in html
|
||||||
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
|
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
|
||||||
assert "if (readOnly) qs('#load-older-issue-comments').hidden = true;" not in html
|
assert "if (readOnly) qs('#load-older-issue-comments').hidden = true;" not in html
|
||||||
|
|
@ -1834,6 +1834,57 @@ Promise.all([
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_sheet_withdraws_filed_item_through_author_access_as_single_flight():
|
||||||
|
script = f"""
|
||||||
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
const calls = [];
|
||||||
|
let finish;
|
||||||
|
const controller = createIssueSheet({{
|
||||||
|
fetchJson: (url, options={{}}) => {{
|
||||||
|
calls.push({{url, method:options.method}});
|
||||||
|
return new Promise(resolve => {{ finish = resolve; }});
|
||||||
|
}},
|
||||||
|
storage:null,
|
||||||
|
}});
|
||||||
|
const filed = {{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}};
|
||||||
|
const first = controller.close(filed);
|
||||||
|
const duplicate = controller.close(filed);
|
||||||
|
finish({{number:18,state:'closed',updated_at:'2026-08-15T10:00:00Z'}});
|
||||||
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||||
|
calls, same:first === duplicate, state:results[0].state
|
||||||
|
}})));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"calls": [{
|
||||||
|
"url": "api/v1/repos/stackchain/api/issues/18/close?access=filed",
|
||||||
|
"method": "PATCH",
|
||||||
|
}],
|
||||||
|
"same": True,
|
||||||
|
"state": "closed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_filed_detail_exposes_only_a_withdrawal_action_for_open_delegated_issue():
|
||||||
|
source = await dashboard()
|
||||||
|
css = (ISSUE_SHEET.parent / "dashboard.css").read_text()
|
||||||
|
|
||||||
|
assert "const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';" in source
|
||||||
|
assert "qs('#close-issue').hidden = readOnly && !withdrawable" in source
|
||||||
|
assert "withdrawable ? 'Withdraw issue'" in source
|
||||||
|
close_handler = source.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||||
|
"qs('#close-pull-sheet').addEventListener", 1
|
||||||
|
)[0]
|
||||||
|
assert "Withdraw " in close_handler
|
||||||
|
assert "closes the delegated request" in close_handler
|
||||||
|
assert "is_completed:true" in close_handler
|
||||||
|
assert "#issue-sheet.read-only #issue-planning" in css
|
||||||
|
assert ".issue-sheet-actions button" in css and "min-height:44px" in css
|
||||||
|
|
||||||
|
|
||||||
def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up():
|
def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
|
@ -3965,7 +4016,7 @@ async def test_closing_issue_advances_active_session_once_and_exposes_close_and_
|
||||||
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
|
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
|
||||||
"await runTodayTransition('complete')"
|
"await runTodayTransition('complete')"
|
||||||
)
|
)
|
||||||
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
|
assert close_handler.index("lastMyWork.filter") < close_handler.index(
|
||||||
"await runTodayTransition('complete')"
|
"await runTodayTransition('complete')"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -6905,9 +6956,10 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
|
||||||
assert '<script src="static/issue-sheet.js"></script>' in html
|
assert '<script src="static/issue-sheet.js"></script>' in html
|
||||||
assert "issueController.load(item)" in html
|
assert "issueController.load(item)" in html
|
||||||
assert "issueController.comment(selectedIssue" in html
|
assert "issueController.comment(selectedIssue" in html
|
||||||
assert "window.confirm('Close ' + selectedIssue.key + '?')" in html
|
assert "'Close ' + selectedIssue.key + '?'" in html
|
||||||
|
assert "window.confirm(confirmation)" in html
|
||||||
assert "issueController.close(selectedIssue)" in html
|
assert "issueController.close(selectedIssue)" in html
|
||||||
assert "lastMyWork = lastMyWork.filter" in html
|
assert "lastMyWork.filter" in html
|
||||||
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
|
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
|
||||||
assert "e.key === 'Escape' && selectedIssue" in html
|
assert "e.key === 'Escape' && selectedIssue" in html
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,33 @@ async def test_completed_filed_issue_remains_authorized_for_detail_and_reply():
|
||||||
assert authorized is True
|
assert authorized is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("state", "author", "authorized"),
|
||||||
|
[("open", "timmy", True), ("closed", "timmy", False), ("open", "alex", False)],
|
||||||
|
)
|
||||||
|
async def test_only_open_authored_issues_are_authorized_for_withdrawal(
|
||||||
|
state, author, authorized
|
||||||
|
):
|
||||||
|
async def upstream(request):
|
||||||
|
if request.url.path.endswith("/user"):
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"state": state,
|
||||||
|
"user": {"login": author},
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.is_open_authored_issue(
|
||||||
|
"stackchain/dashboard", 89
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert result is authorized
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
|
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
|
||||||
async def upstream(request):
|
async def upstream(request):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user