feat: plan mobile work by milestone (#203)
This commit is contained in:
1 parent
09b2898d1c
commit
2be29b684a
9 files changed
+514
-10
No files matched your search
@@ -8,6 +8,91 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if request.method == "GET" and request.url.path.endswith("/milestones"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 9, "title": "August RC", "state": "open"},
|
||||
{"id": 10, "title": "Old release", "state": "closed"},
|
||||
{"id": "bad", "title": "Invalid", "state": "open"},
|
||||
])
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
options = await gitea_proxy.repo_milestones("stackchain/api")
|
||||
result = await gitea_proxy.update_assigned_issue_milestone(
|
||||
"stackchain/api", 17, 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert options == [{"id": 9, "title": "August RC"}]
|
||||
patch_request = next(request for request in requests if request.method == "PATCH")
|
||||
assert patch_request.url.path == "/api/v1/repos/stackchain/api/issues/17"
|
||||
assert json.loads(patch_request.content) == {"milestone": 9}
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def milestones(repository):
|
||||
calls.append(("list", repository))
|
||||
return [{"id": 9, "title": "August RC"}]
|
||||
|
||||
async def update(repository, number, milestone_id):
|
||||
calls.append(("update", repository, number, milestone_id))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"milestone": {"id": milestone_id, "title": "August RC"} if milestone_id else None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
|
||||
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/milestones")
|
||||
updated = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/milestone", json={"milestone_id": 9}
|
||||
)
|
||||
missing = await client.get("/api/v1/repos/other/private/milestones")
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert listed.json() == [{"id": 9, "title": "August RC"}]
|
||||
assert updated.status_code == 200
|
||||
assert updated.headers["cache-control"] == "no-store"
|
||||
assert updated.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||
assert missing.status_code == 404
|
||||
assert calls == [
|
||||
("list", "stackchain/api"),
|
||||
("update", "stackchain/api", 17, 9),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("due_date", "expected_payload", "confirmed_due_date"),
|
||||
@@ -1034,6 +1119,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
@@ -1058,6 +1144,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"due_date": None,
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
|
||||
@@ -321,6 +321,20 @@ 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_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="work-milestone-filter"' in html
|
||||
assert '<option value="unplanned">Unplanned</option>' in html
|
||||
assert 'id="issue-milestone"' in html
|
||||
assert 'id="save-issue-milestone"' in html
|
||||
assert 'class="pill milestone-badge"' in html
|
||||
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
|
||||
assert 'getMilestone: () => selectedWorkMilestone' in html
|
||||
assert 'buildMyWork.replaceIssueMilestone(' in html
|
||||
|
||||
|
||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||
items = [
|
||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||
@@ -345,6 +359,88 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["summary"] == "1 review · 2 assigned"
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
payload = {
|
||||
"issues": [
|
||||
{"number": 1, "repository": "stackchain/api", "title": "RC issue",
|
||||
"milestone": {"id": 9, "title": "August RC"}},
|
||||
{"number": 2, "repository": "stackchain/api", "title": "Unplanned",
|
||||
"milestone": None},
|
||||
],
|
||||
"pull_requests": [
|
||||
{"number": 3, "repository": "stackchain/api", "title": "PR"},
|
||||
],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = {json.dumps(payload)};
|
||||
const queue = buildMyWork(original);
|
||||
const updated = buildMyWork.replaceIssueMilestone(
|
||||
original, 'stackchain/api', 2, {{id:9,title:'August RC'}}
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
rc: buildMyWork.filterMyWork(queue, 'issue', '9').map(item => item.title),
|
||||
unplanned: buildMyWork.filterMyWork(queue, 'all', 'unplanned').map(item => item.title),
|
||||
options: buildMyWork.milestoneLanes(queue),
|
||||
updated: updated.issues[1].milestone,
|
||||
original: original.issues[1].milestone,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"rc": ["RC issue"],
|
||||
"unplanned": ["Unplanned"],
|
||||
"options": [{"id": 9, "title": "August RC"}],
|
||||
"updated": {"id": 9, "title": "August RC"},
|
||||
"original": None,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_milestone_editor_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage,
|
||||
fetchJson:(url, options) => {{
|
||||
calls.push({{url, options}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const first = controller.updateMilestone(item, 9);
|
||||
const duplicate = controller.updateMilestone(item, 9);
|
||||
const during = controller.loadMilestoneDraft(item);
|
||||
finish({{repository:'stackchain/api',number:17,state:'open',milestone:{{id:9,title:'August RC'}}}});
|
||||
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, during, after:controller.loadMilestoneDraft(item), results
|
||||
}})));
|
||||
"""
|
||||
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/issues/17/milestone",
|
||||
"method": "PATCH", "body": {"milestone_id": 9},
|
||||
}]
|
||||
assert output["same"] is True
|
||||
assert output["during"] == 9
|
||||
assert output["after"] is None
|
||||
assert output["results"][0]["milestone"] == {"id": 9, "title": "August RC"}
|
||||
|
||||
|
||||
def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
|
||||
items = [
|
||||
{"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
|
||||
|
||||
@@ -21,6 +21,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"milestone": {"id": 9, "title": "August RC", "state": "open"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
@@ -40,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user