function createReviewController({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), }) { let pendingSubmission = null; function endpoint(item) { const [owner, repo] = String(item.repository || '').split('/'); if (!owner || !repo || !Number.isInteger(Number(item.number))) { throw new Error('This review link is invalid.'); } return 'api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/pulls/' + Number(item.number) + '/review'; } async function load(item) { return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } }); } function submit(item, payload) { if (pendingSubmission) return pendingSubmission; const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number; const fingerprint = JSON.stringify(payload); let operationId; try { const saved = JSON.parse(storage?.getItem(operationKey) || 'null'); operationId = saved?.fingerprint === fingerprint && saved?.operationId ? saved.operationId : String(createOperationId()).slice(0, 128); storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId })); } catch (_error) { operationId = String(createOperationId()).slice(0, 128); } pendingSubmission = fetchJson(endpoint(item), { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId, }, body: JSON.stringify(payload), }).then(result => { try { storage?.removeItem(operationKey); } catch (_error) { /* A confirmed result no longer needs replay identity. */ } return result; }).finally(() => { pendingSubmission = null; }); return pendingSubmission; } return { load, submit }; } function diffLineClass(line) { if (line.startsWith('@@')) return 'hunk'; if (line.startsWith('+')) return 'added'; if (line.startsWith('-')) return 'removed'; return 'context'; } function parseDiffLines(lines) { let oldLine = null; let newLine = null; return (lines || []).map(value => { const text = String(value); const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (hunk) { oldLine = Number(hunk[1]); newLine = Number(hunk[2]); return { text, kind: 'hunk', commentable: false }; } if (text.startsWith('\\')) return { text, kind: 'note', commentable: false }; if (oldLine === null || newLine === null) { return { text, kind: diffLineClass(text), commentable: false }; } if (text.startsWith('-')) { const row = { text, kind: 'removed', commentable: true, old_position: oldLine }; oldLine += 1; return row; } if (text.startsWith('+')) { const row = { text, kind: 'added', commentable: true, new_position: newLine }; newLine += 1; return row; } const row = { text, kind: 'context', commentable: true, new_position: newLine }; oldLine += 1; newLine += 1; return row; }); } function renderDiffFile(file, index, escapeHtml) { const panelId = 'review-diff-' + index; let preview; if (file.diff_available) { const lines = parseDiffLines(file.diff_lines).map(row => { if (!row.commentable) { return '' + escapeHtml(row.text) + ''; } const position = row.old_position ? ' data-old-position="' + row.old_position + '"' : ' data-new-position="' + row.new_position + '"'; return ''; }).join(''); preview = ''; } else { const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.'; preview = ''; } const filename = escapeHtml(file.filename || 'Unknown file'); return '
' + preview + '' + '' + '
'; } function toggleDiff(button, panel) { const expanded = button.getAttribute('aria-expanded') === 'true'; button.setAttribute('aria-expanded', String(!expanded)); panel.hidden = expanded; } function createProgress({ storage, repository, number, headSha, files }) { const filenames = (files || []).map(file => file && file.filename).filter(Boolean); const key = 'stackchain.review-progress.v1:' + repository + '#' + number + '@' + headSha; const headKey = 'stackchain.review-progress.v1:' + repository + '#' + number + ':head'; let reviewed = []; let newHead = false; try { const previousHead = storage.getItem(headKey); newHead = Boolean(previousHead && previousHead !== headSha); storage.setItem(headKey, headSha); const saved = JSON.parse(storage.getItem(key) || '[]'); if (Array.isArray(saved)) reviewed = filenames.filter(filename => saved.includes(filename)); } catch (error) { reviewed = []; } function snapshot() { const pending = filenames.find(filename => !reviewed.includes(filename)) || null; return { reviewed: [...reviewed], reviewedCount: reviewed.length, total: filenames.length, nextFilename: pending, ...(newHead ? { newHead: true } : {}), }; } function markReviewed(filename) { if (filenames.includes(filename) && !reviewed.includes(filename)) { reviewed.push(filename); reviewed = filenames.filter(item => reviewed.includes(item)); try { storage.setItem(key, JSON.stringify(reviewed)); } catch (error) { /* local progress remains usable */ } } return snapshot(); } function clear() { reviewed = []; newHead = false; try { storage.removeItem(key); } catch (error) { /* cleared in memory */ } return snapshot(); } return { snapshot, markReviewed, clear, storageKey: key }; } function createDraft({ storage, repository, number, headSha, files }) { const filenames = (files || []).map(file => file && file.filename).filter(Boolean); const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha; let draft = { notes: {}, comments: [], summary: '', decision: 'comment' }; try { const saved = JSON.parse(storage.getItem(key) || '{}'); if (saved && typeof saved === 'object') { draft.notes = Object.fromEntries(filenames .filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename]) .map(filename => [filename, saved.notes[filename]])); if (Array.isArray(saved.comments)) { draft.comments = saved.comments.filter(comment => comment && filenames.includes(comment.path) && typeof comment.body === 'string' && Boolean(comment.body.trim()) && ((Number.isInteger(comment.new_position) && !comment.old_position) || (Number.isInteger(comment.old_position) && !comment.new_position)) ).map(comment => ({ path: comment.path, body: comment.body, ...(comment.new_position ? { new_position: comment.new_position } : {}), ...(comment.old_position ? { old_position: comment.old_position } : {}), })); } if (typeof saved.summary === 'string') draft.summary = saved.summary; if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision; } } catch (error) { /* start with an empty in-memory draft */ } function persist() { try { storage.setItem(key, JSON.stringify(draft)); } catch (error) { /* draft remains usable */ } } function snapshot() { return { notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })), summary: draft.summary, decision: draft.decision, }; } function setNote(filename, note) { if (!filenames.includes(filename)) return snapshot(); if (String(note)) draft.notes[filename] = String(note); else delete draft.notes[filename]; persist(); return snapshot(); } function inlineKey(comment) { return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position); } function setInlineComment(anchor, body) { if (!anchor || !filenames.includes(anchor.path)) return snapshot(); const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0 ? { new_position: anchor.new_position } : Number.isInteger(anchor.old_position) && anchor.old_position > 0 ? { old_position: anchor.old_position } : null; const text = String(body || '').trim(); if (!position || !text) return snapshot(); const comment = { path: anchor.path, body: text, ...position }; const key = inlineKey(comment); const index = draft.comments.findIndex(item => inlineKey(item) === key); if (index >= 0) draft.comments[index] = comment; else draft.comments.push(comment); persist(); return snapshot(); } function removeInlineComment(anchor) { if (!anchor) return snapshot(); const key = inlineKey(anchor); draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key); persist(); return snapshot(); } function setSummary(summary) { draft.summary = String(summary || ''); persist(); return snapshot(); } function setDecision(decision) { if (['comment', 'approve', 'request_changes'].includes(decision)) { draft.decision = decision; persist(); } return snapshot(); } function clear() { draft = { notes: {}, comments: [], summary: '', decision: 'comment' }; try { storage.removeItem(key); } catch (error) { /* cleared in memory */ } return snapshot(); } return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear, storageKey: key, }; } function formatFeedback(draft, files) { const decisions = { comment: 'Comment', approve: 'Approve', request_changes: 'Request changes', }; const sections = [ '## Intended decision\n' + (decisions[draft.decision] || decisions.comment), ]; if (String(draft.summary || '').trim()) { sections.push('## Summary\n' + String(draft.summary).trim()); } const fileNotes = (files || []).flatMap(file => { const filename = file && file.filename; const note = filename && String(draft.notes?.[filename] || '').trim(); if (!note) return []; return ['### `' + String(filename).replaceAll('`', '\\`') + '`\n' + note]; }); if (fileNotes.length) sections.push('## File notes\n' + fileNotes.join('\n\n')); return sections.join('\n\n'); } async function copyAndContinue({ text, url, copy, open, fallback }) { const destination = open(); try { await copy(text); } catch (error) { destination?.close?.(); fallback(text); return { copied: false, opened: false }; } if (!destination) { fallback(text); return { copied: true, opened: false }; } destination.location.href = url; return { copied: true, opened: true }; } function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) { const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : []; if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false; const filenames = Array.isArray(reviewed) ? reviewed.filter(filename => typeof filename === 'string' && filename) : []; const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha; try { storage?.setItem(key, JSON.stringify(filenames)); } catch (_error) { return false; } return true; } createReviewController.renderDiffFile = renderDiffFile; createReviewController.parseDiffLines = parseDiffLines; createReviewController.toggleDiff = toggleDiff; createReviewController.createProgress = createProgress; createReviewController.createDraft = createDraft; createReviewController.formatFeedback = formatFeedback; createReviewController.copyAndContinue = copyAndContinue; createReviewController.prepareMergeContinuation = prepareMergeContinuation; if (typeof module !== 'undefined' && module.exports) { module.exports = createReviewController; }