Merge pull request 'Open delegated Filed issues in read-only detail' (#873) from timmy/872-filed-read-only-detail into main
All checks were successful
CI / lint (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
timmy 2026-08-15 05:48:39 +00:00
commit 42f2a077b2
11 changed files with 238 additions and 18 deletions

View File

@ -521,6 +521,15 @@ textarea { resize: vertical; min-height: 120px; }
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
#issue-sheet.read-only .issue-comment-composer,
#issue-sheet.read-only #issue-planning,
#issue-sheet.read-only #issue-handoff,
#issue-sheet.read-only #release-issue,
#issue-sheet.read-only #close-issue,
#issue-sheet.read-only .detail-defer,
#issue-sheet.read-only #issue-blockers,
#issue-sheet.read-only #load-older-issue-comments,
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
.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; }

View File

@ -1269,7 +1269,7 @@
closeOpenWorkSheets();
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
},
onQueue: openWorkQueueRoute,
@ -3294,6 +3294,8 @@
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
const readOnly = issueController.readOnly(item);
qs('#issue-sheet').classList.toggle('read-only', readOnly);
issueDetailPosition.open(workDetailIdentity('issue', item));
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
@ -3362,9 +3364,12 @@
).join(' ');
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
renderIssueConversation(issueConversation.snapshot());
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
else renderIssueConversation(issueConversation.snapshot());
if (readOnly) qs('#load-older-issue-comments').hidden = true;
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · read-only' :
'Issue ready · ' + (detail.state || 'open');
qs('#edit-issue-content').disabled = false;
const dueDraft = issueController.loadDueDateDraft(item);
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
@ -3978,7 +3983,12 @@
qs('#new-issue').click();
},
onFileAnother:() => qs('#new-issue').click(),
onViewFiled:issue => { selectMobileQueue('filed'); openRoutedWork({...issue, kind:'issue'}); },
onViewFiled:issue => {
selectMobileQueue('filed');
openRoutedWork({
...issue, kind:'issue', is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin),
});
},
});
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
if (navigate && taskOverlayHistory.current() === 'new') {

View File

@ -38,8 +38,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
readOnly(item) {
return Boolean(item?.is_filed && !item?.is_assigned);
},
load(item) {
return fetchJson(issuePath(item) + '/detail', {
const access = this.readOnly(item) ? '?access=filed' : '';
return fetchJson(issuePath(item) + '/detail' + access, {
headers: { Accept: 'application/json' },
});
},

View File

@ -6,7 +6,7 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
function positiveInteger(value) {
const number = Number(value);
@ -27,7 +27,7 @@
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
}
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
const number = positiveInteger(parts[5]);
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
@ -38,11 +38,12 @@
const notificationId = positiveInteger(item.notification_id);
return notificationId ? '#/my-work/update/' + notificationId : '';
}
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
const repository = String(item.repository || '').split('/');
const number = positiveInteger(item.number);
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
}
function sameRoute(item, route) {
@ -50,7 +51,8 @@
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
const itemKind = item.is_review ? 'review' : item.kind;
const itemKind = item.is_review ? 'review' :
(item.is_filed && !item.is_assigned ? 'filed' : item.kind);
return itemKind === route.kind && item.repository === route.repository &&
Number(item.number) === route.number;
}

View File

@ -30,7 +30,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",

View File

@ -2360,19 +2360,22 @@ async def resolve_work_route(
"title": detail.get("title", ""),
"url": detail.get("url", ""),
}
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
if repository is None or number is None or kind not in {"issue", "filed", "pull", "review"}:
raise WorkRouteUnavailableError("Work identity is missing")
target_path = "issues" if kind == "issue" else "pulls"
target_path = "issues" if kind in {"issue", "filed"} else "pulls"
login, target = await _current_login_and_target(
f"repos/{repository}/{target_path}/{number}"
)
assigned = _login_in_users(login, target.get("assignees"))
requested = _login_in_users(login, target.get("requested_reviewers"))
author = target.get("user") if isinstance(target.get("user"), dict) else {}
authored = author.get("login") == login
eligible = (
target.get("state") == "open"
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
or (kind == "pull" and assigned)
or (kind == "review" and requested)
)
@ -2387,6 +2390,11 @@ async def resolve_work_route(
"state": "open",
"url": _safe_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
**({
"is_filed": True,
"is_assigned": assigned,
"work_reasons": ["created_by_me"],
} if kind == "filed" else {}),
}
@ -2401,6 +2409,18 @@ async def is_assigned_issue(repository: str, number: int) -> bool:
)
async def is_authored_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
return (
issue.get("state") == "open"
and not isinstance(issue.get("pull_request"), dict)
and author.get("login") == login
)
async def pull_requests() -> WorkItems:
assigned, review_requested = await asyncio.gather(
work_page("pull"),

View File

@ -3055,7 +3055,7 @@ async def comment_on_global_search_preview(
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
kind: Literal["issue", "filed", "pull", "review", "update"] = Query(),
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
number: int | None = Query(default=None, gt=0),
notification_id: int | None = Query(default=None, gt=0),
@ -4387,13 +4387,24 @@ async def handoff_assigned_issue(
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
async def assigned_issue_detail(
owner: str,
repo: str,
number: int = PathParam(gt=0),
access: Literal["assigned", "filed"] = Query(default="assigned"),
):
repository = f"{owner}/{repo}"
async def load_assigned_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
authorized = await (
gitea_proxy.is_authored_issue(repository, number)
if access == "filed"
else gitea_proxy.is_assigned_issue(repository, number)
)
if not authorized:
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_detail(repository, number)
detail = await gitea_proxy.issue_detail(repository, number)
return {**detail, "read_only": True} if access == "filed" else detail
try:
return await asyncio.wait_for(

View File

@ -2005,6 +2005,40 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
}
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_authored_filing_in_read_only_mode(monkeypatch):
calls = []
async def authored(repository, number):
calls.append(("authored", repository, number))
return (repository, number) == ("stackchain/api", 8)
async def detail(repository, number):
calls.append(("detail", repository, number))
return {"repository": repository, "number": number, "title": "Delegated filing"}
monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
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/8/detail?access=filed"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"repository": "stackchain/api",
"number": 8,
"title": "Delegated filing",
"read_only": True,
}
assert calls == [
("authored", "stackchain/api", 8),
("detail", "stackchain/api", 8),
]
@pytest.mark.anyio
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
calls = []

View File

@ -128,6 +128,7 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
assert 'createIssueFilingReceipt({' in dashboard
assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard
assert 'onViewFiled:issue =>' in dashboard
assert "is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin)" in dashboard
assert "confirmed.work_reasons = ['created_by_me']" in dashboard
assert "relatedButton:qs('#issue-filing-receipt-related')" in dashboard
assert 'relatedDraft:issueCapture.buildRelatedDraft(durableDraft)' in dashboard

View File

@ -1033,6 +1033,8 @@ def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
const routes = require({json.dumps(str(WORK_ROUTE))});
const inputs = [
{{kind:'issue',repository:'stackchain/api',number:17}},
{{kind:'filed',repository:'stackchain/api',number:18}},
{{kind:'issue',repository:'stackchain/api',number:19,is_filed:true,is_assigned:false}},
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
{{kind:'review',repository:'stackchain/dashboard',number:42}},
{{kind:'update',notification_id:913}},
@ -1055,12 +1057,16 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"paths": [
"#/my-work/issue/stackchain/api/17",
"#/my-work/filed/stackchain/api/18",
"#/my-work/filed/stackchain/api/19",
"#/my-work/pull/stackchain/dashboard/42",
"#/my-work/review/stackchain/dashboard/42",
"#/my-work/update/913",
],
"parsed": [
{"kind": "issue", "repository": "stackchain/api", "number": 17},
{"kind": "filed", "repository": "stackchain/api", "number": 18},
{"kind": "filed", "repository": "stackchain/api", "number": 19},
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
{"kind": "update", "notification_id": 913},
@ -1158,7 +1164,7 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.p
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const canonical = ['today', 'agenda', 'attention', 'updates', 'later', 'drafts'];
const canonical = ['today', 'agenda', 'attention', 'filed', 'updates', 'later', 'drafts'];
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
const listeners = {{}};
const location = {{hash:'#/my-work/agenda'}};
@ -1194,6 +1200,7 @@ process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
["today", {"kind": "queue", "filter": "today"}],
["agenda", {"kind": "queue", "filter": "agenda"}],
["attention", {"kind": "queue", "filter": "attention"}],
["filed", {"kind": "queue", "filter": "filed"}],
["updates", {"kind": "queue", "filter": "update"}],
["later", {"kind": "queue", "filter": "later"}],
["drafts", {"kind": "queue", "filter": "draft"}],
@ -1320,6 +1327,24 @@ const calls = [];
}
@pytest.mark.anyio
async def test_dashboard_opens_delegated_filings_as_read_only_issue_sheets():
html = await dashboard()
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
assert "const readOnly = issueController.readOnly(item);" in html
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
assert "#issue-sheet.read-only .issue-comment-composer" in html
assert "#issue-sheet.read-only #issue-planning" in html
assert "#issue-sheet.read-only #issue-handoff" in html
assert "#issue-sheet.read-only #release-issue" in html
assert "#issue-sheet.read-only #close-issue" in html
assert "#issue-sheet.read-only .detail-defer" in html
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
assert "qs('#load-older-issue-comments').hidden = true;" in html
assert "readOnly ? 'Filed issue ready · read-only'" in html
@pytest.mark.anyio
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
html = await dashboard()
@ -1549,6 +1574,43 @@ process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:origina
}
def test_issue_sheet_identifies_delegated_filings_as_read_only():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}});
process.stdout.write(JSON.stringify([
controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}),
controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}),
controller.readOnly({{kind:'issue',is_assigned:true}}),
]));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [True, False, False]
def test_issue_sheet_loads_filed_items_through_read_only_access():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const urls = [];
const controller = createIssueSheet({{
fetchJson: async url => {{ urls.push(url); return {{}}; }}, storage:null,
}});
Promise.all([
controller.load({{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}}),
controller.load({{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}),
]).then(() => process.stdout.write(JSON.stringify(urls)));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [
"api/v1/repos/stackchain/api/issues/17/detail",
"api/v1/repos/stackchain/api/issues/18/detail?access=filed",
]
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});

