feat: reopen and resume closed issues (Closes #547)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-11 06:37:19 +00:00
parent 9c5b100d6a
commit 446281674b
12 changed files with 336 additions and 81 deletions

View File

@ -302,12 +302,14 @@ successful response is JSON containing
to search commands plus issues and pull requests across every repository visible to to search commands plus issues and pull requests across every repository visible to
the configured Gitea token. Remote search starts after two characters, is debounced, the configured Gitea token. Remote search starts after two characters, is debounced,
and keeps local commands usable if Gitea search is unavailable. Selecting a remote and keeps local commands usable if Gitea search is unavailable. Selecting a remote
result opens a mobile-safe, read-only preview without discarding the search query; result opens a mobile-safe preview without discarding the search query. Open
open unassigned issues can be claimed in place and handed into My Work after Gitea unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. Closed work and pull requests remain read-only with a safe confirms the assignment. A closed issue can be reopened, self-assigned, added to
canonical Gitea link. The bounded APIs are available at Today, and resumed through the same capacity-guarded flow; pull requests remain
`GET /api/v1/search?q=<query>&limit=<1-25>` and read-only with a safe canonical Gitea link. The bounded APIs are available at
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token `GET /api/v1/search?q=<query>&limit=<1-25>`,
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
or place it in a tracked configuration file. or place it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the For service monitoring, GET `/healthz` is a liveness check that confirms the

View File

@ -3551,6 +3551,7 @@
const status = qs('#search-preview-status'); const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result'); const claimButton = qs('#claim-search-result');
const startButton = qs('#start-search-result'); const startButton = qs('#start-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness' qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search'; ? 'Back to blockers' : 'Back to search';
if (state.status === 'closed') { if (state.status === 'closed') {
@ -3562,6 +3563,7 @@
claimButton.disabled = false; claimButton.disabled = false;
startButton.hidden = true; startButton.hidden = true;
startButton.disabled = false; startButton.disabled = false;
if (state.status === 'loading') { if (state.status === 'loading') {
searchPreviewDetail = null; searchPreviewDetail = null;
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number; qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
@ -3591,44 +3593,51 @@
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')); claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming'; claimButton.disabled = state.status === 'claiming';
startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' && startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable ||
(detail.claimable || detail.assigned_to_me)); (detail.state === 'open' && (detail.claimable || detail.assigned_to_me))));
startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'; startButton.textContent = detail.reopenable ? 'Reopen & resume' :
startButton.disabled = state.status === 'claiming'; (detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' : startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
status.textContent = state.status === 'reopening' ? 'Reopening…' :
(state.status === 'claiming' ? 'Assigning this issue to you…' :
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' : (state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
(detail.claimable ? 'This issue is open and unassigned.' : (detail.claimable ? 'This issue is open and unassigned.' :
(detail.assigned_to_me ? 'This issue is already in My Work.' : 'Read-only preview.'))); (detail.assigned_to_me ? 'This issue is already in My Work.' :
(detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.')))));
} }
const searchPreview = createSearchPreview({ const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
claim: detail => fetchReviewJson( mutate: (detail, action) => fetchReviewJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/claim', '/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } } { method:'PATCH', headers:{ Accept:'application/json' } }
), ),
onState: renderSearchPreview, onState: renderSearchPreview,
}); });
const searchAssignAndStart = createAssignAndStart({ function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available, available: createAndStart.available,
claim: detail => searchPreview.claim(detail), claim,
start: confirmed => { start: confirmed => {
const claimed = acceptClaimedIssue(confirmed); const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave(); taskOverlayHistory.leave();
refreshMyWorkView(); refreshMyWorkView();
return createAndStart.complete(claimed); return createAndStart.complete(item);
}, },
recover: confirmed => { recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed); const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave(); taskOverlayHistory.leave();
refreshMyWorkView(); refreshMyWorkView();
openRoutedWork(claimed, qs('#open-palette')); openRoutedWork(item, qs('#open-palette'));
}, },
announce: message => { announce: message => {
qs('#search-preview-status').textContent = message; qs('#search-preview-status').textContent = message;
qs('#my-work-action-status').textContent = message; qs('#my-work-action-status').textContent = message;
}, },
}); });
}
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
const mobileSearchViewport = createMobileSearchViewport({ const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'), palette: qs('#cmd-palette'),
results: qs('#cmd-results'), results: qs('#cmd-results'),
@ -3848,12 +3857,14 @@
}); });
qs('#start-search-result').addEventListener('click', async () => { qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail; const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' || if (!detail || detail.kind !== 'issue') return;
(!detail.claimable && !detail.assigned_to_me)) return;
try { try {
if (detail.reopenable) await searchReopenAndStart.run(detail);
else if (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)) {
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me }); await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
}
} catch (error) { } catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.'; qs('#search-preview-status').textContent = error.message + ' Retry.';
} }
}); });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));

