Reopen and resume closed issues from mobile Search #548

Merged
timmy merged 1 commits from timmy/547-reopen-resume into main 2026-08-11 06:39:24 +00:00
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
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
result opens a mobile-safe, read-only preview without discarding the search query;
open 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
canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>` and
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
result opens a mobile-safe preview without discarding the search query. Open
unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. A closed issue can be reopened, self-assigned, added to
Today, and resumed through the same capacity-guarded flow; pull requests remain
read-only with a safe canonical Gitea link. The bounded APIs are available at
`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.
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 claimButton = qs('#claim-search-result');
const startButton = qs('#start-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search';
if (state.status === 'closed') {
@ -3562,6 +3563,7 @@
claimButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
if (state.status === 'loading') {
searchPreviewDetail = null;
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.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start';
startButton.disabled = state.status === 'claiming';
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable ||
(detail.state === 'open' && (detail.claimable || detail.assigned_to_me))));
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
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…' :
(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({
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('/') +
'/issues/' + encodeURIComponent(detail.number) + '/claim',
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
onState: renderSearchPreview,
});
const searchAssignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: detail => searchPreview.claim(detail),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(claimed);
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(claimed, qs('#open-palette'));
},
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available,
claim,
start: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(item);
},
recover: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(item, qs('#open-palette'));
},
announce: message => {
qs('#search-preview-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({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
@ -3848,12 +3857,14 @@
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me)) return;
if (!detail || detail.kind !== 'issue') return;
try {
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
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 });
}
} 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'));

View File

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

View File

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

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>';
}
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) {
updateRepoMix.renderRepoMix = renderRepoMix;
module.exports = updateRepoMix;
module.exports = renderRepoMix;
}

View File

@ -12,6 +12,9 @@ import rjsmin
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"
FEATURE_SOURCES = {
"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/")
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
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()
minified = rjsmin.jsmin(source.decode()).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,
"url": _safe_web_url(issue.get("html_url")),
"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),
}
@ -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:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"

View File

@ -3224,6 +3224,30 @@ async def claim_available_issue(
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")
async def release_assigned_issue(
owner: str, repo: str, number: int = PathParam(gt=0)

View File

@ -178,7 +178,7 @@ const pending = new Map();
const states = [];
const preview = createSearchPreview({{
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
claim: () => Promise.resolve(),
mutate: () => Promise.resolve(),
onState: state => states.push(state),
}});
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
@ -206,7 +206,7 @@ let resolveClaim;
const states = [];
const preview = createSearchPreview({{
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),
}});
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)
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():
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()
assert 'id="start-search-result"' in html
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
assert "const searchAssignAndStart = createAssignAndStart({" in html
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" 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():
script = f"""
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": [],
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
"reopenable": 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["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"]
@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
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
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
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
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
def test_repo_mix_reports_unavailable_for_failed_context_response():
def test_repo_mix_renders_counts_from_shared_context_snapshot():
script = f"""
const updateRepoMix = require({json.dumps(str(WIDGETS))});
const element = {{ innerHTML: 'stale counts' }};
const failedFetch = async () => ({{
ok: false,
status: 503,
json: async () => ({{ error: 'upstream unavailable' }}),
}});
updateRepoMix(element, failedFetch).then(() => {{
if (!element.innerHTML.includes('Widget unavailable.')) {{
throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`);
}}
const renderRepoMix = require({json.dumps(str(WIDGETS))});
const element = {{ innerHTML: '' }};
renderRepoMix(element, {{
repos:[{{}},{{}}],
issues:[{{state:'open'}},{{state:'closed'}}],
pull_requests:[{{}},{{}},{{}}],
}});
if (!element.innerHTML.includes('<div class="value">2</div>') ||
!element.innerHTML.includes('<div class="value">1</div>') ||
!element.innerHTML.includes('<div class="value">3</div>')) {{
throw new Error('shared snapshot counts were not rendered: ' + element.innerHTML);
}}
"""
subprocess.run(