feat: hand off assigned issues from mobile (#281)
This commit is contained in:
1 parent
1fdf6f417d
commit
04bb6839ae
8 files changed
+471
-3
No files matched your search
@@ -955,6 +955,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_
|
||||
assert result["available"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "timmy", "full_name": "Timmy"},
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "casey", "full_name": ""},
|
||||
{"login": ""},
|
||||
{"full_name": "Missing login"},
|
||||
"malformed",
|
||||
])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == [
|
||||
{"login": "alex", "name": "Alexander"},
|
||||
{"login": "casey", "name": "casey"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/assignees"):
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "casey", "full_name": "Casey"},
|
||||
])
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}, {"login": "casey"}],
|
||||
})
|
||||
assert request.content == b'{"assignees":["casey","alex"]}'
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open",
|
||||
"assignees": [{"login": "casey"}, {"login": "alex"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"assignees": ["casey", "alex"], "recipient": "alex",
|
||||
}
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/assignees"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
|
||||
calls = []
|
||||
@@ -981,6 +1051,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con
|
||||
assert main._available_issue_snapshot_created_at is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def candidates(repository):
|
||||
calls.append(("candidates", repository))
|
||||
return [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
async def handoff(repository, number, recipient):
|
||||
calls.append(("handoff", repository, number, recipient))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"assignees": [recipient], "recipient": recipient,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
|
||||
)
|
||||
transferred = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff",
|
||||
json={"recipient": "alex"},
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
|
||||
assert transferred.status_code == 200
|
||||
assert transferred.json()["recipient"] == "alex"
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert transferred.headers["cache-control"] == "no-store"
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 17),
|
||||
("candidates", "stackchain/api"),
|
||||
("handoff", "stackchain/api", 17, "alex"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
|
||||
async def handoff(_repository, _number, _recipient):
|
||||
raise gitea_proxy.IssueNotAvailableError("stale")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
|
||||
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/handoff",
|
||||
json={"recipient": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "The issue or recipient changed. Reload before handing off."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return False
|
||||
|
||||
async def candidates(_repository):
|
||||
nonlocal called
|
||||
called = True
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
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/issues/17/handoff-candidates"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
|
||||
@@ -470,6 +470,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
|
||||
assert result.stdout == "Issue release was not confirmed."
|
||||
|
||||
|
||||
def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: (url, options) => {{
|
||||
calls.push({{url, options}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
storage: null,
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const first = controller.handoff(item, 'alex', 'timmy');
|
||||
const duplicate = controller.handoff(item, 'alex', 'timmy');
|
||||
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, same: first === duplicate, results
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert len(output["calls"]) == 1
|
||||
assert output["calls"][0]["url"].endswith("/issues/17/handoff")
|
||||
assert output["calls"][0]["options"]["method"] == "PATCH"
|
||||
assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
|
||||
assert output["results"][0]["assignees"] == ["alex"]
|
||||
|
||||
|
||||
def test_issue_handoff_candidates_use_the_assigned_issue_route():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let call;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
|
||||
storage: null,
|
||||
}});
|
||||
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
||||
.then(result => process.stdout.write(JSON.stringify({{call, result}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
|
||||
assert output["call"]["options"]["headers"]["Accept"] == "application/json"
|
||||
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -576,6 +628,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_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="issue-handoff-recipient"' in html
|
||||
assert 'id="load-issue-handoff"' in html
|
||||
assert 'id="confirm-issue-handoff"' in html
|
||||
assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
|
||||
assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
|
||||
assert "issueController.loadHandoffCandidates(selectedIssue)" in html
|
||||
assert "issueController.handoff(selectedIssue, recipient" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_today_queue_ships_in_a_new_shell_cache():
|
||||
def test_issue_handoff_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v20" in source
|
||||
assert "stackchain-dashboard-shell-v21" in source
|
||||
assert "BASE + 'static/today-work.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user