Merge pull request 'Support issue capture across all accessible repositories' (#404) from timmy/403-issue-capture-repository-pagination into main
This commit is contained in:
commit
461f90c8ff
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
60
src/main.py
60
src/main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user