diff --git a/frontend/index.html b/frontend/index.html
index dfd8579..ae24f81 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -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; }
Choose an issue.
+
Recent discussion
@@ -947,6 +958,36 @@ textarea { resize: vertical; min-height: 120px; }
escapeHtml(comment.body || 'No comment body provided.') + '';
}
+ 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 =>
+ ''
+ ).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 =>
'' + escapeHtml(label) + ''
).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 =>
+ '' + escapeHtml(label) + ''
+ ).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();
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js
index 688b59b..93bd21f 100644
--- a/frontend/issue-sheet.js
+++ b/frontend/issue-sheet.js
@@ -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', {
diff --git a/frontend/my-work.js b/frontend/my-work.js
index ebfe436..5b67fd4 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -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;
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 1216cd3..0d46c27 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -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(
diff --git a/src/main.py b/src/main.py
index 61f96bf..4dd8692 100644
--- a/src/main.py
+++ b/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():
diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py
index f4210b6..e246b29 100644
--- a/tests/test_issue_api.py
+++ b/tests/test_issue_api.py
@@ -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()
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 0c14400..3695d90 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -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()