Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488) from timmy/487-searchable-repository-picker into main
Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488)
This commit is contained in:
commit
d4896e10be
|
|
@ -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;
|
||||
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
|
||||
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 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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
|
|
@ -159,6 +160,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
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) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
|
|
@ -230,7 +250,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -349,6 +349,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.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 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; }
|
||||
|
|
|
|||
|
|
@ -2558,6 +2558,7 @@
|
|||
let issueCaptureRepositories = [];
|
||||
let nextIssueRepositoryPage = 2;
|
||||
let moreIssueRepositoriesAvailable = false;
|
||||
let issueRepositorySearchTimer = null;
|
||||
|
||||
function appendIssueRepositories(items) {
|
||||
const select = qs('#create-issue-repository');
|
||||
|
|
@ -2576,6 +2577,43 @@
|
|||
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;
|
||||
function renderIssueDuplicates(state) {
|
||||
if (state.status === 'stale') return;
|
||||
|
|
@ -2703,7 +2741,7 @@
|
|||
if (captureDraft.repository && !issueCaptureRepositories.includes(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>'
|
||||
).join('');
|
||||
if (nextIssueRepositoryPage === 2) {
|
||||
|
|
@ -2711,6 +2749,9 @@
|
|||
}
|
||||
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
|
||||
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-body').value = captureDraft.body;
|
||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||
|
|
@ -2718,11 +2759,10 @@
|
|||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
scheduleIssueDuplicateCheck();
|
||||
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();
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
qs('#create-issue-title').focus();
|
||||
(captureDraft.repository ? qs('#create-issue-title') : qs('#create-issue-repository-search')).focus();
|
||||
}
|
||||
|
||||
let suppressCreateDraftOnHistoryClose = false;
|
||||
|
|
@ -3564,8 +3604,34 @@
|
|||
qs('#create-issue-repository').addEventListener('change', event => {
|
||||
loadIssueLabels(event.target.value);
|
||||
loadIssueMilestones(event.target.value);
|
||||
qs('#create-issue-repository-search').value = event.target.value;
|
||||
saveIssueCaptureDraft();
|
||||
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 => {
|
||||
const button = event.currentTarget;
|
||||
|
|
|
|||
|
|
@ -408,9 +408,15 @@
|
|||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<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>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<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:
|
||||
"""Return a repository only when the authenticated user can access it."""
|
||||
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))
|
||||
|
||||
|
||||
@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")
|
||||
async def repository_page(
|
||||
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
|
||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
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():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user