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 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';
return '
@' +
escapeHtml(reviewer?.login || 'unknown') + '' + escapeHtml(status) + '
';
}).join('');
}
function renderReviewerPanel(doc, detail, escapeHtml = value => String(value)
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')) {
const reviewers = Array.isArray(detail?.reviewers) ? detail.reviewers : [];
doc.querySelector('#pull-reviewer-statuses').innerHTML = renderReviewerStatuses(reviewers, escapeHtml);
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 '' + escapeHtml(text) + '';
}).join('');
const preview = file.diff_available
? '' + lines +
(file.diff_truncated ? 'Preview truncated · open in Gitea for the full diff.' : '') + ''
: '' +
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
(file.diff_truncated ? ' The response was truncated.' : '') + '
';
return '' +
'' + preview +
'';
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
const filename = controller.nextUnreviewed(detail, state);
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;
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
}
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 = '';
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 = '';
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;
}
function bindReviewRequestControls(doc, controller, getSelected, getDetail) {
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));
};
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;
};
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 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);
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 editRequest = null;
const reviewRequests = new Map();
const reviewCache = 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 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;
},
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;
},
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. */ }
},
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;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;