Surface authored pull requests in My Work #1347
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@
|
|||
let launchFilterResolved = false;
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
if (['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
selectedWorkFilter = savedFilter;
|
||||
savedWorkFilter = savedFilter;
|
||||
launchFilterResolved = true;
|
||||
|
|
@ -3498,10 +3498,10 @@
|
|||
if (selectedWorkFilter === 'agenda') return ['issue'];
|
||||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'filed') return ['filed'];
|
||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
||||
if (selectedWorkFilter === 'review') return ['review'];
|
||||
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
|
||||
if (['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)) {
|
||||
return [selectedWorkFilter];
|
||||
}
|
||||
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review', 'authored'];
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -3747,7 +3747,7 @@
|
|||
}
|
||||
|
||||
function updateWorkPaginationControls() {
|
||||
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests' };
|
||||
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests', authored: 'pull requests' };
|
||||
const streams = activeWorkStreams();
|
||||
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
|
||||
const summaries = streams.flatMap(stream => {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@
|
|||
<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="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
|
||||
<button class="work-filter" data-work-filter="authored" aria-pressed="false">My PRs <span data-work-count="authored">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="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
|
|
@ -2085,6 +2086,7 @@
|
|||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ function buildMyWork(data, now = new Date()) {
|
|||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const isAuthored = (item.work_reasons || []).includes('authored_by_me');
|
||||
const isCompleted = isFiled && item.state === 'closed';
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
|
|
@ -78,6 +79,7 @@ function buildMyWork(data, now = new Date()) {
|
|||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_authored: isAuthored,
|
||||
is_completed: isCompleted,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
|
|
@ -85,7 +87,8 @@ function buildMyWork(data, now = new Date()) {
|
|||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' :
|
||||
(isAuthored ? 'Authored by you' : 'Open work')))))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||
};
|
||||
|
|
@ -657,6 +660,7 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
|||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||
else if (selectedFilter === 'authored') filtered = items.filter((item) => item.is_authored);
|
||||
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 !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
|
|
@ -1080,6 +1084,7 @@ function countMyWork(items) {
|
|||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
filed: items.filter((item) => item.is_filed).length,
|
||||
authored: items.filter((item) => item.is_authored).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft', 'following'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
|
|
|
|||
|
|
@ -370,6 +370,7 @@ WORK_SEARCHES = {
|
|||
"filed": ("created=true", "issues", "created_by_me"),
|
||||
"pull": ("assigned=true", "pulls", "assigned_to_me"),
|
||||
"review": ("review_requested=true", "pulls", "review_requested"),
|
||||
"authored": ("created=true", "pulls", "authored_by_me"),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2768,29 +2769,34 @@ async def is_open_authored_issue(repository: str, number: int) -> bool:
|
|||
|
||||
|
||||
async def pull_requests() -> WorkItems:
|
||||
assigned, review_requested = await asyncio.gather(
|
||||
work_page("pull"),
|
||||
work_page("review"),
|
||||
streams = ("pull", "review", "authored")
|
||||
outcomes = await asyncio.gather(
|
||||
*(work_page(stream) for stream in streams),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, asyncio.CancelledError):
|
||||
raise outcome
|
||||
if all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
raise outcomes[0]
|
||||
|
||||
merged: dict[int, dict] = {}
|
||||
for result in (
|
||||
assigned,
|
||||
review_requested,
|
||||
):
|
||||
for pull in result["items"]:
|
||||
pagination = {}
|
||||
for stream, outcome in zip(streams, outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
pagination[stream] = {
|
||||
"page": 1, "total": 0, "has_more": False, "unavailable": True,
|
||||
}
|
||||
continue
|
||||
pagination[stream] = _page_metadata(outcome)
|
||||
for pull in outcome["items"]:
|
||||
identity = pull.get("id")
|
||||
if identity not in merged:
|
||||
merged[identity] = {**pull, "work_reasons": []}
|
||||
for reason in pull.get("work_reasons", []):
|
||||
if reason not in merged[identity]["work_reasons"]:
|
||||
merged[identity]["work_reasons"].append(reason)
|
||||
return WorkItems(
|
||||
list(merged.values()),
|
||||
{
|
||||
"pull": _page_metadata(assigned),
|
||||
"review": _page_metadata(review_requested),
|
||||
},
|
||||
)
|
||||
return WorkItems(list(merged.values()), pagination)
|
||||
|
||||
|
||||
async def is_requested_review(repository: str, number: int) -> bool:
|
||||
|
|
|
|||
10
src/main.py
10
src/main.py
|
|
@ -1329,7 +1329,7 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
|||
user=p["user"].get("login", "") if isinstance(p.get("user"), dict) else "",
|
||||
labels=[label.get("name", "") for label in (p.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (p.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested")],
|
||||
work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested", "authored_by_me")],
|
||||
repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "",
|
||||
updated_at=p.get("updated_at") or "", url=p["html_url"],
|
||||
)
|
||||
|
|
@ -1370,7 +1370,11 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
|||
for item in items
|
||||
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
reason = {
|
||||
"pull": "assigned_to_me",
|
||||
"review": "review_requested",
|
||||
"authored": "authored_by_me",
|
||||
}[stream]
|
||||
return [
|
||||
PullRequest(
|
||||
id=item["id"], number=item["number"], title=item["title"],
|
||||
|
|
@ -4156,7 +4160,7 @@ async def resolve_work_route(
|
|||
|
||||
@app.get("/api/v1/work/{stream}")
|
||||
async def paged_work(
|
||||
stream: Literal["issue", "filed", "pull", "review"],
|
||||
stream: Literal["issue", "filed", "pull", "review", "authored"],
|
||||
page: int = Query(ge=2, le=100),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
|
|
|
|||
89
tests/e2e/test_mobile_authored_pull_queue_release.py
Normal file
89
tests/e2e/test_mobile_authored_pull_queue_release.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile My PRs journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "my-work.js")
|
||||
page.add_script_tag(path=FRONTEND / "work-route.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
|
||||
|
||||
row = page.locator('[data-mobile-queue="authored"]')
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_contain_text("My PRs")
|
||||
expect(row).to_contain_text("Pull requests you authored")
|
||||
assert row.bounding_box()["height"] >= 44
|
||||
|
||||
result = page.evaluate("""() => {
|
||||
const work = buildMyWork({
|
||||
user:{login:'timmy'},
|
||||
pull_requests:[{
|
||||
number:41,title:'Ship the authored mobile flow',repository:'stackchain/dashboard',
|
||||
state:'open',user:'timmy',assignees:[],work_reasons:['authored_by_me'],
|
||||
url:'https://forge.example/git/stackchain/dashboard/pulls/41'
|
||||
}]
|
||||
});
|
||||
const authored = filterMyWork(work, 'authored');
|
||||
let selected = '';
|
||||
let route = '';
|
||||
const action = document.createElement('button');
|
||||
action.className = 'my-work-card-main';
|
||||
action.addEventListener('click', () => { route = createWorkRoute.serialize(authored[0]); });
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:name => { selected = name; },
|
||||
firstAction:() => action,
|
||||
announce:() => {},
|
||||
});
|
||||
const opened = launcher.open('authored');
|
||||
return {selected, route, opened, count:authored.length};
|
||||
}""")
|
||||
assert result == {
|
||||
"selected": "authored",
|
||||
"route": "#/my-work/pull/stackchain/dashboard/41",
|
||||
"opened": "opened",
|
||||
"count": 1,
|
||||
}
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_empty_authored_queue_announces_the_standard_empty_message():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
|
||||
message = page.evaluate("""() => {
|
||||
let announcement = '';
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:() => {}, firstAction:() => null,
|
||||
announce:value => { announcement = value; },
|
||||
});
|
||||
launcher.open('authored');
|
||||
return announcement;
|
||||
}""")
|
||||
assert message == "No work is ready to open."
|
||||
browser.close()
|
||||
|
|
@ -93,3 +93,10 @@ def test_browser_job_gates_latest_pull_review_mobile_journeys():
|
|||
|
||||
assert "tests/e2e/test_mobile_address_review_feedback_release.py" in browser
|
||||
assert "tests/e2e/test_mobile_cancel_pull_review_request_release.py" in browser
|
||||
|
||||
|
||||
def test_browser_job_gates_authored_pull_queue_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser
|
||||
|
|
|
|||
|
|
@ -100,6 +100,32 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_page_loads_open_created_pulls_with_a_distinct_reason():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, headers={"X-Total-Count": "3"}, json=[{
|
||||
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
|
||||
"user": {"login": "timmy"},
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/pulls/41",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("authored", page=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=pulls&limit=50&page=1"
|
||||
]
|
||||
assert result["total"] == 3
|
||||
assert result["items"][0]["work_reasons"] == ["authored_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):
|
||||
|
|
@ -628,7 +654,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 fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73}
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73, "authored": 12}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
|
|
@ -646,6 +672,7 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
|
|||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
"review": {"page": 1, "total": 73, "has_more": True},
|
||||
"authored": {"page": 1, "total": 12, "has_more": True},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -666,11 +693,12 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
|
|||
("filed", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
("authored", 1, 50),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch):
|
||||
async def test_pull_requests_merge_assignment_review_and_authored_responsibilities(monkeypatch):
|
||||
assigned = {
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
|
|
@ -683,10 +711,24 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
|||
"title": "Review mobile",
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
}
|
||||
authored_only = {
|
||||
"id": 13,
|
||||
"number": 9,
|
||||
"title": "Ship authored flow",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
}
|
||||
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
items = {
|
||||
"pull": [assigned],
|
||||
"review": [assigned.copy(), review_only],
|
||||
"authored": [assigned.copy(), authored_only],
|
||||
}[stream]
|
||||
reason = {
|
||||
"pull": "assigned_to_me",
|
||||
"review": "review_requested",
|
||||
"authored": "authored_by_me",
|
||||
}[stream]
|
||||
return {
|
||||
"stream": stream,
|
||||
"items": [{**item, "work_reasons": [reason]} for item in items],
|
||||
|
|
@ -697,9 +739,44 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
|||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
|
||||
assert [pull["id"] for pull in pulls] == [11, 12, 13]
|
||||
assert pulls[0]["work_reasons"] == [
|
||||
"assigned_to_me", "review_requested", "authored_by_me",
|
||||
]
|
||||
assert pulls[1]["work_reasons"] == ["review_requested"]
|
||||
assert pulls[2]["work_reasons"] == ["authored_by_me"]
|
||||
assert pulls.pagination["authored"] == {
|
||||
"page": 1, "total": 2, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_failure_keeps_other_pull_work_and_marks_only_that_stream_unavailable(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
if stream == "authored":
|
||||
raise httpx.ConnectError("authored search unavailable")
|
||||
item = {
|
||||
"id": 11 if stream == "pull" else 12,
|
||||
"number": 7 if stream == "pull" else 8,
|
||||
"title": "Assigned" if stream == "pull" else "Review",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"work_reasons": ["assigned_to_me" if stream == "pull" else "review_requested"],
|
||||
}
|
||||
return {
|
||||
"stream": stream, "items": [item], "page": page,
|
||||
"total": 1, "has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls.pagination["pull"].get("unavailable", False) is False
|
||||
assert pulls.pagination["review"].get("unavailable", False) is False
|
||||
assert pulls.pagination["authored"] == {
|
||||
"page": 1, "total": 0, "has_more": False, "unavailable": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -100,6 +100,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
|||
assert result[1]["reason"] == "Filed by you"
|
||||
|
||||
|
||||
def test_authored_pull_is_classified_for_the_my_prs_queue():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"pull_requests": [{
|
||||
"number": 41, "title": "Ship mobile flow", "repository": "stackchain/dashboard",
|
||||
"state": "open", "user": "timmy", "assignees": [],
|
||||
"work_reasons": ["authored_by_me"], "updated_at": "2026-08-24T12: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 result[0]["is_authored"] is True
|
||||
assert result[0]["reason"] == "Authored by you"
|
||||
assert result[0]["kind"] == "pull"
|
||||
|
||||
|
||||
def test_my_prs_filter_and_count_include_only_authored_pulls():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = buildMyWork({{
|
||||
user:{{login:'timmy'}},
|
||||
pull_requests:[
|
||||
{{number:41,title:'Authored',repository:'stackchain/dashboard',state:'open',user:'timmy',
|
||||
assignees:[],work_reasons:['authored_by_me']}},
|
||||
{{number:42,title:'Review',repository:'stackchain/dashboard',state:'open',user:'alex',
|
||||
assignees:[],work_reasons:['review_requested']}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
authored:buildMyWork.filterMyWork(items,'authored').map(item=>item.number),
|
||||
counts:buildMyWork.countMyWork(items),
|
||||
}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
result = json.loads(completed.stdout)
|
||||
assert result["authored"] == [41]
|
||||
assert result["counts"]["authored"] == 1
|
||||
|
||||
|
||||
def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
|
||||
script = f"""
|
||||
const sheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
|
@ -1059,6 +1106,24 @@ async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_sta
|
|||
assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_prs_is_a_thumb_safe_mobile_queue_and_desktop_filter():
|
||||
html = await dashboard()
|
||||
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||
css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'data-work-filter="authored"' in markup
|
||||
assert 'data-work-count="authored"' in markup
|
||||
assert 'data-mobile-queue="authored"' in markup
|
||||
assert 'data-mobile-queue-count="authored"' in markup
|
||||
assert '<strong>My PRs</strong>' in markup
|
||||
assert ".mobile-queue-list button" in css and "min-height:56px" in css
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored'" in html
|
||||
assert "['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)" in html
|
||||
assert "authored: 'pull requests'" in html
|
||||
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
||||
html = await dashboard()
|
||||
|
|
@ -4988,7 +5053,8 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
|||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
"all": 3, "attention": 1, "filed": 0, "authored": 0,
|
||||
"issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -6165,7 +6231,8 @@ process.stdout.write(JSON.stringify({{
|
|||
assert output["updates"][1]["kind"] == "update"
|
||||
assert output["updates"][1]["update_reason"] == ""
|
||||
assert output["counts"] == {
|
||||
"all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
"all": 2, "attention": 2, "filed": 0, "authored": 0,
|
||||
"issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
}
|
||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||
|
||||
|
|
@ -8480,7 +8547,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="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
|
|
|||
|
|
@ -90,6 +90,47 @@ async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch):
|
|||
assert response.json()["items"][0]["work_reasons"] == ["created_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_page_endpoint_preserves_authorship_reason(monkeypatch):
|
||||
async def page_loader(stream, page):
|
||||
assert (stream, page) == ("authored", 2)
|
||||
return {
|
||||
"stream": stream, "page": page, "total": 51, "has_more": False,
|
||||
"items": [{
|
||||
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
|
||||
"user": {"login": "timmy"}, "labels": [], "assignees": [],
|
||||
"work_reasons": ["authored_by_me"],
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/pulls/41",
|
||||
}],
|
||||
}
|
||||
|
||||
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/authored?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["work_reasons"] == ["authored_by_me"]
|
||||
|
||||
|
||||
def test_initial_context_preserves_authored_pull_reason():
|
||||
payload = main._context_payload(
|
||||
{"id": 1, "login": "timmy"},
|
||||
[],
|
||||
[],
|
||||
[{
|
||||
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
|
||||
"user": {"login": "timmy"}, "labels": [], "assignees": [],
|
||||
"work_reasons": ["authored_by_me"],
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/pulls/41",
|
||||
}],
|
||||
)
|
||||
|
||||
assert payload["pull_requests"][0]["work_reasons"] == ["authored_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user