Compare commits

...
Author SHA1 Message Date
timmy be5603d41b feat: surface authored pulls in My Work (Closes #1346)
CI / lint (pull_request) Successful in 3m26s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 11:13:54 +00:00
timmy 4e074ae18e Merge pull request 'Publish draft pull requests from mobile' (#1345) from timmy/1344-publish-draft-pull into main
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m14s
CI / release-candidate (push) Successful in 7s
2026-08-24 10:16:34 +00:00
timmy a4ae3e1c7f feat: publish draft pull requests from mobile (Closes #1344)
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 10:06:51 +00:00
timmy 1a2297e943 Merge pull request 'Load pull review feedback on demand without blocking mobile review' (#1343) from timmy/1342-lazy-review-feedback into main
CI / lint (push) Successful in 3m28s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m23s
CI / release-candidate (push) Successful in 7s
2026-08-24 09:26:53 +00:00
timmy 611e79fc1e feat: load pull review feedback on demand (Closes #1342)
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 09:16:30 +00:00
rockachopa 50817bed9a Merge pull request 'Cancel and replace pending pull review requests from mobile' (#1341) from timmy/1340-cancel-pending-review into main
CI / lint (push) Successful in 3m25s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 08:25:56 +00:00
timmy 1f9f0f5c22 test: gate mobile review cancellation journey
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 08:16:21 +00:00
timmy d776a2b003 feat: cancel pending pull review requests (Closes #1340)
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 08:01:25 +00:00
timmy 7517d81046 Merge pull request 'Address requested review feedback from mobile pull detail' (#1339) from timmy/1338-address-review-feedback into main
CI / lint (push) Successful in 3m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m45s
CI / release-candidate (push) Successful in 7s
2026-08-24 06:53:19 +00:00
timmy 091a01ba67 feat: address requested review feedback (Closes #1338)
CI / lint (pull_request) Successful in 3m35s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 06:42:45 +00:00
rockachopa bad14d1b94 Merge pull request 'Show requested-change feedback in mobile pull detail' (#1337) from timmy/1336-review-feedback into main
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 05:48:23 +00:00
timmy c4bb2f5eba feat: show requested-change feedback (Closes #1336)
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 05:38:30 +00:00
rockachopa d2a815c4bc Merge pull request 'Show reviewer decisions before merging a pull request' (#1335) from timmy/1334-reviewer-decisions into main
CI / lint (push) Successful in 3m45s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m34s
CI / release-candidate (push) Successful in 9s
2026-08-24 04:41:15 +00:00
timmy 3bc1d2f602 test: exercise mobile reviewer status journey
CI / lint (pull_request) Successful in 3m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:28:24 +00:00
timmy bd96c3caa3 feat: show pull reviewer decisions (Closes #1334)
CI / lint (pull_request) Successful in 3m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:09:05 +00:00
rockachopa f8ab6977ed Merge pull request 'Revise pull request context before requesting review' (#1333) from timmy/1332-revise-pull-context into main
CI / lint (push) Successful in 3m36s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m25s
CI / release-candidate (push) Successful in 8s
2026-08-24 03:21:15 +00:00
20 changed files with 2428 additions and 51 deletions

No files matched your search

+1 -1
View File
@@ -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 -q
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
+28
View File
@@ -1259,6 +1259,34 @@ textarea { resize: vertical; min-height: 120px; }
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
.pull-review-tools button { min-height:44px; }
.pull-reviewer-summary { margin:10px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.pull-reviewer-summary h3 { margin:0 0 6px; font-size:14px; }
.pull-reviewer-status { padding:8px 0; border-top:1px solid #203a5c; overflow-wrap:anywhere; }
.pull-reviewer-status:first-child { border-top:0; }
.pull-reviewer-status-heading { display:flex; justify-content:space-between; gap:10px; }
.pull-reviewer-status-heading span { text-align:right; }
.cancel-review-request { min-height:44px; width:100%; margin-top:8px; }
.pull-review-feedback { margin-top:8px; border:1px solid #2a496e; border-radius:8px; padding:0 8px 8px; }
.pull-review-feedback > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
.pull-review-feedback-summary { margin:4px 0 10px; white-space:pre-wrap; }
.pull-review-feedback-file { margin-top:8px; padding-top:8px; border-top:1px solid #203a5c; }
.pull-review-feedback-file h4 { margin:0 0 4px; overflow-wrap:anywhere; }
.pull-review-feedback-file ul { margin:0; padding-left:20px; }
.pull-review-feedback-file li { margin:6px 0; }
.pull-review-feedback-file small { display:block; color:#93c5fd; }
.pull-review-feedback-file button { min-height:44px; width:100%; margin-top:4px; }
.address-review-feedback, [data-load-review-feedback] { min-height:44px; width:100%; margin-top:8px; }
.pull-feedback-pass { margin-top:10px; padding:10px; border:1px solid #3b82f6; border-radius:10px; overflow-wrap:anywhere; }
.pull-feedback-pass-heading { display:flex; align-items:start; justify-content:space-between; gap:8px; }
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
.pull-feedback-pass p { white-space:pre-wrap; }
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
.pull-feedback-pass button { min-height:44px; }
#finish-pull-feedback, #request-feedback-review { width:100%; margin-top:8px; }
#request-updated-pull-review { min-height:44px; width:100%; margin-top:8px; }
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
.pull-review summary h2 { margin:0; font-size:16px; }
+11 -10
View File
@@ -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;
@@ -1288,6 +1288,7 @@
paintMyWork(lastContextSnapshot);
return false;
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
}
attachReleaseReceipt();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
@@ -3497,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 [];
}
@@ -3746,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 => {
@@ -4536,7 +4537,7 @@
qs('#pull-review-progress').textContent = pullReviewState.total ?
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
const eligibility = createPullSheet.review(detail, pullReviewState, document);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
@@ -4573,9 +4574,7 @@
return;
}
selectedPullDetail = { ...selectedPullDetail, ...status };
const eligibility = createPullSheet.mergeEligibility(selectedPullDetail, pullReviewState);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
renderPullReview(selectedPullDetail);
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
}
@@ -4648,6 +4647,7 @@
qs('#pull-checks-summary').textContent = 'Not loaded';
qs('#pull-check-list').textContent = '';
qs('#pull-checks').open = false;
qs('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
@@ -7245,6 +7245,7 @@
if (event.currentTarget.open) loadPullReview();
});
qs('#pull-review-retry').addEventListener('click', loadPullReview);
qs('#refresh-pull-checks').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
+34
View File
@@ -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>
@@ -1721,6 +1722,36 @@
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
<section class="pull-reviewer-summary" aria-labelledby="pull-reviewer-heading">
<h3 id="pull-reviewer-heading">Reviewer status</h3>
<div id="pull-reviewer-status" class="small" aria-live="polite">Reviewer status not loaded.</div>
<div id="pull-reviewer-statuses"></div>
<button id="request-updated-pull-review" type="button" hidden>Request updated review</button>
<section class="pull-feedback-pass" id="pull-feedback-pass" aria-labelledby="pull-feedback-heading" hidden>
<div class="pull-feedback-pass-heading">
<div><span class="small" id="pull-feedback-progress"></span><h4 id="pull-feedback-heading">Address review feedback</h4></div>
<button id="close-pull-feedback" type="button">Close</button>
</div>
<p class="small" id="pull-feedback-location"></p>
<p id="pull-feedback-body"></p>
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
<fieldset class="pull-feedback-dispositions">
<legend>Disposition</legend>
<button type="button" data-feedback-disposition="addressed">Addressed</button>
<button type="button" data-feedback-disposition="discussion">Needs discussion</button>
<button type="button" data-feedback-disposition="skipped">Skip for now</button>
</fieldset>
<label for="pull-feedback-note">Response note (optional)</label>
<textarea id="pull-feedback-note" maxlength="1000" placeholder="What changed or needs discussion?"></textarea>
<div class="pull-feedback-navigation">
<button id="previous-pull-feedback" type="button">Previous</button>
<button id="next-pull-feedback" type="button">Next</button>
</div>
<button id="finish-pull-feedback" type="button" disabled>Post feedback response</button>
<button id="request-feedback-review" type="button" hidden>Request updated review from this reviewer</button>
<div id="pull-feedback-status" class="small" role="status" aria-live="assertive"></div>
</section>
</section>
<details class="ci-checks" id="pull-checks">
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
@@ -1733,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>
@@ -2053,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
View File
@@ -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,
+431 -6
View File
@@ -12,12 +12,107 @@ function mergeEligibility(detail, reviewState) {
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
const reviewer = (detail.reviewers || []).find(item => item?.blocking);
if (reviewer?.status === 'waiting') {
return { allowed: false, reason: 'Waiting for @' + reviewer.login + '’s review' };
}
if (reviewer?.status === 'changes_requested') {
return { allowed: false, reason: '@' + reviewer.login + ' requested changes' };
}
if (reviewer?.status === 'outdated') {
return { allowed: false, reason: '@' + reviewer.login + '’s review is outdated after new commits' };
}
if (reviewState && !reviewState.complete) {
return { allowed: false, reason: 'Review every changed file before merging' };
}
return { allowed: true, reason: 'Ready to merge' };
}
function renderReviewerFeedback(reviewer, escapeHtml) {
const groups = new Map();
(Array.isArray(reviewer?.comments) ? reviewer.comments : []).forEach(comment => {
if (!comment?.path || !comment?.body) return;
if (!groups.has(comment.path)) groups.set(comment.path, []);
groups.get(comment.path).push(comment);
});
const summary = reviewer?.summary ? '<p class="pull-review-feedback-summary">' +
escapeHtml(reviewer.summary) + '</p>' : '';
const files = Array.from(groups.entries()).map(([path, comments]) =>
'<section class="pull-review-feedback-file"><h4>' + escapeHtml(path) + '</h4><ul>' +
comments.map(comment => '<li>' + escapeHtml(comment.body) +
(comment.line ? '<small>Line ' + Number(comment.line) + '</small>' : '') + '</li>').join('') +
'</ul><button type="button" data-review-feedback-file="' + escapeHtml(path) +
'">View in changes</button></section>'
).join('');
const loadable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
['commented', 'changes_requested', 'outdated'].includes(reviewer?.status);
const feedbackState = Array.isArray(reviewer?.comments) ? 'loaded' : reviewer?.feedback_state;
const feedbackAction = !loadable || feedbackState === 'loaded' ? '' :
feedbackState === 'loading' ? '<p class="small" role="status">Loading inline feedback…</p>' :
'<p class="small" role="status">' + (feedbackState === 'error' ? 'Feedback unavailable. ' : '') +
'<button type="button" data-load-review-feedback="' + Number(reviewer.review_id) + '">' +
(feedbackState === 'error' ? 'Retry feedback' : 'Load inline feedback') + '</button></p>';
const emptyLoaded = loadable && feedbackState === 'loaded' && !files ?
'<p class="small">No inline comments on this review.</p>' : '';
if (!summary && !files && !feedbackAction && !emptyLoaded) return '';
const reviewedHead = reviewer?.status === 'outdated' && reviewer?.head_sha ?
'<p class="small">Reviewed head ' + escapeHtml(reviewer.head_sha.slice(0, 8)) + '</p>' : '';
const addressable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
['changes_requested', 'outdated'].includes(reviewer?.status) && feedbackState === 'loaded' &&
(reviewer.comments || []).some(comment => Number.isInteger(comment?.id) && comment.id > 0);
const address = addressable ? '<button type="button" class="address-review-feedback" data-address-review-feedback="' +
Number(reviewer.review_id) + '">Address feedback</button>' : '';
return '<details class="pull-review-feedback"><summary>Review feedback</summary>' +
reviewedHead + summary + feedbackAction + emptyLoaded + files + address + '</details>';
}
function renderReviewerStatuses(reviewers, escapeHtml) {
const labels = {
waiting: 'Waiting for review',
approved: 'Approved current head',
changes_requested: 'Changes requested',
commented: 'Commented',
outdated: 'Outdated after new commits',
};
return (Array.isArray(reviewers) ? reviewers : []).map(reviewer => {
const status = labels[reviewer?.status] || 'Status unavailable';
const cancel = reviewer?.status === 'waiting' && reviewer?.login ?
'<button type="button" class="cancel-review-request" data-cancel-review-request="' +
escapeHtml(reviewer.login) + '">Cancel request</button>' : '';
return '<article class="pull-reviewer-status" data-reviewer-status="' +
escapeHtml(reviewer?.status || 'unknown') + '" data-review-id="' +
(Number.isInteger(reviewer?.review_id) ? Number(reviewer.review_id) : '') +
'"><div class="pull-reviewer-status-heading"><strong>@' +
escapeHtml(reviewer?.login || 'unknown') + '</strong><span>' + escapeHtml(status) + '</span></div>' +
renderReviewerFeedback(reviewer, escapeHtml) + cancel + '</article>';
}).join('');
}
function renderReviewerPanel(doc, detail, escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')) {
const reviewers = Array.isArray(detail?.reviewers) ? detail.reviewers : [];
const openFeedback = new Set(Array.from(doc.querySelectorAll('.pull-review-feedback[open]'))
.map(panel => panel.closest('.pull-reviewer-status')?.dataset.reviewId).filter(Boolean));
doc.querySelector('#pull-reviewer-statuses').innerHTML = renderReviewerStatuses(reviewers, escapeHtml);
openFeedback.forEach(reviewId => {
const panel = doc.querySelector('.pull-reviewer-status[data-review-id="' + reviewId + '"] .pull-review-feedback');
if (panel) panel.open = true;
});
doc.querySelectorAll('[data-review-feedback-file]').forEach(button => {
button.addEventListener('click', () => focusPullFile(doc, button.dataset.reviewFeedbackFile));
});
doc.querySelector('#pull-reviewer-status').textContent = reviewers.length ?
reviewers.length + ' reviewer status' + (reviewers.length === 1 ? '' : 'es') + ' for this pull request.' :
'No review has been requested; existing merge policy is unchanged.';
doc.querySelector('#request-updated-pull-review').hidden =
!reviewers.some(reviewer => reviewer.status === 'outdated');
}
function reviewEligibility(detail, reviewState, doc) {
renderReviewerPanel(doc, detail);
return mergeEligibility(detail, reviewState);
}
function renderFile(file, index, reviewed, escapeHtml) {
const filename = escapeHtml(file.filename || 'Unknown file');
const panelId = 'pull-diff-' + index;
@@ -41,18 +136,22 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
const filename = controller.nextUnreviewed(detail, state);
function focusPullFile(doc, filename) {
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
.find(file => file.dataset.pullFilename === filename);
const toggle = article?.querySelector('.pull-file-toggle');
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
if (!toggle || !panel) return;
if (!toggle || !panel) return false;
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
return true;
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
focusPullFile(doc, controller.nextUnreviewed(detail, state));
}
function removeFromSnapshot(data, item) {
@@ -114,9 +213,14 @@ function resetReviewRequestControls(doc, detail) {
qs('#load-pull-reviewers').disabled = !detail?.head_sha;
qs('#pull-review-request-status').textContent = detail?.head_sha ?
'Load teammates to request a review.' : 'Load the current pull request before requesting review.';
qs('#pull-reviewer-status').textContent = 'Reviewer status not loaded.';
qs('#pull-reviewer-statuses').textContent = '';
qs('#request-updated-pull-review').hidden = true;
qs('#pull-feedback-pass').hidden = true;
qs('#pull-feedback-status').textContent = '';
}
function bindReviewRequestControls(doc, controller, getSelected) {
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;
@@ -124,8 +228,82 @@ function bindReviewRequestControls(doc, controller, getSelected) {
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', () => {
qs('#pull-review-request').open = true;
qs('#pull-review-request').scrollIntoView({ block:'center', behavior:'smooth' });
load.focus();
});
controller.onReviewRequested = result => {
const detail = getDetail?.();
if (!detail || result.head_sha !== detail.head_sha) return;
detail.reviewers = (detail.reviewers || []).filter(reviewer => reviewer.login !== result.reviewer);
detail.reviewers.push({login:result.reviewer, status:'waiting', head_sha:result.head_sha, blocking:true});
renderReviewerPanel(doc, detail);
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;
const selected = getSelected?.();
const detail = getDetail?.();
const reviewer = button.dataset.cancelReviewRequest;
if (!selected || !detail?.head_sha || !reviewer ||
!globalThis.confirm('Cancel @' + reviewer + '’s review request for ' + selected.key + '?')) return;
button.disabled = true;
qs('#pull-reviewer-status').textContent = 'Cancelling @' + reviewer + '’s review request…';
try {
const result = await controller.cancelReview(selected, reviewer, detail.head_sha);
if (result?.head_sha !== detail.head_sha || result?.requested_reviewers?.includes(reviewer)) {
throw new Error('Cancellation was not confirmed.');
}
detail.reviewers = (detail.reviewers || []).filter(candidate =>
candidate.login !== reviewer || candidate.status !== 'waiting');
renderReviewerPanel(doc, detail);
const eligibility = mergeEligibility(detail, controller.reviewState(selected, detail));
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-review-request').open = true;
qs('#pull-review-request-status').textContent = 'Review request cancelled. Choose a replacement reviewer.';
load.disabled = false;
load.focus();
} catch (error) {
qs('#pull-reviewer-status').textContent = error.message + ' Refresh review data before retrying.';
button.disabled = false;
button.focus();
}
});
load.addEventListener('click', async () => {
const selected = getSelected();
if (!selected) return;
@@ -169,6 +347,149 @@ function bindReviewRequestControls(doc, controller, getSelected) {
});
}
function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const panel = qs('#pull-feedback-pass');
if (panel.dataset.feedbackBound === 'true') return;
panel.dataset.feedbackBound = 'true';
let active = null;
const comments = () => (active?.reviewer?.comments || [])
.filter(comment => Number.isInteger(comment?.id) && comment.id > 0);
const current = () => comments()[active?.state?.index || 0];
const save = () => {
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
};
const render = () => {
if (!active) return;
const items = comments();
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
const comment = current();
const response = active.state.responses[String(comment.id)] || {};
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback';
qs('#pull-feedback-location').textContent = comment.path + (comment.line ? ' · line ' + Number(comment.line) : '');
qs('#pull-feedback-body').textContent = comment.body;
qs('#pull-feedback-note').value = response.note || '';
panel.querySelectorAll('[data-feedback-disposition]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.feedbackDisposition === response.disposition));
});
qs('#previous-pull-feedback').disabled = active.state.index === 0;
qs('#next-pull-feedback').disabled = active.state.index >= items.length - 1;
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
qs('#request-feedback-review').hidden = active.state.posted !== true;
const found = focusPullFile(doc, comment.path);
qs('#pull-feedback-context').textContent = found ?
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
panel.scrollIntoView({ block:'center', behavior:'smooth' });
};
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
const loadButton = event.target.closest?.('[data-load-review-feedback]');
if (loadButton && !loadButton.disabled) {
const detail = getDetail?.();
const item = getSelected?.();
const reviewId = Number(loadButton.dataset.loadReviewFeedback);
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const refresh = reviewer.feedback_state === 'error';
reviewer.feedback_state = 'loading';
renderReviewerPanel(doc, detail);
try {
const result = await controller.loadFeedback(item, detail, reviewer, { refresh });
if (getDetail?.() !== detail || getSelected?.() !== item ||
result.head_sha !== detail.head_sha || Number(result.review_id) !== reviewId) return;
reviewer.comments = Array.isArray(result.comments) ? result.comments : [];
reviewer.feedback_state = 'loaded';
renderReviewerPanel(doc, detail);
doc.querySelector('[data-address-review-feedback="' + reviewId + '"]')?.focus();
} catch (error) {
if (getDetail?.() !== detail || getSelected?.() !== item) return;
reviewer.feedback_state = 'error';
renderReviewerPanel(doc, detail);
doc.querySelector('[data-load-review-feedback="' + reviewId + '"]')?.focus();
}
return;
}
const button = event.target.closest?.('[data-address-review-feedback]');
if (!button) return;
const detail = getDetail?.();
const item = getSelected?.();
const reviewId = Number(button.dataset.addressReviewFeedback);
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
active = { item, detail, reviewer, login,
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
const disposition = event.target.dataset?.feedbackDisposition;
const comment = current();
if (!active || !comment || !['addressed', 'discussion', 'skipped'].includes(disposition)) return;
const key = String(comment.id);
active.state.responses[key] = { ...(active.state.responses[key] || {}), disposition };
save();
render();
});
qs('#pull-feedback-note').addEventListener('input', event => {
const comment = current();
if (!active || !comment) return;
const key = String(comment.id);
active.state.responses[key] = { ...(active.state.responses[key] || {}), note:event.target.value.slice(0, 1000) };
save();
});
qs('#previous-pull-feedback').addEventListener('click', () => {
if (!active) return;
active.state.index -= 1; save(); render();
});
qs('#next-pull-feedback').addEventListener('click', () => {
if (!active) return;
active.state.index += 1; save(); render();
});
qs('#close-pull-feedback').addEventListener('click', () => {
panel.hidden = true;
doc.querySelector('[data-address-review-feedback="' + active?.reviewer?.review_id + '"]')?.focus();
});
qs('#finish-pull-feedback').addEventListener('click', async () => {
if (!active || qs('#finish-pull-feedback').disabled) return;
const latest = getDetail?.();
if (!latest || latest.head_sha !== active.state.headSha) {
qs('#pull-feedback-status').textContent = 'The pull request changed. Refresh review data before posting.';
return;
}
if (!globalThis.confirm('Post one feedback response to ' + active.item.key + '?')) return;
qs('#finish-pull-feedback').disabled = true;
qs('#pull-feedback-status').textContent = 'Posting feedback response…';
try {
await controller.postFeedbackResponse(active.item, active.detail, active.reviewer, active.login, active.state);
active.state.posted = true;
save();
qs('#pull-feedback-status').textContent = 'Response posted. Request an updated review when ready.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Progress kept; retry without duplicating the response.';
qs('#finish-pull-feedback').disabled = false;
}
});
qs('#request-feedback-review').addEventListener('click', async () => {
if (!active?.state?.posted) return;
const button = qs('#request-feedback-review');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Requesting updated review…';
try {
await controller.requestReview(active.item, active.reviewer.login, active.detail.head_sha);
qs('#pull-feedback-status').textContent = 'Response posted; updated review requested from @' + active.reviewer.login + '.';
button.hidden = true;
} catch (error) {
qs('#pull-feedback-status').textContent = 'Response posted; review not requested. ' + error.message;
button.disabled = false;
}
});
}
function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const form = qs('#pull-edit-form');
@@ -242,7 +563,8 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
}
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
bindReviewRequestControls(doc, controller, getSelected);
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);
const load = qs('#load-pull-handoff');
@@ -316,15 +638,22 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
let candidateRequest = null;
let reviewCandidateRequest = null;
let reviewRequestMutation = null;
let reviewCancelMutation = null;
let readyMutation = null;
let editRequest = null;
let feedbackRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const feedbackRequests = new Map();
const feedbackCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
const operationKey = item => draftKey(item) + ':operation';
const editDraftKey = item => 'stackchain.pull-edit.v1:' + item.repository + '#' + item.number;
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
const feedbackKey = (item, detail, reviewer, login) => 'stackchain.feedback-pass.v1:' +
item.repository + '#' + item.number + ':' + reviewer.review_id + ':' + reviewer.head_sha + ':' + detail.head_sha + ':' + login;
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
function reviewState(item, detail) {
@@ -356,6 +685,25 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
reviewRequests.set(key, request);
return request;
},
loadFeedback(item, detail, reviewer, { refresh = false } = {}) {
const reviewId = Number(reviewer?.review_id);
const headSha = detail?.head_sha || '';
const key = item.repository + '#' + item.number + ':' + headSha + ':' + reviewId;
if (!refresh && feedbackCache.has(key)) return Promise.resolve(feedbackCache.get(key));
if (feedbackRequests.has(key)) return feedbackRequests.get(key);
const request = fetchJson(pathFor(item) + '/reviews/' + encodeURIComponent(reviewId) +
'/feedback?expected_head_sha=' + encodeURIComponent(headSha), {
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.head_sha !== headSha || Number(result?.review_id) !== reviewId) {
throw new Error('Review feedback changed. Reload review data before retrying.');
}
feedbackCache.set(key, result);
return result;
}).finally(() => feedbackRequests.delete(key));
feedbackRequests.set(key, request);
return request;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
@@ -383,9 +731,39 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
}).then(result => {
this.onReviewRequested?.(result);
return result;
}).finally(() => { reviewRequestMutation = null; });
return reviewRequestMutation;
},
cancelReview(item, reviewer, expectedHeadSha) {
if (reviewCancelMutation) return reviewCancelMutation;
reviewCancelMutation = fetchJson(pathFor(item) + '/request-review', {
method: 'DELETE',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
}).then(result => {
this.onReviewCancelled?.(result);
return result;
}).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');
@@ -397,6 +775,50 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
catch (_error) { /* The edit fields remain the fallback. */ }
},
loadFeedbackPass(item, detail, reviewer, login) {
const fresh = { headSha:detail.head_sha, index:0, responses:{}, posted:false };
try {
const saved = JSON.parse(storage?.getItem(feedbackKey(item, detail, reviewer, login)) || 'null');
return saved && saved.headSha === detail.head_sha && saved.responses && typeof saved.responses === 'object'
? { ...fresh, ...saved } : fresh;
} catch (_error) { return fresh; }
},
saveFeedbackPass(item, detail, reviewer, login, state) {
try { storage?.setItem(feedbackKey(item, detail, reviewer, login), JSON.stringify(state)); }
catch (_error) { /* The visible pass remains usable without storage. */ }
},
feedbackSummary(reviewer, state) {
const labels = { addressed:'Addressed', discussion:'Needs discussion', skipped:'Skip for now' };
const lines = (reviewer.comments || []).filter(comment => Number.isInteger(comment?.id) && comment.id > 0)
.map(comment => {
const response = state.responses[String(comment.id)] || {};
const path = String(comment.path || '').replaceAll('`', "'").replaceAll('\n', ' ').slice(0, 300);
const body = String(comment.body || '').replaceAll('\n', ' ').slice(0, 180);
const note = String(response.note || '').replaceAll('\n', ' ').slice(0, 1000);
return '- `' + path + '` · comment #' + comment.id + ' · **' + (labels[response.disposition] || 'Unresolved') +
'** — ' + body + (note ? '\n - Response: ' + note : '');
});
return ('Addressed review feedback from @' + reviewer.login + ' (review #' + reviewer.review_id + ').\n\n' +
lines.join('\n')).slice(0, 10000);
},
postFeedbackResponse(item, detail, reviewer, login, state) {
if (feedbackRequest) return feedbackRequest;
const key = feedbackKey(item, detail, reviewer, login);
let operationId;
try {
operationId = storage?.getItem(key + ':operation') || String(createOperationId()).slice(0, 128);
storage?.setItem(key + ':operation', operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
feedbackRequest = fetchJson(pathFor(item) + '/comments', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json', 'Idempotency-Key':operationId },
body:JSON.stringify({ body:this.feedbackSummary(reviewer, state) }),
}).then(result => {
try { storage?.removeItem(key + ':operation'); } catch (_error) { /* Confirmed upstream. */ }
return result;
}).finally(() => { feedbackRequest = null; });
return feedbackRequest;
},
updateContent(item, draft) {
if (editRequest) return editRequest;
this.saveEditDraft(item, draft);
@@ -509,6 +931,8 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.review = reviewEligibility;
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
@@ -521,4 +945,5 @@ createPullSheet.bindOwnershipControls = bindOwnershipControls;
createPullSheet.bindContextEditor = bindContextEditor;
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
createPullSheet.bindFeedbackControls = bindFeedbackControls;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+1 -1
View File
@@ -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'],
+280 -17
View File
@@ -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,116 @@ 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:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
requested = [
item["login"] for item in (pull.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
]
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or head.get("sha") != expected_head_sha
or reviewer not in requested
):
raise IssueNotAvailableError("Pending review request changed")
response = await _get_client().request(
"DELETE",
f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers",
headers=_auth(),
json={"reviewers": [reviewer]},
)
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()
remaining = [
item["login"] for item in (confirmed.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed, dict) else []
confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {}
if reviewer in remaining or confirmed_head.get("sha") != expected_head_sha:
raise ValueError("Gitea did not confirm reviewer cancellation")
return {
"repository": repository,
"number": number,
"head_sha": expected_head_sha,
"requested_reviewers": remaining,
"reviewer": reviewer,
}
async def _change_assigned_pull_owners(
repository: str, number: int, recipient: str | None
) -> dict:
@@ -2658,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:
@@ -2771,6 +2887,106 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
}
def _normalize_review_comments(comments: object) -> list[dict]:
normalized = []
for comment in (comments if isinstance(comments, list) else [])[:100]:
if not isinstance(comment, dict):
continue
path = comment.get("path")
body = comment.get("body")
if not isinstance(path, str) or not path.strip() or not isinstance(body, str) or not body.strip():
continue
item = {"path": path.strip()[:300], "body": body.strip()[:500]}
comment_id = comment.get("id")
if isinstance(comment_id, int) and comment_id > 0:
item["id"] = comment_id
position = comment.get("new_position") or comment.get("old_position")
if isinstance(position, int) and position > 0:
item["line"] = position
normalized.append(item)
if len(normalized) == 20:
break
return normalized
def _latest_feedback_review_ids(reviews: object) -> dict[str, int]:
latest: dict[str, tuple[int, str]] = {}
for review in (reviews if isinstance(reviews, list) else [])[:100]:
if not isinstance(review, dict):
continue
user = review.get("user") if isinstance(review.get("user"), dict) else {}
login = user.get("login")
review_id = review.get("id")
state = review.get("state")
if (
not isinstance(login, str) or not login or not isinstance(review_id, int)
or review_id <= 0 or not isinstance(state, str)
):
continue
key = login.casefold()
if key not in latest or review_id >= latest[key][0]:
latest[key] = (review_id, state)
feedback = [
(key, review_id) for key, (review_id, state) in latest.items()
if state in {"REQUEST_CHANGES", "COMMENT"}
]
return dict(feedback[:10])
def _normalize_reviewer_statuses(pull: dict, reviews: object, head_sha: str) -> list[dict]:
"""Return one bounded, current decision per reviewer without exposing raw review data."""
latest: dict[str, dict] = {}
for review in (reviews if isinstance(reviews, list) else [])[:100]:
if not isinstance(review, dict):
continue
user = review.get("user") if isinstance(review.get("user"), dict) else {}
login = user.get("login")
state = review.get("state")
commit_id = review.get("commit_id")
if (
not isinstance(login, str)
or not login
or state not in {"APPROVED", "REQUEST_CHANGES", "COMMENT"}
or not isinstance(commit_id, str)
or not commit_id
):
continue
previous = latest.get(login.casefold())
review_id = review.get("id") if isinstance(review.get("id"), int) else 0
if previous is None or review_id >= previous["id"]:
body = review.get("body")
latest[login.casefold()] = {
"id": review_id, "login": login, "state": state, "commit_id": commit_id,
"summary": body.strip()[:500] if isinstance(body, str) and body.strip() else "",
}
statuses: dict[str, dict] = {}
state_names = {
"APPROVED": "approved", "REQUEST_CHANGES": "changes_requested", "COMMENT": "commented",
}
for key, review in latest.items():
current = review["commit_id"] == head_sha
status = state_names[review["state"]] if current else "outdated"
statuses[key] = {
"review_id": review["id"],
"login": review["login"],
"status": status,
"head_sha": review["commit_id"],
"blocking": status in {"changes_requested", "outdated"},
}
if review["summary"]:
statuses[key]["summary"] = review["summary"]
requested = pull.get("requested_reviewers") if isinstance(pull, dict) else []
for reviewer in (requested if isinstance(requested, list) else [])[:25]:
login = reviewer.get("login") if isinstance(reviewer, dict) else None
if isinstance(login, str) and login:
statuses[login.casefold()] = {
"login": login, "status": "waiting", "head_sha": head_sha, "blocking": True,
}
return sorted(statuses.values(), key=lambda item: item["login"].casefold())
async def pull_completion_review(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
@@ -2780,15 +2996,17 @@ async def pull_completion_review(repository: str, number: int) -> dict:
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
files, status, diff_result = await asyncio.gather(
files, status, diff_result, reviews = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
fetch(f"{base}/reviews?limit=100"),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
reviewers = _normalize_reviewer_statuses(pull, reviews, sha)
return {
"repository": repository,
"number": number,
@@ -2799,6 +3017,7 @@ async def pull_completion_review(repository: str, number: int) -> dict:
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"reviewers": reviewers,
"files": [
{
"filename": item.get("filename", ""),
@@ -2821,6 +3040,40 @@ async def pull_completion_review(repository: str, number: int) -> dict:
}
async def pull_review_feedback(
repository: str, number: int, review_id: int, expected_head_sha: str
) -> dict:
"""Load optional inline feedback only for a review on the confirmed pull head."""
base = f"repos/{repository}/pulls/{number}"
pull, reviews = await asyncio.gather(
fetch(base),
fetch(f"{base}/reviews?limit=100"),
)
head = pull.get("head") if isinstance(pull, dict) else None
head_sha = head.get("sha") if isinstance(head, dict) else None
if head_sha != expected_head_sha:
raise StaleReviewError("Pull request head changed")
review_items = reviews if isinstance(reviews, list) else []
latest_ids = set(_latest_feedback_review_ids(review_items).values())
review = next(
(
item for item in review_items
if isinstance(item, dict) and item.get("id") == review_id
),
None,
)
if review is None or review_id not in latest_ids:
raise StaleReviewError("Review feedback is no longer current")
comments = await fetch(f"{base}/reviews/{review_id}/comments")
reviewed_head = review.get("commit_id")
return {
"review_id": review_id,
"head_sha": head_sha,
"reviewed_head_sha": reviewed_head if isinstance(reviewed_head, str) else "",
"comments": _normalize_review_comments(comments),
}
async def release_receipt_status(repository: str, commit_sha: str) -> dict:
"""Return CI and release evidence for one exact merge commit."""
status, releases = await asyncio.gather(
@@ -2873,7 +3126,10 @@ async def pull_check_status(repository: str, number: int) -> dict:
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
status = await fetch(f"repos/{repository}/commits/{sha}/status")
status, reviews = await asyncio.gather(
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"{base}/reviews?limit=100"),
)
return {
"repository": repository,
"number": number,
@@ -2884,6 +3140,7 @@ async def pull_check_status(repository: str, number: int) -> dict:
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"reviewers": _normalize_reviewer_statuses(pull, reviews, sha),
}
@@ -2924,6 +3181,12 @@ async def merge_assigned_pull(
or ci_state != "success"
):
raise PullNotMergeableError("Pull request is not currently safe to merge")
reviews = await fetch(f"{base}/reviews?limit=100")
if any(
reviewer["blocking"]
for reviewer in _normalize_reviewer_statuses(pull, reviews, current_sha)
):
raise PullNotMergeableError("Pull request review is not currently resolved")
response = await _get_client().post(
f"/api/v1/{base}/merge",
headers=_auth(),
+114 -3
View File
@@ -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)
@@ -6640,6 +6679,50 @@ async def assigned_pull_review_data(
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews/{review_id}/feedback")
async def assigned_pull_review_feedback(
owner: str,
repo: str,
number: int = PathParam(gt=0),
review_id: int = PathParam(gt=0),
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
):
repository = f"{owner}/{repo}"
async def load_feedback():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.pull_review_feedback(
repository, number, review_id, expected_head_sha
)
try:
result = await asyncio.wait_for(
load_feedback(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StaleReviewError:
return JSONResponse(
{"error": "The pull request or review changed. Reload review data before retrying."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except TimeoutError:
return JSONResponse(
{"error": "Loading review feedback timed out. Please retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Review feedback is temporarily unavailable. Please retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/checks")
async def assigned_pull_checks(
owner: str, repo: str, number: int = PathParam(gt=0)
@@ -6773,6 +6856,34 @@ async def request_assigned_pull_review(
return JSONResponse(result)
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/request-review")
async def cancel_assigned_pull_review(
request: PullReviewRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.cancel_assigned_pull_review(
f"{owner}/{repo}", number, request.reviewer, request.expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or reviewer changed. Reload before cancelling the request."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The cancellation could not be confirmed. Refresh review data before retrying."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/release")
async def release_assigned_pull(
owner: str, repo: str, number: int = PathParam(gt=0)
@@ -0,0 +1,133 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_addresses_every_review_comment_and_requests_updated_review(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"head_sha": "current-head",
"reviewers": [{
"review_id": 42,
"login": "sam",
"status": "changes_requested",
"head_sha": "current-head",
"blocking": True,
"comments": [
{"id": 99, "path": "src/api.py", "body": "Handle the empty state.", "line": 12},
{"id": 100, "path": "frontend/app.js", "body": "Explain the retry.", "line": 8},
],
}],
"files": [
{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -11 +11 @@", "+return empty"]},
{"filename": "frontend/app.js", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -7 +7 @@", "+showRetry()"]},
],
}
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.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 => {
globalThis.confirm = () => true;
globalThis.feedbackCalls = [];
globalThis.feedbackStored = new Map();
globalThis.feedbackStorage = {
getItem:key => feedbackStored.has(key) ? feedbackStored.get(key) : null,
setItem:(key, value) => feedbackStored.set(key, String(value)),
removeItem:key => feedbackStored.delete(key),
};
globalThis.feedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.feedbackDetail = detail;
globalThis.feedbackController = createPullSheet({
storage: feedbackStorage,
createOperationId: () => 'feedback-operation',
fetchJson: async (path, options = {}) => {
feedbackCalls.push({path, options});
if (path.endsWith('/comments')) return {id: 501, body:JSON.parse(options.body).body};
if (path.endsWith('/request-review')) return {
number:7, reviewer:'sam', head_sha:'current-head', requested_reviewers:['sam']
};
throw new Error('unexpected request');
},
});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'))
).join('');
createPullSheet.bindFeedbackControls(document, feedbackController,
() => feedbackItem, () => feedbackDetail, () => 'timmy');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
assert page.locator("#pull-feedback-progress").text_content() == "Comment 1 of 2"
assert page.locator("#finish-pull-feedback").is_disabled()
page.locator('[data-feedback-disposition="addressed"]').click()
page.locator("#next-pull-feedback").click()
page.locator("#pull-feedback-note").fill("Added a visible retry message.")
page.locator('[data-feedback-disposition="discussion"]').click()
assert not page.locator("#finish-pull-feedback").is_disabled()
page.locator("#finish-pull-feedback").click()
page.locator("#request-feedback-review").click()
page.wait_for_function("feedbackCalls.length === 2")
result = page.evaluate(
"""() => ({
calls:feedbackCalls,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
controlHeights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => !button.hidden).map(button => button.getBoundingClientRect().height),
status:document.querySelector('#pull-feedback-status').textContent,
secondExpanded:document.querySelector('[data-pull-filename="frontend/app.js"] .pull-file-toggle')
.getAttribute('aria-expanded'),
persisted:Array.from(feedbackStored.keys()).some(key => key.includes('feedback-pass')),
})"""
)
result["resetHidden"] = page.evaluate(
"""() => {
createPullSheet.resetReviewRequestControls(document, feedbackDetail);
return document.querySelector('#pull-feedback-pass').hidden;
}"""
)
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["controlHeights"]) >= 44
assert result["secondExpanded"] == "true"
assert result["persisted"] is True
assert result["resetHidden"] is True
assert result["calls"][0]["path"].endswith("/comments")
assert result["calls"][0]["options"]["headers"]["Idempotency-Key"] == "feedback-operation"
summary = result["calls"][0]["options"]["body"]
assert "Handle the empty state" in summary
assert "Addressed" in summary
assert "Needs discussion" in summary
assert "Added a visible retry message" in summary
assert result["calls"][1]["path"].endswith("/request-review")
assert '"reviewer":"sam"' in result["calls"][1]["options"]["body"]
assert result["status"] == "Response posted; updated review requested from @sam."
@@ -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,88 @@
import os
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
if os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("release browser journey is opt-in", allow_module_level=True)
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_cancels_pending_review_and_opens_replacement_picker(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
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.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(
"""() => {
globalThis.confirm = () => true;
globalThis.cancelCalls = [];
globalThis.cancelItem = {
repository:'stackchain/api', number:7, key:'stackchain/api#7'
};
globalThis.cancelDetail = {
state:'open', draft:false, mergeable:true, merged:false,
ci_state:'success', head_sha:'abc1234', files:[],
reviewers:[{
login:'sam', status:'waiting', head_sha:'abc1234', blocking:true
}],
};
globalThis.cancelController = createPullSheet({
storage:null,
fetchJson:async (path, options={}) => {
cancelCalls.push({path, options});
return {
repository:'stackchain/api', number:7, reviewer:'sam',
head_sha:'abc1234', requested_reviewers:[],
};
},
});
createPullSheet.bindReviewRequestControls(
document, cancelController, () => cancelItem, () => cancelDetail
);
createPullSheet.review(cancelDetail, null, document);
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
}"""
)
cancel = page.locator('[data-cancel-review-request="sam"]')
bounds = cancel.bounding_box()
assert bounds and bounds["height"] >= 44
cancel.click()
page.wait_for_function("cancelCalls.length === 1")
page.wait_for_function(
"document.querySelector('#pull-review-request-status').textContent.includes('Choose a replacement reviewer')"
)
result = page.evaluate(
"""() => ({
calls:cancelCalls,
waitingRows:document.querySelectorAll('[data-reviewer-status="waiting"]').length,
pickerOpen:document.querySelector('#pull-review-request').open,
focused:document.activeElement?.id,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
})"""
)
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert result["waitingRows"] == 0
assert result["pickerOpen"] is True
assert result["focused"] == "load-pull-reviewers"
assert len(result["calls"]) == 1
assert result["calls"][0]["options"]["method"] == "DELETE"
assert result["calls"][0]["options"]["body"] == (
'{"reviewer":"sam","expected_head_sha":"abc1234"}'
)
@@ -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
@@ -0,0 +1,172 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_received_review_feedback_opens_matching_change(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"head_sha": "current-head",
"reviewers": [{
"login": "sam",
"status": "outdated",
"head_sha": "old-head-123456",
"blocking": True,
"summary": "Please handle the narrow empty state before merging.",
"comments": [{
"path": "src/a/very/long/mobile/path/review_target.py",
"body": "Return before parsing when the payload is empty.",
"line": 12,
}],
}],
"files": [{
"filename": "src/a/very/long/mobile/path/review_target.py",
"status": "modified",
"additions": 2,
"deletions": 1,
"diff_available": True,
"diff_lines": ["@@ -10,2 +10,3 @@", "-parse(payload)", "+if not payload: return", "+parse(payload)"],
}],
}
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.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 => {
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
createPullSheet.review(detail, null, document);
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'))
).join('');
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
jump = page.locator('[data-review-feedback-file]')
jump.click()
metrics = page.evaluate(
"""() => {
const jump = document.querySelector('[data-review-feedback-file]');
const toggle = document.querySelector('.pull-file-toggle');
const panel = document.getElementById(toggle.getAttribute('aria-controls'));
return {
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
jumpHeight: jump.getBoundingClientRect().height,
expanded: toggle.getAttribute('aria-expanded'),
panelHidden: panel.hidden,
focused: document.activeElement === toggle,
reviewedHead: document.querySelector('.pull-review-feedback').textContent,
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
};
}"""
)
browser.close()
assert metrics["scrollWidth"] <= metrics["clientWidth"]
assert metrics["jumpHeight"] >= 44
assert metrics["expanded"] == "true"
assert metrics["panelHidden"] is False
assert metrics["focused"] is True
assert "Reviewed head old-head" in metrics["reviewedHead"]
assert metrics["updatedReviewVisible"] is True
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_pull_review_retries_inline_feedback_without_losing_core_review(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success", "head_sha": "abc1234",
"reviewers": [{
"login": "sam", "review_id": 8, "status": "changes_requested",
"head_sha": "abc1234", "blocking": True,
"summary": "Please handle the empty state.",
}],
"files": [],
}
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.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.lazyFeedbackDetail = detail;
window.lazyFeedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
let attempts = 0;
const controller = createPullSheet({
storage:null,
fetchJson:() => {
attempts += 1;
if (attempts === 1) return Promise.reject(new Error('feedback unavailable'));
return Promise.resolve({
review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234',
comments:[{id:99, path:'src/api.py', body:'Return before parsing.', line:12}],
});
},
});
createPullSheet.bindFeedbackControls(
document, controller, () => window.lazyFeedbackItem,
() => window.lazyFeedbackDetail, () => 'timmy'
);
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
const eligibility = createPullSheet.review(detail, null, document);
document.querySelector('#pull-merge-state').textContent = eligibility.reason;
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
load = page.get_by_role("button", name="Load inline feedback")
load.click()
retry = page.get_by_role("button", name="Retry feedback")
retry.wait_for()
retry.click()
page.get_by_text("Return before parsing.").wait_for()
metrics = page.evaluate(
"""() => {
const details = document.querySelector('.pull-review-feedback');
const address = document.querySelector('[data-address-review-feedback]');
return {
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
blocker: document.querySelector('#pull-merge-state').textContent,
feedback: details.textContent,
addressHeight: address.getBoundingClientRect().height,
};
}"""
)
browser.close()
assert metrics["scrollWidth"] <= metrics["clientWidth"]
assert "requested changes" in metrics["blocker"]
assert "Return before parsing." in metrics["feedback"]
assert metrics["addressHeight"] >= 44
@@ -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_pull_reviewer_status_wraps_and_keeps_updated_review_reachable(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"head_sha": "current-head",
"reviewers": [
{
"login": "casey-with-a-very-long-mobile-login",
"status": "waiting",
"head_sha": "current-head",
"blocking": True,
},
{
"login": "sam",
"status": "changes_requested",
"head_sha": "current-head",
"blocking": True,
},
{
"login": "lee",
"status": "outdated",
"head_sha": "old-head",
"blocking": True,
},
{
"login": "pat",
"status": "approved",
"head_sha": "current-head",
"blocking": False,
},
],
}
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.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 => {
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
createPullSheet.review(detail, null, document);
}""",
detail,
)
metrics = page.evaluate(
"""() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
updatedReviewHeight: document.querySelector('#request-updated-pull-review')
.getBoundingClientRect().height,
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
statuses: Array.from(document.querySelectorAll('.pull-reviewer-status span'))
.map(node => node.textContent),
})"""
)
browser.close()
assert metrics["scrollWidth"] <= metrics["clientWidth"]
assert metrics["updatedReviewHeight"] >= 44
assert metrics["updatedReviewVisible"] is True
assert metrics["statuses"] == [
"Waiting for review",
"Changes requested",
"Outdated after new commits",
"Approved current head",
]
+18 -1
View File
@@ -73,7 +73,9 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
"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 -q"
"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"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
@@ -83,3 +85,18 @@ def test_browser_job_runs_packaged_today_week_reschedule_journey():
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
def test_browser_job_gates_latest_pull_review_mobile_journeys():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
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
+83 -6
View File
@@ -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
+280 -4
View File
@@ -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"
@@ -7629,6 +7696,8 @@ const controller = createPullSheet({{
return new Promise(resolve => {{ finish = resolve; }});
}}
}});
const notified = [];
controller.onReviewRequested = result => notified.push(result);
const item = {{repository:'stackchain/api',number:7}};
(async () => {{
const candidates = await controller.loadReviewCandidates(item);
@@ -7636,7 +7705,7 @@ const item = {{repository:'stackchain/api',number:7}};
const duplicate = controller.requestReview(item, 'casey', 'abc1234');
finish({{reviewer:'casey',requested_reviewers:['sam','casey']}});
const results = await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{calls,candidates,results,same:first===duplicate}}));
process.stdout.write(JSON.stringify({{calls,candidates,results,same:first===duplicate,notified}}));
}})();
"""
output = json.loads(subprocess.run(
@@ -7656,6 +7725,46 @@ const item = {{repository:'stackchain/api',number:7}};
{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]},
],
"same": True,
"notified": [{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]}],
}
def test_pull_sheet_single_flights_pending_review_cancellation():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let finish;
const controller = createPullSheet({{
storage:null,
fetchJson:(url, options={{}}) => {{
calls.push({{url, method:options.method, body:JSON.parse(options.body)}});
return new Promise(resolve => {{ finish = resolve; }});
}}
}});
const notified = [];
controller.onReviewCancelled = result => notified.push(result);
const item = {{repository:'stackchain/api',number:7}};
(async () => {{
const first = controller.cancelReview(item, 'sam', 'abc1234');
const duplicate = controller.cancelReview(item, 'sam', 'abc1234');
finish({{reviewer:'sam',head_sha:'abc1234',requested_reviewers:['casey']}});
const results = await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{calls,results,same:first===duplicate,notified}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {
"calls": [{
"url": "api/v1/repos/stackchain/api/pulls/7/request-review",
"method": "DELETE",
"body": {"reviewer": "sam", "expected_head_sha": "abc1234"},
}],
"results": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}] * 2,
"same": True,
"notified": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}],
}
@@ -7675,6 +7784,22 @@ def test_pull_sheet_exposes_touch_safe_review_request_flow():
assert "pullController.setReviewDetail(detail)" in dashboard
def test_pull_sheet_exposes_mobile_reviewer_status_and_updated_review_action():
root = PULL_SHEET.parents[1]
html = (root / "frontend" / "index.html").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
assert 'id="pull-reviewer-statuses"' in html
assert 'id="request-updated-pull-review"' in html
assert 'id="pull-reviewer-status" class="small" aria-live="polite"' in html
assert ".pull-reviewer-status" in css
assert "overflow-wrap:anywhere" in css
assert "#request-updated-pull-review { min-height:44px;" in css
assert "createPullSheet.review(detail, pullReviewState, document)" in dashboard
assert "reviewers.some(reviewer => reviewer.status === 'outdated')" in PULL_SHEET.read_text()
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -7721,6 +7846,126 @@ process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
def test_pull_sheet_renders_reviewer_decisions_and_blocks_unresolved_requests():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const base = {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'new'}};
const states = [
[{{login:'casey', status:'waiting', head_sha:'new', blocking:true}}],
[{{login:'sam', status:'changes_requested', head_sha:'new', blocking:true}}],
[{{login:'lee', status:'outdated', head_sha:'old', blocking:true}}],
[{{login:'pat', status:'approved', head_sha:'new', blocking:false}}],
];
process.stdout.write(JSON.stringify({{
eligibility:states.map(reviewers => createPullSheet.mergeEligibility({{...base, reviewers}})),
html:createPullSheet.renderReviewerStatuses(states.flat(), value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')),
}}));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["eligibility"] == [
{"allowed": False, "reason": "Waiting for @casey’s review"},
{"allowed": False, "reason": "@sam requested changes"},
{"allowed": False, "reason": "@lee’s review is outdated after new commits"},
{"allowed": True, "reason": "Ready to merge"},
]
assert "@casey" in output["html"] and "Waiting for review" in output["html"]
assert "Changes requested" in output["html"]
assert "Outdated after new commits" in output["html"]
assert "Approved current head" in output["html"]
def test_pull_sheet_offers_cancel_only_for_pending_review_requests():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const html = createPullSheet.renderReviewerStatuses([
{{login:'sam',status:'waiting'}},
{{login:'casey',status:'approved'}},
{{login:'lee',status:'commented'}},
], value => String(value));
process.stdout.write(html);
"""
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
assert html.count('data-cancel-review-request=') == 1
assert 'data-cancel-review-request="sam"' in html
assert "Cancel request" in html
assert 'data-cancel-review-request="casey"' not in html
assert 'data-cancel-review-request="lee"' not in html
def test_pull_sheet_pending_review_cancellation_is_touch_safe_and_opens_replacement_picker():
root = PULL_SHEET.parents[1]
css = (root / "frontend" / "dashboard.css").read_text()
script = PULL_SHEET.read_text()
assert ".cancel-review-request { min-height:44px;" in css
assert "controller.cancelReview(selected, reviewer, detail.head_sha)" in script
assert "Choose a replacement reviewer." in script
assert "qs('#pull-review-request').open = true" in script
def test_pull_sheet_renders_bounded_feedback_grouped_by_file():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const html = createPullSheet.renderReviewerStatuses([{{
login:'sam', status:'changes_requested', head_sha:'abc123', blocking:true,
summary:'Please <split> this helper.',
comments:[
{{path:'src/api.py', body:'Handle <empty>.', line:12}},
{{path:'src/api.py', body:'Keep the return explicit.'}},
{{path:'frontend/app.js', body:'Show the error.'}},
],
}}], value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'));
process.stdout.write(html);
"""
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
assert "Review feedback" in html
assert "Please &lt;split&gt; this helper." in html
assert html.count('data-review-feedback-file="src/api.py"') == 1
assert 'data-review-feedback-file="frontend/app.js"' in html
assert "Handle &lt;empty&gt;." in html
assert "Line 12" in html
assert "View in changes" in html
assert "Please <split>" not in html
def test_pull_sheet_marks_inline_feedback_as_on_demand_and_retryable():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const render = state => createPullSheet.renderReviewerStatuses([{{
login:'sam', review_id:8, status:'changes_requested', head_sha:'abc1234',
blocking:true, summary:'Please fix this.', feedback_state:state,
}}], value => String(value));
process.stdout.write(JSON.stringify({{idle:render(undefined), failed:render('error')}}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert "Load inline feedback" in output["idle"]
assert 'data-load-review-feedback="8"' in output["idle"]
assert "Feedback unavailable" in output["failed"]
assert "Retry feedback" in output["failed"]
assert "Address feedback" not in output["idle"]
def test_pull_sheet_binds_on_demand_feedback_with_visible_retry_state():
script = PULL_SHEET.read_text()
assert "event.target.closest?.('[data-load-review-feedback]')" in script
assert "await controller.loadFeedback(item, detail, reviewer" in script
assert "reviewer.feedback_state = 'loading'" in script
assert "reviewer.feedback_state = 'loaded'" in script
assert "reviewer.feedback_state = 'error'" in script
assert "result.head_sha !== detail.head_sha" in script
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
assert "[data-load-review-feedback] { min-height:44px; width:100%;" in css
def test_pull_sheet_persists_head_scoped_file_review_and_gates_merge():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -7796,6 +8041,37 @@ Promise.allSettled([first, concurrent]).then(async failed => {{
]
def test_pull_sheet_feedback_is_single_flight_cached_by_head_and_review():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let resolveFeedback;
const sheet = createPullSheet({{storage:null, fetchJson:url => {{
calls.push(url);
return new Promise(resolve => {{ resolveFeedback = resolve; }});
}}}});
const item = {{repository:'stackchain/api', number:7}};
const detail = {{head_sha:'abc1234'}};
const reviewer = {{review_id:8, head_sha:'abc1234'}};
const first = sheet.loadFeedback(item, detail, reviewer);
const concurrent = sheet.loadFeedback(item, detail, reviewer);
resolveFeedback({{review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234', comments:[{{id:99}}]}});
Promise.all([first, concurrent]).then(async results => {{
const cached = await sheet.loadFeedback(item, detail, reviewer);
process.stdout.write(JSON.stringify({{same:first === concurrent, results, cached, calls}}));
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["same"] is True
assert output["cached"] == output["results"][0]
assert output["calls"] == [
"api/v1/repos/stackchain/api/pulls/7/reviews/8/feedback?expected_head_sha=abc1234"
]
def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -8271,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
+444 -1
View File
@@ -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 = []
@@ -212,7 +292,152 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
@pytest.mark.anyio
async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
async def test_assigned_pull_feedback_endpoint_authorizes_and_returns_one_review(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def feedback(repository, number, review_id, expected_head_sha):
calls.append(("feedback", repository, number, review_id, expected_head_sha))
return {
"review_id": review_id, "head_sha": expected_head_sha,
"reviewed_head_sha": expected_head_sha, "comments": [],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_review_feedback", feedback, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/pulls/7/reviews/8/feedback?expected_head_sha=abc1234"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["review_id"] == 8
assert calls == [
("assigned", "stackchain/api", 7),
("feedback", "stackchain/api", 7, 8, "abc1234"),
]
def test_reviewer_statuses_distinguish_waiting_current_and_outdated_decisions():
pull = {
"requested_reviewers": [{"login": "casey"}],
"head": {"sha": "new-head"},
}
reviews = [
{"id": 1, "user": {"login": "casey"}, "state": "APPROVED", "commit_id": "old-head"},
{"id": 2, "user": {"login": "sam"}, "state": "REQUEST_CHANGES", "commit_id": "new-head"},
{"id": 3, "user": {"login": "lee"}, "state": "COMMENT", "commit_id": "new-head"},
{"id": 4, "user": {"login": "pat"}, "state": "APPROVED", "commit_id": "new-head"},
]
assert gitea_proxy._normalize_reviewer_statuses(pull, reviews, "new-head") == [
{"login": "casey", "status": "waiting", "head_sha": "new-head", "blocking": True},
{"review_id": 3, "login": "lee", "status": "commented", "head_sha": "new-head", "blocking": False},
{"review_id": 4, "login": "pat", "status": "approved", "head_sha": "new-head", "blocking": False},
{"review_id": 2, "login": "sam", "status": "changes_requested", "head_sha": "new-head", "blocking": True},
]
pull["requested_reviewers"] = []
assert gitea_proxy._normalize_reviewer_statuses(pull, reviews[:1], "new-head") == [
{"review_id": 1, "login": "casey", "status": "outdated", "head_sha": "old-head", "blocking": True},
]
def test_reviewer_status_exposes_only_bounded_nonempty_feedback_summary():
pull = {"requested_reviewers": []}
reviews = [
{
"id": 8,
"user": {"login": "sam"},
"state": "REQUEST_CHANGES",
"commit_id": "abc123",
"body": "Please split this helper. " + ("x" * 900),
},
{
"id": 9,
"user": {"login": "lee"},
"state": "COMMENT",
"commit_id": "abc123",
"body": " ",
},
]
statuses = gitea_proxy._normalize_reviewer_statuses(pull, reviews, "abc123")
sam = next(item for item in statuses if item["login"] == "sam")
lee = next(item for item in statuses if item["login"] == "lee")
assert sam["summary"].startswith("Please split this helper.")
assert len(sam["summary"]) == 500
assert "summary" not in lee
def test_review_comments_are_bounded_and_require_a_file_and_body():
comments = [
{"id": 1, "path": "src/api.py", "body": "Handle the empty state", "new_position": 4},
{"id": 2, "path": "src/api.py", "body": "x" * 900, "old_position": 7},
{"id": 3, "path": "", "body": "missing file"},
{"id": 4, "path": "src/ignored.py", "body": " "},
"malformed",
] + [
{"id": index, "path": f"src/{index}.py", "body": "bounded"}
for index in range(5, 40)
]
normalized = gitea_proxy._normalize_review_comments(comments)
assert len(normalized) == 20
assert normalized[0] == {
"id": 1, "path": "src/api.py", "body": "Handle the empty state", "line": 4,
}
assert normalized[1]["line"] == 7
assert len(normalized[1]["body"]) == 500
assert all(item["path"] and item["body"] for item in normalized)
def test_review_feedback_preserves_stable_review_and_comment_identities():
pull = {"requested_reviewers": []}
reviews = [{
"id": 42,
"user": {"login": "sam"},
"state": "REQUEST_CHANGES",
"commit_id": "abc123",
}]
statuses = gitea_proxy._normalize_reviewer_statuses(pull, reviews, "abc123")
comments = gitea_proxy._normalize_review_comments([
{"id": 99, "path": "src/api.py", "body": "Handle the empty state", "new_position": 4},
{"id": -1, "path": "src/other.py", "body": "Invalid identity is omitted"},
])
assert statuses == [{
"review_id": 42,
"login": "sam",
"status": "changes_requested",
"head_sha": "abc123",
"blocking": True,
}]
assert comments[0]["id"] == 99
assert "id" not in comments[1]
def test_inline_feedback_is_loaded_only_for_each_reviewers_latest_decision():
reviews = [
{"id": 7, "user": {"login": "sam"}, "state": "REQUEST_CHANGES"},
{"id": 8, "user": {"login": "sam"}, "state": "APPROVED"},
{"id": 9, "user": {"login": "lee"}, "state": "COMMENT"},
{"id": 10, "user": {"login": "pat"}, "state": "PENDING"},
]
assert gitea_proxy._latest_feedback_review_ids(reviews) == {"lee": 9}
@pytest.mark.anyio
async def test_gitea_pull_check_status_refreshes_reviewer_decisions_without_files_or_diff():
requests = []
async def handler(request):
@@ -221,7 +446,10 @@ async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
return httpx.Response(200, json={
"state": "open", "draft": False, "mergeable": True, "merged": False,
"head": {"sha": "abc123"},
"requested_reviewers": [{"login": "casey"}],
})
if request.url.path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[])
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={
"state": "success",
@@ -238,12 +466,16 @@ async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
("GET", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
]
assert status == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success",
"checks": [{"name": "tests", "state": "success", "description": "Passed", "url": ""}],
"reviewers": [
{"login": "casey", "status": "waiting", "head_sha": "abc123", "blocking": True},
],
}
@@ -285,7 +517,12 @@ async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
"state": "open",
"mergeable": True,
"head": {"sha": "abc123"},
"requested_reviewers": [{"login": "casey"}],
})
if path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[
{"id": 8, "user": {"login": "sam"}, "state": "APPROVED", "commit_id": "abc123"},
])
if path.endswith("/pulls/7/files"):
return httpx.Response(200, json=[
{"filename": "src/api.py", "status": "modified", "additions": 1, "deletions": 1},
@@ -315,9 +552,89 @@ async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
assert "+new" in detail["files"][0]["diff_lines"]
assert detail["files"][1]["diff_binary"] is True
assert detail["files"][1]["diff_available"] is False
assert detail["reviewers"] == [
{"login": "casey", "status": "waiting", "head_sha": "abc123", "blocking": True},
{"review_id": 8, "login": "sam", "status": "approved", "head_sha": "abc123", "blocking": False},
]
assert "conversation" not in detail
@pytest.mark.anyio
async def test_assigned_pull_review_does_not_block_on_inline_feedback():
requests = []
async def handler(request):
path = request.url.path
requests.append(path)
if path.endswith("/pulls/7"):
return httpx.Response(200, json={
"state": "open", "mergeable": True, "head": {"sha": "abc123"},
"requested_reviewers": [],
})
if path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[
{"id": 7, "user": {"login": "sam"}, "state": "COMMENT", "commit_id": "old-head"},
{"id": 8, "user": {"login": "sam"}, "state": "REQUEST_CHANGES",
"commit_id": "abc123", "body": "Please handle the empty state."},
])
if path.endswith("/pulls/7/files"):
return httpx.Response(200, json=[{"filename": "src/api.py", "status": "modified"}])
if path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if path.endswith("/pulls/7.diff"):
return httpx.Response(200, text="")
raise AssertionError(f"optional feedback must not block core review: {request.method} {path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert not any(path.endswith("/comments") for path in requests)
assert detail["reviewers"] == [{
"review_id": 8, "login": "sam", "status": "changes_requested", "head_sha": "abc123", "blocking": True,
"summary": "Please handle the empty state.",
}]
@pytest.mark.anyio
async def test_pull_review_feedback_loads_one_current_bounded_review():
requests = []
async def handler(request):
path = request.url.path
requests.append(path)
if path.endswith("/pulls/7"):
return httpx.Response(200, json={"head": {"sha": "abc123"}})
if path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[
{"id": 7, "user": {"login": "sam"}, "state": "COMMENT", "commit_id": "old-head"},
{"id": 8, "user": {"login": "sam"}, "state": "REQUEST_CHANGES", "commit_id": "abc123"},
])
if path.endswith("/pulls/7/reviews/8/comments"):
return httpx.Response(200, json=[
{"id": 99, "path": "src/api.py", "body": "Return before parsing.", "new_position": 12},
])
raise AssertionError(f"unexpected request: {request.method} {path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
feedback = await gitea_proxy.pull_review_feedback(
"stackchain/api", 7, 8, "abc123"
)
finally:
await gitea_proxy.stop_client()
assert requests[-1].endswith("/pulls/7/reviews/8/comments")
assert feedback == {
"review_id": 8,
"head_sha": "abc123",
"reviewed_head_sha": "abc123",
"comments": [{"id": 99, "path": "src/api.py", "body": "Return before parsing.", "line": 12}],
}
@pytest.mark.anyio
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
calls = []
@@ -678,6 +995,39 @@ async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
]
@pytest.mark.anyio
@pytest.mark.parametrize("requested,reviews", [
([{"login": "casey"}], []),
([], [{"id": 9, "user": {"login": "casey"}, "state": "REQUEST_CHANGES", "commit_id": "abc123"}]),
])
async def test_gitea_merge_rejects_unresolved_reviewer_status_without_mutation(requested, reviews):
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "draft": False, "mergeable": True,
"merged": False, "head": {"sha": "abc123"},
"requested_reviewers": requested,
})
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if request.url.path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=reviews)
raise AssertionError(f"unexpected mutation: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.PullNotMergeableError):
await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
finally:
await gitea_proxy.stop_client()
assert requests[-1] == ("GET", "/api/v1/repos/stackchain/api/pulls/7/reviews")
assert all(method == "GET" for method, _path in requests)
@pytest.mark.anyio
async def test_assigned_pull_ownership_endpoints_list_handoff_and_release(monkeypatch):
calls = []
@@ -764,6 +1114,30 @@ async def test_assigned_pull_review_request_endpoints_list_and_submit(monkeypatc
]
@pytest.mark.anyio
async def test_assigned_pull_cancel_review_endpoint_forwards_head_scoped_request(monkeypatch):
calls = []
async def cancel(repository, number, reviewer, expected_head_sha):
calls.append((repository, number, reviewer, expected_head_sha))
return {
"repository": repository, "number": number, "reviewer": reviewer,
"head_sha": expected_head_sha, "requested_reviewers": ["casey"],
}
monkeypatch.setattr(main.gitea_proxy, "cancel_assigned_pull_review", cancel, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.request(
"DELETE", "/api/v1/repos/stackchain/api/pulls/7/request-review",
json={"reviewer": "sam", "expected_head_sha": "abc1234"},
)
assert response.status_code == 200
assert response.json()["requested_reviewers"] == ["casey"]
assert calls == [("stackchain/api", 7, "sam", "abc1234")]
@pytest.mark.anyio
async def test_gitea_pull_handoff_preserves_coassignees_and_confirms_ownership_exit():
requests = []
@@ -884,6 +1258,73 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
assert mutation[2] == b'{"reviewers":["casey"]}'
@pytest.mark.anyio
async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal():
requests = []
cancelled = False
async def handler(request):
nonlocal cancelled
requests.append((request.method, request.url.path, request.content))
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "abc123"}, "assignees": [{"login": "timmy"}],
"requested_reviewers": [{"login": "casey"}] if cancelled else [{"login": "sam"}, {"login": "casey"}],
})
if request.method == "DELETE" and request.url.path.endswith("/requested_reviewers"):
assert request.content == b'{"reviewers":["sam"]}'
cancelled = True
return httpx.Response(204)
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.cancel_assigned_pull_review(
"stackchain/api", 7, "sam", "abc123"
)
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"requested_reviewers": ["casey"], "reviewer": "sam",
}
assert [method for method, _path, _body in requests] == ["GET", "GET", "DELETE", "GET"]
@pytest.mark.anyio
async def test_gitea_cancel_pending_pull_review_rejects_head_drift_before_mutation():
requests = []
async def handler(request):
requests.append(request.method)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "new-head"}, "assignees": [{"login": "timmy"}],
"requested_reviewers": [{"login": "sam"}],
})
if request.method == "DELETE":
return httpx.Response(204)
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.IssueNotAvailableError):
await gitea_proxy.cancel_assigned_pull_review(
"stackchain/api", 7, "sam", "old-head"
)
finally:
await gitea_proxy.stop_client()
assert requests == ["GET", "GET"]
@pytest.mark.anyio
async def test_gitea_pull_release_removes_current_login_case_insensitively():
async def handler(request):
@@ -920,6 +1361,8 @@ async def test_gitea_merge_returns_the_exact_merge_commit_for_release_tracking()
})
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if request.url.path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[])
if request.url.path.endswith("/pulls/7/merge"):
return httpx.Response(200, json={"merged": True, "sha": "merge456"})
raise AssertionError(request.url.path)
+41
View File
@@ -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)