diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b63994d..40487bb 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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 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-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%; } +@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::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)); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 4fc54a1..9af4374 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1929,7 +1929,10 @@ 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(); const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml, onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => { diff --git a/frontend/index.html b/frontend/index.html index 0c5a3f1..df8e808 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -558,7 +558,18 @@

Preview


+    
+

Post to Gitea

+

Enter the exact issue or pull request that should receive this reviewed comment.

+ +
+ + +
+
+
+
diff --git a/frontend/today-summary.js b/frontend/today-summary.js index cae20f7..71396cf 100644 --- a/frontend/today-summary.js +++ b/frontend/today-summary.js @@ -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 draftKey = ''; + let postPromise = null; const storageKey = () => { const login = String(getLogin() || '').trim().toLowerCase(); @@ -20,6 +25,7 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null, ...draft, worked:copyRows(draft.worked), tomorrow:copyRows(draft.tomorrow), + target:draft.target ? { ...draft.target } : null, } : null; }; const persist = () => { @@ -37,8 +43,15 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null, if (!storage || !key) return null; try { 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) || - 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); 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)), include_actuals:false, note:'', + destination:'', + target:null, + operation_id:'', }; persist(); return snapshot(); @@ -107,6 +123,57 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null, persist(); 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, text() { syncAccount(); @@ -162,6 +229,10 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) { qs('#today-summary-include-actuals').checked = draft.include_actuals; qs('#today-summary-note').value = draft.note; 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 => { input.addEventListener('change', () => { 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() { qs('#close-today-summary').addEventListener('click', close); qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); }); @@ -212,21 +311,41 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) { summary.setNote(event.currentTarget.value); 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)); } return { - open, close, render, bind, shareDraft, + open, close, render, bind, shareDraft, validateDestination, postDraft, 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({ storage:localStorage, getLogin, share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null, copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null, + resolveTarget:targetResolver, + enqueueDurably, }); const view = createTodaySummaryView({ summary, qs, escapeHtml }); view.bind(); diff --git a/tests/e2e/test_mobile_today_summary_release.py b/tests/e2e/test_mobile_today_summary_release.py index cbc9556..c432e18 100644 --- a/tests/e2e/test_mobile_today_summary_release.py +++ b/tests/e2e/test_mobile_today_summary_release.py @@ -58,6 +58,9 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary( storage:localStorage,getLogin:()=> 'timmy', share:payload=>navigator.share(payload), 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)}); 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 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( """ () => { diff --git a/tests/test_today_summary.py b/tests/test_today_summary.py index b733768..f0106ea 100644 --- a/tests/test_today_summary.py +++ b/tests/test_today_summary.py @@ -116,6 +116,109 @@ process.stdout.write(JSON.stringify({{ 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(): 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-include-actuals"' 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 "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 "openWrapUp(handoff.actual_minutes, workedItems)" in recap assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up 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-destination" in css and "overflow-wrap:anywhere" in css assert "tests/e2e/test_mobile_today_summary_release.py" in ( ROOT / ".gitea" / "workflows" / "ci.yml" ).read_text()