commit
4da3b198d0
|
|
@ -15,7 +15,7 @@ python3 -m pip install -r requirements.txt
|
|||
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
token that can read dashboard data, update the authenticated user's notification
|
||||
threads, create and self-assign issues, discover and claim open unassigned issues,
|
||||
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
||||
create issue comments, close assigned issues, inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
|
|
@ -1718,6 +1719,27 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#release-issue').addEventListener('click', async () => {
|
||||
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
|
||||
const releasing = selectedIssue;
|
||||
const button = qs('#release-issue');
|
||||
button.disabled = true;
|
||||
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
|
||||
try {
|
||||
const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login);
|
||||
lastContextSnapshot = buildMyWork.removeIssue(
|
||||
lastContextSnapshot, releasing.repository, releasing.number
|
||||
);
|
||||
paintMyWork(lastContextSnapshot);
|
||||
closeIssueSheet();
|
||||
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
|
||||
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
|
||||
} catch (error) {
|
||||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#close-issue').addEventListener('click', async () => {
|
||||
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
||||
const closing = selectedIssue;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
function createIssueSheet({ fetchJson, storage }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let labelRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
|
|
@ -61,6 +62,23 @@ function createIssueSheet({ fetchJson, storage }) {
|
|||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
},
|
||||
release(item, currentLogin) {
|
||||
if (releaseRequest) return releaseRequest;
|
||||
releaseRequest = fetchJson(issuePath(item) + '/release', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue release was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { releaseRequest = null; });
|
||||
return releaseRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
|
|
|
|||
|
|
@ -335,6 +335,15 @@ function replaceIssueLabels(data, repository, number, labels) {
|
|||
};
|
||||
}
|
||||
|
||||
function removeIssue(data, repository, number) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).filter(item =>
|
||||
item.repository !== repository || item.number !== number
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMyWork(items) {
|
||||
const updates = items.filter((item) => item.has_update).length;
|
||||
const reviews = items.filter((item) => item.is_review).length;
|
||||
|
|
@ -358,6 +367,7 @@ function countMyWork(items) {
|
|||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
|
|
|
|||
|
|
@ -654,6 +654,53 @@ async def claim_available_issue(repository: str, number: int) -> dict:
|
|||
}
|
||||
|
||||
|
||||
async def release_assigned_issue(repository: str, number: int) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
assignees_value = issue.get("assignees")
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or issue.get("pull_request") is not None
|
||||
or not _login_in_users(login, assignees_value)
|
||||
):
|
||||
raise IssueNotAvailableError("Issue is not assigned to the current user")
|
||||
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
remaining = [
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict)
|
||||
and isinstance(assignee.get("login"), str)
|
||||
and assignee["login"] != login
|
||||
]
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": remaining},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
assignee["login"] for assignee in confirmed_value
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or login in confirmed_assignees
|
||||
or set(confirmed_assignees) != set(remaining)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue release")
|
||||
return {
|
||||
"number": number,
|
||||
"repository": repository,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
"available": not confirmed_assignees,
|
||||
}
|
||||
|
||||
|
||||
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
|
|
|
|||
25
src/main.py
25
src/main.py
|
|
@ -959,6 +959,31 @@ async def claim_available_issue(
|
|||
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)
|
||||
) -> JSONResponse:
|
||||
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.release_assigned_issue(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The assignment could not be released. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
if result.get("available"):
|
||||
_available_issue_snapshot_value = None
|
||||
_available_issue_snapshot_created_at = None
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,68 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
|
||||
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": "Shared work", "state": "open",
|
||||
"pull_request": None,
|
||||
"assignees": [{"login": "timmy"}, {"login": "alex"}],
|
||||
"labels": [],
|
||||
})
|
||||
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": "Shared work", "state": "open",
|
||||
"assignees": [{"login": "alex"}], "labels": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.release_assigned_issue("stackchain/api", 17)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert requests[2].content == b'{"assignees":["alex"]}'
|
||||
assert result["assignees"] == ["alex"]
|
||||
assert result["available"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def release(repository, number):
|
||||
calls.append((repository, number))
|
||||
return {
|
||||
"number": number, "repository": repository, "state": "open",
|
||||
"assignees": [], "available": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "release_assigned_issue", release, raising=False)
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_value", [{"number": 99}])
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_created_at", 123.0)
|
||||
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/release")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["available"] is True
|
||||
assert calls == [("stackchain/api", 17)]
|
||||
assert main._available_issue_snapshot_value is None
|
||||
assert main._available_issue_snapshot_created_at is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
|
|
|
|||
|
|
@ -110,6 +110,51 @@ process.stdout.write(JSON.stringify({{
|
|||
}
|
||||
|
||||
|
||||
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = 0;
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: () => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
storage: null,
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const first = controller.release(item, 'timmy');
|
||||
const duplicate = controller.release(item, 'timmy');
|
||||
finish({{number:17, repository:'stackchain/api', assignees:[], available:true}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, same: first === duplicate, results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["calls"] == 1
|
||||
assert output["same"] is True
|
||||
assert output["results"][0]["available"] is True
|
||||
|
||||
|
||||
def test_issue_release_rejects_response_that_still_assigns_current_user():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: async () => ({{number:17, assignees:['timmy']}}), storage: null,
|
||||
}});
|
||||
controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
|
||||
.then(() => process.stdout.write('unexpected'))
|
||||
.catch(error => process.stdout.write(error.message));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert result.stdout == "Issue release was not confirmed."
|
||||
|
||||
|
||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||
items = [
|
||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||
|
|
@ -1221,6 +1266,40 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
|
|||
assert "e.key === 'Escape' && selectedIssue" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_releases_assignment_with_confirmed_local_removal():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="release-issue"' in html
|
||||
assert 'Release assignment' in html
|
||||
assert "window.confirm('Release ' + selectedIssue.key + ' from your My Work?')" in html
|
||||
assert "issueController.release(selectedIssue, lastContextSnapshot?.user?.login)" in html
|
||||
assert "buildMyWork.removeIssue(" in html
|
||||
|
||||
|
||||
def test_remove_issue_updates_snapshot_without_mutating_other_work():
|
||||
payload = {
|
||||
"issues": [
|
||||
{"repository": "stackchain/api", "number": 17},
|
||||
{"repository": "stackchain/web", "number": 18},
|
||||
],
|
||||
"pull_requests": [{"repository": "stackchain/api", "number": 17}],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = {json.dumps(payload)};
|
||||
const updated = buildMyWork.removeIssue(original, 'stackchain/api', 17);
|
||||
process.stdout.write(JSON.stringify({{updated, original}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["updated"]["issues"] == [{"repository": "stackchain/web", "number": 18}]
|
||||
assert output["updated"]["pull_requests"] == payload["pull_requests"]
|
||||
assert output["original"]["issues"] == payload["issues"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user