function createReviewController({ fetchJson }) {
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;
pendingSubmission = fetchJson(endpoint(item), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).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 renderDiffFile(file, index, escapeHtml) {
const panelId = 'review-diff-' + index;
let preview;
if (file.diff_available) {
const lines = (file.diff_lines || []).map(line =>
'' +
escapeHtml(String(line)) + ''
).join('');
preview = '
' + lines +
(file.diff_truncated ? 'Preview truncated · open in Gitea for the full diff.' : '') +
'';
} else {
const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.';
preview = '' + message +
(file.diff_truncated ? ' The response was truncated.' : '') + '
';
}
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 };
}
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: {}, 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 (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 }, 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 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: {}, summary: '', decision: 'comment' };
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
return snapshot();
}
return { snapshot, setNote, setSummary, setDecision, clear };
}
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 };
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
}