feat: keep delegated filings in My Work (Closes #870)
This commit is contained in:
parent
1fdbba1ca1
commit
a9ffc2d879
|
|
@ -149,7 +149,7 @@
|
||||||
let launchFilterResolved = false;
|
let launchFilterResolved = false;
|
||||||
try {
|
try {
|
||||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||||
if (['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||||
selectedWorkFilter = savedFilter;
|
selectedWorkFilter = savedFilter;
|
||||||
savedWorkFilter = savedFilter;
|
savedWorkFilter = savedFilter;
|
||||||
launchFilterResolved = true;
|
launchFilterResolved = true;
|
||||||
|
|
@ -933,7 +933,7 @@
|
||||||
load: fetchWorkPage,
|
load: fetchWorkPage,
|
||||||
onItems: (stream, items) => {
|
onItems: (stream, items) => {
|
||||||
if (!lastContextSnapshot) return;
|
if (!lastContextSnapshot) return;
|
||||||
if (stream === 'issue') lastContextSnapshot.issues = items;
|
if (stream === 'issue' || stream === 'filed') lastContextSnapshot.issues = items;
|
||||||
else lastContextSnapshot.pull_requests = items;
|
else lastContextSnapshot.pull_requests = items;
|
||||||
paintMyWork(lastContextSnapshot);
|
paintMyWork(lastContextSnapshot);
|
||||||
},
|
},
|
||||||
|
|
@ -2592,10 +2592,11 @@
|
||||||
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
|
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
|
||||||
if (selectedWorkFilter === 'agenda') return ['issue'];
|
if (selectedWorkFilter === 'agenda') return ['issue'];
|
||||||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||||||
|
if (selectedWorkFilter === 'filed') return ['filed'];
|
||||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
if (selectedWorkFilter === 'issue') return ['issue'];
|
||||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
if (selectedWorkFilter === 'pull') return ['pull'];
|
||||||
if (selectedWorkFilter === 'review') return ['review'];
|
if (selectedWorkFilter === 'review') return ['review'];
|
||||||
if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review'];
|
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3966,6 +3967,7 @@
|
||||||
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
|
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
|
||||||
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
|
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
|
||||||
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
|
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
|
||||||
|
filedButton:qs('#issue-filing-receipt-filed'),
|
||||||
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
|
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
|
||||||
fileAnotherButton:qs('#issue-filing-receipt-another'),
|
fileAnotherButton:qs('#issue-filing-receipt-another'),
|
||||||
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
|
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
|
||||||
|
|
@ -3976,6 +3978,7 @@
|
||||||
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'}); },
|
||||||
});
|
});
|
||||||
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
||||||
if (navigate && taskOverlayHistory.current() === 'new') {
|
if (navigate && taskOverlayHistory.current() === 'new') {
|
||||||
|
|
@ -4000,6 +4003,7 @@
|
||||||
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
||||||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||||||
(result.confirmed || []).forEach(confirmed => {
|
(result.confirmed || []).forEach(confirmed => {
|
||||||
|
confirmed.work_reasons = ['created_by_me'];
|
||||||
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||||
});
|
});
|
||||||
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
||||||
|
|
@ -6622,7 +6626,7 @@
|
||||||
if (!stream || !lastContextSnapshot) return;
|
if (!stream || !lastContextSnapshot) return;
|
||||||
const button = qs('#load-more-work');
|
const button = qs('#load-more-work');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
const existing = stream === 'issue' ?
|
const existing = stream === 'issue' || stream === 'filed' ?
|
||||||
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
|
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
|
||||||
try {
|
try {
|
||||||
const loaded = await workPager.loadMore(stream, existing);
|
const loaded = await workPager.loadMore(stream, existing);
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@
|
||||||
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||||
|
<button class="work-filter" data-work-filter="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||||
|
|
@ -433,6 +434,7 @@
|
||||||
<p id="issue-filing-receipt-ownership"></p>
|
<p id="issue-filing-receipt-ownership"></p>
|
||||||
<p id="issue-filing-receipt-status" class="small" aria-live="polite"></p>
|
<p id="issue-filing-receipt-status" class="small" aria-live="polite"></p>
|
||||||
<div class="issue-filing-receipt-actions">
|
<div class="issue-filing-receipt-actions">
|
||||||
|
<button id="issue-filing-receipt-filed" type="button">View in Filed</button>
|
||||||
<a id="issue-filing-receipt-open" class="button-link" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="issue-filing-receipt-open" class="button-link" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
<button id="issue-filing-receipt-share" type="button">Share link</button>
|
<button id="issue-filing-receipt-share" type="button">Share link</button>
|
||||||
<button id="issue-filing-receipt-related" type="button">File related issue</button>
|
<button id="issue-filing-receipt-related" type="button">File related issue</button>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function createIssueFilingReceipt({
|
function createIssueFilingReceipt({
|
||||||
root, heading, key, title, ownership, openLink, shareButton,
|
root, heading, key, title, ownership, openLink, shareButton, filedButton,
|
||||||
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
|
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
|
||||||
onFileRelated = function () {},
|
onFileRelated = function () {},
|
||||||
onFileAnother = function () {},
|
onFileAnother = function () {},
|
||||||
|
onViewFiled = function () {},
|
||||||
}) {
|
}) {
|
||||||
let active = null;
|
let active = null;
|
||||||
let relatedPlan = null;
|
let relatedPlan = null;
|
||||||
|
|
@ -64,6 +65,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
shareButton.addEventListener('click', share);
|
shareButton.addEventListener('click', share);
|
||||||
|
filedButton?.addEventListener('click', () => {
|
||||||
|
if (!active) return;
|
||||||
|
const issue = active;
|
||||||
|
close();
|
||||||
|
onViewFiled(issue);
|
||||||
|
});
|
||||||
root.addEventListener('keydown', event => {
|
root.addEventListener('keydown', event => {
|
||||||
if (event.key !== 'Escape' || root.hidden) return;
|
if (event.key !== 'Escape' || root.hidden) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -70,17 +70,19 @@ 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 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_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' : 'Open work'))),
|
(isReview ? 'Needs your review' : (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 : (assigned ? 4 : 5))),
|
||||||
};
|
};
|
||||||
|
|
@ -651,6 +653,7 @@ function createNotificationReplier({
|
||||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||||
let filtered = items;
|
let filtered = items;
|
||||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||||
|
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||||
|
|
@ -962,6 +965,7 @@ function countMyWork(items) {
|
||||||
return {
|
return {
|
||||||
all: items.length,
|
all: items.length,
|
||||||
attention: items.filter(needsAttention).length,
|
attention: items.filter(needsAttention).length,
|
||||||
|
filed: items.filter((item) => item.is_filed).length,
|
||||||
issue: items.filter((item) => item.kind === 'issue').length,
|
issue: items.filter((item) => item.kind === 'issue').length,
|
||||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||||
review: items.filter((item) => item.is_review).length,
|
review: items.filter((item) => item.is_review).length,
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,7 @@ async def repository_access(repository: str) -> dict | None:
|
||||||
|
|
||||||
WORK_SEARCHES = {
|
WORK_SEARCHES = {
|
||||||
"issue": ("assigned=true", "issues", None),
|
"issue": ("assigned=true", "issues", None),
|
||||||
|
"filed": ("created=true", "issues", "created_by_me"),
|
||||||
"pull": ("assigned=true", "pulls", "assigned_to_me"),
|
"pull": ("assigned=true", "pulls", "assigned_to_me"),
|
||||||
"review": ("review_requested=true", "pulls", "review_requested"),
|
"review": ("review_requested=true", "pulls", "review_requested"),
|
||||||
}
|
}
|
||||||
|
|
@ -738,8 +739,20 @@ def _page_metadata(result: dict) -> dict:
|
||||||
|
|
||||||
|
|
||||||
async def issues() -> WorkItems:
|
async def issues() -> WorkItems:
|
||||||
result = await work_page("issue")
|
assigned, filed = await asyncio.gather(work_page("issue"), work_page("filed"))
|
||||||
return WorkItems(result["items"], {"issue": _page_metadata(result)})
|
merged: dict[int, dict] = {}
|
||||||
|
for result in (assigned, filed):
|
||||||
|
for issue in result["items"]:
|
||||||
|
identity = issue.get("id")
|
||||||
|
if identity not in merged:
|
||||||
|
merged[identity] = {**issue, "work_reasons": []}
|
||||||
|
for reason in issue.get("work_reasons", []):
|
||||||
|
if reason not in merged[identity]["work_reasons"]:
|
||||||
|
merged[identity]["work_reasons"].append(reason)
|
||||||
|
return WorkItems(
|
||||||
|
list(merged.values()),
|
||||||
|
{"issue": _page_metadata(assigned), "filed": _page_metadata(filed)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _safe_web_url(value: Any) -> str:
|
def _safe_web_url(value: Any) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1021,6 +1021,7 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||||
id=i["id"], number=i["number"], title=i["title"], state=i["state"],
|
id=i["id"], number=i["number"], title=i["title"], state=i["state"],
|
||||||
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
||||||
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
|
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
|
||||||
|
work_reasons=[reason for reason in (i.get("work_reasons") or []) if reason == "created_by_me"],
|
||||||
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
||||||
updated_at=i.get("updated_at") or "",
|
updated_at=i.get("updated_at") or "",
|
||||||
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
||||||
|
|
@ -1060,13 +1061,14 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
||||||
if stream == "issue":
|
if stream in {"issue", "filed"}:
|
||||||
return [
|
return [
|
||||||
Issue(
|
Issue(
|
||||||
id=item["id"], number=item["number"], title=item["title"],
|
id=item["id"], number=item["number"], title=item["title"],
|
||||||
state=item["state"],
|
state=item["state"],
|
||||||
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
||||||
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
|
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
|
||||||
|
work_reasons=[reason for reason in (item.get("work_reasons") or []) if reason == "created_by_me"],
|
||||||
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
||||||
updated_at=item.get("updated_at") or "",
|
updated_at=item.get("updated_at") or "",
|
||||||
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
||||||
|
|
@ -3080,7 +3082,7 @@ async def resolve_work_route(
|
||||||
|
|
||||||
@app.get("/api/v1/work/{stream}")
|
@app.get("/api/v1/work/{stream}")
|
||||||
async def paged_work(
|
async def paged_work(
|
||||||
stream: Literal["issue", "pull", "review"],
|
stream: Literal["issue", "filed", "pull", "review"],
|
||||||
page: int = Query(ge=2, le=100),
|
page: int = Query(ge=2, le=100),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class Issue(BaseModel):
|
||||||
state: str
|
state: str
|
||||||
labels: list[str] = []
|
labels: list[str] = []
|
||||||
assignees: list[str] = []
|
assignees: list[str] = []
|
||||||
|
work_reasons: list[str] = []
|
||||||
repository: str = ""
|
repository: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
due_date: str | None = None
|
due_date: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,64 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def upstream(request):
|
||||||
|
requests.append(str(request.url))
|
||||||
|
return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{
|
||||||
|
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||||
|
"repository": {"full_name": "stackchain/dashboard"},
|
||||||
|
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||||
|
}])
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.work_page("filed", page=2)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert requests == [
|
||||||
|
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2"
|
||||||
|
]
|
||||||
|
assert result == {
|
||||||
|
"stream": "filed", "page": 2, "total": 61, "has_more": False,
|
||||||
|
"items": [{
|
||||||
|
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||||
|
"repository": {"full_name": "stackchain/dashboard"},
|
||||||
|
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||||
|
"work_reasons": ["created_by_me"],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
|
||||||
|
async def page(stream, page=1, limit=50):
|
||||||
|
del page, limit
|
||||||
|
common = {
|
||||||
|
"id": 7, "number": 7, "title": "My own filing", "state": "open",
|
||||||
|
"repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7",
|
||||||
|
}
|
||||||
|
if stream == "issue":
|
||||||
|
return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False}
|
||||||
|
return {"items": [{**common, "work_reasons": ["created_by_me"]}, {
|
||||||
|
**common, "id": 8, "number": 8, "title": "Delegated",
|
||||||
|
"work_reasons": ["created_by_me"],
|
||||||
|
}], "page": 1, "total": 2, "has_more": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_proxy, "work_page", page)
|
||||||
|
result = await gitea_proxy.issues()
|
||||||
|
|
||||||
|
assert [item["number"] for item in result] == [7, 8]
|
||||||
|
assert result[0]["work_reasons"] == ["created_by_me"]
|
||||||
|
assert result.pagination == {
|
||||||
|
"issue": {"page": 1, "total": 1, "has_more": False},
|
||||||
|
"filed": {"page": 1, "total": 2, "has_more": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
@ -568,7 +626,7 @@ async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monke
|
||||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||||
async def fake_page(stream, page=1, limit=50):
|
async def fake_page(stream, page=1, limit=50):
|
||||||
assert page == 1
|
assert page == 1
|
||||||
totals = {"issue": 84, "pull": 61, "review": 73}
|
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73}
|
||||||
return {
|
return {
|
||||||
"items": [], "page": 1, "total": totals[stream],
|
"items": [], "page": 1, "total": totals[stream],
|
||||||
"has_more": True, "stream": stream,
|
"has_more": True, "stream": stream,
|
||||||
|
|
@ -580,7 +638,8 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
|
||||||
pulls = await gitea_proxy.pull_requests()
|
pulls = await gitea_proxy.pull_requests()
|
||||||
|
|
||||||
assert assigned_issues.pagination == {
|
assert assigned_issues.pagination == {
|
||||||
"issue": {"page": 1, "total": 84, "has_more": True}
|
"issue": {"page": 1, "total": 84, "has_more": True},
|
||||||
|
"filed": {"page": 1, "total": 62, "has_more": True},
|
||||||
}
|
}
|
||||||
assert pulls.pagination == {
|
assert pulls.pagination == {
|
||||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||||
|
|
@ -602,6 +661,7 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
|
||||||
assert await gitea_proxy.pull_requests() == []
|
assert await gitea_proxy.pull_requests() == []
|
||||||
assert requested_streams == [
|
assert requested_streams == [
|
||||||
("issue", 1, 50),
|
("issue", 1, 50),
|
||||||
|
("filed", 1, 50),
|
||||||
("pull", 1, 50),
|
("pull", 1, 50),
|
||||||
("review", 1, 50),
|
("review", 1, 50),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,24 @@ process.stdout.write(JSON.stringify({{plans,hidden:elements.root.hidden,restored
|
||||||
}], "hidden": True, "restored": 1}
|
}], "hidden": True, "restored": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_in_filed_closes_receipt_and_routes_the_exact_confirmed_issue():
|
||||||
|
script = f"""
|
||||||
|
const createReceipt = require({json.dumps(str(RECEIPT))});
|
||||||
|
const element = () => ({{hidden:true,focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
|
||||||
|
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),filedButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
|
||||||
|
const filed=[];
|
||||||
|
const controller=createReceipt({{...elements,onViewFiled:issue=>filed.push(issue)}});
|
||||||
|
controller.show({{repository:'stackchain/dashboard',number:870,title:'Delegated',assignees:['alex'],url:'https://forge.example/issues/870'}},elements.doneButton);
|
||||||
|
elements.filedButton.click();
|
||||||
|
process.stdout.write(JSON.stringify({{filed,hidden:elements.root.hidden,restored:elements.doneButton.focusCount}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
assert output == {
|
||||||
|
"filed": [{"repository": "stackchain/dashboard", "number": 870, "title": "Delegated", "assignees": ["alex"], "url": "https://forge.example/issues/870"}],
|
||||||
|
"hidden": True, "restored": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
|
def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
|
||||||
index = INDEX.read_text()
|
index = INDEX.read_text()
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
@ -97,6 +115,7 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
|
||||||
assert 'id="issue-filing-receipt" role="dialog" aria-modal="true"' in index
|
assert 'id="issue-filing-receipt" role="dialog" aria-modal="true"' in index
|
||||||
assert 'id="issue-filing-receipt-heading"' in index
|
assert 'id="issue-filing-receipt-heading"' in index
|
||||||
assert 'id="issue-filing-receipt-open"' in index
|
assert 'id="issue-filing-receipt-open"' in index
|
||||||
|
assert 'id="issue-filing-receipt-filed"' in index
|
||||||
assert 'id="issue-filing-receipt-share"' in index
|
assert 'id="issue-filing-receipt-share"' in index
|
||||||
assert 'id="issue-filing-receipt-related"' in index
|
assert 'id="issue-filing-receipt-related"' in index
|
||||||
assert 'id="issue-filing-receipt-another"' in index
|
assert 'id="issue-filing-receipt-another"' in index
|
||||||
|
|
@ -107,6 +126,9 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
|
||||||
assert '.issue-filing-receipt-actions' in css
|
assert '.issue-filing-receipt-actions' in css
|
||||||
assert 'min-height:44px' in css
|
assert 'min-height:44px' in css
|
||||||
assert 'createIssueFilingReceipt({' in dashboard
|
assert 'createIssueFilingReceipt({' in dashboard
|
||||||
|
assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard
|
||||||
|
assert 'onViewFiled:issue =>' 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
|
||||||
assert "issueCapture.saveDraft(plan)" in dashboard
|
assert "issueCapture.saveDraft(plan)" in dashboard
|
||||||
|
|
|
||||||
|
|
@ -3767,7 +3767,7 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(result.stdout) == {
|
assert json.loads(result.stdout) == {
|
||||||
"all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0
|
"all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4927,7 +4927,7 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert output["updates"][1]["kind"] == "update"
|
assert output["updates"][1]["kind"] == "update"
|
||||||
assert output["updates"][1]["update_reason"] == ""
|
assert output["updates"][1]["update_reason"] == ""
|
||||||
assert output["counts"] == {
|
assert output["counts"] == {
|
||||||
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
"all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||||
}
|
}
|
||||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||||
|
|
||||||
|
|
@ -6896,7 +6896,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||||
assert 'data-work-count="review"' in html
|
assert 'data-work-count="review"' in html
|
||||||
assert 'data-work-count="update"' in html
|
assert 'data-work-count="update"' in html
|
||||||
assert 'data-work-count="later"' in html
|
assert 'data-work-count="later"' in html
|
||||||
assert "['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||||
assert 'data-work-count="draft"' in html
|
assert 'data-work-count="draft"' in html
|
||||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||||
|
|
@ -7647,3 +7647,28 @@ first.submit(item, payload).catch(() => {{
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []}
|
assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []}
|
||||||
|
|
||||||
|
def test_filed_queue_contains_authored_issues_and_deduplicates_self_assigned_filings():
|
||||||
|
payload = {
|
||||||
|
"user": {"login": "timmy"},
|
||||||
|
"issues": [
|
||||||
|
{"id": 1, "repository": "stackchain/api", "number": 1, "title": "Delegated", "assignees": ["alex"], "work_reasons": ["created_by_me"]},
|
||||||
|
{"id": 2, "repository": "stackchain/api", "number": 2, "title": "Mine", "assignees": ["timmy"], "work_reasons": ["created_by_me"]},
|
||||||
|
{"id": 3, "repository": "stackchain/api", "number": 3, "title": "Assigned only", "assignees": ["timmy"]},
|
||||||
|
],
|
||||||
|
"pull_requests": [],
|
||||||
|
}
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const items = buildMyWork({json.dumps(payload)});
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
filed: buildMyWork.filterMyWork(items, 'filed').map(item => item.number).sort((a,b) => a-b),
|
||||||
|
count: buildMyWork.countMyWork(items).filed,
|
||||||
|
identities: items.map(item => item.key).sort(),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"filed": [1, 2], "count": 2,
|
||||||
|
"identities": ["stackchain/api#1", "stackchain/api#2", "stackchain/api#3"],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||||
"items": [{
|
"items": [{
|
||||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||||
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
||||||
|
"work_reasons": [],
|
||||||
"updated_at": "2026-08-07T10:00:00Z",
|
"updated_at": "2026-08-07T10:00:00Z",
|
||||||
"due_date": "2026-08-09T23:59:59Z",
|
"due_date": "2026-08-09T23:59:59Z",
|
||||||
"milestone": {"id": 9, "title": "August RC"},
|
"milestone": {"id": 9, "title": "August RC"},
|
||||||
|
|
@ -66,6 +67,29 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk
|
||||||
assert called is False
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch):
|
||||||
|
async def page_loader(stream, page):
|
||||||
|
return {
|
||||||
|
"stream": stream, "page": page, "total": 1, "has_more": False,
|
||||||
|
"items": [{
|
||||||
|
"id": 870, "number": 870, "title": "Delegated", "state": "open",
|
||||||
|
"labels": [], "assignees": [{"login": "alex"}],
|
||||||
|
"work_reasons": ["created_by_me"],
|
||||||
|
"repository": {"full_name": "stackchain/dashboard"},
|
||||||
|
"html_url": "https://forge.example/issues/870",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
|
||||||
|
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/filed?page=2")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["items"][0]["work_reasons"] == ["created_by_me"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
|
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user