feat: capture issues from mobile My Work (#159)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-07 02:58:14 +00:00
1 parent 8c889e3ffc
commit bb394be8b2
7 files changed
+451 -4

No files matched your search

+112
View File
@@ -6,6 +6,118 @@ import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirmed_issue(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee):
calls.append((repository, title, body, assignee))
return {
"id": 81,
"number": 17,
"title": title,
"state": "open",
"repository": repository,
"labels": [],
"assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
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 "},
)
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")]
@pytest.mark.anyio
async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmation():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17",
"assignees": [{"login": "timmy"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.create_issue(
"stackchain/api", "Capture mobile work", "Context", "timmy"
)
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"
assert requests[0].content == (
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy"}'
)
assert result["repository"] == "stackchain/api"
assert result["number"] == 17
assert result["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_create_issue_rejects_blank_title_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": " ", "body": "Context"}
)
assert response.status_code == 422
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_self_assignment():
async def handler(_request):
return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"html_url": "https://forge.example/stackchain/api/issues/17", "assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="self-assignment"):
await gitea_proxy.create_issue(
"stackchain/api", "Capture mobile work", "", "timmy"
)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
+70
View File
@@ -10,6 +10,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"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -117,6 +118,56 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_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 = [];
let release;
const capture = createIssueCapture({{
storage,
fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ release = () => resolve({{
id:81, number:17, title:'Capture work', state:'open', repository:'stackchain/api',
labels:[], assignees:['timmy'], url:'https://forge.example/issues/17'
}}); }});
}},
}});
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context'}};
capture.saveDraft(draft);
const first = capture.submit(draft);
const duplicate = capture.submit(draft);
const during = capture.loadDraft();
release();
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)}})), during, after:capture.loadDraft(), 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",
"method": "POST",
"body": {"title": "Capture work", "body": "Context"},
}]
assert output["during"] == {
"repository": "stackchain/api", "title": "Capture work", "body": "Context"
}
assert output["after"] == {"repository": "", "title": "", "body": ""}
assert output["results"][0]["number"] == 17
assert output["results"][1]["number"] == 17
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
@@ -669,6 +720,25 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
assert "e.key === 'Escape' && selectedIssue" in html
@pytest.mark.anyio
async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet():
html = await dashboard()
assert 'id="new-issue"' in html and 'New issue' in html
assert 'id="create-issue-sheet"' in html and 'aria-modal="true"' in html
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="submit-new-issue"' in html
assert '.create-issue-sheet.open { display:flex; }' in html
assert 'height:100dvh;' in html
assert 'env(safe-area-inset-bottom)' in html
assert '<script src="static/create-issue-sheet.js"></script>' in html
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
assert 'openIssueSheet(created' in html
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()