View File

@ -39,6 +39,33 @@ async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch):
}
@pytest.mark.anyio
async def test_work_route_endpoint_accepts_filed_issue_identity(monkeypatch):
requested = []
async def resolve(kind, repository, number, notification_id):
requested.append((kind, repository, number, notification_id))
return {
"kind": "filed",
"repository": repository,
"number": number,
"title": "Delegated filing",
"is_filed": True,
"is_assigned": False,
}
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/work-route?kind=filed&repository=stackchain/dashboard&number=88"
)
assert response.status_code == 200
assert requested == [("filed", "stackchain/dashboard", 88, None)]
assert response.json()["kind"] == "filed"
@pytest.mark.anyio
async def test_resolve_work_route_returns_only_a_requested_review():
requests = []
@ -80,6 +107,46 @@ async def test_resolve_work_route_returns_only_a_requested_review():
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user():
requests = []
async def upstream(request):
requests.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 701,
"number": 88,
"title": "Delegated filing",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88",
"assignees": [{"login": "alex"}],
"user": {"login": "timmy"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"filed", "stackchain/dashboard", 88, None
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert result == {
"kind": "filed",
"repository": "stackchain/dashboard",
"number": 88,
"title": "Delegated filing",
"state": "open",
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88",
"is_filed": True,
"is_assigned": False,
"work_reasons": ["created_by_me"],
}
@pytest.mark.anyio
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
async def upstream(request):