feat: triage mobile issue capture with labels (#163)
This commit is contained in:
1 parent
e5f001f2b0
commit
b6390d3298
6 files changed
+297
-19
No files matched your search
+106
-7
@@ -16,15 +16,22 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def create(repository, title, body, assignee):
|
||||
calls.append((repository, title, body, assignee))
|
||||
async def labels(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [
|
||||
{"id": 3, "name": "P0", "color": "d73a4a"},
|
||||
{"id": 8, "name": "frontend", "color": "1d76db"},
|
||||
]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
calls.append((repository, title, body, assignee, label_ids))
|
||||
return {
|
||||
"id": 81,
|
||||
"number": 17,
|
||||
"title": title,
|
||||
"state": "open",
|
||||
"repository": repository,
|
||||
"labels": [],
|
||||
"labels": ["P0"],
|
||||
"assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
@@ -32,19 +39,25 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, 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/api/issues",
|
||||
json={"title": " Capture mobile work ", "body": " Context "},
|
||||
json={
|
||||
"title": " Capture mobile work ",
|
||||
"body": " Context ",
|
||||
"label_ids": [3],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["number"] == 17
|
||||
assert response.json()["assignees"] == ["timmy"]
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy")]
|
||||
assert response.json()["labels"] == ["P0"]
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -58,12 +71,13 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"labels": [{"id": 3, "name": "P0"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_issue(
|
||||
"stackchain/api", "Capture mobile work", "Context", "timmy"
|
||||
"stackchain/api", "Capture mobile work", "Context", "timmy", [3]
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
@@ -72,11 +86,63 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||
assert requests[0].method == "POST"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues"
|
||||
assert requests[0].content == (
|
||||
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy"}'
|
||||
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy","labels":[3]}'
|
||||
)
|
||||
assert result["repository"] == "stackchain/api"
|
||||
assert result["number"] == 17
|
||||
assert result["assignees"] == ["timmy"]
|
||||
assert result["labels"] == ["P0"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"},
|
||||
{"id": "bad", "name": "invalid", "color": "000000"},
|
||||
])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.repo_labels("stackchain/api")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/labels"
|
||||
assert requests[0].url.params["limit"] == "50"
|
||||
assert result == [{
|
||||
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def labels(repository):
|
||||
calls.append(repository)
|
||||
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
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/labels")
|
||||
missing = await client.get("/api/v1/repos/other/private/labels")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == [{
|
||||
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
|
||||
}]
|
||||
assert missing.status_code == 404
|
||||
assert calls == ["stackchain/api"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -100,6 +166,39 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||
created = False
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def labels(_repository):
|
||||
return [{"id": 3, "name": "P0"}]
|
||||
|
||||
async def create(*_args):
|
||||
nonlocal created
|
||||
created = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
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/api/issues",
|
||||
json={"title": "Urgent work", "label_ids": [999]},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == "Unknown repository label"
|
||||
assert created is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_confirmed_self_assignment():
|
||||
async def handler(_request):
|
||||
|
||||
+38
-4
@@ -140,7 +140,7 @@ const capture = createIssueCapture({{
|
||||
}}); }});
|
||||
}},
|
||||
}});
|
||||
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context'}};
|
||||
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
|
||||
capture.saveDraft(draft);
|
||||
const first = capture.submit(draft);
|
||||
const duplicate = capture.submit(draft);
|
||||
@@ -159,16 +159,45 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/api/issues",
|
||||
"method": "POST",
|
||||
"body": {"title": "Capture work", "body": "Context"},
|
||||
"body": {"title": "Capture work", "body": "Context", "label_ids": [3]},
|
||||
}]
|
||||
assert output["during"] == {
|
||||
"repository": "stackchain/api", "title": "Capture work", "body": "Context"
|
||||
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
|
||||
"labelIds": [3],
|
||||
}
|
||||
assert output["after"] == {
|
||||
"repository": "", "title": "", "body": "", "labelIds": []
|
||||
}
|
||||
assert output["after"] == {"repository": "", "title": "", "body": ""}
|
||||
assert output["results"][0]["number"] == 17
|
||||
assert output["results"][1]["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_loads_repository_labels_with_priorities_first():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const calls = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||
fetchJson: url => {{
|
||||
calls.push(url);
|
||||
return Promise.resolve([
|
||||
{{id:8,name:'frontend',color:'1d76db'}},
|
||||
{{id:3,name:'P0',color:'d73a4a'}},
|
||||
{{id:5,name:'critical',color:'b60205'}}
|
||||
]);
|
||||
}},
|
||||
}});
|
||||
capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.stringify({{calls,labels}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == ["api/v1/repos/stackchain/api/labels"]
|
||||
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
|
||||
|
||||
|
||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
@@ -804,6 +833,8 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||
assert 'id="create-issue-repository"' in html
|
||||
assert 'id="create-issue-title"' in html and 'maxlength="255"' in html
|
||||
assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html
|
||||
assert 'id="create-issue-labels"' in html and 'aria-describedby="create-issue-label-status"' in html
|
||||
assert 'id="create-issue-label-status"' in html and 'aria-live="polite"' in html
|
||||
assert 'id="submit-new-issue"' in html
|
||||
assert '.create-issue-sheet.open { display:flex; }' in html
|
||||
assert 'height:100dvh;' in html
|
||||
@@ -812,6 +843,9 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
||||
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
||||
assert 'openIssueSheet(created' in html
|
||||
assert 'issueCapture.loadLabels(repository)' in html
|
||||
assert "input[name=\"create-issue-label\"]:checked" in html
|
||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in new issue
Block a user