feat: edit assigned issue content from mobile (#189)
This commit is contained in:
parent
c0eabbf865
commit
937df5d20b
|
|
@ -131,6 +131,13 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-edit-form textarea { min-height:132px; resize:vertical; }
|
||||
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
|
|
@ -346,7 +353,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Retry loading issue</button>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
|
|
@ -355,6 +362,20 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||
</fieldset>
|
||||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="issue-edit-body">Description
|
||||
<textarea id="issue-edit-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-issue-content" type="submit">Save issue</button>
|
||||
<button id="cancel-issue-content" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<h2>Recent discussion</h2>
|
||||
<div id="issue-comments"></div>
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
|
|
@ -589,6 +610,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
let selectedUpdate = null;
|
||||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let selectedIssueDetail = null;
|
||||
let issueTrigger = null;
|
||||
let selectedPull = null;
|
||||
let pullTrigger = null;
|
||||
|
|
@ -1051,6 +1073,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
async function openIssueSheet(item, trigger) {
|
||||
if (!item) return;
|
||||
selectedIssue = item;
|
||||
selectedIssueDetail = null;
|
||||
issueTrigger = trigger;
|
||||
qs('#issue-sheet').classList.add('open');
|
||||
qs('#issue-sheet-key').textContent = item.key || '';
|
||||
|
|
@ -1068,11 +1091,15 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#retry-issue-load').hidden = true;
|
||||
qs('#open-issue-gitea').href = item.url || '#';
|
||||
qs('#send-issue-comment').disabled = false;
|
||||
qs('#edit-issue-content').disabled = true;
|
||||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#issue-edit-status').textContent = '';
|
||||
qs('#close-issue').disabled = false;
|
||||
qs('#close-issue-sheet').focus();
|
||||
try {
|
||||
const detail = await issueController.load(item);
|
||||
if (selectedIssue !== item) return;
|
||||
selectedIssueDetail = detail;
|
||||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||||
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
|
||||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||||
|
|
@ -1085,6 +1112,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||||
qs('#edit-issue-content').disabled = false;
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||||
|
|
@ -1096,6 +1124,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
function closeIssueSheet() {
|
||||
qs('#issue-sheet').classList.remove('open');
|
||||
selectedIssue = null;
|
||||
selectedIssueDetail = null;
|
||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||
}
|
||||
|
||||
|
|
@ -1730,6 +1759,74 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#retry-issue-load').addEventListener('click', () => {
|
||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||||
});
|
||||
qs('#edit-issue-content').addEventListener('click', () => {
|
||||
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
||||
const draft = issueController.loadEditDraft(selectedIssue) || {
|
||||
title: selectedIssueDetail.title || '',
|
||||
body: selectedIssueDetail.body || '',
|
||||
expectedUpdatedAt: selectedIssueDetail.updated_at,
|
||||
};
|
||||
qs('#issue-edit-title').value = draft.title;
|
||||
qs('#issue-edit-body').value = draft.body;
|
||||
qs('#issue-edit-form').hidden = false;
|
||||
qs('#issue-edit-status').textContent = 'Edit the issue, then save.';
|
||||
qs('#issue-edit-title').focus();
|
||||
});
|
||||
['#issue-edit-title', '#issue-edit-body'].forEach(selector =>
|
||||
qs(selector).addEventListener('input', () => {
|
||||
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
||||
issueController.saveEditDraft(selectedIssue, {
|
||||
title: qs('#issue-edit-title').value,
|
||||
body: qs('#issue-edit-body').value,
|
||||
expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
||||
});
|
||||
})
|
||||
);
|
||||
qs('#cancel-issue-content').addEventListener('click', () => {
|
||||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#edit-issue-content').focus();
|
||||
});
|
||||
qs('#issue-edit-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return;
|
||||
const title = qs('#issue-edit-title').value.trim();
|
||||
const body = qs('#issue-edit-body').value.trim();
|
||||
if (!title) {
|
||||
qs('#issue-edit-status').textContent = 'Add a title before saving.';
|
||||
qs('#issue-edit-title').focus();
|
||||
return;
|
||||
}
|
||||
const editing = selectedIssue;
|
||||
const savedDraft = issueController.loadEditDraft(editing);
|
||||
const draft = {
|
||||
title,
|
||||
body,
|
||||
expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
||||
};
|
||||
const button = qs('#save-issue-content');
|
||||
button.disabled = true;
|
||||
qs('#issue-edit-status').textContent = 'Saving issue…';
|
||||
try {
|
||||
const confirmed = await issueController.updateContent(editing, draft);
|
||||
lastContextSnapshot = buildMyWork.replaceIssueContent(
|
||||
lastContextSnapshot, editing.repository, editing.number, confirmed
|
||||
);
|
||||
selectedIssue = { ...editing, ...confirmed, key: editing.key };
|
||||
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
|
||||
qs('#issue-sheet-title').textContent = confirmed.title;
|
||||
qs('#issue-sheet-body').textContent = confirmed.body || 'No description provided.';
|
||||
paintMyWork(lastContextSnapshot);
|
||||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#issue-sheet-status').textContent = 'Issue saved.';
|
||||
qs('#edit-issue-content').focus();
|
||||
} catch (error) {
|
||||
qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.';
|
||||
qs('#retry-issue-load').hidden = false;
|
||||
qs('#issue-edit-title').focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#issue-comment').addEventListener('input', event => {
|
||||
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
|||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
|
|
@ -31,6 +33,38 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
|||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
||||
typeof value.expectedUpdatedAt === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
saveEditDraft(item, draft) {
|
||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
||||
},
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
expected_updated_at: draft.expectedUpdatedAt,
|
||||
}),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) {
|
||||
throw new Error('Issue update was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(editDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
loadLabelDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
|
|
|
|||
|
|
@ -349,6 +349,15 @@ function replaceIssueLabels(data, repository, number, labels) {
|
|||
};
|
||||
}
|
||||
|
||||
function replaceIssueContent(data, repository, number, content) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, ...content } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function removeIssue(data, repository, number) {
|
||||
return {
|
||||
...data,
|
||||
|
|
@ -381,6 +390,7 @@ function countMyWork(items) {
|
|||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ class IssueNotAvailableError(ValueError):
|
|||
"""Raised before assignment when an issue is no longer open and unassigned."""
|
||||
|
||||
|
||||
class IssueEditConflictError(ValueError):
|
||||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
|
|
@ -751,6 +755,7 @@ async def issue_detail(repository: str, number: int) -> dict:
|
|||
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
|
||||
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
|
||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
|
|
@ -764,6 +769,49 @@ async def issue_detail(repository: str, number: int) -> dict:
|
|||
}
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
if issue.get("updated_at") != expected_updated_at:
|
||||
raise IssueEditConflictError("issue changed upstream")
|
||||
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}",
|
||||
headers=_auth(),
|
||||
json={"title": title, "body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("title") != title
|
||||
or confirmed.get("body", "") != body
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the issue content update")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"updated_at": confirmed.get("updated_at", ""),
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
async def _current_login_and_target(path: str) -> tuple[str, dict]:
|
||||
user, target = await asyncio.gather(current_user(), fetch(path))
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
|
|
|
|||
56
src/main.py
56
src/main.py
|
|
@ -155,6 +155,25 @@ class IssueCreation(BaseModel):
|
|||
return value.strip()
|
||||
|
||||
|
||||
class IssueContentUpdate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
expected_updated_at: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def strip_title(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("title must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def strip_body(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class IssueLabelUpdate(BaseModel):
|
||||
label_ids: list[PositiveInt] = Field(max_length=20)
|
||||
|
||||
|
|
@ -1070,6 +1089,43 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
|||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||
async def update_assigned_issue_content(
|
||||
update: IssueContentUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_assigned_issue(
|
||||
repository,
|
||||
number,
|
||||
update.title,
|
||||
update.body,
|
||||
update.expected_updated_at,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueEditConflictError:
|
||||
return JSONResponse(
|
||||
{"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."},
|
||||
status_code=409,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The issue could not be updated. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/labels")
|
||||
async def repository_labels(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,142 @@ import pytest
|
|||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, title, body, expected_updated_at):
|
||||
calls.append((repository, number, title, body, expected_updated_at))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:01:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "update_assigned_issue", update, raising=False
|
||||
)
|
||||
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/content",
|
||||
json={
|
||||
"title": " Clarified scope ",
|
||||
"body": " Updated acceptance criteria ",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["title"] == "Clarified scope"
|
||||
assert calls == [(
|
||||
"stackchain/api", 17, "Clarified scope", "Updated acceptance criteria",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17,
|
||||
"title": "Changed upstream",
|
||||
"body": "Newer body",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:02:00Z",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"pull_request": None,
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueEditConflictError):
|
||||
await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "My draft", "Draft body",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)
|
||||
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"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Old", "body": "Old body", "state": "open",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"assignees": [{"login": "timmy"}], "pull_request": None,
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Clarified", "body": "New body", "state": "open",
|
||||
"updated_at": "2026-08-07T10:01:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "Clarified", "New body", "2026-08-07T10:00:00Z"
|
||||
)
|
||||
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'{"title":"Clarified","body":"New body"}'
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "title": "Clarified",
|
||||
"body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(monkeypatch):
|
||||
async def update(*_args):
|
||||
raise gitea_proxy.IssueEditConflictError("changed")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue", update)
|
||||
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/content",
|
||||
json={
|
||||
"title": "My draft",
|
||||
"body": "Still safe",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_issue_creation_operations():
|
||||
main._issue_creation_operations.clear()
|
||||
|
|
@ -749,6 +885,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
|||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
|
|
@ -771,6 +908,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
|||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
|
|
|
|||
|
|
@ -110,6 +110,34 @@ process.stdout.write(JSON.stringify({{
|
|||
}
|
||||
|
||||
|
||||
def test_confirmed_issue_content_updates_my_work_without_mutating_snapshot():
|
||||
payload = {
|
||||
"issues": [{"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body"}],
|
||||
"pull_requests": [],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = {json.dumps(payload)};
|
||||
const updated = buildMyWork.replaceIssueContent(
|
||||
original, 'stackchain/api', 17,
|
||||
{{title:'Clarified',body:'New body',updated_at:'2026-08-07T10:01:00Z'}}
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:original.issues[0]}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert json.loads(result.stdout) == {
|
||||
"updated": {
|
||||
"number": 17, "repository": "stackchain/api", "title": "Clarified",
|
||||
"body": "New body", "updated_at": "2026-08-07T10:01:00Z",
|
||||
},
|
||||
"original": {
|
||||
"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
|
@ -155,6 +183,57 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
|
|||
assert result.stdout == "Issue release was not confirmed."
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage,
|
||||
fetchJson:(url, options) => {{
|
||||
calls.push({{url, options}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const draft = {{title:'Clarified scope', body:'Updated body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}};
|
||||
controller.saveEditDraft(item, draft);
|
||||
const first = controller.updateContent(item, draft);
|
||||
const duplicate = controller.updateContent(item, draft);
|
||||
const during = controller.loadEditDraft(item);
|
||||
finish({{repository:'stackchain/api',number:17,title:'Clarified scope',body:'Updated body',updated_at:'2026-08-07T10:01:00Z'}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
||||
same:first === duplicate, during, after:controller.loadEditDraft(item), results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/api/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"title": "Clarified scope", "body": "Updated body",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
}]
|
||||
assert output["same"] is True
|
||||
assert output["during"] == {
|
||||
"title": "Clarified scope", "body": "Updated body",
|
||||
"expectedUpdatedAt": "2026-08-07T10:00:00Z",
|
||||
}
|
||||
assert output["after"] is None
|
||||
assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"
|
||||
|
||||
|
||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||
items = [
|
||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||
|
|
@ -415,6 +494,22 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
|||
assert '.load-more-work { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="edit-issue-content"' in html
|
||||
assert 'id="issue-edit-form"' in html
|
||||
assert 'id="issue-edit-title" type="text" maxlength="255"' in html
|
||||
assert 'id="issue-edit-body" maxlength="10000"' in html
|
||||
assert 'id="save-issue-content"' in html
|
||||
assert 'id="cancel-issue-content"' in html
|
||||
assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
|
||||
assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
|
||||
assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
|
||||
assert 'buildMyWork.replaceIssueContent(' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user