feat: reopen and resume closed issues (Closes #547)
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 committed 2026-08-11 06:37:19 +00:00
1 parent 9c5b100d6a
commit 446281674b
12 files changed
+336 -81

No files matched your search

+6
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()
+61
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}"
+24
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)