Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions bin/explainmyrepo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -133,16 +134,17 @@ async function main() {
// Outreach tracking (ADR-0015): `outreach status` polls every message we've sent;
// `outreach open-pr <owner/name>` 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 <owner/name>\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 <owner/name>]\n`);
process.stderr.write(`unknown outreach command: ${sub}\nusage: explainmyrepo outreach [status | followup | open-pr <owner/name>]\n`);
process.exit(2);
}

Expand Down
1 change: 1 addition & 0 deletions src/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
37 changes: 36 additions & 1 deletion src/outreach-cli.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// src/outreach-cli.mjs — `npx explainmyrepo outreach [status | open-pr <owner/name>]` (ADR-0015).
// src/outreach-cli.mjs — `npx explainmyrepo outreach [status | followup | open-pr <owner/name>]` (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".
Expand All @@ -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']);
Expand Down Expand Up @@ -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;
}
46 changes: 46 additions & 0 deletions src/outreach-followup.mjs
Original file line number Diff line number Diff line change
@@ -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');
}
32 changes: 32 additions & 0 deletions src/outreach-mail.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
3 changes: 2 additions & 1 deletion src/readme-ascii.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
'',
];
Expand Down
2 changes: 2 additions & 0 deletions src/readme-pr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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`;
}
Expand Down
55 changes: 55 additions & 0 deletions src/smtp.mjs
Original file line number Diff line number Diff line change
@@ -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.')); });
});
}
75 changes: 75 additions & 0 deletions tests/outreach.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
Loading
Loading