forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.4 KiB
1//! JSON payload builders for webhook deliveries. Each builder returns the event-specific fields.
2//! `emit` adds `action` (when the event has one), `repository` and `sender`.
3//!
4//! The shapes follow docs/webhooks-interfaces.md. Links are built from `base_url` (the configured
5//! public URL), so they point at the instance a receiver can reach.
6
7use forge_core::{Comment, Issue, PullRequest, Repo, Review, Visibility};
8use forge_git::browse::CommitInfo;
9use serde_json::{Value, json};
10
11/// Most commits listed in a push payload. `total_commits` still counts all of them.
12pub const MAX_PAYLOAD_COMMITS: usize = 20;
13
14/// `base_url` without its trailing slash, so joined paths have exactly one separator.
15fn base(base_url: &str) -> &str {
16 base_url.trim_end_matches('/')
17}
18
19/// `{base}/{owner}/{name}`.
20fn repo_url(base_url: &str, repo: &Repo) -> String {
21 format!("{}/{}/{}", base(base_url), repo.owner_name, repo.name)
22}
23
24/// The page of an issue or pull request: `issues` or `pulls`.
25fn item_url(base_url: &str, repo: &Repo, segment: &str, number: i64) -> String {
26 format!("{}/{segment}/{number}", repo_url(base_url, repo))
27}
28
29/// The repository object carried by every payload. `owner` stays the owner's name; `owner_kind` is
30/// `"user"` or `"org"`.
31pub 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/// The sender object, or `null` for a system change (no signed-in user).
46pub 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/// `issues` payload: `issue`. The emit side adds `label` or `assignee` for those actions.
55pub fn issues(base_url: &str, repo: &Repo, issue: &Issue) -> Value {
56 json!({ "issue": issue_object(base_url, repo, issue, false) })
57}
58
59/// `pull_request` payload: `number` and `pull_request`.
60pub 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/// `issue_comment` payload: the item (`issue`, which is a pull request when `pull` is true) and
68/// the `comment`.
69pub 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/// `pull_request_review` payload: `review` and the `pull_request`.
93pub 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/// `push` payload. `commits` is the new commits, oldest first; at most [`MAX_PAYLOAD_COMMITS`]
110/// are listed, and `total` is the number found.
111pub 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/// `create` and `delete` payload: the short branch or tag name and its kind.
145pub fn ref_change(ref_type: &str, name: &str) -> Value {
146 json!({ "ref": name, "ref_type": ref_type })
147}
148
149/// `ping` payload, sent when a hook is created or tested.
150pub fn ping(hook_id: &str) -> Value {
151 json!({
152 "zen": "Forge keeps the repository close.",
153 "hook_id": hook_id,
154 })
155}
156
157fn 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
172fn 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/// RFC 3339 for a Unix time, or an empty string when the time is out of range.
185fn 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)]
192mod 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}