//! JSON payload builders for webhook deliveries. Each builder returns the event-specific fields. //! `emit` adds `action` (when the event has one), `repository` and `sender`. //! //! The shapes follow docs/webhooks-interfaces.md. Links are built from `base_url` (the configured //! public URL), so they point at the instance a receiver can reach. use forge_core::{Comment, Issue, PullRequest, Repo, Review, Visibility}; use forge_git::browse::CommitInfo; use serde_json::{Value, json}; /// Most commits listed in a push payload. `total_commits` still counts all of them. pub const MAX_PAYLOAD_COMMITS: usize = 20; /// `base_url` without its trailing slash, so joined paths have exactly one separator. fn base(base_url: &str) -> &str { base_url.trim_end_matches('/') } /// `{base}/{owner}/{name}`. fn repo_url(base_url: &str, repo: &Repo) -> String { format!("{}/{}/{}", base(base_url), repo.owner_name, repo.name) } /// The page of an issue or pull request: `issues` or `pulls`. fn item_url(base_url: &str, repo: &Repo, segment: &str, number: i64) -> String { format!("{}/{segment}/{number}", repo_url(base_url, repo)) } /// The repository object carried by every payload. `owner` stays the owner's name; `owner_kind` is /// `"user"` or `"org"`. pub fn repository(base_url: &str, repo: &Repo, owner_kind: &str) -> Value { let url = repo_url(base_url, repo); json!({ "id": repo.id, "owner": repo.owner_name, "owner_kind": owner_kind, "name": repo.name, "full_name": format!("{}/{}", repo.owner_name, repo.name), "private": repo.visibility == Visibility::Private, "html_url": url, "clone_url": format!("{url}.git"), }) } /// The sender object, or `null` for a system change (no signed-in user). pub fn sender(id: &str, username: &str) -> Value { if id.is_empty() { Value::Null } else { json!({ "id": id, "username": username }) } } /// `issues` payload: `issue`. The emit side adds `label` or `assignee` for those actions. pub fn issues(base_url: &str, repo: &Repo, issue: &Issue) -> Value { json!({ "issue": issue_object(base_url, repo, issue, false) }) } /// `pull_request` payload: `number` and `pull_request`. pub fn pull_request(base_url: &str, repo: &Repo, pr: &PullRequest) -> Value { json!({ "number": pr.issue.number, "pull_request": pull_object(base_url, repo, pr), }) } /// `issue_comment` payload: the item (`issue`, which is a pull request when `pull` is true) and /// the `comment`. pub fn issue_comment( base_url: &str, repo: &Repo, issue: &Issue, pull: bool, comment: &Comment, ) -> Value { let page = item_url( base_url, repo, if pull { "pulls" } else { "issues" }, issue.number, ); json!({ "issue": issue_object(base_url, repo, issue, pull), "comment": { "id": comment.id, "body": comment.body, "html_url": format!("{page}#comment-{}", comment.id), }, }) } /// `pull_request_review` payload: `review` and the `pull_request`. pub fn pull_request_review( base_url: &str, repo: &Repo, pr: &PullRequest, review: &Review, ) -> Value { json!({ "review": { "id": review.id, "state": review.state.as_str(), "body": review.body, }, "pull_request": pull_object(base_url, repo, pr), }) } /// `push` payload. `commits` is the new commits, oldest first; at most [`MAX_PAYLOAD_COMMITS`] /// are listed, and `total` is the number found. pub fn push( base_url: &str, repo: &Repo, refname: &str, before: &str, after: &str, commits: &[CommitInfo], total: usize, ) -> Value { let url = repo_url(base_url, repo); let listed: Vec = commits .iter() .take(MAX_PAYLOAD_COMMITS) .map(|commit| { json!({ "id": commit.oid, "message": commit.message, "author": { "name": commit.author_name, "email": commit.author_email }, "timestamp": timestamp(commit.author_time), "url": format!("{url}/commit/{}", commit.oid), }) }) .collect(); json!({ "ref": refname, "before": before, "after": after, "commits": listed, "total_commits": total, "compare_url": format!("{url}/compare/{before}...{after}"), }) } /// `create` and `delete` payload: the short branch or tag name and its kind. pub fn ref_change(ref_type: &str, name: &str) -> Value { json!({ "ref": name, "ref_type": ref_type }) } /// `ping` payload, sent when a hook is created or tested. pub fn ping(hook_id: &str) -> Value { json!({ "zen": "Forge keeps the repository close.", "hook_id": hook_id, }) } fn issue_object(base_url: &str, repo: &Repo, issue: &Issue, pull: bool) -> Value { json!({ "number": issue.number, "title": issue.title, "body": issue.body, "state": issue.state.as_str(), "html_url": item_url( base_url, repo, if pull { "pulls" } else { "issues" }, issue.number, ), }) } fn pull_object(base_url: &str, repo: &Repo, pr: &PullRequest) -> Value { json!({ "title": pr.issue.title, "body": pr.issue.body, "state": pr.issue.state.as_str(), "merged": pr.merged_at.is_some(), "base": { "ref": pr.base_branch, "sha": pr.base_oid }, "head": { "ref": pr.head_branch, "sha": pr.head_oid }, "html_url": item_url(base_url, repo, "pulls", pr.issue.number), }) } /// RFC 3339 for a Unix time, or an empty string when the time is out of range. fn timestamp(seconds: i64) -> String { jiff::Timestamp::from_second(seconds) .map(|t| t.to_string()) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; use forge_core::{IssueState, Mergeable, ReviewState}; fn repo() -> Repo { Repo { id: "r1".into(), owner_id: "u1".into(), owner_name: "alice".into(), name: "proj".into(), description: String::new(), visibility: Visibility::Private, default_branch: "main".into(), archived: false, created_at: String::new(), fork_of: None, } } fn issue(state: IssueState) -> Issue { Issue { id: "i1".into(), repo_id: "r1".into(), number: 7, title: "Crash".into(), body: "It crashes".into(), state, state_reason: None, author_id: "u1".into(), milestone_id: None, comment_count: 0, created_at: String::new(), updated_at: String::new(), closed_at: None, locked: false, lock_reason: None, } } fn pr() -> PullRequest { PullRequest { issue: issue(IssueState::Open), base_branch: "main".into(), head_repo_id: Some("r1".into()), head_branch: "feature".into(), head_oid: "h".repeat(40), base_oid: "b".repeat(40), merge_base: None, mergeable: Mergeable::Unknown, conflict_files: Vec::new(), merged_at: None, merged_by: None, merge_commit: None, } } #[test] fn repository_has_urls_built_from_base() { let v = repository("https://forge.test/", &repo(), "user"); assert_eq!(v["full_name"], "alice/proj"); assert_eq!(v["owner"], "alice"); assert_eq!(v["owner_kind"], "user"); assert_eq!( repository("https://forge.test/", &repo(), "org")["owner_kind"], "org" ); assert_eq!(v["private"], true); assert_eq!(v["html_url"], "https://forge.test/alice/proj"); assert_eq!(v["clone_url"], "https://forge.test/alice/proj.git"); } #[test] fn sender_is_null_for_system_changes() { assert_eq!(sender("", ""), Value::Null); assert_eq!( sender("u1", "alice"), json!({"id": "u1", "username": "alice"}) ); } #[test] fn issues_payload_shape() { let v = issues("https://forge.test", &repo(), &issue(IssueState::Closed)); assert_eq!(v["issue"]["number"], 7); assert_eq!(v["issue"]["state"], "closed"); assert_eq!( v["issue"]["html_url"], "https://forge.test/alice/proj/issues/7" ); assert!(v.get("label").is_none()); } #[test] fn pull_request_payload_shape() { let v = pull_request("https://forge.test", &repo(), &pr()); assert_eq!(v["number"], 7); let p = &v["pull_request"]; assert_eq!(p["title"], "Crash"); assert_eq!(p["state"], "open"); assert_eq!(p["merged"], false); assert_eq!(p["base"]["ref"], "main"); assert_eq!(p["head"]["ref"], "feature"); assert_eq!(p["html_url"], "https://forge.test/alice/proj/pulls/7"); } #[test] fn comment_and_review_payloads() { let comment = Comment { id: "c9".into(), issue_id: "i1".into(), author_id: "u1".into(), body: "hi".into(), created_at: String::new(), updated_at: String::new(), edited: false, }; let v = issue_comment( "https://forge.test", &repo(), &issue(IssueState::Open), true, &comment, ); assert_eq!(v["comment"]["id"], "c9"); assert_eq!( v["comment"]["html_url"], "https://forge.test/alice/proj/pulls/7#comment-c9" ); assert_eq!( v["issue"]["html_url"], "https://forge.test/alice/proj/pulls/7" ); let review = Review { id: "rv1".into(), issue_id: "i1".into(), author_id: "u1".into(), author_name: "alice".into(), state: ReviewState::Approved, body: "ship it".into(), commit_oid: "h".repeat(40), dismissed_reason: None, dismissed_from: None, created_at: String::new(), submitted_at: None, }; let v = pull_request_review("https://forge.test", &repo(), &pr(), &review); assert_eq!(v["review"]["state"], "approved"); assert_eq!(v["review"]["body"], "ship it"); assert_eq!(v["pull_request"]["title"], "Crash"); } fn commit(n: usize) -> CommitInfo { CommitInfo { oid: format!("{n:040}"), parents: Vec::new(), author_name: "Ann".into(), author_email: "ann@example.com".into(), author_time: 1_700_000_000, committer_name: "Ann".into(), committer_email: "ann@example.com".into(), committer_time: 1_700_000_000, summary: format!("commit {n}"), message: format!("commit {n}\n"), } } #[test] fn push_payload_caps_commits_and_reports_total() { let commits: Vec = (0..25).map(commit).collect(); let before = "a".repeat(40); let after = "c".repeat(40); let v = push( "https://forge.test", &repo(), "refs/heads/main", &before, &after, &commits, 25, ); assert_eq!(v["ref"], "refs/heads/main"); assert_eq!(v["before"], before.as_str()); assert_eq!(v["after"], after.as_str()); assert_eq!(v["total_commits"], 25); let listed = v["commits"].as_array().expect("array"); assert_eq!(listed.len(), MAX_PAYLOAD_COMMITS); assert_eq!(listed[0]["id"], format!("{:040}", 0)); assert_eq!(listed[0]["author"]["email"], "ann@example.com"); assert_eq!(listed[0]["timestamp"], "2023-11-14T22:13:20Z"); assert_eq!( listed[0]["url"], format!("https://forge.test/alice/proj/commit/{:040}", 0) ); assert_eq!( v["compare_url"], format!("https://forge.test/alice/proj/compare/{before}...{after}") ); } #[test] fn ref_change_and_ping_shapes() { let v = ref_change("tag", "v1"); assert_eq!(v, json!({"ref": "v1", "ref_type": "tag"})); let p = ping("hook1"); assert_eq!(p["hook_id"], "hook1"); assert!(p["zen"].is_string()); } }