feat: discover and claim mobile work (#175)
This commit is contained in:
1 parent
f9e11fca6a
commit
1f997cc504
8 files changed
+585
-6
No files matched your search
@@ -42,6 +42,67 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "77"},
|
||||
json=[
|
||||
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
|
||||
"updated_at": "2026-08-07T12:00:00Z", "assignees": [],
|
||||
"labels": [], "repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
|
||||
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 3, "number": 3, "title": "A pull", "state": "open",
|
||||
"assignees": [], "pull_request": {"merged": False},
|
||||
"repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 4, "number": 4, "title": "Critical", "state": "open",
|
||||
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
|
||||
"labels": [{"name": "critical"}],
|
||||
"repository": {"full_name": "stackchain/web"}},
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_page(page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=2"
|
||||
]
|
||||
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
|
||||
assert result == {
|
||||
"items": result["items"], "page": 2, "total": 77, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def available(page):
|
||||
calls.append(page)
|
||||
return {"items": [{"number": 7}], "page": page, "total": 51, "has_more": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_page", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [{"number": 7}], "page": 1, "total": 51, "has_more": True,
|
||||
}
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
|
||||
@@ -217,6 +217,82 @@ async def test_gitea_create_issue_requires_confirmed_self_assignment():
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_assignee():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Available", "state": "open",
|
||||
"assignees": [], "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
if request.method == "GET" and request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Available", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.claim_available_issue("stackchain/api", 17)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("GET", "/api/v1/user"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert requests[2].content == b'{"assignee":"timmy"}'
|
||||
assert result["repository"] == "stackchain/api"
|
||||
assert result["assignees"] == ["timmy"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def claim(repository, number):
|
||||
calls.append((repository, number))
|
||||
return {
|
||||
"id": 81, "number": number, "title": "Available", "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": ["timmy"],
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["assignees"] == ["timmy"]
|
||||
assert calls == [("stackchain/api", 17)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflict(monkeypatch):
|
||||
async def claim(_repository, _number):
|
||||
raise gitea_proxy.IssueNotAvailableError("claimed")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
|
||||
@@ -12,6 +12,7 @@ REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||
@@ -237,6 +238,85 @@ pager.loadMore('issue', [{{id:1}}]).then(result =>
|
||||
}
|
||||
|
||||
|
||||
def test_find_work_claim_is_single_flight_and_removes_only_confirmed_issue():
|
||||
script = f"""
|
||||
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
||||
let calls = 0;
|
||||
let release;
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const controller = createFindWork({{
|
||||
fetchJson: (url, options) => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
id:17,number:7,title:'Available',repository:'stackchain/api',
|
||||
state:'open',labels:[],assignees:['timmy'],url:'https://forge.example/issues/7'
|
||||
}}); }});
|
||||
}},
|
||||
onItems: items => states.push(items),
|
||||
onPagination: () => {{}},
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
controller.reset({{items:[
|
||||
{{id:17,number:7,title:'Available',repository:'stackchain/api'}},
|
||||
{{id:18,number:8,title:'Other',repository:'stackchain/web'}}
|
||||
],page:1,total:2,has_more:false}});
|
||||
const item = controller.items()[0];
|
||||
const first = controller.claim(item);
|
||||
const duplicate = controller.claim(item);
|
||||
release();
|
||||
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls,states,statuses,results,remaining:controller.items()
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert [item["number"] for item in output["remaining"]] == [8]
|
||||
assert [item["number"] for item in output["states"][-1]] == [8]
|
||||
assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
|
||||
assert output["results"][0]["assignees"] == ["timmy"]
|
||||
assert output["results"][1]["assignees"] == ["timmy"]
|
||||
|
||||
|
||||
def test_find_work_loads_paginated_results_without_duplicates():
|
||||
script = f"""
|
||||
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
||||
const calls = [];
|
||||
const states = [];
|
||||
const pages = [];
|
||||
const controller = createFindWork({{
|
||||
fetchJson: url => {{
|
||||
calls.push(url);
|
||||
const page = Number(new URL(url, 'https://example.test/').searchParams.get('page'));
|
||||
return Promise.resolve(page === 1 ? {{
|
||||
items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:2,has_more:true
|
||||
}} : {{
|
||||
items:[{{id:1,number:1,repository:'stackchain/api'}},{{id:2,number:2,repository:'stackchain/web'}}],
|
||||
page:2,total:2,has_more:false
|
||||
}});
|
||||
}},
|
||||
onItems: items => states.push(items),
|
||||
onPagination: page => pages.push(page),
|
||||
onStatus: () => {{}},
|
||||
}});
|
||||
controller.load().then(() => controller.loadMore()).then(() =>
|
||||
process.stdout.write(JSON.stringify({{calls,states,pages,items:controller.items()}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
|
||||
assert [item["id"] for item in output["items"]] == [1, 2]
|
||||
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||
html = await dashboard()
|
||||
@@ -247,6 +327,21 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||
assert '.load-more-work { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="find-work"' in html
|
||||
assert 'id="find-work-sheet" role="dialog" aria-modal="true"' in html
|
||||
assert 'id="find-work-list"' in html
|
||||
assert 'id="find-work-status" class="small" aria-live="assertive"' in html
|
||||
assert 'id="load-more-available"' in html
|
||||
assert 'static/pick-work.js' in html
|
||||
assert '.find-work-action { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
|
||||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
||||
Reference in new issue
Block a user