feat: review assigned pull diffs before merge (#187)
This commit is contained in:
1 parent
3ff173ca89
commit
2d8a659ebe
5 files changed
+246
-10
No files matched your search
+68
-8
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user