Compare commits

..

No commits in common. "d4896e10be0b4d7a764631174aa52ef884a502c5" and "b1b3f758e4f16441c66bc883e11a021726d49e08" have entirely different histories.

9 changed files with 9 additions and 277 deletions

View File

@ -38,12 +38,7 @@ 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. 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,
creation and assignment permission. 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,7 +24,6 @@ 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();
@ -160,25 +159,6 @@ 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();
@ -250,8 +230,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
searchRepositories, findDuplicates,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};

View File

@ -349,12 +349,6 @@ 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,7 +2558,6 @@
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
let issueRepositorySearchTimer = null;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
@ -2577,43 +2576,6 @@
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;
@ -2741,7 +2703,7 @@
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
issueCaptureRepositories.unshift(captureDraft.repository);
}
qs('#create-issue-repository').innerHTML = '<option value="">Choose repository</option>' + issueCaptureRepositories.map(repository =>
qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository =>
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
).join('');
if (nextIssueRepositoryPage === 2) {
@ -2749,9 +2711,6 @@
}
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 || '';
@ -2759,10 +2718,11 @@
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
updateIssueCreateActions();
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;
(captureDraft.repository ? qs('#create-issue-title') : qs('#create-issue-repository-search')).focus();
qs('#create-issue-title').focus();
}
let suppressCreateDraftOnHistoryClose = false;
@ -3604,34 +3564,8 @@
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,15 +408,9 @@
</div>
</aside>
<form class="create-issue-form" id="create-issue-form">
<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 for="create-issue-repository">Repository
<select id="create-issue-repository" required></select>
</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,21 +301,6 @@ 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,35 +1644,6 @@ 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,62 +430,6 @@ 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,23 +2397,6 @@ 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()
@ -2562,53 +2545,6 @@ 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))});