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.
447 lines
16 KiB
Rust
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"));
|
|
}
|
|
}
|