feat: review completed mobile Filed outcomes (Closes #878)
This commit is contained in:
parent
eb88355229
commit
8411e907af
|
|
@ -465,6 +465,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
.issue-sheet-header button { min-height:44px; }
|
.issue-sheet-header button { min-height:44px; }
|
||||||
|
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||||
|
.completed-filed-actions[hidden] { display:none; }
|
||||||
|
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||||
|
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); }
|
||||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||||
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
|
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
|
||||||
.issue-blockers h2 { margin-top:0; }
|
.issue-blockers h2 { margin-top:0; }
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,10 @@
|
||||||
let confirmedOwnerLogin = '';
|
let confirmedOwnerLogin = '';
|
||||||
let planningOwnerLogin = '';
|
let planningOwnerLogin = '';
|
||||||
let activeFlushLogin = '';
|
let activeFlushLogin = '';
|
||||||
|
const completedFiledReview = createCompletedFiledReview({
|
||||||
|
storage: localStorage,
|
||||||
|
getLogin() { return planningOwnerLogin; },
|
||||||
|
});
|
||||||
let activeMyWork = [];
|
let activeMyWork = [];
|
||||||
let laterMyWork = [];
|
let laterMyWork = [];
|
||||||
let todayMyWork = [];
|
let todayMyWork = [];
|
||||||
|
|
@ -2511,7 +2515,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintMyWork(data) {
|
function paintMyWork(data) {
|
||||||
lastMyWork = buildMyWork(data);
|
lastMyWork = completedFiledReview.visible(buildMyWork(data));
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2814,7 +2818,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWorkPaginationControls() {
|
function updateWorkPaginationControls() {
|
||||||
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
|
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests' };
|
||||||
const streams = activeWorkStreams();
|
const streams = activeWorkStreams();
|
||||||
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
|
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
|
||||||
const summaries = streams.flatMap(stream => {
|
const summaries = streams.flatMap(stream => {
|
||||||
|
|
@ -3322,6 +3326,13 @@
|
||||||
qs('#issue-sheet-key').textContent = item.key || '';
|
qs('#issue-sheet-key').textContent = item.key || '';
|
||||||
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
||||||
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
||||||
|
const completedItems = lastMyWork.filter(candidate => candidate?.is_completed && candidate?.is_filed);
|
||||||
|
const completedPosition = completedItems.findIndex(candidate =>
|
||||||
|
candidate.repository === item.repository && candidate.number === item.number
|
||||||
|
);
|
||||||
|
qs('#completed-filed-actions').hidden = !item.is_completed;
|
||||||
|
qs('#completed-filed-progress').textContent = item.is_completed ?
|
||||||
|
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
|
||||||
qs('#issue-sheet-body').textContent = '';
|
qs('#issue-sheet-body').textContent = '';
|
||||||
qs('#issue-labels').textContent = '';
|
qs('#issue-labels').textContent = '';
|
||||||
qs('#issue-assignees').textContent = '';
|
qs('#issue-assignees').textContent = '';
|
||||||
|
|
@ -3381,7 +3392,8 @@
|
||||||
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
|
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
|
||||||
else renderIssueConversation(issueConversation.snapshot());
|
else renderIssueConversation(issueConversation.snapshot());
|
||||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||||
qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · follow-up enabled' :
|
qs('#issue-sheet-status').textContent = item.is_completed ?
|
||||||
|
'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' :
|
||||||
'Issue ready · ' + (detail.state || 'open');
|
'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);
|
||||||
|
|
@ -3422,6 +3434,29 @@
|
||||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qs('#acknowledge-completed-filed').addEventListener('click', () => {
|
||||||
|
if (!selectedIssue?.is_completed) return;
|
||||||
|
if (!completedFiledReview.acknowledge(selectedIssue)) {
|
||||||
|
qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const acknowledged = selectedIssue;
|
||||||
|
lastMyWork = completedFiledReview.visible(lastMyWork);
|
||||||
|
closeIssueSheet(false);
|
||||||
|
refreshMyWorkView();
|
||||||
|
const target = filedFollowUpTarget(lastMyWork);
|
||||||
|
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
|
||||||
|
(target ? ' Opening the next Filed item.' : ' Filed review is complete.');
|
||||||
|
if (target) {
|
||||||
|
const index = lastMyWork.indexOf(target.item);
|
||||||
|
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
|
||||||
|
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
|
||||||
|
} else {
|
||||||
|
window.location.hash = '#/my-work/filed';
|
||||||
|
qs('#my-work').focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function renderCheckSection(prefix, detail, offline = false) {
|
function renderCheckSection(prefix, detail, offline = false) {
|
||||||
const rendered = createReviewController.renderChecks(detail?.checks, escapeHtml, { offline });
|
const rendered = createReviewController.renderChecks(detail?.checks, escapeHtml, { offline });
|
||||||
qs('#' + prefix + '-checks-summary').textContent = rendered.summary;
|
qs('#' + prefix + '-checks-summary').textContent = rendered.summary;
|
||||||
|
|
|
||||||
|
|
@ -594,6 +594,10 @@
|
||||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||||
|
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
|
||||||
|
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
|
||||||
|
<button id="acknowledge-completed-filed" type="button">Acknowledge & next</button>
|
||||||
|
</section>
|
||||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||||
|
|
||||||
return {
|
return {
|
||||||
readOnly(item) {
|
readOnly(item) {
|
||||||
return Boolean(item?.is_filed && !item?.is_assigned);
|
return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned));
|
||||||
},
|
},
|
||||||
load(item) {
|
load(item) {
|
||||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||||
|
|
|
||||||
|
|
@ -71,20 +71,23 @@ function buildMyWork(data, now = new Date()) {
|
||||||
const assigned = (item.assignees || []).includes(login);
|
const assigned = (item.assignees || []).includes(login);
|
||||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||||
|
const isCompleted = isFiled && item.state === 'closed';
|
||||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...item,
|
...item,
|
||||||
key: (item.repository || 'unknown') + '#' + item.number,
|
key: (item.repository || 'unknown') + '#' + item.number,
|
||||||
is_review: isReview,
|
is_review: isReview,
|
||||||
is_filed: isFiled,
|
is_filed: isFiled,
|
||||||
|
is_completed: isCompleted,
|
||||||
is_assigned: assigned,
|
is_assigned: assigned,
|
||||||
has_update: false,
|
has_update: false,
|
||||||
...(due ? { due_label: due.label } : {}),
|
...(due ? { due_label: due.label } : {}),
|
||||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||||
(due && due.priority < 4 ? due.label :
|
(due && due.priority < 4 ? due.label :
|
||||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work')))),
|
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
||||||
|
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
|
||||||
_priority: priorityLabel ? 0 :
|
_priority: priorityLabel ? 0 :
|
||||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||||
};
|
};
|
||||||
normalized.needs_attention = needsAttention(normalized);
|
normalized.needs_attention = needsAttention(normalized);
|
||||||
normalized.attention_reason = attentionReason(normalized);
|
normalized.attention_reason = attentionReason(normalized);
|
||||||
|
|
@ -666,6 +669,43 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCompletedFiledReview({
|
||||||
|
storage,
|
||||||
|
getLogin,
|
||||||
|
key = 'stackchain.completed-filed-review.v1',
|
||||||
|
}) {
|
||||||
|
const login = () => String(getLogin?.() || '').trim();
|
||||||
|
const ownerKey = () => key + ':' + login();
|
||||||
|
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
|
||||||
|
const read = () => {
|
||||||
|
if (!login()) return {};
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
|
||||||
|
return value && value.version === 1 && value.items && typeof value.items === 'object' ?
|
||||||
|
value.items : {};
|
||||||
|
} catch (_error) { return {}; }
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
visible(items) {
|
||||||
|
const acknowledged = read();
|
||||||
|
return (items || []).filter(item =>
|
||||||
|
!item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
acknowledge(item) {
|
||||||
|
const stamp = String(item?.updated_at || '');
|
||||||
|
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entries = Object.entries({ ...read(), [identity(item)]: stamp }).slice(-200);
|
||||||
|
try {
|
||||||
|
storage?.setItem(ownerKey(), JSON.stringify({ version:1, items:Object.fromEntries(entries) }));
|
||||||
|
return true;
|
||||||
|
} catch (_error) { return false; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function agendaMyWork(items, now = new Date()) {
|
function agendaMyWork(items, now = new Date()) {
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const today = localDay(start);
|
const today = localDay(start);
|
||||||
|
|
@ -981,6 +1021,7 @@ function countMyWork(items) {
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.filterMyWork = filterMyWork;
|
buildMyWork.filterMyWork = filterMyWork;
|
||||||
|
buildMyWork.createCompletedFiledReview = createCompletedFiledReview;
|
||||||
buildMyWork.agendaMyWork = agendaMyWork;
|
buildMyWork.agendaMyWork = agendaMyWork;
|
||||||
buildMyWork.milestoneLanes = milestoneLanes;
|
buildMyWork.milestoneLanes = milestoneLanes;
|
||||||
buildMyWork.createWorkSession = createWorkSession;
|
buildMyWork.createWorkSession = createWorkSession;
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,8 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||||
"/api/v1/repos/issues/search",
|
"/api/v1/repos/issues/search",
|
||||||
headers=_auth(),
|
headers=_auth(),
|
||||||
params={
|
params={
|
||||||
"state": "open", selector.split("=", 1)[0]: "true",
|
"state": "all" if stream == "filed" else "open",
|
||||||
|
selector.split("=", 1)[0]: "true",
|
||||||
"type": item_type, "limit": limit, "page": page,
|
"type": item_type, "limit": limit, "page": page,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -2371,8 +2372,9 @@ async def resolve_work_route(
|
||||||
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 {}
|
author = target.get("user") if isinstance(target.get("user"), dict) else {}
|
||||||
authored = author.get("login") == login
|
authored = author.get("login") == login
|
||||||
|
state = target.get("state")
|
||||||
eligible = (
|
eligible = (
|
||||||
target.get("state") == "open"
|
(state == "open" or (kind == "filed" and state == "closed"))
|
||||||
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 == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
|
||||||
|
|
@ -2387,7 +2389,7 @@ async def resolve_work_route(
|
||||||
"repository": repository,
|
"repository": repository,
|
||||||
"number": number,
|
"number": number,
|
||||||
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
|
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
|
||||||
"state": "open",
|
"state": state,
|
||||||
"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 {}),
|
||||||
**({
|
**({
|
||||||
|
|
@ -2415,7 +2417,7 @@ async def is_authored_issue(repository: str, number: int) -> bool:
|
||||||
)
|
)
|
||||||
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
||||||
return (
|
return (
|
||||||
issue.get("state") == "open"
|
issue.get("state") in {"open", "closed"}
|
||||||
and not isinstance(issue.get("pull_request"), dict)
|
and not isinstance(issue.get("pull_request"), dict)
|
||||||
and author.get("login") == login
|
and author.get("login") == login
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -67,15 +67,16 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason():
|
async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_distinct_reason():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
||||||
def upstream(request):
|
def upstream(request):
|
||||||
requests.append(str(request.url))
|
requests.append(str(request.url))
|
||||||
return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{
|
return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{
|
||||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
|
||||||
|
"updated_at": "2026-08-15T12:00:00Z",
|
||||||
"repository": {"full_name": "stackchain/dashboard"},
|
"repository": {"full_name": "stackchain/dashboard"},
|
||||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
|
||||||
}])
|
}])
|
||||||
|
|
||||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||||
|
|
@ -85,14 +86,15 @@ async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason
|
||||||
await gitea_proxy.stop_client()
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
assert requests == [
|
assert requests == [
|
||||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2"
|
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=all&created=true&type=issues&limit=50&page=2"
|
||||||
]
|
]
|
||||||
assert result == {
|
assert result == {
|
||||||
"stream": "filed", "page": 2, "total": 61, "has_more": False,
|
"stream": "filed", "page": 2, "total": 61, "has_more": False,
|
||||||
"items": [{
|
"items": [{
|
||||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
|
||||||
|
"updated_at": "2026-08-15T12:00:00Z",
|
||||||
"repository": {"full_name": "stackchain/dashboard"},
|
"repository": {"full_name": "stackchain/dashboard"},
|
||||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
|
||||||
"work_reasons": ["created_by_me"],
|
"work_reasons": ["created_by_me"],
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,98 @@ process.stdout.write(JSON.stringify({{available, results}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_filed_outcomes_are_distinct_and_rank_before_open_filings():
|
||||||
|
payload = {
|
||||||
|
"user": {"login": "timmy"},
|
||||||
|
"issues": [
|
||||||
|
{"number": 10, "title": "Still delegated", "repository": "stackchain/api",
|
||||||
|
"state": "open", "assignees": ["alex"], "work_reasons": ["created_by_me"],
|
||||||
|
"updated_at": "2026-08-15T12:00:00Z"},
|
||||||
|
{"number": 9, "title": "Outcome ready", "repository": "stackchain/api",
|
||||||
|
"state": "closed", "assignees": ["timmy"], "work_reasons": ["created_by_me"],
|
||||||
|
"updated_at": "2026-08-14T12:00:00Z"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
result = json.loads(completed.stdout)
|
||||||
|
assert [item["number"] for item in result] == [9, 10]
|
||||||
|
assert result[0]["is_completed"] is True
|
||||||
|
assert result[0]["reason"] == "Completed · review outcome"
|
||||||
|
assert result[1]["is_completed"] is False
|
||||||
|
assert result[1]["reason"] == "Filed by you"
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem:(key, value) => values.set(key, value),
|
||||||
|
}};
|
||||||
|
const create = buildMyWork.createCompletedFiledReview;
|
||||||
|
if (typeof create !== 'function') {{
|
||||||
|
process.stdout.write(JSON.stringify({{available:false}}));
|
||||||
|
}} else {{
|
||||||
|
const items = [
|
||||||
|
{{kind:'issue', key:'stackchain/api#9', repository:'stackchain/api', number:9,
|
||||||
|
is_filed:true, is_completed:true, updated_at:'2026-08-14T12:00:00Z'}},
|
||||||
|
{{kind:'issue', key:'stackchain/api#10', repository:'stackchain/api', number:10,
|
||||||
|
is_filed:true, is_completed:false, updated_at:'2026-08-15T12:00:00Z'}},
|
||||||
|
];
|
||||||
|
const first = create({{storage, getLogin:() => 'timmy'}});
|
||||||
|
const before = first.visible(items).map(item => item.number);
|
||||||
|
const acknowledged = first.acknowledge(items[0]);
|
||||||
|
const after = first.visible(items).map(item => item.number);
|
||||||
|
const reloaded = create({{storage, getLogin:() => 'timmy'}});
|
||||||
|
const afterRefresh = reloaded.visible(items).map(item => item.number);
|
||||||
|
const updated = reloaded.visible([{{...items[0], updated_at:'2026-08-16T12:00:00Z'}}, items[1]])
|
||||||
|
.map(item => item.number);
|
||||||
|
process.stdout.write(JSON.stringify({{available:true, before, acknowledged, after, afterRefresh, updated}}));
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"available": True,
|
||||||
|
"before": [9, 10],
|
||||||
|
"acknowledged": True,
|
||||||
|
"after": [10],
|
||||||
|
"afterRefresh": [10],
|
||||||
|
"updated": [9, 10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
|
||||||
|
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
||||||
|
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
|
||||||
|
source = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="completed-filed-actions"' in markup
|
||||||
|
assert 'id="acknowledge-completed-filed"' in markup
|
||||||
|
assert 'Acknowledge & next' in markup
|
||||||
|
assert "createCompletedFiledReview({" in source
|
||||||
|
assert "completedFiledReview.visible(buildMyWork(data))" in source
|
||||||
|
assert "filed: 'filed issues'" in source
|
||||||
|
handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
|
||||||
|
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
|
||||||
|
assert "filedFollowUpTarget(lastMyWork)" in handler
|
||||||
|
assert "openRoutedWork" in handler
|
||||||
|
assert ".completed-filed-actions" in css
|
||||||
|
action_rule = css.split(".completed-filed-actions", 1)[1].split("}", 1)[0]
|
||||||
|
assert "position:fixed" in action_rule
|
||||||
|
assert "env(safe-area-inset-bottom)" in action_rule
|
||||||
|
assert "min-height:44px" in css.split(".completed-filed-actions", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
||||||
payload = {
|
payload = {
|
||||||
"user": {"login": "timmy"},
|
"user": {"login": "timmy"},
|
||||||
|
|
@ -1612,6 +1704,7 @@ const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}});
|
const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}});
|
||||||
process.stdout.write(JSON.stringify([
|
process.stdout.write(JSON.stringify([
|
||||||
controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}),
|
controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}),
|
||||||
|
controller.readOnly({{kind:'filed',is_filed:true,is_assigned:true,is_completed:true}}),
|
||||||
controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}),
|
controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}),
|
||||||
controller.readOnly({{kind:'issue',is_assigned:true}}),
|
controller.readOnly({{kind:'issue',is_assigned:true}}),
|
||||||
]));
|
]));
|
||||||
|
|
@ -1619,7 +1712,7 @@ process.stdout.write(JSON.stringify([
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["node", "-e", script], check=True, capture_output=True, text=True
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
)
|
)
|
||||||
assert json.loads(result.stdout) == [True, False, False]
|
assert json.loads(result.stdout) == [True, True, False, False]
|
||||||
|
|
||||||
|
|
||||||
def test_issue_sheet_loads_filed_items_through_read_only_access():
|
def test_issue_sheet_loads_filed_items_through_read_only_access():
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,61 @@ async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_use
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_resolve_work_route_returns_a_completed_issue_filed_by_the_current_user():
|
||||||
|
async def upstream(request):
|
||||||
|
if request.url.path.endswith("/user"):
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"id": 702,
|
||||||
|
"number": 89,
|
||||||
|
"title": "Completed delegation",
|
||||||
|
"state": "closed",
|
||||||
|
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/89",
|
||||||
|
"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", 89, None
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"kind": "filed",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 89,
|
||||||
|
"title": "Completed delegation",
|
||||||
|
"state": "closed",
|
||||||
|
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/89",
|
||||||
|
"is_filed": True,
|
||||||
|
"is_assigned": False,
|
||||||
|
"work_reasons": ["created_by_me"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_completed_filed_issue_remains_authorized_for_detail_and_reply():
|
||||||
|
async def upstream(request):
|
||||||
|
if request.url.path.endswith("/user"):
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"state": "closed",
|
||||||
|
"user": {"login": "timmy"},
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||||
|
try:
|
||||||
|
authorized = await gitea_proxy.is_authored_issue("stackchain/dashboard", 89)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert authorized is True
|
||||||
|
|
||||||
|
|
||||||
@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