View File

@ -272,6 +272,7 @@
<div class="search-preview-primary-actions"> <div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button> <button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button> <button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div> </div>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a> <a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div> </div>

View File

@ -3,10 +3,23 @@
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview; if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview; if (root) root.createSearchPreview = createSearchPreview;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () { })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, claim, onState }) { return function createSearchPreview({ fetchJson, mutate, onState }) {
let generation = 0; let generation = 0;
let current = null; let current = null;
let claimRequest = null; let mutationRequest = null;
function run(action, pending, success, detail) {
if (mutationRequest) return mutationRequest;
onState({ status: pending, item: current, detail });
mutationRequest = mutate(detail, action).then(result => {
onState({ status: success, item: current, detail, result });
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { mutationRequest = null; });
return mutationRequest;
}
return { return {
open(item) { open(item) {
@ -32,16 +45,10 @@
onState({ status: 'closed' }); onState({ status: 'closed' });
}, },
claim(detail) { claim(detail) {
if (claimRequest) return claimRequest; return run('claim', 'claiming', 'claimed', detail);
onState({ status: 'claiming', item: current, detail }); },
claimRequest = claim(detail).then(result => { reopen(detail) {
onState({ status: 'claimed', item: current, detail, result }); return run('reopen', 'reopening', 'reopened', detail);
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { claimRequest = null; });
return claimRequest;
}, },
}; };
}; };

View File

@ -5,19 +5,6 @@ function renderRepoMix(element, data) {
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>'; element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
} }
async function updateRepoMix(element, fetchContext = fetch) {
try {
const response = await fetchContext('api/v1/context', {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('HTTP ' + response.status);
renderRepoMix(element, await response.json());
} catch (error) {
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
}
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
updateRepoMix.renderRepoMix = renderRepoMix; module.exports = renderRepoMix;
module.exports = updateRepoMix;
} }

View File

@ -12,6 +12,9 @@ import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE) SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
COMMONJS_EXPORT_LINE = re.compile(
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$", re.MULTILINE
)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = { FEATURE_SOURCES = {
"issue-capture": ("static/create-issue-sheet.js",), "issue-capture": ("static/create-issue-sheet.js",),
@ -49,6 +52,9 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
path = frontend_dir / source.removeprefix("static/") path = frontend_dir / source.removeprefix("static/")
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n") chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
source = b"".join(chunks) source = b"".join(chunks)
# Node-only export shims support source-level unit tests but are unreachable
# in the browser. Strip the simple one-line form from shipped bundles.
source = COMMONJS_EXPORT_LINE.sub(b"", source)
revision = hashlib.sha256(source).hexdigest() revision = hashlib.sha256(source).hexdigest()
minified = rjsmin.jsmin(source.decode()).encode() minified = rjsmin.jsmin(source.decode()).encode()
return minified + f';"source-sha256:{revision}";'.encode() return minified + f';"source-sha256:{revision}";'.encode()

View File

@ -472,6 +472,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
"assignees": assignee_names, "assignees": assignee_names,
"url": _safe_web_url(issue.get("html_url")), "url": _safe_web_url(issue.get("html_url")),
"claimable": actual_kind == "issue" and state == "open" and not assignee_names, "claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"reopenable": actual_kind == "issue" and state == "closed",
"assigned_to_me": bool(login and login in assignee_names), "assigned_to_me": bool(login and login in assignee_names),
} }
@ -1272,6 +1273,66 @@ async def claim_available_issue(repository: str, number: int) -> dict:
} }
async def reopen_issue(repository: str, number: int) -> dict:
"""Reopen a closed issue, assign it to the current user, and confirm both."""
issue, user = await asyncio.gather(
fetch(f"repos/{repository}/issues/{number}"), current_user()
)
login = user.get("login") if isinstance(user, dict) else None
if (
not isinstance(issue, dict)
or issue.get("number") != number
or issue.get("pull_request") is not None
or not isinstance(login, str)
or not login
):
raise IssueNotAvailableError("Issue cannot be resumed")
if issue.get("state") == "open" and _login_in_users(login, issue.get("assignees")):
confirmed = issue
elif issue.get("state") == "closed":
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"state": "open", "assignee": login},
)
response.raise_for_status()
confirmed = response.json()
else:
raise IssueNotAvailableError("Issue is no longer available to resume")
assignees_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
assignees = assignees_value if isinstance(assignees_value, list) else []
logins = [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
]
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or confirmed.get("state") != "open"
or login not in logins
):
raise ValueError("Gitea did not confirm issue reopening and assignment")
labels_value = confirmed.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
return {
"id": confirmed.get("id"),
"number": number,
"title": confirmed.get("title", "")
if isinstance(confirmed.get("title"), str) else "",
"state": "open",
"repository": repository,
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": logins,
"updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")),
}
async def release_assigned_issue(repository: str, number: int) -> dict: async def release_assigned_issue(repository: str, number: int) -> dict:
login, issue = await _current_login_and_target( login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}" f"repos/{repository}/issues/{number}"

