function mergeEligibility(detail, reviewState) { if (!detail || detail.state !== 'open' || detail.merged) { return { allowed: false, reason: 'Pull request is not open' }; } if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' }; if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' }; if (detail.ci_state !== 'success') { const blockers = (detail.checks || []).filter(check => ['failure', 'error', 'pending'].includes(check.state) ).map(check => check.name).filter(Boolean).slice(0, 3); return { allowed: false, reason: blockers.length ? 'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' }; } if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' }; const reviewer = (detail.reviewers || []).find(item => item?.blocking); if (reviewer?.status === 'waiting') { return { allowed: false, reason: 'Waiting for @' + reviewer.login + '’s review' }; } if (reviewer?.status === 'changes_requested') { return { allowed: false, reason: '@' + reviewer.login + ' requested changes' }; } if (reviewer?.status === 'outdated') { return { allowed: false, reason: '@' + reviewer.login + '’s review is outdated after new commits' }; } if (reviewState && !reviewState.complete) { return { allowed: false, reason: 'Review every changed file before merging' }; } return { allowed: true, reason: 'Ready to merge' }; } function renderReviewerFeedback(reviewer, escapeHtml) { const groups = new Map(); (Array.isArray(reviewer?.comments) ? reviewer.comments : []).forEach(comment => { if (!comment?.path || !comment?.body) return; if (!groups.has(comment.path)) groups.set(comment.path, []); groups.get(comment.path).push(comment); }); const summary = reviewer?.summary ? '

' + escapeHtml(reviewer.summary) + '

' : ''; const files = Array.from(groups.entries()).map(([path, comments]) => '

' + escapeHtml(path) + '

' ).join(''); const loadable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 && ['commented', 'changes_requested', 'outdated'].includes(reviewer?.status); const feedbackState = Array.isArray(reviewer?.comments) ? 'loaded' : reviewer?.feedback_state; const feedbackAction = !loadable || feedbackState === 'loaded' ? '' : feedbackState === 'loading' ? '

Loading inline feedback…

' : '

' + (feedbackState === 'error' ? 'Feedback unavailable. ' : '') + '

'; const emptyLoaded = loadable && feedbackState === 'loaded' && !files ? '

No inline comments on this review.

' : ''; if (!summary && !files && !feedbackAction && !emptyLoaded) return ''; const reviewedHead = reviewer?.status === 'outdated' && reviewer?.head_sha ? '

Reviewed head ' + escapeHtml(reviewer.head_sha.slice(0, 8)) + '

