feat: manage issue blockers in app (Closes #531)
This commit is contained in:
1 parent
29ceedfc01
commit
2c8a1225e0
8 files changed
+484
-1
No files matched your search
@@ -1939,3 +1939,132 @@ async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
|
||||
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
|
||||
calls.append((repository, number, blocker_repository, blocker_number, remove))
|
||||
return {
|
||||
"repository": repository, "number": number, "dependencies_available": True,
|
||||
"dependencies": [] if remove else [{
|
||||
"repository": blocker_repository, "number": blocker_number,
|
||||
"title": "Restore API", "state": "open",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
added = await client.post(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
removed = await client.request(
|
||||
"DELETE", "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert [added.status_code, removed.status_code] == [200, 200]
|
||||
assert added.headers["cache-control"] == "no-store"
|
||||
assert added.json()["dependencies"][0]["number"] == 9
|
||||
assert removed.json()["dependencies"] == []
|
||||
assert calls == [
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, False),
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
|
||||
async def mutate(*_args, **_kwargs):
|
||||
raise gitea_proxy.IssueDependencyInvalidError("blocker must be an open issue")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
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/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {"error": "blocker must be an open issue"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
path = request.url.path
|
||||
if path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if path == "/api/v1/repos/stackchain/api/issues/9":
|
||||
return httpx.Response(200, json={"number": 9, "state": "open"})
|
||||
if request.method == "GET" and path.endswith("/dependencies"):
|
||||
mutated = any(r.method == "POST" for r in requests)
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}] if mutated else [])
|
||||
if request.method == "POST" and path.endswith("/dependencies"):
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError((request.method, path))
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, "stackchain/api", 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
mutation = next(request for request in requests if request.method == "POST")
|
||||
assert mutation.url.path == "/api/v1/repos/stackchain/dashboard/issues/17/dependencies"
|
||||
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
|
||||
assert result["dependencies"] == [{
|
||||
"repository": "stackchain/api", "number": 9, "title": "Restore API",
|
||||
"state": "open", "url": "https://forge.example/stackchain/api/issues/9",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
|
||||
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
|
||||
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open"}, "stackchain/api", 9, [
|
||||
{"number": 9, "state": "open", "repository": {"full_name": "stackchain/api"}},
|
||||
]),
|
||||
])
|
||||
async def test_gitea_dependency_rejects_invalid_candidates(
|
||||
candidate, blocker_repository, blocker_number, existing
|
||||
):
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if request.url.path.endswith("/dependencies"):
|
||||
return httpx.Response(200, json=existing)
|
||||
return httpx.Response(200, json=candidate)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueDependencyInvalidError):
|
||||
await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, blocker_repository, blocker_number
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
@@ -811,6 +811,77 @@ controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
||||
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
|
||||
def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }},
|
||||
}});
|
||||
const item = {{repository:'stackchain/dashboard', number:17}};
|
||||
const blocker = {{repository:'stackchain/api', number:9}};
|
||||
const first = controller.updateBlocker(item, blocker, false);
|
||||
const duplicate = controller.updateBlocker(item, blocker, false);
|
||||
finish({{repository:item.repository, number:item.number, dependencies_available:true,
|
||||
dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
||||
same:first === duplicate, result:results[0]
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "POST",
|
||||
"body": {"repository": "stackchain/api", "number": 9},
|
||||
}]
|
||||
assert output["result"]["dependencies"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_issue_blocker_removal_rejects_unconfirmed_canonical_state():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:async () => ({{dependencies_available:true,
|
||||
dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}),
|
||||
}});
|
||||
controller.updateBlocker(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9}}, true
|
||||
).then(() => process.stdout.write('unexpected'))
|
||||
.catch(error => process.stdout.write(error.message));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert result.stdout == "Blocker change was not confirmed."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="manage-issue-blockers"' in html
|
||||
assert 'id="issue-blocker-search" type="search"' in html
|
||||
assert 'id="issue-blocker-results" role="listbox"' in html
|
||||
assert 'id="cancel-issue-blocker"' in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html
|
||||
assert "api('api/v1/search?q='" in html
|
||||
assert "result.kind === 'issue' && result.state === 'open'" in html
|
||||
assert "renderPlanIssueDependencies(selectedIssueDetail)" in html
|
||||
assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html
|
||||
assert "width:100%; max-width:100%; box-sizing:border-box" in html
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
||||
Reference in new issue
Block a user