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' }; if (reviewState && !reviewState.complete) { return { allowed: false, reason: 'Review every changed file before merging' }; } return { allowed: true, reason: 'Ready to merge' }; } 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 focusNextUnreviewed(doc, detail, state, controller) { if (!detail || !state) return; const filename = controller.nextUnreviewed(detail, state); 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; toggle.setAttribute('aria-expanded', 'true'); panel.hidden = false; toggle.scrollIntoView({ block: 'center', behavior: 'smooth' }); toggle.focus(); } 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 resetOwnershipControls(doc, item, checkpointed) { const qs = selector => doc.querySelector(selector); 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 = false; qs('#release-pull').disabled = false; qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment'; qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.'; } 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.'; } function bindReviewRequestControls(doc, controller, getSelected) { 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); resetReviewRequestControls(doc, null); 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 bindOwnershipControls(doc, controller, getSelected, finish) { bindReviewRequestControls(doc, controller, getSelected); 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, 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 candidateRequest = null; let reviewCandidateRequest = null; let reviewRequestMutation = null; const reviewRequests = new Map(); const reviewCache = 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 reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha; 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 { 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; }, loadChecks(item) { if (checkRequest) return checkRequest; checkRequest = fetchJson(pathFor(item) + '/checks', { headers: { Accept: 'application/json' }, }).finally(() => { checkRequest = null; }); return checkRequest; }, 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 }), }).finally(() => { reviewRequestMutation = null; }); return reviewRequestMutation; }, 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; }, }; } createPullSheet.mergeEligibility = mergeEligibility; createPullSheet.renderFile = renderFile; createPullSheet.focusNextUnreviewed = focusNextUnreviewed; createPullSheet.removeFromSnapshot = removeFromSnapshot; createPullSheet.sameTarget = sameTarget; createPullSheet.ownershipSelectors = ownershipSelectors; createPullSheet.renderHandoffCandidates = renderHandoffCandidates; createPullSheet.ownershipExitMessage = ownershipExitMessage; createPullSheet.resetOwnershipControls = resetOwnershipControls; createPullSheet.bindOwnershipControls = bindOwnershipControls; createPullSheet.resetReviewRequestControls = resetReviewRequestControls; createPullSheet.bindReviewRequestControls = bindReviewRequestControls; if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;