Discover and claim unassigned issues from mobile My Work #176

Merged
rockachopa merged 1 commits from timmy/175-mobile-find-work into main 2026-08-07 07:31:46 +00:00
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 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 token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, create issue comments, close assigned threads, create and self-assign issues, discover and claim open unassigned issues,
issues, inspect/comment on assigned pull requests, merge assigned pull requests, and create issue comments, close assigned issues, inspect/comment on assigned pull
submit pull-request reviews. Pull-request replies and mobile My Work issue and PR requests, merge assigned pull requests, and submit pull-request reviews.
comments use Gitea's issue-comment API; mobile issue capture requires issue 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, creation and assignment permission. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support 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-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; } .issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; } .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 { 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-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; } .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> <h2>My Work</h2>
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div> <div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</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"> <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="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> <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> </section>
</div> </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"> <div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
<section class="create-issue-panel"> <section class="create-issue-panel">
<div class="create-issue-header"> <div class="create-issue-header">
@ -494,6 +521,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/commands.js"></script> <script src="static/commands.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/my-work.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/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script> <script src="static/create-issue-sheet.js"></script>
<script src="static/pull-sheet.js"></script> <script src="static/pull-sheet.js"></script>
@ -549,6 +577,8 @@ textarea { resize: vertical; min-height: 120px; }
let pullTrigger = null; let pullTrigger = null;
let selectedPullDetail = null; let selectedPullDetail = null;
let creatingIssue = false; let creatingIssue = false;
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null; let progress = null;
let draft = null; let draft = null;
let reviewFiles = []; let reviewFiles = [];
@ -568,6 +598,15 @@ textarea { resize: vertical; min-height: 120px; }
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ 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 setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); } 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); .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() { function saveIssueCaptureDraft() {
issueCapture.saveDraft({ issueCapture.saveDraft({
repository: qs('#create-issue-repository').value, 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('#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)); qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && findingWork) {
e.preventDefault();
closeFindWorkSheet();
return;
}
if (e.key === 'Escape' && creatingIssue) { if (e.key === 'Escape' && creatingIssue) {
e.preventDefault(); e.preventDefault();
saveIssueCaptureDraft(); saveIssueCaptureDraft();
@ -1479,6 +1580,21 @@ textarea { resize: vertical; min-height: 120px; }
} }
}); });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); 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('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#cancel-new-issue').addEventListener('click', () => { qs('#cancel-new-issue').addEventListener('click', () => {
saveIssueCaptureDraft(); 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.""" """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]: def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"} headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN: 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: def _page_metadata(result: dict) -> dict:
return { return {
"page": result["page"], "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: async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch( response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}", f"/api/v1/repos/{repository}/issues/{number}",

View File

@ -269,7 +269,7 @@ app.include_router(frontend_router)
@app.middleware("http") @app.middleware("http")
async def prevent_live_api_caching(request, call_next): async def prevent_live_api_caching(request, call_next):
response = await call_next(request) 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/") request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review") and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or ( ) 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: async def _load_context_for_user(user_data: dict) -> dict:
repo_data, issues_data, prs_data = await asyncio.gather( repo_data, issues_data, prs_data = await asyncio.gather(
repos(), issues(), pull_requests() repos(), issues(), pull_requests()
@ -856,6 +871,30 @@ async def reply_to_notification(
return JSONResponse(result, status_code=201) 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") @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)): async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}" 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"] 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 @pytest.mark.anyio
async def test_initial_work_collections_expose_independent_pagination(monkeypatch): async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
async def fake_page(stream, page=1, limit=50): 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() 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 @pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch): async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number): 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" ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-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" 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(): 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 @pytest.mark.anyio
async def test_mobile_my_work_exposes_truthful_work_pagination_control(): async def test_mobile_my_work_exposes_truthful_work_pagination_control():
html = await dashboard() 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 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(): def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
script = f""" script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});