Edit assigned issue content safely from mobile #190
|
|
@ -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 { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
.issue-sheet-header button { min-height:44px; }
|
.issue-sheet-header button { min-height:44px; }
|
||||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
.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 { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
.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>
|
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</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>
|
<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">
|
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||||
<legend>Labels</legend>
|
<legend>Labels</legend>
|
||||||
|
|
@ -355,6 +362,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
<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>
|
<h2>Recent discussion</h2>
|
||||||
<div id="issue-comments"></div>
|
<div id="issue-comments"></div>
|
||||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||||
|
|
@ -589,6 +610,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let selectedUpdate = null;
|
let selectedUpdate = null;
|
||||||
let updateTrigger = null;
|
let updateTrigger = null;
|
||||||
let selectedIssue = null;
|
let selectedIssue = null;
|
||||||
|
let selectedIssueDetail = null;
|
||||||
let issueTrigger = null;
|
let issueTrigger = null;
|
||||||
let selectedPull = null;
|
let selectedPull = null;
|
||||||
let pullTrigger = null;
|
let pullTrigger = null;
|
||||||
|
|
@ -1051,6 +1073,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
async function openIssueSheet(item, trigger) {
|
async function openIssueSheet(item, trigger) {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
selectedIssue = item;
|
selectedIssue = item;
|
||||||
|
selectedIssueDetail = null;
|
||||||
issueTrigger = trigger;
|
issueTrigger = trigger;
|
||||||
qs('#issue-sheet').classList.add('open');
|
qs('#issue-sheet').classList.add('open');
|
||||||
qs('#issue-sheet-key').textContent = item.key || '';
|
qs('#issue-sheet-key').textContent = item.key || '';
|
||||||
|
|
@ -1068,11 +1091,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#retry-issue-load').hidden = true;
|
qs('#retry-issue-load').hidden = true;
|
||||||
qs('#open-issue-gitea').href = item.url || '#';
|
qs('#open-issue-gitea').href = item.url || '#';
|
||||||
qs('#send-issue-comment').disabled = false;
|
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').disabled = false;
|
||||||
qs('#close-issue-sheet').focus();
|
qs('#close-issue-sheet').focus();
|
||||||
try {
|
try {
|
||||||
const detail = await issueController.load(item);
|
const detail = await issueController.load(item);
|
||||||
if (selectedIssue !== item) return;
|
if (selectedIssue !== item) return;
|
||||||
|
selectedIssueDetail = detail;
|
||||||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||||||
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
|
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
|
||||||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
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>';
|
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||||||
|
qs('#edit-issue-content').disabled = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (selectedIssue !== item) return;
|
if (selectedIssue !== item) return;
|
||||||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
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() {
|
function closeIssueSheet() {
|
||||||
qs('#issue-sheet').classList.remove('open');
|
qs('#issue-sheet').classList.remove('open');
|
||||||
selectedIssue = null;
|
selectedIssue = null;
|
||||||
|
selectedIssueDetail = null;
|
||||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1730,6 +1759,74 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#retry-issue-load').addEventListener('click', () => {
|
qs('#retry-issue-load').addEventListener('click', () => {
|
||||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
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 => {
|
qs('#issue-comment').addEventListener('input', event => {
|
||||||
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
let closeRequest = null;
|
let closeRequest = null;
|
||||||
let releaseRequest = null;
|
let releaseRequest = null;
|
||||||
let labelRequest = null;
|
let labelRequest = null;
|
||||||
|
let editRequest = 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);
|
||||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||||
const operationKey = item => draftKey(item) + ':operation';
|
const operationKey = item => draftKey(item) + ':operation';
|
||||||
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||||
|
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load(item) {
|
load(item) {
|
||||||
|
|
@ -31,6 +33,38 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
}
|
}
|
||||||
catch (_error) { /* The textarea remains the fallback. */ }
|
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) {
|
loadLabelDraft(item) {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
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) {
|
function removeIssue(data, repository, number) {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -381,6 +390,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.replaceIssueContent = replaceIssueContent;
|
||||||
buildMyWork.removeIssue = removeIssue;
|
buildMyWork.removeIssue = removeIssue;
|
||||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||||
buildMyWork.countMyWork = countMyWork;
|
buildMyWork.countMyWork = countMyWork;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ class IssueNotAvailableError(ValueError):
|
||||||
"""Raised before assignment when an issue is no longer open and unassigned."""
|
"""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]:
|
def _auth() -> dict[str, str]:
|
||||||
headers: dict[str, str] = {"Accept": "application/json"}
|
headers: dict[str, str] = {"Accept": "application/json"}
|
||||||
if GITEA_TOKEN:
|
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 "",
|
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
|
||||||
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
|
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
|
||||||
"body": issue.get("body", "") if isinstance(issue.get("body"), 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")),
|
"url": _safe_web_url(issue.get("html_url")),
|
||||||
"labels": [
|
"labels": [
|
||||||
label["name"] for label in 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]:
|
async def _current_login_and_target(path: str) -> tuple[str, dict]:
|
||||||
user, target = await asyncio.gather(current_user(), fetch(path))
|
user, target = await asyncio.gather(current_user(), fetch(path))
|
||||||
login = user.get("login") if isinstance(user, dict) else None
|
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()
|
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):
|
class IssueLabelUpdate(BaseModel):
|
||||||
label_ids: list[PositiveInt] = Field(max_length=20)
|
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")
|
@app.get("/api/v1/repos/{owner}/{repo}/labels")
|
||||||
async def repository_labels(owner: str, repo: str):
|
async def repository_labels(owner: str, repo: str):
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,142 @@ import pytest
|
||||||
from src import gitea_proxy, main
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_issue_creation_operations():
|
def clear_issue_creation_operations():
|
||||||
main._issue_creation_operations.clear()
|
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",
|
"title": "Fix mobile flow",
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"body": "Full issue context",
|
"body": "Full issue context",
|
||||||
|
"updated_at": "2026-08-07T09:59:00Z",
|
||||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||||
"labels": [{"name": "P1"}],
|
"labels": [{"name": "P1"}],
|
||||||
"assignees": [{"login": "timmy"}],
|
"assignees": [{"login": "timmy"}],
|
||||||
|
|
@ -771,6 +908,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||||
"title": "Fix mobile flow",
|
"title": "Fix mobile flow",
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"body": "Full issue context",
|
"body": "Full issue context",
|
||||||
|
"updated_at": "2026-08-07T09:59:00Z",
|
||||||
"url": "https://forge.example/stackchain/api/issues/7",
|
"url": "https://forge.example/stackchain/api/issues/7",
|
||||||
"labels": ["P1"],
|
"labels": ["P1"],
|
||||||
"assignees": ["timmy"],
|
"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():
|
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
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."
|
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():
|
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},
|
||||||
|
|
@ -415,6 +494,22 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||||
assert '.load-more-work { min-height:44px;' in html
|
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
|
@pytest.mark.anyio
|
||||||
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
|
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user