feat: open delegated filings read-only (Closes #872)
This commit is contained in:
parent
efafaaddd2
commit
0bb76bdc8b
|
|
@ -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 { 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); }
|
.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; }
|
.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 { 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 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; }
|
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
|
|
|
||||||
|
|
@ -1269,7 +1269,7 @@
|
||||||
closeOpenWorkSheets();
|
closeOpenWorkSheets();
|
||||||
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
||||||
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
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);
|
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||||||
},
|
},
|
||||||
onQueue: openWorkQueueRoute,
|
onQueue: openWorkQueueRoute,
|
||||||
|
|
@ -3294,6 +3294,8 @@
|
||||||
|
|
||||||
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
const readOnly = issueController.readOnly(item);
|
||||||
|
qs('#issue-sheet').classList.toggle('read-only', readOnly);
|
||||||
issueDetailPosition.open(workDetailIdentity('issue', item));
|
issueDetailPosition.open(workDetailIdentity('issue', item));
|
||||||
qs('#issue-planning').inert = false;
|
qs('#issue-planning').inert = false;
|
||||||
qs('#issue-handoff').inert = false;
|
qs('#issue-handoff').inert = false;
|
||||||
|
|
@ -3362,9 +3364,12 @@
|
||||||
).join(' ');
|
).join(' ');
|
||||||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
'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('#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;
|
qs('#edit-issue-content').disabled = false;
|
||||||
const dueDraft = issueController.loadDueDateDraft(item);
|
const dueDraft = issueController.loadDueDateDraft(item);
|
||||||
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
|
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
|
||||||
|
|
@ -3978,7 +3983,12 @@
|
||||||
qs('#new-issue').click();
|
qs('#new-issue').click();
|
||||||
},
|
},
|
||||||
onFileAnother:() => 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) {
|
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
||||||
if (navigate && taskOverlayHistory.current() === 'new') {
|
if (navigate && taskOverlayHistory.current() === 'new') {
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
readOnly(item) {
|
||||||
|
return Boolean(item?.is_filed && !item?.is_assigned);
|
||||||
|
},
|
||||||
load(item) {
|
load(item) {
|
||||||
return fetchJson(issuePath(item) + '/detail', {
|
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||||
|
return fetchJson(issuePath(item) + '/detail' + access, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
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) {
|
function positiveInteger(value) {
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
const notificationId = positiveInteger(parts[3]);
|
const notificationId = positiveInteger(parts[3]);
|
||||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
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;
|
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||||
const number = positiveInteger(parts[5]);
|
const number = positiveInteger(parts[5]);
|
||||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||||
|
|
@ -38,11 +38,12 @@
|
||||||
const notificationId = positiveInteger(item.notification_id);
|
const notificationId = positiveInteger(item.notification_id);
|
||||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
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 repository = String(item.repository || '').split('/');
|
||||||
const number = positiveInteger(item.number);
|
const number = positiveInteger(item.number);
|
||||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
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) {
|
function sameRoute(item, route) {
|
||||||
|
|
@ -50,7 +51,8 @@
|
||||||
if (route.kind === 'update') {
|
if (route.kind === 'update') {
|
||||||
return Number(item.notification_id) === route.notification_id;
|
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 &&
|
return itemKind === route.kind && item.repository === route.repository &&
|
||||||
Number(item.number) === route.number;
|
Number(item.number) === route.number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"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/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/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",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
|
|
|
||||||
|
|
@ -2360,19 +2360,22 @@ async def resolve_work_route(
|
||||||
"title": detail.get("title", ""),
|
"title": detail.get("title", ""),
|
||||||
"url": detail.get("url", ""),
|
"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")
|
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(
|
login, target = await _current_login_and_target(
|
||||||
f"repos/{repository}/{target_path}/{number}"
|
f"repos/{repository}/{target_path}/{number}"
|
||||||
)
|
)
|
||||||
assigned = _login_in_users(login, target.get("assignees"))
|
assigned = _login_in_users(login, target.get("assignees"))
|
||||||
requested = _login_in_users(login, target.get("requested_reviewers"))
|
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 = (
|
eligible = (
|
||||||
target.get("state") == "open"
|
target.get("state") == "open"
|
||||||
and (
|
and (
|
||||||
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
|
(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 == "pull" and assigned)
|
||||||
or (kind == "review" and requested)
|
or (kind == "review" and requested)
|
||||||
)
|
)
|
||||||
|
|
@ -2387,6 +2390,11 @@ async def resolve_work_route(
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"url": _safe_web_url(target.get("html_url")),
|
"url": _safe_web_url(target.get("html_url")),
|
||||||
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
|
**({"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:
|
async def pull_requests() -> WorkItems:
|
||||||
assigned, review_requested = await asyncio.gather(
|
assigned, review_requested = await asyncio.gather(
|
||||||
work_page("pull"),
|
work_page("pull"),
|
||||||
|
|
|
||||||
19
src/main.py
19
src/main.py
|
|
@ -3055,7 +3055,7 @@ async def comment_on_global_search_preview(
|
||||||
|
|
||||||
@app.get("/api/v1/work-route")
|
@app.get("/api/v1/work-route")
|
||||||
async def resolve_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_.-]+$"),
|
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
|
||||||
number: int | None = Query(default=None, gt=0),
|
number: int | None = Query(default=None, gt=0),
|
||||||
notification_id: 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")
|
@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}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
async def load_assigned_issue():
|
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")
|
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:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
|
|
|
||||||
|
|
@ -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
|
@pytest.mark.anyio
|
||||||
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
|
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
|
||||||
assert 'createIssueFilingReceipt({' in dashboard
|
assert 'createIssueFilingReceipt({' in dashboard
|
||||||
assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard
|
assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard
|
||||||
assert 'onViewFiled:issue =>' 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 "confirmed.work_reasons = ['created_by_me']" in dashboard
|
||||||
assert "relatedButton:qs('#issue-filing-receipt-related')" in dashboard
|
assert "relatedButton:qs('#issue-filing-receipt-related')" in dashboard
|
||||||
assert 'relatedDraft:issueCapture.buildRelatedDraft(durableDraft)' in dashboard
|
assert 'relatedDraft:issueCapture.buildRelatedDraft(durableDraft)' in dashboard
|
||||||
|
|
|
||||||
|
|
@ -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 routes = require({json.dumps(str(WORK_ROUTE))});
|
||||||
const inputs = [
|
const inputs = [
|
||||||
{{kind:'issue',repository:'stackchain/api',number:17}},
|
{{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:'pull',repository:'stackchain/dashboard',number:42}},
|
||||||
{{kind:'review',repository:'stackchain/dashboard',number:42}},
|
{{kind:'review',repository:'stackchain/dashboard',number:42}},
|
||||||
{{kind:'update',notification_id:913}},
|
{{kind:'update',notification_id:913}},
|
||||||
|
|
@ -1055,12 +1057,16 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert json.loads(result.stdout) == {
|
assert json.loads(result.stdout) == {
|
||||||
"paths": [
|
"paths": [
|
||||||
"#/my-work/issue/stackchain/api/17",
|
"#/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/pull/stackchain/dashboard/42",
|
||||||
"#/my-work/review/stackchain/dashboard/42",
|
"#/my-work/review/stackchain/dashboard/42",
|
||||||
"#/my-work/update/913",
|
"#/my-work/update/913",
|
||||||
],
|
],
|
||||||
"parsed": [
|
"parsed": [
|
||||||
{"kind": "issue", "repository": "stackchain/api", "number": 17},
|
{"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": "pull", "repository": "stackchain/dashboard", "number": 42},
|
||||||
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
|
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
|
||||||
{"kind": "update", "notification_id": 913},
|
{"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():
|
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
|
||||||
script = f"""
|
script = f"""
|
||||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
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 parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
|
||||||
const listeners = {{}};
|
const listeners = {{}};
|
||||||
const location = {{hash:'#/my-work/agenda'}};
|
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"}],
|
["today", {"kind": "queue", "filter": "today"}],
|
||||||
["agenda", {"kind": "queue", "filter": "agenda"}],
|
["agenda", {"kind": "queue", "filter": "agenda"}],
|
||||||
["attention", {"kind": "queue", "filter": "attention"}],
|
["attention", {"kind": "queue", "filter": "attention"}],
|
||||||
|
["filed", {"kind": "queue", "filter": "filed"}],
|
||||||
["updates", {"kind": "queue", "filter": "update"}],
|
["updates", {"kind": "queue", "filter": "update"}],
|
||||||
["later", {"kind": "queue", "filter": "later"}],
|
["later", {"kind": "queue", "filter": "later"}],
|
||||||
["drafts", {"kind": "queue", "filter": "draft"}],
|
["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
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
|
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
|
||||||
html = await dashboard()
|
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():
|
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))});
|
||||||
|
|
|
||||||
|
|
@ -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
|
@pytest.mark.anyio
|
||||||
async def test_resolve_work_route_returns_only_a_requested_review():
|
async def test_resolve_work_route_returns_only_a_requested_review():
|
||||||
requests = []
|
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
|
@pytest.mark.anyio
|
||||||
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
|
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
|
||||||
async def upstream(request):
|
async def upstream(request):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user