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