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
19 changes: 19 additions & 0 deletions app/api/cron/maintainer-outreach-report/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { sendMaintainerOutreachReport } from '../../../../lib/maintainer-outreach-report.js';

export const maxDuration = 300;

export async function GET(request) {
const cronSecret = process.env.CRON_SECRET?.trim();
if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const summary = await sendMaintainerOutreachReport();
console.info(JSON.stringify({ event: 'devglobe_maintainer_outreach_report', outcome: 'completed', ...summary }));
return NextResponse.json({ ok: true, ...summary });
} catch {
console.error(JSON.stringify({ event: 'devglobe_maintainer_outreach_report', outcome: 'failed' }));
return NextResponse.json({ error: 'Maintainer outreach report failed' }, { status: 500 });
}
}
20 changes: 20 additions & 0 deletions app/api/cron/maintainer-outreach/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { runMaintainerOutreachSchedule } from '../../../../lib/maintainer-outreach-scheduler.js';

export const maxDuration = 300;

export async function GET(request) {
const cronSecret = process.env.CRON_SECRET?.trim();
if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

try {
const summary = await runMaintainerOutreachSchedule();
console.info(JSON.stringify({ event: 'devglobe_maintainer_outreach_queue', outcome: 'completed', ...summary }));
return NextResponse.json({ ok: true, ...summary });
} catch {
console.error(JSON.stringify({ event: 'devglobe_maintainer_outreach_queue', outcome: 'failed' }));
return NextResponse.json({ error: 'Maintainer outreach queue failed' }, { status: 500 });
}
}
2 changes: 2 additions & 0 deletions docs/azure-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ The Container App and Function App require their existing Cosmos, GitHub, and Az

The `repository-agent-ingest` timer uses the Function App's `GITHUB_TOKEN` to scan a bounded batch of stale developer profiles. It examines filenames from up to eight recent public, non-fork, non-archived owner repositories and stores the resulting tool IDs and evidence on the existing developer document. It never reads or stores repository file contents. Each profile is refreshed after seven days, and GitHub rate-limit responses stop the current batch.

The `maintainer-outreach` timer creates up to ten deduplicated review-only drafts each day through the protected Container App endpoint. It never sends outreach. Operators explicitly approve and record manual sends with `npm run outreach:review`. The `maintainer-outreach-report` timer emails aggregate weekly outcomes to `GROWTH_REPORT_EMAIL`; its Function App settings are `MAINTAINER_OUTREACH_URL`, `MAINTAINER_OUTREACH_REPORT_URL`, and the shared `CRON_SECRET`.

Repository evidence is observational. It does not imply that a developer personally uses a tool or consents to agent contact; only a public, self-declared AI profile controls contact availability.

## Frontend configuration
Expand Down
52 changes: 52 additions & 0 deletions docs/prd/maintainer-outreach-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Maintainer Outreach Review Queue

## Issue

GitHub issue #384: Growth: automate maintainer outreach review queue.

## Problem

DevGlobe can generate activation copy, but candidate selection, deduplication, review state, follow-up timing, and outcome reporting are manual. Repeating that work daily is costly and makes growth experiments inconsistent.

## Goals

- Select up to ten high-signal unclaimed public profiles each day.
- Create personalized campaign-attributed drafts without duplicating queued people.
- Require explicit operator approval and manual delivery.
- Prepare at most one follow-up, four days after a recorded send.
- Report selected, approved, contacted, profile-viewed, and claimed counts.

## Non-goals

- Sending email, direct messages, GitHub comments, or social posts.
- Discovering or storing private contact details.
- More than two outreach attempts per person.
- Bypassing platform anti-spam or moderation controls.

## Workflow

1. The daily timer calls the protected queue endpoint at 13:30 UTC.
2. The scheduler selects public, unclaimed profiles and creates pending drafts.
3. An operator lists drafts and records approval, rejection, and manual sends through the CLI.
4. A sent first attempt becomes eligible for one follow-up after four days.
5. The report command joins bounded campaign engagement into aggregate funnel counts.
6. A weekly timer emails the aggregate report to `GROWTH_REPORT_EMAIL`.

