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:
parent
9459d65e02
commit
414ad78c36
5 changed files with 346 additions and 0 deletions
54
app/api/admin/openclaw-instances/[id]/route.ts
Normal file
54
app/api/admin/openclaw-instances/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
32
app/api/admin/openclaw-instances/route.ts
Normal file
32
app/api/admin/openclaw-instances/route.ts
Normal 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 });
|
||||
}
|
||||
48
app/api/openclaw/repo-commits/route.ts
Normal file
48
app/api/openclaw/repo-commits/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
170
app/api/webhooks/forgejo/route.ts
Normal file
170
app/api/webhooks/forgejo/route.ts
Normal 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 });
|
||||
}
|
||||
42
migrations/058_create_repo_commit_tracking.sql
Normal file
42
migrations/058_create_repo_commit_tracking.sql
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue