synops/maskinrommet/src/webhook_templates.rs
vegard a3dfa3b254 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.
2026-03-18 22:10:33 +00:00

447 lines
16 KiB
Rust

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