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