Add an explicit searchable repository picker for mobile capture #488
|
|
@ -38,7 +38,12 @@ issue-comment API. In issue, pull-request, and unread-update conversations, typi
|
||||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||||
|
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||||
|
authenticated repository search. Search results include only visible repository identities, stale
|
||||||
|
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
|
||||||
|
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
|
||||||
|
repository and meaningful title are selected,
|
||||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let duplicateRequest = 0;
|
let duplicateRequest = 0;
|
||||||
|
let repositorySearchRequest = 0;
|
||||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||||
let acknowledgedDuplicateKey = '';
|
let acknowledgedDuplicateKey = '';
|
||||||
const repositoryPageRequests = new Map();
|
const repositoryPageRequests = new Map();
|
||||||
|
|
@ -159,6 +160,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchRepositories(value) {
|
||||||
|
const query = String(value || '').trim().slice(0, 80);
|
||||||
|
const request = ++repositorySearchRequest;
|
||||||
|
if (query.length < 2) return {status: 'idle', items: []};
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(
|
||||||
|
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
|
||||||
|
);
|
||||||
|
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||||
|
return {status: 'failed', items: [], error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function duplicateKey(draft) {
|
function duplicateKey(draft) {
|
||||||
const repository = String(draft?.repository || '').trim();
|
const repository = String(draft?.repository || '').trim();
|
||||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
@ -230,7 +250,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||||
|
searchRepositories, findDuplicates,
|
||||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.create-issue-form { display:grid; gap:12px; }
|
.create-issue-form { display:grid; gap:12px; }
|
||||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||||
|
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||||
|
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||||
|
#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
|
||||||
|
#create-issue-repository-results[hidden] { display:none; }
|
||||||
|
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||||
|
.create-issue-repository-result:last-child { border-bottom:0; }
|
||||||
.create-issue-form label { display:grid; gap:6px; }
|
.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-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; }
|
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||||
|
|
|
||||||
|
|
@ -2558,6 +2558,7 @@
|
||||||
let issueCaptureRepositories = [];
|
let issueCaptureRepositories = [];
|
||||||
let nextIssueRepositoryPage = 2;
|
let nextIssueRepositoryPage = 2;
|
||||||
let moreIssueRepositoriesAvailable = false;
|
let moreIssueRepositoriesAvailable = false;
|
||||||
|
let issueRepositorySearchTimer = null;
|
||||||
|
|
||||||
function appendIssueRepositories(items) {
|
function appendIssueRepositories(items) {
|
||||||
const select = qs('#create-issue-repository');
|
const select = qs('#create-issue-repository');
|
||||||
|
|
@ -2576,6 +2577,43 @@
|
||||||
if (selected) select.value = selected;
|
if (selected) select.value = selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateIssueCreateActions() {
|
||||||
|
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
||||||
|
qs('#submit-new-issue').disabled = !hasRepository;
|
||||||
|
qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIssueRepositoryResults(items) {
|
||||||
|
const results = qs('#create-issue-repository-results');
|
||||||
|
results.replaceChildren();
|
||||||
|
(Array.isArray(items) ? items : []).forEach(item => {
|
||||||
|
const repository = String(item?.full_name || '').trim();
|
||||||
|
if (!repository) return;
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'create-issue-repository-result';
|
||||||
|
button.setAttribute('role', 'option');
|
||||||
|
button.textContent = repository;
|
||||||
|
button.addEventListener('click', () => selectIssueCaptureRepository(repository));
|
||||||
|
results.appendChild(button);
|
||||||
|
});
|
||||||
|
results.hidden = !results.childElementCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIssueCaptureRepository(repository) {
|
||||||
|
appendIssueRepositories([{full_name: repository}]);
|
||||||
|
qs('#create-issue-repository').value = repository;
|
||||||
|
qs('#create-issue-repository-search').value = repository;
|
||||||
|
qs('#create-issue-repository-results').hidden = true;
|
||||||
|
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
|
||||||
|
loadIssueLabels(repository);
|
||||||
|
loadIssueMilestones(repository);
|
||||||
|
saveIssueCaptureDraft();
|
||||||
|
scheduleIssueDuplicateCheck();
|
||||||
|
updateIssueCreateActions();
|
||||||
|
qs('#create-issue-title').focus();
|
||||||
|
}
|
||||||
|
|
||||||
let duplicateCheckTimer = null;
|
let duplicateCheckTimer = null;
|
||||||
function renderIssueDuplicates(state) {
|
function renderIssueDuplicates(state) {
|
||||||
if (state.status === 'stale') return;
|
if (state.status === 'stale') return;
|
||||||
|
|
@ -2703,7 +2741,7 @@
|
||||||
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
||||||
issueCaptureRepositories.unshift(captureDraft.repository);
|
issueCaptureRepositories.unshift(captureDraft.repository);
|
||||||
}
|
}
|
||||||
qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository =>
|
qs('#create-issue-repository').innerHTML = '<option value="">Choose repository</option>' + issueCaptureRepositories.map(repository =>
|
||||||
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
|
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
|
||||||
).join('');
|
).join('');
|
||||||
if (nextIssueRepositoryPage === 2) {
|
if (nextIssueRepositoryPage === 2) {
|
||||||
|
|
@ -2711,6 +2749,9 @@
|
||||||
}
|
}
|
||||||
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
|
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
|
||||||
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
||||||
|
else qs('#create-issue-repository').value = '';
|
||||||
|
qs('#create-issue-repository-search').value = captureDraft.repository || '';
|
||||||
|
qs('#create-issue-repository-results').hidden = true;
|
||||||
qs('#create-issue-title').value = captureDraft.title;
|
qs('#create-issue-title').value = captureDraft.title;
|
||||||
qs('#create-issue-body').value = captureDraft.body;
|
qs('#create-issue-body').value = captureDraft.body;
|
||||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||||
|
|
@ -2718,11 +2759,10 @@
|
||||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||||
scheduleIssueDuplicateCheck();
|
scheduleIssueDuplicateCheck();
|
||||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||||
qs('#submit-new-issue').disabled = !issueCaptureRepositories.length;
|
updateIssueCreateActions();
|
||||||
qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available();
|
|
||||||
qs('#create-issue-sheet').classList.add('open');
|
qs('#create-issue-sheet').classList.add('open');
|
||||||
creatingIssue = true;
|
creatingIssue = true;
|
||||||
qs('#create-issue-title').focus();
|
(captureDraft.repository ? qs('#create-issue-title') : qs('#create-issue-repository-search')).focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
let suppressCreateDraftOnHistoryClose = false;
|
let suppressCreateDraftOnHistoryClose = false;
|
||||||
|
|
@ -3564,8 +3604,34 @@
|
||||||
qs('#create-issue-repository').addEventListener('change', event => {
|
qs('#create-issue-repository').addEventListener('change', event => {
|
||||||
loadIssueLabels(event.target.value);
|
loadIssueLabels(event.target.value);
|
||||||
loadIssueMilestones(event.target.value);
|
loadIssueMilestones(event.target.value);
|
||||||
|
qs('#create-issue-repository-search').value = event.target.value;
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
scheduleIssueDuplicateCheck();
|
scheduleIssueDuplicateCheck();
|
||||||
|
updateIssueCreateActions();
|
||||||
|
});
|
||||||
|
qs('#create-issue-repository-search').addEventListener('input', event => {
|
||||||
|
clearTimeout(issueRepositorySearchTimer);
|
||||||
|
const query = event.target.value.trim();
|
||||||
|
const results = qs('#create-issue-repository-results');
|
||||||
|
const status = qs('#create-issue-repository-status');
|
||||||
|
if (query.length < 2) {
|
||||||
|
issueCapture.searchRepositories(query);
|
||||||
|
results.hidden = true;
|
||||||
|
status.textContent = query ? 'Enter at least 2 characters to search.' : 'Search or browse to choose a repository.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = 'Searching accessible repositories…';
|
||||||
|
issueRepositorySearchTimer = setTimeout(async () => {
|
||||||
|
const state = await issueCapture.searchRepositories(query);
|
||||||
|
if (state.status === 'stale') return;
|
||||||
|
if (state.status === 'failed') {
|
||||||
|
results.hidden = true;
|
||||||
|
status.textContent = 'Repository search failed. Your draft is safe; retry or browse below.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderIssueRepositoryResults(state.items);
|
||||||
|
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
|
||||||
|
}, 250);
|
||||||
});
|
});
|
||||||
qs('#load-more-issue-repositories').addEventListener('click', async event => {
|
qs('#load-more-issue-repositories').addEventListener('click', async event => {
|
||||||
const button = event.currentTarget;
|
const button = event.currentTarget;
|
||||||
|
|
|
||||||
|
|
@ -408,9 +408,15 @@
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<form class="create-issue-form" id="create-issue-form">
|
<form class="create-issue-form" id="create-issue-form">
|
||||||
<label for="create-issue-repository">Repository
|
<div class="create-issue-repository-picker">
|
||||||
<select id="create-issue-repository" required></select>
|
<label for="create-issue-repository-search">Repository search
|
||||||
</label>
|
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||||
|
<label for="create-issue-repository">Selected repository
|
||||||
|
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
<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>
|
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||||
<label for="create-issue-title">Title
|
<label for="create-issue-title">Title
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,21 @@ async def repos() -> RepositoryItems:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_repositories(query: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Search repositories visible to the authenticated Gitea user."""
|
||||||
|
response = await _get_client().get(
|
||||||
|
"/api/v1/repos/search",
|
||||||
|
headers=_auth(),
|
||||||
|
params={"q": query.strip(), "limit": limit, "page": 1},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
items = payload.get("data") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise ValueError("Gitea repository search response was not a list")
|
||||||
|
return [item for item in items if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
async def repository_access(repository: str) -> dict | None:
|
async def repository_access(repository: str) -> dict | None:
|
||||||
"""Return a repository only when the authenticated user can access it."""
|
"""Return a repository only when the authenticated user can access it."""
|
||||||
response = await _get_client().get(
|
response = await _get_client().get(
|
||||||
|
|
|
||||||
29
src/main.py
29
src/main.py
|
|
@ -1644,6 +1644,35 @@ async def context() -> JSONResponse:
|
||||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/repositories/search")
|
||||||
|
async def repository_search(
|
||||||
|
q: str = Query(min_length=2, max_length=80),
|
||||||
|
limit: int = Query(default=20, ge=1, le=20),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Return minimal repository identities matching a bounded visible-repo search."""
|
||||||
|
query = q.strip()
|
||||||
|
if len(query) < 2:
|
||||||
|
return JSONResponse({"error": "Enter at least 2 search characters."}, status_code=400)
|
||||||
|
try:
|
||||||
|
matches = await asyncio.wait_for(
|
||||||
|
gitea_proxy.search_repositories(query, limit),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Repository search is temporarily unavailable. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
items = [
|
||||||
|
{"id": item["id"], "name": item["name"], "full_name": item["full_name"]}
|
||||||
|
for item in matches
|
||||||
|
if isinstance(item, dict)
|
||||||
|
and all(field in item for field in ("id", "name", "full_name"))
|
||||||
|
]
|
||||||
|
return JSONResponse({"items": items}, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repositories")
|
@app.get("/api/v1/repositories")
|
||||||
async def repository_page(
|
async def repository_page(
|
||||||
page: int = Query(default=1, ge=1, le=1000),
|
page: int = Query(default=1, ge=1, le=1000),
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,62 @@ async def test_repository_page_reports_more_results_and_targeted_access_uses_rep
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_repository_search_finds_visible_repositories_without_loading_pages():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append((request.url.path, dict(request.url.params)))
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"ok": True,
|
||||||
|
"data": [
|
||||||
|
{"id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True},
|
||||||
|
"invalid",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.search_repositories(" mobile ", limit=20)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert result == [{
|
||||||
|
"id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True,
|
||||||
|
}]
|
||||||
|
assert requests == [(
|
||||||
|
"/api/v1/repos/search", {"q": "mobile", "limit": "20", "page": "1"},
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_repository_search_endpoint_returns_minimal_visible_matches(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def search(query, limit):
|
||||||
|
calls.append((query, limit))
|
||||||
|
return [{
|
||||||
|
"id": 151,
|
||||||
|
"name": "mobile",
|
||||||
|
"full_name": "stackchain/mobile",
|
||||||
|
"html_url": "https://forge.example/stackchain/mobile",
|
||||||
|
"description": "secret context that the picker does not need",
|
||||||
|
"private": True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "search_repositories", search)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/repositories/search?q=%20mobile%20&limit=20")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json() == {"items": [{
|
||||||
|
"id": 151, "name": "mobile", "full_name": "stackchain/mobile",
|
||||||
|
}]}
|
||||||
|
assert calls == [("mobile", 20)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||||
access_calls = []
|
access_calls = []
|
||||||
|
|
|
||||||
|
|
@ -2397,6 +2397,23 @@ async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_f
|
||||||
assert '.create-issue-repository-more { min-height:44px;' in html
|
assert '.create-issue-repository-more { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_issue_capture_requires_explicit_searchable_repository_selection():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="create-issue-repository-search" type="search" maxlength="80"' in html
|
||||||
|
assert 'placeholder="Search accessible repositories"' in html
|
||||||
|
assert 'id="create-issue-repository-results" role="listbox"' in html
|
||||||
|
assert '<option value="">Choose repository</option>' in html
|
||||||
|
assert "issueCapture.searchRepositories(query)" in html
|
||||||
|
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
|
||||||
|
assert "renderIssueRepositoryResults(state.items)" in html
|
||||||
|
assert "selectIssueCaptureRepository(repository)" in html
|
||||||
|
assert "const hasRepository = Boolean(qs('#create-issue-repository').value);" in html
|
||||||
|
assert '.create-issue-repository-result { min-height:44px;' in html
|
||||||
|
assert '.create-issue-repository-picker { min-width:0;' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -2545,6 +2562,53 @@ capture.loadRepositoryPage(2).catch(() => {{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_capture_repository_search_ignores_late_results_and_preserves_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),
|
||||||
|
}};
|
||||||
|
const pending = {{}};
|
||||||
|
const calls = [];
|
||||||
|
const capture = createIssueCapture({{
|
||||||
|
storage,
|
||||||
|
fetchJson: url => {{
|
||||||
|
calls.push(url);
|
||||||
|
return new Promise(resolve => {{ pending[url] = resolve; }});
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
||||||
|
const first = capture.searchRepositories('mob');
|
||||||
|
const second = capture.searchRepositories('mobile');
|
||||||
|
pending['api/v1/repositories/search?q=mobile&limit=20']({{items:[{{full_name:'stackchain/mobile'}}]}});
|
||||||
|
second.then(secondResult => {{
|
||||||
|
pending['api/v1/repositories/search?q=mob&limit=20']({{items:[{{full_name:'stackchain/obsolete'}}]}});
|
||||||
|
first.then(firstResult => process.stdout.write(JSON.stringify({{
|
||||||
|
calls, firstResult, secondResult, draft:capture.loadDraft()
|
||||||
|
}})));
|
||||||
|
}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output == {
|
||||||
|
"calls": [
|
||||||
|
"api/v1/repositories/search?q=mob&limit=20",
|
||||||
|
"api/v1/repositories/search?q=mobile&limit=20",
|
||||||
|
],
|
||||||
|
"firstResult": {"status": "stale", "items": []},
|
||||||
|
"secondResult": {"status": "ready", "items": [{"full_name": "stackchain/mobile"}]},
|
||||||
|
"draft": {
|
||||||
|
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user