- Hand off to teammate
+ Hand off to teammate
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js
index 1292cb3..e5c45b9 100644
--- a/frontend/issue-sheet.js
+++ b/frontend/issue-sheet.js
@@ -23,6 +23,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let closeRequest = null;
let releaseRequest = null;
let handoffRequest = null;
+ let reassignRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
@@ -70,7 +71,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
});
},
loadHandoffCandidates(item) {
- return fetchJson(issuePath(item) + '/handoff-candidates', {
+ const access = item?.is_filed ? '?access=filed' : '';
+ return fetchJson(issuePath(item) + '/handoff-candidates' + access, {
headers: { Accept: 'application/json' },
});
},
@@ -262,6 +264,28 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { handoffRequest = null; });
return handoffRequest;
},
+ reassign(item, recipient) {
+ if (reassignRequest) return reassignRequest;
+ const expectedAssignees = Array.isArray(item?.assignees) ? item.assignees.slice() : [];
+ reassignRequest = fetchJson(issuePath(item) + '/reassign', {
+ method: 'PATCH',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ recipient, expected_assignees: expectedAssignees }),
+ }).then(result => {
+ if (
+ result?.number !== item.number ||
+ result?.recipient !== recipient ||
+ !Array.isArray(result.assignees) ||
+ result.assignees.length !== 1 ||
+ result.assignees[0] !== recipient ||
+ JSON.stringify(result.previous_assignees) !== JSON.stringify(expectedAssignees)
+ ) {
+ throw new Error('Issue reassignment was not confirmed.');
+ }
+ return result;
+ }).finally(() => { reassignRequest = null; });
+ return reassignRequest;
+ },
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index c29a619..a5e3fdf 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -1843,6 +1843,63 @@ async def handoff_assigned_issue(
}
+async def reassign_authored_issue(
+ repository: str, number: int, recipient: str, expected_assignees: list[str]
+) -> dict:
+ login, issue = await _current_login_and_target(
+ f"repos/{repository}/issues/{number}"
+ )
+ author = issue.get("user")
+ author_login = author.get("login") if isinstance(author, dict) else None
+ assignees_value = issue.get("assignees")
+ current_assignees = [
+ item["login"] for item in assignees_value
+ if isinstance(item, dict) and isinstance(item.get("login"), str)
+ ] if isinstance(assignees_value, list) else []
+ if (
+ issue.get("state") != "open"
+ or issue.get("pull_request") is not None
+ or not isinstance(author_login, str)
+ or author_login.casefold() != login.casefold()
+ or not current_assignees
+ or current_assignees != expected_assignees
+ ):
+ raise IssueNotAvailableError("Authored issue delegate changed")
+
+ eligible = {
+ item["login"] for item in await issue_handoff_candidates(repository)
+ }
+ if recipient not in eligible or recipient in current_assignees:
+ raise IssueNotAvailableError("Reassignment recipient is not eligible")
+
+ response = await _get_client().patch(
+ f"/api/v1/repos/{repository}/issues/{number}",
+ headers=_auth(),
+ json={"assignees": [recipient]},
+ )
+ response.raise_for_status()
+ confirmed = response.json()
+ confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
+ confirmed_assignees = [
+ item["login"] for item in confirmed_value
+ if isinstance(item, dict) and isinstance(item.get("login"), str)
+ ] if isinstance(confirmed_value, list) else []
+ if (
+ not isinstance(confirmed, dict)
+ or confirmed.get("number") != number
+ or confirmed_assignees != [recipient]
+ ):
+ raise ValueError("Gitea did not confirm issue reassignment")
+ return {
+ "repository": repository,
+ "number": number,
+ "state": confirmed.get("state", "open"),
+ "assignees": confirmed_assignees,
+ "recipient": recipient,
+ "previous_assignees": expected_assignees,
+ }
+
+
async def pull_handoff_candidates(repository: str) -> list[dict]:
candidates = await issue_handoff_candidates(repository)
return [
diff --git a/src/main.py b/src/main.py
index b2e39a6..d3dfe64 100644
--- a/src/main.py
+++ b/src/main.py
@@ -881,6 +881,22 @@ class IssueHandoff(BaseModel):
)
+class IssueReassignment(IssueHandoff):
+ expected_assignees: list[str] = Field(min_length=1, max_length=10)
+
+ @field_validator("expected_assignees")
+ @classmethod
+ def validate_expected_assignees(cls, values: list[str]) -> list[str]:
+ if any(
+ not isinstance(value, str)
+ or not re.fullmatch(r"[A-Za-z0-9_.-]+", value)
+ or len(value) > 255
+ for value in values
+ ) or len(set(values)) != len(values):
+ raise ValueError("expected assignees must be unique valid logins")
+ return values
+
+
class IssueBlockerUpdate(BaseModel):
repository: str = Field(
min_length=3,
@@ -4369,12 +4385,20 @@ async def release_assigned_issue(
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
async def issue_handoff_candidates(
- owner: str, repo: str, number: int = PathParam(gt=0)
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+ access: Literal["assigned", "filed"] = Query(default="assigned"),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def load_candidates():
- if not await gitea_proxy.is_assigned_issue(repository, number):
+ authorized = await (
+ gitea_proxy.is_authored_issue(repository, number)
+ if access == "filed"
+ else gitea_proxy.is_assigned_issue(repository, number)
+ )
+ if not authorized:
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_handoff_candidates(repository)
@@ -4444,6 +4468,38 @@ async def handoff_assigned_issue(
return JSONResponse(result)
+@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reassign")
+async def reassign_authored_issue(
+ reassignment: IssueReassignment,
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+) -> JSONResponse:
+ repository = f"{owner}/{repo}"
+ try:
+ result = await asyncio.wait_for(
+ gitea_proxy.reassign_authored_issue(
+ repository,
+ number,
+ reassignment.recipient,
+ reassignment.expected_assignees,
+ ),
+ timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
+ )
+ except gitea_proxy.IssueNotAvailableError:
+ return JSONResponse(
+ {"error": "The delegate changed. Reload before reassigning."},
+ status_code=409,
+ )
+ except Exception:
+ return JSONResponse(
+ {"error": "The reassignment could not be confirmed. The current delegate is unchanged; please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result)
+
+
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(
owner: str,
diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py
index ab6615b..1dfb824 100644
--- a/tests/test_issue_api.py
+++ b/tests/test_issue_api.py
@@ -1905,6 +1905,70 @@ async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
]
+@pytest.mark.anyio
+async def test_gitea_reassigns_authored_issue_only_from_reviewed_delegate():
+ requests = []
+
+ async def handler(request):
+ requests.append(request)
+ if request.url.path == "/api/v1/user":
+ return httpx.Response(200, json={"login": "timmy"})
+ if request.url.path == "/api/v1/repos/stackchain/api/issues/17" and request.method == "GET":
+ return httpx.Response(200, json={
+ "number": 17, "state": "open", "user": {"login": "timmy"},
+ "assignees": [{"login": "alex"}],
+ })
+ if request.url.path == "/api/v1/repos/stackchain/api/assignees":
+ return httpx.Response(200, json=[
+ {"login": "timmy", "full_name": "Timmy"},
+ {"login": "alex", "full_name": "Alexander"},
+ {"login": "casey", "full_name": "Casey"},
+ ])
+ assert request.method == "PATCH"
+ assert json.loads(request.content) == {"assignees": ["casey"]}
+ return httpx.Response(200, json={
+ "number": 17, "state": "open", "assignees": [{"login": "casey"}],
+ })
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ result = await gitea_proxy.reassign_authored_issue(
+ "stackchain/api", 17, "casey", ["alex"]
+ )
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert result == {
+ "repository": "stackchain/api", "number": 17, "state": "open",
+ "assignees": ["casey"], "recipient": "casey", "previous_assignees": ["alex"],
+ }
+
+
+@pytest.mark.anyio
+async def test_gitea_reassign_authored_issue_rejects_stale_delegate_without_patch():
+ requests = []
+
+ async def handler(request):
+ requests.append((request.method, request.url.path))
+ if request.url.path == "/api/v1/user":
+ return httpx.Response(200, json={"login": "timmy"})
+ return httpx.Response(200, json={
+ "number": 17, "state": "open", "user": {"login": "timmy"},
+ "assignees": [{"login": "alex-new"}],
+ })
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ with pytest.raises(gitea_proxy.IssueNotAvailableError):
+ await gitea_proxy.reassign_authored_issue(
+ "stackchain/api", 17, "casey", ["alex"]
+ )
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert not any(method == "PATCH" for method, _path in requests)
+
+
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
@@ -1976,6 +2040,72 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk
]
+@pytest.mark.anyio
+async def test_filed_reassignment_endpoints_authorize_author_and_preserve_reviewed_delegate(monkeypatch):
+ calls = []
+
+ async def authored(repository, number):
+ calls.append(("authored", repository, number))
+ return True
+
+ async def candidates(repository):
+ calls.append(("candidates", repository))
+ return [{"login": "alex", "name": "Alexander"}, {"login": "casey", "name": "Casey"}]
+
+ async def reassign(repository, number, recipient, expected_assignees):
+ calls.append(("reassign", repository, number, recipient, expected_assignees))
+ return {
+ "repository": repository, "number": number, "state": "open",
+ "assignees": [recipient], "recipient": recipient,
+ "previous_assignees": expected_assignees,
+ }
+
+ monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored)
+ monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
+ monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ listed = await client.get(
+ "/api/v1/repos/stackchain/api/issues/17/handoff-candidates?access=filed"
+ )
+ transferred = await client.patch(
+ "/api/v1/repos/stackchain/api/issues/17/reassign",
+ json={"recipient": "casey", "expected_assignees": ["alex"]},
+ )
+
+ assert listed.status_code == 200
+ assert listed.json() == [
+ {"login": "alex", "name": "Alexander"},
+ {"login": "casey", "name": "Casey"},
+ ]
+ assert transferred.status_code == 200
+ assert transferred.json()["previous_assignees"] == ["alex"]
+ assert calls == [
+ ("authored", "stackchain/api", 17),
+ ("candidates", "stackchain/api"),
+ ("reassign", "stackchain/api", 17, "casey", ["alex"]),
+ ]
+
+
+@pytest.mark.anyio
+async def test_filed_reassignment_returns_conflict_when_delegate_changed(monkeypatch):
+ async def reassign(_repository, _number, _recipient, _expected_assignees):
+ raise gitea_proxy.IssueNotAvailableError("stale")
+
+ monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign)
+ 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/reassign",
+ json={"recipient": "casey", "expected_assignees": ["alex"]},
+ )
+
+ assert response.status_code == 409
+ assert response.json() == {
+ "error": "The delegate changed. Reload before reassigning."
+ }
+
+
@pytest.mark.anyio
async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch):
calls = []
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index a8ec0d7..b970614 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -1885,6 +1885,23 @@ async def test_mobile_filed_detail_exposes_only_a_withdrawal_action_for_open_del
assert ".issue-sheet-actions button" in css and "min-height:44px" in css
+@pytest.mark.anyio
+async def test_mobile_filed_detail_exposes_change_delegate_flow_for_open_assigned_filing():
+ source = await dashboard()
+ css = (ISSUE_SHEET.parent / "dashboard.css").read_text()
+ html = (ISSUE_SHEET.parent / "index.html").read_text()
+
+ assert "const reassignable = item.is_filed && !item.is_completed && item.state === 'open' &&" in source
+ assert "classList.toggle('filed-reassignable', reassignable)" in source
+ assert "reassignable ? 'Change delegate' : 'Hand off to teammate'" in source
+ assert "issueController.reassign(selectedIssue, recipient)" in source
+ assert "Change delegate from @" in source and " to @" in source
+ assert "Assigned to ' + recipient" in source
+ assert "#issue-sheet.read-only.filed-reassignable #issue-handoff" in css
+ assert ".issue-handoff select, .issue-handoff button { min-height:44px; }" in css
+ assert 'id="issue-handoff-summary"' in html
+
+
def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -1931,6 +1948,57 @@ Promise.all([
]
+def test_issue_sheet_reassigns_filed_delegate_with_reviewed_owner_as_single_flight():
+ script = f"""
+const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+const calls = [];
+let finish;
+const controller = createIssueSheet({{
+ fetchJson: (url, options={{}}) => {{
+ calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
+ if (url.includes('handoff-candidates')) return Promise.resolve([
+ {{login:'alex',name:'Alexander'}}, {{login:'casey',name:'Casey'}}
+ ]);
+ return new Promise(resolve => {{ finish = resolve; }});
+ }}, storage:null,
+}});
+const filed = {{repository:'stackchain/api',number:18,is_filed:true,is_assigned:false,assignees:['alex']}};
+(async () => {{
+ const candidates = await controller.loadHandoffCandidates(filed);
+ const first = controller.reassign(filed, 'casey');
+ const duplicate = controller.reassign(filed, 'casey');
+ finish({{number:18,repository:'stackchain/api',state:'open',assignees:['casey'],recipient:'casey',previous_assignees:['alex']}});
+ const results = await Promise.all([first, duplicate]);
+ process.stdout.write(JSON.stringify({{calls,candidates,same:first===duplicate,result:results[0]}}));
+}})();
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ assert json.loads(result.stdout) == {
+ "calls": [
+ {
+ "url": "api/v1/repos/stackchain/api/issues/18/handoff-candidates?access=filed",
+ "method": "GET", "body": None,
+ },
+ {
+ "url": "api/v1/repos/stackchain/api/issues/18/reassign",
+ "method": "PATCH",
+ "body": {"recipient": "casey", "expected_assignees": ["alex"]},
+ },
+ ],
+ "candidates": [
+ {"login": "alex", "name": "Alexander"},
+ {"login": "casey", "name": "Casey"},
+ ],
+ "same": True,
+ "result": {
+ "number": 18, "repository": "stackchain/api", "state": "open",
+ "assignees": ["casey"], "recipient": "casey", "previous_assignees": ["alex"],
+ },
+ }
+
+
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});