feat: post reviewed Today summaries to Gitea (Closes #1130)
This commit is contained in:
parent
8794200c2a
commit
a0da8e9fb2
|
|
@ -376,8 +376,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
|
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
|
||||||
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
|
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
|
||||||
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
|
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
|
||||||
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
.today-summary-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
|
||||||
|
.today-summary-destination h3 { margin-top:0; }
|
||||||
|
.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||||
|
.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
|
||||||
|
.today-summary-target { min-height:1.4em; margin-top:8px; }
|
||||||
|
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||||
.today-summary-actions button { min-height:44px; width:100%; }
|
.today-summary-actions button { min-height:44px; width:100%; }
|
||||||
|
@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
|
||||||
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||||
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||||
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
||||||
|
|
|
||||||
|
|
@ -1929,7 +1929,10 @@
|
||||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
|
const todaySummaryView = setupTodaySummary({
|
||||||
|
qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
|
||||||
|
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||||||
|
});
|
||||||
todaySummaryView.resume();
|
todaySummaryView.resume();
|
||||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||||||
|
|
|
||||||
|
|
@ -558,7 +558,18 @@
|
||||||
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
|
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
|
||||||
<h3>Preview</h3>
|
<h3>Preview</h3>
|
||||||
<pre id="today-summary-preview" class="today-summary-preview"></pre>
|
<pre id="today-summary-preview" class="today-summary-preview"></pre>
|
||||||
|
<section class="today-summary-destination" aria-labelledby="today-summary-destination-title">
|
||||||
|
<h3 id="today-summary-destination-title">Post to Gitea</h3>
|
||||||
|
<p class="small muted">Enter the exact issue or pull request that should receive this reviewed comment.</p>
|
||||||
|
<label for="today-summary-destination">Destination</label>
|
||||||
|
<div class="today-summary-destination-controls">
|
||||||
|
<input id="today-summary-destination" type="text" inputmode="text" autocomplete="off" maxlength="260" placeholder="owner/repo#42">
|
||||||
|
<button id="validate-today-summary-destination" type="button">Review destination</button>
|
||||||
|
</div>
|
||||||
|
<div id="today-summary-target" class="today-summary-target small" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
<div class="today-summary-actions">
|
<div class="today-summary-actions">
|
||||||
|
<button id="post-today-summary" type="button" disabled>Post to Gitea</button>
|
||||||
<button id="share-today-summary" type="button">Share summary</button>
|
<button id="share-today-summary" type="button">Share summary</button>
|
||||||
<button id="discard-today-summary" type="button">Discard draft</button>
|
<button id="discard-today-summary" type="button">Discard draft</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
|
function createTodaySummary({
|
||||||
|
storage = null, getLogin = () => '', share = null, copy = null,
|
||||||
|
resolveTarget = null, enqueueDurably = null,
|
||||||
|
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||||
|
} = {}) {
|
||||||
let draft = null;
|
let draft = null;
|
||||||
let draftKey = '';
|
let draftKey = '';
|
||||||
|
let postPromise = null;
|
||||||
|
|
||||||
const storageKey = () => {
|
const storageKey = () => {
|
||||||
const login = String(getLogin() || '').trim().toLowerCase();
|
const login = String(getLogin() || '').trim().toLowerCase();
|
||||||
|
|
@ -20,6 +25,7 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||||
...draft,
|
...draft,
|
||||||
worked:copyRows(draft.worked),
|
worked:copyRows(draft.worked),
|
||||||
tomorrow:copyRows(draft.tomorrow),
|
tomorrow:copyRows(draft.tomorrow),
|
||||||
|
target:draft.target ? { ...draft.target } : null,
|
||||||
} : null;
|
} : null;
|
||||||
};
|
};
|
||||||
const persist = () => {
|
const persist = () => {
|
||||||
|
|
@ -37,8 +43,15 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||||
if (!storage || !key) return null;
|
if (!storage || !key) return null;
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||||
|
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
|
||||||
|
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
|
||||||
|
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
|
||||||
|
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
|
||||||
|
typeof saved.target.state === 'string'));
|
||||||
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
|
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
|
||||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
|
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
|
||||||
|
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
|
||||||
|
!['string', 'undefined'].includes(typeof saved.operation_id)) {
|
||||||
if (saved !== null) storage.removeItem(key);
|
if (saved !== null) storage.removeItem(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +88,9 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||||
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
|
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
|
||||||
include_actuals:false,
|
include_actuals:false,
|
||||||
note:'',
|
note:'',
|
||||||
|
destination:'',
|
||||||
|
target:null,
|
||||||
|
operation_id:'',
|
||||||
};
|
};
|
||||||
persist();
|
persist();
|
||||||
return snapshot();
|
return snapshot();
|
||||||
|
|
@ -107,6 +123,57 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||||
persist();
|
persist();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
setDestination(value) {
|
||||||
|
syncAccount();
|
||||||
|
if (!draft) return null;
|
||||||
|
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
|
||||||
|
draft.destination = String(value || '').trim().slice(0, 260);
|
||||||
|
draft.target = null;
|
||||||
|
draft.operation_id = '';
|
||||||
|
persist();
|
||||||
|
if (!match) return null;
|
||||||
|
const repository = (match[1] + '/' + match[2]).toLowerCase();
|
||||||
|
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
|
||||||
|
},
|
||||||
|
async validateDestination() {
|
||||||
|
syncAccount();
|
||||||
|
const parsed = this.setDestination(draft?.destination || '');
|
||||||
|
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
|
||||||
|
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
|
||||||
|
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
|
||||||
|
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
|
||||||
|
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
|
||||||
|
draft.target = {
|
||||||
|
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
|
||||||
|
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
|
||||||
|
};
|
||||||
|
draft.operation_id = String(createOperationId()).slice(0, 128);
|
||||||
|
persist();
|
||||||
|
return { ...draft.target };
|
||||||
|
},
|
||||||
|
postSummary() {
|
||||||
|
if (postPromise) return postPromise;
|
||||||
|
syncAccount();
|
||||||
|
const body = this.text();
|
||||||
|
if (!body) throw new Error('Select at least one summary item or add a note.');
|
||||||
|
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
|
||||||
|
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
|
||||||
|
const target = { ...draft.target };
|
||||||
|
const message = {
|
||||||
|
kind:'search-reply', repository:target.repository, number:target.number,
|
||||||
|
targetKind:target.kind, body, operationId:draft.operation_id,
|
||||||
|
};
|
||||||
|
postPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const admission = await enqueueDurably(message);
|
||||||
|
discard();
|
||||||
|
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
|
||||||
|
} finally {
|
||||||
|
postPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return postPromise;
|
||||||
|
},
|
||||||
discard,
|
discard,
|
||||||
text() {
|
text() {
|
||||||
syncAccount();
|
syncAccount();
|
||||||
|
|
@ -162,6 +229,10 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||||
qs('#today-summary-include-actuals').checked = draft.include_actuals;
|
qs('#today-summary-include-actuals').checked = draft.include_actuals;
|
||||||
qs('#today-summary-note').value = draft.note;
|
qs('#today-summary-note').value = draft.note;
|
||||||
qs('#today-summary-preview').textContent = summary.text();
|
qs('#today-summary-preview').textContent = summary.text();
|
||||||
|
qs('#today-summary-destination').value = draft.destination || '';
|
||||||
|
qs('#today-summary-target').textContent = draft.target ?
|
||||||
|
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
|
||||||
|
qs('#post-today-summary').disabled = !draft.target;
|
||||||
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
|
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
|
||||||
input.addEventListener('change', () => {
|
input.addEventListener('change', () => {
|
||||||
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
|
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
|
||||||
|
|
@ -202,6 +273,34 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateDestination(button) {
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#today-summary-status').textContent = 'Checking destination…';
|
||||||
|
try {
|
||||||
|
await summary.validateDestination();
|
||||||
|
render();
|
||||||
|
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
|
||||||
|
} catch (error) {
|
||||||
|
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDraft(button) {
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
|
||||||
|
try {
|
||||||
|
const result = await summary.postSummary();
|
||||||
|
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
|
||||||
|
result.target.repository + '#' + result.target.number + '.';
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bind() {
|
function bind() {
|
||||||
qs('#close-today-summary').addEventListener('click', close);
|
qs('#close-today-summary').addEventListener('click', close);
|
||||||
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
|
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
|
||||||
|
|
@ -212,21 +311,41 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||||
summary.setNote(event.currentTarget.value);
|
summary.setNote(event.currentTarget.value);
|
||||||
qs('#today-summary-preview').textContent = summary.text();
|
qs('#today-summary-preview').textContent = summary.text();
|
||||||
});
|
});
|
||||||
|
qs('#today-summary-destination').addEventListener('input', event => {
|
||||||
|
summary.setDestination(event.currentTarget.value);
|
||||||
|
qs('#today-summary-target').textContent = '';
|
||||||
|
qs('#post-today-summary').disabled = true;
|
||||||
|
});
|
||||||
|
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
|
||||||
|
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
|
||||||
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
|
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open, close, render, bind, shareDraft,
|
open, close, render, bind, shareDraft, validateDestination, postDraft,
|
||||||
resume() { if (summary.restore()) show(); },
|
resume() { if (summary.restore()) show(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
|
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
|
||||||
|
const targetResolver = resolveTarget || (async target => {
|
||||||
|
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
|
||||||
|
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
|
||||||
|
'/preview?kind=issue');
|
||||||
|
if (String(item.repository || '').toLowerCase() !== target.repository ||
|
||||||
|
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
|
||||||
|
throw new Error('That exact issue or pull request is not visible to this account.');
|
||||||
|
}
|
||||||
|
return { repository:target.repository, number:target.number, kind:item.kind,
|
||||||
|
title:item.title || '', state:item.state || '' };
|
||||||
|
});
|
||||||
const summary = createTodaySummary({
|
const summary = createTodaySummary({
|
||||||
storage:localStorage,
|
storage:localStorage,
|
||||||
getLogin,
|
getLogin,
|
||||||
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
|
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
|
||||||
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
|
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
|
||||||
|
resolveTarget:targetResolver,
|
||||||
|
enqueueDurably,
|
||||||
});
|
});
|
||||||
const view = createTodaySummaryView({ summary, qs, escapeHtml });
|
const view = createTodaySummaryView({ summary, qs, escapeHtml });
|
||||||
view.bind();
|
view.bind();
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||||
storage:localStorage,getLogin:()=> 'timmy',
|
storage:localStorage,getLogin:()=> 'timmy',
|
||||||
share:payload=>navigator.share(payload),
|
share:payload=>navigator.share(payload),
|
||||||
copy:text=>navigator.clipboard.writeText(text),
|
copy:text=>navigator.clipboard.writeText(text),
|
||||||
|
createOperationId:()=> 'release-summary-op',
|
||||||
|
resolveTarget:async target=>({...target,kind:'issue',title:'Daily status',state:'open'}),
|
||||||
|
enqueueDurably:async message=>{ window.__queuedSummary=message; return {item:message,durability:'background'}; },
|
||||||
});
|
});
|
||||||
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||||
window.__summaryTest={summary,view};
|
window.__summaryTest={summary,view};
|
||||||
|
|
@ -80,6 +83,42 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||||
assert bounds and bounds["height"] >= 44
|
assert bounds and bounds["height"] >= 44
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
|
page.locator("#today-summary-destination").fill("acme/mobile#41")
|
||||||
|
page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
window.__summaryTest.summary.setDestination(document.querySelector('#today-summary-destination').value);
|
||||||
|
return window.__summaryTest.view.validateDestination(document.querySelector('#validate-today-summary-destination'));
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
expect(page.locator("#today-summary-target")).to_contain_text(
|
||||||
|
"acme/mobile#41 · issue · Daily status"
|
||||||
|
)
|
||||||
|
expect(page.locator("#post-today-summary")).to_be_enabled()
|
||||||
|
page.evaluate(
|
||||||
|
"window.__summaryTest.view.postDraft(document.querySelector('#post-today-summary'))"
|
||||||
|
)
|
||||||
|
expect(sheet).to_be_hidden()
|
||||||
|
assert page.evaluate("window.__queuedSummary") == {
|
||||||
|
"kind": "search-reply",
|
||||||
|
"repository": "acme/mobile",
|
||||||
|
"number": 41,
|
||||||
|
"targetKind": "issue",
|
||||||
|
"body": "Today\n- Ship mobile capture\n\nTomorrow\n- Review release",
|
||||||
|
"operationId": "release-summary-op",
|
||||||
|
}
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"""
|
||||||
|
window.__summaryTest.view.open(
|
||||||
|
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
|
||||||
|
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
expect(sheet).to_be_visible()
|
||||||
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,109 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert output["keptAfterCopy"] is False
|
assert output["keptAfterCopy"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_validates_an_exact_gitea_thread_before_durable_posting():
|
||||||
|
script = f"""
|
||||||
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||||
|
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),
|
||||||
|
}};
|
||||||
|
const resolved=[];
|
||||||
|
const admitted=[];
|
||||||
|
const summary=createSummary({{
|
||||||
|
storage,getLogin:()=> 'timmy',createOperationId:()=> 'summary-op-1',
|
||||||
|
resolveTarget:async target=>{{resolved.push(target);return{{...target,kind:'issue',title:'Daily status',state:'open'}};}},
|
||||||
|
enqueueDurably:async message=>{{admitted.push(message);return{{item:message,durability:'background'}};}},
|
||||||
|
}});
|
||||||
|
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
||||||
|
const parsed=summary.setDestination(' StackChain/Status #42 ');
|
||||||
|
const target=await summary.validateDestination();
|
||||||
|
const result=await summary.postSummary();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
parsed,target,result,resolved,admitted,snapshot:summary.snapshot(),
|
||||||
|
kept:values.has('stackchain.today-summary-draft.v1.timmy'),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
|
assert output["parsed"] == {
|
||||||
|
"repository": "stackchain/status", "number": 42, "label": "stackchain/status#42"
|
||||||
|
}
|
||||||
|
assert output["resolved"] == [{"repository": "stackchain/status", "number": 42}]
|
||||||
|
assert output["target"] == {
|
||||||
|
"repository": "stackchain/status", "number": 42, "kind": "issue",
|
||||||
|
"title": "Daily status", "state": "open",
|
||||||
|
}
|
||||||
|
assert output["admitted"] == [{
|
||||||
|
"kind": "search-reply", "repository": "stackchain/status", "number": 42,
|
||||||
|
"targetKind": "issue", "body": "Today\n- Ship mobile capture",
|
||||||
|
"operationId": "summary-op-1",
|
||||||
|
}]
|
||||||
|
assert output["result"]["status"] == "queued"
|
||||||
|
assert output["result"]["durability"] == "background"
|
||||||
|
assert output["snapshot"] is None
|
||||||
|
assert output["kept"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_keeps_reviewed_destination_and_operation_when_admission_fails():
|
||||||
|
script = f"""
|
||||||
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||||
|
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),
|
||||||
|
}};
|
||||||
|
const options={{
|
||||||
|
storage,getLogin:()=> 'timmy',createOperationId:()=> 'stable-op',
|
||||||
|
resolveTarget:async target=>({{...target,kind:'pull',title:'Release candidate',state:'open'}}),
|
||||||
|
enqueueDurably:async()=>{{throw new Error('storage unavailable');}},
|
||||||
|
}};
|
||||||
|
const summary=createSummary(options);
|
||||||
|
summary.begin([{{identity:'pull:acme/mobile:9:',title:'Review release',actual_minutes:18}}], []);
|
||||||
|
summary.setDestination('acme/mobile#9');
|
||||||
|
await summary.validateDestination();
|
||||||
|
let error='';
|
||||||
|
try {{ await summary.postSummary(); }} catch (caught) {{ error=caught.message; }}
|
||||||
|
const restored=createSummary(options);
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
error,restored:restored.restore(),snapshot:restored.snapshot(),text:restored.text(),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
|
assert output["error"] == "storage unavailable"
|
||||||
|
assert output["restored"] is True
|
||||||
|
assert output["snapshot"]["operation_id"] == "stable-op"
|
||||||
|
assert output["snapshot"]["target"]["kind"] == "pull"
|
||||||
|
assert output["text"] == "Today\n- Review release"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_coalesces_concurrent_post_taps_into_one_durable_admission():
|
||||||
|
script = f"""
|
||||||
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||||
|
let release;
|
||||||
|
const gate=new Promise(resolve=>release=resolve);
|
||||||
|
const admitted=[];
|
||||||
|
const summary=createSummary({{
|
||||||
|
getLogin:()=> 'timmy',createOperationId:()=> 'one-operation',
|
||||||
|
resolveTarget:async target=>({{...target,kind:'issue',title:'Status',state:'open'}}),
|
||||||
|
enqueueDurably:async message=>{{admitted.push(message);await gate;return{{item:message,durability:'background'}};}},
|
||||||
|
}});
|
||||||
|
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
||||||
|
summary.setDestination('acme/mobile#41');
|
||||||
|
await summary.validateDestination();
|
||||||
|
const first=summary.postSummary();
|
||||||
|
const second=summary.postSummary();
|
||||||
|
release();
|
||||||
|
const results=await Promise.all([first,second]);
|
||||||
|
process.stdout.write(JSON.stringify({{admitted,results}}));
|
||||||
|
"""
|
||||||
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
|
assert len(output["admitted"]) == 1
|
||||||
|
assert output["results"][0] == output["results"][1]
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
|
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
|
||||||
from src import main
|
from src import main
|
||||||
|
|
||||||
|
|
@ -130,13 +233,21 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
|
||||||
assert 'id="today-summary-tomorrow"' in html
|
assert 'id="today-summary-tomorrow"' in html
|
||||||
assert 'id="today-summary-include-actuals"' in html
|
assert 'id="today-summary-include-actuals"' in html
|
||||||
assert 'id="share-today-summary"' in html
|
assert 'id="share-today-summary"' in html
|
||||||
|
assert 'id="today-summary-destination"' in html
|
||||||
|
assert 'id="validate-today-summary-destination"' in html
|
||||||
|
assert 'id="post-today-summary"' in html
|
||||||
|
assert 'id="today-summary-target"' in html
|
||||||
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
|
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
|
||||||
assert "setupTodaySummary({" in dashboard
|
assert "setupTodaySummary({" in dashboard
|
||||||
|
assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in dashboard
|
||||||
|
assert "fetchJson:api" in dashboard
|
||||||
|
assert "/preview?kind=issue" in (ROOT / "frontend" / "today-summary.js").read_text()
|
||||||
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
|
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
|
||||||
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
|
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
|
||||||
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
|
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
|
||||||
assert ".today-summary-actions button { min-height:44px;" in css
|
assert ".today-summary-actions button { min-height:44px;" in css
|
||||||
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
|
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
|
||||||
|
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
|
||||||
assert "tests/e2e/test_mobile_today_summary_release.py" in (
|
assert "tests/e2e/test_mobile_today_summary_release.py" in (
|
||||||
ROOT / ".gitea" / "workflows" / "ci.yml"
|
ROOT / ".gitea" / "workflows" / "ci.yml"
|
||||||
).read_text()
|
).read_text()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user