feat: surface authored pulls in My Work (Closes #1346)
This commit is contained in:
parent
4e074ae18e
commit
be5603d41b
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
pip install -r requirements-e2e.txt
|
pip install -r requirements-e2e.txt
|
||||||
python3 -m playwright install --with-deps chromium
|
python3 -m playwright install --with-deps chromium
|
||||||
- name: Exercise packaged mobile work journeys
|
- 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:
|
release-candidate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,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', '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;
|
selectedWorkFilter = savedFilter;
|
||||||
savedWorkFilter = savedFilter;
|
savedWorkFilter = savedFilter;
|
||||||
launchFilterResolved = true;
|
launchFilterResolved = true;
|
||||||
|
|
@ -3498,10 +3498,10 @@
|
||||||
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 === 'filed') return ['filed'];
|
||||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
if (['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)) {
|
||||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
return [selectedWorkFilter];
|
||||||
if (selectedWorkFilter === 'review') return ['review'];
|
}
|
||||||
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
|
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review', 'authored'];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3747,7 +3747,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWorkPaginationControls() {
|
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 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 => {
|
||||||
|
|
|
||||||
|
|
@ -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="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="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="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>
|
||||||
|
|
@ -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="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="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="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="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="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>
|
<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 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 isAuthored = (item.work_reasons || []).includes('authored_by_me');
|
||||||
const isCompleted = isFiled && item.state === 'closed';
|
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 = {
|
||||||
|
|
@ -78,6 +79,7 @@ function buildMyWork(data, now = new Date()) {
|
||||||
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_authored: isAuthored,
|
||||||
is_completed: isCompleted,
|
is_completed: isCompleted,
|
||||||
is_assigned: assigned,
|
is_assigned: assigned,
|
||||||
has_update: false,
|
has_update: false,
|
||||||
|
|
@ -85,7 +87,8 @@ function buildMyWork(data, now = new Date()) {
|
||||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||||
(due && due.priority < 4 ? due.label :
|
(due && due.priority < 4 ? due.label :
|
||||||
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
(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 :
|
_priority: priorityLabel ? 0 :
|
||||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
(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;
|
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 === '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 === '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);
|
||||||
|
|
@ -1080,6 +1084,7 @@ function countMyWork(items) {
|
||||||
all: items.length,
|
all: items.length,
|
||||||
attention: items.filter(needsAttention).length,
|
attention: items.filter(needsAttention).length,
|
||||||
filed: items.filter((item) => item.is_filed).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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft', 'following'];
|
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
|
||||||
const sections = {
|
const sections = {
|
||||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,7 @@ WORK_SEARCHES = {
|
||||||
"filed": ("created=true", "issues", "created_by_me"),
|
"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"),
|
||||||
|
"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:
|
async def pull_requests() -> WorkItems:
|
||||||
assigned, review_requested = await asyncio.gather(
|
streams = ("pull", "review", "authored")
|
||||||
work_page("pull"),
|
outcomes = await asyncio.gather(
|
||||||
work_page("review"),
|
*(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] = {}
|
merged: dict[int, dict] = {}
|
||||||
for result in (
|
pagination = {}
|
||||||
assigned,
|
for stream, outcome in zip(streams, outcomes, strict=True):
|
||||||
review_requested,
|
if isinstance(outcome, BaseException):
|
||||||
):
|
pagination[stream] = {
|
||||||
for pull in result["items"]:
|
"page": 1, "total": 0, "has_more": False, "unavailable": True,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
pagination[stream] = _page_metadata(outcome)
|
||||||
|
for pull in outcome["items"]:
|
||||||
identity = pull.get("id")
|
identity = pull.get("id")
|
||||||
if identity not in merged:
|
if identity not in merged:
|
||||||
merged[identity] = {**pull, "work_reasons": []}
|
merged[identity] = {**pull, "work_reasons": []}
|
||||||
for reason in pull.get("work_reasons", []):
|
for reason in pull.get("work_reasons", []):
|
||||||
if reason not in merged[identity]["work_reasons"]:
|
if reason not in merged[identity]["work_reasons"]:
|
||||||
merged[identity]["work_reasons"].append(reason)
|
merged[identity]["work_reasons"].append(reason)
|
||||||
return WorkItems(
|
return WorkItems(list(merged.values()), pagination)
|
||||||
list(merged.values()),
|
|
||||||
{
|
|
||||||
"pull": _page_metadata(assigned),
|
|
||||||
"review": _page_metadata(review_requested),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def is_requested_review(repository: str, number: int) -> bool:
|
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 "",
|
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)],
|
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)],
|
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 "",
|
repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "",
|
||||||
updated_at=p.get("updated_at") or "", url=p["html_url"],
|
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
|
for item in items
|
||||||
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
|
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 [
|
return [
|
||||||
PullRequest(
|
PullRequest(
|
||||||
id=item["id"], number=item["number"], title=item["title"],
|
id=item["id"], number=item["number"], title=item["title"],
|
||||||
|
|
@ -4156,7 +4160,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", "filed", "pull", "review"],
|
stream: Literal["issue", "filed", "pull", "review", "authored"],
|
||||||
page: int = Query(ge=2, le=100),
|
page: int = Query(ge=2, le=100),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
try:
|
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_address_review_feedback_release.py" in browser
|
||||||
assert "tests/e2e/test_mobile_cancel_pull_review_request_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
|
@pytest.mark.anyio
|
||||||
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
|
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
|
||||||
async def page(stream, page=1, limit=50):
|
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 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, "filed": 62, "pull": 61, "review": 73}
|
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73, "authored": 12}
|
||||||
return {
|
return {
|
||||||
"items": [], "page": 1, "total": totals[stream],
|
"items": [], "page": 1, "total": totals[stream],
|
||||||
"has_more": True, "stream": stream,
|
"has_more": True, "stream": stream,
|
||||||
|
|
@ -646,6 +672,7 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
|
||||||
assert pulls.pagination == {
|
assert pulls.pagination == {
|
||||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||||
"review": {"page": 1, "total": 73, "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),
|
("filed", 1, 50),
|
||||||
("pull", 1, 50),
|
("pull", 1, 50),
|
||||||
("review", 1, 50),
|
("review", 1, 50),
|
||||||
|
("authored", 1, 50),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@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 = {
|
assigned = {
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
|
@ -683,10 +711,24 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||||
"title": "Review mobile",
|
"title": "Review mobile",
|
||||||
"repository": {"full_name": "stackchain/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):
|
async def fake_page(stream, page=1, limit=50):
|
||||||
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
|
items = {
|
||||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
"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 {
|
return {
|
||||||
"stream": stream,
|
"stream": stream,
|
||||||
"items": [{**item, "work_reasons": [reason]} for item in items],
|
"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()
|
pulls = await gitea_proxy.pull_requests()
|
||||||
|
|
||||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
assert [pull["id"] for pull in pulls] == [11, 12, 13]
|
||||||
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
|
assert pulls[0]["work_reasons"] == [
|
||||||
|
"assigned_to_me", "review_requested", "authored_by_me",
|
||||||
|
]
|
||||||
assert pulls[1]["work_reasons"] == ["review_requested"]
|
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
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
||||||
assert result[1]["reason"] == "Filed by you"
|
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():
|
def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
|
||||||
script = f"""
|
script = f"""
|
||||||
const sheet = require({json.dumps(str(ISSUE_SHEET))});
|
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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -4988,7 +5053,8 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(result.stdout) == {
|
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]["kind"] == "update"
|
||||||
assert output["updates"][1]["update_reason"] == ""
|
assert output["updates"][1]["update_reason"] == ""
|
||||||
assert output["counts"] == {
|
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"
|
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="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', '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 '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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
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
|
@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