feat: review assigned pull diffs before merge (#187)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-07 10:23:15 +00:00
1 parent 3ff173ca89
commit 2d8a659ebe
5 files changed
+246 -10

No files matched your search

+68 -8
View File
@@ -178,6 +178,17 @@ textarea { resize: vertical; min-height: 120px; }
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
.pull-review-file { margin-top:8px; }
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
.pull-diff-line { display:block; width:max-content; min-width:100%; }
.pull-diff-line.added { color:#86efac; background:#123422; }
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
.pull-review-tools button { min-height:44px; }
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -449,7 +460,9 @@ textarea { resize: vertical; min-height: 120px; }
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Checking merge status</span></div>
<p class="pull-sheet-content" id="pull-sheet-body"></p>
<h2>Changed files</h2><div id="pull-files"></div>
<h2>Changed files</h2>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
<h2>Recent discussion</h2><div id="pull-comments"></div>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
@@ -580,6 +593,7 @@ textarea { resize: vertical; min-height: 120px; }
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let pullReviewState = null;
let creatingIssue = false;
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
@@ -1085,17 +1099,67 @@ textarea { resize: vertical; min-height: 120px; }
if (issueTrigger?.isConnected) issueTrigger.focus();
}
function renderPullReview(detail, focusFilename = null) {
pullReviewState = pullController.reviewState(selectedPull, detail);
qs('#pull-review-progress').textContent = pullReviewState.total ?
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, pullReviewState.reviewed.includes(file.filename), escapeHtml)
).join('') : '<div class="muted">No changed files reported.</div>';
qs('#pull-files').querySelectorAll('.pull-file-toggle').forEach(button => {
button.addEventListener('click', () => {
const panel = document.getElementById(button.getAttribute('aria-controls'));
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
if (panel) panel.hidden = expanded;
});
});
qs('#pull-files').querySelectorAll('.pull-review-file').forEach(button => {
button.addEventListener('click', () => {
const filename = button.dataset.pullReviewFile;
pullReviewState = pullController.toggleReviewed(selectedPull, detail, filename);
renderPullReview(detail, filename);
});
});
if (focusFilename) {
Array.from(qs('#pull-files').querySelectorAll('.pull-review-file'))
.find(button => button.dataset.pullReviewFile === focusFilename)?.focus();
}
}
function focusNextUnreviewedPullFile() {
if (!selectedPullDetail || !pullReviewState) return;
const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState);
const article = Array.from(qs('#pull-files').querySelectorAll('.pull-file'))
.find(file => file.dataset.pullFilename === filename);
const toggle = article?.querySelector('.pull-file-toggle');
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
if (toggle && panel) {
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
}
}
async function openPullSheet(item, trigger) {
if (!item) return;
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-review-progress').textContent = 'Loading review progress…';
qs('#next-unreviewed-pull-file').disabled = true;
qs('#pull-comments').textContent = '';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
@@ -1109,16 +1173,10 @@ textarea { resize: vertical; min-height: 120px; }
const detail = await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
const eligibility = createPullSheet.mergeEligibility(detail);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
qs('#pull-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map(file =>
'<div class="pull-file"><strong>' + escapeHtml(file.filename) + '</strong><div class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</div></div>'
).join('') : '<div class="muted">No changed files reported.</div>';
renderPullReview(detail);
qs('#pull-comments').innerHTML = (detail.comments || []).length ? detail.comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
@@ -1136,6 +1194,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
pullReviewState = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
@@ -1767,6 +1826,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
+54 -1
View File
@@ -1,4 +1,4 @@
function mergeEligibility(detail) {
function mergeEligibility(detail, reviewState) {
if (!detail || detail.state !== 'open' || detail.merged) {
return { allowed: false, reason: 'Pull request is not open' };
}
@@ -8,9 +8,35 @@ function mergeEligibility(detail) {
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 '<span class="pull-diff-line ' + kind + '">' + escapeHtml(text) + '</span>';
}).join('');
const preview = file.diff_available
? '<pre class="pull-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
: '<div class="pull-diff-empty" id="' + panelId + '" hidden>' +
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
return '<article class="pull-file" data-pull-filename="' + filename + '">' +
'<button type="button" class="pull-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 +
'<button type="button" class="pull-review-file" data-pull-review-file="' + filename +
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
@@ -18,11 +44,37 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
.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' } });
},
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 ''; }
@@ -71,4 +123,5 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.renderFile = renderFile;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;