Merge pull request 'Discover and claim unassigned issues from mobile My Work' (#176) from timmy/175-mobile-find-work into main
This commit is contained in:
commit
9293dde1af
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
77
frontend/pick-work.js
Normal 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;
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
41
src/main.py
41
src/main.py
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user