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:
vegard 2026-03-18 22:10:33 +00:00
parent af014fc883
commit a3dfa3b254
8 changed files with 701 additions and 30 deletions

View file

@ -62,13 +62,16 @@ POST /api/webhook/<token>
→ belongs_to-edge til målsamling
```
Webhook-templates for kjente tjenester:
- **GitHub:** commits, issues, PR-er → noder med riktig tittel/innhold
- **Slack:** meldinger → innholdsnoder (bridge)
- **CI/CD:** build-status → noder med status-edge
Webhook-templates (forhåndsdefinerte mappinger) for kjente tjenester:
- **GitHub Push:** `[repo] Push til branch — N commit(s)` med formaterte commit-meldinger
- **GitHub Issues:** `[repo] Issue #N action: title` med labels og state
- **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
valgfri template for JSON→node-mapping.
Template-id lagres i webhook-nodens metadata (`metadata.template_id`).
Admin-API: `GET /admin/webhooks/templates` (liste), `POST /admin/webhooks/set_template`.
Uten template brukes generisk ekstraksjon (title/content/body-felt).
#### Video
Opptak direkte i nettleseren.

View file

@ -1432,6 +1432,7 @@ export interface WebhookInfo {
id: string;
title: string | null;
token: string;
template_id: string | null;
collection_id: string;
collection_title: string | null;
created_at: string;
@ -1439,6 +1440,13 @@ export interface WebhookInfo {
last_event_at: string | null;
}
export interface WebhookTemplateInfo {
id: string;
name: string;
description: string;
service: string;
}
export interface WebhookEvent {
node_id: string;
title: string | null;
@ -1492,7 +1500,7 @@ export async function fetchWebhookEvents(
/** Opprett ny webhook for en samling. */
export function createWebhook(
accessToken: string,
data: { title?: string; collection_id: string }
data: { title?: string; collection_id: string; template_id?: string }
): Promise<CreateWebhookResponse> {
return post(accessToken, '/admin/webhooks/create', data);
}
@ -1513,6 +1521,30 @@ export function deleteWebhook(
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(
accessToken: string,
roomId: string,

View file

@ -9,11 +9,14 @@
import {
fetchWebhooks,
fetchWebhookEvents,
fetchWebhookTemplates,
createWebhook,
regenerateWebhookToken,
deleteWebhook,
setWebhookTemplate,
type WebhookInfo,
type WebhookEvent
type WebhookEvent,
type WebhookTemplateInfo
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
@ -24,10 +27,14 @@
let error = $state<string | null>(null);
let actionLoading = $state<string | null>(null);
// Templates
let templates = $state<WebhookTemplateInfo[]>([]);
// Opprett-skjema
let showCreateForm = $state(false);
let newTitle = $state('');
let newCollectionId = $state('');
let newTemplateId = $state('');
let collections = $state<{ id: string; title: string | null }[]>([]);
// Hendelser per webhook (utfelt)
@ -38,10 +45,11 @@
// Kopier-feedback
let copiedToken = $state<string | null>(null);
// Poll webhooks every 10 seconds
// Poll webhooks every 10 seconds + load templates once
$effect(() => {
if (!accessToken) return;
loadWebhooks();
loadTemplates();
const interval = setInterval(loadWebhooks, 10000);
return () => clearInterval(interval);
});
@ -57,6 +65,15 @@
}
}
async function loadTemplates() {
if (!accessToken) return;
try {
templates = await fetchWebhookTemplates(accessToken);
} catch {
// Ikke kritisk
}
}
async function loadCollections() {
if (!accessToken) return;
try {
@ -89,10 +106,12 @@
try {
await createWebhook(accessToken, {
title: newTitle || undefined,
collection_id: newCollectionId
collection_id: newCollectionId,
template_id: newTemplateId || undefined
});
newTitle = '';
newCollectionId = '';
newTemplateId = '';
showCreateForm = false;
await loadWebhooks();
} 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) {
try {
await navigator.clipboard.writeText(token);
@ -241,6 +280,21 @@
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</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>
<label for="wh-collection" class="mb-1 block text-xs font-medium text-gray-600">
Målsamling
@ -288,6 +342,22 @@
</span>
</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 -->
<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">

View file

@ -32,6 +32,7 @@ pub mod feed_poller;
pub mod orchestration_trigger;
mod webhook;
mod webhook_admin;
mod webhook_templates;
pub mod script_compiler;
pub mod script_executor;
pub mod tiptap;
@ -299,6 +300,8 @@ async fn main() {
.route("/admin/webhooks/create", post(webhook_admin::create_webhook))
.route("/admin/webhooks/regenerate_token", post(webhook_admin::regenerate_token))
.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)
.route("/api/webhook/{token}", post(webhook::handle_webhook))
// Observerbarhet (oppgave 12.1)

View file

@ -14,6 +14,7 @@ use axum::{
use serde::Serialize;
use uuid::Uuid;
use crate::webhook_templates;
use crate::AppState;
#[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)]
struct WebhookLookup {
webhook_id: Uuid,
collection_id: Uuid,
template_id: Option<String>,
}
/// POST /api/webhook/<token>
@ -61,7 +63,8 @@ pub async fn handle_webhook(
// Token lagres i metadata.token på en webhook-node.
// Målsamlingen finnes via belongs_to-edge fra webhook-noden.
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
JOIN edges e ON e.source_id = w.id AND e.edge_type = 'belongs_to'
WHERE w.node_kind = 'webhook'
@ -85,28 +88,33 @@ pub async fn handle_webhook(
.map(|Json(v)| v)
.unwrap_or(serde_json::json!({}));
// Trekk ut tittel fra payload hvis den har et "title"-felt
let title = payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Trekk ut innhold fra payload hvis den har et "content" eller "body"-felt
let content = payload
.get("content")
.or_else(|| payload.get("body"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// -- Ekstraher title/content via template eller generisk --
let (title, content, extra_meta) = if let Some(ref tmpl_id) = lookup.template_id {
if let Some(result) = webhook_templates::apply_template(tmpl_id, &payload) {
(result.title, result.content, Some(result.extra_metadata))
} else {
tracing::warn!(template_id = %tmpl_id, "Ukjent webhook-template, bruker generisk");
extract_generic(&payload)
}
} else {
extract_generic(&payload)
};
// -- Opprett content-node --
let node_id = Uuid::now_v7();
let metadata = serde_json::json!({
let mut metadata = serde_json::json!({
"source": "webhook",
"webhook_id": lookup.webhook_id.to_string(),
"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(
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 }))
}
/// 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)
}

View file

@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::AdminUser;
use crate::webhook_templates;
use crate::AppState;
// =============================================================================
@ -37,6 +38,7 @@ pub struct WebhookInfo {
pub id: Uuid,
pub title: Option<String>,
pub token: String,
pub template_id: Option<String>,
pub collection_id: Uuid,
pub collection_title: Option<String>,
pub created_at: DateTime<Utc>,
@ -71,6 +73,7 @@ pub async fn list_webhooks(
w.id,
w.title,
w.metadata->>'token' AS token,
w.metadata->>'template_id' AS template_id,
e.target_id AS collection_id,
c.title AS collection_title,
w.created_at,
@ -143,6 +146,7 @@ pub async fn webhook_events(
pub struct CreateWebhookRequest {
pub title: Option<String>,
pub collection_id: Uuid,
pub template_id: Option<String>,
}
#[derive(Serialize)]
@ -169,13 +173,23 @@ pub async fn create_webhook(
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 token = Uuid::now_v7();
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(),
});
if let Some(ref tmpl_id) = req.template_id {
metadata["template_id"] = serde_json::Value::String(tmpl_id.clone());
}
// Opprett webhook-node
sqlx::query(
@ -294,3 +308,82 @@ pub async fn delete_webhook(
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 }))
}

View 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"));
}
}

View file

@ -399,8 +399,7 @@ noden er det som lever videre.
### 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.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.
> Påbegynt: 2026-03-18T22:00
- [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.
### 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.