Post reviewed Today summaries to a Gitea status thread #1131

Merged
rockachopa merged 1 commits from timmy/1130-post-today-summary into main 2026-08-19 12:45:00 +00:00
6 changed files with 295 additions and 6 deletions

View File

@ -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)); }

View File

@ -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) => {

View File

@ -558,7 +558,18 @@
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
<h3>Preview</h3>
<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">
<button id="post-today-summary" type="button" disabled>Post to Gitea</button>
<button id="share-today-summary" type="button">Share summary</button>
<button id="discard-today-summary" type="button">Discard draft</button>
</div>

View File

@ -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();

View File

@ -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(
"""
() => {

View File

@ -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()