Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (oppgave 29.6)
Legger til et template-system for webhooks som vet hvordan kjente tjenester strukturerer sine JSON-payloads, og mapper dem til meningsfulle node title/content/metadata. Templates: - github-push: Commits med repo, branch, pusher, formaterte meldinger - github-issues: Issue-hendelser med nummer, labels, state - github-pull-request: PR-hendelser med branch-info, merge-status - slack-message: Slack Event API-meldinger med kanal og bruker - ci-build: Generisk CI/CD (GitHub Actions, GitLab CI, Jenkins) Backend: - webhook_templates.rs: Template-definisjoner og apply-logikk - webhook.rs: Bruker template fra webhook-nodens metadata.template_id - webhook_admin.rs: GET /admin/webhooks/templates, POST set_template, template_id i create og list Frontend: - Template-velger i opprett-skjema og på hver webhook-kort - Kan bytte template på eksisterende webhooks 6 unit-tester for alle templates. Verifisert med curl mot live endpoint.
This commit is contained in:
parent
af014fc883
commit
a3dfa3b254
8 changed files with 701 additions and 30 deletions
|
|
@ -62,13 +62,16 @@ POST /api/webhook/<token>
|
||||||
→ belongs_to-edge til målsamling
|
→ belongs_to-edge til målsamling
|
||||||
```
|
```
|
||||||
|
|
||||||
Webhook-templates for kjente tjenester:
|
Webhook-templates (forhåndsdefinerte mappinger) for kjente tjenester:
|
||||||
- **GitHub:** commits, issues, PR-er → noder med riktig tittel/innhold
|
- **GitHub Push:** `[repo] Push til branch — N commit(s)` med formaterte commit-meldinger
|
||||||
- **Slack:** meldinger → innholdsnoder (bridge)
|
- **GitHub Issues:** `[repo] Issue #N action: title` med labels og state
|
||||||
- **CI/CD:** build-status → noder med status-edge
|
- **GitHub PR:** `[repo] PR #N action: title` med branch-info og merge-status
|
||||||
|
- **Slack:** `Slack #channel — user` med meldingstekst
|
||||||
|
- **CI/CD:** `[repo] Build STATUS: pipeline` med branch, varighet og lenke
|
||||||
|
|
||||||
Hvert webhook har et unikt token, en mål-samling, og en
|
Template-id lagres i webhook-nodens metadata (`metadata.template_id`).
|
||||||
valgfri template for JSON→node-mapping.
|
Admin-API: `GET /admin/webhooks/templates` (liste), `POST /admin/webhooks/set_template`.
|
||||||
|
Uten template brukes generisk ekstraksjon (title/content/body-felt).
|
||||||
|
|
||||||
#### Video
|
#### Video
|
||||||
Opptak direkte i nettleseren.
|
Opptak direkte i nettleseren.
|
||||||
|
|
|
||||||
|
|
@ -1432,6 +1432,7 @@ export interface WebhookInfo {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
token: string;
|
token: string;
|
||||||
|
template_id: string | null;
|
||||||
collection_id: string;
|
collection_id: string;
|
||||||
collection_title: string | null;
|
collection_title: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -1439,6 +1440,13 @@ export interface WebhookInfo {
|
||||||
last_event_at: string | null;
|
last_event_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebhookTemplateInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
service: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WebhookEvent {
|
export interface WebhookEvent {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
|
@ -1492,7 +1500,7 @@ export async function fetchWebhookEvents(
|
||||||
/** Opprett ny webhook for en samling. */
|
/** Opprett ny webhook for en samling. */
|
||||||
export function createWebhook(
|
export function createWebhook(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
data: { title?: string; collection_id: string }
|
data: { title?: string; collection_id: string; template_id?: string }
|
||||||
): Promise<CreateWebhookResponse> {
|
): Promise<CreateWebhookResponse> {
|
||||||
return post(accessToken, '/admin/webhooks/create', data);
|
return post(accessToken, '/admin/webhooks/create', data);
|
||||||
}
|
}
|
||||||
|
|
@ -1513,6 +1521,30 @@ export function deleteWebhook(
|
||||||
return post(accessToken, '/admin/webhooks/delete', { webhook_id: webhookId });
|
return post(accessToken, '/admin/webhooks/delete', { webhook_id: webhookId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hent tilgjengelige webhook-templates. */
|
||||||
|
export async function fetchWebhookTemplates(accessToken: string): Promise<WebhookTemplateInfo[]> {
|
||||||
|
const res = await fetch(`${BASE_URL}/admin/webhooks/templates`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`webhook templates failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sett eller fjern template på en webhook. */
|
||||||
|
export function setWebhookTemplate(
|
||||||
|
accessToken: string,
|
||||||
|
webhookId: string,
|
||||||
|
templateId: string | null
|
||||||
|
): Promise<{ template_id: string | null }> {
|
||||||
|
return post(accessToken, '/admin/webhooks/set_template', {
|
||||||
|
webhook_id: webhookId,
|
||||||
|
template_id: templateId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function setMixerRole(
|
export async function setMixerRole(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@
|
||||||
import {
|
import {
|
||||||
fetchWebhooks,
|
fetchWebhooks,
|
||||||
fetchWebhookEvents,
|
fetchWebhookEvents,
|
||||||
|
fetchWebhookTemplates,
|
||||||
createWebhook,
|
createWebhook,
|
||||||
regenerateWebhookToken,
|
regenerateWebhookToken,
|
||||||
deleteWebhook,
|
deleteWebhook,
|
||||||
|
setWebhookTemplate,
|
||||||
type WebhookInfo,
|
type WebhookInfo,
|
||||||
type WebhookEvent
|
type WebhookEvent,
|
||||||
|
type WebhookTemplateInfo
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
|
||||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
|
|
@ -24,10 +27,14 @@
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let actionLoading = $state<string | null>(null);
|
let actionLoading = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
let templates = $state<WebhookTemplateInfo[]>([]);
|
||||||
|
|
||||||
// Opprett-skjema
|
// Opprett-skjema
|
||||||
let showCreateForm = $state(false);
|
let showCreateForm = $state(false);
|
||||||
let newTitle = $state('');
|
let newTitle = $state('');
|
||||||
let newCollectionId = $state('');
|
let newCollectionId = $state('');
|
||||||
|
let newTemplateId = $state('');
|
||||||
let collections = $state<{ id: string; title: string | null }[]>([]);
|
let collections = $state<{ id: string; title: string | null }[]>([]);
|
||||||
|
|
||||||
// Hendelser per webhook (utfelt)
|
// Hendelser per webhook (utfelt)
|
||||||
|
|
@ -38,10 +45,11 @@
|
||||||
// Kopier-feedback
|
// Kopier-feedback
|
||||||
let copiedToken = $state<string | null>(null);
|
let copiedToken = $state<string | null>(null);
|
||||||
|
|
||||||
// Poll webhooks every 10 seconds
|
// Poll webhooks every 10 seconds + load templates once
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
loadWebhooks();
|
loadWebhooks();
|
||||||
|
loadTemplates();
|
||||||
const interval = setInterval(loadWebhooks, 10000);
|
const interval = setInterval(loadWebhooks, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
});
|
});
|
||||||
|
|
@ -57,6 +65,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
templates = await fetchWebhookTemplates(accessToken);
|
||||||
|
} catch {
|
||||||
|
// Ikke kritisk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCollections() {
|
async function loadCollections() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,10 +106,12 @@
|
||||||
try {
|
try {
|
||||||
await createWebhook(accessToken, {
|
await createWebhook(accessToken, {
|
||||||
title: newTitle || undefined,
|
title: newTitle || undefined,
|
||||||
collection_id: newCollectionId
|
collection_id: newCollectionId,
|
||||||
|
template_id: newTemplateId || undefined
|
||||||
});
|
});
|
||||||
newTitle = '';
|
newTitle = '';
|
||||||
newCollectionId = '';
|
newCollectionId = '';
|
||||||
|
newTemplateId = '';
|
||||||
showCreateForm = false;
|
showCreateForm = false;
|
||||||
await loadWebhooks();
|
await loadWebhooks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -151,6 +170,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSetTemplate(webhookId: string, templateId: string) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
actionLoading = `tmpl-${webhookId}`;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await setWebhookTemplate(accessToken, webhookId, templateId || null);
|
||||||
|
await loadWebhooks();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateName(templateId: string | null): string {
|
||||||
|
if (!templateId) return 'Ingen';
|
||||||
|
const tmpl = templates.find((t) => t.id === templateId);
|
||||||
|
return tmpl ? tmpl.name : templateId;
|
||||||
|
}
|
||||||
|
|
||||||
async function copyToken(token: string) {
|
async function copyToken(token: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(token);
|
await navigator.clipboard.writeText(token);
|
||||||
|
|
@ -241,6 +280,21 @@
|
||||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
|
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="wh-template" class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
|
Template (valgfritt)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="wh-template"
|
||||||
|
bind:value={newTemplateId}
|
||||||
|
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Ingen — generisk mapping</option>
|
||||||
|
{#each templates as tmpl (tmpl.id)}
|
||||||
|
<option value={tmpl.id}>{tmpl.name} — {tmpl.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="wh-collection" class="mb-1 block text-xs font-medium text-gray-600">
|
<label for="wh-collection" class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
Målsamling
|
Målsamling
|
||||||
|
|
@ -288,6 +342,22 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Template -->
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-400">Template:</span>
|
||||||
|
<select
|
||||||
|
value={wh.template_id || ''}
|
||||||
|
onchange={(e) => handleSetTemplate(wh.id, (e.target as HTMLSelectElement).value)}
|
||||||
|
disabled={actionLoading === `tmpl-${wh.id}`}
|
||||||
|
class="rounded border border-gray-200 px-1.5 py-0.5 text-xs text-gray-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{#each templates as tmpl (tmpl.id)}
|
||||||
|
<option value={tmpl.id}>{tmpl.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Token -->
|
<!-- Token -->
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
<code class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
<code class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ pub mod feed_poller;
|
||||||
pub mod orchestration_trigger;
|
pub mod orchestration_trigger;
|
||||||
mod webhook;
|
mod webhook;
|
||||||
mod webhook_admin;
|
mod webhook_admin;
|
||||||
|
mod webhook_templates;
|
||||||
pub mod script_compiler;
|
pub mod script_compiler;
|
||||||
pub mod script_executor;
|
pub mod script_executor;
|
||||||
pub mod tiptap;
|
pub mod tiptap;
|
||||||
|
|
@ -299,6 +300,8 @@ async fn main() {
|
||||||
.route("/admin/webhooks/create", post(webhook_admin::create_webhook))
|
.route("/admin/webhooks/create", post(webhook_admin::create_webhook))
|
||||||
.route("/admin/webhooks/regenerate_token", post(webhook_admin::regenerate_token))
|
.route("/admin/webhooks/regenerate_token", post(webhook_admin::regenerate_token))
|
||||||
.route("/admin/webhooks/delete", post(webhook_admin::delete_webhook))
|
.route("/admin/webhooks/delete", post(webhook_admin::delete_webhook))
|
||||||
|
.route("/admin/webhooks/templates", get(webhook_admin::list_templates))
|
||||||
|
.route("/admin/webhooks/set_template", post(webhook_admin::set_template))
|
||||||
// Webhook universell input (oppgave 29.4)
|
// Webhook universell input (oppgave 29.4)
|
||||||
.route("/api/webhook/{token}", post(webhook::handle_webhook))
|
.route("/api/webhook/{token}", post(webhook::handle_webhook))
|
||||||
// Observerbarhet (oppgave 12.1)
|
// Observerbarhet (oppgave 12.1)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use axum::{
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::webhook_templates;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -41,11 +42,12 @@ fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rad fra token-oppslag: webhook-nodens id og dens målsamling.
|
/// Rad fra token-oppslag: webhook-nodens id, målsamling og evt. template.
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct WebhookLookup {
|
struct WebhookLookup {
|
||||||
webhook_id: Uuid,
|
webhook_id: Uuid,
|
||||||
collection_id: Uuid,
|
collection_id: Uuid,
|
||||||
|
template_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/webhook/<token>
|
/// POST /api/webhook/<token>
|
||||||
|
|
@ -61,7 +63,8 @@ pub async fn handle_webhook(
|
||||||
// Token lagres i metadata.token på en webhook-node.
|
// Token lagres i metadata.token på en webhook-node.
|
||||||
// Målsamlingen finnes via belongs_to-edge fra webhook-noden.
|
// Målsamlingen finnes via belongs_to-edge fra webhook-noden.
|
||||||
let lookup = sqlx::query_as::<_, WebhookLookup>(
|
let lookup = sqlx::query_as::<_, WebhookLookup>(
|
||||||
r#"SELECT w.id AS webhook_id, e.target_id AS collection_id
|
r#"SELECT w.id AS webhook_id, e.target_id AS collection_id,
|
||||||
|
w.metadata->>'template_id' AS template_id
|
||||||
FROM nodes w
|
FROM nodes w
|
||||||
JOIN edges e ON e.source_id = w.id AND e.edge_type = 'belongs_to'
|
JOIN edges e ON e.source_id = w.id AND e.edge_type = 'belongs_to'
|
||||||
WHERE w.node_kind = 'webhook'
|
WHERE w.node_kind = 'webhook'
|
||||||
|
|
@ -85,28 +88,33 @@ pub async fn handle_webhook(
|
||||||
.map(|Json(v)| v)
|
.map(|Json(v)| v)
|
||||||
.unwrap_or(serde_json::json!({}));
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
|
||||||
// Trekk ut tittel fra payload hvis den har et "title"-felt
|
// -- Ekstraher title/content via template eller generisk --
|
||||||
let title = payload
|
let (title, content, extra_meta) = if let Some(ref tmpl_id) = lookup.template_id {
|
||||||
.get("title")
|
if let Some(result) = webhook_templates::apply_template(tmpl_id, &payload) {
|
||||||
.and_then(|v| v.as_str())
|
(result.title, result.content, Some(result.extra_metadata))
|
||||||
.unwrap_or("")
|
} else {
|
||||||
.to_string();
|
tracing::warn!(template_id = %tmpl_id, "Ukjent webhook-template, bruker generisk");
|
||||||
|
extract_generic(&payload)
|
||||||
// Trekk ut innhold fra payload hvis den har et "content" eller "body"-felt
|
}
|
||||||
let content = payload
|
} else {
|
||||||
.get("content")
|
extract_generic(&payload)
|
||||||
.or_else(|| payload.get("body"))
|
};
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// -- Opprett content-node --
|
// -- Opprett content-node --
|
||||||
let node_id = Uuid::now_v7();
|
let node_id = Uuid::now_v7();
|
||||||
let metadata = serde_json::json!({
|
let mut metadata = serde_json::json!({
|
||||||
"source": "webhook",
|
"source": "webhook",
|
||||||
"webhook_id": lookup.webhook_id.to_string(),
|
"webhook_id": lookup.webhook_id.to_string(),
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
});
|
});
|
||||||
|
// Merg inn ekstra metadata fra template
|
||||||
|
if let Some(extra) = extra_meta {
|
||||||
|
if let Some(obj) = extra.as_object() {
|
||||||
|
for (k, v) in obj {
|
||||||
|
metadata[k.clone()] = v.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
||||||
|
|
@ -161,3 +169,19 @@ pub async fn handle_webhook(
|
||||||
|
|
||||||
Ok(Json(WebhookResponse { node_id, edge_id }))
|
Ok(Json(WebhookResponse { node_id, edge_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generisk ekstraksjon: prøv title og content/body-felt.
|
||||||
|
fn extract_generic(payload: &serde_json::Value) -> (String, String, Option<serde_json::Value>) {
|
||||||
|
let title = payload
|
||||||
|
.get("title")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let content = payload
|
||||||
|
.get("content")
|
||||||
|
.or_else(|| payload.get("body"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
(title, content, None)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AdminUser;
|
use crate::auth::AdminUser;
|
||||||
|
use crate::webhook_templates;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -37,6 +38,7 @@ pub struct WebhookInfo {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub template_id: Option<String>,
|
||||||
pub collection_id: Uuid,
|
pub collection_id: Uuid,
|
||||||
pub collection_title: Option<String>,
|
pub collection_title: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
|
@ -71,6 +73,7 @@ pub async fn list_webhooks(
|
||||||
w.id,
|
w.id,
|
||||||
w.title,
|
w.title,
|
||||||
w.metadata->>'token' AS token,
|
w.metadata->>'token' AS token,
|
||||||
|
w.metadata->>'template_id' AS template_id,
|
||||||
e.target_id AS collection_id,
|
e.target_id AS collection_id,
|
||||||
c.title AS collection_title,
|
c.title AS collection_title,
|
||||||
w.created_at,
|
w.created_at,
|
||||||
|
|
@ -143,6 +146,7 @@ pub async fn webhook_events(
|
||||||
pub struct CreateWebhookRequest {
|
pub struct CreateWebhookRequest {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub collection_id: Uuid,
|
pub collection_id: Uuid,
|
||||||
|
pub template_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -169,13 +173,23 @@ pub async fn create_webhook(
|
||||||
return Err(bad_request("Samlingen finnes ikke"));
|
return Err(bad_request("Samlingen finnes ikke"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valider template_id hvis oppgitt
|
||||||
|
if let Some(ref tmpl_id) = req.template_id {
|
||||||
|
if webhook_templates::get_template(tmpl_id).is_none() {
|
||||||
|
return Err(bad_request(&format!("Ukjent template: {tmpl_id}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let webhook_id = Uuid::now_v7();
|
let webhook_id = Uuid::now_v7();
|
||||||
let token = Uuid::now_v7();
|
let token = Uuid::now_v7();
|
||||||
let title = req.title.unwrap_or_else(|| "Webhook".to_string());
|
let title = req.title.unwrap_or_else(|| "Webhook".to_string());
|
||||||
|
|
||||||
let metadata = serde_json::json!({
|
let mut metadata = serde_json::json!({
|
||||||
"token": token.to_string(),
|
"token": token.to_string(),
|
||||||
});
|
});
|
||||||
|
if let Some(ref tmpl_id) = req.template_id {
|
||||||
|
metadata["template_id"] = serde_json::Value::String(tmpl_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
// Opprett webhook-node
|
// Opprett webhook-node
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -294,3 +308,82 @@ pub async fn delete_webhook(
|
||||||
deleted: result.rows_affected() > 0,
|
deleted: result.rows_affected() > 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /admin/webhooks/templates — liste tilgjengelige templates
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
pub async fn list_templates(
|
||||||
|
_admin: AdminUser,
|
||||||
|
) -> Json<Vec<webhook_templates::TemplateInfo>> {
|
||||||
|
Json(webhook_templates::list_templates())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/webhooks/set_template — sett eller fjern template på webhook
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetTemplateRequest {
|
||||||
|
pub webhook_id: Uuid,
|
||||||
|
/// None eller tom streng fjerner template.
|
||||||
|
pub template_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SetTemplateResponse {
|
||||||
|
pub template_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_template(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: AdminUser,
|
||||||
|
Json(req): Json<SetTemplateRequest>,
|
||||||
|
) -> Result<Json<SetTemplateResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let tmpl_id = req.template_id.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
// Valider template_id
|
||||||
|
if let Some(ref id) = tmpl_id {
|
||||||
|
if webhook_templates::get_template(id).is_none() {
|
||||||
|
return Err(bad_request(&format!("Ukjent template: {id}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oppdater metadata: sett eller fjern template_id
|
||||||
|
let (query, effective_id) = if let Some(ref id) = tmpl_id {
|
||||||
|
(
|
||||||
|
r#"UPDATE nodes
|
||||||
|
SET metadata = jsonb_set(metadata, '{template_id}', to_jsonb($1::text)),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $2 AND node_kind = 'webhook'"#,
|
||||||
|
id.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
r#"UPDATE nodes
|
||||||
|
SET metadata = metadata - 'template_id',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $2 AND node_kind = 'webhook'"#,
|
||||||
|
String::new(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = sqlx::query(query)
|
||||||
|
.bind(&effective_id)
|
||||||
|
.bind(req.webhook_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved oppdatering av template: {e}")))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(bad_request("Webhook finnes ikke"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
webhook_id = %req.webhook_id,
|
||||||
|
template_id = ?tmpl_id,
|
||||||
|
"Webhook-template oppdatert"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(SetTemplateResponse { template_id: tmpl_id }))
|
||||||
|
}
|
||||||
|
|
|
||||||
447
maskinrommet/src/webhook_templates.rs
Normal file
447
maskinrommet/src/webhook_templates.rs
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
// Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (oppgave 29.6)
|
||||||
|
//
|
||||||
|
// Hver template vet hvordan den skal ekstrahere title, content og metadata
|
||||||
|
// fra en innkommende JSON-payload. Templates er hardkodet — de dekker kjente
|
||||||
|
// tjenester (GitHub, Slack, CI/CD). Webhook-noder kan ha en template_id i
|
||||||
|
// metadata som styrer hvilken template som brukes ved mottak.
|
||||||
|
//
|
||||||
|
// Hvis ingen template er satt, brukes den generiske ekstraksjonen fra
|
||||||
|
// webhook.rs (title, content/body-felt).
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Resultat av å anvende en template på en payload.
|
||||||
|
pub struct TemplateResult {
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
/// Ekstra metadata-felt som legges til node-metadata (utover source/webhook_id/payload).
|
||||||
|
pub extra_metadata: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// En forhåndsdefinert template-definisjon.
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct TemplateInfo {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub service: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle tilgjengelige templates.
|
||||||
|
pub fn list_templates() -> Vec<TemplateInfo> {
|
||||||
|
vec![
|
||||||
|
TemplateInfo {
|
||||||
|
id: "github-push",
|
||||||
|
name: "GitHub Push",
|
||||||
|
description: "Commits pushet til et repository",
|
||||||
|
service: "GitHub",
|
||||||
|
},
|
||||||
|
TemplateInfo {
|
||||||
|
id: "github-issues",
|
||||||
|
name: "GitHub Issues",
|
||||||
|
description: "Issue opprettet, endret eller lukket",
|
||||||
|
service: "GitHub",
|
||||||
|
},
|
||||||
|
TemplateInfo {
|
||||||
|
id: "github-pull-request",
|
||||||
|
name: "GitHub Pull Request",
|
||||||
|
description: "PR opprettet, merget eller lukket",
|
||||||
|
service: "GitHub",
|
||||||
|
},
|
||||||
|
TemplateInfo {
|
||||||
|
id: "slack-message",
|
||||||
|
name: "Slack-melding",
|
||||||
|
description: "Melding sendt i en Slack-kanal",
|
||||||
|
service: "Slack",
|
||||||
|
},
|
||||||
|
TemplateInfo {
|
||||||
|
id: "ci-build",
|
||||||
|
name: "CI/CD Build",
|
||||||
|
description: "Byggstatus fra CI/CD-pipeline (GitLab CI, GitHub Actions, Jenkins)",
|
||||||
|
service: "CI/CD",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finn en template basert på id.
|
||||||
|
pub fn get_template(id: &str) -> Option<TemplateInfo> {
|
||||||
|
list_templates().into_iter().find(|t| t.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anvend en template på en payload. Returnerer None hvis template_id er ukjent.
|
||||||
|
pub fn apply_template(template_id: &str, payload: &serde_json::Value) -> Option<TemplateResult> {
|
||||||
|
match template_id {
|
||||||
|
"github-push" => Some(apply_github_push(payload)),
|
||||||
|
"github-issues" => Some(apply_github_issues(payload)),
|
||||||
|
"github-pull-request" => Some(apply_github_pull_request(payload)),
|
||||||
|
"slack-message" => Some(apply_slack_message(payload)),
|
||||||
|
"ci-build" => Some(apply_ci_build(payload)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GitHub Push
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn apply_github_push(p: &serde_json::Value) -> TemplateResult {
|
||||||
|
let repo = json_str(p, &["repository", "full_name"])
|
||||||
|
.or_else(|| json_str(p, &["repository", "name"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let git_ref = json_str(p, &["ref"]).unwrap_or_default();
|
||||||
|
// ref er typisk "refs/heads/main" — vis bare branch-navnet
|
||||||
|
let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(&git_ref);
|
||||||
|
let pusher = json_str(p, &["pusher", "name"])
|
||||||
|
.or_else(|| json_str(p, &["sender", "login"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Samle commit-meldinger
|
||||||
|
let commits = p.get("commits").and_then(|c| c.as_array());
|
||||||
|
let commit_count = commits.map(|c| c.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
let title = if repo.is_empty() {
|
||||||
|
format!("Push: {commit_count} commit(s)")
|
||||||
|
} else {
|
||||||
|
format!("[{repo}] Push til {branch} — {commit_count} commit(s)")
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = if let Some(commits) = commits {
|
||||||
|
commits
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| {
|
||||||
|
let sha = c.get("id").and_then(|v| v.as_str()).unwrap_or("").get(..7).unwrap_or("");
|
||||||
|
let msg = c.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if msg.is_empty() { None } else { Some(format!("- `{sha}` {msg}")) }
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let extra = serde_json::json!({
|
||||||
|
"template": "github-push",
|
||||||
|
"repo": repo,
|
||||||
|
"branch": branch,
|
||||||
|
"pusher": pusher,
|
||||||
|
"commit_count": commit_count,
|
||||||
|
});
|
||||||
|
|
||||||
|
TemplateResult { title, content, extra_metadata: extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GitHub Issues
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn apply_github_issues(p: &serde_json::Value) -> TemplateResult {
|
||||||
|
let action = json_str(p, &["action"]).unwrap_or_default();
|
||||||
|
let repo = json_str(p, &["repository", "full_name"]).unwrap_or_default();
|
||||||
|
let number = p.get("issue").and_then(|i| i.get("number")).and_then(|n| n.as_i64()).unwrap_or(0);
|
||||||
|
let issue_title = json_str(p, &["issue", "title"]).unwrap_or_default();
|
||||||
|
let body = json_str(p, &["issue", "body"]).unwrap_or_default();
|
||||||
|
let user = json_str(p, &["issue", "user", "login"])
|
||||||
|
.or_else(|| json_str(p, &["sender", "login"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let state = json_str(p, &["issue", "state"]).unwrap_or_default();
|
||||||
|
|
||||||
|
let labels: Vec<String> = p
|
||||||
|
.get("issue")
|
||||||
|
.and_then(|i| i.get("labels"))
|
||||||
|
.and_then(|l| l.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|l| l.get("name").and_then(|n| n.as_str()).map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let title = if repo.is_empty() {
|
||||||
|
format!("Issue #{number}: {issue_title}")
|
||||||
|
} else {
|
||||||
|
format!("[{repo}] Issue #{number} {action}: {issue_title}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let extra = serde_json::json!({
|
||||||
|
"template": "github-issues",
|
||||||
|
"action": action,
|
||||||
|
"repo": repo,
|
||||||
|
"issue_number": number,
|
||||||
|
"user": user,
|
||||||
|
"state": state,
|
||||||
|
"labels": labels,
|
||||||
|
});
|
||||||
|
|
||||||
|
TemplateResult { title, content: body, extra_metadata: extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GitHub Pull Request
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn apply_github_pull_request(p: &serde_json::Value) -> TemplateResult {
|
||||||
|
let action = json_str(p, &["action"]).unwrap_or_default();
|
||||||
|
let repo = json_str(p, &["repository", "full_name"]).unwrap_or_default();
|
||||||
|
let number = p.get("pull_request").and_then(|pr| pr.get("number")).and_then(|n| n.as_i64()).unwrap_or(0);
|
||||||
|
let pr_title = json_str(p, &["pull_request", "title"]).unwrap_or_default();
|
||||||
|
let body = json_str(p, &["pull_request", "body"]).unwrap_or_default();
|
||||||
|
let user = json_str(p, &["pull_request", "user", "login"])
|
||||||
|
.or_else(|| json_str(p, &["sender", "login"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let state = json_str(p, &["pull_request", "state"]).unwrap_or_default();
|
||||||
|
let merged = p.get("pull_request").and_then(|pr| pr.get("merged")).and_then(|m| m.as_bool()).unwrap_or(false);
|
||||||
|
let base = json_str(p, &["pull_request", "base", "ref"]).unwrap_or_default();
|
||||||
|
let head = json_str(p, &["pull_request", "head", "ref"]).unwrap_or_default();
|
||||||
|
|
||||||
|
let title = if repo.is_empty() {
|
||||||
|
format!("PR #{number} {action}: {pr_title}")
|
||||||
|
} else {
|
||||||
|
format!("[{repo}] PR #{number} {action}: {pr_title}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let extra = serde_json::json!({
|
||||||
|
"template": "github-pull-request",
|
||||||
|
"action": action,
|
||||||
|
"repo": repo,
|
||||||
|
"pr_number": number,
|
||||||
|
"user": user,
|
||||||
|
"state": state,
|
||||||
|
"merged": merged,
|
||||||
|
"base_branch": base,
|
||||||
|
"head_branch": head,
|
||||||
|
});
|
||||||
|
|
||||||
|
TemplateResult { title, content: body, extra_metadata: extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Slack Message
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn apply_slack_message(p: &serde_json::Value) -> TemplateResult {
|
||||||
|
// Slack Event API sender event.type = "message" inne i en event-wrapper
|
||||||
|
let event = p.get("event").unwrap_or(p);
|
||||||
|
|
||||||
|
let channel = json_str(event, &["channel"])
|
||||||
|
.or_else(|| json_str(p, &["channel_name"]))
|
||||||
|
.or_else(|| json_str(p, &["channel"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let user = json_str(event, &["user"])
|
||||||
|
.or_else(|| json_str(event, &["username"]))
|
||||||
|
.or_else(|| json_str(p, &["user_name"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let text = json_str(event, &["text"])
|
||||||
|
.or_else(|| json_str(p, &["text"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let team = json_str(p, &["team_id"])
|
||||||
|
.or_else(|| json_str(event, &["team"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let ts = json_str(event, &["ts"])
|
||||||
|
.or_else(|| json_str(p, &["message_ts"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let title = if channel.is_empty() {
|
||||||
|
format!("Slack: {user}")
|
||||||
|
} else {
|
||||||
|
format!("Slack #{channel} — {user}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let extra = serde_json::json!({
|
||||||
|
"template": "slack-message",
|
||||||
|
"channel": channel,
|
||||||
|
"user": user,
|
||||||
|
"team": team,
|
||||||
|
"ts": ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
TemplateResult { title, content: text, extra_metadata: extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CI/CD Build Status (generisk — dekker GitHub Actions, GitLab CI, Jenkins)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn apply_ci_build(p: &serde_json::Value) -> TemplateResult {
|
||||||
|
// Prøv flere vanlige CI-payload-formater
|
||||||
|
|
||||||
|
// GitHub Actions: check_run / workflow_run
|
||||||
|
let status = json_str(p, &["check_run", "conclusion"])
|
||||||
|
.or_else(|| json_str(p, &["workflow_run", "conclusion"]))
|
||||||
|
.or_else(|| json_str(p, &["build", "status"]))
|
||||||
|
.or_else(|| json_str(p, &["object_attributes", "status"])) // GitLab
|
||||||
|
.or_else(|| json_str(p, &["status"]))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let pipeline = json_str(p, &["workflow_run", "name"])
|
||||||
|
.or_else(|| json_str(p, &["check_run", "name"]))
|
||||||
|
.or_else(|| json_str(p, &["object_attributes", "name"])) // GitLab
|
||||||
|
.or_else(|| json_str(p, &["build", "full_display_name"])) // Jenkins
|
||||||
|
.or_else(|| json_str(p, &["name"]))
|
||||||
|
.unwrap_or_else(|| "Build".to_string());
|
||||||
|
|
||||||
|
let repo = json_str(p, &["repository", "full_name"])
|
||||||
|
.or_else(|| json_str(p, &["project", "path_with_namespace"])) // GitLab
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let url = json_str(p, &["workflow_run", "html_url"])
|
||||||
|
.or_else(|| json_str(p, &["check_run", "html_url"]))
|
||||||
|
.or_else(|| json_str(p, &["build", "url"])) // Jenkins
|
||||||
|
.or_else(|| json_str(p, &["object_attributes", "url"])) // GitLab
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let duration = p.get("workflow_run")
|
||||||
|
.and_then(|wr| wr.get("run_started_at"))
|
||||||
|
.and_then(|_| p.get("workflow_run").and_then(|wr| wr.get("updated_at")))
|
||||||
|
.map(|_| "se payload".to_string())
|
||||||
|
.or_else(|| json_str(p, &["object_attributes", "duration"]).map(|d| format!("{d}s")))
|
||||||
|
.or_else(|| json_str(p, &["build", "duration"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let branch = json_str(p, &["workflow_run", "head_branch"])
|
||||||
|
.or_else(|| json_str(p, &["check_run", "check_suite", "head_branch"]))
|
||||||
|
.or_else(|| json_str(p, &["object_attributes", "ref"])) // GitLab
|
||||||
|
.or_else(|| json_str(p, &["ref"]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let status_emoji = match status.as_str() {
|
||||||
|
"success" => "OK",
|
||||||
|
"failure" | "failed" => "FEIL",
|
||||||
|
"cancelled" | "canceled" => "AVBRUTT",
|
||||||
|
"pending" | "running" | "in_progress" => "KJØRER",
|
||||||
|
_ => &status,
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = if repo.is_empty() {
|
||||||
|
format!("Build {status_emoji}: {pipeline}")
|
||||||
|
} else {
|
||||||
|
format!("[{repo}] Build {status_emoji}: {pipeline}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut content_parts: Vec<String> = Vec::new();
|
||||||
|
if !branch.is_empty() {
|
||||||
|
content_parts.push(format!("Branch: {branch}"));
|
||||||
|
}
|
||||||
|
if !duration.is_empty() {
|
||||||
|
content_parts.push(format!("Varighet: {duration}"));
|
||||||
|
}
|
||||||
|
if !url.is_empty() {
|
||||||
|
content_parts.push(format!("Lenke: {url}"));
|
||||||
|
}
|
||||||
|
let content = content_parts.join("\n");
|
||||||
|
|
||||||
|
let extra = serde_json::json!({
|
||||||
|
"template": "ci-build",
|
||||||
|
"status": status,
|
||||||
|
"pipeline": pipeline,
|
||||||
|
"repo": repo,
|
||||||
|
"branch": branch,
|
||||||
|
"url": url,
|
||||||
|
});
|
||||||
|
|
||||||
|
TemplateResult { title, content, extra_metadata: extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Hjelpefunksjon: traverser nestet JSON med nøkkel-path
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn json_str(value: &serde_json::Value, path: &[&str]) -> Option<String> {
|
||||||
|
let mut current = value;
|
||||||
|
for key in path {
|
||||||
|
current = current.get(*key)?;
|
||||||
|
}
|
||||||
|
current.as_str().map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_github_push_template() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"repository": { "full_name": "vegard/synops", "name": "synops" },
|
||||||
|
"pusher": { "name": "vegard" },
|
||||||
|
"commits": [
|
||||||
|
{ "id": "abc1234567890", "message": "Fix bug" },
|
||||||
|
{ "id": "def4567890123", "message": "Add feature" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let result = apply_template("github-push", &payload).unwrap();
|
||||||
|
assert!(result.title.contains("vegard/synops"));
|
||||||
|
assert!(result.title.contains("main"));
|
||||||
|
assert!(result.title.contains("2 commit(s)"));
|
||||||
|
assert!(result.content.contains("Fix bug"));
|
||||||
|
assert!(result.content.contains("Add feature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_github_issues_template() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"action": "opened",
|
||||||
|
"repository": { "full_name": "vegard/synops" },
|
||||||
|
"issue": {
|
||||||
|
"number": 42,
|
||||||
|
"title": "Something is broken",
|
||||||
|
"body": "Details about the bug",
|
||||||
|
"user": { "login": "vegard" },
|
||||||
|
"state": "open",
|
||||||
|
"labels": [{ "name": "bug" }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let result = apply_template("github-issues", &payload).unwrap();
|
||||||
|
assert!(result.title.contains("#42"));
|
||||||
|
assert!(result.title.contains("opened"));
|
||||||
|
assert_eq!(result.content, "Details about the bug");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slack_message_template() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"event": {
|
||||||
|
"type": "message",
|
||||||
|
"channel": "general",
|
||||||
|
"user": "U123",
|
||||||
|
"text": "Hello world",
|
||||||
|
"ts": "1234567890.123456"
|
||||||
|
},
|
||||||
|
"team_id": "T123"
|
||||||
|
});
|
||||||
|
let result = apply_template("slack-message", &payload).unwrap();
|
||||||
|
assert!(result.title.contains("general"));
|
||||||
|
assert_eq!(result.content, "Hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ci_build_template() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"workflow_run": {
|
||||||
|
"name": "CI",
|
||||||
|
"conclusion": "success",
|
||||||
|
"head_branch": "main",
|
||||||
|
"html_url": "https://github.com/vegard/synops/actions/runs/123"
|
||||||
|
},
|
||||||
|
"repository": { "full_name": "vegard/synops" }
|
||||||
|
});
|
||||||
|
let result = apply_template("ci-build", &payload).unwrap();
|
||||||
|
assert!(result.title.contains("OK"));
|
||||||
|
assert!(result.title.contains("CI"));
|
||||||
|
assert!(result.content.contains("main"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_template_returns_none() {
|
||||||
|
let payload = serde_json::json!({});
|
||||||
|
assert!(apply_template("unknown", &payload).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_templates() {
|
||||||
|
let templates = list_templates();
|
||||||
|
assert_eq!(templates.len(), 5);
|
||||||
|
assert!(templates.iter().any(|t| t.id == "github-push"));
|
||||||
|
assert!(templates.iter().any(|t| t.id == "slack-message"));
|
||||||
|
assert!(templates.iter().any(|t| t.id == "ci-build"));
|
||||||
|
}
|
||||||
|
}
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -399,8 +399,7 @@ noden er det som lever videre.
|
||||||
### Webhook (universell ekstern input)
|
### Webhook (universell ekstern input)
|
||||||
- [x] 29.4 Webhook-endepunkt i vaktmesteren: `POST /api/webhook/<token>` → opprett node fra JSON-body. Hvert webhook har et unikt token (UUID) knyttet til en `webhook`-node med `belongs_to`-edge til målsamling. Validerer token, oppretter `content`-node med payload i metadata.
|
- [x] 29.4 Webhook-endepunkt i vaktmesteren: `POST /api/webhook/<token>` → opprett node fra JSON-body. Hvert webhook har et unikt token (UUID) knyttet til en `webhook`-node med `belongs_to`-edge til målsamling. Validerer token, oppretter `content`-node med payload i metadata.
|
||||||
- [x] 29.5 Webhook-admin: UI for å opprette/administrere webhooks. Vis token, mål-samling, siste hendelser, aktivitet-logg. Regenerer token. Deaktiver/slett.
|
- [x] 29.5 Webhook-admin: UI for å opprette/administrere webhooks. Vis token, mål-samling, siste hendelser, aktivitet-logg. Regenerer token. Deaktiver/slett.
|
||||||
- [~] 29.6 Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (GitHub → commits/issues, Slack → meldinger, CI/CD → build-status). Template mapper JSON-felt til node title/content/metadata.
|
- [x] 29.6 Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (GitHub → commits/issues, Slack → meldinger, CI/CD → build-status). Template mapper JSON-felt til node title/content/metadata.
|
||||||
> Påbegynt: 2026-03-18T22:00
|
|
||||||
|
|
||||||
### Video
|
### Video
|
||||||
- [ ] 29.7 Video-opptak i frontend: webcam/skjermopptak via MediaRecorder API → upload til CAS → media-node. Start/stopp-knapp i input-komponenten. Maks varighet konfigurerbar.
|
- [ ] 29.7 Video-opptak i frontend: webcam/skjermopptak via MediaRecorder API → upload til CAS → media-node. Start/stopp-knapp i input-komponenten. Maks varighet konfigurerbar.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue