feat: complete assigned issues from mobile My Work (#157)
This commit is contained in:
1 parent
30a014b619
commit
1c0cacb2b8
7 files changed
+809
-6
No files matched your search
@@ -0,0 +1,270 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def detail(repository, number):
|
||||
return {"repository": repository, "number": number, "title": "Fix mobile flow"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
|
||||
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/issues/7/detail")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"repository": "stackchain/api", "number": 7, "title": "Fix mobile flow"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def assigned(repository, number):
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
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/issues/7/detail")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {"error": "Loading the issue timed out. Please retry."}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "json_body"),
|
||||
[
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7/detail", None),
|
||||
("POST", "/api/v1/repos/stackchain/api/issues/7/comments", {"body": "Hello"}),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/7/close", None),
|
||||
],
|
||||
)
|
||||
async def test_issue_actions_reject_items_not_assigned_to_service_user(
|
||||
monkeypatch, method, path, json_body
|
||||
):
|
||||
async def assigned(repository, number):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.request(method, path, json=json_body)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("body", [" ", "x" * 10_001])
|
||||
async def test_issue_comment_rejects_blank_or_oversized_body_before_upstream(
|
||||
monkeypatch, body
|
||||
):
|
||||
called = False
|
||||
|
||||
async def assigned(repository, number):
|
||||
nonlocal called
|
||||
called = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
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/7/comments", json={"body": body}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_comment_endpoint_posts_only_to_assigned_issue(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def comment(repository, number, body):
|
||||
calls.append((repository, number, body))
|
||||
return {"id": 82, "author": "timmy", "body": body}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
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/7/comments",
|
||||
json={"body": " Ready to ship "},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"id": 82, "author": "timmy", "body": "Ready to ship"}
|
||||
assert calls == [("stackchain/api", 7, "Ready to ship")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def close(repository, number):
|
||||
calls.append((repository, number))
|
||||
return {"number": number, "state": "closed", "closed_at": "now"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
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/7/close")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["state"] == "closed"
|
||||
assert calls == [("stackchain/api", 7)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.close_issue("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "PATCH"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7"
|
||||
assert requests[0].content == b'{"state":"closed"}'
|
||||
assert result == {"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"id": 82,
|
||||
"body": "Ready to ship",
|
||||
"created_at": "2026-08-07T10:10:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
|
||||
"user": {"login": "timmy"},
|
||||
},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.comment_on_issue("stackchain/api", 7, "Ready to ship")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "POST"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7/comments"
|
||||
assert requests[0].content == b'{"body":"Ready to ship"}'
|
||||
assert result == {
|
||||
"id": 82,
|
||||
"author": "timmy",
|
||||
"body": "Ready to ship",
|
||||
"created_at": "2026-08-07T10:10:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.url.query.decode()))
|
||||
if request.url.path.endswith("/comments"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 81,
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
"user": {"login": "sam"},
|
||||
}
|
||||
],
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 7,
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_detail("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
|
||||
]
|
||||
assert result == {
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
"comments": [
|
||||
{
|
||||
"id": 81,
|
||||
"author": "sam",
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from src.views import dashboard
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||
@@ -543,6 +544,131 @@ reader.open(item, [item]).then(() => {{
|
||||
]
|
||||
|
||||
|
||||
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let request;
|
||||
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
|
||||
request = {{url, accept:options.headers.Accept}};
|
||||
return {{title:'Fix mobile flow'}};
|
||||
}} }});
|
||||
controller.load({{repository:'stackchain/api', number:7}}).then(detail =>
|
||||
process.stdout.write(JSON.stringify({{request, title:detail.title}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"request": {
|
||||
"url": "api/v1/repos/stackchain/api/issues/7/detail",
|
||||
"accept": "application/json",
|
||||
},
|
||||
"title": "Fix mobile flow",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let calls = 0;
|
||||
let release;
|
||||
const controller = createIssueSheet({{
|
||||
storage,
|
||||
fetchJson: (url, options) => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{id:82, body:'Ready'}}); }});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
controller.saveDraft(item, 'Ready');
|
||||
const first = controller.comment(item, 'Ready');
|
||||
const duplicate = controller.comment(item, 'Ready');
|
||||
const during = controller.loadDraft(item);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, during, after:controller.loadDraft(item), results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["during"] == "Ready"
|
||||
assert output["after"] == ""
|
||||
assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}]
|
||||
|
||||
|
||||
def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = 0;
|
||||
let release;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: (url, options) => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{number:7, state:'closed'}}); }});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
const first = controller.close(item);
|
||||
const duplicate = controller.close(item);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results =>
|
||||
process.stdout.write(JSON.stringify({{calls, results}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 1,
|
||||
"results": [
|
||||
{"number": 7, "state": "closed"},
|
||||
{"number": 7, "state": "closed"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="issue-sheet"' in html and 'aria-modal="true"' in html
|
||||
assert 'class="my-work-card-main issue-trigger"' in html
|
||||
assert 'id="close-issue-sheet"' in html
|
||||
assert 'id="retry-issue-load"' in html
|
||||
assert 'id="issue-sheet-body"' in html
|
||||
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
|
||||
assert 'id="issue-comments"' in html
|
||||
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
|
||||
assert 'id="send-issue-comment"' in html
|
||||
assert 'id="close-issue"' in html
|
||||
assert 'id="open-issue-gitea"' in html and 'rel="noopener noreferrer"' in html
|
||||
assert '.issue-sheet-panel { width:min(560px,100%);' in html
|
||||
assert '.issue-sheet-content { overflow-wrap:anywhere;' in html
|
||||
assert '.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '<script src="static/issue-sheet.js"></script>' in html
|
||||
assert "issueController.load(item)" in html
|
||||
assert "issueController.comment(selectedIssue" in html
|
||||
assert "window.confirm('Close ' + selectedIssue.key + '?')" in html
|
||||
assert "issueController.close(selectedIssue)" in html
|
||||
assert "lastMyWork = lastMyWork.filter" in html
|
||||
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
|
||||
assert "e.key === 'Escape' && selectedIssue" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
|
||||
Reference in new issue
Block a user