feat: plan issues during mobile capture (#221)
This commit is contained in:
1 parent
ce26f54618
commit
19dfc93b85
7 files changed
+280
-7
No files matched your search
@@ -373,6 +373,55 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def milestones(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [{"id": 9, "title": "August RC"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids, milestone_id, due_date):
|
||||
calls.append((repository, title, body, assignee, label_ids, milestone_id, due_date))
|
||||
return {
|
||||
"id": 221, "number": 221, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"due_date": "2026-08-31T23:59:59Z",
|
||||
"updated_at": "2026-08-07T20:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/221",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
|
||||
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": "Ship mobile plan",
|
||||
"milestone_id": 9,
|
||||
"due_date": "2026-08-31T23:59:59Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
|
||||
assert calls == [(
|
||||
"stackchain/api", "Ship mobile plan", "", "timmy", [], 9,
|
||||
"2026-08-31T23:59:59Z",
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
|
||||
calls = 0
|
||||
@@ -630,6 +679,26 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||
assert result["labels"] == ["P0"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_confirmed_release_plan():
|
||||
async def handler(_request):
|
||||
return httpx.Response(201, json={
|
||||
"id": 221, "number": 221, "title": "Planned work", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "labels": [],
|
||||
"milestone": None, "due_date": "2026-09-01T23:59:59Z",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="release plan"):
|
||||
await gitea_proxy.create_issue(
|
||||
"stackchain/api", "Planned work", "", "timmy", [], 9,
|
||||
"2026-08-31T23:59:59Z",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
|
||||
requests = []
|
||||
@@ -702,6 +771,27 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_nonexistent_due_date_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def user():
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
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": "Impossible plan", "due_date": "2026-02-31T23:59:59Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||
created = False
|
||||
|
||||
@@ -521,6 +521,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_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-issue-milestone"' in html
|
||||
assert 'id="create-issue-due-date" type="date"' in html
|
||||
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
|
||||
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
|
||||
assert 'issueCapture.loadMilestones(repository)' in html
|
||||
assert "milestoneId: Number(qs('#create-issue-milestone').value) || null" in html
|
||||
assert "dueDate: qs('#create-issue-due-date').value" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
||||
html = await dashboard()
|
||||
@@ -1245,6 +1258,50 @@ capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.st
|
||||
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
|
||||
|
||||
|
||||
def test_issue_capture_persists_and_submits_milestone_and_due_date():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const calls = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
createOperationId: () => 'planned-operation',
|
||||
fetchJson: (url, options) => {{
|
||||
calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]);
|
||||
return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}});
|
||||
}},
|
||||
}});
|
||||
const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}};
|
||||
capture.saveDraft(draft);
|
||||
Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results =>
|
||||
process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/repos/stackchain/api/milestones", "body": None},
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/api/issues",
|
||||
"body": {
|
||||
"title": "Ship plan", "body": "", "label_ids": [],
|
||||
"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z",
|
||||
},
|
||||
},
|
||||
]
|
||||
assert output["milestones"] == [{"id": 9, "title": "August RC"}]
|
||||
assert output["stored"]["number"] == 221
|
||||
|
||||
|
||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
|
||||
Reference in new issue
Block a user