950 lines
47 KiB
JavaScript
950 lines
47 KiB
JavaScript
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('&', '&').replaceAll('<', '<').replaceAll('>', '>')) {
|
||
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;
|