## Commands

```powershell
npm run setup-maintainer-outreach-container
npm run outreach:queue
npm run outreach:review -- list pending
npm run outreach:review -- approve octocat operator
npm run outreach:review -- sent octocat operator
npm run outreach:review -- report
```

## Acceptance Criteria

- Repeated daily runs do not duplicate pending or rejected profiles.
- Every generated link uses the bounded `manual_outreach` and `developer_activation` attribution.
- Sending requires an explicit out-of-band operator action; application code cannot deliver drafts.
- A profile receives no more than one follow-up draft.
- State transitions and aggregate reporting are covered by focused tests.
13 changes: 13 additions & 0 deletions functions/maintainer-outreach-report/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"bindings": [
{
"name": "timer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 30 13 * * 1",
"runOnStartup": false,
"useMonitor": true
}
],
"scriptFile": "index.js"
}
19 changes: 19 additions & 0 deletions functions/maintainer-outreach-report/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module.exports = async function maintainerOutreachReport(context) {
const endpoint = process.env.MAINTAINER_OUTREACH_REPORT_URL;
const secret = process.env.CRON_SECRET;
if (!endpoint || !secret) throw new Error('MAINTAINER_OUTREACH_REPORT_URL and CRON_SECRET are required');

const response = await fetch(endpoint, {
headers: { Authorization: `Bearer ${secret}` },
});
const result = await response.json();
context.log('DevGlobe maintainer outreach report', {
status: response.status,
selected: result.selected,
contacted: result.contacted,
claimed: result.claimed,
reportSent: result.reportSent,
reason: result.reason,
});
if (!response.ok) throw new Error(`Maintainer outreach report returned ${response.status}`);
};
13 changes: 13 additions & 0 deletions functions/maintainer-outreach/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"bindings": [
{
"name": "timer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 30 13 * * *",
"runOnStartup": false,
"useMonitor": true
}
],
"scriptFile": "index.js"
}
17 changes: 17 additions & 0 deletions functions/maintainer-outreach/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module.exports = async function maintainerOutreach(context) {
const endpoint = process.env.MAINTAINER_OUTREACH_URL;
const secret = process.env.CRON_SECRET;
if (!endpoint || !secret) throw new Error('MAINTAINER_OUTREACH_URL and CRON_SECRET are required');

const response = await fetch(endpoint, {
headers: { Authorization: `Bearer ${secret}` },
});
const result = await response.json();
context.log('DevGlobe maintainer outreach queue', {
status: response.status,
selected: result.selected,
queued: result.queued,
delivery: result.delivery,
});
if (!response.ok) throw new Error(`Maintainer outreach queue returned ${response.status}`);
};
41 changes: 41 additions & 0 deletions lib/maintainer-outreach-report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { sendLifecycleEmail } from './lifecycle-email.js';
import { getMaintainerOutreachReport } from './maintainer-outreach-scheduler.js';

function percentage(numerator, denominator) {
return denominator ? Math.round((1000 * numerator) / denominator) / 10 : 0;
}

export function buildMaintainerOutreachReportEmail(report) {
const visitRate = percentage(report.profileViewed, report.contacted);
const claimRate = percentage(report.claimed, report.contacted);
const lines = [
`Selected: ${report.selected}`,
`Pending review: ${report.pending}`,
`Approved: ${report.approved}`,
`Contacted: ${report.contacted}`,
`Profile viewed: ${report.profileViewed} (${visitRate}%)`,
`Claimed: ${report.claimed} (${claimRate}%)`,
];
return {
subject: 'DevGlobe weekly maintainer outreach report',
text: `DevGlobe maintainer outreach\n\n${lines.join('\n')}\n\nDraft delivery remains manual.`,
html: `<h1>DevGlobe maintainer outreach</h1><ul>${lines.map(line => `<li>${line}</li>`).join('')}</ul><p>Draft delivery remains manual.</p>`,
};
}

