feat: prepare conflict-safe release rollback pulls (Closes #1370)
This commit is contained in:
1 parent
400534f446
commit
b753cdd922
25 files changed
+754
-43
No files matched your search
+12
-11
@@ -387,23 +387,24 @@
|
||||
let confirmedOwnerLogin = '';
|
||||
let planningOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
let releaseReceipt = null;
|
||||
function attachReleaseReceipt() {
|
||||
if (releaseReceipt) return releaseReceipt;
|
||||
releaseReceipt = createReleaseReceipt({
|
||||
let rR = null;
|
||||
function rRC() {
|
||||
if (rR) return rR;
|
||||
rR = createReleaseReceipt({
|
||||
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
|
||||
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
|
||||
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
|
||||
releaseNode:qs('#release-receipt-link'),
|
||||
listNode:qs('#release-watchlist'),
|
||||
openPull:openPullSheet,
|
||||
});
|
||||
releaseReceipt.bind();
|
||||
rR.bind();
|
||||
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
|
||||
qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => {
|
||||
qs('#refresh-release-receipt').addEventListener('click', () => rR.refresh().catch(error => {
|
||||
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
|
||||
}));
|
||||
qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss());
|
||||
return releaseReceipt;
|
||||
qs('#dismiss-release-receipt').addEventListener('click', () => rR.dismiss());
|
||||
return rR;
|
||||
}
|
||||
const completedFiledReview = createCompletedFiledReview({
|
||||
storage: localStorage,
|
||||
@@ -1290,7 +1291,7 @@
|
||||
return false;
|
||||
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
|
||||
}
|
||||
attachReleaseReceipt();
|
||||
rRC();
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
@@ -1306,7 +1307,7 @@
|
||||
try {
|
||||
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
|
||||
} catch (_error) { return; }
|
||||
ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {});
|
||||
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
|
||||
}
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
@@ -7368,7 +7369,7 @@
|
||||
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
||||
try {
|
||||
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||||
releaseReceipt.capture(merging, mergeResult);
|
||||
rR.capture(merging, mergeResult);
|
||||
closePullSheet();
|
||||
lastMyWork = lastMyWork.filter(item =>
|
||||
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, openPull = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
const prefix = 'stackchain.release-receipt.v1:';
|
||||
const limit = 12;
|
||||
let entries = [];
|
||||
let refreshing = null;
|
||||
const recoveries = new Map();
|
||||
const retrying = new Map();
|
||||
const rollingBack = new Map();
|
||||
let timer = null;
|
||||
let bound = false;
|
||||
documentRef ||= typeof document !== 'undefined' ? document : null;
|
||||
@@ -149,6 +150,12 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
: 'Branch ' + entry.source_branch + ' · ' + entry.source_head_sha.slice(0, 8);
|
||||
copy.append(branch);
|
||||
}
|
||||
if (entry.rollback?.state === 'prepared') {
|
||||
const rollbackStatus = document.createElement('span');
|
||||
rollbackStatus.className = 'small release-rollback-status';
|
||||
rollbackStatus.textContent = entry.rollback.message;
|
||||
copy.append(rollbackStatus);
|
||||
}
|
||||
if (entry.status?.release?.url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = entry.status.release.url;
|
||||
@@ -202,6 +209,21 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
render();
|
||||
}));
|
||||
actions.append(retry);
|
||||
const rollback = document.createElement('button');
|
||||
rollback.type = 'button';
|
||||
rollback.textContent = entry.rollback?.state === 'prepared'
|
||||
? 'Open rollback #' + entry.rollback.number
|
||||
: (rollingBack.has(identity(entry)) ? 'Preparing rollback…' : 'Prepare rollback PR');
|
||||
rollback.disabled = rollingBack.has(identity(entry));
|
||||
rollback.setAttribute(
|
||||
'aria-label',
|
||||
'Prepare rollback pull request for ' + entry.key + ' at ' + entry.commit_sha.slice(0, 8),
|
||||
);
|
||||
rollback.addEventListener('click', () => prepareRollback(entry.repository, entry.commit_sha).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
|
||||
render();
|
||||
}));
|
||||
actions.append(rollback);
|
||||
recovery.append(sha, excerpt, actions);
|
||||
summary.append(recovery);
|
||||
}
|
||||
@@ -318,6 +340,57 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
return operation;
|
||||
}
|
||||
|
||||
function prepareRollback(repository, commitSha) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
|
||||
if (entry.rollback?.state === 'prepared') {
|
||||
openPull?.(entry.rollback.pull);
|
||||
return Promise.resolve(entry.rollback.pull);
|
||||
}
|
||||
const key = identity(entry);
|
||||
if (rollingBack.has(key)) return rollingBack.get(key);
|
||||
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
|
||||
if (!approve('Prepare a draft rollback PR for ' + entry.key + ' at ' + commitSha.slice(0, 8) + '?')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const operation = fetchJson(
|
||||
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
|
||||
+ '/pulls/' + encodeURIComponent(entry.number)
|
||||
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha) + '/rollback',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': 'release-rollback-' + entry.number + '-' + entry.commit_sha,
|
||||
},
|
||||
},
|
||||
).then(result => {
|
||||
if (result?.rollback_of !== entry.commit_sha || !Number.isInteger(result?.number)) {
|
||||
throw new Error('Rollback preparation did not match the failed release.');
|
||||
}
|
||||
const pull = {
|
||||
...result,
|
||||
kind: 'pull',
|
||||
key: result.repository + '#' + result.number,
|
||||
state: 'open',
|
||||
};
|
||||
entry.rollback = {
|
||||
state: 'prepared',
|
||||
number: result.number,
|
||||
message: 'Draft rollback #' + result.number + ' is ready for review.',
|
||||
pull,
|
||||
};
|
||||
persist();
|
||||
render();
|
||||
if (dialog?.open) dialog.close();
|
||||
openPull?.(pull);
|
||||
return result;
|
||||
}).finally(() => rollingBack.delete(key));
|
||||
rollingBack.set(key, operation);
|
||||
render();
|
||||
return operation;
|
||||
}
|
||||
|
||||
async function deleteBranch(repository, commitSha) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry?.source_branch || !entry?.source_head_sha || entry.cleanup?.state === 'deleted') {
|
||||
@@ -385,7 +458,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
schedule();
|
||||
}
|
||||
|
||||
return { capture, restore, refresh, reviewFailure, retryFailure, deleteBranch, dismiss, bind, fetchJson };
|
||||
return { capture, restore, refresh, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
|
||||
@@ -232,6 +232,7 @@
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
source_branch_deleted: 'Source branch deleted',
|
||||
release_rollback_prepared: 'Release rollback prepared',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v138';
|
||||
const CACHE = 'stackchain-dashboard-shell-v139';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
||||
Reference in new issue
Block a user