Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87058d72e6 | ||
|
|
e96ff42dff | ||
|
|
b6c7ec83e4 | ||
|
|
b4f984e331 | ||
|
|
b4b13a7db1 | ||
|
|
1947f878e9 | ||
|
|
6f54d5764b | ||
|
|
9293dde1af |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user