export async function sendMaintainerOutreachReport({
now = new Date(),
recipient = process.env.GROWTH_REPORT_EMAIL?.trim(),
loadReport = getMaintainerOutreachReport,
sendEmail = sendLifecycleEmail,
} = {}) {
const report = await loadReport({ now });
if (!recipient) return { ...report, reportSent: false, reason: 'missing_recipient' };
const week = now.toISOString().slice(0, 10);
const delivery = await sendEmail({
to: recipient,
message: buildMaintainerOutreachReportEmail(report),
idempotencyKey: `maintainer-outreach-report-${week}`,
});
return { ...report, reportSent: delivery.sent, reason: delivery.reason || null };
}
64 changes: 64 additions & 0 deletions lib/maintainer-outreach-scheduler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getCosmosContainer } from './cosmos.js';
import { getEngagementContainer } from './engagement-store.js';
import {
MAINTAINER_OUTREACH_LIMIT,
selectMaintainerOutreachDrafts,
summarizeMaintainerOutreach,
} from './maintainer-outreach.js';
import {
getMaintainerOutreachContainer,
listMaintainerOutreachRecords,
saveMaintainerOutreachDraft,
} from './maintainer-outreach-store.js';

async function loadCandidates(container) {
if (!container) throw new Error('Developer Cosmos container is required');
const { resources } = await container.items.query({
query: `SELECT TOP 500 c.login, c.name, c.topLanguage, c.score, c.totalStars,
c.totalCommits, c.followers, c.soReputation, c.claimed
FROM c
WHERE (NOT IS_DEFINED(c.nomination) OR c.nomination.status = "approved")
AND (NOT IS_DEFINED(c.claimed) OR c.claimed != true)
ORDER BY c.score DESC`,
}).fetchAll();
return resources;
}

export async function runMaintainerOutreachSchedule({
now = new Date(),
limit = MAINTAINER_OUTREACH_LIMIT,
developers,
developerContainer = getCosmosContainer(),
outreachContainer = getMaintainerOutreachContainer(),
} = {}) {
if (!outreachContainer) throw new Error('Maintainer outreach Cosmos container is required');
const [candidates, records] = await Promise.all([
developers ? Promise.resolve(developers) : loadCandidates(developerContainer),
listMaintainerOutreachRecords(undefined, outreachContainer),
]);
const drafts = selectMaintainerOutreachDrafts({ developers: candidates, records, now, limit });
const queued = [];
for (const draft of drafts) queued.push(await saveMaintainerOutreachDraft(draft, outreachContainer));
return { selected: drafts.length, queued: queued.length, delivery: 'manual_review_only' };
}

export async function getMaintainerOutreachReport({
now = new Date(),
days = 30,
outreachContainer = getMaintainerOutreachContainer(),
engagementContainer = getEngagementContainer(),
} = {}) {
const records = await listMaintainerOutreachRecords(undefined, outreachContainer);
if (!engagementContainer) return summarizeMaintainerOutreach(records);
const since = new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString();
const { resources } = await engagementContainer.items.query({
query: `SELECT c.eventName, c.targetLogin FROM c
WHERE c.documentType = "engagement-event"
AND c.createdAt >= @since
AND c.eventName IN ("profile_viewed", "profile_claimed")
AND c.properties.source = "manual_outreach"
AND c.properties.campaign = "developer_activation"`,
parameters: [{ name: '@since', value: since }],
}).fetchAll();
return summarizeMaintainerOutreach(records, resources);
}
111 changes: 111 additions & 0 deletions lib/maintainer-outreach-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { getCosmosContainer } from './cosmos.js';
import { followUpDueAt, MAINTAINER_OUTREACH_MAX_ATTEMPTS } from './maintainer-outreach.js';

const memoryRecords = new Map();

