Compare commits

...
Author SHA1 Message Date
timmy 87058d72e6 feat: release mobile issue assignments (#183)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 09:24:21 +00:00
rockachopa e96ff42dff Merge pull request 'Make Find Work production-schema-safe and truthfully paginated' (#182) from timmy/181-find-work-schema-pagination into main
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 08:59:02 +00:00
timmy b6c7ec83e4 feat: make Find Work production-schema-safe (#181)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:58:07 +00:00
rockachopa b4f984e331 Preview issue details before claiming mobile work (#180)
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #179
2026-08-07 08:23:29 +00:00
timmy b4b13a7db1 feat: preview issue details before claiming (#179)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:22:43 +00:00
rockachopa 1947f878e9 Make mobile issue capture idempotent across retries (#178)
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #177
2026-08-07 07:55:30 +00:00
timmy 6f54d5764b feat: make mobile issue capture idempotent (#177)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 07:54:42 +00:00
rockachopa 9293dde1af Merge pull request 'Discover and claim unassigned issues from mobile My Work' (#176) from timmy/175-mobile-find-work into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 07:31:45 +00:00
12 changed files with 1014 additions and 97 deletions

No files matched your search

+5 -2
View File
@@ -15,12 +15,15 @@ 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, discover and claim open unassigned issues,
threads, create and self-assign issues, discover, claim, and release issue assignments,
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,
creation and assignment permission. Issue capture persists a per-draft idempotency key,
so retrying after a timeout or reload replays a confirmed creation instead of posting a
duplicate; callers integrating directly should preserve the `Idempotency-Key` header
with the unchanged payload until a `201` response is confirmed. 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
head-scoped draft comments anchored to changed lines; the dashboard validates each
+45 -15
View File
@@ -1,4 +1,14 @@
function createIssueCapture({ fetchJson, storage }) {
function newIssueOperationId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
if (globalThis.crypto?.getRandomValues) {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
}
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
let pending = null;
const safeLabelIds = value => Array.from(new Set(
@@ -6,30 +16,44 @@ function createIssueCapture({ fetchJson, storage }) {
)).slice(0, 20);
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
function loadStored() {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
return {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128),
};
} catch (_error) {
return {...emptyDraft(), operationId: ''};
}
}
function writeStored(record) {
try { storage.setItem(storageKey, JSON.stringify(record)); }
catch (_error) { /* Keep the form as the in-memory fallback. */ }
}
function saveDraft(draft) {
const previous = loadStored();
const safe = {
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
try { storage.setItem(storageKey, JSON.stringify(safe)); }
catch (_error) { /* Keep the form as the in-memory fallback. */ }
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe;
}
function loadDraft() {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
return parsed && typeof parsed === 'object' ? {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds),
} : emptyDraft();
} catch (_error) {
return emptyDraft();
}
const {operationId: _operationId, ...draft} = loadStored();
return draft;
}
function clearDraft() {
@@ -52,10 +76,16 @@ function createIssueCapture({ fetchJson, storage }) {
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
const stored = loadStored();
const operationId = stored.operationId || String(createOperationId()).slice(0, 128);
writeStored({...saved, operationId});
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
}),
+50 -6
View File
@@ -150,11 +150,14 @@ textarea { resize: vertical; min-height: 120px; }
.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-header button, .find-work-card button, .find-work-card a, .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%; } }
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .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; }
@@ -350,6 +353,7 @@ textarea { resize: vertical; min-height: 120px; }
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<div class="issue-sheet-actions">
<button id="release-issue" type="button">Release assignment</button>
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
@@ -1139,12 +1143,31 @@ textarea { resize: vertical; min-height: 120px; }
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) + '#' +
list.innerHTML = items.length ? items.map((item, index) => {
const expanded = findWorkController.isPreviewed(item);
const detailId = 'find-work-detail-' + index;
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
'><p class="find-work-description">' + escapeHtml(item.body || 'No description provided.') + '</p>' +
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
'</div>';
return '<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>';
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
detail + '<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-preview-index]').forEach(button => {
button.addEventListener('click', () => {
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
if (!item) return;
const expanded = findWorkController.togglePreview(item);
const detail = document.getElementById(button.getAttribute('aria-controls'));
button.setAttribute('aria-expanded', String(expanded));
button.textContent = expanded ? 'Hide details' : 'View details';
if (detail) detail.hidden = !expanded;
});
});
list.querySelectorAll('[data-claim-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
@@ -1696,6 +1719,27 @@ textarea { resize: vertical; min-height: 120px; }
button.disabled = false;
}
});
qs('#release-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
const releasing = selectedIssue;
const button = qs('#release-issue');
button.disabled = true;
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
try {
const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, releasing.repository, releasing.number
);
paintMyWork(lastContextSnapshot);
closeIssueSheet();
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
const closing = selectedIssue;
+18
View File
@@ -1,6 +1,7 @@
function createIssueSheet({ fetchJson, storage }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let labelRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
@@ -61,6 +62,23 @@ function createIssueSheet({ fetchJson, storage }) {
}).finally(() => { closeRequest = null; });
return closeRequest;
},
release(item, currentLogin) {
if (releaseRequest) return releaseRequest;
releaseRequest = fetchJson(issuePath(item) + '/release', {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
if (
result?.number !== item.number ||
!Array.isArray(result.assignees) ||
result.assignees.includes(currentLogin)
) {
throw new Error('Issue release was not confirmed.');
}
return result;
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
+10
View File
@@ -335,6 +335,15 @@ function replaceIssueLabels(data, repository, number, labels) {
};
}
function removeIssue(data, repository, number) {
return {
...data,
issues: (data.issues || []).filter(item =>
item.repository !== repository || item.number !== number
),
};
}
function summarizeMyWork(items) {
const updates = items.filter((item) => item.has_update).length;
const reviews = items.filter((item) => item.is_review).length;
@@ -358,6 +367,7 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
+22
View File
@@ -3,6 +3,11 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let claimRequest = null;
const previewed = new Set();
function itemKey(item) {
return String(item?.repository || '') + '#' + String(item?.number || '');
}
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
@@ -15,6 +20,10 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
} else {
available = incoming.map(item => ({ ...item }));
}
const availableKeys = new Set(available.map(itemKey));
previewed.forEach(key => {
if (!availableKeys.has(key)) previewed.delete(key);
});
pagination = {
page: Number(result?.page) || 1,
total: Number(result?.total) || available.length,
@@ -49,6 +58,15 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
items() {
return available.slice();
},
togglePreview(item) {
const key = itemKey(item);
if (previewed.has(key)) previewed.delete(key);
else previewed.add(key);
return previewed.has(key);
},
isPreviewed(item) {
return previewed.has(itemKey(item));
},
claim(item) {
if (claimRequest) return claimRequest;
const key = item.repository + '#' + item.number;
@@ -65,7 +83,11 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
available = available.filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number
);
pagination.total = Math.max(available.length, pagination.total - 1);
pagination.has_more = available.length < pagination.total;
previewed.delete(itemKey(item));
onItems(available.slice());
onPagination({ ...pagination });
onStatus('Assigned ' + key + ' to you.');
return confirmed;
}).finally(() => { claimRequest = null; });
+125 -46
View File
@@ -179,48 +179,70 @@ 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")
def _normalize_available_issue(item: Any) -> dict | None:
if (
not isinstance(item, dict)
or item.get("state") != "open"
or item.get("pull_request") is not None
or item.get("assignees") not in (None, [])
):
return None
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 {}
return {
"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")),
}
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, [])
async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50) -> list[dict]:
"""Load, filter, and globally rank a bounded snapshot of available issues."""
items: list[dict] = []
seen_ids: set[Any] = set()
upstream_total: int | None = None
for upstream_page in range(1, max_pages + 1):
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={
"state": "open", "type": "issues",
"limit": upstream_limit, "page": upstream_page,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
if upstream_total is None:
try:
upstream_total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
upstream_total = None
for raw_item in payload:
item = _normalize_available_issue(raw_item)
if item is not None and item["id"] not in seen_ids:
seen_ids.add(item["id"])
items.append(item)
loaded = upstream_page * upstream_limit
if not payload or len(payload) < upstream_limit or (
upstream_total is not None and loaded >= upstream_total
):
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")),
})
break
priority = {"p0", "priority-high", "critical"}
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
@@ -228,11 +250,21 @@ async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
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}
return items
async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
"""Return one logical page from a bounded, globally ranked available-work scan."""
items = await available_issue_snapshot()
total = len(items)
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
"items": page_items,
"page": page,
"total": total,
"has_more": start + len(page_items) < total,
}
def _page_metadata(result: dict) -> dict:
@@ -575,7 +607,7 @@ async def claim_available_issue(repository: str, number: int) -> dict:
if (
not isinstance(issue, dict)
or issue.get("state") != "open"
or "pull_request" in issue
or issue.get("pull_request") is not None
or assignees_value not in (None, [])
):
raise IssueNotAvailableError("Issue is no longer available")
@@ -622,6 +654,53 @@ async def claim_available_issue(repository: str, number: int) -> dict:
}
async def release_assigned_issue(repository: str, number: int) -> dict:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
assignees_value = issue.get("assignees")
if (
issue.get("state") != "open"
or issue.get("pull_request") is not None
or not _login_in_users(login, assignees_value)
):
raise IssueNotAvailableError("Issue is not assigned to the current user")
assignees = assignees_value if isinstance(assignees_value, list) else []
remaining = [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict)
and isinstance(assignee.get("login"), str)
and assignee["login"] != login
]
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": remaining},
)
response.raise_for_status()
confirmed = response.json()
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
confirmed_assignees = [
assignee["login"] for assignee in confirmed_value
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
] if isinstance(confirmed_value, list) else []
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or login in confirmed_assignees
or set(confirmed_assignees) != set(remaining)
):
raise ValueError("Gitea did not confirm issue release")
return {
"number": number,
"repository": repository,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
"available": not confirmed_assignees,
}
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}",
+148 -14
View File
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Literal
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
@@ -30,23 +30,27 @@ from src.views import router as frontend_router
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _live_snapshot_task
global _live_snapshot_task, _available_issue_snapshot_task
gitea_proxy.start_client()
try:
yield
finally:
task = _live_snapshot_task
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
live_task = _live_snapshot_task
available_task = _available_issue_snapshot_task
for task in (live_task, available_task):
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
try:
await gitea_proxy.stop_client()
finally:
if _live_snapshot_task is task:
if _live_snapshot_task is live_task:
_live_snapshot_task = None
if _available_issue_snapshot_task is available_task:
_available_issue_snapshot_task = None
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
@@ -55,6 +59,8 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES = 256
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
@@ -64,6 +70,7 @@ BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
@@ -80,6 +87,12 @@ _live_section_retry_at: dict[str, float | None] = {
}
_live_snapshot_refreshing_sections: set[str] = set()
_read_notification_ids: set[int] = set()
_issue_creation_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
class ContextPayloadError(ValueError):
@@ -400,11 +413,52 @@ async def paged_work(
})
async def _refresh_available_issue_snapshot() -> list[dict]:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
result = await gitea_proxy.available_issue_snapshot()
_available_issue_snapshot_value = result
_available_issue_snapshot_created_at = time.monotonic()
return result
async def _available_issue_snapshot() -> tuple[list[dict], bool]:
global _available_issue_snapshot_task
now = time.monotonic()
if (
_available_issue_snapshot_value is not None
and _available_issue_snapshot_created_at is not None
and now - _available_issue_snapshot_created_at
< AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
):
return _available_issue_snapshot_value, False
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot()
)
try:
return await asyncio.shield(_available_issue_snapshot_task), False
except Exception:
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True
raise
def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
"items": page_items,
"page": page,
"total": len(items),
"has_more": start + len(page_items) < len(items),
}
@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
items, stale = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
@@ -412,6 +466,9 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
result = _available_issue_page(items, page)
if stale:
result["stale"] = True
return JSONResponse(result)
@@ -892,6 +949,38 @@ async def claim_available_issue(
status_code=503,
headers={"Retry-After": "1"},
)
if _available_issue_snapshot_value is not None:
_available_issue_snapshot_value[:] = [
item for item in _available_issue_snapshot_value
if not (
item.get("repository") == repository and item.get("number") == number
)
]
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release")
async def release_assigned_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.release_assigned_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except Exception:
return JSONResponse(
{"error": "The assignment could not be released. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
if result.get("available"):
_available_issue_snapshot_value = None
_available_issue_snapshot_created_at = None
return JSONResponse(result)
@@ -952,7 +1041,12 @@ async def repository_labels(owner: str, repo: str):
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
async def create_assigned_issue(
creation: IssueCreation,
owner: str,
repo: str,
idempotency_key: str | None = Header(default=None, max_length=128),
):
repository = f"{owner}/{repo}"
async def create_issue():
@@ -980,8 +1074,48 @@ async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
repository, creation.title, creation.body, login, creation.label_ids
)
operation = create_issue()
if idempotency_key:
now = time.monotonic()
expired = [
key for key, (_, task, created_at) in _issue_creation_operations.items()
if task.done()
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
]
for key in expired:
_issue_creation_operations.pop(key, None)
fingerprint = (
repository, creation.title, creation.body, tuple(creation.label_ids)
)
existing = _issue_creation_operations.get(idempotency_key)
if existing is not None:
operation.close()
if existing[0] != fingerprint:
raise HTTPException(status_code=409, detail="Idempotency key already used")
task = existing[1]
else:
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
completed = [
key for key, (_, task, _) in _issue_creation_operations.items()
if task.done()
]
if not completed:
operation.close()
raise HTTPException(
status_code=503,
detail="Issue creation is busy; please retry",
headers={"Retry-After": "1"},
)
oldest = min(
completed, key=lambda key: _issue_creation_operations[key][2]
)
_issue_creation_operations.pop(oldest)
task = asyncio.create_task(operation)
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
operation = asyncio.shield(task)
try:
result = await asyncio.wait_for(create_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
+35
View File
@@ -79,3 +79,38 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
main._live_snapshot_task = None
assert calls == ["start", "snapshot cancelled", "stop (done)"]
@pytest.mark.anyio
async def test_application_shutdown_cancels_available_work_scan_before_transport(monkeypatch):
calls = []
started = asyncio.Event()
async def active_scan():
started.set()
try:
await asyncio.Event().wait()
finally:
calls.append("available scan cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
task = main._available_issue_snapshot_task
calls.append(f"stop ({'done' if task is not None and task.done() else 'active'})")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
try:
async with main.app.router.lifespan_context(main.app):
main._available_issue_snapshot_task = asyncio.create_task(active_scan())
await started.wait()
finally:
task = main._available_issue_snapshot_task
if task is not None and not task.done():
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
main._available_issue_snapshot_task = None
assert calls == ["start", "available scan cancelled", "stop (done)"]
+140 -11
View File
@@ -8,6 +8,20 @@ from src import gitea_proxy
from src import main
@pytest.fixture(autouse=True)
def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
yield
task = main._available_issue_snapshot_task
if task is not None and not task.done():
task.cancel()
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
@pytest.mark.anyio
async def test_work_page_preserves_total_and_reason_without_loading_other_pages():
requests = []
@@ -50,10 +64,11 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "77"},
headers={"X-Total-Count": "4"},
json=[
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
"updated_at": "2026-08-07T12:00:00Z", "assignees": [],
"updated_at": "2026-08-07T12:00:00Z", "assignees": None,
"pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"}},
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
@@ -62,6 +77,7 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
"repository": {"full_name": "stackchain/api"}},
{"id": 4, "number": 4, "title": "Critical", "state": "open",
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
"pull_request": None,
"labels": [{"name": "critical"}],
"repository": {"full_name": "stackchain/web"}},
],
@@ -69,16 +85,54 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.available_issue_page(page=2)
result = await gitea_proxy.available_issue_page(page=1)
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"
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=1"
]
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
assert result == {
"items": result["items"], "page": 2, "total": 77, "has_more": False,
"items": result["items"], "page": 1, "total": 2, "has_more": False,
}
@pytest.mark.anyio
async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagination():
requests = []
def issue(number, *, assigned=True, critical=False, updated_at="2026-08-07T12:00:00Z"):
return {
"id": number, "number": number, "title": f"Issue {number}", "state": "open",
"updated_at": updated_at, "assignees": [{"login": "alex"}] if assigned else None,
"pull_request": None,
"labels": [{"name": "critical"}] if critical else [],
"repository": {"full_name": "stackchain/api"},
}
first_page = [issue(number) for number in range(1, 51)]
first_page[0] = issue(1, assigned=False, updated_at="2026-08-07T13:00:00Z")
def upstream(request):
requests.append(str(request.url))
page = int(request.url.params["page"])
payload = first_page if page == 1 else [
issue(51, assigned=False, critical=True, updated_at="2026-08-07T10:00:00Z"),
issue(52, assigned=False, updated_at="2026-08-07T11:00:00Z"),
]
return httpx.Response(200, headers={"X-Total-Count": "52"}, json=payload)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.available_issue_page(page=1, limit=2)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert [item["number"] for item in result["items"]] == [51, 1]
assert result == {
"items": result["items"], "page": 1, "total": 3, "has_more": True,
}
@@ -86,11 +140,11 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
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}
async def available():
calls.append(True)
return [{"number": number} for number in range(1, 52)]
monkeypatch.setattr(main.gitea_proxy, "available_issue_page", available)
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/available-issues?page=1")
@@ -98,9 +152,84 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
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,
"items": [{"number": number} for number in range(1, 51)],
"page": 1, "total": 51, "has_more": True,
}
assert calls == [True]
@pytest.mark.anyio
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def available():
nonlocal calls
calls += 1
started.set()
await release.wait()
return [{"number": number} for number in range(1, 64)]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.get("/api/v1/available-issues?page=1"))
second = asyncio.create_task(client.get("/api/v1/available-issues?page=2"))
await asyncio.wait_for(started.wait(), timeout=1)
release.set()
first_response, second_response = await asyncio.gather(first, second)
repeated_response = await client.get("/api/v1/available-issues?page=1")
assert calls == 1
assert len(first_response.json()["items"]) == 50
assert len(second_response.json()["items"]) == 13
assert second_response.json()["total"] == 63
assert second_response.json()["has_more"] is False
assert repeated_response.status_code == 200
@pytest.mark.anyio
async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure(monkeypatch):
async def unavailable():
raise httpx.ConnectError("offline")
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
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.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True,
}
@pytest.mark.anyio
async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monkeypatch):
main._available_issue_snapshot_value = [
{"number": 7, "repository": "stackchain/api"},
{"number": 8, "repository": "stackchain/web"},
]
main._available_issue_snapshot_created_at = 10**12
async def claim(repository, number):
return {"repository": repository, "number": number, "assignees": ["timmy"]}
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:
claimed = await client.patch("/api/v1/repos/stackchain/api/issues/7/claim")
available = await client.get("/api/v1/available-issues?page=1")
assert claimed.status_code == 200
assert available.json() == {
"items": [{"number": 8, "repository": "stackchain/web"}],
"page": 1, "total": 1, "has_more": False,
}
assert calls == [1]
@pytest.mark.anyio
+230 -1
View File
@@ -6,6 +6,13 @@ import pytest
from src import gitea_proxy, main
@pytest.fixture(autouse=True)
def clear_issue_creation_operations():
main._issue_creation_operations.clear()
yield
main._issue_creation_operations.clear()
@pytest.mark.anyio
async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirmed_issue(monkeypatch):
calls = []
@@ -60,6 +67,166 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
started.set()
await release.wait()
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-concurrent"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
))
await started.wait()
second = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
))
await asyncio.sleep(0)
release.set()
responses = await asyncio.gather(first, second)
assert [response.status_code for response in responses] == [201, 201]
assert [response.json()["number"] for response in responses] == [17, 17]
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
calls.append(title)
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "First title"}, headers=headers
)
conflict = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Changed title"}, headers=headers
)
assert created.status_code == 201
assert conflict.status_code == 409
assert conflict.json()["detail"] == "Idempotency key already used"
assert calls == ["First title"]
@pytest.mark.anyio
async def test_create_issue_retry_recovers_result_after_the_first_request_times_out(monkeypatch):
calls = 0
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
await asyncio.sleep(0.03)
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
timed_out = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
)
await asyncio.sleep(0.03)
recovered = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
)
assert timed_out.status_code == 503
assert recovered.status_code == 201
assert recovered.json()["number"] == 17
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for index in range(3):
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": f"Capture {index}"},
headers={"Idempotency-Key": f"capture-177-bounded-{index}"},
)
assert response.status_code == 201
assert len(main._issue_creation_operations) == 2
assert "capture-177-bounded-0" not in main._issue_creation_operations
@pytest.mark.anyio
async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmation():
requests = []
@@ -226,7 +393,7 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
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": [],
"assignees": None, "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
if request.method == "GET" and request.url.path == "/api/v1/user":
@@ -293,6 +460,68 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
}
@pytest.mark.anyio
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
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": "Shared work", "state": "open",
"pull_request": None,
"assignees": [{"login": "timmy"}, {"login": "alex"}],
"labels": [],
})
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": "Shared work", "state": "open",
"assignees": [{"login": "alex"}], "labels": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.release_assigned_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"assignees":["alex"]}'
assert result["assignees"] == ["alex"]
assert result["available"] is False
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
async def release(repository, number):
calls.append((repository, number))
return {
"number": number, "repository": repository, "state": "open",
"assignees": [], "available": True,
}
monkeypatch.setattr(main.gitea_proxy, "release_assigned_issue", release, raising=False)
monkeypatch.setattr(main, "_available_issue_snapshot_value", [{"number": 99}])
monkeypatch.setattr(main, "_available_issue_snapshot_created_at", 123.0)
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/release")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["available"] is True
assert calls == [("stackchain/api", 17)]
assert main._available_issue_snapshot_value is None
assert main._available_issue_snapshot_created_at is None
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
+186 -2
View File
@@ -110,6 +110,51 @@ process.stdout.write(JSON.stringify({{
}
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = 0;
let finish;
const controller = createIssueSheet({{
fetchJson: () => {{
calls += 1;
return new Promise(resolve => {{ finish = resolve; }});
}},
storage: null,
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.release(item, 'timmy');
const duplicate = controller.release(item, 'timmy');
finish({{number:17, repository:'stackchain/api', assignees:[], available:true}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, same: first === duplicate, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["same"] is True
assert output["results"][0]["available"] is True
def test_issue_release_rejects_response_that_still_assigns_current_user():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{
fetchJson: async () => ({{number:17, assignees:['timmy']}}), storage: null,
}});
controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
.then(() => process.stdout.write('unexpected'))
.catch(error => process.stdout.write(error.message));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert result.stdout == "Issue release was not confirmed."
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
@@ -245,6 +290,7 @@ let calls = 0;
let release;
const states = [];
const statuses = [];
const pages = [];
const controller = createFindWork({{
fetchJson: (url, options) => {{
calls += 1;
@@ -254,7 +300,7 @@ const controller = createFindWork({{
}}); }});
}},
onItems: items => states.push(items),
onPagination: () => {{}},
onPagination: page => pages.push(page),
onStatus: status => statuses.push(status),
}});
controller.reset({{items:[
@@ -266,7 +312,7 @@ 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()
calls,states,statuses,pages,results,remaining:controller.items()
}})));
"""
result = subprocess.run(
@@ -278,6 +324,7 @@ Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringi
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["pages"][-1] == {"page": 1, "total": 1, "has_more": False}
assert output["results"][0]["assignees"] == ["timmy"]
assert output["results"][1]["assignees"] == ["timmy"]
@@ -317,6 +364,47 @@ controller.load().then(() => controller.loadMore()).then(() =>
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const states = [];
const controller = createFindWork({{
fetchJson: (url, options) => options?.method === 'PATCH'
? Promise.resolve({{number:7,repository:'stackchain/api',assignees:['timmy']}})
: Promise.resolve({{
items:[
{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}},
{{id:18,number:8,title:'Next',repository:'stackchain/web',body:''}}
],page:2,total:2,has_more:false
}}),
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: () => {{}},
}});
controller.reset({{
items:[{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}}],
page:1,total:2,has_more:true
}});
const target = controller.items()[0];
const opened = controller.togglePreview(target);
controller.loadMore().then(() => {{
const afterPagination = controller.isPreviewed(controller.items()[0]);
return controller.claim(controller.items()[0]).then(() => process.stdout.write(JSON.stringify({{
opened, afterPagination, afterClaim:controller.isPreviewed(target), states
}})));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["opened"] is True
assert output["afterPagination"] is True
assert output["afterClaim"] is False
assert [item["number"] for item in output["states"][-1]] == [8]
@pytest.mark.anyio
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
html = await dashboard()
@@ -342,6 +430,21 @@ async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requests():
html = await dashboard()
assert 'data-preview-index=' in html
assert 'aria-expanded="' in html
assert 'aria-controls="' in html
assert "const detailId = 'find-work-detail-' + index" in html
assert 'class="find-work-detail"' in html
assert "escapeHtml(item.body || 'No description provided.')" in html
assert '>Open in Gitea</a>' in html
assert '.find-work-card a, .find-work-more { min-height:44px;' in html
assert '.find-work-detail { min-width:0;' 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))});
@@ -395,6 +498,53 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"][1]["number"] == 17
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const calls = [];
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
const first = createIssueCapture({{
storage,
createOperationId: () => 'operation-177',
fetchJson: (_url, options) => {{ calls.push(options.headers['Idempotency-Key']); return Promise.reject(new Error('timeout')); }},
}});
first.submit(draft).catch(() => {{
const restored = createIssueCapture({{
storage,
createOperationId: () => 'must-not-replace-operation-177',
fetchJson: (_url, options) => {{
calls.push(options.headers['Idempotency-Key']);
return Promise.resolve({{number:17, title:'Capture work'}});
}},
}});
const before = restored.loadDraft();
restored.submit(before).then(issue => process.stdout.write(JSON.stringify({{
calls, before, after:restored.loadDraft(), number:issue.number
}})));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["operation-177", "operation-177"]
assert output["before"] == {
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
"labelIds": [3],
}
assert output["after"] == {
"repository": "", "title": "", "body": "", "labelIds": []
}
assert output["number"] == 17
def test_issue_capture_loads_repository_labels_with_priorities_first():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
@@ -1116,6 +1266,40 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
assert "e.key === 'Escape' && selectedIssue" in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_releases_assignment_with_confirmed_local_removal():
html = await dashboard()
assert 'id="release-issue"' in html
assert 'Release assignment' in html
assert "window.confirm('Release ' + selectedIssue.key + ' from your My Work?')" in html
assert "issueController.release(selectedIssue, lastContextSnapshot?.user?.login)" in html
assert "buildMyWork.removeIssue(" in html
def test_remove_issue_updates_snapshot_without_mutating_other_work():
payload = {
"issues": [
{"repository": "stackchain/api", "number": 17},
{"repository": "stackchain/web", "number": 18},
],
"pull_requests": [{"repository": "stackchain/api", "number": 17}],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.removeIssue(original, 'stackchain/api', 17);
process.stdout.write(JSON.stringify({{updated, original}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["updated"]["issues"] == [{"repository": "stackchain/web", "number": 18}]
assert output["updated"]["pull_requests"] == payload["pull_requests"]
assert output["original"]["issues"] == payload["issues"]
@pytest.mark.anyio
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
html = await dashboard()