Merge pull request 'Discover and claim unassigned issues from mobile My Work' (#176) from timmy/175-mobile-find-work into main
All checks were successful
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s

This commit is contained in:
rockachopa 2026-08-07 07:31:45 +00:00
commit 9293dde1af
8 changed files with 585 additions and 6 deletions

View File

@ -15,10 +15,11 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, create issue comments, close assigned
issues, inspect/comment on assigned pull requests, merge assigned pull requests, and
submit pull-request reviews. Pull-request replies and mobile My Work issue and PR
comments use Gitea's issue-comment API; mobile issue capture requires issue
threads, create and self-assign issues, discover and claim open unassigned issues,
create issue comments, close assigned issues, inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support

View File

@ -144,6 +144,17 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header button, .find-work-card button, .find-work-more { min-height:44px; }
.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 button { width:100%; font-weight:700; }
@media(max-width:320px) { .find-work-panel { padding:12px; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
@ -204,7 +215,10 @@ textarea { resize: vertical; min-height: 120px; }
<h2>My Work</h2>
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<button class="new-issue" id="new-issue" type="button">New issue</button>
<div class="my-work-actions">
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
</div>
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
@ -342,6 +356,19 @@ textarea { resize: vertical; min-height: 120px; }
</section>
</div>
<div class="find-work-sheet" id="find-work-sheet" role="dialog" aria-modal="true" aria-labelledby="find-work-heading">
<section class="find-work-panel">
<div class="find-work-header">
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<button id="close-find-work" type="button">Close</button>
</div>
<p class="small">Claim an available issue and continue it in My Work.</p>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
</section>
</div>
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
<section class="create-issue-panel">
<div class="create-issue-header">
@ -494,6 +521,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/commands.js"></script>
<script src="static/widgets.js"></script>
<script src="static/my-work.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/pull-sheet.js"></script>
@ -549,6 +577,8 @@ textarea { resize: vertical; min-height: 120px; }
let pullTrigger = null;
let selectedPullDetail = null;
let creatingIssue = false;
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null;
let draft = null;
let reviewFiles = [];
@ -568,6 +598,15 @@ textarea { resize: vertical; min-height: 120px; }
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
onPagination: pagination => {
availablePagination = pagination;
qs('#load-more-available').hidden = !pagination.has_more;
},
onStatus: message => { qs('#find-work-status').textContent = message; },
});
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
@ -1098,6 +1137,63 @@ textarea { resize: vertical; min-height: 120px; }
.map(input => Number(input.value)).filter(Number.isInteger);
}
function renderAvailableIssues(items) {
const list = qs('#find-work-list');
list.innerHTML = items.length ? items.map((item, index) =>
'<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
'</div><button type="button" data-claim-index="' + index + '">Assign to me</button></article>'
).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
list.querySelectorAll('[data-claim-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
if (!item) return;
button.disabled = true;
try {
const confirmed = await findWorkController.claim(item);
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
lastMyWork = buildMyWork(lastContextSnapshot);
const claimed = lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
closeFindWorkSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
openIssueSheet(claimed, qs('#find-work'));
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
button.disabled = false;
button.focus();
}
});
});
}
async function openFindWorkSheet() {
findingWork = true;
qs('#find-work-sheet').classList.add('open');
qs('#find-work-list').textContent = '';
qs('#find-work-status').textContent = 'Loading available issues…';
qs('#load-more-available').hidden = true;
qs('#close-find-work').focus();
try {
await findWorkController.load();
qs('#find-work-status').textContent = findWorkController.items().length ?
findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' :
'No unassigned issues are available.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Close and retry.';
}
}
function closeFindWorkSheet() {
findingWork = false;
qs('#find-work-sheet').classList.remove('open');
qs('#find-work').focus();
}
function saveIssueCaptureDraft() {
issueCapture.saveDraft({
repository: qs('#create-issue-repository').value,
@ -1453,6 +1549,11 @@ textarea { resize: vertical; min-height: 120px; }
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && findingWork) {
e.preventDefault();
closeFindWorkSheet();
return;
}
if (e.key === 'Escape' && creatingIssue) {
e.preventDefault();
saveIssueCaptureDraft();
@ -1479,6 +1580,21 @@ textarea { resize: vertical; min-height: 120px; }
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
qs('#load-more-available').addEventListener('click', async event => {
event.currentTarget.disabled = true;
qs('#find-work-status').textContent = 'Loading more available issues…';
try {
await findWorkController.loadMore();
qs('#find-work-status').textContent = findWorkController.items().length + ' of ' +
availablePagination.total + ' available issues loaded.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Retry loading more.';
} finally {
event.currentTarget.disabled = false;
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#cancel-new-issue').addEventListener('click', () => {
saveIssueCaptureDraft();

77
frontend/pick-work.js Normal file
View File

@ -0,0 +1,77 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let claimRequest = null;
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
if (append) {
const byId = new Map(available.map(item => [item.id, item]));
incoming.forEach(item => {
if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
});
available = Array.from(byId.values());
} else {
available = incoming.map(item => ({ ...item }));
}
pagination = {
page: Number(result?.page) || 1,
total: Number(result?.total) || available.length,
has_more: result?.has_more === true,
};
onItems(available.slice());
onPagination({ ...pagination });
}
function loadPage(page, append) {
if (loadRequest) return loadRequest;
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
headers: { Accept: 'application/json' },
}).then(result => {
apply(result, append);
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
}
return {
reset(result) {
apply(result, false);
},
load() {
return loadPage(1, false);
},
loadMore() {
if (!pagination.has_more) return Promise.resolve(false);
return loadPage(pagination.page + 1, true);
},
items() {
return available.slice();
},
claim(item) {
if (claimRequest) return claimRequest;
const key = item.repository + '#' + item.number;
onStatus('Assigning ' + key + '…');
const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number) + '/claim';
claimRequest = fetchJson(path, {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(confirmed => {
if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
throw new Error('Issue assignment was not confirmed.');
}
available = available.filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number
);
onItems(available.slice());
onStatus('Assigned ' + key + ' to you.');
return confirmed;
}).finally(() => { claimRequest = null; });
return claimRequest;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;

View File

@ -38,6 +38,10 @@ class PullNotMergeableError(ValueError):
"""Raised before merge when current pull state or checks prohibit it."""
class IssueNotAvailableError(ValueError):
"""Raised before assignment when an issue is no longer open and unassigned."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@ -175,6 +179,62 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
}
async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
"""Return one bounded page of open, unassigned issues visible to the user."""
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={"state": "open", "type": "issues", "limit": limit, "page": page},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
items = []
for item in payload:
if (
not isinstance(item, dict)
or item.get("state") != "open"
or "pull_request" in item
or item.get("assignees") not in (None, [])
):
continue
labels_value = item.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
repository_value = item.get("repository")
repository = repository_value if isinstance(repository_value, dict) else {}
items.append({
"id": item.get("id"),
"number": item.get("number"),
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"body": item.get("body", "") if isinstance(item.get("body"), str) else "",
"state": "open",
"repository": repository.get("full_name", "")
if isinstance(repository.get("full_name"), str) else "",
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": [],
"updated_at": item.get("updated_at", "")
if isinstance(item.get("updated_at"), str) else "",
"url": _safe_web_url(item.get("html_url")),
})
priority = {"p0", "priority-high", "critical"}
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
items.sort(key=lambda item: item["updated_at"], reverse=True)
items.sort(key=lambda item: (
0 if any(str(label).lower() in priority for label in item["labels"]) else 1
))
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {"items": items, "page": page, "total": total, "has_more": page * limit < total}
def _page_metadata(result: dict) -> dict:
return {
"page": result["page"],
@ -508,6 +568,60 @@ async def create_issue(
}
async def claim_available_issue(repository: str, number: int) -> dict:
issue = await fetch(f"repos/{repository}/issues/{number}")
user = await current_user()
assignees_value = issue.get("assignees") if isinstance(issue, dict) else None
if (
not isinstance(issue, dict)
or issue.get("state") != "open"
or "pull_request" in issue
or assignees_value not in (None, [])
):
raise IssueNotAvailableError("Issue is no longer available")
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Gitea current user did not include a login")
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignee": login},
)
response.raise_for_status()
confirmed = response.json()
if not isinstance(confirmed, dict) or confirmed.get("number") != number:
raise ValueError("Gitea did not confirm issue assignment")
confirmed_assignees_value = confirmed.get("assignees")
confirmed_assignees = (
confirmed_assignees_value if isinstance(confirmed_assignees_value, list) else []
)
logins = [
assignee["login"] for assignee in confirmed_assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
]
if login not in logins:
raise ValueError("Gitea did not confirm issue assignment")
labels_value = confirmed.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
return {
"id": confirmed.get("id"),
"number": number,
"title": confirmed.get("title", "")
if isinstance(confirmed.get("title"), str) else "",
"state": "open",
"repository": repository,
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": logins,
"updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")),
}
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",

View File

@ -269,7 +269,7 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or request.url.path.startswith("/api/v1/work/") or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or (
@ -400,6 +400,21 @@ async def paged_work(
})
@app.get("/api/v1/available-issues")
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.available_issue_page(page), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Available issues are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
return JSONResponse(result)
async def _load_context_for_user(user_data: dict) -> dict:
repo_data, issues_data, prs_data = await asyncio.gather(
repos(), issues(), pull_requests()
@ -856,6 +871,30 @@ async def reply_to_notification(
return JSONResponse(result, status_code=201)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
async def claim_available_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.claim_available_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "This issue was already claimed or is no longer open. Refresh Find Work."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The issue could not be assigned. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"

View File

@ -42,6 +42,67 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
assert result["items"][0]["work_reasons"] == ["review_requested"]
@pytest.mark.anyio
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "77"},
json=[
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
"updated_at": "2026-08-07T12:00:00Z", "assignees": [],
"labels": [], "repository": {"full_name": "stackchain/api"}},
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
{"id": 3, "number": 3, "title": "A pull", "state": "open",
"assignees": [], "pull_request": {"merged": False},
"repository": {"full_name": "stackchain/api"}},
{"id": 4, "number": 4, "title": "Critical", "state": "open",
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
"labels": [{"name": "critical"}],
"repository": {"full_name": "stackchain/web"}},
],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.available_issue_page(page=2)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=2"
]
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
assert result == {
"items": result["items"], "page": 2, "total": 77, "has_more": False,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
calls = []
async def available(page):
calls.append(page)
return {"items": [{"number": 7}], "page": page, "total": 51, "has_more": True}
monkeypatch.setattr(main.gitea_proxy, "available_issue_page", 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?page=1")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [{"number": 7}], "page": 1, "total": 51, "has_more": True,
}
assert calls == [1]
@pytest.mark.anyio
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
async def fake_page(stream, page=1, limit=50):

View File

@ -217,6 +217,82 @@ async def test_gitea_create_issue_requires_confirmed_self_assignment():
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_assignee():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": [], "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.claim_available_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("GET", "/api/v1/user"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"assignee":"timmy"}'
assert result["repository"] == "stackchain/api"
assert result["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
calls = []
async def claim(repository, number):
calls.append((repository, number))
return {
"id": 81, "number": number, "title": "Available", "state": "open",
"repository": repository, "labels": [], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["assignees"] == ["timmy"]
assert calls == [("stackchain/api", 17)]
@pytest.mark.anyio
async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflict(monkeypatch):
async def claim(_repository, _number):
raise gitea_proxy.IssueNotAvailableError("claimed")
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
assert response.status_code == 409
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
}
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):

View File

@ -12,6 +12,7 @@ REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@ -237,6 +238,85 @@ pager.loadMore('issue', [{{id:1}}]).then(result =>
}
def test_find_work_claim_is_single_flight_and_removes_only_confirmed_issue():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = createFindWork({{
fetchJson: (url, options) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
id:17,number:7,title:'Available',repository:'stackchain/api',
state:'open',labels:[],assignees:['timmy'],url:'https://forge.example/issues/7'
}}); }});
}},
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
controller.reset({{items:[
{{id:17,number:7,title:'Available',repository:'stackchain/api'}},
{{id:18,number:8,title:'Other',repository:'stackchain/web'}}
],page:1,total:2,has_more:false}});
const item = controller.items()[0];
const first = controller.claim(item);
const duplicate = controller.claim(item);
release();
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls,states,statuses,results,remaining:controller.items()
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert [item["number"] for item in output["remaining"]] == [8]
assert [item["number"] for item in output["states"][-1]] == [8]
assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
assert output["results"][0]["assignees"] == ["timmy"]
assert output["results"][1]["assignees"] == ["timmy"]
def test_find_work_loads_paginated_results_without_duplicates():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const calls = [];
const states = [];
const pages = [];
const controller = createFindWork({{
fetchJson: url => {{
calls.push(url);
const page = Number(new URL(url, 'https://example.test/').searchParams.get('page'));
return Promise.resolve(page === 1 ? {{
items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:2,has_more:true
}} : {{
items:[{{id:1,number:1,repository:'stackchain/api'}},{{id:2,number:2,repository:'stackchain/web'}}],
page:2,total:2,has_more:false
}});
}},
onItems: items => states.push(items),
onPagination: page => pages.push(page),
onStatus: () => {{}},
}});
controller.load().then(() => controller.loadMore()).then(() =>
process.stdout.write(JSON.stringify({{calls,states,pages,items:controller.items()}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
assert [item["id"] for item in output["items"]] == [1, 2]
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
@pytest.mark.anyio
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
html = await dashboard()
@ -247,6 +327,21 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
assert '.load-more-work { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
html = await dashboard()
assert 'id="find-work"' in html
assert 'id="find-work-sheet" role="dialog" aria-modal="true"' in html
assert 'id="find-work-list"' in html
assert 'id="find-work-status" class="small" aria-live="assertive"' in html
assert 'id="load-more-available"' in html
assert 'static/pick-work.js' in html
assert '.find-work-action { min-height:44px;' in html
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});