409 lines
16 KiB
JavaScript
409 lines
16 KiB
JavaScript
function createReviewController({
|
||
fetchJson,
|
||
storage,
|
||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||
}) {
|
||
let pendingSubmission = null;
|
||
let pendingChecks = 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 loadChecks(item) {
|
||
if (pendingChecks) return pendingChecks;
|
||
pendingChecks = fetchJson(endpoint(item) + '/checks', {
|
||
headers: { Accept: 'application/json' },
|
||
}).finally(() => { pendingChecks = null; });
|
||
return pendingChecks;
|
||
}
|
||
|
||
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, loadChecks, submit };
|
||
}
|
||
|
||
function createWrapPreference({ storage, mobile }) {
|
||
const key = 'stackchain.review-wrap.v1';
|
||
let explicit = false;
|
||
let wrapped = Boolean(mobile);
|
||
try {
|
||
const saved = storage?.getItem(key);
|
||
if (saved === 'true' || saved === 'false') {
|
||
explicit = true;
|
||
wrapped = saved === 'true';
|
||
}
|
||
} catch (_error) { /* keep the viewport default */ }
|
||
|
||
function snapshot() { return { wrapped, explicit }; }
|
||
function setWrapped(value) {
|
||
wrapped = Boolean(value);
|
||
explicit = true;
|
||
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
|
||
return snapshot();
|
||
}
|
||
return { snapshot, setWrapped, storageKey: key };
|
||
}
|
||
|
||
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_line: oldLine, old_position: oldLine };
|
||
oldLine += 1;
|
||
return row;
|
||
}
|
||
if (text.startsWith('+')) {
|
||
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
|
||
newLine += 1;
|
||
return row;
|
||
}
|
||
const row = {
|
||
text, kind: 'context', commentable: true,
|
||
old_line: oldLine, new_line: newLine, 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 => {
|
||
const oldNumber = row.old_line || row.old_position || '';
|
||
const newNumber = row.new_line || row.new_position || '';
|
||
const content = '<span class="review-line-numbers" aria-hidden="true">' +
|
||
'<span class="review-line-number old">' + oldNumber + '</span>' +
|
||
'<span class="review-line-number new">' + newNumber + '</span></span>' +
|
||
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
|
||
if (!row.commentable) {
|
||
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
|
||
}
|
||
const position = row.old_position
|
||
? ' data-old-position="' + row.old_position + '"'
|
||
: ' data-new-position="' + row.new_position + '"';
|
||
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
|
||
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
|
||
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
|
||
(row.old_position || row.new_position) + '">' + content + '</button>';
|
||
}).join('');
|
||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||
'</pre>';
|
||
} else {
|
||
const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.';
|
||
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
|
||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||
}
|
||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||
return '<div class="review-file" data-review-filename="' + filename + '"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||
'<strong>' + filename + '</strong><span class="small">' +
|
||
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
|
||
Number(file.deletions || 0) + '</span></button>' + preview +
|
||
'<label class="small" for="review-note-' + index + '">Note for ' + filename + '</label>' +
|
||
'<textarea class="review-note" id="review-note-' + index + '" data-review-filename="' + filename + '" placeholder="Capture feedback for this file"></textarea>' +
|
||
'<button class="review-mark" data-review-filename="' + filename + '" aria-pressed="false">Mark reviewed</button></div>';
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
||
const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
|
||
const items = (Array.isArray(checks) ? checks : []).filter(check =>
|
||
check && typeof check.name === 'string' && check.name
|
||
).map((check, index) => ({ ...check, index })).sort((left, right) =>
|
||
(rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
|
||
);
|
||
const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
|
||
const pending = items.filter(check => check.state === 'pending').length;
|
||
const passed = items.filter(check => check.state === 'success').length;
|
||
const other = items.length - failed - pending - passed;
|
||
const parts = [
|
||
failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
|
||
passed ? passed + ' passed' : '', other ? other + ' other' : '',
|
||
].filter(Boolean);
|
||
const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
|
||
const html = items.map(check => {
|
||
const url = typeof check.url === 'string' ? check.url : '';
|
||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||
}).join('');
|
||
return { summary, html, expanded: failed > 0 };
|
||
}
|
||
|
||
createReviewController.renderDiffFile = renderDiffFile;
|
||
createReviewController.createWrapPreference = createWrapPreference;
|
||
createReviewController.parseDiffLines = parseDiffLines;
|
||
createReviewController.toggleDiff = toggleDiff;
|
||
createReviewController.createProgress = createProgress;
|
||
createReviewController.createDraft = createDraft;
|
||
createReviewController.formatFeedback = formatFeedback;
|
||
createReviewController.copyAndContinue = copyAndContinue;
|
||
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
|
||
createReviewController.renderChecks = renderChecks;
|
||
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = createReviewController;
|
||
}
|