View File

@ -3224,6 +3224,30 @@ async def claim_available_issue(
return JSONResponse(result) return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reopen")
async def reopen_closed_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.reopen_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "This issue is no longer closed or cannot be resumed."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The issue could not be reopened. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release") @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release")
async def release_assigned_issue( async def release_assigned_issue(
owner: str, repo: str, number: int = PathParam(gt=0) owner: str, repo: str, number: int = PathParam(gt=0)

View File

@ -178,7 +178,7 @@ const pending = new Map();
const states = []; const states = [];
const preview = createSearchPreview({{ const preview = createSearchPreview({{
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)), fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
claim: () => Promise.resolve(), mutate: () => Promise.resolve(),
onState: state => states.push(state), onState: state => states.push(state),
}}); }});
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }}); preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
@ -206,7 +206,7 @@ let resolveClaim;
const states = []; const states = [];
const preview = createSearchPreview({{ const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item), fetchJson: item => Promise.resolve(item),
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }}, mutate: (_detail, action) => {{ if (action !== 'claim') throw new Error('wrong action'); claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
onState: state => states.push(state), onState: state => states.push(state),
}}); }});
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }}); await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
@ -223,6 +223,32 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_reopen_is_single_flight_and_reports_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let reopens = 0;
let resolveReopen;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item),
mutate: (_detail, action) => {{ if (action !== 'reopen') throw new Error('wrong action'); reopens += 1; return new Promise(resolve => {{ resolveReopen = resolve; }}); }},
onState: state => states.push(state),
}});
const detail = {{ repository:'stackchain/api', number:42, reopenable:true }};
await preview.open(detail);
const first = preview.reopen(detail);
const second = preview.reopen(detail);
if (reopens !== 1 || first !== second) throw new Error('reopen was not single-flight');
resolveReopen({{ state:'open', assignees:['timmy'] }});
await first;
if (!states.some(state => state.status === 'reopened')) throw new Error('reopen confirmation missing');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_search_selection_opens_native_preview_without_navigation(): def test_remote_search_selection_opens_native_preview_without_navigation():
html = dashboard_bundle_text() html = dashboard_bundle_text()
@ -248,13 +274,31 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
css = (FRONTEND / "dashboard.css").read_text() css = (FRONTEND / "dashboard.css").read_text()
assert 'id="start-search-result"' in html assert 'id="start-search-result"' in html
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
assert "const searchAssignAndStart = createAssignAndStart({" in html assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" in css assert ".search-preview-primary-actions" in css
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
assert "mutate: (detail, action) => fetchReviewJson(" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
)[0]
assert "searchReopenAndStart.run(detail)" in handler
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
"const searchAssignAndStart", 1
)[0]
assert "available: createAndStart.available" in orchestrator
assert "claim," in orchestrator
assert "acceptClaimedIssue(confirmed)" in orchestrator
assert "const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail))" in html
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners(): def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
script = f""" script = f"""
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))}); const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});

