feat: add explicit searchable repository picker (Closes #487)
All checks were successful
CI / lint (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-10 14:01:49 +00:00
parent b1b3f758e4
commit 81e1af7f16
9 changed files with 277 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = []

View File

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