feat: track exact post-merge release progress (Closes #1092)
This commit is contained in:
parent
087300f9c3
commit
6db0802461
|
|
@ -14,6 +14,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||||
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||||
.issue-filing-receipt[hidden] { display:none; }
|
.issue-filing-receipt[hidden] { display:none; }
|
||||||
|
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||||
|
.release-receipt-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||||
|
.release-receipt-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||||
|
.release-receipt-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||||
|
.release-receipt-launcher { width:100%; min-height:44px; border-color:#4ade80; }
|
||||||
|
.release-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||||
|
.release-receipt-panel button, .release-receipt-panel .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
|
||||||
|
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
|
||||||
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||||
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
||||||
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,23 @@
|
||||||
let confirmedOwnerLogin = '';
|
let confirmedOwnerLogin = '';
|
||||||
let planningOwnerLogin = '';
|
let planningOwnerLogin = '';
|
||||||
let activeFlushLogin = '';
|
let activeFlushLogin = '';
|
||||||
|
let releaseReceipt = null;
|
||||||
|
function attachReleaseReceipt() {
|
||||||
|
if (releaseReceipt) return releaseReceipt;
|
||||||
|
releaseReceipt = 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'),
|
||||||
|
});
|
||||||
|
releaseReceipt.bind();
|
||||||
|
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
|
||||||
|
qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => {
|
||||||
|
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
|
||||||
|
}));
|
||||||
|
qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss());
|
||||||
|
return releaseReceipt;
|
||||||
|
}
|
||||||
const completedFiledReview = createCompletedFiledReview({
|
const completedFiledReview = createCompletedFiledReview({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin() { return planningOwnerLogin; },
|
getLogin() { return planningOwnerLogin; },
|
||||||
|
|
@ -1136,6 +1153,7 @@
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
attachReleaseReceipt();
|
||||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
if (!wrapPreference) {
|
if (!wrapPreference) {
|
||||||
wrapPreference = createReviewController.createWrapPreference({
|
wrapPreference = createReviewController.createWrapPreference({
|
||||||
|
|
@ -1145,6 +1163,14 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function restoreReleaseReceipt() {
|
||||||
|
const account = String(confirmedOwnerLogin || '').trim().toLowerCase();
|
||||||
|
if (!account) return;
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
|
||||||
|
} catch (_error) { return; }
|
||||||
|
ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {});
|
||||||
|
}
|
||||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||||
|
|
@ -5246,6 +5272,7 @@
|
||||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||||
if (activeFlushLogin) {
|
if (activeFlushLogin) {
|
||||||
confirmedOwnerLogin = activeFlushLogin;
|
confirmedOwnerLogin = activeFlushLogin;
|
||||||
|
restoreReleaseReceipt();
|
||||||
updateDeliveryReceiptControls();
|
updateDeliveryReceiptControls();
|
||||||
interruptionPrompt.restore();
|
interruptionPrompt.restore();
|
||||||
}
|
}
|
||||||
|
|
@ -7019,7 +7046,8 @@
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
||||||
try {
|
try {
|
||||||
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||||||
|
releaseReceipt.capture(merging, mergeResult);
|
||||||
closePullSheet();
|
closePullSheet();
|
||||||
lastMyWork = lastMyWork.filter(item =>
|
lastMyWork = lastMyWork.filter(item =>
|
||||||
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
||||||
|
|
@ -7493,6 +7521,7 @@
|
||||||
if (!saved) return false;
|
if (!saved) return false;
|
||||||
const outage = mode === 'outage';
|
const outage = mode === 'outage';
|
||||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||||
|
restoreReleaseReceipt();
|
||||||
planningOwnerLogin = confirmedOwnerLogin;
|
planningOwnerLogin = confirmedOwnerLogin;
|
||||||
interruptionPrompt.restore();
|
interruptionPrompt.restore();
|
||||||
updatePlanningAvailability();
|
updatePlanningAvailability();
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,7 @@
|
||||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||||
|
<button class="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
|
||||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||||
<div class="small" id="today-session-sync-status" role="status" aria-live="polite" hidden></div>
|
<div class="small" id="today-session-sync-status" role="status" aria-live="polite" hidden></div>
|
||||||
|
|
@ -319,6 +320,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<dialog class="release-receipt-sheet" id="release-receipt-sheet" aria-labelledby="release-receipt-title">
|
||||||
|
<section class="release-receipt-panel">
|
||||||
|
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release progress</h2></div><button id="close-release-receipt" type="button">Close</button></header>
|
||||||
|
<p id="release-receipt-status" role="status" aria-live="polite">Checking the exact merge commit…</p>
|
||||||
|
<p class="small" id="release-receipt-checks"></p>
|
||||||
|
<a class="button-link" id="release-receipt-link" href="" hidden>Open release</a>
|
||||||
|
<div class="release-receipt-actions"><button id="refresh-release-receipt" type="button">Refresh</button><button id="dismiss-release-receipt" type="button">Dismiss</button></div>
|
||||||
|
</section>
|
||||||
|
</dialog>
|
||||||
<dialog class="agenda-export-sheet" id="agenda-export-sheet" aria-labelledby="agenda-export-title">
|
<dialog class="agenda-export-sheet" id="agenda-export-sheet" aria-labelledby="agenda-export-title">
|
||||||
<section class="agenda-export-panel">
|
<section class="agenda-export-panel">
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -1875,6 +1885,7 @@
|
||||||
<script src="static/queue-today.js"></script>
|
<script src="static/queue-today.js"></script>
|
||||||
<script src="static/pull-sheet.js"></script>
|
<script src="static/pull-sheet.js"></script>
|
||||||
<script src="static/review-sheet.js"></script>
|
<script src="static/review-sheet.js"></script>
|
||||||
|
<script src="static/release-receipt.js"></script>
|
||||||
<script src="static/work-route.js"></script>
|
<script src="static/work-route.js"></script>
|
||||||
<script src="static/task-overlay-history.js"></script>
|
<script src="static/task-overlay-history.js"></script>
|
||||||
<script src="static/context-poller.js"></script>
|
<script src="static/context-poller.js"></script>
|
||||||
|
|
|
||||||
95
frontend/release-receipt.js
Normal file
95
frontend/release-receipt.js
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null }) {
|
||||||
|
const prefix = 'stackchain.release-receipt.v1:';
|
||||||
|
let receipt = null;
|
||||||
|
|
||||||
|
const login = () => String(getLogin?.() || '').trim().toLowerCase();
|
||||||
|
const key = () => prefix + login();
|
||||||
|
const path = value => 'api/v1/repos/' + String(value.repository || '').split('/')
|
||||||
|
.map(encodeURIComponent).join('/') + '/release-receipt/' + encodeURIComponent(value.commit_sha);
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
const account = login();
|
||||||
|
if (!account) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage?.getItem(prefix + account) || 'null');
|
||||||
|
receipt = parsed && parsed.account === account && parsed.repository && parsed.commit_sha ? parsed : null;
|
||||||
|
} catch (_error) { receipt = null; }
|
||||||
|
render(receipt?.status || null);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(item, mergeResult) {
|
||||||
|
const account = login();
|
||||||
|
const commitSha = String(mergeResult?.merge_commit_sha || '').trim();
|
||||||
|
if (!account || !item?.repository || !commitSha) throw new Error('The exact merge commit is unavailable.');
|
||||||
|
receipt = {
|
||||||
|
account,
|
||||||
|
repository: item.repository,
|
||||||
|
number: Number(item.number),
|
||||||
|
key: item.key || item.repository + '#' + item.number,
|
||||||
|
commit_sha: commitSha,
|
||||||
|
status: null,
|
||||||
|
};
|
||||||
|
storage?.setItem(key(), JSON.stringify(receipt));
|
||||||
|
render(null);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(payload) {
|
||||||
|
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
|
||||||
|
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
|
||||||
|
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
|
||||||
|
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
|
||||||
|
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
|
||||||
|
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
|
||||||
|
return { ...payload, label: 'Checks running', checks: pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(status) {
|
||||||
|
if (launcher) {
|
||||||
|
launcher.hidden = !receipt;
|
||||||
|
launcher.textContent = status?.label || (receipt ? 'Merged · tracking release' : '');
|
||||||
|
}
|
||||||
|
if (statusNode) statusNode.textContent = status?.label || 'Checking the exact merge commit…';
|
||||||
|
if (checksNode) checksNode.textContent = (status?.checks || []).join(', ');
|
||||||
|
if (releaseNode) {
|
||||||
|
releaseNode.hidden = !status?.release?.url;
|
||||||
|
if (status?.release?.url) {
|
||||||
|
releaseNode.href = status.release.url;
|
||||||
|
releaseNode.textContent = 'Open release ' + status.release.tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!receipt) restore();
|
||||||
|
if (!receipt) return null;
|
||||||
|
const payload = await fetchJson(path(receipt), { headers: { Accept: 'application/json' } });
|
||||||
|
if (payload?.commit_sha !== receipt.commit_sha) throw new Error('Release evidence did not match the merged commit.');
|
||||||
|
const status = summarize(payload);
|
||||||
|
receipt.status = status;
|
||||||
|
storage?.setItem(key(), JSON.stringify(receipt));
|
||||||
|
render(status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
const accountKey = key();
|
||||||
|
storage?.removeItem(accountKey);
|
||||||
|
receipt = null;
|
||||||
|
render(null);
|
||||||
|
if (dialog?.open) dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
launcher?.addEventListener('click', async () => {
|
||||||
|
dialog?.showModal?.();
|
||||||
|
try { await refresh(); }
|
||||||
|
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { capture, restore, refresh, dismiss, bind, fetchJson };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
|
||||||
|
|
@ -105,6 +105,7 @@ const SHELL = [
|
||||||
BASE + 'static/queue-today.js',
|
BASE + 'static/queue-today.js',
|
||||||
BASE + 'static/pull-sheet.js',
|
BASE + 'static/pull-sheet.js',
|
||||||
BASE + 'static/review-sheet.js',
|
BASE + 'static/review-sheet.js',
|
||||||
|
BASE + 'static/release-receipt.js',
|
||||||
BASE + 'static/work-route.js',
|
BASE + 'static/work-route.js',
|
||||||
BASE + 'static/task-overlay-history.js',
|
BASE + 'static/task-overlay-history.js',
|
||||||
BASE + 'static/context-poller.js',
|
BASE + 'static/context-poller.js',
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ FEATURE_SOURCES = {
|
||||||
"issue-capture": (
|
"issue-capture": (
|
||||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||||
),
|
),
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||||
"push-notifications": ("static/push-notifications.js",),
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
"device-setup": (
|
"device-setup": (
|
||||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||||
|
|
|
||||||
|
|
@ -2674,6 +2674,48 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def release_receipt_status(repository: str, commit_sha: str) -> dict:
|
||||||
|
"""Return CI and release evidence for one exact merge commit."""
|
||||||
|
status, releases = await asyncio.gather(
|
||||||
|
fetch(f"repos/{repository}/commits/{commit_sha}/status"),
|
||||||
|
fetch(f"repos/{repository}/releases?limit=20"),
|
||||||
|
)
|
||||||
|
normalized_checks = _normalize_commit_checks(status)
|
||||||
|
checks = [
|
||||||
|
{"name": check["name"], "state": check["state"], "url": check["url"]}
|
||||||
|
for check in normalized_checks
|
||||||
|
]
|
||||||
|
matching = next(
|
||||||
|
(
|
||||||
|
release
|
||||||
|
for release in (releases if isinstance(releases, list) else [])
|
||||||
|
if isinstance(release, dict) and release.get("target_commitish") == commit_sha
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
normalized_release = None
|
||||||
|
if matching is not None:
|
||||||
|
assets = matching.get("assets")
|
||||||
|
normalized_release = {
|
||||||
|
"tag": matching.get("tag_name") if isinstance(matching.get("tag_name"), str) else "",
|
||||||
|
"url": _safe_gitea_web_url(matching.get("html_url")),
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": asset.get("name") if isinstance(asset.get("name"), str) else "",
|
||||||
|
"url": _safe_gitea_web_url(asset.get("browser_download_url")),
|
||||||
|
}
|
||||||
|
for asset in (assets if isinstance(assets, list) else [])[:20]
|
||||||
|
if isinstance(asset, dict)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"commit_sha": commit_sha,
|
||||||
|
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||||
|
"checks": checks,
|
||||||
|
"release": normalized_release,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def pull_check_status(repository: str, number: int) -> dict:
|
async def pull_check_status(repository: str, number: int) -> dict:
|
||||||
"""Load only mutable pull and CI state, without immutable review data."""
|
"""Load only mutable pull and CI state, without immutable review data."""
|
||||||
base = f"repos/{repository}/pulls/{number}"
|
base = f"repos/{repository}/pulls/{number}"
|
||||||
|
|
@ -2741,7 +2783,16 @@ async def merge_assigned_pull(
|
||||||
json={"Do": "merge", "head_commit_id": current_sha},
|
json={"Do": "merge", "head_commit_id": current_sha},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return {"number": number, "merged": True, "state": "closed"}
|
payload = response.json()
|
||||||
|
merge_commit_sha = payload.get("sha") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(merge_commit_sha, str) or not merge_commit_sha:
|
||||||
|
raise ValueError("Gitea merge response did not include the merge commit")
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"merged": True,
|
||||||
|
"state": "closed",
|
||||||
|
"merge_commit_sha": merge_commit_sha,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def pull_review_detail(repository: str, number: int) -> dict:
|
async def pull_review_detail(repository: str, number: int) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -6012,6 +6012,12 @@ async def merge_assigned_pull(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/repos/{owner}/{repo}/release-receipt/{commit_sha}")
|
||||||
|
async def release_receipt(owner: str, repo: str, commit_sha: str):
|
||||||
|
result = await gitea_proxy.release_receipt_status(f"{owner}/{repo}", commit_sha)
|
||||||
|
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
||||||
async def submit_review(
|
async def submit_review(
|
||||||
submission: PullReviewSubmission,
|
submission: PullReviewSubmission,
|
||||||
|
|
|
||||||
|
|
@ -733,3 +733,96 @@ async def test_gitea_pull_release_removes_current_login_case_insensitively():
|
||||||
await gitea_proxy.stop_client()
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
assert released["assignees"] == ["sam"]
|
assert released["assignees"] == ["sam"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_merge_returns_the_exact_merge_commit_for_release_tracking():
|
||||||
|
async def handler(request):
|
||||||
|
if request.url.path.endswith("/pulls/7"):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"number": 7, "state": "open", "draft": False, "mergeable": True,
|
||||||
|
"merged": False, "head": {"sha": "abc123"},
|
||||||
|
})
|
||||||
|
if request.url.path.endswith("/commits/abc123/status"):
|
||||||
|
return httpx.Response(200, json={"state": "success"})
|
||||||
|
if request.url.path.endswith("/pulls/7/merge"):
|
||||||
|
return httpx.Response(200, json={"merged": True, "sha": "merge456"})
|
||||||
|
raise AssertionError(request.url.path)
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"number": 7, "merged": True, "state": "closed", "merge_commit_sha": "merge456"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_release_receipt_matches_only_the_captured_commit_and_reports_checks(monkeypatch):
|
||||||
|
async def receipt(repository, commit_sha):
|
||||||
|
assert (repository, commit_sha) == ("stackchain/api", "merge456")
|
||||||
|
return {
|
||||||
|
"commit_sha": "merge456",
|
||||||
|
"ci_state": "pending",
|
||||||
|
"checks": [{"name": "browser", "state": "pending", "url": ""}],
|
||||||
|
"release": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "release_receipt_status", receipt, raising=False)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/repos/stackchain/api/release-receipt/merge456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json()["checks"] == [
|
||||||
|
{"name": "browser", "state": "pending", "url": ""}
|
||||||
|
]
|
||||||
|
assert response.json()["release"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_release_receipt_ignores_other_commits_and_normalizes_matching_assets():
|
||||||
|
forge = gitea_proxy.GITEA_URL.rstrip("/")
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
if request.url.path.endswith("/commits/merge456/status"):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"state": "success",
|
||||||
|
"statuses": [{"context": "browser", "status": "success", "target_url": ""}],
|
||||||
|
})
|
||||||
|
if request.url.path.endswith("/releases"):
|
||||||
|
return httpx.Response(200, json=[
|
||||||
|
{"tag_name": "newer", "target_commitish": "other789", "html_url": f"{forge}/stackchain/api/releases/tag/newer"},
|
||||||
|
{
|
||||||
|
"tag_name": "rc-42", "target_commitish": "merge456",
|
||||||
|
"html_url": f"{forge}/stackchain/api/releases/tag/rc-42",
|
||||||
|
"assets": [
|
||||||
|
{"name": "manifest.json", "browser_download_url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.json"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
raise AssertionError(request.url.path)
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.release_receipt_status("stackchain/api", "merge456")
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert result["commit_sha"] == "merge456"
|
||||||
|
assert result["ci_state"] == "success"
|
||||||
|
assert result["checks"] == [{"name": "browser", "state": "success", "url": ""}]
|
||||||
|
assert result["release"] == {
|
||||||
|
"tag": "rc-42",
|
||||||
|
"url": f"{forge}/stackchain/api/releases/tag/rc-42",
|
||||||
|
"assets": [{
|
||||||
|
"name": "manifest.json",
|
||||||
|
"url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.json",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
|
||||||
76
tests/test_release_receipt.py
Normal file
76
tests/test_release_receipt.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).parents[1] / "frontend" / "release-receipt.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(body: str) -> dict:
|
||||||
|
script = f"const createReleaseReceipt=require({json.dumps(str(SOURCE))});\n" + body
|
||||||
|
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_survives_reload_for_confirmed_account_only_and_tracks_exact_commit():
|
||||||
|
output = run_node(r"""
|
||||||
|
const values=new Map();
|
||||||
|
const storage={getItem:key=>values.has(key)?values.get(key):null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||||
|
let login='timmy'; const calls=[];
|
||||||
|
const first=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:async path=>{calls.push(path);return {commit_sha:'merge456',ci_state:'pending',checks:[{name:'browser',state:'pending',url:''}],release:null};}});
|
||||||
|
const captured=first.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
||||||
|
const restored=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
|
||||||
|
restored && first.refresh().then(status=>{
|
||||||
|
login='alex';
|
||||||
|
const other=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
|
||||||
|
process.stdout.write(JSON.stringify({captured,restored,status,other,calls,keys:[...values.keys()]}));
|
||||||
|
});
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert output["captured"]["commit_sha"] == "merge456"
|
||||||
|
assert output["restored"]["repository"] == "stackchain/api"
|
||||||
|
assert output["status"]["label"] == "Checks running"
|
||||||
|
assert output["status"]["checks"] == ["browser"]
|
||||||
|
assert output["other"] is None
|
||||||
|
assert output["calls"] == ["api/v1/repos/stackchain/api/release-receipt/merge456"]
|
||||||
|
assert output["keys"] == ["stackchain.release-receipt.v1:timmy"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_distinguishes_failed_checks_waiting_for_release_and_exact_release():
|
||||||
|
output = run_node(r"""
|
||||||
|
const values=new Map();
|
||||||
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||||
|
const responses=[
|
||||||
|
{commit_sha:'merge456',ci_state:'failure',checks:[{name:'unit',state:'success'},{name:'browser',state:'failure'}],release:null},
|
||||||
|
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:null},
|
||||||
|
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:{tag:'rc-42',url:'https://forge.example/git/x/releases/tag/rc-42',assets:[{name:'manifest.json',url:'https://forge.example/manifest'}]}},
|
||||||
|
];
|
||||||
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>responses.shift()});
|
||||||
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
||||||
|
(async()=>{
|
||||||
|
const failed=await receipt.refresh(); const waiting=await receipt.refresh(); const released=await receipt.refresh();
|
||||||
|
receipt.dismiss();
|
||||||
|
process.stdout.write(JSON.stringify({failed,waiting,released,restored:receipt.restore()}));
|
||||||
|
})();
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert output["failed"]["label"] == "Checks failed"
|
||||||
|
assert output["failed"]["checks"] == ["browser"]
|
||||||
|
assert output["waiting"]["label"] == "Checks passed · waiting for release"
|
||||||
|
assert output["released"]["label"] == "Released · rc-42"
|
||||||
|
assert output["released"]["release"]["assets"][0]["name"] == "manifest.json"
|
||||||
|
assert output["restored"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
html = (root / "frontend" / "index.html").read_text()
|
||||||
|
css = (root / "frontend" / "dashboard.css").read_text()
|
||||||
|
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
assert '<script src="static/release-receipt.js"></script>' in html
|
||||||
|
assert 'id="release-receipt-launcher"' in html
|
||||||
|
assert 'id="release-receipt-sheet"' in html
|
||||||
|
assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard
|
||||||
|
assert "min-height:44px" in css[css.index(".release-receipt-sheet"):]
|
||||||
|
assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):]
|
||||||
|
|
@ -1206,6 +1206,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/queue-today.js",
|
"/dashboard/static/queue-today.js",
|
||||||
"/dashboard/static/pull-sheet.js",
|
"/dashboard/static/pull-sheet.js",
|
||||||
"/dashboard/static/review-sheet.js",
|
"/dashboard/static/review-sheet.js",
|
||||||
|
"/dashboard/static/release-receipt.js",
|
||||||
"/dashboard/static/work-route.js",
|
"/dashboard/static/work-route.js",
|
||||||
"/dashboard/static/task-overlay-history.js",
|
"/dashboard/static/task-overlay-history.js",
|
||||||
"/dashboard/static/context-poller.js",
|
"/dashboard/static/context-poller.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user