Reprioritize assigned issues from the mobile issue sheet #174
|
|
@ -134,6 +134,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
|
|
@ -315,6 +320,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<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>
|
||||
<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>
|
||||
<div class="issue-label-list" id="issue-label-list"></div>
|
||||
<div class="small" id="issue-label-status" aria-live="polite">Load an issue to edit labels.</div>
|
||||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||
</fieldset>
|
||||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
||||
<h2>Recent discussion</h2>
|
||||
<div id="issue-comments"></div>
|
||||
|
|
@ -947,6 +958,36 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
|
||||
}
|
||||
|
||||
async function loadIssueLabelEditor(item, confirmedNames) {
|
||||
const list = qs('#issue-label-list');
|
||||
const status = qs('#issue-label-status');
|
||||
list.textContent = '';
|
||||
qs('#save-issue-labels').disabled = true;
|
||||
status.textContent = 'Loading labels…';
|
||||
try {
|
||||
const labels = await issueController.loadLabels(item);
|
||||
if (selectedIssue !== item) return;
|
||||
const draftIds = issueController.loadLabelDraft(item);
|
||||
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
|
||||
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
|
||||
);
|
||||
list.innerHTML = labels.map(label =>
|
||||
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
|
||||
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||||
escapeHtml(label.name) + '</span></label>'
|
||||
).join('');
|
||||
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
|
||||
qs('#save-issue-labels').disabled = false;
|
||||
} catch (_error) {
|
||||
status.textContent = 'Labels could not be loaded. Retry by reopening this issue.';
|
||||
}
|
||||
}
|
||||
|
||||
function selectedEditIssueLabelIds() {
|
||||
return Array.from(document.querySelectorAll('input[name="issue-label"]:checked'))
|
||||
.map(input => Number(input.value)).filter(Number.isInteger);
|
||||
}
|
||||
|
||||
async function openIssueSheet(item, trigger) {
|
||||
if (!item) return;
|
||||
selectedIssue = item;
|
||||
|
|
@ -961,6 +1002,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#issue-comments').textContent = '';
|
||||
qs('#issue-comment').value = issueController.loadDraft(item);
|
||||
qs('#issue-comment-status').textContent = '';
|
||||
qs('#issue-label-list').textContent = '';
|
||||
qs('#issue-label-status').textContent = 'Loading labels…';
|
||||
qs('#save-issue-labels').disabled = true;
|
||||
qs('#retry-issue-load').hidden = true;
|
||||
qs('#open-issue-gitea').href = item.url || '#';
|
||||
qs('#send-issue-comment').disabled = false;
|
||||
|
|
@ -974,6 +1018,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||||
).join(' ');
|
||||
loadIssueLabelEditor(item, detail.labels || []);
|
||||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||||
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
|
||||
|
|
@ -1487,6 +1532,29 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#issue-comment').addEventListener('input', event => {
|
||||
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
||||
});
|
||||
qs('#save-issue-labels').addEventListener('click', async () => {
|
||||
if (!selectedIssue || !lastContextSnapshot) return;
|
||||
const editing = selectedIssue;
|
||||
const button = qs('#save-issue-labels');
|
||||
button.disabled = true;
|
||||
qs('#issue-label-status').textContent = 'Saving labels…';
|
||||
try {
|
||||
const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds());
|
||||
lastContextSnapshot = buildMyWork.replaceIssueLabels(
|
||||
lastContextSnapshot, editing.repository, editing.number, confirmed.labels
|
||||
);
|
||||
selectedIssue = { ...editing, labels: confirmed.labels };
|
||||
qs('#issue-labels').innerHTML = confirmed.labels.map(label =>
|
||||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||||
).join(' ');
|
||||
paintMyWork(lastContextSnapshot);
|
||||
qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.';
|
||||
} catch (error) {
|
||||
qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#send-issue-comment').addEventListener('click', async () => {
|
||||
if (!selectedIssue) return;
|
||||
const body = qs('#issue-comment').value.trim();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
function createIssueSheet({ fetchJson, storage }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let labelRequest = 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 labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
|
|
@ -11,6 +13,11 @@ function createIssueSheet({ fetchJson, storage }) {
|
|||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadLabels(item) {
|
||||
return fetchJson(issuePath(item) + '/labels', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
|
|
@ -19,6 +26,30 @@ function createIssueSheet({ fetchJson, storage }) {
|
|||
try { storage?.setItem(draftKey(item), body); }
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
loadLabelDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
return Array.isArray(value) ? value.filter(Number.isInteger) : [];
|
||||
} catch (_error) { return []; }
|
||||
},
|
||||
updateLabels(item, labelIds) {
|
||||
if (labelRequest) return labelRequest;
|
||||
try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); }
|
||||
catch (_error) { /* The checked controls remain the fallback. */ }
|
||||
labelRequest = fetchJson(issuePath(item) + '/labels', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label_ids: labelIds }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || !Array.isArray(result.labels)) {
|
||||
throw new Error('Issue labels were not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(labelDraftKey(item)); }
|
||||
catch (_error) { /* The upstream label set is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { labelRequest = null; });
|
||||
return labelRequest;
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
|
|
|
|||
|
|
@ -326,6 +326,15 @@ function filterMyWork(items, selectedFilter) {
|
|||
return items.filter((item) => item.kind === selectedFilter);
|
||||
}
|
||||
|
||||
function replaceIssueLabels(data, repository, number, labels) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMyWork(items) {
|
||||
const updates = items.filter((item) => item.has_update).length;
|
||||
const reviews = items.filter((item) => item.is_review).length;
|
||||
|
|
@ -348,6 +357,7 @@ function countMyWork(items) {
|
|||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
|
|
|
|||
|
|
@ -508,6 +508,33 @@ async def create_issue(
|
|||
}
|
||||
|
||||
|
||||
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}",
|
||||
headers=_auth(),
|
||||
json={"labels": label_ids},
|
||||
)
|
||||
response.raise_for_status()
|
||||
issue = response.json()
|
||||
if not isinstance(issue, dict) or issue.get("number") != number:
|
||||
raise ValueError("Gitea did not confirm the label update")
|
||||
labels_value = issue.get("labels")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
confirmed_ids = {
|
||||
item["id"] for item in labels
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
||||
}
|
||||
if confirmed_ids != set(label_ids):
|
||||
raise ValueError("Gitea did not confirm the requested label set")
|
||||
return {
|
||||
"number": number,
|
||||
"labels": [
|
||||
item["name"] for item in labels
|
||||
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def issue_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/issues/{number}"
|
||||
issue, comments = await asyncio.gather(
|
||||
|
|
|
|||
62
src/main.py
62
src/main.py
|
|
@ -137,6 +137,10 @@ class IssueCreation(BaseModel):
|
|||
return value.strip()
|
||||
|
||||
|
||||
class IssueLabelUpdate(BaseModel):
|
||||
label_ids: list[PositiveInt] = Field(max_length=20)
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
|
|
@ -995,6 +999,64 @@ async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt
|
|||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
|
||||
async def assigned_issue_label_options(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_labels():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.repo_labels(repository)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Labels could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
|
||||
async def update_assigned_issue_labels(
|
||||
update: IssueLabelUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def update_labels():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
available = await gitea_proxy.repo_labels(repository)
|
||||
valid_ids = {
|
||||
item.get("id") for item in available
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
||||
}
|
||||
if any(label_id not in valid_ids for label_id in update.label_ids):
|
||||
raise HTTPException(status_code=422, detail="Unknown repository label")
|
||||
return await gitea_proxy.update_issue_labels(repository, number, update.label_ids)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(update_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Labels could not be updated. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
|
||||
async def review_detail(owner: str, repo: str, number: int):
|
||||
async def load_requested_review():
|
||||
|
|
|
|||
|
|
@ -479,3 +479,107 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
|||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def labels(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [
|
||||
{"id": 3, "name": "P0", "color": "d73a4a"},
|
||||
{"id": 8, "name": "frontend", "color": "1d76db"},
|
||||
]
|
||||
|
||||
async def update(repository, number, label_ids):
|
||||
calls.append((repository, number, label_ids))
|
||||
return {"number": number, "labels": ["P0", "frontend"]}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_issue_labels", 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/7/labels",
|
||||
json={"label_ids": [3, 8]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"number": 7, "labels": ["P0", "frontend"]}
|
||||
assert calls == [("stackchain/api", 7, [3, 8])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_label_options_do_not_depend_on_repository_listing(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def labels(repository):
|
||||
calls.append(("labels", repository))
|
||||
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/repos/stackchain/api/issues/7/labels")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()[0]["name"] == "P0"
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("labels", "stackchain/api"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_update_issue_labels_patches_ids_and_normalizes_confirmation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"labels": [
|
||||
{"id": 3, "name": "P0"},
|
||||
{"id": 8, "name": "frontend"},
|
||||
],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_issue_labels("stackchain/api", 7, [3, 8])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "PATCH"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7"
|
||||
assert requests[0].content == b'{"labels":[3,8]}'
|
||||
assert result == {"number": 7, "labels": ["P0", "frontend"]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
|
||||
async def handler(_request):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"labels": [{"id": 8, "name": "frontend"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="confirm"):
|
||||
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
|
|
|||
|
|
@ -77,6 +77,38 @@ process.stdout.write(JSON.stringify(queue));
|
|||
assert queue[2]["reason"] == "Assigned to you"
|
||||
|
||||
|
||||
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"issues": [
|
||||
{"number": 1, "title": "Older", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
|
||||
{"number": 2, "title": "Newer", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T12:00:00Z"},
|
||||
],
|
||||
"pull_requests": [],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = {json.dumps(payload)};
|
||||
const updated = buildMyWork.replaceIssueLabels(original, 'stackchain/api', 1, ['P0']);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
titles: buildMyWork(updated).map(item => item.title),
|
||||
labels: updated.issues[0].labels,
|
||||
original: original.issues[0].labels,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"titles": ["Older", "Newer"],
|
||||
"labels": ["P0"],
|
||||
"original": [],
|
||||
}
|
||||
|
||||
|
||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||
items = [
|
||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||
|
|
@ -746,6 +778,30 @@ controller.load({{repository:'stackchain/api', number:7}}).then(detail =>
|
|||
}
|
||||
|
||||
|
||||
def test_issue_sheet_loads_labels_through_assigned_issue_boundary():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let request;
|
||||
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
|
||||
request = {{url, accept:options.headers.Accept}};
|
||||
return [{{id:3, name:'P0'}}];
|
||||
}} }});
|
||||
controller.loadLabels({{repository:'stackchain/api', number:7}}).then(labels =>
|
||||
process.stdout.write(JSON.stringify({{request, labels}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert json.loads(result.stdout) == {
|
||||
"request": {
|
||||
"url": "api/v1/repos/stackchain/api/issues/7/labels",
|
||||
"accept": "application/json",
|
||||
},
|
||||
"labels": [{"id": 3, "name": "P0"}],
|
||||
}
|
||||
|
||||
|
||||
def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
|
@ -785,6 +841,51 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
|||
assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}]
|
||||
|
||||
|
||||
def test_issue_sheet_label_save_is_single_flight_and_keeps_selection_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),
|
||||
}};
|
||||
const calls = [];
|
||||
let release;
|
||||
const controller = createIssueSheet({{
|
||||
storage,
|
||||
fetchJson: (url, options={{}}) => {{
|
||||
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
|
||||
return new Promise(resolve => {{ release = () => resolve({{number:7, labels:['P0']}}); }});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
const first = controller.updateLabels(item, [3]);
|
||||
const duplicate = controller.updateLabels(item, [3]);
|
||||
const during = controller.loadLabelDraft(item);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, during, after:controller.loadLabelDraft(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/7/labels",
|
||||
"method": "PATCH",
|
||||
"body": {"label_ids": [3]},
|
||||
}]
|
||||
assert output["during"] == [3]
|
||||
assert output["after"] == []
|
||||
assert output["results"] == [
|
||||
{"number": 7, "labels": ["P0"]},
|
||||
{"number": 7, "labels": ["P0"]},
|
||||
]
|
||||
|
||||
|
||||
def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
|
@ -920,6 +1021,21 @@ 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_edits_labels_and_repaints_confirmed_priority():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="issue-label-editor"' in html
|
||||
assert 'aria-describedby="issue-label-status"' in html
|
||||
assert 'id="issue-label-list"' in html
|
||||
assert 'id="save-issue-labels"' in html
|
||||
assert '.issue-label-option { min-height:44px;' in html
|
||||
assert 'max-width:100%;' in html
|
||||
assert "issueController.updateLabels(selectedIssue" in html
|
||||
assert "buildMyWork.replaceIssueLabels" in html
|
||||
assert "paintMyWork(lastContextSnapshot)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user