feat: repo commit tracking and OpenClaw notification

- Migration 058: repo_commits + openclaw_instances tables (seeded with overwatch)
- POST /api/webhooks/forgejo: receives Forgejo push events, stores commits,
  forwards HMAC-signed payload to all enabled OpenClaw instances, sends Telegram
- GET /api/openclaw/repo-commits: OpenClaw polling endpoint (filters: since, repo, branch, limit)
- GET/POST /api/admin/openclaw-instances: manage instance registry
- PATCH/DELETE /api/admin/openclaw-instances/[id]: update/remove instances
- FORGEJO_WEBHOOK_SECRET in .env.local (leave empty to skip HMAC verification)
This commit is contained in:
lorentz 2026-03-21 18:52:22 -04:00
parent 9459d65e02
commit 414ad78c36
5 changed files with 346 additions and 0 deletions

View file

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const { name, webhook_url, webhook_secret, enabled } = body;
const sets: string[] = [];
const values: unknown[] = [];
if (name !== undefined) { values.push(name); sets.push(`name = $${values.length}`); }
if (webhook_url !== undefined) { values.push(webhook_url); sets.push(`webhook_url = $${values.length}`); }
if (webhook_secret !== undefined) { values.push(webhook_secret); sets.push(`webhook_secret = $${values.length}`); }
if (enabled !== undefined) { values.push(enabled); sets.push(`enabled = $${values.length}`); }
if (sets.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const result = await postgresClient.query(
`UPDATE openclaw_instances SET ${sets.join(', ')}
WHERE id = $${values.length}
RETURNING id, name, webhook_url, enabled, last_notified_at, created_at`,
values
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ data: result.rows[0] });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const result = await postgresClient.query(
`DELETE FROM openclaw_instances WHERE id = $1 RETURNING id`,
[id]
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET() {
const result = await postgresClient.query(
`SELECT id, name, webhook_url, enabled, last_notified_at, created_at
FROM openclaw_instances
ORDER BY created_at`
);
return NextResponse.json({ data: result.rows });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const { name, webhook_url, webhook_secret, enabled = true } = body;
if (!name || !webhook_url || !webhook_secret) {
return NextResponse.json(
{ error: 'name, webhook_url, and webhook_secret are required' },
{ status: 400 }
);
}
const result = await postgresClient.query(
`INSERT INTO openclaw_instances (name, webhook_url, webhook_secret, enabled)
VALUES ($1, $2, $3, $4)
RETURNING id, name, webhook_url, enabled, created_at`,
[name, webhook_url, webhook_secret, enabled]
);
return NextResponse.json({ data: result.rows[0] }, { status: 201 });
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { validateOpenClawKey } from '@/lib/utils/openclaw-auth';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
const authError = validateOpenClawKey(request);
if (authError) return authError;
const params = request.nextUrl.searchParams;
const since = params.get('since');
const repo = params.get('repo');
const branch = params.get('branch');
const limit = Math.min(500, Math.max(1, parseInt(params.get('limit') || '100', 10)));
const conditions: string[] = [];
const values: unknown[] = [];
if (since) {
values.push(since);
conditions.push(`received_at > $${values.length}`);
}
if (repo) {
values.push(repo);
conditions.push(`(repo_name = $${values.length} OR repo_full_name = $${values.length})`);
}
if (branch) {
values.push(branch);
conditions.push(`branch = $${values.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
values.push(limit);
const result = await postgresClient.query(
`SELECT id, repo_name, repo_full_name, branch, commit_sha, commit_message,
author_name, author_email, committed_at, pushed_by, received_at, notified_at
FROM repo_commits
${where}
ORDER BY received_at DESC
LIMIT $${values.length}`,
values
);
return NextResponse.json({
data: result.rows,
total: result.rowCount,
});
}

View file

@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
import { postgresClient } from '@/lib/services/postgres-client';
function signPayload(secret: string, body: string): string {
return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
}
function verifySignature(secret: string, body: string, header: string): boolean {
try {
const expected = Buffer.from(signPayload(secret, body));
const actual = Buffer.from(header);
if (expected.length !== actual.length) return false;
return timingSafeEqual(expected, actual);
} catch {
return false;
}
}
async function sendTelegram(message: string): Promise<void> {
const result = await postgresClient.query<{ config: Record<string, string> }>(
`SELECT config FROM notification_channels WHERE channel_type = 'telegram' AND is_active = true LIMIT 1`
);
if (result.rows.length === 0) return;
const { bot_token, chat_id, parse_mode } = result.rows[0].config;
if (!bot_token || !chat_id) return;
await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id,
text: message,
parse_mode: parse_mode || 'HTML',
}),
});
}
async function notifyOpenClawInstances(payload: object): Promise<void> {
const instances = await postgresClient.query<{
id: number;
name: string;
webhook_url: string;
webhook_secret: string;
}>(`SELECT id, name, webhook_url, webhook_secret FROM openclaw_instances WHERE enabled = true`);
const body = JSON.stringify(payload);
await Promise.allSettled(
instances.rows.map(async (instance) => {
const signature = signPayload(instance.webhook_secret, body);
try {
await fetch(instance.webhook_url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-webhook-signature': signature,
},
body,
signal: AbortSignal.timeout(10000),
});
await postgresClient.query(
`UPDATE openclaw_instances SET last_notified_at = NOW() WHERE id = $1`,
[instance.id]
);
} catch (err) {
console.error(`[forgejo-webhook] Failed to notify OpenClaw instance "${instance.name}":`, err);
}
})
);
}
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const webhookSecret = process.env.FORGEJO_WEBHOOK_SECRET;
if (webhookSecret) {
const sigHeader =
request.headers.get('x-gitea-signature') ||
request.headers.get('x-hub-signature-256') ||
'';
const sigToVerify = sigHeader.startsWith('sha256=') ? sigHeader : `sha256=${sigHeader}`;
if (!verifySignature(webhookSecret, rawBody, sigToVerify)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
}
let payload: any;
try {
payload = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const eventType = request.headers.get('x-gitea-event') || request.headers.get('x-github-event') || '';
if (eventType !== 'push' && eventType !== '') {
return NextResponse.json({ ok: true, skipped: true, event: eventType });
}
const repoName: string = payload?.repository?.name ?? 'unknown';
const repoFullName: string = payload?.repository?.full_name ?? repoName;
const branch: string = (payload?.ref ?? '').replace('refs/heads/', '') || 'unknown';
const pushedBy: string = payload?.pusher?.login ?? payload?.sender?.login ?? 'unknown';
const commits: any[] = Array.isArray(payload?.commits) ? payload.commits : [];
if (commits.length === 0) {
return NextResponse.json({ ok: true, stored: 0 });
}
let stored = 0;
for (const commit of commits) {
const sha: string = commit.id ?? commit.sha ?? '';
const message: string = (commit.message ?? '').split('\n')[0];
const authorName: string = commit.author?.name ?? pushedBy;
const authorEmail: string = commit.author?.email ?? '';
const committedAt: string | null = commit.timestamp ?? null;
try {
await postgresClient.query(
`INSERT INTO repo_commits
(repo_name, repo_full_name, branch, commit_sha, commit_message,
author_name, author_email, committed_at, pushed_by, raw_payload)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (repo_full_name, commit_sha) DO NOTHING`,
[repoName, repoFullName, branch, sha, message,
authorName, authorEmail, committedAt, pushedBy, payload]
);
stored++;
} catch (err) {
console.error('[forgejo-webhook] Failed to insert commit:', err);
}
}
await postgresClient.query(
`UPDATE repo_commits SET notified_at = NOW()
WHERE repo_full_name = $1 AND branch = $2 AND notified_at IS NULL`,
[repoFullName, branch]
);
const forwardPayload = {
repository: { name: repoName, full_name: repoFullName },
pusher: { login: pushedBy },
ref: payload?.ref,
commits: commits.map((c: any) => ({
id: c.id ?? c.sha,
message: (c.message ?? '').split('\n')[0],
author: c.author,
timestamp: c.timestamp,
})),
};
void notifyOpenClawInstances(forwardPayload);
const commitLines = commits
.slice(0, 5)
.map((c: any) => `• <code>${(c.id ?? c.sha ?? '').substring(0, 7)}</code> — ${(c.message ?? '').split('\n')[0]}`)
.join('\n');
const extra = commits.length > 5 ? `\n<i>…and ${commits.length - 5} more</i>` : '';
const telegramMsg =
`🔀 <b>${repoName}</b> · ${branch}\n` +
`👤 ${pushedBy} · ${commits.length} commit${commits.length === 1 ? '' : 's'}\n\n` +
commitLines + extra;
void sendTelegram(telegramMsg);
return NextResponse.json({ ok: true, stored, commits: commits.length });
}

View file

@ -0,0 +1,42 @@
-- Migration 058: Repo commit tracking and OpenClaw instance registry
CREATE TABLE IF NOT EXISTS repo_commits (
id SERIAL PRIMARY KEY,
repo_name TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
branch TEXT NOT NULL,
commit_sha TEXT NOT NULL,
commit_message TEXT,
author_name TEXT,
author_email TEXT,
committed_at TIMESTAMPTZ,
pushed_by TEXT,
raw_payload JSONB,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
notified_at TIMESTAMPTZ,
UNIQUE (repo_full_name, commit_sha)
);
CREATE INDEX IF NOT EXISTS idx_repo_commits_repo ON repo_commits (repo_name);
CREATE INDEX IF NOT EXISTS idx_repo_commits_received ON repo_commits (received_at DESC);
CREATE INDEX IF NOT EXISTS idx_repo_commits_branch ON repo_commits (branch);
CREATE TABLE IF NOT EXISTS openclaw_instances (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
webhook_url TEXT NOT NULL,
webhook_secret TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed overwatch instance
INSERT INTO openclaw_instances (name, webhook_url, webhook_secret, enabled)
VALUES (
'overwatch',
'http://100.89.248.105:8420/webhook/git-push',
'e984b79196c91104a7ab8fa7d7f554e7c5b3ba5a4ebddb92f2ae2fbee675c14b',
TRUE
)
ON CONFLICT (name) DO NOTHING;