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
|
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
|
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
|
create issue comments, close assigned issues, inspect/comment on assigned pull
|
||||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
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>
|
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
<div class="issue-sheet-actions">
|
<div class="issue-sheet-actions">
|
||||||
|
<button id="release-issue" type="button">Release assignment</button>
|
||||||
<button id="close-issue" type="button">Close issue</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>
|
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1718,6 +1719,27 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
button.disabled = false;
|
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 () => {
|
qs('#close-issue').addEventListener('click', async () => {
|
||||||
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
||||||
const closing = selectedIssue;
|
const closing = selectedIssue;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
function createIssueSheet({ fetchJson, storage }) {
|
function createIssueSheet({ fetchJson, storage }) {
|
||||||
let commentRequest = null;
|
let commentRequest = null;
|
||||||
let closeRequest = null;
|
let closeRequest = null;
|
||||||
|
let releaseRequest = null;
|
||||||
let labelRequest = null;
|
let labelRequest = null;
|
||||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||||
'/issues/' + encodeURIComponent(item.number);
|
'/issues/' + encodeURIComponent(item.number);
|
||||||
|
|
@ -61,6 +62,23 @@ function createIssueSheet({ fetchJson, storage }) {
|
||||||
}).finally(() => { closeRequest = null; });
|
}).finally(() => { closeRequest = null; });
|
||||||
return closeRequest;
|
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) {
|
comment(item, body) {
|
||||||
if (commentRequest) return commentRequest;
|
if (commentRequest) return commentRequest;
|
||||||
this.saveDraft(item, body);
|
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) {
|
function summarizeMyWork(items) {
|
||||||
const updates = items.filter((item) => item.has_update).length;
|
const updates = items.filter((item) => item.has_update).length;
|
||||||
const reviews = items.filter((item) => item.is_review).length;
|
const reviews = items.filter((item) => item.is_review).length;
|
||||||
|
|
@ -358,6 +367,7 @@ function countMyWork(items) {
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.filterMyWork = filterMyWork;
|
buildMyWork.filterMyWork = filterMyWork;
|
||||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||||
|
buildMyWork.removeIssue = removeIssue;
|
||||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||||
buildMyWork.countMyWork = countMyWork;
|
buildMyWork.countMyWork = countMyWork;
|
||||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
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:
|
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||||
response = await _get_client().patch(
|
response = await _get_client().patch(
|
||||||
f"/api/v1/repos/{repository}/issues/{number}",
|
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)
|
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")
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||||
repository = f"{owner}/{repo}"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||||
async def assigned(repository, number):
|
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():
|
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||||
items = [
|
items = [
|
||||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
{"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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
|
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user