diff --git a/docs/features/universell_input.md b/docs/features/universell_input.md index 1316c21..7a0887c 100644 --- a/docs/features/universell_input.md +++ b/docs/features/universell_input.md @@ -62,13 +62,16 @@ POST /api/webhook/ → 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. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7ff3615..2d2f313 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { 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 { + 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, diff --git a/frontend/src/routes/admin/webhooks/+page.svelte b/frontend/src/routes/admin/webhooks/+page.svelte index 7fbe4f7..1668e98 100644 --- a/frontend/src/routes/admin/webhooks/+page.svelte +++ b/frontend/src/routes/admin/webhooks/+page.svelte @@ -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 | undefined); @@ -24,10 +27,14 @@ let error = $state(null); let actionLoading = $state(null); + // Templates + let templates = $state([]); + // 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(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" /> +
+ + +
+ +
+ Template: + +
+
diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index d739bda..7e0e9a0 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -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) diff --git a/maskinrommet/src/webhook.rs b/maskinrommet/src/webhook.rs index 80a91cc..4738ca1 100644 --- a/maskinrommet/src/webhook.rs +++ b/maskinrommet/src/webhook.rs @@ -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) { ) } -/// 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, } /// POST /api/webhook/ @@ -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) { + 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) +} diff --git a/maskinrommet/src/webhook_admin.rs b/maskinrommet/src/webhook_admin.rs index fb88dfd..b1f1dc7 100644 --- a/maskinrommet/src/webhook_admin.rs +++ b/maskinrommet/src/webhook_admin.rs @@ -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, pub token: String, + pub template_id: Option, pub collection_id: Uuid, pub collection_title: Option, pub created_at: DateTime, @@ -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, pub collection_id: Uuid, + pub template_id: Option, } #[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> { + 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, +} + +#[derive(Serialize)] +pub struct SetTemplateResponse { + pub template_id: Option, +} + +pub async fn set_template( + State(state): State, + _admin: AdminUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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 })) +} diff --git a/maskinrommet/src/webhook_templates.rs b/maskinrommet/src/webhook_templates.rs new file mode 100644 index 0000000..44dcc57 --- /dev/null +++ b/maskinrommet/src/webhook_templates.rs @@ -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 { + 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 { + 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 { + 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::>() + .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 = 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 = 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 { + 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")); + } +} diff --git a/tasks.md b/tasks.md index eb70e7d..34f0b09 100644 --- a/tasks.md +++ b/tasks.md @@ -399,8 +399,7 @@ noden er det som lever videre. ### Webhook (universell ekstern input) - [x] 29.4 Webhook-endepunkt i vaktmesteren: `POST /api/webhook/` → 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.