Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5603d41b | ||
|
|
4e074ae18e | ||
|
|
a4ae3e1c7f |
No files matched your search
@@ -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>
|
||||
@@ -1763,6 +1764,8 @@
|
||||
<details class="pull-review-request" id="pull-review-request">
|
||||
<summary>Request review</summary>
|
||||
<div>
|
||||
<button id="publish-draft-pull" type="button" hidden>Ready for review</button>
|
||||
<div id="publish-draft-pull-status" class="small" aria-live="assertive"></div>
|
||||
<button id="load-pull-reviewers" type="button">Choose reviewer</button>
|
||||
<label for="pull-review-recipient" class="small">Eligible repository reviewer</label>
|
||||
<select id="pull-review-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
@@ -2083,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>
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+47
-2
@@ -220,7 +220,7 @@ function resetReviewRequestControls(doc, detail) {
|
||||
qs('#pull-feedback-status').textContent = '';
|
||||
}
|
||||
|
||||
function bindReviewRequestControls(doc, controller, getSelected, getDetail) {
|
||||
function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-reviewers');
|
||||
if (load.dataset.reviewRequestBound === 'true') return;
|
||||
@@ -228,6 +228,11 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail) {
|
||||
controller.setReviewDetail = detail => {
|
||||
resetReviewRequestControls(doc, detail);
|
||||
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
|
||||
const publishable = detail?.state === 'open' && detail?.draft === true &&
|
||||
detail?.author === getLogin?.() && detail?.head_sha;
|
||||
qs('#publish-draft-pull').hidden = !publishable;
|
||||
if (publishable) qs('#pull-review-request').open = true;
|
||||
qs('#publish-draft-pull-status').textContent = '';
|
||||
};
|
||||
resetReviewRequestControls(doc, null);
|
||||
qs('#request-updated-pull-review').addEventListener('click', () => {
|
||||
@@ -244,6 +249,30 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail) {
|
||||
qs('#pull-merge-state').textContent = 'Waiting for @' + result.reviewer + '’s review';
|
||||
qs('#merge-pull').disabled = true;
|
||||
};
|
||||
qs('#publish-draft-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected?.();
|
||||
const detail = getDetail?.();
|
||||
if (!selected || !detail?.head_sha || detail.draft !== true ||
|
||||
!globalThis.confirm('Publish ' + selected.key + ' as ready for review?')) return;
|
||||
const button = qs('#publish-draft-pull');
|
||||
button.disabled = true;
|
||||
qs('#publish-draft-pull-status').textContent = 'Publishing draft…';
|
||||
try {
|
||||
const result = await controller.publishReady(selected, detail.head_sha);
|
||||
if (getSelected?.() !== selected || getDetail?.() !== detail) return;
|
||||
Object.assign(detail, result);
|
||||
selected.title = result.title;
|
||||
qs('#pull-sheet-title').textContent = result.title;
|
||||
controller.setReviewDetail(detail);
|
||||
qs('#publish-draft-pull-status').textContent = 'Published and ready for review.';
|
||||
qs('#pull-review-request').open = true;
|
||||
load.focus();
|
||||
} catch (error) {
|
||||
qs('#publish-draft-pull-status').textContent = error.message + ' The pull request remains a draft; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
|
||||
const button = event.target.closest?.('[data-cancel-review-request]');
|
||||
if (!button || button.disabled) return;
|
||||
@@ -534,7 +563,7 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
|
||||
bindReviewRequestControls(doc, controller, getSelected, getDetail);
|
||||
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
@@ -610,6 +639,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
let reviewCandidateRequest = null;
|
||||
let reviewRequestMutation = null;
|
||||
let reviewCancelMutation = null;
|
||||
let readyMutation = null;
|
||||
let editRequest = null;
|
||||
let feedbackRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
@@ -719,6 +749,21 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { reviewCancelMutation = null; });
|
||||
return reviewCancelMutation;
|
||||
},
|
||||
publishReady(item, expectedHeadSha) {
|
||||
if (readyMutation) return readyMutation;
|
||||
readyMutation = fetchJson(pathFor(item) + '/ready', {
|
||||
method:'PATCH',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
|
||||
result?.state !== 'open' || result?.draft !== false || !result?.title) {
|
||||
throw new Error('Draft publication was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { readyMutation = null; });
|
||||
return readyMutation;
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+84
-15
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -2028,6 +2029,69 @@ async def request_assigned_pull_review(
|
||||
}
|
||||
|
||||
|
||||
async def publish_authored_assigned_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
title = pull.get("title") if isinstance(pull.get("title"), str) else ""
|
||||
ready_title = re.sub(
|
||||
r"^(?:WIP\s*:|\[WIP\]|Draft\s*:|\[Draft\])\s*",
|
||||
"",
|
||||
title,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or pull.get("draft") is not True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or head.get("sha") != expected_head_sha
|
||||
or not ready_title
|
||||
or ready_title == title
|
||||
):
|
||||
raise IssueNotAvailableError("Draft pull request is no longer publishable")
|
||||
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}",
|
||||
headers=_auth(),
|
||||
json={"title": ready_title},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
|
||||
)
|
||||
confirmed_response.raise_for_status()
|
||||
confirmed = confirmed_response.json()
|
||||
confirmed_head = (
|
||||
confirmed.get("head")
|
||||
if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict)
|
||||
else {}
|
||||
)
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != "open"
|
||||
or confirmed.get("draft") is not False
|
||||
or confirmed.get("title") != ready_title
|
||||
or confirmed_head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the pull request is ready")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": ready_title,
|
||||
"head_sha": expected_head_sha,
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
}
|
||||
|
||||
|
||||
async def cancel_assigned_pull_review(
|
||||
repository: str, number: int, reviewer: str, expected_head_sha: str
|
||||
) -> dict:
|
||||
@@ -2705,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:
|
||||
|
||||
+42
-3
@@ -1117,6 +1117,12 @@ class PullReviewRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class PullReadyRequest(BaseModel):
|
||||
expected_head_sha: str = Field(
|
||||
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
|
||||
)
|
||||
|
||||
|
||||
class PullContentUpdate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
@@ -1323,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"],
|
||||
)
|
||||
@@ -1364,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"],
|
||||
@@ -4150,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:
|
||||
@@ -6609,6 +6619,35 @@ async def update_authored_assigned_pull_content(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/ready")
|
||||
async def publish_authored_assigned_pull(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.publish_authored_assigned_pull(
|
||||
repository, number, update.expected_head_sha
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The draft pull request changed. Reload before publishing."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The draft pull request could not be published. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
|
||||
async def assigned_pull_review_data(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_publishes_draft_and_continues_to_reviewer_picker(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"number": 7,
|
||||
"title": "WIP: Ship mobile flow",
|
||||
"author": "timmy",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"head_sha": "abc1234",
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
window.publishDetail = detail;
|
||||
window.publishItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:(path, options) => {
|
||||
if (!path.endsWith('/ready') || options.method !== 'PATCH') {
|
||||
return Promise.reject(new Error('unexpected request'));
|
||||
}
|
||||
const payload = JSON.parse(options.body);
|
||||
if (payload.expected_head_sha !== detail.head_sha) {
|
||||
return Promise.reject(new Error('wrong head'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
repository:'stackchain/api', number:7, title:'Ship mobile flow',
|
||||
head_sha:detail.head_sha, state:'open', draft:false,
|
||||
});
|
||||
},
|
||||
});
|
||||
createPullSheet.bindOwnershipControls(
|
||||
document, controller, () => window.publishItem, async () => false,
|
||||
() => window.publishDetail, () => 'timmy'
|
||||
);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
controller.setReviewDetail(detail);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
ready = page.get_by_role("button", name="Ready for review")
|
||||
before = ready.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
|
||||
ready.click()
|
||||
page.get_by_text("Published and ready for review.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
readyHidden:document.querySelector('#publish-draft-pull').hidden,
|
||||
requestOpen:document.querySelector('#pull-review-request').open,
|
||||
reviewerFocused:document.activeElement === document.querySelector('#load-pull-reviewers'),
|
||||
title:window.publishDetail.title,
|
||||
draft:window.publishDetail.draft,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert before["hidden"] is False
|
||||
assert before["height"] >= 44
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["readyHidden"] is True
|
||||
assert metrics["requestOpen"] is True
|
||||
assert metrics["reviewerFocused"] is True
|
||||
assert metrics["title"] == "Ship mobile flow"
|
||||
assert metrics["draft"] is False
|
||||
@@ -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
|
||||
|
||||
+70
-3
@@ -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
|
||||
|
||||
@@ -8,6 +8,86 @@ from src import gitea_proxy, main
|
||||
from src.security_event_store import SecurityEventStoreError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def publish(repository, number, expected_head_sha):
|
||||
calls.append((repository, number, expected_head_sha))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": "Ship mobile flow",
|
||||
"head_sha": expected_head_sha,
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "publish_authored_assigned_pull", publish, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/ready",
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["draft"] is False
|
||||
assert calls == [("stackchain/api", 7, "abc1234")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_publishes_only_authored_assigned_draft_and_verifies_ready_head():
|
||||
requests = []
|
||||
pull = {
|
||||
"number": 7,
|
||||
"title": "WIP: Ship mobile flow",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"user": {"login": "alex"},
|
||||
"assignees": [{"login": "alex"}],
|
||||
"head": {"sha": "abc1234"},
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.content))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
|
||||
return httpx.Response(200, json=pull)
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
|
||||
assert json.loads(request.content) == {"title": "Ship mobile flow"}
|
||||
pull.update({"title": "Ship mobile flow", "draft": False})
|
||||
return httpx.Response(201, json=pull)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.publish_authored_assigned_pull(
|
||||
"stackchain/api", 7, "abc1234"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "Ship mobile flow",
|
||||
"head_sha": "abc1234",
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
}
|
||||
assert [item[:2] for item in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_author_can_update_assigned_open_pull_context(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user