View File

@ -229,6 +229,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"assignees": [], "assignees": [],
"url": "https://forge.example/stackchain/api/issues/42", "url": "https://forge.example/stackchain/api/issues/42",
"claimable": True, "claimable": True,
"reopenable": False,
"assigned_to_me": False, "assigned_to_me": False,
} }
@ -255,3 +256,26 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
assert preview["kind"] == "pull" assert preview["kind"] == "pull"
assert preview["claimable"] is False assert preview["claimable"] is False
@pytest.mark.anyio
async def test_work_preview_offers_reopen_only_for_closed_issues():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 42,
"title": "Resume work",
"state": "closed",
"html_url": "https://forge.example/stackchain/api/issues/42",
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
finally:
await gitea_proxy.stop_client()
assert preview["reopenable"] is True
assert preview["claimable"] is False

View File

@ -1094,6 +1094,71 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
assert result["assignees"] == ["timmy"] assert result["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assignment():
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": "Resume work", "state": "closed",
"assignees": [], "pull_request": None, "labels": [{"name": "P1"}],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.reopen_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("GET", "/api/v1/user"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"state":"open","assignee":"timmy"}'
assert result == {
"id": 81, "number": 17, "title": "Resume work", "state": "open",
"repository": "stackchain/api", "labels": ["P1"],
"assignees": ["timmy"], "updated_at": "",
"url": "https://forge.example/stackchain/api/issues/17",
}
@pytest.mark.anyio
async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "open",
"assignees": [{"login": "timmy"}], "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.reopen_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert result["state"] == "open"
assert result["assignees"] == ["timmy"]
assert all(request.method == "GET" for request in requests)
@pytest.mark.anyio @pytest.mark.anyio
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch): async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
calls = [] calls = []
@ -1134,6 +1199,30 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
} }
@pytest.mark.anyio
async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monkeypatch):
calls = []
async def reopen(repository, number):
calls.append((repository, number))
return {
"id": 81, "number": number, "title": "Resume work", "state": "open",
"repository": repository, "labels": ["P1"], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False)
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/reopen")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["state"] == "open"
assert response.json()["assignees"] == ["timmy"]
assert calls == [("stackchain/api", 17)]
@pytest.mark.anyio @pytest.mark.anyio
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers(): async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
requests = [] requests = []

View File

@ -36,21 +36,20 @@ async def test_dashboard_does_not_offer_fabricated_work_when_context_fetch_fails
assert "Agent UI delta binding" not in html assert "Agent UI delta binding" not in html
def test_repo_mix_reports_unavailable_for_failed_context_response(): def test_repo_mix_renders_counts_from_shared_context_snapshot():
script = f""" script = f"""
const updateRepoMix = require({json.dumps(str(WIDGETS))}); const renderRepoMix = require({json.dumps(str(WIDGETS))});
const element = {{ innerHTML: 'stale counts' }}; const element = {{ innerHTML: '' }};
const failedFetch = async () => ({{ renderRepoMix(element, {{
ok: false, repos:[{{}},{{}}],
status: 503, issues:[{{state:'open'}},{{state:'closed'}}],
json: async () => ({{ error: 'upstream unavailable' }}), pull_requests:[{{}},{{}},{{}}],
}}); }});
if (!element.innerHTML.includes('<div class="value">2</div>') ||
updateRepoMix(element, failedFetch).then(() => {{ !element.innerHTML.includes('<div class="value">1</div>') ||
if (!element.innerHTML.includes('Widget unavailable.')) {{ !element.innerHTML.includes('<div class="value">3</div>')) {{
throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`); throw new Error('shared snapshot counts were not rendered: ' + element.innerHTML);
}} }}
}});
""" """
subprocess.run( subprocess.run(