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') { return { allowed: false, reason: '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 createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { let commentRequest = null; let mergeRequest = null; 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' } }); }, 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('Pull request merge was not confirmed.'); return result; }).finally(() => { mergeRequest = null; }); return mergeRequest; }, }; } createPullSheet.mergeEligibility = mergeEligibility; createPullSheet.renderFile = renderFile; if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;