' : ''; const addressable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 && ['changes_requested', 'outdated'].includes(reviewer?.status) && feedbackState === 'loaded' && (reviewer.comments || []).some(comment => Number.isInteger(comment?.id) && comment.id > 0); const address = addressable ? '' : ''; return '
Review feedback' + reviewedHead + summary + feedbackAction + emptyLoaded + files + address + '
'; } function renderReviewerStatuses(reviewers, escapeHtml) { const labels = { waiting: 'Waiting for review', approved: 'Approved current head', changes_requested: 'Changes requested', commented: 'Commented', outdated: 'Outdated after new commits', }; return (Array.isArray(reviewers) ? reviewers : []).map(reviewer => { const status = labels[reviewer?.status] || 'Status unavailable'; const cancel = reviewer?.status === 'waiting' && reviewer?.login ? '' : ''; return '
@' + escapeHtml(reviewer?.login || 'unknown') + '' + escapeHtml(status) + '
' + renderReviewerFeedback(reviewer, escapeHtml) + cancel + '
'; }).join(''); } function renderReviewerPanel(doc, detail, escapeHtml = value => String(value) .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')) { const reviewers = Array.isArray(detail?.reviewers) ? detail.reviewers : []; const openFeedback = new Set(Array.from(doc.querySelectorAll('.pull-review-feedback[open]')) .map(panel => panel.closest('.pull-reviewer-status')?.dataset.reviewId).filter(Boolean)); doc.querySelector('#pull-reviewer-statuses').innerHTML = renderReviewerStatuses(reviewers, escapeHtml); openFeedback.forEach(reviewId => { const panel = doc.querySelector('.pull-reviewer-status[data-review-id="' + reviewId + '"] .pull-review-feedback'); if (panel) panel.open = true; }); doc.querySelectorAll('[data-review-feedback-file]').forEach(button => { button.addEventListener('click', () => focusPullFile(doc, button.dataset.reviewFeedbackFile)); }); doc.querySelector('#pull-reviewer-status').textContent = reviewers.length ? reviewers.length + ' reviewer status' + (reviewers.length === 1 ? '' : 'es') + ' for this pull request.' : 'No review has been requested; existing merge policy is unchanged.'; doc.querySelector('#request-updated-pull-review').hidden = !reviewers.some(reviewer => reviewer.status === 'outdated'); } function reviewEligibility(detail, reviewState, doc) { renderReviewerPanel(doc, detail); return mergeEligibility(detail, reviewState); } function renderFile(file, index, reviewed, escapeHtml) { const filename = escapeHtml(file.filename || 'Unknown file'); const panelId = 'pull-diff-' + index; const lines = (file.diff_lines || []).map(line => { const text = String(line); const kind = text.startsWith('@@') ? 'hunk' : text.startsWith('+') ? 'added' : text.startsWith('-') ? 'removed' : 'context'; return '' + escapeHtml(text) + ''; }).join(''); const preview = file.diff_available ? '' : ''; return '
' + '' + preview + '
'; } function focusPullFile(doc, filename) { const article = Array.from(doc.querySelectorAll('#pull-files .pull-file')) .find(file => file.dataset.pullFilename === filename); const toggle = article?.querySelector('.pull-file-toggle'); const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls')); if (!toggle || !panel) return false; toggle.setAttribute('aria-expanded', 'true'); panel.hidden = false; toggle.scrollIntoView({ block: 'center', behavior: 'smooth' }); toggle.focus(); return true; } function focusNextUnreviewed(doc, detail, state, controller) { if (!detail || !state) return; focusPullFile(doc, controller.nextUnreviewed(detail, state)); } function removeFromSnapshot(data, item) { return { ...data, pulls:(data.pulls || []).filter(candidate => candidate.repository !== item.repository || candidate.number !== item.number) }; } function sameTarget(left, right) { return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number)); } function ownershipSelectors() { return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff']; } function renderHandoffCandidates(select, candidates, doc) { select.textContent = ''; const placeholder = doc.createElement('option'); placeholder.value = ''; placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates'; select.appendChild(placeholder); candidates.forEach(candidate => { const option = doc.createElement('option'); option.value = candidate.login; option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')'); select.appendChild(option); }); return candidates.length; } function ownershipExitMessage(item, action, transitionResult) { return item.key + ' ' + action + (transitionResult === 'opened' ? '. Next work item opened.' : '. Choose the next ready Today item.'); } 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 = ''; 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 = !assigned; qs('#release-pull').disabled = !assigned; qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment'; 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 = ''; } function resetReviewRequestControls(doc, detail) { const qs = selector => doc.querySelector(selector); qs('#pull-review-request').open = false; qs('#pull-review-request').dataset.headSha = detail?.head_sha || ''; qs('#pull-review-recipient').innerHTML = ''; qs('#pull-review-recipient').disabled = true; qs('#confirm-pull-review').disabled = true; qs('#load-pull-reviewers').disabled = !detail?.head_sha; qs('#pull-review-request-status').textContent = detail?.head_sha ? 'Load teammates to request a review.' : 'Load the current pull request before requesting review.'; qs('#pull-reviewer-status').textContent = 'Reviewer status not loaded.'; qs('#pull-reviewer-statuses').textContent = ''; qs('#request-updated-pull-review').hidden = true; qs('#pull-feedback-pass').hidden = true; qs('#pull-feedback-status').textContent = ''; } function resetBranchUpdateControls(doc, detail) { const panel = doc.querySelector('#pull-branch-update'); if (!panel) return; const eligible = detail?.capabilities?.authored === true && detail?.state === 'open' && detail?.draft !== true && detail?.merged !== true && detail?.mergeable === false && detail?.head_sha; panel.hidden = !eligible; doc.querySelector('#update-pull-branch').disabled = false; doc.querySelector('#pull-branch-update-status').textContent = eligible ? 'Merge the latest base branch into this pull request. Reviews and checks may run again.' : ''; } function bindBranchUpdateControls(doc, controller, getSelected, getDetail) { const button = doc.querySelector('#update-pull-branch'); if (!button || button.dataset.bound === 'true') return; button.dataset.bound = 'true'; button.addEventListener('click', async () => { const item = getSelected?.(); const detail = getDetail?.(); if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true || !globalThis.confirm('Update ' + item.key + ' with the latest base branch? Reviews and checks may run again.')) return; button.disabled = true; const status = doc.querySelector('#pull-branch-update-status'); status.textContent = 'Updating branch…'; try { const result = await controller.updateBranch(item, detail.head_sha); if (getSelected?.() !== item || getDetail?.() !== detail) return; Object.assign(detail, result, { mergeable:null, ci_state:'pending', reviewers:[] }); status.textContent = 'Branch updated. Refreshing checks and review state…'; doc.querySelector('#pull-review-retry')?.click(); status.textContent = 'Branch updated to ' + result.head_sha.slice(0, 8) + '. Review the refreshed checks before merging.'; button.hidden = true; doc.querySelector('#refresh-pull-checks')?.focus(); } catch (error) { status.textContent = error.message + ' Reload the latest pull request state before retrying.'; button.disabled = false; button.focus(); } }); } function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) { const qs = selector => doc.querySelector(selector); const load = qs('#load-pull-reviewers'); if (load.dataset.reviewRequestBound === 'true') return; load.dataset.reviewRequestBound = 'true'; controller.setReviewDetail = detail => { resetReviewRequestControls(doc, detail); resetBranchUpdateControls(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; qs('#publish-draft-pull').hidden = !publishable; if (publishable) qs('#pull-review-request').open = true; qs('#publish-draft-pull-status').textContent = ''; }; resetReviewRequestControls(doc, null); qs('#request-updated-pull-review').addEventListener('click', () => { qs('#pull-review-request').open = true; qs('#pull-review-request').scrollIntoView({ block:'center', behavior:'smooth' }); load.focus(); }); controller.onReviewRequested = result => { const detail = getDetail?.(); if (!detail || result.head_sha !== detail.head_sha) return; detail.reviewers = (detail.reviewers || []).filter(reviewer => reviewer.login !== result.reviewer); detail.reviewers.push({login:result.reviewer, status:'waiting', head_sha:result.head_sha, blocking:true}); renderReviewerPanel(doc, detail); qs('#pull-merge-state').textContent = 'Waiting for @' + result.reviewer + '’s review'; qs('#merge-pull').disabled = true; }; qs('#publish-draft-pull').addEventListener('click', async () => { const selected = getSelected?.(); const detail = getDetail?.(); if (!selected || !detail?.head_sha || detail.draft !== true || !globalThis.confirm('Publish ' + selected.key + ' as ready for review?')) return; const button = qs('#publish-draft-pull'); button.disabled = true; qs('#publish-draft-pull-status').textContent = 'Publishing draft…'; try { const result = await controller.publishReady(selected, detail.head_sha); if (getSelected?.() !== selected || getDetail?.() !== detail) return; Object.assign(detail, result); selected.title = result.title; qs('#pull-sheet-title').textContent = result.title; controller.setReviewDetail(detail); qs('#publish-draft-pull-status').textContent = 'Published and ready for review.'; qs('#pull-review-request').open = true; load.focus(); } catch (error) { qs('#publish-draft-pull-status').textContent = error.message + ' The pull request remains a draft; retry.'; button.disabled = false; button.focus(); } }); qs('#pull-reviewer-statuses').addEventListener('click', async event => { const button = event.target.closest?.('[data-cancel-review-request]'); if (!button || button.disabled) return; const selected = getSelected?.(); const detail = getDetail?.(); const reviewer = button.dataset.cancelReviewRequest; if (!selected || !detail?.head_sha || !reviewer || !globalThis.confirm('Cancel @' + reviewer + '’s review request for ' + selected.key + '?')) return; button.disabled = true; qs('#pull-reviewer-status').textContent = 'Cancelling @' + reviewer + '’s review request…'; try { const result = await controller.cancelReview(selected, reviewer, detail.head_sha); if (result?.head_sha !== detail.head_sha || result?.requested_reviewers?.includes(reviewer)) { throw new Error('Cancellation was not confirmed.'); } detail.reviewers = (detail.reviewers || []).filter(candidate => candidate.login !== reviewer || candidate.status !== 'waiting'); renderReviewerPanel(doc, detail); const eligibility = mergeEligibility(detail, controller.reviewState(selected, detail)); qs('#pull-merge-state').textContent = eligibility.reason; qs('#merge-pull').disabled = !eligibility.allowed; qs('#pull-review-request').open = true; qs('#pull-review-request-status').textContent = 'Review request cancelled. Choose a replacement reviewer.'; load.disabled = false; load.focus(); } catch (error) { qs('#pull-reviewer-status').textContent = error.message + ' Refresh review data before retrying.'; button.disabled = false; button.focus(); } }); load.addEventListener('click', async () => { const selected = getSelected(); if (!selected) return; const select = qs('#pull-review-recipient'); load.disabled = true; qs('#pull-review-request-status').textContent = 'Loading eligible reviewers…'; try { const count = renderHandoffCandidates(select, await controller.loadReviewCandidates(selected), doc); select.disabled = !count; qs('#confirm-pull-review').disabled = true; qs('#pull-review-request-status').textContent = count ? 'Choose a teammate to review the current head.' : 'No eligible reviewers were found.'; if (count) select.focus(); else load.disabled = false; } catch (error) { qs('#pull-review-request-status').textContent = error.message + ' Retry loading reviewers.'; load.disabled = false; load.focus(); } }); qs('#pull-review-recipient').addEventListener('change', event => { qs('#confirm-pull-review').disabled = !event.target.value; }); qs('#confirm-pull-review').addEventListener('click', async () => { const selected = getSelected(); const detail = { head_sha: qs('#pull-review-request').dataset.headSha }; const reviewer = qs('#pull-review-recipient').value; if (!selected || !detail?.head_sha || !reviewer || !globalThis.confirm('Request review of ' + selected.key + ' at ' + detail.head_sha.slice(0, 8) + ' from @' + reviewer + '?')) return; const button = qs('#confirm-pull-review'); button.disabled = true; qs('#pull-review-request-status').textContent = 'Requesting review…'; try { await controller.requestReview(selected, reviewer, detail.head_sha); qs('#pull-review-request-status').textContent = 'Review requested from @' + reviewer + '.'; } catch (error) { qs('#pull-review-request-status').textContent = error.message + ' Selection kept; retry the request.'; button.disabled = false; button.focus(); } }); } function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin) { const qs = selector => doc.querySelector(selector); const panel = qs('#pull-feedback-pass'); if (panel.dataset.feedbackBound === 'true') return; panel.dataset.feedbackBound = 'true'; let active = null; const comments = () => (active?.reviewer?.comments || []) .filter(comment => Number.isInteger(comment?.id) && comment.id > 0); const current = () => comments()[active?.state?.index || 0]; const save = () => { if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state); }; const clearSuggestion = () => { qs('#pull-feedback-suggestion-preview').hidden = true; qs('#cancel-pull-feedback-suggestion').hidden = true; qs('#stage-pull-feedback-suggestion').hidden = true; qs('#apply-pull-feedback-suggestion').hidden = true; if (active) active.suggestion = null; }; const suggestedFile = (file, suggestion) => { const line = Number(suggestion?.line); if (!Number.isInteger(line) || line < 1 || typeof suggestion?.content !== 'string') return null; const trailingNewline = file.content.endsWith('\n'); const lines = file.content.split('\n'); if (trailingNewline) lines.pop(); if (line > lines.length) return null; const before = lines[line - 1]; lines.splice(line - 1, 1, ...suggestion.content.split('\n')); return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') }; }; const renderBatchButton = () => { const count = active?.batch ? Object.values(active.batch) .reduce((total, file) => total + file.suggestions.length, 0) : 0; const button = qs('#review-pull-feedback-batch'); button.hidden = count < 2; button.textContent = 'Review fixes (' + count + ')'; }; const rebuildBatchFile = entry => { const trailingNewline = entry.file.content.endsWith('\n'); const lines = entry.file.content.split('\n'); if (trailingNewline) lines.pop(); const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line); if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null; for (const suggestion of ordered) { if (suggestion.line < 1 || suggestion.line > lines.length) return null; lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n')); } return lines.join('\n') + (trailingNewline ? '\n' : ''); }; const render = () => { if (!active) return; const items = comments(); active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1)); const comment = current(); if (active.suggestion && active.suggestion.commentId !== comment.id) clearSuggestion(); const response = active.state.responses[String(comment.id)] || {}; qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length; qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback'; qs('#pull-feedback-location').textContent = comment.path + (comment.line ? ' · line ' + Number(comment.line) : ''); qs('#pull-feedback-body').textContent = comment.body; qs('#pull-feedback-note').value = response.note || ''; panel.querySelectorAll('[data-feedback-disposition]').forEach(button => { button.setAttribute('aria-pressed', String(button.dataset.feedbackDisposition === response.disposition)); }); qs('#previous-pull-feedback').disabled = active.state.index === 0; qs('#next-pull-feedback').disabled = active.state.index >= items.length - 1; qs('#finish-pull-feedback').disabled = active.state.posted === true || items.some(item => !active.state.responses[String(item.id)]?.disposition); qs('#request-feedback-review').hidden = active.state.posted !== true; qs('#make-pull-feedback-fix').hidden = active.detail?.capabilities?.authored !== true || active.detail?.state !== 'open' || active.detail?.merged === true || !comment.path; const canSuggest = active.detail?.capabilities?.authored === true && active.detail?.state === 'open' && active.detail?.merged !== true && active.reviewer?.head_sha === active.detail?.head_sha && comment.suggestion?.line === comment.line && typeof comment.suggestion?.content === 'string'; qs('#review-pull-feedback-suggestion').hidden = !canSuggest; qs('#review-pull-feedback-suggestion').disabled = canSuggest && globalThis.navigator?.onLine === false; if (canSuggest && globalThis.navigator?.onLine === false) { qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.'; } const found = focusPullFile(doc, comment.path); qs('#pull-feedback-context').textContent = found ? 'Matching change opened below.' : 'Matching change is unavailable in this preview.'; panel.scrollIntoView({ block:'center', behavior:'smooth' }); }; qs('#pull-reviewer-statuses').addEventListener('click', async event => { const loadButton = event.target.closest?.('[data-load-review-feedback]'); if (loadButton && !loadButton.disabled) { const detail = getDetail?.(); const item = getSelected?.(); const reviewId = Number(loadButton.dataset.loadReviewFeedback); const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId); if (!detail || !item || !reviewer) return; const refresh = reviewer.feedback_state === 'error'; reviewer.feedback_state = 'loading'; renderReviewerPanel(doc, detail); try { const result = await controller.loadFeedback(item, detail, reviewer, { refresh }); if (getDetail?.() !== detail || getSelected?.() !== item || result.head_sha !== detail.head_sha || Number(result.review_id) !== reviewId) return; reviewer.comments = Array.isArray(result.comments) ? result.comments : []; reviewer.feedback_state = 'loaded'; renderReviewerPanel(doc, detail); doc.querySelector('[data-address-review-feedback="' + reviewId + '"]')?.focus(); } catch (error) { if (getDetail?.() !== detail || getSelected?.() !== item) return; reviewer.feedback_state = 'error'; renderReviewerPanel(doc, detail); doc.querySelector('[data-load-review-feedback="' + reviewId + '"]')?.focus(); } return; } const button = event.target.closest?.('[data-address-review-feedback]'); if (!button) return; const detail = getDetail?.(); const item = getSelected?.(); const reviewId = Number(button.dataset.addressReviewFeedback); const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId); if (!detail || !item || !reviewer) return; const login = getLogin?.() || ''; active = { item, detail, reviewer, login, batch:{}, state:controller.loadFeedbackPass(item, detail, reviewer, login) }; panel.hidden = false; qs('#pull-feedback-file-editor').hidden = true; qs('#pull-feedback-suggestion-preview').hidden = true; qs('#pull-feedback-batch-review').hidden = true; qs('#cancel-pull-feedback-fix').hidden = true; qs('#commit-pull-feedback-fix').hidden = true; active.file = null; active.suggestion = null; renderBatchButton(); qs('#pull-feedback-status').textContent = active.state.posted ? 'Response already posted. You can request an updated review.' : 'Progress saves on this device.'; render(); }); qs('#review-pull-feedback-suggestion').addEventListener('click', async () => { const comment = current(); if (!active || !comment?.suggestion || globalThis.navigator?.onLine === false) { qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.'; return; } const button = qs('#review-pull-feedback-suggestion'); button.disabled = true; qs('#pull-feedback-status').textContent = 'Loading the current line for review…'; try { const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha); if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return; const change = suggestedFile(file, comment.suggestion); if (!change) throw new Error('The suggested line is no longer available.'); active.suggestion = { file, content:change.content, commentId:comment.id }; qs('#pull-feedback-suggestion-before').textContent = change.before; qs('#pull-feedback-suggestion-after').textContent = change.after; qs('#pull-feedback-suggestion-message').value = 'fix: apply review suggestion'; qs('#pull-feedback-file-editor').hidden = true; qs('#pull-feedback-suggestion-preview').hidden = false; qs('#cancel-pull-feedback-suggestion').hidden = false; qs('#stage-pull-feedback-suggestion').hidden = false; qs('#apply-pull-feedback-suggestion').hidden = false; qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.'; qs('#apply-pull-feedback-suggestion').focus(); } catch (error) { qs('#pull-feedback-status').textContent = error.message + ' Use the full-file editor if needed.'; button.focus(); } finally { button.disabled = false; } }); qs('#cancel-pull-feedback-suggestion').addEventListener('click', () => { clearSuggestion(); qs('#review-pull-feedback-suggestion').focus(); }); qs('#stage-pull-feedback-suggestion').addEventListener('click', () => { if (!active?.suggestion) return; const pending = active.suggestion; const path = pending.file.path; const stagedCount = Object.values(active.batch) .reduce((total, file) => total + file.suggestions.length, 0); if (stagedCount >= 8) { qs('#pull-feedback-status').textContent = 'Commit this bounded batch before staging more suggestions.'; return; } const entry = active.batch[path] || {file:pending.file, suggestions:[]}; if (entry.file.blob_sha !== pending.file.blob_sha || entry.suggestions.some(item => item.commentId === pending.commentId)) return; const comment = current(); entry.suggestions.push({ line:Number(comment.suggestion.line), content:comment.suggestion.content, commentId:pending.commentId, }); entry.content = rebuildBatchFile(entry); if (entry.content === null) { entry.suggestions.pop(); qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.'; return; } active.batch[path] = entry; clearSuggestion(); renderBatchButton(); qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.'; qs('#next-pull-feedback').focus(); }); qs('#review-pull-feedback-batch').addEventListener('click', () => { if (!active?.batch) return; const list = qs('#pull-feedback-batch-items'); list.replaceChildren(...Object.values(active.batch).map(entry => { const item = doc.createElement('li'); item.textContent = entry.file.path + ' · ' + entry.suggestions.length + (entry.suggestions.length === 1 ? ' suggestion' : ' suggestions'); return item; })); qs('#pull-feedback-batch-review').hidden = false; qs('#cancel-pull-feedback-batch').hidden = false; qs('#commit-pull-feedback-batch').hidden = false; qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'}); qs('#commit-pull-feedback-batch').focus(); }); qs('#cancel-pull-feedback-batch').addEventListener('click', () => { qs('#pull-feedback-batch-review').hidden = true; qs('#cancel-pull-feedback-batch').hidden = true; qs('#commit-pull-feedback-batch').hidden = true; qs('#review-pull-feedback-batch').focus(); }); qs('#commit-pull-feedback-batch').addEventListener('click', async () => { if (!active?.batch) return; const entries = Object.values(active.batch); const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0); const message = qs('#pull-feedback-batch-message').value.trim(); if (count < 2 || !message) return; if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return; const button = qs('#commit-pull-feedback-batch'); button.disabled = true; qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…'; try { const result = await controller.commitFeedbackBatch(active.item, { files:entries.map(entry => ({ path:entry.file.path, content:entry.content, expectedBlobSha:entry.file.blob_sha, })), suggestionCount:count, message, expectedHeadSha:active.detail.head_sha, }); active.detail.head_sha = result.head_sha; active.state.headSha = result.head_sha; active.batch = {}; save(); qs('#pull-feedback-batch-review').hidden = true; qs('#cancel-pull-feedback-batch').hidden = true; qs('#commit-pull-feedback-batch').hidden = true; renderBatchButton(); qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.'; render(); } catch (error) { qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.'; button.disabled = false; button.focus(); } }); qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => { if (!active?.suggestion) return; const message = qs('#pull-feedback-suggestion-message').value.trim(); if (!message) { qs('#pull-feedback-status').textContent = 'Enter a commit message before committing.'; return; } if (!globalThis.confirm('Commit this suggested change to ' + active.item.key + '?')) return; const button = qs('#apply-pull-feedback-suggestion'); button.disabled = true; const pending = active.suggestion; qs('#pull-feedback-status').textContent = 'Committing and verifying the suggested change…'; try { const result = await controller.commitFeedbackFix(active.item, { path:pending.file.path, content:pending.content, message, expectedHeadSha:pending.file.head_sha, expectedBlobSha:pending.file.blob_sha, }); active.detail.head_sha = result.head_sha; active.state.headSha = result.head_sha; clearSuggestion(); save(); qs('#pull-feedback-status').textContent = 'Suggested change committed. Mark the comment Addressed when satisfied.'; render(); } catch (error) { qs('#pull-feedback-status').textContent = error.message + ' Preview kept; reload if the branch changed, or retry.'; button.disabled = false; button.focus(); } }); qs('#make-pull-feedback-fix').addEventListener('click', async () => { const comment = current(); if (!active || !comment?.path || active.detail?.capabilities?.authored !== true) return; const button = qs('#make-pull-feedback-fix'); button.disabled = true; qs('#pull-feedback-status').textContent = 'Loading the current pull file…'; try { const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha); if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return; active.file = file; clearSuggestion(); qs('#pull-feedback-file-path').textContent = file.path; qs('#pull-feedback-file-content').value = file.content; qs('#pull-feedback-commit-message').value = 'fix: address review feedback'; qs('#pull-feedback-file-editor').hidden = false; qs('#cancel-pull-feedback-fix').hidden = false; qs('#commit-pull-feedback-fix').hidden = false; qs('#pull-feedback-status').textContent = 'Edit the bounded file, then commit it to this pull branch.'; qs('#pull-feedback-file-content').focus(); } catch (error) { qs('#pull-feedback-status').textContent = error.message + ' Retry without losing your feedback progress.'; } finally { button.disabled = false; } }); qs('#cancel-pull-feedback-fix').addEventListener('click', () => { qs('#pull-feedback-file-editor').hidden = true; qs('#cancel-pull-feedback-fix').hidden = true; qs('#commit-pull-feedback-fix').hidden = true; active.file = null; qs('#make-pull-feedback-fix').focus(); }); qs('#commit-pull-feedback-fix').addEventListener('click', async () => { if (!active?.file) return; const content = qs('#pull-feedback-file-content').value; const message = qs('#pull-feedback-commit-message').value.trim(); if (!message || content === active.file.content) { qs('#pull-feedback-status').textContent = 'Change the file and enter a commit message before committing.'; return; } if (!globalThis.confirm('Commit this change to ' + active.item.key + '?')) return; const button = qs('#commit-pull-feedback-fix'); button.disabled = true; qs('#pull-feedback-status').textContent = 'Committing and verifying the new pull head…'; try { const result = await controller.commitFeedbackFix(active.item, { path:active.file.path, content, message, expectedHeadSha:active.file.head_sha, expectedBlobSha:active.file.blob_sha, }); active.detail.head_sha = result.head_sha; active.state.headSha = result.head_sha; active.file = null; save(); qs('#pull-feedback-file-editor').hidden = true; qs('#cancel-pull-feedback-fix').hidden = true; qs('#commit-pull-feedback-fix').hidden = true; qs('#pull-feedback-status').textContent = 'Code change committed. Mark the comment Addressed when satisfied.'; render(); } catch (error) { qs('#pull-feedback-status').textContent = error.message + ' Draft kept; reload if the branch changed, or retry.'; button.disabled = false; button.focus(); } }); panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => { const disposition = event.target.dataset?.feedbackDisposition; const comment = current(); if (!active || !comment || !['addressed', 'discussion', 'skipped'].includes(disposition)) return; const key = String(comment.id); active.state.responses[key] = { ...(active.state.responses[key] || {}), disposition }; save(); render(); }); qs('#pull-feedback-note').addEventListener('input', event => { const comment = current(); if (!active || !comment) return; const key = String(comment.id); active.state.responses[key] = { ...(active.state.responses[key] || {}), note:event.target.value.slice(0, 1000) }; save(); }); qs('#previous-pull-feedback').addEventListener('click', () => { if (!active) return; active.state.index -= 1; save(); render(); }); qs('#next-pull-feedback').addEventListener('click', () => { if (!active) return; active.state.index += 1; save(); render(); }); qs('#close-pull-feedback').addEventListener('click', () => { panel.hidden = true; doc.querySelector('[data-address-review-feedback="' + active?.reviewer?.review_id + '"]')?.focus(); }); qs('#finish-pull-feedback').addEventListener('click', async () => { if (!active || qs('#finish-pull-feedback').disabled) return; const latest = getDetail?.(); if (!latest || latest.head_sha !== active.state.headSha) { qs('#pull-feedback-status').textContent = 'The pull request changed. Refresh review data before posting.'; return; } if (!globalThis.confirm('Post one feedback response to ' + active.item.key + '?')) return; qs('#finish-pull-feedback').disabled = true; qs('#pull-feedback-status').textContent = 'Posting feedback response…'; try { await controller.postFeedbackResponse(active.item, active.detail, active.reviewer, active.login, active.state); active.state.posted = true; save(); qs('#pull-feedback-status').textContent = 'Response posted. Request an updated review when ready.'; render(); } catch (error) { qs('#pull-feedback-status').textContent = error.message + ' Progress kept; retry without duplicating the response.'; qs('#finish-pull-feedback').disabled = false; } }); qs('#request-feedback-review').addEventListener('click', async () => { if (!active?.state?.posted) return; const button = qs('#request-feedback-review'); button.disabled = true; qs('#pull-feedback-status').textContent = 'Requesting updated review…'; try { await controller.requestReview(active.item, active.reviewer.login, active.detail.head_sha); qs('#pull-feedback-status').textContent = 'Response posted; updated review requested from @' + active.reviewer.login + '.'; button.hidden = true; } catch (error) { qs('#pull-feedback-status').textContent = 'Response posted; review not requested. ' + error.message; button.disabled = false; } }); } function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) { const qs = selector => doc.querySelector(selector); const form = qs('#pull-edit-form'); const controls = () => form.querySelectorAll('input,textarea,button'); const draft = () => ({ title:qs('#pull-edit-title').value.trim(), body:qs('#pull-edit-body').value, expectedHeadSha:getDetail()?.head_sha || '', }); qs('#edit-pull-content').addEventListener('click', () => { const item = getSelected(), detail = getDetail(); if (!item || !detail) return; const saved = controller.loadEditDraft(item) || { title:detail.title || '', body:detail.body || '', expectedHeadSha:detail.head_sha || '', }; qs('#pull-edit-title').value = saved.title; qs('#pull-edit-body').value = saved.body; form.hidden = false; qs('#pull-edit-status').textContent = ''; qs('#pull-edit-title').focus(); }); qs('#cancel-pull-edit').addEventListener('click', () => { form.hidden = true; qs('#edit-pull-content').focus(); }); ['#pull-edit-title', '#pull-edit-body'].forEach(selector => qs(selector).addEventListener('input', () => { const item = getSelected(); if (item && getDetail()) controller.saveEditDraft(item, draft()); })); form.addEventListener('submit', async event => { event.preventDefault(); const item = getSelected(), update = draft(); if (!item || !getDetail()) return; if (!update.title) { qs('#pull-edit-status').textContent = 'Title is required.'; qs('#pull-edit-title').focus(); return; } controls().forEach(control => { control.disabled = true; }); qs('#pull-edit-status').textContent = 'Saving review context…'; try { const result = await controller.updateContent(item, update); if (getSelected() !== item) return; const detail = getDetail(); Object.assign(detail, result); item.title = result.title; qs('#pull-sheet-title').textContent = result.title; qs('#pull-sheet-body').innerHTML = renderMarkdown(result.body || 'No description provided.'); controller.setReviewDetail(detail); qs('#pull-sheet-status').textContent = 'Review context updated · ready to request review'; form.hidden = true; qs('#pull-edit-status').textContent = ''; qs('#edit-pull-content').focus(); } catch (error) { qs('#pull-edit-status').textContent = error.message + ' Your change is still here; retry.'; qs('#pull-edit-title').focus(); } finally { controls().forEach(control => { control.disabled = false; }); } }); return { reset() { qs('#edit-pull-content').hidden = true; form.hidden = true; qs('#pull-edit-status').textContent = ''; }, setDetail(detail, offline = false) { const editable = !offline && detail?.state === 'open' && detail.author === getLogin(); qs('#edit-pull-content').hidden = !editable; }, }; } function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) { bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin); bindBranchUpdateControls(doc, controller, getSelected, getDetail); 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'); if (load.dataset.ownershipBound === 'true') return; load.dataset.ownershipBound = 'true'; load.addEventListener('click', async () => { const selected = getSelected(); if (!selected) return; const select = qs('#pull-handoff-recipient'); load.disabled = true; qs('#pull-handoff-status').textContent = 'Loading eligible teammates…'; try { const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc); select.disabled = !count; qs('#confirm-pull-handoff').disabled = true; qs('#pull-handoff-status').textContent = count ? 'Choose who should own this pull request next.' : 'No other eligible assignees were found.'; if (count) select.focus(); } catch (error) { qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.'; load.disabled = false; load.focus(); } }); qs('#pull-handoff-recipient').addEventListener('change', event => { qs('#confirm-pull-handoff').disabled = !event.target.value; }); qs('#confirm-pull-handoff').addEventListener('click', async () => { const selected = getSelected(); const recipient = qs('#pull-handoff-recipient').value; if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return; const button = qs('#confirm-pull-handoff'); button.disabled = true; qs('#pull-handoff-status').textContent = 'Confirming handoff…'; try { await controller.handoff(selected, recipient); const transition = await finish(selected); qs('#my-work-action-status').textContent = transition === false ? selected.key + ' handed off to @' + recipient + '.' : ownershipExitMessage(selected, 'handed off to @' + recipient, transition); } catch (error) { qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.'; button.disabled = false; button.focus(); } }); qs('#release-pull').addEventListener('click', async () => { const selected = getSelected(); if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return; const button = qs('#release-pull'); button.disabled = true; qs('#pull-sheet-status').textContent = 'Releasing assignment…'; try { await controller.release(selected); const transition = await finish(selected); qs('#my-work-action-status').textContent = transition === false ? selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition); } catch (error) { qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.'; button.disabled = false; button.focus(); } }); } 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; let ownershipRequest = null; let branchUpdateRequest = null; let candidateRequest = null; let reviewCandidateRequest = null; let reviewRequestMutation = null; let reviewCancelMutation = null; let readyMutation = null; let lifecycleMutation = null; let editRequest = null; let feedbackRequest = null; let feedbackFixRequest = null; const reviewRequests = new Map(); const reviewCache = new Map(); const feedbackRequests = new Map(); const feedbackCache = new Map(); const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/') .map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number); const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number; const operationKey = item => draftKey(item) + ':operation'; const editDraftKey = item => 'stackchain.pull-edit.v1:' + item.repository + '#' + item.number; const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha; const feedbackKey = (item, detail, reviewer, login) => 'stackchain.feedback-pass.v1:' + item.repository + '#' + item.number + ':' + reviewer.review_id + ':' + reviewer.head_sha + ':' + detail.head_sha + ':' + login; const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean); function reviewState(item, detail) { const files = fileNames(detail); let saved = []; try { const value = JSON.parse(storage?.getItem(reviewKey(item, detail)) || '[]'); if (Array.isArray(value)) saved = value; } catch (_error) { /* Corrupt progress safely starts over. */ } const reviewed = files.filter(filename => saved.includes(filename)); return { reviewed, total: files.length, complete: reviewed.length === files.length }; } return { onState, load(item) { return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } }); }, loadReview(item, headSha, { refresh = false } = {}) { const key = item.repository + '#' + item.number + ':' + headSha; if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key)); if (reviewRequests.has(key)) return reviewRequests.get(key); const request = fetchJson(pathFor(item) + '/review-data', { headers: { Accept: 'application/json' }, }).then(detail => { const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha; reviewCache.set(confirmedKey, detail); return detail; }).finally(() => reviewRequests.delete(key)); reviewRequests.set(key, request); return request; }, loadFeedback(item, detail, reviewer, { refresh = false } = {}) { const reviewId = Number(reviewer?.review_id); const headSha = detail?.head_sha || ''; const key = item.repository + '#' + item.number + ':' + headSha + ':' + reviewId; if (!refresh && feedbackCache.has(key)) return Promise.resolve(feedbackCache.get(key)); if (feedbackRequests.has(key)) return feedbackRequests.get(key); const request = fetchJson(pathFor(item) + '/reviews/' + encodeURIComponent(reviewId) + '/feedback?expected_head_sha=' + encodeURIComponent(headSha), { headers: { Accept: 'application/json' }, }).then(result => { if (result?.head_sha !== headSha || Number(result?.review_id) !== reviewId) { throw new Error('Review feedback changed. Reload review data before retrying.'); } feedbackCache.set(key, result); return result; }).finally(() => feedbackRequests.delete(key)); feedbackRequests.set(key, request); return request; }, loadFeedbackFile(item, path, expectedHeadSha) { return fetchJson(pathFor(item) + '/feedback-file?path=' + encodeURIComponent(path) + '&expected_head_sha=' + encodeURIComponent(expectedHeadSha), { headers:{ Accept:'application/json' }, }); }, commitFeedbackFix(item, draft) { if (feedbackFixRequest) return feedbackFixRequest; feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-file', { method:'PATCH', headers:{ Accept:'application/json', 'Content-Type':'application/json' }, body:JSON.stringify({ path:draft.path, content:draft.content, message:draft.message, expected_head_sha:draft.expectedHeadSha, expected_blob_sha:draft.expectedBlobSha, }), }).then(result => { if (!result?.head_sha || result.head_sha === draft.expectedHeadSha || result.path !== draft.path) { throw new Error('The feedback fix was not confirmed.'); } return result; }).finally(() => { feedbackFixRequest = null; }); return feedbackFixRequest; }, commitFeedbackBatch(item, draft) { if (feedbackFixRequest) return feedbackFixRequest; feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', { method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' }, body:JSON.stringify({ files:draft.files.map(file => ({ path:file.path, content:file.content, expected_blob_sha:file.expectedBlobSha, })), suggestion_count:draft.suggestionCount, message:draft.message, expected_head_sha:draft.expectedHeadSha, }), }).then(result => { const expectedPaths = draft.files.map(file => file.path); if (!result?.head_sha || result.head_sha === draft.expectedHeadSha || !Array.isArray(result.paths) || result.paths.length !== expectedPaths.length || result.paths.some((path, index) => path !== expectedPaths[index]) || result.suggestion_count !== draft.suggestionCount) { throw new Error('The suggestion batch was not confirmed.'); } return result; }).finally(() => { feedbackFixRequest = null; }); return feedbackFixRequest; }, loadChecks(item) { if (checkRequest) return checkRequest; checkRequest = fetchJson(pathFor(item) + '/checks', { headers: { Accept: 'application/json' }, }).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', { headers: { Accept: 'application/json' }, }).finally(() => { candidateRequest = null; }); return candidateRequest; }, loadReviewCandidates(item) { if (reviewCandidateRequest) return reviewCandidateRequest; reviewCandidateRequest = fetchJson(pathFor(item) + '/review-candidates', { headers: { Accept: 'application/json' }, }).finally(() => { reviewCandidateRequest = null; }); return reviewCandidateRequest; }, requestReview(item, reviewer, expectedHeadSha) { if (reviewRequestMutation) return reviewRequestMutation; reviewRequestMutation = fetchJson(pathFor(item) + '/request-review', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }), }).then(result => { this.onReviewRequested?.(result); return result; }).finally(() => { reviewRequestMutation = null; }); return reviewRequestMutation; }, cancelReview(item, reviewer, expectedHeadSha) { if (reviewCancelMutation) return reviewCancelMutation; reviewCancelMutation = fetchJson(pathFor(item) + '/request-review', { method: 'DELETE', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }), }).then(result => { this.onReviewCancelled?.(result); return result; }).finally(() => { reviewCancelMutation = null; }); return reviewCancelMutation; }, publishReady(item, expectedHeadSha) { if (readyMutation) return readyMutation; readyMutation = fetchJson(pathFor(item) + '/ready', { method:'PATCH', headers:{ Accept:'application/json', 'Content-Type':'application/json' }, body:JSON.stringify({ expected_head_sha:expectedHeadSha }), }).then(result => { if (result?.number !== item.number || result?.head_sha !== expectedHeadSha || result?.state !== 'open' || result?.draft !== false || !result?.title) { throw new Error('Draft publication was not confirmed.'); } return result; }).finally(() => { readyMutation = null; }); return readyMutation; }, 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'); return value && typeof value.title === 'string' && typeof value.body === 'string' && typeof value.expectedHeadSha === 'string' ? value : null; } catch (_error) { return null; } }, saveEditDraft(item, draft) { try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); } catch (_error) { /* The edit fields remain the fallback. */ } }, loadFeedbackPass(item, detail, reviewer, login) { const fresh = { headSha:detail.head_sha, index:0, responses:{}, posted:false }; try { const saved = JSON.parse(storage?.getItem(feedbackKey(item, detail, reviewer, login)) || 'null'); return saved && saved.headSha === detail.head_sha && saved.responses && typeof saved.responses === 'object' ? { ...fresh, ...saved } : fresh; } catch (_error) { return fresh; } }, saveFeedbackPass(item, detail, reviewer, login, state) { try { storage?.setItem(feedbackKey(item, detail, reviewer, login), JSON.stringify(state)); } catch (_error) { /* The visible pass remains usable without storage. */ } }, feedbackSummary(reviewer, state) { const labels = { addressed:'Addressed', discussion:'Needs discussion', skipped:'Skip for now' }; const lines = (reviewer.comments || []).filter(comment => Number.isInteger(comment?.id) && comment.id > 0) .map(comment => { const response = state.responses[String(comment.id)] || {}; const path = String(comment.path || '').replaceAll('`', "'").replaceAll('\n', ' ').slice(0, 300); const body = String(comment.body || '').replaceAll('\n', ' ').slice(0, 180); const note = String(response.note || '').replaceAll('\n', ' ').slice(0, 1000); return '- `' + path + '` · comment #' + comment.id + ' · **' + (labels[response.disposition] || 'Unresolved') + '** — ' + body + (note ? '\n - Response: ' + note : ''); }); return ('Addressed review feedback from @' + reviewer.login + ' (review #' + reviewer.review_id + ').\n\n' + lines.join('\n')).slice(0, 10000); }, postFeedbackResponse(item, detail, reviewer, login, state) { if (feedbackRequest) return feedbackRequest; const key = feedbackKey(item, detail, reviewer, login); let operationId; try { operationId = storage?.getItem(key + ':operation') || String(createOperationId()).slice(0, 128); storage?.setItem(key + ':operation', operationId); } catch (_error) { operationId = String(createOperationId()).slice(0, 128); } feedbackRequest = fetchJson(pathFor(item) + '/comments', { method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json', 'Idempotency-Key':operationId }, body:JSON.stringify({ body:this.feedbackSummary(reviewer, state) }), }).then(result => { try { storage?.removeItem(key + ':operation'); } catch (_error) { /* Confirmed upstream. */ } return result; }).finally(() => { feedbackRequest = null; }); return feedbackRequest; }, updateContent(item, draft) { if (editRequest) return editRequest; this.saveEditDraft(item, draft); editRequest = fetchJson(pathFor(item) + '/content', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ title: draft.title, body: draft.body, expected_head_sha: draft.expectedHeadSha, }), }).then(result => { if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body || result?.head_sha !== draft.expectedHeadSha) { throw new Error('Pull request update was not confirmed.'); } try { storage?.removeItem(editDraftKey(item)); } catch (_error) { /* Confirmed upstream content is authoritative. */ } return result; }).finally(() => { editRequest = null; }); return editRequest; }, handoff(item, recipient) { if (ownershipRequest) return ownershipRequest; ownershipRequest = fetchJson(pathFor(item) + '/handoff', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient }), }).finally(() => { ownershipRequest = null; }); return ownershipRequest; }, release(item) { if (ownershipRequest) return ownershipRequest; ownershipRequest = fetchJson(pathFor(item) + '/release', { method: 'PATCH', headers: { Accept: 'application/json' }, }).finally(() => { ownershipRequest = null; }); return ownershipRequest; }, conversation(item, initialPage) { const pager = createConversationPager({ loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', { headers: { Accept: 'application/json' }, }), }); pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 }); return pager; }, reviewState, toggleReviewed(item, detail, filename) { const state = reviewState(item, detail); const reviewed = new Set(state.reviewed); if (reviewed.has(filename)) reviewed.delete(filename); else if (fileNames(detail).includes(filename)) reviewed.add(filename); try { storage?.setItem(reviewKey(item, detail), JSON.stringify(Array.from(reviewed))); } catch (_error) { /* In-memory controls still work for this render. */ } return reviewState(item, detail); }, nextUnreviewed(detail, state) { return fileNames(detail).find(filename => !state.reviewed.includes(filename)) || null; }, loadDraft(item) { try { return storage?.getItem(draftKey(item)) || ''; } catch (_error) { return ''; } }, saveDraft(item, body) { try { if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item)); storage?.setItem(draftKey(item), body); } catch (_error) { /* The textarea remains the fallback. */ } }, comment(item, body) { if (commentRequest) return commentRequest; this.saveDraft(item, body); let operationId; try { operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128); storage?.setItem(operationKey(item), operationId); } catch (_error) { operationId = String(createOperationId()).slice(0, 128); } commentRequest = fetchJson(pathFor(item) + '/comments', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId }, body: JSON.stringify({ body }), }).then(result => { try { storage?.removeItem(draftKey(item)); } catch (_error) { /* The upstream comment is authoritative. */ } try { storage?.removeItem(operationKey(item)); } catch (_error) { /* A confirmed result no longer needs replay identity. */ } return result; }).finally(() => { commentRequest = null; }); return commentRequest; }, merge(item, expectedHeadSha) { if (mergeRequest) return mergeRequest; mergeRequest = fetchJson(pathFor(item) + '/merge', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ expected_head_sha: expectedHeadSha }), }).then(result => { if (!result?.merged) { throw new Error(result?.confirmation_pending && result?.error ? result.error : 'Pull request merge was not confirmed.'); } return result; }).finally(() => { mergeRequest = null; }); return mergeRequest; }, updateBranch(item, expectedHeadSha) { if (branchUpdateRequest) return branchUpdateRequest; branchUpdateRequest = fetchJson(pathFor(item) + '/update-branch', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ expected_head_sha: expectedHeadSha }), }).then(result => { if (!result?.head_sha || result.head_sha === expectedHeadSha) { throw new Error('Branch update was not confirmed.'); } return result; }).finally(() => { branchUpdateRequest = null; }); return branchUpdateRequest; }, }; } 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; 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; createPullSheet.resetOwnershipControls = resetOwnershipControls; createPullSheet.bindOwnershipControls = bindOwnershipControls; createPullSheet.bindContextEditor = bindContextEditor; createPullSheet.resetReviewRequestControls = resetReviewRequestControls; createPullSheet.bindReviewRequestControls = bindReviewRequestControls; createPullSheet.bindFeedbackControls = bindFeedbackControls; createPullSheet.bindCheckRecovery = bindCheckRecovery; if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;