Merge pull request 'Support issue capture across all accessible repositories' (#404) from timmy/403-issue-capture-repository-pagination into main
All checks were successful
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
rockachopa 2026-08-09 13:40:58 +00:00
commit 461f90c8ff
16 changed files with 393 additions and 46 deletions

View File

@ -26,6 +26,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
let duplicateRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
@ -143,6 +144,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
.then(payload => ({
items: Array.isArray(payload?.items) ? payload.items : [],
page: Number(payload?.page) || safePage,
total: Math.max(0, Number(payload?.total) || 0),
has_more: payload?.has_more === true,
}))
.finally(() => repositoryPageRequests.delete(safePage));
repositoryPageRequests.set(safePage, request);
return request;
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
@ -214,7 +230,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};

View File

@ -289,6 +289,7 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }

View File

@ -1868,6 +1868,27 @@
};
}
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
const selected = select.value;
const known = new Set(issueCaptureRepositories);
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository || known.has(repository)) return;
known.add(repository);
issueCaptureRepositories.push(repository);
const option = document.createElement('option');
option.value = repository;
option.textContent = repository;
select.appendChild(option);
});
if (selected) select.value = selected;
}
let duplicateCheckTimer = null;
function renderIssueDuplicates(state) {
if (state.status === 'stale') return;
@ -1990,13 +2011,18 @@
return;
}
const captureDraft = issueCapture.loadDraft();
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (captureDraft.repository && !repositories.includes(captureDraft.repository)) {
repositories.unshift(captureDraft.repository);
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
issueCaptureRepositories.unshift(captureDraft.repository);
}
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository =>
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
).join('');
if (nextIssueRepositoryPage === 2) {
moreIssueRepositoriesAvailable = lastContextSnapshot?.repository_pagination?.has_more === true;
}
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
@ -2004,9 +2030,9 @@
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !issueCaptureRepositories.length;
qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
qs('#create-issue-title').focus();
@ -2799,6 +2825,26 @@
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
});
qs('#load-more-issue-repositories').addEventListener('click', async event => {
const button = event.currentTarget;
const status = qs('#create-issue-repository-status');
saveIssueCaptureDraft();
button.disabled = true;
status.textContent = 'Loading more repositories…';
try {
const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage);
appendIssueRepositories(payload.items);
nextIssueRepositoryPage = payload.page + 1;
moreIssueRepositoriesAvailable = payload.has_more;
button.hidden = !moreIssueRepositoriesAvailable;
status.textContent = payload.items.length ?
'More repositories are available in the selector.' : 'All accessible repositories are loaded.';
} catch (_error) {
status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-anyway').addEventListener('click', () => {

View File

@ -361,6 +361,8 @@
<label for="create-issue-repository">Repository
<select id="create-issue-repository" required></select>
</label>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label for="create-issue-title">Title
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
</label>

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v63';
const CACHE = 'stackchain-dashboard-shell-v64';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -133,6 +133,14 @@ class WorkItems(list[dict]):
self.pagination = pagination
class RepositoryItems(list[dict]):
"""A list-compatible repository page carrying truthful pagination metadata."""
def __init__(self, items: list[dict], pagination: dict):
super().__init__(items)
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
@ -261,8 +269,48 @@ async def current_user() -> dict:
return await fetch("user")
async def repos() -> list[dict]:
return await fetch("user/repos?limit=50")
async def repo_page(page: int = 1, limit: int = 50) -> dict:
"""Load one bounded page of repositories available to the current user."""
response = await _get_client().get(
"/api/v1/user/repos",
headers=_auth(),
params={"page": page, "limit": limit},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea repository response was not a list")
items = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
async def repos() -> RepositoryItems:
result = await repo_page()
return RepositoryItems(
result["items"],
{key: result[key] for key in ("page", "total", "has_more")},
)
async def repository_access(repository: str) -> dict | None:
"""Return a repository only when the authenticated user can access it."""
response = await _get_client().get(
f"/api/v1/repos/{repository}", headers=_auth()
)
if response.status_code == 404:
return None
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else None
WORK_SEARCHES = {

View File

@ -502,6 +502,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
]
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
repository_pagination = getattr(repo_data, "pagination", None)
if isinstance(repository_pagination, dict):
payload["repository_pagination"] = repository_pagination
pagination = {}
pagination.update(getattr(issues_data, "pagination", {}))
pagination.update(getattr(prs_data, "pagination", {}))
@ -1213,6 +1216,36 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/repositories")
async def repository_page(
page: int = Query(default=1, ge=1, le=1000),
limit: int = Query(default=50, ge=1, le=50),
) -> JSONResponse:
"""Return one bounded repository page for lazy issue-capture selection."""
try:
result = await asyncio.wait_for(
gitea_proxy.repo_page(page=page, limit=limit),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
items = [
Repo(
id=item["id"], name=item["name"], full_name=item["full_name"],
description=item.get("description") or "", url=item["html_url"],
updated_at=item.get("updated_at", ""),
).model_dump()
for item in result.get("items", [])
if isinstance(item, dict)
and all(field in item for field in ("id", "name", "full_name", "html_url"))
]
return JSONResponse({**result, "items": items})
except Exception:
return JSONResponse(
{"error": "Repositories could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/background-identity")
async def background_identity() -> JSONResponse:
"""Return only the account key required to safely drain a browser outbox."""
@ -2191,13 +2224,7 @@ async def repository_milestones(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_milestones():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_milestones(repository)
@ -2220,13 +2247,7 @@ async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_labels():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_labels(repository)
@ -2252,16 +2273,11 @@ async def create_assigned_issue(
repository = f"{owner}/{repo}"
async def create_issue():
user, available = await asyncio.gather(
gitea_proxy.current_user(), gitea_proxy.repos()
user, accessible = await asyncio.gather(
gitea_proxy.current_user(), gitea_proxy.repository_access(repository)
)
login = user.get("login") if isinstance(user, dict) else None
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if not login or repository not in accessible:
if not login or accessible is None:
raise HTTPException(status_code=404, detail="Repository not found")
if creation.label_ids:
available_labels = await gitea_proxy.repo_labels(repository)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))});

View File

@ -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

View File

@ -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

View File

@ -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