stackchain-dashboard/frontend/pull-sheet.js
timmy a4ae3e1c7f
All checks were successful
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m33s
CI / release-candidate (pull_request) Has been skipped
feat: publish draft pull requests from mobile (Closes #1344)
2026-08-24 10:06:51 +00:00

950 lines
47 KiB
JavaScript
Raw Permalink 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 resetOwnershipControls(doc, item, checkpointed) {
const qs = selector => doc.querySelector(selector);
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 = false;
qs('#release-pull').disabled = false;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
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 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);
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 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();
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;
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,
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
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);
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
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, 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 candidateRequest = null;
let reviewCandidateRequest = null;
let reviewRequestMutation = null;
let reviewCancelMutation = null;
let readyMutation = null;
let editRequest = null;
let feedbackRequest = 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 {
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;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { checkRequest = null; });
return checkRequest;
},
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;
},
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;
},
};
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.review = reviewEligibility;
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
createPullSheet.sameTarget = sameTarget;
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;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;