stackchain-dashboard/frontend/pull-sheet.js
timmy 1c02dcb217
All checks were successful
CI / lint (pull_request) Successful in 3m32s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m13s
CI / release-candidate (pull_request) Has been skipped
feat: apply review suggestions across files
Closes #1386
2026-08-25 08:02:16 +00:00

1540 lines
75 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 mergeEligibility(detail, reviewState) {
if (!detail || detail.state !== 'open' || detail.merged) {
return { allowed: false, reason: 'Pull request is not open' };
}
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
if (detail.ci_state !== 'success') {
const blockers = (detail.checks || []).filter(check =>
['failure', 'error', 'pending'].includes(check.state)
).map(check => check.name).filter(Boolean).slice(0, 3);
return { allowed: false, reason: blockers.length ?
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
const reviewer = (detail.reviewers || []).find(item => item?.blocking);
if (reviewer?.status === 'waiting') {
return { allowed: false, reason: 'Waiting for @' + reviewer.login + 's review' };
}
if (reviewer?.status === 'changes_requested') {
return { allowed: false, reason: '@' + reviewer.login + ' requested changes' };
}
if (reviewer?.status === 'outdated') {
return { allowed: false, reason: '@' + reviewer.login + 's review is outdated after new commits' };
}
if (reviewState && !reviewState.complete) {
return { allowed: false, reason: 'Review every changed file before merging' };
}
return { allowed: true, reason: 'Ready to merge' };
}
function renderReviewerFeedback(reviewer, escapeHtml) {
const groups = new Map();
(Array.isArray(reviewer?.comments) ? reviewer.comments : []).forEach(comment => {
if (!comment?.path || !comment?.body) return;
if (!groups.has(comment.path)) groups.set(comment.path, []);
groups.get(comment.path).push(comment);
});
const summary = reviewer?.summary ? '<p class="pull-review-feedback-summary">' +
escapeHtml(reviewer.summary) + '</p>' : '';
const files = Array.from(groups.entries()).map(([path, comments]) =>
'<section class="pull-review-feedback-file"><h4>' + escapeHtml(path) + '</h4><ul>' +
comments.map(comment => '<li>' + escapeHtml(comment.body) +
(comment.line ? '<small>Line ' + Number(comment.line) + '</small>' : '') + '</li>').join('') +
'</ul><button type="button" data-review-feedback-file="' + escapeHtml(path) +
'">View in changes</button></section>'
).join('');
const loadable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
['commented', 'changes_requested', 'outdated'].includes(reviewer?.status);
const feedbackState = Array.isArray(reviewer?.comments) ? 'loaded' : reviewer?.feedback_state;
const feedbackAction = !loadable || feedbackState === 'loaded' ? '' :
feedbackState === 'loading' ? '<p class="small" role="status">Loading inline feedback…</p>' :
'<p class="small" role="status">' + (feedbackState === 'error' ? 'Feedback unavailable. ' : '') +
'<button type="button" data-load-review-feedback="' + Number(reviewer.review_id) + '">' +
(feedbackState === 'error' ? 'Retry feedback' : 'Load inline feedback') + '</button></p>';
const emptyLoaded = loadable && feedbackState === 'loaded' && !files ?
'<p class="small">No inline comments on this review.</p>' : '';
if (!summary && !files && !feedbackAction && !emptyLoaded) return '';
const reviewedHead = reviewer?.status === 'outdated' && reviewer?.head_sha ?
'<p class="small">Reviewed head ' + escapeHtml(reviewer.head_sha.slice(0, 8)) + '</p>' : '';
const addressable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
['changes_requested', 'outdated'].includes(reviewer?.status) && feedbackState === 'loaded' &&
(reviewer.comments || []).some(comment => Number.isInteger(comment?.id) && comment.id > 0);
const address = addressable ? '<button type="button" class="address-review-feedback" data-address-review-feedback="' +
Number(reviewer.review_id) + '">Address feedback</button>' : '';
return '<details class="pull-review-feedback"><summary>Review feedback</summary>' +
reviewedHead + summary + feedbackAction + emptyLoaded + files + address + '</details>';
}
function renderReviewerStatuses(reviewers, escapeHtml) {
const labels = {
waiting: 'Waiting for review',
approved: 'Approved current head',
changes_requested: 'Changes requested',
commented: 'Commented',
outdated: 'Outdated after new commits',
};
return (Array.isArray(reviewers) ? reviewers : []).map(reviewer => {
const status = labels[reviewer?.status] || 'Status unavailable';
const cancel = reviewer?.status === 'waiting' && reviewer?.login ?
'<button type="button" class="cancel-review-request" data-cancel-review-request="' +
escapeHtml(reviewer.login) + '">Cancel request</button>' : '';
return '<article class="pull-reviewer-status" data-reviewer-status="' +
escapeHtml(reviewer?.status || 'unknown') + '" data-review-id="' +
(Number.isInteger(reviewer?.review_id) ? Number(reviewer.review_id) : '') +
'"><div class="pull-reviewer-status-heading"><strong>@' +
escapeHtml(reviewer?.login || 'unknown') + '</strong><span>' + escapeHtml(status) + '</span></div>' +
renderReviewerFeedback(reviewer, escapeHtml) + cancel + '</article>';
}).join('');
}
function renderReviewerPanel(doc, detail, escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')) {
const reviewers = Array.isArray(detail?.reviewers) ? detail.reviewers : [];
const openFeedback = new Set(Array.from(doc.querySelectorAll('.pull-review-feedback[open]'))
.map(panel => panel.closest('.pull-reviewer-status')?.dataset.reviewId).filter(Boolean));
doc.querySelector('#pull-reviewer-statuses').innerHTML = renderReviewerStatuses(reviewers, escapeHtml);
openFeedback.forEach(reviewId => {
const panel = doc.querySelector('.pull-reviewer-status[data-review-id="' + reviewId + '"] .pull-review-feedback');
if (panel) panel.open = true;
});
doc.querySelectorAll('[data-review-feedback-file]').forEach(button => {
button.addEventListener('click', () => focusPullFile(doc, button.dataset.reviewFeedbackFile));
});
doc.querySelector('#pull-reviewer-status').textContent = reviewers.length ?
reviewers.length + ' reviewer status' + (reviewers.length === 1 ? '' : 'es') + ' for this pull request.' :
'No review has been requested; existing merge policy is unchanged.';
doc.querySelector('#request-updated-pull-review').hidden =
!reviewers.some(reviewer => reviewer.status === 'outdated');
}
function reviewEligibility(detail, reviewState, doc) {
renderReviewerPanel(doc, detail);
return mergeEligibility(detail, reviewState);
}
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 focusPullFile(doc, filename) {
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
.find(file => file.dataset.pullFilename === filename);
const toggle = article?.querySelector('.pull-file-toggle');
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
if (!toggle || !panel) return false;
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
return true;
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
focusPullFile(doc, controller.nextUnreviewed(detail, state));
}
function removeFromSnapshot(data, item) {
return { ...data, pulls:(data.pulls || []).filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number) };
}
function sameTarget(left, right) {
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
}
function ownershipSelectors() {
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
}
function renderHandoffCandidates(select, candidates, doc) {
select.textContent = '';
const placeholder = doc.createElement('option');
placeholder.value = '';
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
select.appendChild(placeholder);
candidates.forEach(candidate => {
const option = doc.createElement('option');
option.value = candidate.login;
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
select.appendChild(option);
});
return candidates.length;
}
function ownershipExitMessage(item, action, transitionResult) {
return item.key + ' ' + action + (transitionResult === 'opened' ?
'. Next work item opened.' : '. Choose the next ready Today item.');
}
function applyOwnershipCapabilities(doc, capabilities = null) {
const ownership = doc.querySelector('#pull-ownership');
const assigned = capabilities?.assigned !== false;
ownership.hidden = !assigned;
ownership.inert = !assigned;
return assigned;
}
function resetOwnershipControls(doc, item, checkpointed, capabilities = null) {
const qs = selector => doc.querySelector(selector);
const assigned = applyOwnershipCapabilities(doc, capabilities);
resetLifecycleControls(doc, null);
qs('#pull-ownership').open = false;
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-handoff-recipient').disabled = true;
qs('#confirm-pull-handoff').disabled = true;
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-pull-handoff').disabled = !assigned;
qs('#release-pull').disabled = !assigned;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = assigned ?
'Load teammates to transfer ownership.' : 'Assignment controls are not available for this pull request.';
qs('#edit-pull-content').hidden = true;
qs('#pull-edit-form').hidden = true;
qs('#pull-edit-status').textContent = '';
}
function resetReviewRequestControls(doc, detail) {
const qs = selector => doc.querySelector(selector);
qs('#pull-review-request').open = false;
qs('#pull-review-request').dataset.headSha = detail?.head_sha || '';
qs('#pull-review-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-review-recipient').disabled = true;
qs('#confirm-pull-review').disabled = true;
qs('#load-pull-reviewers').disabled = !detail?.head_sha;
qs('#pull-review-request-status').textContent = detail?.head_sha ?
'Load teammates to request a review.' : 'Load the current pull request before requesting review.';
qs('#pull-reviewer-status').textContent = 'Reviewer status not loaded.';
qs('#pull-reviewer-statuses').textContent = '';
qs('#request-updated-pull-review').hidden = true;
qs('#pull-feedback-pass').hidden = true;
qs('#pull-feedback-status').textContent = '';
}
function resetBranchUpdateControls(doc, detail) {
const panel = doc.querySelector('#pull-branch-update');
if (!panel) return;
const eligible = detail?.capabilities?.authored === true && detail?.state === 'open' &&
detail?.draft !== true && detail?.merged !== true && detail?.mergeable === false && detail?.head_sha;
panel.hidden = !eligible;
doc.querySelector('#update-pull-branch').disabled = false;
doc.querySelector('#pull-branch-update-status').textContent = eligible ?
'Merge the latest base branch into this pull request. Reviews and checks may run again.' : '';
}
function bindBranchUpdateControls(doc, controller, getSelected, getDetail) {
const button = doc.querySelector('#update-pull-branch');
if (!button || button.dataset.bound === 'true') return;
button.dataset.bound = 'true';
button.addEventListener('click', async () => {
const item = getSelected?.();
const detail = getDetail?.();
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
!globalThis.confirm('Update ' + item.key + ' with the latest base branch? Reviews and checks may run again.')) return;
button.disabled = true;
const status = doc.querySelector('#pull-branch-update-status');
status.textContent = 'Updating branch…';
try {
const result = await controller.updateBranch(item, detail.head_sha);
if (getSelected?.() !== item || getDetail?.() !== detail) return;
Object.assign(detail, result, { mergeable:null, ci_state:'pending', reviewers:[] });
status.textContent = 'Branch updated. Refreshing checks and review state…';
doc.querySelector('#pull-review-retry')?.click();
status.textContent = 'Branch updated to ' + result.head_sha.slice(0, 8) + '. Review the refreshed checks before merging.';
button.hidden = true;
doc.querySelector('#refresh-pull-checks')?.focus();
} catch (error) {
status.textContent = error.message + ' Reload the latest pull request state before retrying.';
button.disabled = false;
button.focus();
}
});
}
function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-reviewers');
if (load.dataset.reviewRequestBound === 'true') return;
load.dataset.reviewRequestBound = 'true';
controller.setReviewDetail = detail => {
resetReviewRequestControls(doc, detail);
resetBranchUpdateControls(doc, detail);
applyOwnershipCapabilities(doc, detail?.capabilities);
resetLifecycleControls(doc, detail?.saved_at ? null : detail);
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
const publishable = detail?.state === 'open' && detail?.draft === true &&
detail?.author === getLogin?.() && detail?.head_sha;
qs('#publish-draft-pull').hidden = !publishable;
if (publishable) qs('#pull-review-request').open = true;
qs('#publish-draft-pull-status').textContent = '';
};
resetReviewRequestControls(doc, null);
qs('#request-updated-pull-review').addEventListener('click', () => {
qs('#pull-review-request').open = true;
qs('#pull-review-request').scrollIntoView({ block:'center', behavior:'smooth' });
load.focus();
});
controller.onReviewRequested = result => {
const detail = getDetail?.();
if (!detail || result.head_sha !== detail.head_sha) return;
detail.reviewers = (detail.reviewers || []).filter(reviewer => reviewer.login !== result.reviewer);
detail.reviewers.push({login:result.reviewer, status:'waiting', head_sha:result.head_sha, blocking:true});
renderReviewerPanel(doc, detail);
qs('#pull-merge-state').textContent = 'Waiting for @' + result.reviewer + 's review';
qs('#merge-pull').disabled = true;
};
qs('#publish-draft-pull').addEventListener('click', async () => {
const selected = getSelected?.();
const detail = getDetail?.();
if (!selected || !detail?.head_sha || detail.draft !== true ||
!globalThis.confirm('Publish ' + selected.key + ' as ready for review?')) return;
const button = qs('#publish-draft-pull');
button.disabled = true;
qs('#publish-draft-pull-status').textContent = 'Publishing draft…';
try {
const result = await controller.publishReady(selected, detail.head_sha);
if (getSelected?.() !== selected || getDetail?.() !== detail) return;
Object.assign(detail, result);
selected.title = result.title;
qs('#pull-sheet-title').textContent = result.title;
controller.setReviewDetail(detail);
qs('#publish-draft-pull-status').textContent = 'Published and ready for review.';
qs('#pull-review-request').open = true;
load.focus();
} catch (error) {
qs('#publish-draft-pull-status').textContent = error.message + ' The pull request remains a draft; retry.';
button.disabled = false;
button.focus();
}
});
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
const button = event.target.closest?.('[data-cancel-review-request]');
if (!button || button.disabled) return;
const selected = getSelected?.();
const detail = getDetail?.();
const reviewer = button.dataset.cancelReviewRequest;
if (!selected || !detail?.head_sha || !reviewer ||
!globalThis.confirm('Cancel @' + reviewer + 's review request for ' + selected.key + '?')) return;
button.disabled = true;
qs('#pull-reviewer-status').textContent = 'Cancelling @' + reviewer + 's review request…';
try {
const result = await controller.cancelReview(selected, reviewer, detail.head_sha);
if (result?.head_sha !== detail.head_sha || result?.requested_reviewers?.includes(reviewer)) {
throw new Error('Cancellation was not confirmed.');
}
detail.reviewers = (detail.reviewers || []).filter(candidate =>
candidate.login !== reviewer || candidate.status !== 'waiting');
renderReviewerPanel(doc, detail);
const eligibility = mergeEligibility(detail, controller.reviewState(selected, detail));
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-review-request').open = true;
qs('#pull-review-request-status').textContent = 'Review request cancelled. Choose a replacement reviewer.';
load.disabled = false;
load.focus();
} catch (error) {
qs('#pull-reviewer-status').textContent = error.message + ' Refresh review data before retrying.';
button.disabled = false;
button.focus();
}
});
load.addEventListener('click', async () => {
const selected = getSelected();
if (!selected) return;
const select = qs('#pull-review-recipient');
load.disabled = true;
qs('#pull-review-request-status').textContent = 'Loading eligible reviewers…';
try {
const count = renderHandoffCandidates(select, await controller.loadReviewCandidates(selected), doc);
select.disabled = !count;
qs('#confirm-pull-review').disabled = true;
qs('#pull-review-request-status').textContent = count ?
'Choose a teammate to review the current head.' : 'No eligible reviewers were found.';
if (count) select.focus();
else load.disabled = false;
} catch (error) {
qs('#pull-review-request-status').textContent = error.message + ' Retry loading reviewers.';
load.disabled = false;
load.focus();
}
});
qs('#pull-review-recipient').addEventListener('change', event => {
qs('#confirm-pull-review').disabled = !event.target.value;
});
qs('#confirm-pull-review').addEventListener('click', async () => {
const selected = getSelected();
const detail = { head_sha: qs('#pull-review-request').dataset.headSha };
const reviewer = qs('#pull-review-recipient').value;
if (!selected || !detail?.head_sha || !reviewer ||
!globalThis.confirm('Request review of ' + selected.key + ' at ' + detail.head_sha.slice(0, 8) + ' from @' + reviewer + '?')) return;
const button = qs('#confirm-pull-review');
button.disabled = true;
qs('#pull-review-request-status').textContent = 'Requesting review…';
try {
await controller.requestReview(selected, reviewer, detail.head_sha);
qs('#pull-review-request-status').textContent = 'Review requested from @' + reviewer + '.';
} catch (error) {
qs('#pull-review-request-status').textContent = error.message + ' Selection kept; retry the request.';
button.disabled = false;
button.focus();
}
});
}
function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const panel = qs('#pull-feedback-pass');
if (panel.dataset.feedbackBound === 'true') return;
panel.dataset.feedbackBound = 'true';
let active = null;
const comments = () => (active?.reviewer?.comments || [])
.filter(comment => Number.isInteger(comment?.id) && comment.id > 0);
const current = () => comments()[active?.state?.index || 0];
const save = () => {
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
};
const clearSuggestion = () => {
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#cancel-pull-feedback-suggestion').hidden = true;
qs('#stage-pull-feedback-suggestion').hidden = true;
qs('#apply-pull-feedback-suggestion').hidden = true;
if (active) active.suggestion = null;
};
const suggestedFile = (file, suggestion) => {
const line = Number(suggestion?.line);
if (!Number.isInteger(line) || line < 1 || typeof suggestion?.content !== 'string') return null;
const trailingNewline = file.content.endsWith('\n');
const lines = file.content.split('\n');
if (trailingNewline) lines.pop();
if (line > lines.length) return null;
const before = lines[line - 1];
lines.splice(line - 1, 1, ...suggestion.content.split('\n'));
return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') };
};
const renderBatchButton = () => {
const count = active?.batch ? Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0) : 0;
const button = qs('#review-pull-feedback-batch');
button.hidden = count < 2;
button.textContent = 'Review fixes (' + count + ')';
};
const rebuildBatchFile = entry => {
const trailingNewline = entry.file.content.endsWith('\n');
const lines = entry.file.content.split('\n');
if (trailingNewline) lines.pop();
const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line);
if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null;
for (const suggestion of ordered) {
if (suggestion.line < 1 || suggestion.line > lines.length) return null;
lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n'));
}
return lines.join('\n') + (trailingNewline ? '\n' : '');
};
const render = () => {
if (!active) return;
const items = comments();
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
const comment = current();
if (active.suggestion && active.suggestion.commentId !== comment.id) clearSuggestion();
const response = active.state.responses[String(comment.id)] || {};
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + 's feedback';
qs('#pull-feedback-location').textContent = comment.path + (comment.line ? ' · line ' + Number(comment.line) : '');
qs('#pull-feedback-body').textContent = comment.body;
qs('#pull-feedback-note').value = response.note || '';
panel.querySelectorAll('[data-feedback-disposition]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.feedbackDisposition === response.disposition));
});
qs('#previous-pull-feedback').disabled = active.state.index === 0;
qs('#next-pull-feedback').disabled = active.state.index >= items.length - 1;
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
qs('#request-feedback-review').hidden = active.state.posted !== true;
qs('#make-pull-feedback-fix').hidden = active.detail?.capabilities?.authored !== true ||
active.detail?.state !== 'open' || active.detail?.merged === true || !comment.path;
const canSuggest = active.detail?.capabilities?.authored === true && active.detail?.state === 'open' &&
active.detail?.merged !== true && active.reviewer?.head_sha === active.detail?.head_sha &&
comment.suggestion?.line === comment.line && typeof comment.suggestion?.content === 'string';
qs('#review-pull-feedback-suggestion').hidden = !canSuggest;
qs('#review-pull-feedback-suggestion').disabled = canSuggest && globalThis.navigator?.onLine === false;
if (canSuggest && globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
}
const found = focusPullFile(doc, comment.path);
qs('#pull-feedback-context').textContent = found ?
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
panel.scrollIntoView({ block:'center', behavior:'smooth' });
};
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
const loadButton = event.target.closest?.('[data-load-review-feedback]');
if (loadButton && !loadButton.disabled) {
const detail = getDetail?.();
const item = getSelected?.();
const reviewId = Number(loadButton.dataset.loadReviewFeedback);
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const refresh = reviewer.feedback_state === 'error';
reviewer.feedback_state = 'loading';
renderReviewerPanel(doc, detail);
try {
const result = await controller.loadFeedback(item, detail, reviewer, { refresh });
if (getDetail?.() !== detail || getSelected?.() !== item ||
result.head_sha !== detail.head_sha || Number(result.review_id) !== reviewId) return;
reviewer.comments = Array.isArray(result.comments) ? result.comments : [];
reviewer.feedback_state = 'loaded';
renderReviewerPanel(doc, detail);
doc.querySelector('[data-address-review-feedback="' + reviewId + '"]')?.focus();
} catch (error) {
if (getDetail?.() !== detail || getSelected?.() !== item) return;
reviewer.feedback_state = 'error';
renderReviewerPanel(doc, detail);
doc.querySelector('[data-load-review-feedback="' + reviewId + '"]')?.focus();
}
return;
}
const button = event.target.closest?.('[data-address-review-feedback]');
if (!button) return;
const detail = getDetail?.();
const item = getSelected?.();
const reviewId = Number(button.dataset.addressReviewFeedback);
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
active = { item, detail, reviewer, login, batch:{},
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
active.suggestion = null;
renderBatchButton();
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
qs('#review-pull-feedback-suggestion').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.suggestion || globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
return;
}
const button = qs('#review-pull-feedback-suggestion');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current line for review…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
const change = suggestedFile(file, comment.suggestion);
if (!change) throw new Error('The suggested line is no longer available.');
active.suggestion = { file, content:change.content, commentId:comment.id };
qs('#pull-feedback-suggestion-before').textContent = change.before;
qs('#pull-feedback-suggestion-after').textContent = change.after;
qs('#pull-feedback-suggestion-message').value = 'fix: apply review suggestion';
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = false;
qs('#cancel-pull-feedback-suggestion').hidden = false;
qs('#stage-pull-feedback-suggestion').hidden = false;
qs('#apply-pull-feedback-suggestion').hidden = false;
qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.';
qs('#apply-pull-feedback-suggestion').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Use the full-file editor if needed.';
button.focus();
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-suggestion').addEventListener('click', () => {
clearSuggestion();
qs('#review-pull-feedback-suggestion').focus();
});
qs('#stage-pull-feedback-suggestion').addEventListener('click', () => {
if (!active?.suggestion) return;
const pending = active.suggestion;
const path = pending.file.path;
const stagedCount = Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0);
if (stagedCount >= 8) {
qs('#pull-feedback-status').textContent = 'Commit this bounded batch before staging more suggestions.';
return;
}
const entry = active.batch[path] || {file:pending.file, suggestions:[]};
if (entry.file.blob_sha !== pending.file.blob_sha ||
entry.suggestions.some(item => item.commentId === pending.commentId)) return;
const comment = current();
entry.suggestions.push({
line:Number(comment.suggestion.line), content:comment.suggestion.content,
commentId:pending.commentId,
});
entry.content = rebuildBatchFile(entry);
if (entry.content === null) {
entry.suggestions.pop();
qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.';
return;
}
active.batch[path] = entry;
clearSuggestion();
renderBatchButton();
qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.';
qs('#next-pull-feedback').focus();
});
qs('#review-pull-feedback-batch').addEventListener('click', () => {
if (!active?.batch) return;
const list = qs('#pull-feedback-batch-items');
list.replaceChildren(...Object.values(active.batch).map(entry => {
const item = doc.createElement('li');
item.textContent = entry.file.path + ' · ' + entry.suggestions.length +
(entry.suggestions.length === 1 ? ' suggestion' : ' suggestions');
return item;
}));
qs('#pull-feedback-batch-review').hidden = false;
qs('#cancel-pull-feedback-batch').hidden = false;
qs('#commit-pull-feedback-batch').hidden = false;
qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'});
qs('#commit-pull-feedback-batch').focus();
});
qs('#cancel-pull-feedback-batch').addEventListener('click', () => {
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
qs('#review-pull-feedback-batch').focus();
});
qs('#commit-pull-feedback-batch').addEventListener('click', async () => {
if (!active?.batch) return;
const entries = Object.values(active.batch);
const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0);
const message = qs('#pull-feedback-batch-message').value.trim();
if (count < 2 || !message) return;
if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-batch');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…';
try {
const result = await controller.commitFeedbackBatch(active.item, {
files:entries.map(entry => ({
path:entry.file.path, content:entry.content,
expectedBlobSha:entry.file.blob_sha,
})),
suggestionCount:count, message, expectedHeadSha:active.detail.head_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.batch = {};
save();
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
renderBatchButton();
qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => {
if (!active?.suggestion) return;
const message = qs('#pull-feedback-suggestion-message').value.trim();
if (!message) {
qs('#pull-feedback-status').textContent = 'Enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this suggested change to ' + active.item.key + '?')) return;
const button = qs('#apply-pull-feedback-suggestion');
button.disabled = true;
const pending = active.suggestion;
qs('#pull-feedback-status').textContent = 'Committing and verifying the suggested change…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:pending.file.path, content:pending.content, message,
expectedHeadSha:pending.file.head_sha, expectedBlobSha:pending.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
clearSuggestion();
save();
qs('#pull-feedback-status').textContent = 'Suggested change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Preview kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#make-pull-feedback-fix').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.path || active.detail?.capabilities?.authored !== true) return;
const button = qs('#make-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current pull file…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
active.file = file;
clearSuggestion();
qs('#pull-feedback-file-path').textContent = file.path;
qs('#pull-feedback-file-content').value = file.content;
qs('#pull-feedback-commit-message').value = 'fix: address review feedback';
qs('#pull-feedback-file-editor').hidden = false;
qs('#cancel-pull-feedback-fix').hidden = false;
qs('#commit-pull-feedback-fix').hidden = false;
qs('#pull-feedback-status').textContent = 'Edit the bounded file, then commit it to this pull branch.';
qs('#pull-feedback-file-content').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Retry without losing your feedback progress.';
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-fix').addEventListener('click', () => {
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
qs('#make-pull-feedback-fix').focus();
});
qs('#commit-pull-feedback-fix').addEventListener('click', async () => {
if (!active?.file) return;
const content = qs('#pull-feedback-file-content').value;
const message = qs('#pull-feedback-commit-message').value.trim();
if (!message || content === active.file.content) {
qs('#pull-feedback-status').textContent = 'Change the file and enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this change to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying the new pull head…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:active.file.path, content, message,
expectedHeadSha:active.file.head_sha, expectedBlobSha:active.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.file = null;
save();
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
qs('#pull-feedback-status').textContent = 'Code change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Draft kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
const disposition = event.target.dataset?.feedbackDisposition;
const comment = current();
if (!active || !comment || !['addressed', 'discussion', 'skipped'].includes(disposition)) return;
const key = String(comment.id);
active.state.responses[key] = { ...(active.state.responses[key] || {}), disposition };
save();
render();
});
qs('#pull-feedback-note').addEventListener('input', event => {
const comment = current();
if (!active || !comment) return;
const key = String(comment.id);
active.state.responses[key] = { ...(active.state.responses[key] || {}), note:event.target.value.slice(0, 1000) };
save();
});
qs('#previous-pull-feedback').addEventListener('click', () => {
if (!active) return;
active.state.index -= 1; save(); render();
});
qs('#next-pull-feedback').addEventListener('click', () => {
if (!active) return;
active.state.index += 1; save(); render();
});
qs('#close-pull-feedback').addEventListener('click', () => {
panel.hidden = true;
doc.querySelector('[data-address-review-feedback="' + active?.reviewer?.review_id + '"]')?.focus();
});
qs('#finish-pull-feedback').addEventListener('click', async () => {
if (!active || qs('#finish-pull-feedback').disabled) return;
const latest = getDetail?.();
if (!latest || latest.head_sha !== active.state.headSha) {
qs('#pull-feedback-status').textContent = 'The pull request changed. Refresh review data before posting.';
return;
}
if (!globalThis.confirm('Post one feedback response to ' + active.item.key + '?')) return;
qs('#finish-pull-feedback').disabled = true;
qs('#pull-feedback-status').textContent = 'Posting feedback response…';
try {
await controller.postFeedbackResponse(active.item, active.detail, active.reviewer, active.login, active.state);
active.state.posted = true;
save();
qs('#pull-feedback-status').textContent = 'Response posted. Request an updated review when ready.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Progress kept; retry without duplicating the response.';
qs('#finish-pull-feedback').disabled = false;
}
});
qs('#request-feedback-review').addEventListener('click', async () => {
if (!active?.state?.posted) return;
const button = qs('#request-feedback-review');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Requesting updated review…';
try {
await controller.requestReview(active.item, active.reviewer.login, active.detail.head_sha);
qs('#pull-feedback-status').textContent = 'Response posted; updated review requested from @' + active.reviewer.login + '.';
button.hidden = true;
} catch (error) {
qs('#pull-feedback-status').textContent = 'Response posted; review not requested. ' + error.message;
button.disabled = false;
}
});
}
function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const form = qs('#pull-edit-form');
const controls = () => form.querySelectorAll('input,textarea,button');
const draft = () => ({
title:qs('#pull-edit-title').value.trim(), body:qs('#pull-edit-body').value,
expectedHeadSha:getDetail()?.head_sha || '',
});
qs('#edit-pull-content').addEventListener('click', () => {
const item = getSelected(), detail = getDetail();
if (!item || !detail) return;
const saved = controller.loadEditDraft(item) || {
title:detail.title || '', body:detail.body || '', expectedHeadSha:detail.head_sha || '',
};
qs('#pull-edit-title').value = saved.title;
qs('#pull-edit-body').value = saved.body;
form.hidden = false;
qs('#pull-edit-status').textContent = '';
qs('#pull-edit-title').focus();
});
qs('#cancel-pull-edit').addEventListener('click', () => {
form.hidden = true;
qs('#edit-pull-content').focus();
});
['#pull-edit-title', '#pull-edit-body'].forEach(selector => qs(selector).addEventListener('input', () => {
const item = getSelected();
if (item && getDetail()) controller.saveEditDraft(item, draft());
}));
form.addEventListener('submit', async event => {
event.preventDefault();
const item = getSelected(), update = draft();
if (!item || !getDetail()) return;
if (!update.title) {
qs('#pull-edit-status').textContent = 'Title is required.';
qs('#pull-edit-title').focus();
return;
}
controls().forEach(control => { control.disabled = true; });
qs('#pull-edit-status').textContent = 'Saving review context…';
try {
const result = await controller.updateContent(item, update);
if (getSelected() !== item) return;
const detail = getDetail();
Object.assign(detail, result);
item.title = result.title;
qs('#pull-sheet-title').textContent = result.title;
qs('#pull-sheet-body').innerHTML = renderMarkdown(result.body || 'No description provided.');
controller.setReviewDetail(detail);
qs('#pull-sheet-status').textContent = 'Review context updated · ready to request review';
form.hidden = true;
qs('#pull-edit-status').textContent = '';
qs('#edit-pull-content').focus();
} catch (error) {
qs('#pull-edit-status').textContent = error.message + ' Your change is still here; retry.';
qs('#pull-edit-title').focus();
} finally {
controls().forEach(control => { control.disabled = false; });
}
});
return {
reset() {
qs('#edit-pull-content').hidden = true;
form.hidden = true;
qs('#pull-edit-status').textContent = '';
},
setDetail(detail, offline = false) {
const editable = !offline && detail?.state === 'open' && detail.author === getLogin();
qs('#edit-pull-content').hidden = !editable;
},
};
}
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
bindBranchUpdateControls(doc, controller, getSelected, getDetail);
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
bindCheckRecovery(doc, controller, getSelected, getDetail);
bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result));
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
if (load.dataset.ownershipBound === 'true') return;
load.dataset.ownershipBound = 'true';
load.addEventListener('click', async () => {
const selected = getSelected();
if (!selected) return;
const select = qs('#pull-handoff-recipient');
load.disabled = true;
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
try {
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
select.disabled = !count;
qs('#confirm-pull-handoff').disabled = true;
qs('#pull-handoff-status').textContent = count ?
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
if (count) select.focus();
} catch (error) {
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
load.disabled = false;
load.focus();
}
});
qs('#pull-handoff-recipient').addEventListener('change', event => {
qs('#confirm-pull-handoff').disabled = !event.target.value;
});
qs('#confirm-pull-handoff').addEventListener('click', async () => {
const selected = getSelected();
const recipient = qs('#pull-handoff-recipient').value;
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
const button = qs('#confirm-pull-handoff');
button.disabled = true;
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
try {
await controller.handoff(selected, recipient);
const transition = await finish(selected);
qs('#my-work-action-status').textContent = transition === false ?
selected.key + ' handed off to @' + recipient + '.' :
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
} catch (error) {
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#release-pull').addEventListener('click', async () => {
const selected = getSelected();
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
const button = qs('#release-pull');
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
try {
await controller.release(selected);
const transition = await finish(selected);
qs('#my-work-action-status').textContent = transition === false ?
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
}
function createPullSheet({ fetchJson, storage, onState, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
let ownershipRequest = null;
let branchUpdateRequest = null;
let candidateRequest = null;
let reviewCandidateRequest = null;
let reviewRequestMutation = null;
let reviewCancelMutation = null;
let readyMutation = null;
let lifecycleMutation = null;
let editRequest = null;
let feedbackRequest = null;
let feedbackFixRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const feedbackRequests = new Map();
const feedbackCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.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 editDraftKey = item => 'stackchain.pull-edit.v1:' + item.repository + '#' + item.number;
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
const feedbackKey = (item, detail, reviewer, login) => 'stackchain.feedback-pass.v1:' +
item.repository + '#' + item.number + ':' + reviewer.review_id + ':' + reviewer.head_sha + ':' + detail.head_sha + ':' + login;
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 {
onState,
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadReview(item, headSha, { refresh = false } = {}) {
const key = item.repository + '#' + item.number + ':' + headSha;
if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (reviewRequests.has(key)) return reviewRequests.get(key);
const request = fetchJson(pathFor(item) + '/review-data', {
headers: { Accept: 'application/json' },
}).then(detail => {
const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha;
reviewCache.set(confirmedKey, detail);
return detail;
}).finally(() => reviewRequests.delete(key));
reviewRequests.set(key, request);
return request;
},
loadFeedback(item, detail, reviewer, { refresh = false } = {}) {
const reviewId = Number(reviewer?.review_id);
const headSha = detail?.head_sha || '';
const key = item.repository + '#' + item.number + ':' + headSha + ':' + reviewId;
if (!refresh && feedbackCache.has(key)) return Promise.resolve(feedbackCache.get(key));
if (feedbackRequests.has(key)) return feedbackRequests.get(key);
const request = fetchJson(pathFor(item) + '/reviews/' + encodeURIComponent(reviewId) +
'/feedback?expected_head_sha=' + encodeURIComponent(headSha), {
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.head_sha !== headSha || Number(result?.review_id) !== reviewId) {
throw new Error('Review feedback changed. Reload review data before retrying.');
}
feedbackCache.set(key, result);
return result;
}).finally(() => feedbackRequests.delete(key));
feedbackRequests.set(key, request);
return request;
},
loadFeedbackFile(item, path, expectedHeadSha) {
return fetchJson(pathFor(item) + '/feedback-file?path=' + encodeURIComponent(path) +
'&expected_head_sha=' + encodeURIComponent(expectedHeadSha), {
headers:{ Accept:'application/json' },
});
},
commitFeedbackFix(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-file', {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
path:draft.path, content:draft.content, message:draft.message,
expected_head_sha:draft.expectedHeadSha,
expected_blob_sha:draft.expectedBlobSha,
}),
}).then(result => {
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha || result.path !== draft.path) {
throw new Error('The feedback fix was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
commitFeedbackBatch(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
files:draft.files.map(file => ({
path:file.path, content:file.content,
expected_blob_sha:file.expectedBlobSha,
})),
suggestion_count:draft.suggestionCount,
message:draft.message, expected_head_sha:draft.expectedHeadSha,
}),
}).then(result => {
const expectedPaths = draft.files.map(file => file.path);
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha ||
!Array.isArray(result.paths) || result.paths.length !== expectedPaths.length ||
result.paths.some((path, index) => path !== expectedPaths[index]) ||
result.suggestion_count !== draft.suggestionCount) {
throw new Error('The suggestion batch was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { checkRequest = null; });
return checkRequest;
},
loadCheckFailure(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/failure?expected_head_sha=' +
encodeURIComponent(expectedHeadSha), {
headers: { Accept: 'application/json' },
});
},
retryCheck(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
});
},
loadHandoffCandidates(item) {
if (candidateRequest) return candidateRequest;
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
headers: { Accept: 'application/json' },
}).finally(() => { candidateRequest = null; });
return candidateRequest;
},
loadReviewCandidates(item) {
if (reviewCandidateRequest) return reviewCandidateRequest;
reviewCandidateRequest = fetchJson(pathFor(item) + '/review-candidates', {
headers: { Accept: 'application/json' },
}).finally(() => { reviewCandidateRequest = null; });
return reviewCandidateRequest;
},
requestReview(item, reviewer, expectedHeadSha) {
if (reviewRequestMutation) return reviewRequestMutation;
reviewRequestMutation = fetchJson(pathFor(item) + '/request-review', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
}).then(result => {
this.onReviewRequested?.(result);
return result;
}).finally(() => { reviewRequestMutation = null; });
return reviewRequestMutation;
},
cancelReview(item, reviewer, expectedHeadSha) {
if (reviewCancelMutation) return reviewCancelMutation;
reviewCancelMutation = fetchJson(pathFor(item) + '/request-review', {
method: 'DELETE',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
}).then(result => {
this.onReviewCancelled?.(result);
return result;
}).finally(() => { reviewCancelMutation = null; });
return reviewCancelMutation;
},
publishReady(item, expectedHeadSha) {
if (readyMutation) return readyMutation;
readyMutation = fetchJson(pathFor(item) + '/ready', {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
}).then(result => {
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
result?.state !== 'open' || result?.draft !== false || !result?.title) {
throw new Error('Draft publication was not confirmed.');
}
return result;
}).finally(() => { readyMutation = null; });
return readyMutation;
},
transition(item, expectedHeadSha, action) {
if (lifecycleMutation) return lifecycleMutation;
lifecycleMutation = fetchJson(pathFor(item) + '/' + action, {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
}).then(result => {
const expectedState = action === 'close' ? 'closed' : 'open';
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
result?.state !== expectedState || result?.merged === true) {
throw new Error('Pull request state change was not confirmed.');
}
return result;
}).finally(() => { lifecycleMutation = null; });
return lifecycleMutation;
},
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
typeof value.expectedHeadSha === 'string' ? value : null;
} catch (_error) { return null; }
},
saveEditDraft(item, draft) {
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
catch (_error) { /* The edit fields remain the fallback. */ }
},
loadFeedbackPass(item, detail, reviewer, login) {
const fresh = { headSha:detail.head_sha, index:0, responses:{}, posted:false };
try {
const saved = JSON.parse(storage?.getItem(feedbackKey(item, detail, reviewer, login)) || 'null');
return saved && saved.headSha === detail.head_sha && saved.responses && typeof saved.responses === 'object'
? { ...fresh, ...saved } : fresh;
} catch (_error) { return fresh; }
},
saveFeedbackPass(item, detail, reviewer, login, state) {
try { storage?.setItem(feedbackKey(item, detail, reviewer, login), JSON.stringify(state)); }
catch (_error) { /* The visible pass remains usable without storage. */ }
},
feedbackSummary(reviewer, state) {
const labels = { addressed:'Addressed', discussion:'Needs discussion', skipped:'Skip for now' };
const lines = (reviewer.comments || []).filter(comment => Number.isInteger(comment?.id) && comment.id > 0)
.map(comment => {
const response = state.responses[String(comment.id)] || {};
const path = String(comment.path || '').replaceAll('`', "'").replaceAll('\n', ' ').slice(0, 300);
const body = String(comment.body || '').replaceAll('\n', ' ').slice(0, 180);
const note = String(response.note || '').replaceAll('\n', ' ').slice(0, 1000);
return '- `' + path + '` · comment #' + comment.id + ' · **' + (labels[response.disposition] || 'Unresolved') +
'** — ' + body + (note ? '\n - Response: ' + note : '');
});
return ('Addressed review feedback from @' + reviewer.login + ' (review #' + reviewer.review_id + ').\n\n' +
lines.join('\n')).slice(0, 10000);
},
postFeedbackResponse(item, detail, reviewer, login, state) {
if (feedbackRequest) return feedbackRequest;
const key = feedbackKey(item, detail, reviewer, login);
let operationId;
try {
operationId = storage?.getItem(key + ':operation') || String(createOperationId()).slice(0, 128);
storage?.setItem(key + ':operation', operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
feedbackRequest = fetchJson(pathFor(item) + '/comments', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json', 'Idempotency-Key':operationId },
body:JSON.stringify({ body:this.feedbackSummary(reviewer, state) }),
}).then(result => {
try { storage?.removeItem(key + ':operation'); } catch (_error) { /* Confirmed upstream. */ }
return result;
}).finally(() => { feedbackRequest = null; });
return feedbackRequest;
},
updateContent(item, draft) {
if (editRequest) return editRequest;
this.saveEditDraft(item, draft);
editRequest = fetchJson(pathFor(item) + '/content', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
body: draft.body,
expected_head_sha: draft.expectedHeadSha,
}),
}).then(result => {
if (result?.number !== item.number || result?.title !== draft.title ||
result?.body !== draft.body || result?.head_sha !== draft.expectedHeadSha) {
throw new Error('Pull request update was not confirmed.');
}
try { storage?.removeItem(editDraftKey(item)); }
catch (_error) { /* Confirmed upstream content is authoritative. */ }
return result;
}).finally(() => { editRequest = null; });
return editRequest;
},
handoff(item, recipient) {
if (ownershipRequest) return ownershipRequest;
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient }),
}).finally(() => { ownershipRequest = null; });
return ownershipRequest;
},
release(item) {
if (ownershipRequest) return ownershipRequest;
ownershipRequest = fetchJson(pathFor(item) + '/release', {
method: 'PATCH', headers: { Accept: 'application/json' },
}).finally(() => { ownershipRequest = null; });
return ownershipRequest;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
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 ''; }
},
saveDraft(item, body) {
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
catch (_error) { /* The textarea remains the fallback. */ }
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(pathFor(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
},
merge(item, expectedHeadSha) {
if (mergeRequest) return mergeRequest;
mergeRequest = fetchJson(pathFor(item) + '/merge', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.merged) {
throw new Error(result?.confirmation_pending && result?.error
? result.error
: 'Pull request merge was not confirmed.');
}
return result;
}).finally(() => { mergeRequest = null; });
return mergeRequest;
},
updateBranch(item, expectedHeadSha) {
if (branchUpdateRequest) return branchUpdateRequest;
branchUpdateRequest = fetchJson(pathFor(item) + '/update-branch', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.head_sha || result.head_sha === expectedHeadSha) {
throw new Error('Branch update was not confirmed.');
}
return result;
}).finally(() => { branchUpdateRequest = null; });
return branchUpdateRequest;
},
};
}
function resetLifecycleControls(doc, detail) {
const authored = detail?.capabilities?.authored === true;
const lifecycle = doc.querySelector('#pull-lifecycle');
if (!lifecycle) return;
lifecycle.hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#close-authored-pull').hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-reopen-status').textContent = '';
}
function bindLifecycleControls(doc, controller, selectedPull, selectedDetail, applyResult) {
const close = doc.querySelector('#close-authored-pull');
const reopen = doc.querySelector('#reopen-authored-pull');
close.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
!globalThis.confirm('Close ' + item.key + ' at head ' + detail.head_sha.slice(0, 12) + '?')) return;
close.disabled = true;
doc.querySelector('#pull-lifecycle-status').textContent = 'Closing pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'close');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-lifecycle').hidden = true;
doc.querySelector('#pull-close-receipt').hidden = false;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-close-receipt-title').focus();
} catch (error) {
doc.querySelector('#pull-lifecycle-status').textContent = error.message + ' The pull request remains open; reload and retry.';
close.disabled = false;
close.focus();
}
});
reopen.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha) return;
reopen.disabled = true;
doc.querySelector('#pull-reopen-status').textContent = 'Reopening pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'reopen');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle').hidden = false;
close.hidden = false;
close.disabled = false;
doc.querySelector('#pull-lifecycle-status').textContent = 'Pull request reopened.';
doc.querySelector('#pull-reopen-status').textContent = '';
close.focus();
} catch (error) {
doc.querySelector('#pull-reopen-status').textContent = error.message + ' The pull request remains closed; reload and retry.';
reopen.disabled = false;
reopen.focus();
}
});
}
function bindCheckRecovery(
doc, controller, selectedPull, selectedDetail,
confirmRetry = message => globalThis.confirm(message)
) {
let current = null;
const get = selector => doc.querySelector(selector);
function reset({ restoreFocus = true } = {}) {
const recovery = current;
current = null;
get('#pull-check-recovery').hidden = true;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = '';
get('#retry-pull-check').disabled = true;
if (restoreFocus) (recovery?.trigger?.isConnected ? recovery.trigger : get('#refresh-pull-checks'))?.focus();
}
async function poll(recovery, attempt = 0) {
if (current !== recovery || selectedPull() !== recovery.item) return;
if (attempt >= 12) {
get('#pull-check-recovery-status').textContent = 'Retry is still queued. Use Refresh checks to continue monitoring.';
return;
}
await new Promise(resolve => setTimeout(resolve, 1500));
if (current !== recovery || selectedPull() !== recovery.item) return;
try {
const status = await controller.loadChecks(recovery.item);
if (current !== recovery || selectedPull() !== recovery.item) return;
if (status.head_sha !== recovery.headSha) {
get('#pull-check-recovery-status').textContent = 'New commits arrived. Reload the pull request before retrying again.';
return;
}
const check = (status.checks || []).find(candidate =>
candidate?.recovery?.run_id === recovery.runId && candidate?.recovery?.job_index === recovery.jobIndex
);
if (!check || !['failure', 'error', 'pending'].includes(check.state)) {
get('#refresh-pull-checks').click();
get('#pull-check-recovery-status').textContent = 'Retried check completed. Merge readiness refreshed.';
return;
}
get('#pull-check-recovery-status').textContent = check.state === 'pending'
? 'Retried check is running…' : 'Retry queued; waiting for a fresh run…';
} catch (_error) {
get('#pull-check-recovery-status').textContent = 'Waiting for check status…';
}
return poll(recovery, attempt + 1);
}
get('#pull-check-list').addEventListener('click', async event => {
const trigger = event.target.closest('.ci-check-inspect');
const item = selectedPull();
const detail = selectedDetail();
if (!trigger || !item || !detail?.head_sha) return;
const runId = Number(trigger.dataset.runId);
const jobIndex = Number(trigger.dataset.jobIndex);
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) return;
const recovery = {
item, headSha: detail.head_sha, runId, jobIndex, trigger,
name: trigger.closest('.ci-check')?.querySelector('strong')?.textContent || 'Failed check',
};
current = recovery;
get('#pull-check-recovery').hidden = false;
get('#pull-check-recovery-title').textContent = recovery.name;
get('#pull-check-log').textContent = 'Loading failure details…';
get('#pull-check-recovery-status').textContent = 'Loading failure details…';
get('#retry-pull-check').disabled = true;
get('#pull-check-recovery-title').focus();
try {
const result = await controller.loadCheckFailure(item, recovery.headSha, runId, jobIndex);
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = result.excerpt || 'No failure output was reported.';
get('#pull-check-recovery-status').textContent = 'Failure details loaded.';
get('#retry-pull-check').disabled = false;
} catch (error) {
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = error.message;
}
});
get('#close-pull-check-recovery').addEventListener('click', () => reset());
get('#pull-checks').addEventListener('toggle', () => {
if (current && selectedPull() !== current.item) reset({ restoreFocus: false });
});
get('#retry-pull-check').addEventListener('click', async () => {
const recovery = current;
if (!recovery || selectedPull() !== recovery.item) return;
if (!confirmRetry('Retry ' + recovery.name + ' for head ' + recovery.headSha.slice(0, 12) + '?')) return;
const button = get('#retry-pull-check');
button.disabled = true;
get('#pull-check-recovery-status').textContent = 'Queueing retry…';
try {
await controller.retryCheck(recovery.item, recovery.headSha, recovery.runId, recovery.jobIndex);
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = 'Retry queued; waiting for a fresh run…';
poll(recovery);
} catch (error) {
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = error.message;
button.disabled = false;
button.focus();
}
});
return { reset };
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.review = reviewEligibility;
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
createPullSheet.sameTarget = sameTarget;
createPullSheet.bindLifecycleControls = bindLifecycleControls;
createPullSheet.resetLifecycleControls = resetLifecycleControls;
createPullSheet.ownershipSelectors = ownershipSelectors;
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
createPullSheet.ownershipExitMessage = ownershipExitMessage;
createPullSheet.resetOwnershipControls = resetOwnershipControls;
createPullSheet.bindOwnershipControls = bindOwnershipControls;
createPullSheet.bindContextEditor = bindContextEditor;
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
createPullSheet.bindFeedbackControls = bindFeedbackControls;
createPullSheet.bindCheckRecovery = bindCheckRecovery;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;