stackchain-dashboard/frontend/review-sheet.js
timmy 331eb99c1e
All checks were successful
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
feat: carry mobile review feedback into Gitea (#129)
2026-08-06 18:56:06 +00:00

189 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

function createReviewController({ fetchJson }) {
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' } });
}
return { load };
}
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 =>
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
escapeHtml(String(line)) + '</span>'
).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();
}
return { snapshot, markReviewed };
}
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();
}
return { snapshot, setNote, setSummary, setDecision };
}
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 }) {
try {
await copy(text);
} catch (error) {
fallback(text);
return false;
}
open(url);
return 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;
}