Compare commits

...
Author SHA1 Message Date
rockachopa a9becd079f Merge pull request 'Recover closed authored pull requests from mobile Search' (#1359) from timmy/1358-reopen-closed-authored-pull-search into main
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 17:22:36 +00:00
timmy 99750d44f5 feat: recover closed authored pulls from search (Closes #1358)
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 17:12:51 +00:00
rockachopa e4d0cc9115 Merge pull request 'Close obsolete authored pull requests from mobile with recovery' (#1357) from timmy/1356-close-authored-pull-recovery into main
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m20s
CI / release-candidate (push) Successful in 7s
2026-08-24 16:28:53 +00:00
timmy 77f22f3cd5 feat: close authored pulls with recovery (Closes #1356)
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 16:18:24 +00:00
timmy fde3ba21ca Merge pull request 'Recover failed CI checks from the mobile pull workspace' (#1355) from timmy/1354-mobile-ci-recovery into main
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m55s
CI / release-candidate (push) Successful in 6s
2026-08-24 15:16:09 +00:00
timmy 8cb92c6858 feat: recover failed CI checks from mobile (Closes #1354)
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 15:06:01 +00:00
rockachopa 3e7a6c81fc Merge pull request 'feat: CI browser journey: Insights overlay does not inert page header at 320px' (#1353) from timmy/1352-ci-browser-journey-insights-overlay-does-not-ine into main
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 14:12:39 +00:00
timmy 084c0b5732 Fix mobile overlay inert ownership (#1352)
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:49:48 +00:00
rockachopa c73fbf8379 Merge pull request 'Load pull workspaces through one authorized snapshot' (#1351) from timmy/1350-authorized-pull-snapshot into main
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 5m27s
CI / release-candidate (push) Has been skipped
2026-08-24 13:24:47 +00:00
timmy b47ed5d86e perf: reuse authorized pull snapshots (Closes #1350)
CI / lint (pull_request) Successful in 3m31s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:14:48 +00:00
timmy f0f415c65d Merge pull request 'Complete authored pull request workspace access' (#1349) from timmy/1348-authored-pull-workspace into main
CI / lint (push) Successful in 3m9s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 6s
2026-08-24 12:20:21 +00:00
timmy 2102b0e5da feat: complete authored pull workspace access (Closes #1348)
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 12:10:49 +00:00
rockachopa 050390d6f6 Merge pull request 'Surface authored pull requests in My Work' (#1347) from timmy/1346-authored-pull-queue into main
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m16s
CI / release-candidate (push) Successful in 6s
2026-08-24 11:24:14 +00:00
21 changed files with 1918 additions and 87 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 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
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 tests/e2e/test_mobile_close_authored_pull_release.py tests/e2e/test_mobile_search_authored_pull_recovery_release.py
release-candidate:
runs-on: ubuntu-latest
+15 -2
View File
@@ -792,6 +792,14 @@ textarea { resize: vertical; min-height: 120px; }
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
.ci-check-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; min-height:44px; }
.ci-check-inspect { min-height:44px; white-space:nowrap; }
.ci-check-recovery { max-width:100%; min-width:0; margin:0 8px 8px; padding:10px; border:1px solid #dc2626; border-radius:8px; background:#101b2e; }
.ci-check-recovery[hidden] { display:none; }
.ci-check-recovery h3 { margin:0 0 6px; overflow-wrap:anywhere; }
.ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; }
.ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; }
.ci-check-recovery-actions button { min-height:44px; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
@@ -958,8 +966,13 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff, .pull-ownership, .pull-review-request, .pull-lifecycle { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div, .pull-lifecycle > div { display:grid; gap:8px; margin-top:10px; }
.pull-lifecycle { border-color:#b45353; }
.pull-lifecycle button { min-height:44px; border-color:#ef4444; background:#5f1d24; color:#fff; }
.pull-close-receipt { margin-top:14px; padding:12px; border:1px solid #4ade80; border-radius:12px; background:#10291e; }
.pull-close-receipt h3 { margin-top:0; }
.pull-close-receipt button { width:100%; min-height:44px; border-color:#4ade80; }
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.pull-ownership select, .pull-ownership button { min-height:44px; }
+21 -10
View File
@@ -368,6 +368,7 @@
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let pendingIssueFilingIntent = 'create-and-assign';
@@ -1279,7 +1280,7 @@
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
}, () => {
if (!pullController) {
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage, onState:refreshMyWorkView });
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
const continuing = workSession.checkpointed(item);
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
@@ -1288,7 +1289,6 @@
paintMyWork(lastContextSnapshot);
return false;
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
}
attachReleaseReceipt();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
@@ -4605,6 +4605,7 @@
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
if (!await ensurePullWorkflow(trigger)) return;
pullDetailPosition.open(workDetailIdentity('pull', item));
qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false;
@@ -5745,11 +5746,7 @@
planButton.hidden = !searchWeekPlan.eligible(detail);
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
planButton.disabled = searchWeekPlan.pending();
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
renderSearchPreviewStart(detail, state, startButton);
renderSearchPreviewWatch(detail, state, watchButton);
followingQueue.preview(state);
const shareStatus = {
@@ -5771,9 +5768,7 @@
fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
fetchReview:searchSubscription.review,
mutate:(detail,action)=>fetchReviewJson(
searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'}
),
mutate:searchPreviewMutation(fetchReviewJson),
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
followingQueue.load().catch(() => {}).then(() => result)),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
@@ -5867,6 +5862,14 @@
}
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
const recoverSearchPull = createSearchAuthoredPullRecovery({
confirm,
reopen:detail => searchPreview.reopenPull(detail),
refresh:load,
find:detail => lastMyWork.find(item => item.key === detail.repository + '#' + detail.number),
open:item => openRoutedWork(item, qs('#start-search-result')),
unavailable:message => qs('#search-preview-status').textContent = message,
});
const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
@@ -6215,6 +6218,14 @@
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (detail?.authored_pull_reopenable) {
try {
await recoverSearchPull(detail);
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
return;
}
if (detail?.reviewable) {
taskOverlayHistory.leave();
detail.kind = 'review';
+23
View File
@@ -1756,6 +1756,15 @@
<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>
<div class="ci-check-list" id="pull-check-list"></div>
<section class="ci-check-recovery" id="pull-check-recovery" aria-labelledby="pull-check-recovery-title" hidden>
<h3 id="pull-check-recovery-title" tabindex="-1">Failed check</h3>
<div id="pull-check-recovery-status" class="small" role="status" aria-live="assertive"></div>
<pre class="ci-check-log" id="pull-check-log" tabindex="0"></pre>
<div class="ci-check-recovery-actions">
<button id="retry-pull-check" type="button" disabled>Retry failed job</button>
<button id="close-pull-check-recovery" type="button">Close details</button>
</div>
</section>
</details>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
@@ -1783,6 +1792,20 @@
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
</div>
</details>
<details class="pull-lifecycle" id="pull-lifecycle" hidden>
<summary>Close pull request</summary>
<div>
<p class="small">Close an obsolete pull request without deleting its branch or discussion.</p>
<button id="close-authored-pull" type="button" hidden>Close pull request</button>
<div id="pull-lifecycle-status" class="small" role="status" aria-live="assertive"></div>
</div>
</details>
<section class="pull-close-receipt" id="pull-close-receipt" aria-labelledby="pull-close-receipt-title" hidden>
<h3 id="pull-close-receipt-title" tabindex="-1">Pull request closed.</h3>
<p class="small">This pull request was removed from active work. Reopen it if the close was accidental.</p>
<button id="reopen-authored-pull" type="button">Reopen pull request</button>
<div id="pull-reopen-status" class="small" role="status" aria-live="assertive"></div>
</section>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-pull-detail" type="button">Watch pull request</button>
+15 -1
View File
@@ -8,12 +8,25 @@
let active = false;
let previous = '#/my-work';
let retries = 0;
let backgroundInert = null;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map(o.backgrounds.map(element => [element, element.inert]));
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
}
function render(open, focus = false) {
const wasActive = active;
active = Boolean(open && o.mediaQuery.matches);
@@ -28,7 +41,8 @@
attr(o.root, 'aria-modal', active ? 'true' : null);
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
o.root.inert = !active && o.mediaQuery.matches;
o.backgrounds.forEach(element => { element.inert = active; });
if (active) containBackground();
else releaseBackground();
o.dock.hidden = active;
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
if (active) o.closeButton.focus();
+218 -5
View File
@@ -187,17 +187,28 @@ function ownershipExitMessage(item, action, transitionResult) {
'. Next work item opened.' : '. Choose the next ready Today item.');
}
function resetOwnershipControls(doc, item, checkpointed) {
function applyOwnershipCapabilities(doc, capabilities = null) {
const ownership = doc.querySelector('#pull-ownership');
const assigned = capabilities?.assigned !== false;
ownership.hidden = !assigned;
ownership.inert = !assigned;
return assigned;
}
function resetOwnershipControls(doc, item, checkpointed, capabilities = null) {
const qs = selector => doc.querySelector(selector);
const assigned = applyOwnershipCapabilities(doc, capabilities);
resetLifecycleControls(doc, null);
qs('#pull-ownership').open = false;
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-handoff-recipient').disabled = true;
qs('#confirm-pull-handoff').disabled = true;
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-pull-handoff').disabled = false;
qs('#release-pull').disabled = false;
qs('#load-pull-handoff').disabled = !assigned;
qs('#release-pull').disabled = !assigned;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#pull-handoff-status').textContent = assigned ?
'Load teammates to transfer ownership.' : 'Assignment controls are not available for this pull request.';
qs('#edit-pull-content').hidden = true;
qs('#pull-edit-form').hidden = true;
qs('#pull-edit-status').textContent = '';
@@ -227,6 +238,8 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL
load.dataset.reviewRequestBound = 'true';
controller.setReviewDetail = detail => {
resetReviewRequestControls(doc, detail);
applyOwnershipCapabilities(doc, detail?.capabilities);
resetLifecycleControls(doc, detail?.saved_at ? null : detail);
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
const publishable = detail?.state === 'open' && detail?.draft === true &&
detail?.author === getLogin?.() && detail?.head_sha;
@@ -565,6 +578,8 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
bindCheckRecovery(doc, controller, getSelected, getDetail);
bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result));
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
@@ -630,7 +645,7 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail,
});
}
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, onState, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
@@ -640,6 +655,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
let reviewRequestMutation = null;
let reviewCancelMutation = null;
let readyMutation = null;
let lifecycleMutation = null;
let editRequest = null;
let feedbackRequest = null;
const reviewRequests = new Map();
@@ -668,6 +684,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}
return {
onState,
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
@@ -711,6 +728,21 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { checkRequest = null; });
return checkRequest;
},
loadCheckFailure(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/failure?expected_head_sha=' +
encodeURIComponent(expectedHeadSha), {
headers: { Accept: 'application/json' },
});
},
retryCheck(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
});
},
loadHandoffCandidates(item) {
if (candidateRequest) return candidateRequest;
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
@@ -764,6 +796,22 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { readyMutation = null; });
return readyMutation;
},
transition(item, expectedHeadSha, action) {
if (lifecycleMutation) return lifecycleMutation;
lifecycleMutation = fetchJson(pathFor(item) + '/' + action, {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
}).then(result => {
const expectedState = action === 'close' ? 'closed' : 'open';
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
result?.state !== expectedState || result?.merged === true) {
throw new Error('Pull request state change was not confirmed.');
}
return result;
}).finally(() => { lifecycleMutation = null; });
return lifecycleMutation;
},
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
@@ -930,6 +978,168 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
};
}
function resetLifecycleControls(doc, detail) {
const authored = detail?.capabilities?.authored === true;
const lifecycle = doc.querySelector('#pull-lifecycle');
if (!lifecycle) return;
lifecycle.hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#close-authored-pull').hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-reopen-status').textContent = '';
}
function bindLifecycleControls(doc, controller, selectedPull, selectedDetail, applyResult) {
const close = doc.querySelector('#close-authored-pull');
const reopen = doc.querySelector('#reopen-authored-pull');
close.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
!globalThis.confirm('Close ' + item.key + ' at head ' + detail.head_sha.slice(0, 12) + '?')) return;
close.disabled = true;
doc.querySelector('#pull-lifecycle-status').textContent = 'Closing pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'close');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-lifecycle').hidden = true;
doc.querySelector('#pull-close-receipt').hidden = false;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-close-receipt-title').focus();
} catch (error) {
doc.querySelector('#pull-lifecycle-status').textContent = error.message + ' The pull request remains open; reload and retry.';
close.disabled = false;
close.focus();
}
});
reopen.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha) return;
reopen.disabled = true;
doc.querySelector('#pull-reopen-status').textContent = 'Reopening pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'reopen');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle').hidden = false;
close.hidden = false;
close.disabled = false;
doc.querySelector('#pull-lifecycle-status').textContent = 'Pull request reopened.';
doc.querySelector('#pull-reopen-status').textContent = '';
close.focus();
} catch (error) {
doc.querySelector('#pull-reopen-status').textContent = error.message + ' The pull request remains closed; reload and retry.';
reopen.disabled = false;
reopen.focus();
}
});
}
function bindCheckRecovery(
doc, controller, selectedPull, selectedDetail,
confirmRetry = message => globalThis.confirm(message)
) {
let current = null;
const get = selector => doc.querySelector(selector);
function reset({ restoreFocus = true } = {}) {
const recovery = current;
current = null;
get('#pull-check-recovery').hidden = true;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = '';
get('#retry-pull-check').disabled = true;
if (restoreFocus) (recovery?.trigger?.isConnected ? recovery.trigger : get('#refresh-pull-checks'))?.focus();
}
async function poll(recovery, attempt = 0) {
if (current !== recovery || selectedPull() !== recovery.item) return;
if (attempt >= 12) {
get('#pull-check-recovery-status').textContent = 'Retry is still queued. Use Refresh checks to continue monitoring.';
return;
}
await new Promise(resolve => setTimeout(resolve, 1500));
if (current !== recovery || selectedPull() !== recovery.item) return;
try {
const status = await controller.loadChecks(recovery.item);
if (current !== recovery || selectedPull() !== recovery.item) return;
if (status.head_sha !== recovery.headSha) {
get('#pull-check-recovery-status').textContent = 'New commits arrived. Reload the pull request before retrying again.';
return;
}
const check = (status.checks || []).find(candidate =>
candidate?.recovery?.run_id === recovery.runId && candidate?.recovery?.job_index === recovery.jobIndex
);
if (!check || !['failure', 'error', 'pending'].includes(check.state)) {
get('#refresh-pull-checks').click();
get('#pull-check-recovery-status').textContent = 'Retried check completed. Merge readiness refreshed.';
return;
}
get('#pull-check-recovery-status').textContent = check.state === 'pending'
? 'Retried check is running…' : 'Retry queued; waiting for a fresh run…';
} catch (_error) {
get('#pull-check-recovery-status').textContent = 'Waiting for check status…';
}
return poll(recovery, attempt + 1);
}
get('#pull-check-list').addEventListener('click', async event => {
const trigger = event.target.closest('.ci-check-inspect');
const item = selectedPull();
const detail = selectedDetail();
if (!trigger || !item || !detail?.head_sha) return;
const runId = Number(trigger.dataset.runId);
const jobIndex = Number(trigger.dataset.jobIndex);
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) return;
const recovery = {
item, headSha: detail.head_sha, runId, jobIndex, trigger,
name: trigger.closest('.ci-check')?.querySelector('strong')?.textContent || 'Failed check',
};
current = recovery;
get('#pull-check-recovery').hidden = false;
get('#pull-check-recovery-title').textContent = recovery.name;
get('#pull-check-log').textContent = 'Loading failure details…';
get('#pull-check-recovery-status').textContent = 'Loading failure details…';
get('#retry-pull-check').disabled = true;
get('#pull-check-recovery-title').focus();
try {
const result = await controller.loadCheckFailure(item, recovery.headSha, runId, jobIndex);
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = result.excerpt || 'No failure output was reported.';
get('#pull-check-recovery-status').textContent = 'Failure details loaded.';
get('#retry-pull-check').disabled = false;
} catch (error) {
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = error.message;
}
});
get('#close-pull-check-recovery').addEventListener('click', () => reset());
get('#pull-checks').addEventListener('toggle', () => {
if (current && selectedPull() !== current.item) reset({ restoreFocus: false });
});
get('#retry-pull-check').addEventListener('click', async () => {
const recovery = current;
if (!recovery || selectedPull() !== recovery.item) return;
if (!confirmRetry('Retry ' + recovery.name + ' for head ' + recovery.headSha.slice(0, 12) + '?')) return;
const button = get('#retry-pull-check');
button.disabled = true;
get('#pull-check-recovery-status').textContent = 'Queueing retry…';
try {
await controller.retryCheck(recovery.item, recovery.headSha, recovery.runId, recovery.jobIndex);
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = 'Retry queued; waiting for a fresh run…';
poll(recovery);
} catch (error) {
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = error.message;
button.disabled = false;
button.focus();
}
});
return { reset };
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.review = reviewEligibility;
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
@@ -937,6 +1147,8 @@ createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
createPullSheet.sameTarget = sameTarget;
createPullSheet.bindLifecycleControls = bindLifecycleControls;
createPullSheet.resetLifecycleControls = resetLifecycleControls;
createPullSheet.ownershipSelectors = ownershipSelectors;
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
createPullSheet.ownershipExitMessage = ownershipExitMessage;
@@ -946,4 +1158,5 @@ createPullSheet.bindContextEditor = bindContextEditor;
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
createPullSheet.bindFeedbackControls = bindFeedbackControls;
createPullSheet.bindCheckRecovery = bindCheckRecovery;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+11 -1
View File
@@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
const recovery = check.recovery;
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
: '';
const actions = inspect || link
? '<div class="ci-check-actions">' + inspect + link + '</div>'
: '';
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
'<span class="small">' + escapeHtml(check.state || 'unknown') +
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
}).join('');
return { summary, html, expanded: failed > 0 };
}
+37
View File
@@ -36,6 +36,16 @@
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewMutation = fetchJson => (detail, action) => {
if (action === 'reopen-pull') {
const path = root.searchPreviewPath(detail).replace('/issues/', '/pulls/').replace(/\/preview.*$/, '/reopen');
return fetchJson(path, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({expected_head_sha:detail.head_sha}),
});
}
return fetchJson(root.searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'});
};
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => {
@@ -102,6 +112,30 @@
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};
root.renderSearchPreviewStart = (detail, state, button) => {
const pull = detail.kind === 'pull' && detail.state === 'closed' &&
detail.authored_pull_reopenable === true;
const issue = detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)));
button.hidden = !(detail.reviewable || pull || issue);
button.textContent = detail.reviewable ? 'Review now' : pull ? 'Reopen in My Work' :
detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
button.disabled = state.status === 'claiming' || state.status === 'reopening';
};
root.createSearchAuthoredPullRecovery = o =>
async d => {
if (!o.confirm('Reopen ' + d.repository + ' #' + d.number + '?')) return 'canceled';
await o.reopen(d);
await o.refresh();
const item = o.find(d);
if (!item) {
o.unavailable('Reopened. Refresh My Work.');
return 'unavailable';
}
o.open(item);
return 'opened';
};
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
const detail = getDetail();
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
@@ -548,6 +582,9 @@
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
reopenPull(detail) {
return run('reopen-pull', 'reopening', 'reopened', detail);
},
setWatching(watching) {
if (watchRequest) return watchRequest;
if (!current || typeof watch !== 'function') {
+263 -14
View File
@@ -590,11 +590,13 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
reviewable = False
if actual_kind == "pull" and state == "open" and login:
pull = None
if actual_kind == "pull" and login:
try:
pull = await fetch(f"repos/{repository}/pulls/{number}")
except Exception:
pull = None
if actual_kind == "pull" and state == "open" and login:
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
if not isinstance(requested_reviewers, list):
requested_reviewers = []
@@ -602,6 +604,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
isinstance(reviewer, dict) and reviewer.get("login") == login
for reviewer in requested_reviewers
)
pull_author = pull.get("user") if isinstance(pull, dict) and isinstance(pull.get("user"), dict) else {}
pull_head = pull.get("head") if isinstance(pull, dict) and isinstance(pull.get("head"), dict) else {}
head_sha = pull_head.get("sha") if isinstance(pull_head.get("sha"), str) else ""
authored_pull_reopenable = bool(
actual_kind == "pull"
and state == "closed"
and isinstance(pull, dict)
and pull.get("state") == "closed"
and pull.get("merged") is not True
and login
and pull_author.get("login", "").casefold() == login.casefold()
and head_sha
)
return {
"kind": actual_kind,
"repository": repository,
@@ -623,6 +638,10 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
"assigned_to_me": bool(login and login in assignee_names),
"reviewable": reviewable,
"commentable": bool(login),
**({
"authored_pull_reopenable": authored_pull_reopenable,
"head_sha": head_sha,
} if actual_kind == "pull" else {}),
}
@@ -793,13 +812,25 @@ def _normalize_commit_checks(status: Any) -> list[dict]:
if state not in rank:
state = "unknown"
description = entry.get("description")
checks.append({
url = _safe_gitea_web_url(entry.get("target_url"))
check = {
"name": name.strip()[:120],
"state": state,
"description": description.strip()[:240] if isinstance(description, str) else "",
"url": _safe_gitea_web_url(entry.get("target_url")),
"url": url,
"_index": index,
})
}
configured_path = urlsplit(GITEA_URL).path.rstrip("/")
action_match = re.fullmatch(
re.escape(configured_path) + r"/[^/]+/[^/]+/actions/runs/(\d+)/jobs/(\d+)",
urlsplit(url).path,
)
if action_match:
check["recovery"] = {
"run_id": int(action_match.group(1)),
"job_index": int(action_match.group(2)),
}
checks.append(check)
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
@@ -1984,6 +2015,80 @@ async def pull_review_candidates(repository: str, number: int) -> list[dict]:
][:25]
def _login_can_manage_pull(login: str, pull: dict) -> bool:
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return (
author.get("login", "").casefold() == login.casefold()
or _login_in_users(login, pull.get("assignees"))
)
async def _transition_authored_pull(
repository: str,
number: int,
expected_head_sha: str,
*,
from_state: str,
to_state: 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 {}
if (
pull.get("state") != from_state
or pull.get("merged") is True
or author.get("login", "").casefold() != login.casefold()
or head.get("sha") != expected_head_sha
):
raise IssueNotAvailableError("Pull request state changed")
response = await _get_client().patch(
f"/api/v1/repos/{repository}/pulls/{number}",
headers=_auth(),
json={"state": to_state},
)
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") != to_state
or confirmed.get("merged") is True
or confirmed_head.get("sha") != expected_head_sha
):
raise ValueError("Gitea did not confirm the pull request state")
return {
"repository": repository,
"number": number,
"title": confirmed.get("title", ""),
"head_sha": expected_head_sha,
"state": to_state,
"merged": False,
}
async def close_authored_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
return await _transition_authored_pull(
repository, number, expected_head_sha, from_state="open", to_state="closed"
)
async def reopen_authored_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
return await _transition_authored_pull(
repository, number, expected_head_sha, from_state="closed", to_state="open"
)
async def request_assigned_pull_review(
repository: str, number: int, reviewer: str, expected_head_sha: str
) -> dict:
@@ -1994,7 +2099,7 @@ async def request_assigned_pull_review(
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or not _login_can_manage_pull(login, pull)
or head.get("sha") != expected_head_sha
):
raise IssueNotAvailableError("Pull request is no longer eligible for review request")
@@ -2050,7 +2155,6 @@ async def publish_authored_assigned_pull(
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
@@ -2106,7 +2210,7 @@ async def cancel_assigned_pull_review(
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or not _login_can_manage_pull(login, pull)
or head.get("sha") != expected_head_sha
or reviewer not in requested
):
@@ -2711,7 +2815,7 @@ async def resolve_work_route(
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
or (kind == "pull" and assigned)
or (kind == "pull" and (assigned or authored))
or (kind == "review" and requested)
)
)
@@ -2730,6 +2834,14 @@ async def resolve_work_route(
"is_assigned": assigned,
"work_reasons": ["created_by_me"],
} if kind == "filed" else {}),
**({
"is_assigned": assigned,
"work_reasons": [
reason for reason, present in (
("assigned_to_me", assigned), ("authored_by_me", authored)
) if present
],
} if kind == "pull" else {}),
}
@@ -2809,6 +2921,34 @@ async def is_requested_review(repository: str, number: int) -> bool:
)
async def pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
open_pull = pull.get("state") == "open" and pull.get("merged") is not True
return {
"authored": open_pull and author.get("login", "").casefold() == login.casefold(),
"assigned": open_pull and _login_in_users(login, pull.get("assignees")),
}
async def pull_workspace_snapshot(repository: str, number: int) -> dict:
"""Resolve one canonical pull and the access granted by that exact snapshot."""
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
open_pull = pull.get("state") == "open" and pull.get("merged") is not True
return {
"pull": pull,
"capabilities": {
"authored": open_pull and author.get("login", "").casefold() == login.casefold(),
"assigned": open_pull and _login_in_users(login, pull.get("assignees")),
},
}
async def is_assigned_pull(repository: str, number: int) -> bool:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
@@ -2833,8 +2973,7 @@ async def update_authored_assigned_pull(
if (
pull.get("state") != "open"
or pull.get("merged") is True
or author.get("login") != login
or not _login_in_users(login, pull.get("assignees"))
or author.get("login", "").casefold() != login.casefold()
):
raise IssueNotAvailableError("pull request not found")
if head.get("sha") != expected_head_sha:
@@ -2865,9 +3004,12 @@ async def update_authored_assigned_pull(
}
async def pull_completion_detail(repository: str, number: int) -> dict:
async def pull_completion_detail(
repository: str, number: int, pull: dict | None = None
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if pull is None:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
conversation = await issue_conversation_page(repository, number)
@@ -2987,9 +3129,12 @@ def _normalize_reviewer_statuses(pull: dict, reviews: object, head_sha: str) ->
return sorted(statuses.values(), key=lambda item: item["login"].casefold())
async def pull_completion_review(repository: str, number: int) -> dict:
async def pull_completion_review(
repository: str, number: int, pull: dict | None = None
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if pull is None:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head_value = pull.get("head")
@@ -3144,6 +3289,110 @@ async def pull_check_status(repository: str, number: int) -> dict:
}
def _bounded_action_log_excerpt(value: str, max_bytes: int = 24 * 1024) -> str:
redacted = re.sub(
r"(?im)^(authorization\s*:)\s*[^\r\n]*$",
r"\1 [redacted]",
value,
)
redacted = re.sub(
r"(?im)\b(gitea_token|github_token|access_token)=\S+",
r"\1=[redacted]",
redacted,
)
encoded = redacted.encode("utf-8")
if len(encoded) <= max_bytes:
return redacted
return encoded[-max_bytes:].decode("utf-8", errors="ignore")
async def action_failure_excerpt(
repository: str,
number: int,
expected_head_sha: str,
run_id: int,
job_index: int,
) -> dict:
pull = await fetch(f"repos/{repository}/pulls/{number}")
head = pull.get("head") if isinstance(pull, dict) else None
sha = head.get("sha") if isinstance(head, dict) else None
if sha != expected_head_sha:
raise StalePullError("Pull request head changed")
status = await fetch(f"repos/{repository}/commits/{sha}/status")
matching = next(
(
check
for check in _normalize_commit_checks(status)
if check.get("state") in {"failure", "error"}
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
),
None,
)
if matching is None:
raise ValueError("Failed Gitea Actions job is unavailable")
response = await _get_client().get(
f"{GITEA_URL}/{quote(repository, safe='/')}/actions/runs/{run_id}/jobs/{job_index}/logs",
headers=_auth(),
)
response.raise_for_status()
return {
"head_sha": sha,
"run_id": run_id,
"job_index": job_index,
"name": matching["name"],
"excerpt": _bounded_action_log_excerpt(response.text),
}
async def retry_action_job(
repository: str,
number: int,
expected_head_sha: str,
run_id: int,
job_index: int,
) -> dict:
pull = await fetch(f"repos/{repository}/pulls/{number}")
head = pull.get("head") if isinstance(pull, dict) else None
sha = head.get("sha") if isinstance(head, dict) else None
if sha != expected_head_sha:
raise StalePullError("Pull request head changed")
status = await fetch(f"repos/{repository}/commits/{sha}/status")
matching = next(
(
check
for check in _normalize_commit_checks(status)
if check.get("state") in {"failure", "error"}
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
),
None,
)
if matching is None:
raise ValueError("Failed Gitea Actions job is unavailable")
job_url = (
f"{GITEA_URL}/{quote(repository, safe='/')}"
f"/actions/runs/{run_id}/jobs/{job_index}"
)
page = await _get_client().get(job_url, headers=_auth())
page.raise_for_status()
csrf_match = re.search(r"\bcsrfToken:\s*'([^']+)'", page.text)
if not csrf_match:
raise ValueError("Gitea did not provide a retry authorization token")
csrf_token = csrf_match.group(1)
response = await _get_client().post(
f"{job_url}/rerun",
headers={**_auth(), "X-Csrf-Token": csrf_token},
data={"_csrf": csrf_token},
)
if response.is_error:
response.raise_for_status()
return {
"head_sha": sha,
"run_id": run_id,
"job_index": job_index,
"status": "queued",
}
async def is_pull_merged_at_head(
repository: str, number: int, expected_head_sha: str
) -> bool:
+212 -24
View File
@@ -6173,8 +6173,10 @@ async def edit_assigned_pull_comment(
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
@@ -6190,8 +6192,10 @@ async def delete_assigned_pull_comment(
),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@@ -6555,14 +6559,31 @@ async def requested_review_checks(
)
async def _pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]:
# Preserve the established fast path for assigned work while widening access
# only after Gitea confirms authorship of the same open pull request.
if await gitea_proxy.is_assigned_pull(repository, number):
return {"authored": False, "assigned": True}
return await gitea_proxy.pull_workspace_capabilities(repository, number)
def _has_pull_workspace_access(capabilities: dict[str, bool]) -> bool:
return capabilities.get("authored") is True or capabilities.get("assigned") is True
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/detail")
async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
async def load_assigned_pull():
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_completion_detail(repository, number)
snapshot = await gitea_proxy.pull_workspace_snapshot(repository, number)
capabilities = snapshot["capabilities"]
if not _has_pull_workspace_access(capabilities):
raise HTTPException(status_code=404, detail="Pull request not found")
detail = await gitea_proxy.pull_completion_detail(
repository, number, snapshot["pull"]
)
return {**detail, "capabilities": capabilities}
try:
return await asyncio.wait_for(
@@ -6648,6 +6669,58 @@ async def publish_authored_assigned_pull(
return JSONResponse(result)
async def _transition_authored_pull(
update: PullReadyRequest,
owner: str,
repo: str,
number: int,
transition,
action: str,
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
transition(repository, number, update.expected_head_sha),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": f"The pull request changed. Reload before {action}."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": f"The pull request could not be {action}. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/close")
async def close_authored_pull(
update: PullReadyRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
return await _transition_authored_pull(
update, owner, repo, number, gitea_proxy.close_authored_pull, "closed"
)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/reopen")
async def reopen_authored_pull(
update: PullReadyRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
return await _transition_authored_pull(
update, owner, repo, number, gitea_proxy.reopen_authored_pull, "reopened"
)
@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)
@@ -6655,9 +6728,14 @@ async def assigned_pull_review_data(
repository = f"{owner}/{repo}"
async def load_assigned_pull_review():
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_completion_review(repository, number)
snapshot = await gitea_proxy.pull_workspace_snapshot(repository, number)
capabilities = snapshot["capabilities"]
if not _has_pull_workspace_access(capabilities):
raise HTTPException(status_code=404, detail="Pull request not found")
review = await gitea_proxy.pull_completion_review(
repository, number, snapshot["pull"]
)
return {**review, "capabilities": capabilities}
try:
return await asyncio.wait_for(
@@ -6690,8 +6768,10 @@ async def assigned_pull_review_feedback(
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")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.pull_review_feedback(
repository, number, review_id, expected_head_sha
)
@@ -6730,8 +6810,10 @@ async def assigned_pull_checks(
repository = f"{owner}/{repo}"
async def load_checks():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.pull_check_status(repository, number)
try:
@@ -6754,6 +6836,106 @@ async def assigned_pull_checks(
)
@app.get(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/failure"
)
async def pull_action_failure(
owner: str,
repo: str,
number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def load_excerpt():
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.action_failure_excerpt(
repository, number, expected_head_sha, run_id, job_index
)
try:
result = await asyncio.wait_for(
load_excerpt(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits arrived. Reload checks before diagnosing this job."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except ValueError:
return JSONResponse(
{"error": "This failed check does not expose a recoverable Actions job."},
status_code=404,
headers={"Cache-Control": "no-store"},
)
except Exception:
return JSONResponse(
{"error": "The failure log is temporarily unavailable. Open the job or retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/retry"
)
async def retry_pull_action_job(
retry: PullReadyRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def retry_job():
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.retry_action_job(
repository, number, retry.expected_head_sha, run_id, job_index
)
try:
result = await asyncio.wait_for(
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits arrived. Reload checks before retrying this job."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except ValueError:
return JSONResponse(
{"error": "This check is no longer failed or cannot be retried."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except Exception:
return JSONResponse(
{"error": "The failed job could not be queued. Open the job or retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(
result, status_code=202, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff-candidates")
async def pull_handoff_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)
@@ -6811,8 +6993,10 @@ async def pull_review_candidates(
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
result = await asyncio.wait_for(
gitea_proxy.pull_review_candidates(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
@@ -6915,8 +7099,10 @@ async def assigned_pull_conversation(
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
@@ -6944,8 +7130,10 @@ async def comment_on_assigned_pull(
repository = f"{owner}/{repo}"
async def post_comment():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
@@ -6986,8 +7174,8 @@ async def merge_assigned_pull(
)
try:
assigned = await asyncio.wait_for(
gitea_proxy.is_assigned_pull(repository, number),
capabilities = await asyncio.wait_for(
_pull_workspace_capabilities(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
@@ -6996,8 +7184,8 @@ async def merge_assigned_pull(
status_code=503,
headers={"Retry-After": "1"},
)
if not assigned:
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(capabilities):
raise HTTPException(status_code=404, detail="Pull request not found")
journal = _security_event_store()
try:
@@ -26,6 +26,7 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
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.add_script_tag(path=FRONTEND / "pull-sheet.js")
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
row = page.locator('[data-mobile-queue="authored"]')
@@ -63,6 +64,25 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
"opened": "opened",
"count": 1,
}
capabilities = page.evaluate("""() => {
const item = {key:'stackchain/dashboard#41'};
createPullSheet.resetOwnershipControls(
document, item, () => false, {authored:true, assigned:false}
);
const ownership = document.querySelector('#pull-ownership');
return {
hidden: ownership.hidden,
inert: ownership.inert,
releaseDisabled: document.querySelector('#release-pull').disabled,
handoffDisabled: document.querySelector('#load-pull-handoff').disabled,
};
}""")
assert capabilities == {
"hidden": True,
"inert": True,
"releaseDisabled": True,
"handoffDisabled": True,
}
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
@@ -0,0 +1,87 @@
import os
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
pytestmark = pytest.mark.skipif(
os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1",
reason="release browser journey is opt-in",
)
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_closes_obsolete_pull_then_recovers_it(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.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(
"""() => {
window.lifecycleCalls = [];
window.lifecycleItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
window.lifecycleDetail = {
number:7, title:'Obsolete experiment', author:'timmy', state:'open',
merged:false, head_sha:'abc1234', capabilities:{authored:true, assigned:false},
};
const controller = createPullSheet({
storage:null,
fetchJson:async (path, options) => {
lifecycleCalls.push({path, method:options.method, body:JSON.parse(options.body)});
const state = path.endsWith('/close') ? 'closed' : 'open';
return {repository:'stackchain/api', number:7, title:'Obsolete experiment',
head_sha:'abc1234', state, merged:false};
},
});
createPullSheet.bindLifecycleControls(
document, controller, () => lifecycleItem, () => lifecycleDetail,
result => { lifecycleDetail = {...lifecycleDetail, ...result}; }
);
createPullSheet.resetLifecycleControls(document, lifecycleDetail);
document.querySelector('#pull-sheet').classList.add('open');
}"""
)
page.locator("#pull-lifecycle summary").click()
close = page.get_by_role("button", name="Close pull request")
before = close.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
close.click()
page.get_by_text("Pull request closed.").wait_for()
reopen = page.get_by_role("button", name="Reopen pull request")
receipt = reopen.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.closest('[hidden]') !== null})")
reopen.click()
page.get_by_text("Pull request reopened.").wait_for()
metrics = page.evaluate(
"""() => ({
calls:lifecycleCalls,
state:lifecycleDetail.state,
closeHidden:document.querySelector('#close-authored-pull').hidden,
receiptHidden:document.querySelector('#pull-close-receipt').hidden,
focused:document.activeElement === document.querySelector('#close-authored-pull'),
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
})"""
)
browser.close()
assert before["hidden"] is False
assert before["height"] >= 44
assert receipt["height"] >= 44
assert receipt["hidden"] is False
assert [call["path"].rsplit("/", 1)[-1] for call in metrics["calls"]] == ["close", "reopen"]
assert all(call["body"] == {"expected_head_sha": "abc1234"} for call in metrics["calls"])
assert metrics["state"] == "open"
assert metrics["closeHidden"] is False
assert metrics["receiptHidden"] is True
assert metrics["focused"] is True
assert metrics["scrollWidth"] <= metrics["clientWidth"]
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("rendered authored-pull recovery checks run only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_mobile_search_reopens_closed_authored_pull_into_my_work(width, height):
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
page.locator("#search-preview").evaluate("node => node.classList.add('open')")
page.evaluate("""() => {
const detail = {
repository:'stackchain/web', number:9, kind:'pull', state:'closed',
authored_pull_reopenable:true, head_sha:'abc1234',
};
const button = document.querySelector('#start-search-result');
globalThis.renderSearchPreviewStart(detail, {status:'ready'}, button);
globalThis.recoveryMetrics = {calls:[], history:['search']};
const mutate = globalThis.searchPreviewMutation((path, options) => {
globalThis.recoveryMetrics.calls.push({path, body:JSON.parse(options.body)});
return Promise.resolve({...detail, state:'open'});
});
const preview = globalThis.createSearchPreview({
fetchJson:async () => detail,
mutate,
onState:() => {},
});
preview.open(detail);
const recover = globalThis.createSearchAuthoredPullRecovery({
confirm:message => window.confirm(message),
reopen:target => preview.reopenPull(target),
refresh:async () => { globalThis.recoveryMetrics.refreshed = true; },
find:() => ({repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}),
open:item => globalThis.recoveryMetrics.history.push(item.kind),
unavailable:message => { globalThis.recoveryMetrics.unavailable = message; },
});
button.addEventListener('click', () => recover(detail));
}""")
action = page.locator("#start-search-result")
expect(action).to_be_visible()
expect(action).to_have_text("Reopen in My Work")
bounds = action.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.once("dialog", lambda dialog: dialog.accept())
action.click()
page.wait_for_function("globalThis.recoveryMetrics.history.length === 2")
metrics = page.evaluate("globalThis.recoveryMetrics")
assert metrics["calls"] == [{
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
"body": {"expected_head_sha": "abc1234"},
}]
assert metrics["refreshed"] is True
assert metrics["history"] == ["search", "pull"]
assert "unavailable" not in metrics
browser.close()
+14
View File
@@ -100,3 +100,17 @@ def test_browser_job_gates_authored_pull_queue_journey():
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser
def test_browser_job_gates_authored_pull_close_recovery_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_close_authored_pull_release.py" in browser
def test_browser_job_gates_search_authored_pull_recovery_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_search_authored_pull_recovery_release.py" in browser
+146 -5
View File
@@ -827,6 +827,143 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_closed_authored_pull_reopen_sends_head_guard_to_pull_api():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls = [];
const mutate = globalThis.searchPreviewMutation(async (path, options) => {{
calls.push({{path, options}});
return {{repository:'stackchain/web',number:9,state:'open',head_sha:'abc1234'}};
}});
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const result = await mutate(detail, 'reopen-pull');
process.stdout.write(JSON.stringify({{calls,result}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [{
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
"options": {
"method": "PATCH",
"headers": {"Content-Type": "application/json"},
"body": '{"expected_head_sha":"abc1234"}',
},
}],
"result": {
"repository": "stackchain/web",
"number": 9,
"state": "open",
"head_sha": "abc1234",
},
}
def test_search_preview_closed_authored_pull_exposes_reopen_in_my_work_action():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const ready = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewStart(detail, {{status:'ready'}}, ready);
const pending = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewStart(detail, {{status:'reopening'}}, pending);
process.stdout.write(JSON.stringify({{ready,pending}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"ready": {
"hidden": False,
"textContent": "Reopen in My Work",
"disabled": False,
},
"pending": {
"hidden": False,
"textContent": "Reopen in My Work",
"disabled": True,
},
}
def test_search_preview_closed_authored_pull_reopen_is_single_flight():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
let resolveMutation;
const states = [];
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const preview = createSearchPreview({{
fetchJson:async () => detail,
mutate:(_detail, action) => {{
if (action !== 'reopen-pull') throw new Error('wrong action');
calls += 1;
return new Promise(resolve => {{ resolveMutation = resolve; }});
}},
onState:state => states.push(state),
}});
await preview.open(detail);
const first = preview.reopenPull(detail);
const second = preview.reopenPull(detail);
if (first !== second || calls !== 1) throw new Error('pull recovery was not single-flight');
resolveMutation({{...detail,state:'open'}});
await first;
process.stdout.write(JSON.stringify({{calls,status:states.at(-1).status}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"calls": 1, "status": "reopened"}
def test_authored_pull_recovery_confirms_refreshes_and_opens_above_search_history():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const events = [];
const history = ['search'];
const detail = {{repository:'stackchain/web',number:9,kind:'pull',head_sha:'abc1234'}};
const item = {{repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}};
const recover = globalThis.createSearchAuthoredPullRecovery({{
confirm:message => {{ events.push(['confirm',message]); return true; }},
reopen:target => {{ events.push(['reopen',target.head_sha]); return Promise.resolve({{...target,state:'open'}}); }},
refresh:() => {{ events.push(['refresh']); return Promise.resolve(); }},
find:target => target.number === 9 ? item : null,
open:opened => {{ events.push(['open',opened.key]); history.push('pull'); }},
unavailable:message => events.push(['unavailable',message]),
}});
const outcome = await recover(detail);
process.stdout.write(JSON.stringify({{events,history,outcome}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["events"] == [
["confirm", "Reopen stackchain/web #9?"],
["reopen", "abc1234"],
["refresh"],
["open", "stackchain/web#9"],
]
assert payload["history"] == ["search", "pull"]
assert payload["outcome"] == "opened"
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -1159,9 +1296,11 @@ def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claim
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert "startButton.hidden = !(detail.reviewable" in html
assert "detail.reviewable ? 'Review now'" in html
assert "renderSearchPreviewStart(detail, state, startButton)" in html
assert "button.hidden = !(detail.reviewable" in preview
assert "detail.reviewable ? 'Review now'" in preview
handler = html.split(
"qs('#start-search-result').addEventListener('click'", 1
)[1].split("qs('#close-whiteboard')", 1)[0]
@@ -1176,9 +1315,10 @@ def test_requested_pull_preview_opens_native_review_without_assignment_mutation(
def test_search_preview_offers_assign_and_start_for_eligible_issues():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert 'id="start-search-result"' in html
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in preview
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" in css
@@ -1332,9 +1472,10 @@ process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
preview = SEARCH_PREVIEW.read_text()
assert "detail.reopenable ? 'Reopen & resume'" in html
assert "mutate:(detail,action)=>fetchReviewJson(" in html
assert "detail.reopenable ? 'Reopen & resume'" in preview
assert "mutate:searchPreviewMutation(fetchReviewJson)" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
)[0]
+102
View File
@@ -875,6 +875,108 @@ async def test_work_preview_marks_authoritative_requested_pull_review_actionable
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_offers_closed_authored_unmerged_pull_recovery_with_head_guard():
requested_paths = []
async def handler(request):
requested_paths.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": False,
"user": {"login": "timmy"},
"head": {"sha": "abc1234"},
"requested_reviewers": [],
})
return httpx.Response(200, json={
"number": 9,
"title": "Recover mobile flow",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "timmy"},
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is True
assert preview["head_sha"] == "abc1234"
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_does_not_offer_closed_pull_recovery_to_non_author():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": False,
"user": {"login": "alexander"},
"head": {"sha": "abc1234"},
})
return httpx.Response(200, json={
"number": 9,
"title": "Foreign pull",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "alexander"},
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is False
@pytest.mark.anyio
async def test_work_preview_does_not_offer_recovery_for_merged_pull():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": True,
"user": {"login": "timmy"},
"head": {"sha": "abc1234"},
})
return httpx.Response(200, json={
"number": 9,
"title": "Merged pull",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "timmy"},
"pull_request": {"merged": True},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is False
@pytest.mark.anyio
async def test_work_preview_offers_reopen_only_for_closed_issues():
async def handler(request):
+31
View File
@@ -133,6 +133,37 @@ process.stdout.write(JSON.stringify({{current:controller.current(),hash:location
}
def test_late_inactive_route_event_does_not_release_background_owned_by_next_overlay():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), header=element();
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
const history={{state:null, pushState() {{}}, back() {{}}, replaceState(state,unused,url) {{
this.state=state; location.hash=url.slice(url.indexOf('#'));
}}}};
const controller=createInsights({{
root,launcher,closeButton,dock,hud,backgrounds:[header],history,location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true,addEventListener() {{}}}},
}});
controller.start();
closeButton.listeners.click();
header.inert = true;
listeners.hashchange();
process.stdout.write(JSON.stringify({{current:controller.current(), headerInert:header.inert}}));
"""
assert run_node(script) == {"current": False, "headerInert": True}
def test_escape_requests_back_navigation_only_while_insights_is_open():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
+103
View File
@@ -7642,6 +7642,39 @@ process.stdout.write(JSON.stringify({{
assert output["released"] == "stackchain/api#7 released. Choose the next ready Today item."
def test_authored_unassigned_pull_hides_assignment_ownership_controls():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const elements = new Map([
['#pull-ownership', {{open:true,hidden:false,inert:false}}],
['#pull-handoff-recipient', {{innerHTML:'stale',disabled:false}}],
['#confirm-pull-handoff', {{disabled:false,textContent:''}}],
['#load-pull-handoff', {{disabled:false}}],
['#release-pull', {{disabled:false,textContent:''}}],
['#pull-handoff-status', {{textContent:''}}],
['#edit-pull-content', {{hidden:false}}],
['#pull-edit-form', {{hidden:false}}],
['#pull-edit-status', {{textContent:'stale'}}],
]);
const doc = {{querySelector:selector => elements.get(selector)}};
createPullSheet.resetOwnershipControls(
doc,
{{key:'stackchain/api#7'}},
() => false,
{{authored:true,assigned:false}}
);
process.stdout.write(JSON.stringify(Object.fromEntries(elements)));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["#pull-ownership"]["hidden"] is True
assert output["#pull-ownership"]["inert"] is True
assert output["#load-pull-handoff"]["disabled"] is True
assert output["#release-pull"]["disabled"] is True
def test_pull_sheet_single_flights_handoff_and_release_ownership_mutations():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -8670,6 +8703,76 @@ process.stdout.write(JSON.stringify({{result, offline}}));
assert output["offline"]["summary"].startswith("Last known · ")
def test_failed_actions_check_renders_in_app_recovery_control_only_when_supported():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const supported = reviewSheet.renderChecks([
{{name:'CI / lint', state:'failure', url:'https://forge.example/jobs/3', recovery:{{run_id:91, job_index:3}}}},
], escapeHtml);
const external = reviewSheet.renderChecks([
{{name:'Vendor scan', state:'failure', url:'https://forge.example/jobs/4'}},
], escapeHtml);
process.stdout.write(JSON.stringify({{supported, external}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert 'class="ci-check-inspect"' in output["supported"]["html"]
assert 'data-run-id="91"' in output["supported"]["html"]
assert 'data-job-index="3"' in output["supported"]["html"]
assert "Inspect failure" in output["supported"]["html"]
assert "ci-check-inspect" not in output["external"]["html"]
assert "Open job" in output["external"]["html"]
def test_pull_controller_loads_failure_and_retries_exact_head():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
const controller = createPullSheet({{
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{status:'queued'}}; }},
storage: null,
}});
const item = {{repository:'stackchain/api', number:7}};
controller.loadCheckFailure(item, 'abc1234', 91, 3).then(() =>
controller.retryCheck(item, 'abc1234', 91, 3)
).then(() => process.stdout.write(JSON.stringify(calls)));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
calls = json.loads(result.stdout)
assert calls[0]["url"].endswith(
"/pulls/7/checks/91/jobs/3/failure?expected_head_sha=abc1234"
)
assert calls[0]["options"]["headers"]["Accept"] == "application/json"
assert calls[1]["url"].endswith("/pulls/7/checks/91/jobs/3/retry")
assert calls[1]["options"]["method"] == "POST"
assert json.loads(calls[1]["options"]["body"]) == {"expected_head_sha": "abc1234"}
@pytest.mark.anyio
async def test_mobile_pull_workspace_packages_accessible_ci_recovery_panel():
html = await dashboard()
assert 'id="pull-check-recovery"' in html
assert 'id="pull-check-recovery-title"' in html
assert 'id="pull-check-log"' in html
assert 'id="retry-pull-check"' in html
assert 'id="close-pull-check-recovery"' in html
assert 'aria-live="assertive"' in html
assert "createPullSheet.bindOwnershipControls" in html
assert "#pull-check-list" in html and "ci-check-inspect" in html
assert ".ci-check-recovery" in html and "max-width:100%" in html
assert ".ci-check-log" in html and "overflow-x:auto" in html
assert ".ci-check-actions" in html and "min-height:44px" in html
@pytest.mark.anyio
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
html = await dashboard()
+489 -24
View File
@@ -8,6 +8,129 @@ from src import gitea_proxy, main
from src.security_event_store import SecurityEventStoreError
@pytest.mark.anyio
async def test_gitea_closes_authored_pull_at_expected_head_and_verifies_state():
requests = []
pull = {
"number": 7,
"title": "Obsolete experiment",
"state": "open",
"merged": False,
"user": {"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) == {"state": "closed"}
pull["state"] = "closed"
return httpx.Response(200, 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.close_authored_pull("stackchain/api", 7, "abc1234")
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api",
"number": 7,
"title": "Obsolete experiment",
"head_sha": "abc1234",
"state": "closed",
"merged": 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_gitea_reopens_authored_unmerged_pull_at_expected_head():
pull = {
"number": 7,
"title": "Obsolete experiment",
"state": "closed",
"merged": False,
"user": {"login": "alex"},
"head": {"sha": "abc1234"},
}
async def handler(request):
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) == {"state": "open"}
pull["state"] = "open"
return httpx.Response(200, 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.reopen_authored_pull("stackchain/api", 7, "abc1234")
finally:
await gitea_proxy.stop_client()
assert result["state"] == "open"
assert result["head_sha"] == "abc1234"
@pytest.mark.anyio
async def test_author_can_close_then_reopen_pull_through_api(monkeypatch):
calls = []
async def transition(repository, number, expected_head_sha, state):
calls.append((state, repository, number, expected_head_sha))
return {
"repository": repository,
"number": number,
"head_sha": expected_head_sha,
"state": state,
"merged": False,
}
monkeypatch.setattr(
main.gitea_proxy,
"close_authored_pull",
lambda repository, number, head: transition(repository, number, head, "closed"),
)
monkeypatch.setattr(
main.gitea_proxy,
"reopen_authored_pull",
lambda repository, number, head: transition(repository, number, head, "open"),
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
closed = await client.patch(
"/api/v1/repos/stackchain/api/pulls/7/close",
json={"expected_head_sha": "abc1234"},
)
reopened = await client.patch(
"/api/v1/repos/stackchain/api/pulls/7/reopen",
json={"expected_head_sha": "abc1234"},
)
assert closed.status_code == 200
assert closed.json()["state"] == "closed"
assert reopened.status_code == 200
assert reopened.json()["state"] == "open"
assert calls == [
("closed", "stackchain/api", 7, "abc1234"),
("open", "stackchain/api", 7, "abc1234"),
]
@pytest.mark.anyio
async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypatch):
calls = []
@@ -39,7 +162,7 @@ async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypat
@pytest.mark.anyio
async def test_gitea_publishes_only_authored_assigned_draft_and_verifies_ready_head():
async def test_gitea_publishes_authored_unassigned_draft_and_verifies_ready_head():
requests = []
pull = {
"number": 7,
@@ -48,7 +171,7 @@ async def test_gitea_publishes_only_authored_assigned_draft_and_verifies_ready_h
"draft": True,
"merged": False,
"user": {"login": "alex"},
"assignees": [{"login": "alex"}],
"assignees": [],
"head": {"sha": "abc1234"},
}
@@ -131,7 +254,7 @@ async def test_pull_author_can_update_assigned_open_pull_context(monkeypatch):
@pytest.mark.anyio
async def test_gitea_pull_context_update_requires_author_assignment_and_matching_head():
async def test_gitea_pull_context_update_allows_author_without_assignment_at_matching_head():
requests = []
async def handler(request):
@@ -141,7 +264,7 @@ async def test_gitea_pull_context_update_requires_author_assignment_and_matching
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
return httpx.Response(200, json={
"number": 7, "title": "Old context", "body": "Old body", "state": "open",
"merged": False, "user": {"login": "alex"}, "assignees": [{"login": "alex"}],
"merged": False, "user": {"login": "alex"}, "assignees": [],
"head": {"sha": "abc1234"},
"html_url": "https://forge.example/stackchain/api/pulls/7",
})
@@ -173,12 +296,116 @@ async def test_gitea_pull_context_update_requires_author_assignment_and_matching
@pytest.mark.anyio
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def test_gitea_authored_unassigned_pull_has_workspace_access():
requests = []
async def detail(repository, number):
async def handler(request):
requests.append((request.method, request.url.path))
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":
return httpx.Response(200, json={
"number": 7,
"state": "open",
"merged": False,
"user": {"login": "alex"},
"assignees": [],
})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
capabilities = await gitea_proxy.pull_workspace_capabilities("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert capabilities == {"authored": True, "assigned": False}
assert requests == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
]
@pytest.mark.anyio
async def test_authored_pull_detail_reuses_one_authorized_snapshot():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
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":
return httpx.Response(200, json={
"number": 7,
"title": "Ship mobile flow",
"body": "Ready for review",
"state": "open",
"merged": False,
"head": {"sha": "abc1234"},
"user": {"login": "alex"},
"assignees": [],
})
if request.url.path == "/api/v1/repos/stackchain/api/issues/7/comments":
return httpx.Response(200, json=[], headers={"X-Total-Count": "0"})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
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/detail")
finally:
await gitea_proxy.stop_client()
assert response.status_code == 200
assert response.json()["capabilities"] == {"authored": True, "assigned": False}
assert requests.count(("GET", "/api/v1/user")) == 1
assert requests.count(("GET", "/api/v1/repos/stackchain/api/pulls/7")) == 1
assert requests.count(("GET", "/api/v1/repos/stackchain/api/issues/7/comments")) == 1
@pytest.mark.anyio
async def test_authored_unassigned_pull_detail_returns_capabilities(monkeypatch):
pull = {"number": 7}
async def snapshot(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {"pull": pull, "capabilities": {"authored": True, "assigned": False}}
async def detail(repository, number, authorized_pull):
assert authorized_pull is pull
return {
"repository": repository,
"number": number,
"title": "Ship mobile flow",
"body": "Ready for review",
"author": "alex",
"head_sha": "abc1234",
"state": "open",
"conversation": {"comments": [], "page": 1, "older_page": None, "total": 0},
}
monkeypatch.setattr(main.gitea_proxy, "pull_workspace_snapshot", snapshot)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_detail", detail)
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/detail")
assert response.status_code == 200
assert response.json()["capabilities"] == {"authored": True, "assigned": False}
@pytest.mark.anyio
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
pull = {"number": 7}
async def snapshot(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {"pull": pull, "capabilities": {"authored": False, "assigned": True}}
async def detail(repository, number, authorized_pull):
assert (repository, number) == ("stackchain/api", 7)
assert authorized_pull is pull
return {
"repository": repository,
"number": number,
@@ -196,7 +423,7 @@ async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
"comments": [{"id": 9, "author": "sam", "body": "Ship it"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "pull_workspace_snapshot", snapshot)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_detail", detail, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -210,10 +437,10 @@ async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
@pytest.mark.anyio
async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
async def assigned(repository, number):
return False
async def snapshot(repository, number):
return {"pull": {}, "capabilities": {"authored": False, "assigned": False}}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "pull_workspace_snapshot", snapshot)
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/private/secret/pulls/9/detail")
@@ -263,12 +490,14 @@ async def test_gitea_assigned_pull_detail_loads_reading_without_review_resources
@pytest.mark.anyio
async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monkeypatch):
calls = []
pull = {"number": 7}
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def snapshot(repository, number):
calls.append(("snapshot", repository, number))
return {"pull": pull, "capabilities": {"authored": False, "assigned": True}}
async def review(repository, number):
async def review(repository, number, authorized_pull):
assert authorized_pull is pull
calls.append(("review", repository, number))
return {
"repository": repository, "number": number, "head_sha": "abc123",
@@ -276,7 +505,7 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
"ci_state": "success", "files": [{"filename": "src/api.py"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_workspace_snapshot", snapshot)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", review, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -286,11 +515,49 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"] == [{"filename": "src/api.py"}]
assert calls == [
("assigned", "stackchain/api", 7),
("snapshot", "stackchain/api", 7),
("review", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_assigned_pull_review_reuses_one_authorized_snapshot():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
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":
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False, "mergeable": True,
"head": {"sha": "abc1234"}, "user": {"login": "sam"},
"assignees": [{"login": "alex"}], "requested_reviewers": [],
})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7/files":
return httpx.Response(200, json=[])
if request.url.path == "/api/v1/repos/stackchain/api/commits/abc1234/status":
return httpx.Response(200, json={"state": "pending", "statuses": []})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7.diff":
return httpx.Response(200, text="")
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7/reviews":
return httpx.Response(200, json=[])
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
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/review-data")
finally:
await gitea_proxy.stop_client()
assert response.status_code == 200
assert response.json()["capabilities"] == {"authored": False, "assigned": True}
assert requests.count(("GET", "/api/v1/user")) == 1
assert requests.count(("GET", "/api/v1/repos/stackchain/api/pulls/7")) == 1
@pytest.mark.anyio
async def test_assigned_pull_feedback_endpoint_authorizes_and_returns_one_review(monkeypatch):
calls = []
@@ -506,6 +773,201 @@ async def test_assigned_pull_checks_endpoint_authorizes_and_returns_status_only(
]
def test_commit_checks_expose_only_same_forge_actions_recovery_metadata(monkeypatch):
monkeypatch.setattr(
gitea_proxy, "GITEA_URL", "https://forge.example/git"
)
checks = gitea_proxy._normalize_commit_checks({
"statuses": [
{
"context": "CI / lint (pull_request)",
"status": "failure",
"description": "Failed",
"target_url": "/git/acme/mobile/actions/runs/91/jobs/3",
},
{
"context": "external",
"status": "failure",
"target_url": "https://checks.example/jobs/4",
},
]
})
assert checks[0] == {
"name": "CI / lint (pull_request)",
"state": "failure",
"description": "Failed",
"url": "https://forge.example/git/acme/mobile/actions/runs/91/jobs/3",
"recovery": {"run_id": 91, "job_index": 3},
}
assert checks[1]["url"] == ""
assert "recovery" not in checks[1]
@pytest.mark.anyio
async def test_action_failure_excerpt_is_bounded_and_redacts_credentials(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
requests = []
noisy_log = "old output\n" * 4_000 + (
"Authorization: token super-secret\n"
"GITEA_TOKEN=another-secret\n"
"AssertionError: expected ready, got blocked\n"
)
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={"head": {"sha": "abc1234"}})
if request.url.path.endswith("/commits/abc1234/status"):
return httpx.Response(200, json={"statuses": [{
"context": "CI / lint (pull_request)",
"status": "failure",
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
}]})
if request.url.path.endswith("/actions/runs/91/jobs/3/logs"):
return httpx.Response(200, text=noisy_log)
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.action_failure_excerpt(
"stackchain/api", 7, "abc1234", 91, 3
)
finally:
await gitea_proxy.stop_client()
assert result["head_sha"] == "abc1234"
assert result["run_id"] == 91
assert result["job_index"] == 3
assert len(result["excerpt"].encode()) <= 24 * 1024
assert "AssertionError: expected ready, got blocked" in result["excerpt"]
assert "super-secret" not in result["excerpt"]
assert "another-secret" not in result["excerpt"]
assert "Authorization: [redacted]" in result["excerpt"]
assert requests == [
("GET", "/git/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/git/api/v1/repos/stackchain/api/commits/abc1234/status"),
("GET", "/git/stackchain/api/actions/runs/91/jobs/3/logs"),
]
@pytest.mark.anyio
async def test_action_failure_endpoint_authorizes_and_returns_no_store(monkeypatch):
calls = []
async def capabilities(repository, number):
calls.append(("access", repository, number))
return {"authored": True, "assigned": False}
async def excerpt(repository, number, head_sha, run_id, job_index):
calls.append(("excerpt", repository, number, head_sha, run_id, job_index))
return {
"head_sha": head_sha,
"run_id": run_id,
"job_index": job_index,
"name": "CI / lint",
"excerpt": "AssertionError: failed",
}
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
monkeypatch.setattr(main.gitea_proxy, "action_failure_excerpt", excerpt, 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/checks/91/jobs/3/failure",
params={"expected_head_sha": "abc1234"},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["excerpt"] == "AssertionError: failed"
assert calls == [
("access", "stackchain/api", 7),
("excerpt", "stackchain/api", 7, "abc1234", 91, 3),
]
@pytest.mark.anyio
async def test_retry_failed_action_job_validates_head_and_uses_gitea_csrf(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
requests = []
async def handler(request):
requests.append((request.method, request.url.path, request.headers.get("x-csrf-token")))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={"head": {"sha": "abc1234"}})
if request.url.path.endswith("/commits/abc1234/status"):
return httpx.Response(200, json={"statuses": [{
"context": "CI / lint (pull_request)",
"status": "failure",
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
}]})
if request.method == "GET" and request.url.path.endswith("/actions/runs/91/jobs/3"):
return httpx.Response(200, text="<script>csrfToken: 'csrf-123'</script>")
if request.method == "POST" and request.url.path.endswith("/actions/runs/91/jobs/3/rerun"):
assert request.headers["x-csrf-token"] == "csrf-123"
assert b"_csrf=csrf-123" in request.content
return httpx.Response(303, headers={"location": "/git/stackchain/api/actions/runs/92"})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.retry_action_job(
"stackchain/api", 7, "abc1234", 91, 3
)
finally:
await gitea_proxy.stop_client()
assert result == {
"head_sha": "abc1234",
"run_id": 91,
"job_index": 3,
"status": "queued",
}
assert requests == [
("GET", "/git/api/v1/repos/stackchain/api/pulls/7", None),
("GET", "/git/api/v1/repos/stackchain/api/commits/abc1234/status", None),
("GET", "/git/stackchain/api/actions/runs/91/jobs/3", None),
("POST", "/git/stackchain/api/actions/runs/91/jobs/3/rerun", "csrf-123"),
]
@pytest.mark.anyio
async def test_retry_action_endpoint_requires_workspace_access_and_exact_head(monkeypatch):
calls = []
async def capabilities(repository, number):
calls.append(("access", repository, number))
return {"authored": True, "assigned": False}
async def retry(repository, number, head_sha, run_id, job_index):
calls.append(("retry", repository, number, head_sha, run_id, job_index))
return {
"head_sha": head_sha,
"run_id": run_id,
"job_index": job_index,
"status": "queued",
}
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry",
json={"expected_head_sha": "abc1234"},
)
assert response.status_code == 202
assert response.headers["cache-control"] == "no-store"
assert response.json()["status"] == "queued"
assert calls == [
("access", "stackchain/api", 7),
("retry", "stackchain/api", 7, "abc1234", 91, 3),
]
@pytest.mark.anyio
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
async def handler(request):
@@ -1208,7 +1670,7 @@ async def test_pull_handoff_candidates_are_bounded_and_exclude_invalid_or_curren
@pytest.mark.anyio
async def test_gitea_pull_review_request_filters_candidates_and_confirms_requested_reviewer():
async def test_gitea_authored_unassigned_pull_review_request_filters_candidates_and_confirms_reviewer():
requests = []
review_requested = False
@@ -1230,8 +1692,8 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
requested.append({"login": "casey"})
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "abc123"}, "user": {"login": "alex"},
"assignees": [{"login": "timmy"}],
"head": {"sha": "abc123"}, "user": {"login": "timmy"},
"assignees": [],
"requested_reviewers": requested,
})
if request.method == "POST" and request.url.path.endswith("/requested_reviewers"):
@@ -1248,7 +1710,10 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
finally:
await gitea_proxy.stop_client()
assert candidates == [{"login": "casey", "name": "Casey"}]
assert candidates == [
{"login": "alex", "name": "Alexander"},
{"login": "casey", "name": "Casey"},
]
assert result == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"requested_reviewers": ["sam", "casey"], "reviewer": "casey",
@@ -1259,7 +1724,7 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
@pytest.mark.anyio
async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal():
async def test_gitea_authored_unassigned_pull_can_cancel_pending_review():
requests = []
cancelled = False
@@ -1271,7 +1736,7 @@ async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal()
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "abc123"}, "assignees": [{"login": "timmy"}],
"head": {"sha": "abc123"}, "user": {"login": "timmy"}, "assignees": [],
"requested_reviewers": [{"login": "casey"}] if cancelled else [{"login": "sam"}, {"login": "casey"}],
})
if request.method == "DELETE" and request.url.path.endswith("/requested_reviewers"):
+1
View File
@@ -108,6 +108,7 @@ async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch
"name": "lint/<unsafe>", "state": "failure",
"description": "Formatting failed " + "x" * 222,
"url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
"recovery": {"run_id": 9, "job_index": 4},
},
{"name": "external", "state": "pending", "description": "Waiting", "url": ""},
{"name": "build-release", "state": "success", "description": "Passed", "url": ""},
+30
View File
@@ -107,6 +107,36 @@ async def test_resolve_work_route_returns_only_a_requested_review():
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_authored_unassigned_pull():
async def upstream(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 703,
"number": 90,
"title": "Ship authored flow",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/90",
"assignees": [],
"requested_reviewers": [],
"user": {"login": "timmy"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"pull", "stackchain/dashboard", 90, None
)
finally:
await gitea_proxy.stop_client()
assert result["kind"] == "pull"
assert result["number"] == 90
assert result["is_assigned"] is False
assert result["work_reasons"] == ["authored_by_me"]
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user():
requests = []