feat: paginate issue capture repositories (#403)
This commit is contained in:
1 parent
33a6d1bb6d
commit
9ea590a65c
16 files changed
+393
-46
No files matched your search
@@ -195,7 +195,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
@@ -8,6 +8,13 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
async def repository_access_from(loader, repository):
|
||||
repositories = await loader()
|
||||
return next(
|
||||
(item for item in repositories if item.get("full_name") == repository), None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
|
||||
requests = []
|
||||
@@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
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)
|
||||
@@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -373,6 +388,118 @@ 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_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append((request.url.path, dict(request.url.params)))
|
||||
if request.url.path.endswith("/user/repos"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
headers={"X-Total-Count": "51"},
|
||||
)
|
||||
if request.url.path.endswith("/repos/stackchain/later"):
|
||||
return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
page = await gitea_proxy.repo_page(page=2, limit=50)
|
||||
first_page = await gitea_proxy.repos()
|
||||
accessible = await gitea_proxy.repository_access("stackchain/later")
|
||||
missing = await gitea_proxy.repository_access("stackchain/missing")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert page == {
|
||||
"items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
"page": 2,
|
||||
"total": 51,
|
||||
"has_more": False,
|
||||
}
|
||||
assert accessible["full_name"] == "stackchain/later"
|
||||
assert missing is None
|
||||
assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
|
||||
assert calls == [
|
||||
("/api/v1/user/repos", {"page": "2", "limit": "50"}),
|
||||
("/api/v1/user/repos", {"page": "1", "limit": "50"}),
|
||||
("/api/v1/repos/stackchain/later", {}),
|
||||
("/api/v1/repos/stackchain/missing", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||
access_calls = []
|
||||
create_calls = []
|
||||
|
||||
async def page(page, limit):
|
||||
assert (page, limit) == (2, 50)
|
||||
return {
|
||||
"items": [{
|
||||
"id": 51, "name": "later", "full_name": "stackchain/later",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/later",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
"page": 2, "total": 51, "has_more": False,
|
||||
}
|
||||
|
||||
async def access(repository):
|
||||
access_calls.append(repository)
|
||||
return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
create_calls.append((repository, title, assignee))
|
||||
return {
|
||||
"id": 403, "number": 403, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/later/issues/403",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
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:
|
||||
listed = await client.get("/api/v1/repositories?page=2&limit=50")
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["items"][0]["full_name"] == "stackchain/later"
|
||||
assert listed.json()["has_more"] is False
|
||||
assert created.status_code == 201
|
||||
assert access_calls == ["stackchain/later"]
|
||||
assert create_calls == [("stackchain/later", "Later-page work", "timmy")]
|
||||
|
||||
|
||||
def test_context_exposes_truthful_repository_pagination():
|
||||
repositories = gitea_proxy.RepositoryItems(
|
||||
[{
|
||||
"id": 1, "name": "api", "full_name": "stackchain/api",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/api",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
{"page": 1, "total": 51, "has_more": True},
|
||||
)
|
||||
|
||||
payload = main._context_payload(
|
||||
{"id": 2, "login": "timmy"}, repositories, [], []
|
||||
)
|
||||
|
||||
assert payload["repository_pagination"] == {
|
||||
"page": 1, "total": 51, "has_more": True
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
|
||||
calls = []
|
||||
@@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-concurrent"}
|
||||
@@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-create-201"}
|
||||
@@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
|
||||
@@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
|
||||
@@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
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:
|
||||
@@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(
|
||||
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
@@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v63" in worker
|
||||
assert "stackchain-dashboard-shell-v64" in worker
|
||||
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v63" in worker
|
||||
assert "stackchain-dashboard-shell-v64" in worker
|
||||
@@ -1938,6 +1938,19 @@ 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_issue_capture_can_load_more_repositories_without_leaving_form():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="load-more-issue-repositories"' in html
|
||||
assert '>Load more repositories<' in html
|
||||
assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
|
||||
assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
|
||||
assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
|
||||
assert 'appendIssueRepositories(payload.items)' in html
|
||||
assert '.create-issue-repository-more { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
@@ -2040,6 +2053,52 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["results"][1]["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
|
||||
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 = 0;
|
||||
let release;
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
fetchJson: url => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('temporary'));
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
|
||||
}}); }});
|
||||
}},
|
||||
}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
||||
capture.loadRepositoryPage(2).catch(() => {{
|
||||
const first = capture.loadRepositoryPage(2);
|
||||
const duplicate = capture.loadRepositoryPage(2);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, results, draft:capture.loadDraft()
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 2
|
||||
assert output["results"] == [
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
]
|
||||
assert output["draft"] == {
|
||||
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
||||
@@ -113,5 +113,5 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
@@ -121,7 +121,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -130,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -138,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -154,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v63" in source
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user