feat: manage issue blockers in app (Closes #531)
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 02:02:23 +00:00
1 parent 29ceedfc01
commit 2c8a1225e0
8 files changed
+484 -1

No files matched your search

+129
View File
@@ -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()
+71
View File
@@ -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))});