| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use forge_core::{Comment, Issue, PullRequest, Repo, Review, Visibility}; |
| 8 | use forge_git::browse::CommitInfo; |
| 9 | use serde_json::{Value, json}; |
| 10 | |
| 11 | |
| 12 | pub const MAX_PAYLOAD_COMMITS: usize = 20; |
| 13 | |
| 14 | |
| 15 | fn base(base_url: &str) -> &str { |
| 16 | base_url.trim_end_matches('/') |
| 17 | } |
| 18 | |
| 19 | |
| 20 | fn repo_url(base_url: &str, repo: &Repo) -> String { |
| 21 | format!("{}/{}/{}", base(base_url), repo.owner_name, repo.name) |
| 22 | } |
| 23 | |
| 24 | |
| 25 | fn item_url(base_url: &str, repo: &Repo, segment: &str, number: i64) -> String { |
| 26 | format!("{}/{segment}/{number}", repo_url(base_url, repo)) |
| 27 | } |
| 28 | |
| 29 | |
| 30 | |
| 31 | pub fn repository(base_url: &str, repo: &Repo, owner_kind: &str) -> Value { |
| 32 | let url = repo_url(base_url, repo); |
| 33 | json!({ |
| 34 | "id": repo.id, |
| 35 | "owner": repo.owner_name, |
| 36 | "owner_kind": owner_kind, |
| 37 | "name": repo.name, |
| 38 | "full_name": format!("{}/{}", repo.owner_name, repo.name), |
| 39 | "private": repo.visibility == Visibility::Private, |
| 40 | "html_url": url, |
| 41 | "clone_url": format!("{url}.git"), |
| 42 | }) |
| 43 | } |
| 44 | |
| 45 | |
| 46 | pub fn sender(id: &str, username: &str) -> Value { |
| 47 | if id.is_empty() { |
| 48 | Value::Null |
| 49 | } else { |
| 50 | json!({ "id": id, "username": username }) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | |
| 55 | pub fn issues(base_url: &str, repo: &Repo, issue: &Issue) -> Value { |
| 56 | json!({ "issue": issue_object(base_url, repo, issue, false) }) |
| 57 | } |
| 58 | |
| 59 | |
| 60 | pub fn pull_request(base_url: &str, repo: &Repo, pr: &PullRequest) -> Value { |
| 61 | json!({ |
| 62 | "number": pr.issue.number, |
| 63 | "pull_request": pull_object(base_url, repo, pr), |
| 64 | }) |
| 65 | } |
| 66 | |
| 67 | |
| 68 | |
| 69 | pub fn issue_comment( |
| 70 | base_url: &str, |
| 71 | repo: &Repo, |
| 72 | issue: &Issue, |
| 73 | pull: bool, |
| 74 | comment: &Comment, |
| 75 | ) -> Value { |
| 76 | let page = item_url( |
| 77 | base_url, |
| 78 | repo, |
| 79 | if pull { "pulls" } else { "issues" }, |
| 80 | issue.number, |
| 81 | ); |
| 82 | json!({ |
| 83 | "issue": issue_object(base_url, repo, issue, pull), |
| 84 | "comment": { |
| 85 | "id": comment.id, |
| 86 | "body": comment.body, |
| 87 | "html_url": format!("{page}#comment-{}", comment.id), |
| 88 | }, |
| 89 | }) |
| 90 | } |
| 91 | |
| 92 | |
| 93 | pub fn pull_request_review( |
| 94 | base_url: &str, |
| 95 | repo: &Repo, |
| 96 | pr: &PullRequest, |
| 97 | review: &Review, |
| 98 | ) -> Value { |
| 99 | json!({ |
| 100 | "review": { |
| 101 | "id": review.id, |
| 102 | "state": review.state.as_str(), |
| 103 | "body": review.body, |
| 104 | }, |
| 105 | "pull_request": pull_object(base_url, repo, pr), |
| 106 | }) |
| 107 | } |
| 108 | |
| 109 | |
| 110 | |
| 111 | pub fn push( |
| 112 | base_url: &str, |
| 113 | repo: &Repo, |
| 114 | refname: &str, |
| 115 | before: &str, |
| 116 | after: &str, |
| 117 | commits: &[CommitInfo], |
| 118 | total: usize, |
| 119 | ) -> Value { |
| 120 | let url = repo_url(base_url, repo); |
| 121 | let listed: Vec<Value> = commits |
| 122 | .iter() |
| 123 | .take(MAX_PAYLOAD_COMMITS) |
| 124 | .map(|commit| { |
| 125 | json!({ |
| 126 | "id": commit.oid, |
| 127 | "message": commit.message, |
| 128 | "author": { "name": commit.author_name, "email": commit.author_email }, |
| 129 | "timestamp": timestamp(commit.author_time), |
| 130 | "url": format!("{url}/commit/{}", commit.oid), |
| 131 | }) |
| 132 | }) |
| 133 | .collect(); |
| 134 | json!({ |
| 135 | "ref": refname, |
| 136 | "before": before, |
| 137 | "after": after, |
| 138 | "commits": listed, |
| 139 | "total_commits": total, |
| 140 | "compare_url": format!("{url}/compare/{before}...{after}"), |
| 141 | }) |
| 142 | } |
| 143 | |
| 144 | |
| 145 | pub fn ref_change(ref_type: &str, name: &str) -> Value { |
| 146 | json!({ "ref": name, "ref_type": ref_type }) |
| 147 | } |
| 148 | |
| 149 | |
| 150 | pub fn ping(hook_id: &str) -> Value { |
| 151 | json!({ |
| 152 | "zen": "Forge keeps the repository close.", |
| 153 | "hook_id": hook_id, |
| 154 | }) |
| 155 | } |
| 156 | |
| 157 | fn issue_object(base_url: &str, repo: &Repo, issue: &Issue, pull: bool) -> Value { |
| 158 | json!({ |
| 159 | "number": issue.number, |
| 160 | "title": issue.title, |
| 161 | "body": issue.body, |
| 162 | "state": issue.state.as_str(), |
| 163 | "html_url": item_url( |
| 164 | base_url, |
| 165 | repo, |
| 166 | if pull { "pulls" } else { "issues" }, |
| 167 | issue.number, |
| 168 | ), |
| 169 | }) |
| 170 | } |
| 171 | |
| 172 | fn pull_object(base_url: &str, repo: &Repo, pr: &PullRequest) -> Value { |
| 173 | json!({ |
| 174 | "title": pr.issue.title, |
| 175 | "body": pr.issue.body, |
| 176 | "state": pr.issue.state.as_str(), |
| 177 | "merged": pr.merged_at.is_some(), |
| 178 | "base": { "ref": pr.base_branch, "sha": pr.base_oid }, |
| 179 | "head": { "ref": pr.head_branch, "sha": pr.head_oid }, |
| 180 | "html_url": item_url(base_url, repo, "pulls", pr.issue.number), |
| 181 | }) |
| 182 | } |
| 183 | |
| 184 | |
| 185 | fn timestamp(seconds: i64) -> String { |
| 186 | jiff::Timestamp::from_second(seconds) |
| 187 | .map(|t| t.to_string()) |
| 188 | .unwrap_or_default() |
| 189 | } |
| 190 | |
| 191 | #[cfg(test)] |
| 192 | mod tests { |
| 193 | use super::*; |
| 194 | use forge_core::{IssueState, Mergeable, ReviewState}; |
| 195 | |
| 196 | fn repo() -> Repo { |
| 197 | Repo { |
| 198 | id: "r1".into(), |
| 199 | owner_id: "u1".into(), |
| 200 | owner_name: "alice".into(), |
| 201 | name: "proj".into(), |
| 202 | description: String::new(), |
| 203 | visibility: Visibility::Private, |
| 204 | default_branch: "main".into(), |
| 205 | archived: false, |
| 206 | created_at: String::new(), |
| 207 | fork_of: None, |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | fn issue(state: IssueState) -> Issue { |
| 212 | Issue { |
| 213 | id: "i1".into(), |
| 214 | repo_id: "r1".into(), |
| 215 | number: 7, |
| 216 | title: "Crash".into(), |
| 217 | body: "It crashes".into(), |
| 218 | state, |
| 219 | state_reason: None, |
| 220 | author_id: "u1".into(), |
| 221 | milestone_id: None, |
| 222 | comment_count: 0, |
| 223 | created_at: String::new(), |
| 224 | updated_at: String::new(), |
| 225 | closed_at: None, |
| 226 | locked: false, |
| 227 | lock_reason: None, |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | fn pr() -> PullRequest { |
| 232 | PullRequest { |
| 233 | issue: issue(IssueState::Open), |
| 234 | base_branch: "main".into(), |
| 235 | head_repo_id: Some("r1".into()), |
| 236 | head_branch: "feature".into(), |
| 237 | head_oid: "h".repeat(40), |
| 238 | base_oid: "b".repeat(40), |
| 239 | merge_base: None, |
| 240 | mergeable: Mergeable::Unknown, |
| 241 | conflict_files: Vec::new(), |
| 242 | merged_at: None, |
| 243 | merged_by: None, |
| 244 | merge_commit: None, |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn repository_has_urls_built_from_base() { |
| 250 | let v = repository("https://forge.test/", &repo(), "user"); |
| 251 | assert_eq!(v["full_name"], "alice/proj"); |
| 252 | assert_eq!(v["owner"], "alice"); |
| 253 | assert_eq!(v["owner_kind"], "user"); |
| 254 | assert_eq!( |
| 255 | repository("https://forge.test/", &repo(), "org")["owner_kind"], |
| 256 | "org" |
| 257 | ); |
| 258 | assert_eq!(v["private"], true); |
| 259 | assert_eq!(v["html_url"], "https://forge.test/alice/proj"); |
| 260 | assert_eq!(v["clone_url"], "https://forge.test/alice/proj.git"); |
| 261 | } |
| 262 | |
| 263 | #[test] |
| 264 | fn sender_is_null_for_system_changes() { |
| 265 | assert_eq!(sender("", ""), Value::Null); |
| 266 | assert_eq!( |
| 267 | sender("u1", "alice"), |
| 268 | json!({"id": "u1", "username": "alice"}) |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn issues_payload_shape() { |
| 274 | let v = issues("https://forge.test", &repo(), &issue(IssueState::Closed)); |
| 275 | assert_eq!(v["issue"]["number"], 7); |
| 276 | assert_eq!(v["issue"]["state"], "closed"); |
| 277 | assert_eq!( |
| 278 | v["issue"]["html_url"], |
| 279 | "https://forge.test/alice/proj/issues/7" |
| 280 | ); |
| 281 | assert!(v.get("label").is_none()); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn pull_request_payload_shape() { |
| 286 | let v = pull_request("https://forge.test", &repo(), &pr()); |
| 287 | assert_eq!(v["number"], 7); |
| 288 | let p = &v["pull_request"]; |
| 289 | assert_eq!(p["title"], "Crash"); |
| 290 | assert_eq!(p["state"], "open"); |
| 291 | assert_eq!(p["merged"], false); |
| 292 | assert_eq!(p["base"]["ref"], "main"); |
| 293 | assert_eq!(p["head"]["ref"], "feature"); |
| 294 | assert_eq!(p["html_url"], "https://forge.test/alice/proj/pulls/7"); |
| 295 | } |
| 296 | |
| 297 | #[test] |
| 298 | fn comment_and_review_payloads() { |
| 299 | let comment = Comment { |
| 300 | id: "c9".into(), |
| 301 | issue_id: "i1".into(), |
| 302 | author_id: "u1".into(), |
| 303 | body: "hi".into(), |
| 304 | created_at: String::new(), |
| 305 | updated_at: String::new(), |
| 306 | edited: false, |
| 307 | }; |
| 308 | let v = issue_comment( |
| 309 | "https://forge.test", |
| 310 | &repo(), |
| 311 | &issue(IssueState::Open), |
| 312 | true, |
| 313 | &comment, |
| 314 | ); |
| 315 | assert_eq!(v["comment"]["id"], "c9"); |
| 316 | assert_eq!( |
| 317 | v["comment"]["html_url"], |
| 318 | "https://forge.test/alice/proj/pulls/7#comment-c9" |
| 319 | ); |
| 320 | assert_eq!( |
| 321 | v["issue"]["html_url"], |
| 322 | "https://forge.test/alice/proj/pulls/7" |
| 323 | ); |
| 324 | |
| 325 | let review = Review { |
| 326 | id: "rv1".into(), |
| 327 | issue_id: "i1".into(), |
| 328 | author_id: "u1".into(), |
| 329 | author_name: "alice".into(), |
| 330 | state: ReviewState::Approved, |
| 331 | body: "ship it".into(), |
| 332 | commit_oid: "h".repeat(40), |
| 333 | dismissed_reason: None, |
| 334 | dismissed_from: None, |
| 335 | created_at: String::new(), |
| 336 | submitted_at: None, |
| 337 | }; |
| 338 | let v = pull_request_review("https://forge.test", &repo(), &pr(), &review); |
| 339 | assert_eq!(v["review"]["state"], "approved"); |
| 340 | assert_eq!(v["review"]["body"], "ship it"); |
| 341 | assert_eq!(v["pull_request"]["title"], "Crash"); |
| 342 | } |
| 343 | |
| 344 | fn commit(n: usize) -> CommitInfo { |
| 345 | CommitInfo { |
| 346 | oid: format!("{n:040}"), |
| 347 | parents: Vec::new(), |
| 348 | author_name: "Ann".into(), |
| 349 | author_email: "ann@example.com".into(), |
| 350 | author_time: 1_700_000_000, |
| 351 | committer_name: "Ann".into(), |
| 352 | committer_email: "ann@example.com".into(), |
| 353 | committer_time: 1_700_000_000, |
| 354 | summary: format!("commit {n}"), |
| 355 | message: format!("commit {n}\n"), |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | #[test] |
| 360 | fn push_payload_caps_commits_and_reports_total() { |
| 361 | let commits: Vec<CommitInfo> = (0..25).map(commit).collect(); |
| 362 | let before = "a".repeat(40); |
| 363 | let after = "c".repeat(40); |
| 364 | let v = push( |
| 365 | "https://forge.test", |
| 366 | &repo(), |
| 367 | "refs/heads/main", |
| 368 | &before, |
| 369 | &after, |
| 370 | &commits, |
| 371 | 25, |
| 372 | ); |
| 373 | assert_eq!(v["ref"], "refs/heads/main"); |
| 374 | assert_eq!(v["before"], before.as_str()); |
| 375 | assert_eq!(v["after"], after.as_str()); |
| 376 | assert_eq!(v["total_commits"], 25); |
| 377 | let listed = v["commits"].as_array().expect("array"); |
| 378 | assert_eq!(listed.len(), MAX_PAYLOAD_COMMITS); |
| 379 | assert_eq!(listed[0]["id"], format!("{:040}", 0)); |
| 380 | assert_eq!(listed[0]["author"]["email"], "ann@example.com"); |
| 381 | assert_eq!(listed[0]["timestamp"], "2023-11-14T22:13:20Z"); |
| 382 | assert_eq!( |
| 383 | listed[0]["url"], |
| 384 | format!("https://forge.test/alice/proj/commit/{:040}", 0) |
| 385 | ); |
| 386 | assert_eq!( |
| 387 | v["compare_url"], |
| 388 | format!("https://forge.test/alice/proj/compare/{before}...{after}") |
| 389 | ); |
| 390 | } |
| 391 | |
| 392 | #[test] |
| 393 | fn ref_change_and_ping_shapes() { |
| 394 | let v = ref_change("tag", "v1"); |
| 395 | assert_eq!(v, json!({"ref": "v1", "ref_type": "tag"})); |
| 396 | let p = ping("hook1"); |
| 397 | assert_eq!(p["hook_id"], "hook1"); |
| 398 | assert!(p["zen"].is_string()); |
| 399 | } |
| 400 | } |