function loginOf(value) {
return String(value || '').trim().toLowerCase();
}

export function getMaintainerOutreachContainer() {
return getCosmosContainer(process.env.COSMOS_MAINTAINER_OUTREACH_CONTAINER || 'maintainer-outreach');
}

export async function getMaintainerOutreachRecord(login, container = getMaintainerOutreachContainer()) {
const normalizedLogin = loginOf(login);
if (!container) return memoryRecords.get(normalizedLogin) || null;
try {
const { resource } = await container.item(normalizedLogin, normalizedLogin).read();
return resource || null;
} catch (error) {
if (error.code === 404 || error.statusCode === 404) return null;
throw error;
}
}

export async function listMaintainerOutreachRecords(status, container = getMaintainerOutreachContainer()) {
if (!container) {
return [...memoryRecords.values()]
.filter(record => !status || record.status === status)
.sort((left, right) => right.selectedAt.localeCompare(left.selectedAt));
}
const query = status
? {
query: 'SELECT * FROM c WHERE c.documentType = "maintainer-outreach" AND c.status = @status ORDER BY c.selectedAt DESC',
parameters: [{ name: '@status', value: status }],
}
: 'SELECT * FROM c WHERE c.documentType = "maintainer-outreach" ORDER BY c.selectedAt DESC';
const { resources } = await container.items.query(query).fetchAll();
return resources;
}

async function replaceRecord(record, container) {
if (!container) {
memoryRecords.set(record.login, record);
return record;
}
const { resource } = await container.item(record.id, record.login).replace(record, {
accessCondition: record._etag ? { type: 'IfMatch', condition: record._etag } : undefined,
});
return resource;
}

export async function saveMaintainerOutreachDraft(draft, container = getMaintainerOutreachContainer()) {
const existing = await getMaintainerOutreachRecord(draft.login, container);
const updatedAt = new Date().toISOString();
if (!existing) {
const document = { ...draft, createdAt: updatedAt, updatedAt, attemptHistory: [] };
if (!container) {
memoryRecords.set(document.login, document);
return document;
}
try {
const { resource } = await container.items.create(document);
return resource;
} catch (error) {
if (error.code === 409 || error.statusCode === 409) return getMaintainerOutreachRecord(draft.login, container);
throw error;
}
}
if (existing.status !== 'sent' || draft.attempt !== existing.attempt + 1) return existing;
return replaceRecord({
...existing,
...draft,
createdAt: existing.createdAt,
updatedAt,
approvedAt: null,
approvedBy: null,
sentAt: null,
followUpDueAt: null,
attemptHistory: [
...(existing.attemptHistory || []),
{ attempt: existing.attempt, sentAt: existing.sentAt },
],
}, container);
}

export async function updateMaintainerOutreachStatus(login, action, actor, container = getMaintainerOutreachContainer(), now = new Date()) {
const existing = await getMaintainerOutreachRecord(login, container);
if (!existing) throw new Error(`Outreach record not found: ${login}`);
const timestamp = now.toISOString();
let patch;
if (action === 'approve' && existing.status === 'pending') {
patch = { status: 'approved', approvedAt: timestamp, approvedBy: actor || null };
} else if (action === 'reject' && ['pending', 'approved'].includes(existing.status)) {
patch = { status: 'rejected', rejectedAt: timestamp, rejectedBy: actor || null };
} else if (action === 'sent' && existing.status === 'approved') {
patch = {
status: 'sent',
sentAt: timestamp,
sentBy: actor || null,
followUpDueAt: existing.attempt < MAINTAINER_OUTREACH_MAX_ATTEMPTS ? followUpDueAt(timestamp) : null,
};
} else {
throw new Error(`Cannot ${action} outreach record in ${existing.status} state`);
}
return replaceRecord({ ...existing, ...patch, updatedAt: timestamp }, container);
}

export function __resetMemoryMaintainerOutreachStoreForTests() {
memoryRecords.clear();
}
Loading