diff --git a/bin/explainmyrepo.mjs b/bin/explainmyrepo.mjs index fd319f3..61928e1 100755 --- a/bin/explainmyrepo.mjs +++ b/bin/explainmyrepo.mjs @@ -77,6 +77,7 @@ OUTREACH (on by default — the readme-enhance station) Turn it off: --no-readme-pr (or README_ENHANCE=0) Send PRs directly: README_OUTREACH=pr Your own voice: README_PR_INTRO="…{repo}…" README_PR_SIGNOFF="Your Name" Rehearse: README_OUTREACH_DRYRUN=1 Track replies: npx explainmyrepo outreach status + Check in once: npx explainmyrepo outreach followup (one note, a week after the last message, only if unanswered) After a yes: npx explainmyrepo outreach open-pr owner/name WHICH DOOR IS FOR YOU @@ -133,16 +134,17 @@ async function main() { // Outreach tracking (ADR-0015): `outreach status` polls every message we've sent; // `outreach open-pr ` is the second step after a maintainer says yes. if (positional[0] === 'outreach') { - const { statusCommand, openPrCommand } = await import('../src/outreach-cli.mjs'); + const { statusCommand, openPrCommand, followupCommand } = await import('../src/outreach-cli.mjs'); const sub = positional[1] || 'status'; if (sub === 'status') { await statusCommand({ env: process.env }); return; } + if (sub === 'followup') { followupCommand({ env: { ...process.env, ...loadEnv(path.join(HERE, '..')) }, dryRun: process.argv.includes('--dry-run') }); return; } if (sub === 'open-pr') { if (!positional[2] || !/^[^/\s]+\/[^/\s]+$/.test(positional[2])) { process.stderr.write('usage: explainmyrepo outreach open-pr \n'); process.exit(2); } process.exit(openPrCommand(positional[2], { env: { ...process.env, ...loadEnv(path.join(HERE, '..')) }, repoRoot: path.join(HERE, '..') })); } - process.stderr.write(`unknown outreach command: ${sub}\nusage: explainmyrepo outreach [status | open-pr ]\n`); + process.stderr.write(`unknown outreach command: ${sub}\nusage: explainmyrepo outreach [status | followup | open-pr ]\n`); process.exit(2); } diff --git a/src/orchestrator.mjs b/src/orchestrator.mjs index 64de039..4d5a7cc 100644 --- a/src/orchestrator.mjs +++ b/src/orchestrator.mjs @@ -132,6 +132,7 @@ function stations(opts) { tool('make-diagrams', 'make-diagrams'), tool('assemble-page', 'assemble-page'), tool('make-pack', 'make-pack'), + tool('make-overview', 'make-overview', { fatal: false }), // Station 6b — NotebookLM video + infographic, local door only, non-blocking tool('quality-grade', 'quality-grade'), tool('deploy', 'deploy'), tool('publish-repo', 'publish-repo'), diff --git a/src/outreach-cli.mjs b/src/outreach-cli.mjs index 7588e34..377c564 100644 --- a/src/outreach-cli.mjs +++ b/src/outreach-cli.mjs @@ -1,4 +1,4 @@ -// src/outreach-cli.mjs — `npx explainmyrepo outreach [status | open-pr ]` (ADR-0015). +// src/outreach-cli.mjs — `npx explainmyrepo outreach [status | followup | open-pr ]` (ADR-0015). // // status poll GitHub for every message we've sent and say who responded — the owner's "make sure // you always track these and see if people respond and react to them". @@ -10,6 +10,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync, spawnSync } from 'node:child_process'; import { ledgerPath, readLedger, appendLedger, classifyResponse, summarize } from './outreach.mjs'; +import { followupsDue, renderFollowup, DEFAULT_FOLLOWUP_DAYS } from './outreach-followup.mjs'; const POSITIVE = new Set(['THUMBS_UP', 'HEART', 'HOORAY', 'ROCKET', 'EYES']); const NEGATIVE = new Set(['THUMBS_DOWN', 'CONFUSED']); @@ -84,3 +85,37 @@ export function openPrCommand(repoFull, { env = process.env, repoRoot, write = ( }); return r.status || 0; } + +/** + * `outreach followup` — ONE short check-in per issue, `OUTREACH_FOLLOWUP_DAYS` (default 7) after our last + * message, only while the maintainer has not replied and the issue is still open. Records a 'followup' line + * either way, so it can never post twice. `dryRun` writes nothing and posts nothing. + */ +export function followupCommand({ env = process.env, view = ghView, post, dryRun = false, now = Date.now(), write = (s) => process.stdout.write(s) } = {}) { + const file = ledgerPath(env); + const entries = readLedger(file); + const minDays = Number(env.OUTREACH_FOLLOWUP_DAYS || DEFAULT_FOLLOWUP_DAYS); + const contact = env.README_PR_CONTACT ?? env.OUTREACH_CONTACT; + const poster = post || ((e, body) => execFileSync('gh', ['issue', 'comment', e.url, '--body', body], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); + const result = { due: 0, posted: [], quiet: [], errors: [] }; + const due = followupsDue(entries, now, minDays); + result.due = due.length; + for (const e of due) { + try { + const snap = snapshotFromGh(view({ ...e, kind: 'issue' }), String(e.repo).split('/')[0]); + const c = classifyResponse(snap, now); + if (c.label !== 'waiting') { + if (!dryRun) appendLedger(file, { type: 'followup', ts: new Date(now).toISOString(), url: e.url, repo: e.repo, action: 'none', reason: c.label }); + result.quiet.push({ url: e.url, why: c.label }); + continue; + } + const body = renderFollowup({ repoName: String(e.repo).split('/')[1], liveUrl: e.liveUrl, contact }); + if (dryRun) { result.posted.push({ url: e.url, dryRun: true, body }); continue; } + poster(e, body); + appendLedger(file, { type: 'followup', ts: new Date(now).toISOString(), url: e.url, repo: e.repo, action: 'commented' }); + result.posted.push({ url: e.url }); + } catch (err) { result.errors.push(`${e.url}: ${String(err.stderr || err.message).slice(0, 100)}`); } + } + write(`follow-ups: ${result.due} due, ${result.posted.length} posted, ${result.quiet.length} quiet (replied/closed), ${result.errors.length} errors\n`); + return result; +} diff --git a/src/outreach-followup.mjs b/src/outreach-followup.mjs new file mode 100644 index 0000000..0b9635d --- /dev/null +++ b/src/outreach-followup.mjs @@ -0,0 +1,46 @@ +// src/outreach-followup.mjs — one gentle check-in per issue (pure helpers; the CLI does the I/O). +// +// Owner's rule (2026-10-14): post the work, track it, and if nobody has answered after a week send ONE short +// note saying we are only trying to help and how to reach us. Never more than one. Silent if they replied +// or closed the issue. Reads the same ledger the readme-enhance station writes (src/outreach.mjs). + +export const DEFAULT_FOLLOWUP_DAYS = 7; + +/** Latest thing WE said on each issue: the original 'sent' record or a later 'update' comment. */ +export function lastContact(entries) { + const m = new Map(); + for (const e of entries) { + if ((e.type !== 'sent' && e.type !== 'update') || !e.url) continue; + const prev = m.get(e.url); + const ts = Date.parse(e.ts); + if (!prev) m.set(e.url, { url: e.url, repo: e.repo, kind: e.kind, number: e.number, liveUrl: e.liveUrl, lastTs: ts }); + else { + if (e.type === 'sent') Object.assign(prev, { repo: e.repo || prev.repo, kind: e.kind || prev.kind, number: e.number || prev.number, liveUrl: e.liveUrl || prev.liveUrl }); + if (ts > prev.lastTs) prev.lastTs = ts; + } + } + return [...m.values()]; +} + +/** Issues (never PRs) we spoke on at least `minDays` ago, with no follow-up yet and not withdrawn. */ +export function followupsDue(entries, now = Date.now(), minDays = DEFAULT_FOLLOWUP_DAYS) { + const done = new Set(entries.filter((e) => e.type === 'followup' && e.url).map((e) => e.url)); + const out = []; + for (const c of lastContact(entries)) { + if (c.kind && c.kind !== 'issue') continue; + if (!c.number || done.has(c.url)) continue; + const ageDays = (now - c.lastTs) / 86_400_000; + if (ageDays >= minDays) out.push({ ...c, ageDays: Math.floor(ageDays) }); + } + return out; +} + +/** The note itself. Plain facts and a way to reach a person; no claims about ourselves. */ +export function renderFollowup({ repoName, liveUrl, contact }) { + return [ + `Hi again. I'm only trying to be helpful with the ${repoName} explainer${liveUrl ? ` (${liveUrl})` : ''}. Did you get a chance to see it, and was it any use?`, + '', + 'A one-word answer is plenty, and no answer is fine too. I will not post again.', + ...(contact && String(contact).trim() ? ['', `Questions are welcome: ${String(contact).trim()}`] : []), + ].join('\n'); +} diff --git a/src/outreach-mail.mjs b/src/outreach-mail.mjs new file mode 100644 index 0000000..485f630 --- /dev/null +++ b/src/outreach-mail.mjs @@ -0,0 +1,32 @@ +// src/outreach-mail.mjs — the maintainer email DRAFT and who it would go to (pure helpers). +// +// The maintainer is never mailed automatically. The draft is mailed to the OWNER as a review copy +// (src/smtp.mjs) so they can forward it if they choose. Copy never vouches for itself. + +export function pickMaintainerEmail(commits) { + const bad = /noreply|no-reply|users\.noreply\.github\.com|\[bot\]|bot@|dependabot|renovate|github-actions/i; + const counts = new Map(); + for (const c of Array.isArray(commits) ? commits : []) { + const email = (c?.commit?.author?.email || c?.author?.email || '').trim().toLowerCase(); + if (!email || bad.test(email) || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) continue; + counts.set(email, (counts.get(email) || 0) + 1); + } + let best = null; + for (const [email, n] of counts) if (!best || n > best.n) best = { email, n }; + return best ? best.email : null; +} + +export function renderEmail({ name, to, liveUrl, issueUrl, fromName, contact }) { + const body = [ + 'Hi,', + '', + `I built an independent visual explainer for ${name}: ${liveUrl}`, + issueUrl ? `I also left a short note on the project's issue tracker so it is not only in your inbox: ${issueUrl}` : '', + '', + 'It is a static page and the source is public. Nothing is asked of you, and you are free to ignore this.', + '', + `— ${fromName}`, + contact && String(contact).trim() ? String(contact).trim() : '', + ].filter((l, i, a) => !(l === '' && a[i - 1] === '')).join('\n'); + return { to, subject: `A visual explainer of ${name}`, body }; +} diff --git a/src/readme-ascii.mjs b/src/readme-ascii.mjs index bca4edb..94145a1 100644 --- a/src/readme-ascii.mjs +++ b/src/readme-ascii.mjs @@ -263,7 +263,7 @@ function diagramAlt(body, n) { * @param {{svgPath:string,alt:string,rel:string}[]} [o.fallback] generated diagrams used ONLY when no ASCII diagram exists * @returns {{ markdown:string, diagrams:{file:string,svg:string,alt:string}[], mode:'ascii'|'fallback'|'link-only' }} */ -export function rewriteReadme(md, { liveUrl, repoName, svgDir, fallback = [] }) { +export function rewriteReadme(md, { liveUrl, repoName, svgDir, fallback = [], hero = null }) { const clean = stripEnhancements(md); const base = clean.trim() === '' ? `# ${repoName}\n` : clean; const lines = base.split('\n'); @@ -294,6 +294,7 @@ export function rewriteReadme(md, { liveUrl, repoName, svgDir, fallback = [] }) `[![Explainer — live](https://img.shields.io/badge/Explainer-live-7c3aed?style=flat-square)](${liveUrl})`, '', `A visual, newcomer-friendly explainer for **${repoName}**: ${liveUrl}`, + ...(hero ? ['', `[![${String(hero.alt || `How ${repoName} works`).replace(/[\[\]\r\n]+/g, ' ')}](${hero.rel})](${liveUrl})`] : []), LINK_END, '', ]; diff --git a/src/readme-pr.mjs b/src/readme-pr.mjs index b90e34b..dec3f9f 100644 --- a/src/readme-pr.mjs +++ b/src/readme-pr.mjs @@ -42,6 +42,7 @@ const MODE_LINE = { * @param {string[]} o.files other files in the PR (repo-relative) * @param {string} [o.intro] replaces the two opening paragraphs; {repo} is substituted * @param {string} [o.signoff] name to sign with + * @param {string} [o.contact] one plain line (name · phone · email) so a person can be reached; omitted when empty * @param {'pr'|'issue'} [o.kind] 'issue' = a proposal with the work already visible and a PR on request */ export function buildPrBody(o) { @@ -90,6 +91,7 @@ export function buildPrBody(o) { out.push('Thanks again for building this, and for making it open.'); out.push(''); if (o.signoff && o.signoff.trim()) out.push(o.signoff.trim(), ''); + if (o.contact && String(o.contact).trim()) out.push(`Questions are welcome: ${String(o.contact).trim()}`, ''); out.push(`Made with [explainmyrepo](${EXPLAINER_REPO})`); return `${out.join('\n')}\n`; } diff --git a/src/smtp.mjs b/src/smtp.mjs new file mode 100644 index 0000000..bc81442 --- /dev/null +++ b/src/smtp.mjs @@ -0,0 +1,55 @@ +// smtp.mjs — minimal SMTP-over-implicit-TLS sender (same dialogue as tools/notify.mjs, no npm deps). +// Used by tools/outreach.mjs to mail the OWNER a review copy of the maintainer email draft. +import tls from 'node:tls'; + +const CRLF = '\r\n'; +const b64 = (s) => Buffer.from(s, 'utf8').toString('base64'); +const wrap = (s) => s.replace(/(.{76})/g, '$1' + CRLF); + +export function buildMessage({ from, fromName, to, subject, text }) { + const safe = (v) => String(v).replace(/[\r\n]+/g, ' '); // header-injection guard + return [ + `From: =?UTF-8?B?${b64(safe(fromName || from))}?= <${safe(from)}>`, + `To: <${safe(to)}>`, + `Subject: =?UTF-8?B?${b64(safe(subject))}?=`, + `Date: ${new Date().toUTCString()}`, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: base64', + '', + wrap(b64(text)), + ].join(CRLF); +} + +export function sendMail({ host = 'smtp.gmail.com', port = 465, user, pass, from, fromName, to, subject, text }) { + const message = buildMessage({ from: from || user, fromName, to, subject, text }).replace(/\r\n\./g, '\r\n..'); + return new Promise((resolve, reject) => { + const socket = tls.connect({ host, port, servername: host }); + socket.setEncoding('utf8'); + socket.setTimeout(60000, () => { reject(new Error(`SMTP connection to ${host}:${port} timed out.`)); socket.destroy(); }); + const steps = [ + { expect: '220', send: null }, + { expect: '250', send: 'EHLO explainmyrepo' }, + { expect: '334', send: 'AUTH LOGIN' }, + { expect: '334', send: b64(user) }, + { expect: '235', send: b64(pass) }, + { expect: '250', send: `MAIL FROM:<${user}>` }, + { expect: '250', send: `RCPT TO:<${to}>` }, + { expect: '354', send: 'DATA' }, + { expect: '250', send: `${message}${CRLF}.` }, + { expect: '221', send: 'QUIT' }, + ]; + let i = 0, buf = ''; + socket.on('data', (chunk) => { + buf += chunk; + const lines = buf.split(CRLF).filter(Boolean), last = lines[lines.length - 1]; + if (!last || !/^\d{3} /.test(last)) return; + buf = ''; + if (last.slice(0, 3) !== steps[i].expect) { reject(new Error(`SMTP expected ${steps[i].expect} but got "${last}" (step ${i})`)); socket.end(); return; } + if (++i >= steps.length) { resolve(); socket.end(); return; } + if (steps[i].send !== null) socket.write(steps[i].send + CRLF); + }); + socket.on('error', reject); + socket.on('end', () => { if (i < steps.length) reject(new Error('SMTP connection closed before completion.')); }); + }); +} diff --git a/tests/outreach.test.mjs b/tests/outreach.test.mjs index 9a800ef..40a0672 100644 --- a/tests/outreach.test.mjs +++ b/tests/outreach.test.mjs @@ -92,3 +92,78 @@ test('summarize: a response rate that counts only real responses', () => { test('summarize: an empty ledger is 0%, never NaN', () => { assert.equal(summarize([]).responseRate, 0); }); + +// ── follow-up, contact line, review mail (owner's rules 2026-10-14) ────────────────────────────────────── +import { followupsDue as fuDue, lastContact, renderFollowup as fuRender } from '../src/outreach-followup.mjs'; +import { pickMaintainerEmail as pickMail, renderEmail as mailRender } from '../src/outreach-mail.mjs'; +import { followupCommand } from '../src/outreach-cli.mjs'; +import { buildPrBody } from '../src/readme-pr.mjs'; +import { test as t2 } from 'node:test'; +import a2 from 'node:assert/strict'; +import fs2 from 'node:fs'; +import os2 from 'node:os'; +import path2 from 'node:path'; + +const DAY = 86_400_000; +const mkSent = (repo, n, daysAgo, now, extra = {}) => ({ type: 'sent', ts: new Date(now - daysAgo * DAY).toISOString(), repo, kind: 'issue', url: `https://github.com/${repo}/issues/${n}`, number: n, liveUrl: 'https://x.netlify.app', ...extra }); + +t2('followupsDue: waits a week from the LAST message, once, issues only', () => { + const now = Date.parse('2026-10-30T00:00:00Z'); + const rows = [ + mkSent('a/young', 1, 3, now), mkSent('a/old', 2, 8, now), mkSent('a/pr', 3, 9, now, { kind: 'pr' }), + mkSent('a/updated', 4, 10, now), + { type: 'update', ts: new Date(now - 2 * DAY).toISOString(), url: 'https://github.com/a/updated/issues/4', repo: 'a/updated' }, + mkSent('a/done', 5, 20, now), { type: 'followup', url: 'https://github.com/a/done/issues/5' }, + ]; + a2.deepEqual(fuDue(rows, now).map((d) => d.repo), ['a/old']); + a2.equal(lastContact(rows).find((c) => c.repo === 'a/updated').lastTs, now - 2 * DAY); +}); + +t2('followupCommand: posts one note when unanswered, stays quiet when answered, never twice', () => { + const now = Date.parse('2026-10-30T00:00:00Z'); + const dir = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'fu-')); + const file = path2.join(dir, 'outreach.jsonl'); + fs2.writeFileSync(file, [mkSent('o/quiet', 7, 8, now), mkSent('o/talked', 8, 8, now)].map((r) => JSON.stringify(r)).join('\n') + '\n'); + const posted = []; + const view = (e) => (e.repo === 'o/talked' + ? { state: 'OPEN', createdAt: new Date(now - 8 * DAY).toISOString(), comments: [{ author: { login: 'o' } }], reactionGroups: [] } + : { state: 'OPEN', createdAt: new Date(now - 8 * DAY).toISOString(), comments: [], reactionGroups: [] }); + const env = { EMR_OUTREACH_LEDGER: file, OUTREACH_CONTACT: 'Jo · +1 555 0100 · jo@example.com' }; + const r1 = followupCommand({ env, view, post: (e, b) => posted.push({ url: e.url, b }), now, write: () => {} }); + a2.equal(r1.posted.length, 1); + a2.equal(r1.quiet.length, 1); + a2.ok(posted[0].b.includes('jo@example.com') && posted[0].b.includes('only trying to be helpful')); + const r2 = followupCommand({ env, view, post: (e, b) => posted.push({ url: e.url, b }), now, write: () => {} }); + a2.equal(r2.due, 0); + a2.equal(posted.length, 1, 'never posts twice'); +}); + +t2('copy: contact line appears, and no message vouches for itself', () => { + const contact = 'Jo · +1 555 0100 · jo@example.com'; + const issue = buildPrBody({ repoName: 'x', liveUrl: 'https://l', readmeUrl: 'https://r', mode: 'link-only', addedLines: 3, files: [], kind: 'issue', signoff: 'Jo', contact }); + const fu = fuRender({ repoName: 'x', liveUrl: 'https://l', contact }); + const mail = mailRender({ name: 'x', to: 'm@e.co', liveUrl: 'https://l', fromName: 'Jo', contact }).body; + for (const t of [issue, fu, mail]) { + a2.ok(t.includes('jo@example.com')); + for (const w of ['safe', 'clean', 'secure', 'trusted', 'virus', 'malware-free', "don't worry", 'no risk']) a2.ok(!t.toLowerCase().includes(w), `banned: ${w}`); + } + a2.ok(!buildPrBody({ repoName: 'x', liveUrl: 'https://l', readmeUrl: 'https://r', mode: 'link-only', addedLines: 3, files: [], kind: 'issue' }).includes('Questions are welcome'), 'no contact, no line'); +}); + +t2('pickMaintainerEmail skips noreply and bots', () => { + const c = (e) => ({ commit: { author: { email: e } } }); + a2.equal(pickMail([c('1+x@users.noreply.github.com'), c('dev@x.io'), c('dev@x.io'), c('bot@y.io')]), 'dev@x.io'); + a2.equal(pickMail([c('noreply@github.com')]), null); +}); + +import { rewriteReadme as rw2 } from '../src/readme-ascii.mjs'; +t2('rewriteReadme: hero image sits under the explainer link and strips cleanly on a re-run', () => { + const md = '# Proj\n\nIntro text.\n'; + const a = rw2(md, { liveUrl: 'https://l.app', repoName: 'Proj', svgDir: 'docs/explainer', hero: { rel: 'docs/explainer/overview.jpg', alt: 'How Proj works' } }); + a2.ok(a.markdown.includes('[![How Proj works](docs/explainer/overview.jpg)](https://l.app)')); + a2.ok(a.markdown.indexOf('overview.jpg') < a.markdown.indexOf('Intro text.')); + const again = rw2(a.markdown, { liveUrl: 'https://l.app', repoName: 'Proj', svgDir: 'docs/explainer', hero: { rel: 'docs/explainer/overview.jpg', alt: 'How Proj works' } }); + a2.equal((again.markdown.match(/overview\.jpg/g) || []).length, 1, 'one hero, not two'); + const none = rw2(md, { liveUrl: 'https://l.app', repoName: 'Proj', svgDir: 'docs/explainer' }); + a2.ok(!none.markdown.includes('overview.jpg')); +}); diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index e0d07c2..1f926ce 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -284,6 +284,8 @@ and the slot + files it **writes**. | 11 | **publish-repo** | Create the dedicated **explainer GitHub repo** (public) and invite the owner as a collaborator (best-effort). | `repo.owner` / `repo.name` / `repo.slug`, `page.dir` (+ GitHub token from env) | `publish.explainerRepoUrl` / `publish.ownerInvited` | | 12 | **repo-seo** | Set **GitHub topics + a strong description** on the explainer repo (via API) so it is discoverable; emit **suggested** topic/description improvements for the SOURCE repo (offered, never set directly — INV-16). | `publish.explainerRepoUrl`, `concept`, `understanding.summary` (+ GitHub token) | `publish.repoTopics` / `.repoDescription` / `.sourceRepoSeoSuggested` | | 13 | **readme-enhance** | **OPTIONAL (Station 8b, off the critical path).** Offer to enhance the SOURCE repo's README via the `readme-enhance` skill — architectural explanation + the **shared Station-4 SVGs** + an explainer badge — delivered **ONLY as a pull request** (never a direct push — INV-16). Failure is a warning, never sinks the build. | `repo` slot, `publish.liveUrl`, `visuals.architectureDiagram` / `.flowDiagram` (the shared SVGs) | `readmePr` slot (a PR URL or `"declined"`) | +| 13b | **make-overview** | **OPTIONAL (Station 6b, local door only, non-blocking).** One NotebookLM video (<=120s, narrated) plus ONE detailed infographic from the same notebook, embedded in the overview band under the video; `OVERVIEW_NOTEBOOK=` reuses an existing notebook (nothing is regenerated), `OVERVIEW_INFOGRAPHIC=0` skips the picture. Writes the `overview` slot (`overview.infographic.path` is the README hero for readme-enhance). | +| 13c | **outreach follow-up** | Not a station: `explainmyrepo outreach followup` (also run after every readme-enhance send). ONE short check-in per issue, `OUTREACH_FOLLOWUP_DAYS` (default 7) after our last message, only while the issue is open and unanswered; recorded as a `followup` ledger line so it can never post twice. Messages carry `README_PR_CONTACT` / `OUTREACH_CONTACT` (name, phone, email) and a review copy of the maintainer email goes to `OUTREACH_REVIEW_TO` over SMTP (never to the maintainer). | | 14 | **notify** | Email the owner the **scorecard + both screenshots + links** (live URL, explainer repo, pack, and any optional README-PR / source-repo SEO suggestions); also return inline. Pure SMTP (absorbs the old `phase9-send-email.mjs`). A notify failure degrades to a warning — it never inverts a live, graded, deployed build. | `publish` slot, `quality.scorecard`, the two screenshot paths, `pack.zipPath`, `readmePr` (+ SMTP creds from env) | `notify` slot (`emailSent` / `smtp250` / `inlineReturned`) | ### Secrets diff --git a/tools/make-overview.mjs b/tools/make-overview.mjs new file mode 100644 index 0000000..59fc86a --- /dev/null +++ b/tools/make-overview.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +// make-overview.mjs — Station 6b (OPTIONAL, NON-BLOCKING, LOCAL-DOOR-ONLY): a SHORT narrated video +// overview of the repo, made with Google NotebookLM (`nlm` CLI), embedded in the page before grading. +// +// Why short: the default NotebookLM explainer runs ~7 minutes; Stuart's standard is under two. +// The tool enforces it — a render longer than MAX_SECONDS is discarded, never shipped. +// +// CONTRACT: node tools/make-overview.mjs +// Reads: repo{name,url}, concept{metaphor,palette}, page{dir} +// Writes: /assets/overview.mp4 + overview-poster.jpg, a
in +// index.html (if absent), and the `overview` slot in build.json. +// stdout = ONE JSON result object; diagnostics -> stderr; exit 0 (a skip is not a failure). +// +// The look is derived per-repo from the build's own concept (metaphor + palette). There is no +// house style: a different repo gets a different brief. + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const MAX_SECONDS = 120; +const POLL_MS = 15000, TIMEOUT_MS = 20 * 60 * 1000; +const log = (m) => process.stderr.write(`[make-overview] ${m}\n`); +const emit = (o) => process.stdout.write(JSON.stringify(o) + '\n'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd, args, o = {}) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...o }).trim(); + +if (process.env.CI || process.env.GITHUB_ACTIONS) { log('refusing to run in CI — local-door-only.'); emit({ ok: false, outputs: {}, error: 'local-door-only' }); process.exit(1); } +const buildDir = process.argv[2]; +if (!buildDir) { emit({ ok: false, outputs: {}, error: 'usage: node tools/make-overview.mjs ' }); process.exit(1); } +const bjPath = path.join(buildDir, 'build.json'); +const ctx = JSON.parse(fs.readFileSync(bjPath, 'utf8')); +function done(slot) { + const cur = JSON.parse(fs.readFileSync(bjPath, 'utf8')); + cur.overview = slot; + fs.writeFileSync(bjPath, JSON.stringify(cur, null, 2) + '\n'); + emit({ ok: true, outputs: { overview: slot }, error: null }); + process.exit(0); +} +const skip = (why) => { log(`SKIPPED — ${why}`); done({ skipped: why }); }; + +if (/^(0|false|no|off)$/i.test(String(process.env.OVERVIEW || '').trim())) skip('OVERVIEW=0'); +const { name, url } = ctx.repo || {}; +const pageDir = ctx.page?.dir; +if (!name || !url) skip('repo slot incomplete'); +if (!pageDir || !fs.existsSync(path.join(pageDir, 'index.html'))) skip('page not assembled yet'); +try { sh('nlm', ['login', '--check']); } catch { skip('nlm missing or not authenticated (run `nlm login`)'); } +try { sh('ffprobe', ['-version']); } catch { skip('ffprobe missing'); } + +// Per-repo brief: the page's own metaphor and palette, never a fixed house style. +const firstSentence = (s) => String(s || '').split(/(?<=[.!?])\s/)[0].slice(0, 220); +const pal = ctx.concept?.palette || {}; +const stylePrompt = `${firstSentence(ctx.concept?.metaphor) || `A visual language that fits what ${name} is.`} ` + + `Colors: ${pal.bg || 'a calm neutral'} background with ${pal.accent || 'one restrained accent'}. Calm, precise, no clutter.`; +const focus = `A tight under-two-minute explainer of ${name} for a developer: the problem it solves, the one idea that makes it work, and how to start. ` + + `Plain words. Say it is an independent explainer, not made by the author.`; + +const uuid = (s) => (String(s).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i) || [])[0]; +let nb; +try { + if (process.env.OVERVIEW_NOTEBOOK) nb = process.env.OVERVIEW_NOTEBOOK; // re-use an existing notebook (no new sources) + else { + nb = uuid(sh('nlm', ['notebook', 'create', `${name} explained (explainmyrepo)`, '--json'])); + if (!nb) throw new Error('no notebook id in output'); + const sources = [url]; if (ctx.publish?.liveUrl) sources.push(ctx.publish.liveUrl); + sh('nlm', ['source', 'add', nb, ...sources.flatMap((u) => ['--url', u]), '--wait', '--json'], { timeout: 12 * 60 * 1000 }); + } +} catch (e) { skip(`could not create the notebook: ${String(e.stderr || e.message).slice(0, 160)}`); } + +const formats = (process.env.OVERVIEW_FORMAT ? [process.env.OVERVIEW_FORMAT] : ['brief', 'short']); +const work = path.join(buildDir, 'overview'); fs.mkdirSync(work, { recursive: true }); +let result = null; +for (const format of formats) { + try { + const created = sh('nlm', ['video', 'create', nb, '--format', format, '--focus', focus, '--style-prompt', stylePrompt, '-y', '--json']); + const aid = uuid(created); if (!aid) throw new Error('no artifact id'); + log(`${format}: queued ${aid}`); + const t0 = Date.now(); let state = ''; + while (Date.now() - t0 < TIMEOUT_MS) { + await sleep(POLL_MS); + const list = JSON.parse(sh('nlm', ['studio', 'status', nb])); + const a = list.find((x) => x.id === aid || x.artifact_id === aid); + state = a?.status || ''; + if (state === 'completed' || state === 'failed') break; + } + if (state !== 'completed') { log(`${format}: ended as "${state || 'timeout'}"`); continue; } + const mp4 = path.join(work, `${format}.mp4`); + sh('nlm', ['download', 'video', nb, '--id', aid, '-o', mp4, '--no-progress']); + const dur = parseFloat(sh('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', mp4])); + log(`${format}: ${dur.toFixed(1)}s`); + if (!(dur > 0) || dur > MAX_SECONDS) { log(`${format}: over ${MAX_SECONDS}s — discarded, never shipped`); continue; } + result = { format, aid, mp4, dur }; + break; + } catch (e) { log(`${format}: ${String(e.stderr || e.message).slice(0, 160)}`); } +} +if (!result) skip(`no format produced a video under ${MAX_SECONDS}s`); + +const assets = path.join(pageDir, 'assets'); fs.mkdirSync(assets, { recursive: true }); + +// Detailed infographic from the same notebook. Optional: any failure just leaves the video on its own. +let info = null; +if (!/^(0|false|no|off)$/i.test(String(process.env.OVERVIEW_INFOGRAPHIC || '').trim())) { + try { + const created = sh('nlm', ['infographic', 'create', nb, '--detail', 'detailed', '--orientation', 'landscape', '--focus', + `The one idea that makes ${name} work, in a few large labelled steps. Short words only; every label spelled exactly and fully legible.`, '-y', '--json']); + const aid = uuid(created); if (!aid) throw new Error('no artifact id'); + log(`infographic: queued ${aid}`); + const t0 = Date.now(); let state = ''; + while (Date.now() - t0 < TIMEOUT_MS) { + await sleep(POLL_MS); + const a = JSON.parse(sh('nlm', ['studio', 'status', nb])).find((x) => x.id === aid || x.artifact_id === aid); + state = a?.status || ''; + if (state === 'completed' || state === 'failed') break; + } + if (state !== 'completed') throw new Error(`ended as "${state || 'timeout'}"`); + const png = path.join(work, 'infographic.png'); + sh('nlm', ['download', 'infographic', nb, '--id', aid, '-o', png, '--no-progress']); + // 4 MB PNG is too heavy for a page; 1800px JPEG is ~10x smaller and still crisp at text size. + sh('ffmpeg', ['-v', 'error', '-y', '-i', png, '-vf', 'scale=1800:-2', '-q:v', '3', path.join(assets, 'overview-infographic.jpg')]); + info = { artifactId: aid, path: 'assets/overview-infographic.jpg' }; + log('infographic: saved'); + } catch (e) { log(`infographic skipped: ${String(e.stderr || e.message).slice(0, 160)}`); } +} +fs.copyFileSync(result.mp4, path.join(assets, 'overview.mp4')); +sh('ffmpeg', ['-v', 'error', '-y', '-ss', String(Math.max(1, result.dur * 0.25)), '-i', result.mp4, '-frames:v', '1', '-vf', 'scale=1280:-1', '-q:v', '4', path.join(assets, 'overview-poster.jpg')]); + +const htmlPath = path.join(pageDir, 'index.html'); +let html = fs.readFileSync(htmlPath, 'utf8'), injected = false; +if (!/data-overview/.test(html)) { + const secs = Math.round(result.dur); + const block = `
+
+ +
The ${secs}-second narrated overview, made with Google NotebookLM from the project's README. It is a machine-made summary, so treat the sections below and the repo as the source of truth.
+
${info ? ` +
Infographic summarising how ${name} works
` : ''}
+
+ + `; + const anchor = html.indexOf('
'); + if (anchor > -1) { html = html.slice(0, anchor) + block + html.slice(anchor); fs.writeFileSync(htmlPath, html); injected = true; } + else log('no
anchor — video copied but not embedded'); +} +done({ notebookId: nb, infographic: info, artifactId: result.aid, format: result.format, durationSec: +result.dur.toFixed(1), path: 'assets/overview.mp4', injected, stylePrompt }); diff --git a/tools/readme-enhance.mjs b/tools/readme-enhance.mjs index 0fde092..96ee7a0 100644 --- a/tools/readme-enhance.mjs +++ b/tools/readme-enhance.mjs @@ -183,8 +183,13 @@ async function main() { // ── the rewrite (additions only) ───────────────────────────────────────────────────────────────── const readmeName = fs.readdirSync(clone).find((f) => /^readme(\.md|\.markdown)?$/i.test(f)) || 'README.md'; const original = fs.existsSync(path.join(clone, readmeName)) ? fs.readFileSync(path.join(clone, readmeName), 'utf8') : ''; + // Hero: the NotebookLM infographic from Station 6b, when one was made (the picture sits up near the title). + const heroSrc = ctx.overview && ctx.overview.infographic && ctx.overview.infographic.path && ctx.page && ctx.page.dir + ? path.join(ctx.page.dir, ctx.overview.infographic.path) : null; + const heroRel = heroSrc && fs.existsSync(heroSrc) ? `${SVG_DIR}/overview${path.extname(heroSrc).toLowerCase() || '.png'}` : null; const rw = rewriteReadme(original, { liveUrl, repoName: repo.name, svgDir: SVG_DIR, + hero: heroRel ? { rel: heroRel, alt: `How ${repo.name} works` } : null, fallback: [ { alt: caption(arch.altText, `How ${repo.name} is put together.`), rel: `${SVG_DIR}/architecture.svg` }, { alt: caption(flow.altText, `How ${repo.name} works.`), rel: `${SVG_DIR}/flow.svg` }, @@ -206,6 +211,7 @@ async function main() { fs.copyFileSync(flowSvg, path.join(proposal, SVG_DIR, 'flow.svg')); files.push(`${SVG_DIR}/architecture.svg`, `${SVG_DIR}/flow.svg`); } + if (heroRel) { fs.copyFileSync(heroSrc, path.join(proposal, heroRel)); files.push(heroRel); } const haveArchPng = await rasterize(archSvg, path.join(proposal, 'preview', 'architecture.png')); const haveFlowPng = await rasterize(flowSvg, path.join(proposal, 'preview', 'flow.png')); @@ -259,7 +265,7 @@ async function main() { // ── the message ────────────────────────────────────────────────────────────────────────────────── let body = buildPrBody({ repoName: repo.name, liveUrl, readmeUrl: readmeUrl || liveUrl, mode: rw.mode, addedLines, files, images, - intro: env.README_PR_INTRO, signoff: env.README_PR_SIGNOFF || ghName, kind: channel === 'pr' ? 'pr' : 'issue', + intro: env.README_PR_INTRO, signoff: env.README_PR_SIGNOFF || ghName, contact: env.README_PR_CONTACT ?? env.OUTREACH_CONTACT, kind: channel === 'pr' ? 'pr' : 'issue', }); if (channel === 'pr' && env.README_ENHANCE_ISSUE) body = `Follow-up to #${String(env.README_ENHANCE_ISSUE).replace(/\D/g, '')}, as offered.\n\n${body}`; @@ -290,6 +296,34 @@ async function main() { explainerRepo: explainerUrl, buildDir: path.resolve(buildDir), mode: rw.mode, sentBy: ghLogin, }); log(`sent ${channel}: ${url}`); + + // Owner's review copy of the maintainer email (never sent to the maintainer), then settle any older + // follow-ups. Both are best-effort and never change the result of this send. + if (channel === 'issue') { + try { + const smtpUser = (env.SMTP_USER || env.GMAIL_USER || '').trim(); + const smtpPass = (env.SMTP_PASS || env.GMAIL_APP_PASSWORD || '').replace(/\s+/g, ''); + const reviewTo = (env.OUTREACH_REVIEW_TO || '').trim(); + if (reviewTo && smtpUser && smtpPass && !/^(0|false|no|off)$/i.test(String(env.OUTREACH_REVIEW_MAIL || ''))) { + const { pickMaintainerEmail, renderEmail } = await import('../src/outreach-mail.mjs'); + const { sendMail } = await import('../src/smtp.mjs'); + let to = null; + try { to = pickMaintainerEmail(JSON.parse(run('gh', ['api', `repos/${repoFull}/commits?per_page=40`]))); } catch { /* no public address */ } + const contact = env.README_PR_CONTACT ?? env.OUTREACH_CONTACT; + const draft = to ? renderEmail({ name: repo.name, to, liveUrl, issueUrl: url, fromName: env.README_PR_SIGNOFF || ghName, contact }) : null; + const text = [ + `A note was posted on ${repoFull}: ${url}`, `Explainer: ${liveUrl}`, '', + draft ? `Draft email to the maintainer (${draft.to}) — NOT sent. Forward it if you want to:\n\nSubject: ${draft.subject}\n\n${draft.body}` : 'No public maintainer address was found in recent commits.', + ].join('\n'); + await sendMail({ host: env.SMTP_HOST || 'smtp.gmail.com', port: Number(env.SMTP_PORT || 465), user: smtpUser, pass: smtpPass, fromName: 'explainmyrepo outreach', to: reviewTo, subject: `[review] ${repoFull}: note posted, email draft inside`, text }); + log(`review copy mailed to ${reviewTo}`); + } + } catch (e) { log(`review mail skipped: ${String(e.message).slice(0, 120)}`); } + } + try { + const { followupCommand } = await import('../src/outreach-cli.mjs'); + followupCommand({ env, write: (s) => log(s.trim()) }); + } catch (e) { log(`follow-up pass skipped: ${String(e.message).slice(0, 120)}`); } done({ prUrl: url, kind: channel, mode: rw.mode, previewUrl: previewReadmeUrl, addedLines, svgsShared: files }, { url }); }