157 lines
7.7 KiB
JavaScript
157 lines
7.7 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') {
|
||
return { allowed: false, reason: 'CI must succeed before merging' };
|
||
}
|
||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||
if (reviewState && !reviewState.complete) {
|
||
return { allowed: false, reason: 'Review every changed file before merging' };
|
||
}
|
||
return { allowed: true, reason: 'Ready to merge' };
|
||
}
|
||
|
||
function renderFile(file, index, reviewed, escapeHtml) {
|
||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||
const panelId = 'pull-diff-' + index;
|
||
const lines = (file.diff_lines || []).map(line => {
|
||
const text = String(line);
|
||
const kind = text.startsWith('@@') ? 'hunk' : text.startsWith('+') ? 'added' :
|
||
text.startsWith('-') ? 'removed' : 'context';
|
||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(text) + '</span>';
|
||
}).join('');
|
||
const preview = file.diff_available
|
||
? '<pre class="pull-diff" id="' + panelId + '" hidden>' + lines +
|
||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||
: '<div class="pull-diff-empty" id="' + panelId + '" hidden>' +
|
||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
|
||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||
return '<article class="pull-file" data-pull-filename="' + filename + '">' +
|
||
'<button type="button" class="pull-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||
'<strong>' + filename + '</strong><span class="small">' + escapeHtml(file.status || 'changed') +
|
||
' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span></button>' + preview +
|
||
'<button type="button" class="pull-review-file" data-pull-review-file="' + filename +
|
||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||
}
|
||
|
||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||
let commentRequest = null;
|
||
let mergeRequest = 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 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) {
|
||
const key = item.repository + '#' + item.number + ':' + headSha;
|
||
if (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;
|
||
},
|
||
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.renderFile = renderFile;
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|