feat: filter Find Work by repository and label (Closes #681)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-12 21:33:27 +00:00
parent 057bec7dc2
commit f031a37e8d
7 changed files with 203 additions and 10 deletions

View File

@ -458,6 +458,14 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.find-work-search input, .find-work-search button { min-height:44px; }
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
#clear-find-work-filters { min-height:44px; width:100%; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }

View File

@ -541,6 +541,14 @@
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
<span id="find-work-match-status" class="small" aria-live="polite"></span>
</form>
<details class="find-work-filters" id="find-work-filters">
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
<div class="find-work-filter-panel" aria-label="Filter available work">
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
<button id="clear-find-work-filters" type="button">Clear filters</button>
</div>
</details>
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>

View File

@ -1,9 +1,11 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {} }) {
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let loadGeneration = 0;
let query = '';
let filters = { repositories: [], labels: [] };
let facetView;
let claimRequest = null;
const previewed = new Set();
let selecting = false;
@ -41,12 +43,27 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
};
onItems(available.slice());
onPagination({ ...pagination });
if (result?.facets) onFacets({
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
});
if (result?.facets && typeof document !== 'undefined') {
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])),
next => { filters = next; loadPage(1, false); });
facetView(result.facets);
}
}
function loadPage(page, append, requestedQuery = query) {
if (loadRequest && requestedQuery === query && append) return loadRequest;
const generation = ++loadGeneration;
const queryPart = requestedQuery ? '&q=' + encodeURIComponent(requestedQuery) : '';
const params = new URLSearchParams();
params.set('facets', 'true');
if (requestedQuery) params.set('q', requestedQuery);
filters.repositories.forEach(value => params.append('repository', value));
filters.labels.forEach(value => params.append('label', value));
const queryPart = params.toString() ? '&' + params.toString() : '';
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
headers: { Accept: 'application/json' },
}).then(result => {
@ -81,6 +98,17 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
query() {
return query;
},
setFilters(next) {
filters = {
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
};
return loadPage(1, false);
},
filters() {
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
},
items() {
return available.slice();
},
@ -163,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
};
}
function attachFindWorkFacets(root, escapeHtml, onChange) {
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
root.querySelector('#clear-find-work-filters').onclick = () => {
root.querySelectorAll('input').forEach(input => { input.checked = false; });
onChange({ repositories:[], labels:[] });
};
return facets => {
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
const render = (id, choices, selected, name) => {
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
};
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
const count = active.repositories.length + active.labels.length;
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;

View File

@ -2722,8 +2722,20 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
def _available_issue_page(
items: list[dict], page: int, limit: int = 50, query: str = ""
items: list[dict], page: int, limit: int = 50, query: str = "",
repositories: list[str] | None = None, labels: list[str] | None = None,
include_facets: bool = False,
) -> dict:
facets = {
"repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold),
"labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold),
}
repository_filter = {value.casefold() for value in (repositories or [])}
label_filter = {value.casefold() for value in (labels or [])}
if repository_filter:
items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter]
if label_filter:
items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}]
normalized_query = query.strip().casefold()
if normalized_query:
number_query = normalized_query.removeprefix("#")
@ -2735,19 +2747,27 @@ def _available_issue_page(
]
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
result = {
"items": page_items,
"page": page,
"total": len(items),
"has_more": start + len(page_items) < len(items),
}
if include_facets or repository_filter or label_filter:
result["facets"] = facets
return result
@app.get("/api/v1/available-issues")
async def available_issues(
page: int = Query(default=1, ge=1, le=100),
q: str = Query(default="", max_length=100),
repository: list[str] = Query(default=[], max_length=100),
label: list[str] = Query(default=[], max_length=100),
facets: bool = Query(default=False),
) -> JSONResponse:
if len(repository) > 10 or len(label) > 10:
return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422)
try:
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
@ -2758,7 +2778,9 @@ async def available_issues(
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
result = _available_issue_page(items, page, query=q)
result = _available_issue_page(
items, page, query=q, repositories=repository, labels=label, include_facets=facets
)
if stale:
result["stale"] = True
if revalidating:

View File

@ -269,7 +269,7 @@ const pages={{
}};
const controller=createFindWork({{
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
Promise.resolve(pages[path.endsWith('=2') ? 2 : 1]),
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
controller.load().then(()=>{{
@ -426,9 +426,9 @@ second.then(()=>{{
assert run_node(script) == {
"calls": [
"api/v1/available-issues?page=1&q=api",
"api/v1/available-issues?page=1&q=dashboard",
"api/v1/available-issues?page=1&q=worker",
"api/v1/available-issues?page=1&facets=true&q=api",
"api/v1/available-issues?page=1&facets=true&q=dashboard",
"api/v1/available-issues?page=1&facets=true&q=worker",
],
"rendered": [[673], [674]],
"items": [674],
@ -449,3 +449,40 @@ def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
assert "#find-work-search" in dashboard
assert ".find-work-search" in css
assert ".find-work-search button" in css and "min-height:44px" in css
def test_find_work_facets_serialize_filters_and_preserve_selection():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const calls=[];
const controller=createFindWork({{
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
}});
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
}})));
"""
assert run_node(script) == {
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
"selected": [9],
}
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="find-work-filters"' in html
assert 'id="find-work-repository-filters"' in html
assert 'id="find-work-label-filters"' in html
assert 'id="clear-find-work-filters"' in html
assert 'aria-label="Filter available work"' in html
assert "setFilters(next)" in PICK_WORK.read_text()
assert ".find-work-filters" in css
assert ".find-work-filter-option" in css and "min-height:44px" in css

View File

@ -248,6 +248,74 @@ async def test_available_issue_search_filters_full_catalog_before_pagination(mon
assert by_title.json() == by_repo.json()
@pytest.mark.anyio
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
async def available():
return [
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/available-issues",
params=[("repository", "stackchain/api"), ("label", "critical")],
)
assert response.status_code == 200
assert response.json() == {
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
"page": 1,
"total": 1,
"has_more": False,
"facets": {
"repositories": ["stackchain/api", "stackchain/dashboard"],
"labels": ["backend", "critical", "docs", "frontend"],
},
}
@pytest.mark.anyio
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
async def available():
return [
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/available-issues",
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
("label", "backend"), ("label", "frontend")],
)
assert [item["number"] for item in response.json()["items"]] == [1, 2]
@pytest.mark.anyio
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
async def must_not_scan():
raise AssertionError("invalid facets must be rejected before catalog scan")
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/available-issues",
params=[("label", f"label-{index}") for index in range(11)],
)
assert response.status_code == 422
@pytest.mark.anyio
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
async def must_not_scan():

View File

@ -3345,7 +3345,10 @@ controller.load().then(() => controller.loadMore()).then(() =>
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
assert output["calls"] == [
"api/v1/available-issues?page=1&facets=true",
"api/v1/available-issues?page=2&facets=true",
]
assert [item["id"] for item in output["items"]] == [1, 2]
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}