Reassign open delegated issues from mobile Filed #896
|
|
@ -539,6 +539,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
#issue-sheet.read-only .detail-defer,
|
||||
#issue-sheet.read-only #issue-blockers,
|
||||
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
||||
#issue-sheet.read-only.filed-reassignable #issue-handoff { display:block; }
|
||||
.filed-claim-actions { position:sticky; bottom:0; z-index:4; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:14px; padding:10px 4px calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #4ade80; }
|
||||
.filed-claim-actions[hidden] { display:none; }
|
||||
.filed-claim-actions button { min-width:0; min-height:44px; }
|
||||
|
|
|
|||
|
|
@ -3383,7 +3383,10 @@
|
|||
if (!item) return;
|
||||
const readOnly = issueController.readOnly(item);
|
||||
const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';
|
||||
const reassignable = item.is_filed && !item.is_completed && item.state === 'open' &&
|
||||
Array.isArray(item.assignees) && item.assignees.length > 0;
|
||||
qs('#issue-sheet').classList.toggle('read-only', readOnly);
|
||||
qs('#issue-sheet').classList.toggle('filed-reassignable', reassignable);
|
||||
issueDetailPosition.open(workDetailIdentity('issue', item));
|
||||
qs('#issue-planning').inert = false;
|
||||
qs('#issue-handoff').inert = false;
|
||||
|
|
@ -3417,12 +3420,15 @@
|
|||
qs('#issue-comment-title').textContent = readOnly ? 'Add follow-up' : 'Add comment';
|
||||
qs('#issue-comment-status').textContent = '';
|
||||
qs('#issue-handoff').open = false;
|
||||
qs('#issue-handoff-summary').textContent = reassignable ? 'Change delegate' : 'Hand off to teammate';
|
||||
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#issue-handoff-recipient').disabled = true;
|
||||
qs('#confirm-issue-handoff').disabled = true;
|
||||
qs('#confirm-issue-handoff').textContent = workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||||
qs('#confirm-issue-handoff').textContent = reassignable ? 'Confirm change' :
|
||||
(workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff');
|
||||
qs('#load-issue-handoff').disabled = false;
|
||||
qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
qs('#issue-handoff-status').textContent = reassignable ?
|
||||
'Load teammates to change the current delegate.' : 'Load teammates to transfer ownership.';
|
||||
qs('#issue-planning').open = false;
|
||||
qs('#retry-issue-planning').hidden = true;
|
||||
qs('#issue-label-list').textContent = '';
|
||||
|
|
@ -5939,22 +5945,24 @@
|
|||
qs('#issue-handoff-status').textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const candidates = await issueController.loadHandoffCandidates(selectedIssue);
|
||||
const currentAssignees = new Set(selectedIssue.is_filed ? (selectedIssue.assignees || []) : []);
|
||||
const availableCandidates = candidates.filter(candidate => !currentAssignees.has(candidate.login));
|
||||
select.textContent = '';
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||||
placeholder.textContent = availableCandidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||||
select.appendChild(placeholder);
|
||||
candidates.forEach(candidate => {
|
||||
availableCandidates.forEach(candidate => {
|
||||
const option = document.createElement('option');
|
||||
option.value = candidate.login;
|
||||
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.disabled = !candidates.length;
|
||||
select.disabled = !availableCandidates.length;
|
||||
qs('#confirm-issue-handoff').disabled = true;
|
||||
qs('#issue-handoff-status').textContent = candidates.length ?
|
||||
qs('#issue-handoff-status').textContent = availableCandidates.length ?
|
||||
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
|
||||
if (candidates.length) select.focus();
|
||||
if (availableCandidates.length) select.focus();
|
||||
} catch (error) {
|
||||
qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
||||
button.disabled = false;
|
||||
|
|
@ -5966,13 +5974,31 @@
|
|||
});
|
||||
qs('#confirm-issue-handoff').addEventListener('click', async () => {
|
||||
const recipient = qs('#issue-handoff-recipient').value;
|
||||
if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return;
|
||||
if (!selectedIssue || !recipient) return;
|
||||
const changingDelegate = selectedIssue.is_filed && !selectedIssue.is_completed &&
|
||||
Array.isArray(selectedIssue.assignees) && selectedIssue.assignees.length > 0;
|
||||
const previousDelegates = changingDelegate ? selectedIssue.assignees.join(', @') : '';
|
||||
const confirmation = changingDelegate ?
|
||||
'Change delegate from @' + previousDelegates + ' to @' + recipient + ' for ' + selectedIssue.key + '?' :
|
||||
'Hand off ' + selectedIssue.key + ' to @' + recipient + '?';
|
||||
if (!window.confirm(confirmation)) return;
|
||||
const handingOff = selectedIssue;
|
||||
const continuingSession = workSession.checkpointed(handingOff);
|
||||
const button = qs('#confirm-issue-handoff');
|
||||
button.disabled = true;
|
||||
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
|
||||
try {
|
||||
if (changingDelegate) {
|
||||
const confirmed = await issueController.reassign(selectedIssue, recipient);
|
||||
Object.assign(handingOff, { assignees:confirmed.assignees, updated_at:confirmed.updated_at || handingOff.updated_at });
|
||||
selectedIssue = handingOff;
|
||||
if (selectedIssueDetail) selectedIssueDetail.assignees = confirmed.assignees;
|
||||
qs('#issue-assignees').textContent = 'Assigned to ' + recipient;
|
||||
qs('#issue-handoff').open = false;
|
||||
qs('#issue-handoff-status').textContent = 'Delegate changed to @' + recipient + '.';
|
||||
refreshMyWorkView({ reconcileSession:false });
|
||||
return;
|
||||
}
|
||||
await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
|
||||
lastContextSnapshot = buildMyWork.removeIssue(
|
||||
lastContextSnapshot, handingOff.repository, handingOff.number
|
||||
|
|
|
|||
|
|
@ -689,7 +689,7 @@
|
|||
</fieldset>
|
||||
</details>
|
||||
<details class="issue-handoff" id="issue-handoff">
|
||||
<summary>Hand off to teammate</summary>
|
||||
<summary id="issue-handoff-summary">Hand off to teammate</summary>
|
||||
<div>
|
||||
<button id="load-issue-handoff" type="button">Choose teammate</button>
|
||||
<label for="issue-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
60
src/main.py
60
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,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class FakeGiteaServer(ThreadingHTTPServer):
|
|||
def __init__(self, address: tuple[str, int]):
|
||||
super().__init__(address, FakeGiteaHandler)
|
||||
self.created_issues: list[dict] = []
|
||||
self.issue_creation_enabled = False
|
||||
|
||||
|
||||
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
|
|
@ -62,6 +63,9 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
|||
if path != "/api/v1/repos/acme/mobile/issues":
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
if not self.server.issue_creation_enabled:
|
||||
self._json(503, {"message": "release journey is still offline"})
|
||||
return
|
||||
self.server.created_issues.append(payload)
|
||||
assignee = payload.get("assignee")
|
||||
self._json(
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
|||
|
||||
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||
failed_responses.clear()
|
||||
fake.issue_creation_enabled = True
|
||||
context.set_offline(False)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(80):
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user