| 1 | //! `github.event`: the webhook-shaped payload GitHub gives a workflow, |
| 2 | //! built from g1t's own records so `github.event.pull_request.number`, |
| 3 | //! `github.event.issue.labels.*.name` and the like read as they do there. |
| 4 | //! |
| 5 | //! Ids are as GitHub's are: `id` a whole number, the same for an object |
| 6 | //! wherever it appears ([`github_id`] of its g1t id, [`account_id`] of a |
| 7 | //! person's or workspace's name), and `node_id` g1t's own id. What g1t's |
| 8 | //! API addresses by its own id keeps that id as `id`, so a payload's id |
| 9 | //! can be passed straight back to the API: a comment (`PATCH |
| 10 | //! /repos/{owner}/{repo}/issues/comments/{comment_id}`), a release, a |
| 11 | //! deployment and its statuses, and a workflow run. |
| 12 | |
| 13 | use g1t_actions::events::{account_id, github_id}; |
| 14 | use g1t_contracts::{PrincipalKind, User}; |
| 15 | use g1t_contracts::repos::{Commit, Repo, RepoPath}; |
| 16 | use g1t_contracts::work::{Comment, Issue, MilestoneRef, Pull, PullStatus, State}; |
| 17 | use serde_json::{Value, json}; |
| 18 | |
| 19 | use crate::{API, SITE}; |
| 20 | |
| 21 | /// The commit hash GitHub gives for no commit: a branch made or deleted. |
| 22 | pub const ZERO: &str = "0000000000000000000000000000000000000000"; |
| 23 | |
| 24 | /// A repository's owner, as a workspace (an `Organization`). A personal |
| 25 | /// workspace's repositories are owned by a `User` instead: see |
| 26 | /// [`owned_by_person`]. |
| 27 | fn owner(login: &str) -> Value { |
| 28 | json!({ |
| 29 | "login": login, |
| 30 | "id": account_id(login), |
| 31 | "type": "Organization", |
| 32 | "html_url": format!("{SITE}/{login}"), |
| 33 | "url": format!("{API}/users/{login}"), |
| 34 | }) |
| 35 | } |
| 36 | |
| 37 | pub fn repository(repo: &Repo) -> Value { |
| 38 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 39 | json!({ |
| 40 | "id": github_id(&repo.id), |
| 41 | "node_id": repo.id, |
| 42 | "name": repo.name, |
| 43 | "full_name": full_name, |
| 44 | "private": repo.is_private, |
| 45 | "owner": owner(&repo.namespace), |
| 46 | "html_url": format!("{SITE}/{full_name}"), |
| 47 | "url": format!("{API}/repos/{full_name}"), |
| 48 | "clone_url": format!("{SITE}/{full_name}.git"), |
| 49 | "description": repo.description, |
| 50 | "homepage": repo.website, |
| 51 | "topics": repo.topics, |
| 52 | "default_branch": repo.default_branch, |
| 53 | "fork": false, |
| 54 | "archived": repo.archived(), |
| 55 | "created_at": repo.created_at, |
| 56 | "visibility": if repo.is_private { "private" } else { "public" }, |
| 57 | }) |
| 58 | } |
| 59 | |
| 60 | /// A pull request's fork, from what the pull request knows of it. |
| 61 | fn fork_repository(fork: &RepoPath, id: Option<&str>) -> Value { |
| 62 | let full_name = format!("{}/{}", fork.namespace, fork.name); |
| 63 | json!({ |
| 64 | "id": id.map(github_id), |
| 65 | "node_id": id, |
| 66 | "name": fork.name, |
| 67 | "full_name": full_name, |
| 68 | "owner": owner(&fork.namespace), |
| 69 | "html_url": format!("{SITE}/{full_name}"), |
| 70 | "url": format!("{API}/repos/{full_name}"), |
| 71 | "clone_url": format!("{SITE}/{full_name}.git"), |
| 72 | "fork": true, |
| 73 | }) |
| 74 | } |
| 75 | |
| 76 | /// Marks the owner of `namespace`'s repositories as a `User` everywhere |
| 77 | /// in a payload: for a personal workspace, whose name is its person's. |
| 78 | pub fn owned_by_person(payload: &mut Value, namespace: &str) { |
| 79 | match payload { |
| 80 | Value::Object(map) => { |
| 81 | if map.get("login").and_then(Value::as_str).is_some_and(|login| login.eq_ignore_ascii_case(namespace)) |
| 82 | && map.get("type").and_then(Value::as_str) == Some("Organization") |
| 83 | { |
| 84 | map.insert("type".into(), json!("User")); |
| 85 | } |
| 86 | map.values_mut().for_each(|value| owned_by_person(value, namespace)); |
| 87 | } |
| 88 | Value::Array(items) => items.iter_mut().for_each(|value| owned_by_person(value, namespace)), |
| 89 | _ => {} |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | pub fn user(login: &str) -> Value { |
| 94 | json!({ |
| 95 | "login": login, |
| 96 | "id": account_id(login), |
| 97 | "type": "User", |
| 98 | "html_url": format!("{SITE}/{login}"), |
| 99 | "url": format!("{API}/users/{login}"), |
| 100 | }) |
| 101 | } |
| 102 | |
| 103 | /// Someone as GitHub names them. g1t, the author of what it makes and |
| 104 | /// files, is a `Bot`, as an app is there. |
| 105 | fn person(user: &User) -> Value { |
| 106 | let mut named = self::user(&user.username); |
| 107 | named["node_id"] = json!(user.id); |
| 108 | if matches!(user.kind, PrincipalKind::Agent | PrincipalKind::System) { |
| 109 | named["type"] = json!("Bot"); |
| 110 | } |
| 111 | named |
| 112 | } |
| 113 | |
| 114 | fn people(logins: &[String]) -> Vec<Value> { |
| 115 | logins.iter().map(|login| user(login)).collect() |
| 116 | } |
| 117 | |
| 118 | fn labels(repo: &Repo, names: &[String]) -> Value { |
| 119 | Value::Array(names.iter().map(|name| label(repo, name, None)).collect()) |
| 120 | } |
| 121 | |
| 122 | /// A label, by its name in `repo`. |
| 123 | fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value { |
| 124 | let mut label = json!({ "id": github_id(&format!("{}:label:{name}", repo.id)), "name": name }); |
| 125 | if let Some(color) = color { |
| 126 | label["color"] = color.clone(); |
| 127 | } |
| 128 | label |
| 129 | } |
| 130 | |
| 131 | /// A milestone as an issue or a pull request names it. |
| 132 | fn milestone(repo: &Repo, milestone: Option<&MilestoneRef>) -> Value { |
| 133 | milestone.map_or(Value::Null, |milestone| milestone_of(repo, milestone.number, &json!(milestone.title))) |
| 134 | } |
| 135 | |
| 136 | fn milestone_of(repo: &Repo, number: u32, title: &Value) -> Value { |
| 137 | json!({ |
| 138 | "id": github_id(&format!("{}:milestone:{number}", repo.id)), |
| 139 | "number": number, |
| 140 | "title": title, |
| 141 | "html_url": format!("{SITE}/{}/{}/milestones/{number}", repo.namespace, repo.name), |
| 142 | }) |
| 143 | } |
| 144 | |
| 145 | pub fn commit(repo: &Repo, commit: &Commit) -> Value { |
| 146 | json!({ |
| 147 | "id": commit.hash, |
| 148 | "tree_id": commit.tree_hash, |
| 149 | "message": commit.message, |
| 150 | "timestamp": commit.authored_at, |
| 151 | "url": format!("{SITE}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash), |
| 152 | "author": { "name": commit.author.name, "email": commit.author.email }, |
| 153 | "committer": { "name": commit.author.name, "email": commit.author.email }, |
| 154 | "distinct": true, |
| 155 | "added": [], |
| 156 | "removed": [], |
| 157 | "modified": [], |
| 158 | }) |
| 159 | } |
| 160 | |
| 161 | /// A push. `forced` is whether `before` is not in `after`'s history, when |
| 162 | /// that is known; a push whose history is too long to read says `false`. |
| 163 | pub fn push(repo: &Repo, git_ref: &str, before: Option<&str>, after: &str, commits: &[Commit], pusher: &str, forced: bool) -> Value { |
| 164 | let before = before.unwrap_or(ZERO); |
| 165 | let created = before == ZERO; |
| 166 | let deleted = after == ZERO; |
| 167 | let commits: Vec<Value> = commits.iter().map(|c| commit(repo, c)).collect(); |
| 168 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 169 | let compare = if created { |
| 170 | format!("{SITE}/{full_name}/commit/{after}") |
| 171 | } else { |
| 172 | format!("{SITE}/{full_name}/compare/{before}...{after}") |
| 173 | }; |
| 174 | json!({ |
| 175 | "ref": git_ref, |
| 176 | "before": before, |
| 177 | "after": after, |
| 178 | "created": created, |
| 179 | "deleted": deleted, |
| 180 | "forced": forced && !created && !deleted, |
| 181 | "base_ref": null, |
| 182 | "compare": compare, |
| 183 | // GitHub's head commit is the one pushed: the last. |
| 184 | "head_commit": commits.last().cloned().unwrap_or(Value::Null), |
| 185 | "commits": commits, |
| 186 | "pusher": { "name": pusher, "email": null }, |
| 187 | "repository": repository(repo), |
| 188 | "sender": user(pusher), |
| 189 | }) |
| 190 | } |
| 191 | |
| 192 | pub fn issue(repo: &Repo, issue: &Issue) -> Value { |
| 193 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 194 | json!({ |
| 195 | "id": github_id(&issue.id), |
| 196 | "node_id": issue.id, |
| 197 | "number": issue.number, |
| 198 | "title": issue.title, |
| 199 | "body": issue.body, |
| 200 | "state": if issue.state == State::Open { "open" } else { "closed" }, |
| 201 | "state_reason": issue.reason, |
| 202 | "locked": false, |
| 203 | "labels": labels(repo, &issue.labels), |
| 204 | "milestone": milestone(repo, issue.milestone.as_ref()), |
| 205 | "user": person(&issue.author), |
| 206 | // g1t's own field: for an issue its agent filed, who it worked for. |
| 207 | "requested_by": issue.requested_by.as_ref().map(person), |
| 208 | "assignee": issue.assignees.first().map(|a| user(a)), |
| 209 | "assignees": people(&issue.assignees), |
| 210 | "comments": issue.comment_count, |
| 211 | "created_at": issue.created_at, |
| 212 | "updated_at": issue.updated_at, |
| 213 | "closed_at": issue.closed_at, |
| 214 | "html_url": format!("{SITE}/{full_name}/issues/{}", issue.number), |
| 215 | "url": format!("{API}/repos/{full_name}/issues/{}", issue.number), |
| 216 | "repository_url": format!("{API}/repos/{full_name}"), |
| 217 | }) |
| 218 | } |
| 219 | |
| 220 | /// A pull request, with its own labels. `base.sha` is the pull request's |
| 221 | /// merge base until the trigger sets the base branch's tip, as GitHub |
| 222 | /// gives it (trigger.rs). |
| 223 | pub fn pull(repo: &Repo, pull: &Pull) -> Value { |
| 224 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 225 | let head_ref = head_ref(pull); |
| 226 | let base_ref = pull.base_branch(&repo.default_branch); |
| 227 | let (head_repo, head_owner) = match &pull.fork { |
| 228 | Some(fork) => (fork_repository(fork, pull.fork_repo_id.as_deref()), fork.namespace.as_str()), |
| 229 | None => (repository(repo), repo.namespace.as_str()), |
| 230 | }; |
| 231 | let merged = pull.status == PullStatus::Merged; |
| 232 | let closed_at = match pull.status { |
| 233 | PullStatus::Merged => pull.merged_at.clone(), |
| 234 | PullStatus::Closed => Some(pull.updated_at.clone()), |
| 235 | _ => None, |
| 236 | }; |
| 237 | json!({ |
| 238 | "id": github_id(&pull.id), |
| 239 | "node_id": pull.id, |
| 240 | "number": pull.number, |
| 241 | "title": pull.title, |
| 242 | "body": pull.body, |
| 243 | "state": if pull.status.is_active() { "open" } else { "closed" }, |
| 244 | "locked": false, |
| 245 | "draft": pull.status == PullStatus::Draft, |
| 246 | "merged": merged, |
| 247 | "merged_at": pull.merged_at, |
| 248 | "merged_by": pull.merged_by.as_deref().map(user), |
| 249 | // Only once merged, as on GitHub; the trigger sets the commit it |
| 250 | // landed as when the event says. |
| 251 | "merge_commit_sha": if merged { pull.head_commit.clone() } else { None }, |
| 252 | "labels": labels(repo, &pull.labels), |
| 253 | "milestone": milestone(repo, pull.milestone.as_ref()), |
| 254 | "user": person(&pull.author), |
| 255 | // g1t's own field: for a change g1t made, who asked for it. |
| 256 | "requested_by": pull.requested_by.as_ref().map(person), |
| 257 | "assignee": pull.assignees.first().map(|a| user(a)), |
| 258 | "assignees": people(&pull.assignees), |
| 259 | "requested_reviewers": people(&pull.reviewers), |
| 260 | "head": { |
| 261 | "ref": head_ref, |
| 262 | "sha": pull.head_commit, |
| 263 | "label": format!("{head_owner}:{head_ref}"), |
| 264 | "user": owner(head_owner), |
| 265 | "repo": head_repo, |
| 266 | }, |
| 267 | "base": { |
| 268 | "ref": base_ref, |
| 269 | "sha": pull.merge_base, |
| 270 | "label": format!("{}:{base_ref}", repo.namespace), |
| 271 | "user": owner(&repo.namespace), |
| 272 | "repo": repository(repo), |
| 273 | }, |
| 274 | "changed_files": pull.files.len(), |
| 275 | "additions": pull.files.iter().map(|f| u64::from(f.additions)).sum::<u64>(), |
| 276 | "deletions": pull.files.iter().map(|f| u64::from(f.deletions)).sum::<u64>(), |
| 277 | "created_at": pull.created_at, |
| 278 | "updated_at": pull.updated_at, |
| 279 | "closed_at": closed_at, |
| 280 | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), |
| 281 | "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number), |
| 282 | "issue_url": format!("{API}/repos/{full_name}/issues/{}", pull.number), |
| 283 | }) |
| 284 | } |
| 285 | |
| 286 | /// What a `labeled`, `unlabeled`, `milestoned`, `demilestoned`, |
| 287 | /// `assigned` or base change was about, from the g1t event: `label`, |
| 288 | /// `milestone`, `assignee` (the first of those newly assigned; g1t sends |
| 289 | /// one event for them all, GitHub one each), and for a new base, |
| 290 | /// `changes.base.ref.from` is not known, so only the new base is in |
| 291 | /// `pull_request.base`. |
| 292 | pub fn changed(repo: &Repo, payload: &mut Value, data: &Value) { |
| 293 | if let Some(first) = data["added"].as_array().and_then(|added| added.first()).and_then(Value::as_str) { |
| 294 | payload["assignee"] = user(first); |
| 295 | } |
| 296 | if let Some(found) = data.get("label").filter(|label| label.is_object()) { |
| 297 | payload["label"] = label(repo, found["name"].as_str().unwrap_or_default(), Some(&found["color"])); |
| 298 | } |
| 299 | if let Some(found) = data.get("milestone").filter(|milestone| milestone.is_object()) { |
| 300 | payload["milestone"] = match found["number"].as_u64() { |
| 301 | Some(number) => milestone_of(repo, number as u32, &found["title"]), |
| 302 | None => json!({ "number": found["number"], "title": found["title"] }), |
| 303 | }; |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /// The branch a pull request comes from, or a name for its fork. |
| 308 | pub fn head_ref(pull: &Pull) -> String { |
| 309 | pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number)) |
| 310 | } |
| 311 | |
| 312 | /// A comment. Its `id` is g1t's (`cmt_…`), as the API takes it back to |
| 313 | /// edit or delete it. |
| 314 | pub fn comment(repo: &Repo, number: u32, comment: &Comment, on_pull: bool) -> Value { |
| 315 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 316 | let page = if on_pull { "pull" } else { "issues" }; |
| 317 | json!({ |
| 318 | "id": comment.id, |
| 319 | "node_id": comment.id, |
| 320 | "body": comment.body, |
| 321 | "user": person(&comment.author), |
| 322 | "created_at": comment.created_at, |
| 323 | "updated_at": comment.edited_at.as_deref().unwrap_or(&comment.created_at), |
| 324 | "path": comment.path, |
| 325 | "line": comment.line, |
| 326 | "html_url": format!("{SITE}/{full_name}/{page}/{number}#{}", comment.id), |
| 327 | "url": format!("{API}/repos/{full_name}/issues/comments/{}", comment.id), |
| 328 | "issue_url": format!("{API}/repos/{full_name}/issues/{number}"), |
| 329 | }) |
| 330 | } |
| 331 | |
| 332 | /// A review, for `pull_request_review`: g1t keeps a review as a comment |
| 333 | /// with a verdict, and its id is that comment's. |
| 334 | pub fn review(repo: &Repo, pull: &Pull, review: Option<&Comment>) -> Value { |
| 335 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 336 | let Some(review) = review else { |
| 337 | return json!({ "state": null, "body": null, "user": null }); |
| 338 | }; |
| 339 | json!({ |
| 340 | "id": github_id(&review.id), |
| 341 | "node_id": review.id, |
| 342 | "state": review.verdict.map(review_state), |
| 343 | "body": review.body, |
| 344 | "user": person(&review.author), |
| 345 | "commit_id": pull.head_commit, |
| 346 | "submitted_at": review.created_at, |
| 347 | "html_url": format!("{SITE}/{full_name}/pull/{}#{}", pull.number, review.id), |
| 348 | "pull_request_url": format!("{API}/repos/{full_name}/pulls/{}", pull.number), |
| 349 | }) |
| 350 | } |
| 351 | |
| 352 | /// An issue as `issue_comment` gives it for a pull request: the pull |
| 353 | /// request's number and title, with `pull_request` set. Its id is not the |
| 354 | /// pull request's, as on GitHub, where the two are different objects. |
| 355 | pub fn pull_as_issue(repo: &Repo, pull: &Pull) -> Value { |
| 356 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 357 | json!({ |
| 358 | "id": github_id(&format!("issue:{}", pull.id)), |
| 359 | "node_id": pull.id, |
| 360 | "number": pull.number, |
| 361 | "title": pull.title, |
| 362 | "body": pull.body, |
| 363 | "state": if pull.status.is_active() { "open" } else { "closed" }, |
| 364 | "locked": false, |
| 365 | "draft": pull.status == PullStatus::Draft, |
| 366 | "labels": labels(repo, &pull.labels), |
| 367 | "milestone": milestone(repo, pull.milestone.as_ref()), |
| 368 | "user": person(&pull.author), |
| 369 | "requested_by": pull.requested_by.as_ref().map(person), |
| 370 | "assignee": pull.assignees.first().map(|a| user(a)), |
| 371 | "assignees": people(&pull.assignees), |
| 372 | "created_at": pull.created_at, |
| 373 | "updated_at": pull.updated_at, |
| 374 | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), |
| 375 | "url": format!("{API}/repos/{full_name}/issues/{}", pull.number), |
| 376 | "repository_url": format!("{API}/repos/{full_name}"), |
| 377 | "pull_request": { |
| 378 | "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number), |
| 379 | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), |
| 380 | "merged_at": pull.merged_at, |
| 381 | }, |
| 382 | }) |
| 383 | } |
| 384 | |
| 385 | /// A comment as the event that deleted it kept it (`comment.deleted`'s |
| 386 | /// `comment`), since it can no longer be read. |
| 387 | pub fn deleted_comment(repo: &Repo, number: u32, kept: &Value, on_pull: bool) -> Value { |
| 388 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 389 | let page = if on_pull { "pull" } else { "issues" }; |
| 390 | let id = kept["id"].as_str().unwrap_or_default(); |
| 391 | let login = kept["author"]["username"].as_str().unwrap_or_default(); |
| 392 | json!({ |
| 393 | "id": id, |
| 394 | "node_id": id, |
| 395 | "body": kept["body"], |
| 396 | "user": user(login), |
| 397 | "created_at": kept["createdAt"], |
| 398 | "updated_at": kept["editedAt"].as_str().map_or(kept["createdAt"].clone(), |at| json!(at)), |
| 399 | "path": kept["path"], |
| 400 | "line": kept["line"], |
| 401 | "html_url": format!("{SITE}/{full_name}/{page}/{number}#{id}"), |
| 402 | "url": format!("{API}/repos/{full_name}/issues/comments/{id}"), |
| 403 | "issue_url": format!("{API}/repos/{full_name}/issues/{number}"), |
| 404 | }) |
| 405 | } |
| 406 | |
| 407 | /// What `workflow_run` is told of the run it follows: the |
| 408 | /// `workflow.completed` event's data, and the run's row when it is still |
| 409 | /// there. Its `id` and `workflow_id` are g1t's (`run_…`, `wfl_…`), which |
| 410 | /// `GITHUB_RUN_ID` and the API use too, so `actions/download-artifact` |
| 411 | /// with `run-id: ${{ github.event.workflow_run.id }}` finds its artifacts. |
| 412 | pub struct FollowedRun<'a> { |
| 413 | pub data: &'a Value, |
| 414 | pub workflow_id: Option<&'a str>, |
| 415 | pub attempt: Option<u64>, |
| 416 | pub created_at: Option<&'a str>, |
| 417 | pub started_at: Option<&'a str>, |
| 418 | pub updated_at: Option<&'a str>, |
| 419 | pub actor: Option<&'a str>, |
| 420 | pub triggering_actor: Option<&'a str>, |
| 421 | pub title: Option<&'a str>, |
| 422 | pub head_commit: Option<&'a Commit>, |
| 423 | } |
| 424 | |
| 425 | pub fn workflow_run(repo: &Repo, run: &FollowedRun) -> Value { |
| 426 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 427 | let data = run.data; |
| 428 | let id = data["runId"].as_str().unwrap_or_default(); |
| 429 | let name = data["workflow"].as_str().unwrap_or_default(); |
| 430 | let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned(); |
| 431 | let url = format!("{API}/repos/{full_name}/actions/runs/{id}"); |
| 432 | let actor = run.actor.map(user); |
| 433 | json!({ |
| 434 | "id": id, |
| 435 | "node_id": id, |
| 436 | "name": name, |
| 437 | "display_title": run.title.unwrap_or(name), |
| 438 | "path": data["path"], |
| 439 | "event": data["event"], |
| 440 | "status": "completed", |
| 441 | "conclusion": data["conclusion"], |
| 442 | "workflow_id": run.workflow_id, |
| 443 | "head_sha": data["sha"], |
| 444 | "head_branch": head_branch, |
| 445 | "head_commit": run.head_commit.map(|c| json!({ |
| 446 | "id": c.hash, |
| 447 | "tree_id": c.tree_hash, |
| 448 | "message": c.message, |
| 449 | "timestamp": c.authored_at, |
| 450 | "author": { "name": c.author.name, "email": c.author.email }, |
| 451 | "committer": { "name": c.author.name, "email": c.author.email }, |
| 452 | })), |
| 453 | "run_number": data["number"], |
| 454 | "run_attempt": run.attempt.unwrap_or(1), |
| 455 | "created_at": run.created_at, |
| 456 | "run_started_at": run.started_at.or(run.created_at), |
| 457 | "updated_at": run.updated_at.or(run.created_at), |
| 458 | "actor": actor, |
| 459 | "triggering_actor": run.triggering_actor.map(user).or_else(|| run.actor.map(user)), |
| 460 | "html_url": format!("{SITE}/{full_name}/actions/runs/{id}"), |
| 461 | "url": url, |
| 462 | "jobs_url": format!("{url}/jobs"), |
| 463 | "logs_url": format!("{url}/logs"), |
| 464 | "artifacts_url": format!("{url}/artifacts"), |
| 465 | "workflow_url": run.workflow_id.map(|w| format!("{API}/repos/{full_name}/actions/workflows/{w}")), |
| 466 | "pull_requests": data["pull"].as_u64().map(|n| vec![json!({ |
| 467 | "number": n, |
| 468 | "url": format!("{API}/repos/{full_name}/pulls/{n}"), |
| 469 | })]).unwrap_or_default(), |
| 470 | "repository": repository(repo), |
| 471 | "head_repository": repository(repo), |
| 472 | }) |
| 473 | } |
| 474 | |
| 475 | /// A release, from what a `release.*` event carries |
| 476 | /// (`g1t_contracts::about::Release`). Its `id` is g1t's, as |
| 477 | /// `/repos/{owner}/{repo}/releases/{id}` takes it. |
| 478 | pub fn release(repo: &Repo, release: &Value) -> Value { |
| 479 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 480 | let tag = release["tagName"].as_str().unwrap_or_default(); |
| 481 | let id = release["id"].as_str().unwrap_or_default(); |
| 482 | json!({ |
| 483 | "id": id, |
| 484 | "node_id": id, |
| 485 | "tag_name": tag, |
| 486 | "target_commitish": release["target"], |
| 487 | "name": release["name"].as_str().unwrap_or(tag), |
| 488 | "body": release["body"], |
| 489 | "draft": release["draft"].as_bool().unwrap_or(false), |
| 490 | "prerelease": release["prerelease"].as_bool().unwrap_or(false), |
| 491 | "created_at": release["createdAt"], |
| 492 | "published_at": release["publishedAt"], |
| 493 | "author": user(release["author"].as_str().unwrap_or(&repo.namespace)), |
| 494 | "assets": [], |
| 495 | "html_url": format!("{SITE}/{full_name}/releases/tag/{tag}"), |
| 496 | "url": format!("{API}/repos/{full_name}/releases/{id}"), |
| 497 | "zipball_url": format!("{SITE}/{full_name}/archive/{tag}.zip"), |
| 498 | }) |
| 499 | } |
| 500 | |
| 501 | /// A deployment, from what `deployment.created` and |
| 502 | /// `deployment_status.created` carry (`RepoDeployment`, already in GitHub's |
| 503 | /// spelling). |
| 504 | pub fn deployment(repo: &Repo, deployment: &Value) -> Value { |
| 505 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 506 | let id = deployment["id"].as_str().unwrap_or_default(); |
| 507 | json!({ |
| 508 | "id": id, |
| 509 | "node_id": id, |
| 510 | "sha": deployment["sha"], |
| 511 | "ref": deployment["ref"], |
| 512 | "task": deployment["task"].as_str().unwrap_or("deploy"), |
| 513 | "payload": deployment.get("payload").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({})), |
| 514 | "environment": deployment["environment"], |
| 515 | "original_environment": deployment["environment"], |
| 516 | "description": deployment["description"], |
| 517 | "creator": user(deployment["creator"].as_str().unwrap_or("g1t")), |
| 518 | "transient_environment": deployment["transient_environment"].as_bool().unwrap_or(false), |
| 519 | "production_environment": deployment["production_environment"].as_bool().unwrap_or(false), |
| 520 | "created_at": deployment["created_at"], |
| 521 | "updated_at": deployment["updated_at"], |
| 522 | "url": format!("{API}/repos/{full_name}/deployments/{id}"), |
| 523 | "statuses_url": format!("{API}/repos/{full_name}/deployments/{id}/statuses"), |
| 524 | }) |
| 525 | } |
| 526 | |
| 527 | /// A deployment's status (`DeploymentStatus`), for `deployment_status`. |
| 528 | pub fn deployment_status(repo: &Repo, status: &Value, deployment: &Value) -> Value { |
| 529 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 530 | let deployment_id = deployment["id"].as_str().unwrap_or_default(); |
| 531 | let id = status["id"].as_str().unwrap_or_default(); |
| 532 | json!({ |
| 533 | "id": id, |
| 534 | "node_id": id, |
| 535 | "state": status["state"], |
| 536 | "description": status["description"].as_str().unwrap_or_default(), |
| 537 | "environment": deployment["environment"], |
| 538 | "environment_url": status["environment_url"].as_str().unwrap_or_default(), |
| 539 | "log_url": status["log_url"].as_str().unwrap_or_default(), |
| 540 | "target_url": status["log_url"].as_str().unwrap_or_default(), |
| 541 | "creator": user(status["creator"].as_str().unwrap_or("g1t")), |
| 542 | "created_at": status["created_at"], |
| 543 | "updated_at": status["created_at"], |
| 544 | "deployment_url": format!("{API}/repos/{full_name}/deployments/{deployment_id}"), |
| 545 | "url": format!("{API}/repos/{full_name}/deployments/{deployment_id}/statuses/{id}"), |
| 546 | }) |
| 547 | } |
| 548 | |
| 549 | |
| 550 | /// A review's `state` as GitHub's `pull_request_review` event spells it. |
| 551 | pub(crate) fn review_state(verdict: g1t_contracts::work::Verdict) -> &'static str { |
| 552 | match verdict { |
| 553 | g1t_contracts::work::Verdict::Approve => "approved", |
| 554 | g1t_contracts::work::Verdict::RequestChanges => "changes_requested", |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | #[cfg(test)] |
| 559 | mod review_tests { |
| 560 | use super::*; |
| 561 | |
| 562 | #[test] |
| 563 | fn review_states_are_spelled_as_githubs() { |
| 564 | assert_eq!(review_state(g1t_contracts::work::Verdict::Approve), "approved"); |
| 565 | assert_eq!(review_state(g1t_contracts::work::Verdict::RequestChanges), "changes_requested"); |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | /// g1t's payloads against GitHub's: each fixture in `tests/fixtures` is a |
| 570 | /// trimmed webhook payload in GitHub's documented shape, and every key it |
| 571 | /// has must be in g1t's payload for the same event with the same JSON type |
| 572 | /// (a whole number where GitHub's is one). `null` in a fixture takes any |
| 573 | /// type. Where g1t differs on purpose, `KNOWN` says how and why. |
| 574 | #[cfg(test)] |
| 575 | mod github_shapes { |
| 576 | use super::*; |
| 577 | use g1t_contracts::work::Verdict; |
| 578 | |
| 579 | /// Paths where g1t's payload differs from GitHub's on purpose, and |
| 580 | /// what g1t has there: a JSON type, or `absent`. |
| 581 | const KNOWN: &[(&str, &str, &str)] = &[ |
| 582 | // The API addresses these by g1t's own id, so the payload gives |
| 583 | // that id, to be passed straight back. |
| 584 | ("issue_comment", "comment.id", "string"), |
| 585 | ("release", "release.id", "string"), |
| 586 | ("workflow_run", "workflow_run.id", "string"), |
| 587 | ("workflow_run", "workflow_run.workflow_id", "string"), |
| 588 | ("workflow_run", "workflow.id", "string"), |
| 589 | // g1t keeps labels by name on issues and pull requests; only a |
| 590 | // `labeled` event's own `label` has its color. |
| 591 | ("pull_request", "pull_request.labels[].color", "absent"), |
| 592 | ("issues", "issue.labels[].color", "absent"), |
| 593 | // A push names who pushed, not their address. |
| 594 | ("push", "pusher.email", "null"), |
| 595 | ]; |
| 596 | |
| 597 | fn kind(value: &Value) -> &'static str { |
| 598 | match value { |
| 599 | Value::Null => "null", |
| 600 | Value::Bool(_) => "boolean", |
| 601 | Value::Number(_) => "number", |
| 602 | Value::String(_) => "string", |
| 603 | Value::Array(_) => "array", |
| 604 | Value::Object(_) => "object", |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | fn compare(event: &str, github: &Value, ours: Option<&Value>, path: &str, problems: &mut Vec<String>) { |
| 609 | if let Some((_, _, known)) = KNOWN.iter().find(|(e, p, _)| *e == event && *p == path) { |
| 610 | let had = ours.map_or("absent", kind); |
| 611 | if had != *known { |
| 612 | problems.push(format!("{path}: g1t is known to have {known} here, but has {had}")); |
| 613 | } |
| 614 | return; |
| 615 | } |
| 616 | let Some(ours) = ours else { |
| 617 | problems.push(format!("{path}: missing")); |
| 618 | return; |
| 619 | }; |
| 620 | if github.is_null() { |
| 621 | return; |
| 622 | } |
| 623 | if kind(github) != kind(ours) { |
| 624 | problems.push(format!("{path}: GitHub's is {}, g1t's {}", kind(github), kind(ours))); |
| 625 | return; |
| 626 | } |
| 627 | match github { |
| 628 | Value::Number(n) if n.is_u64() => { |
| 629 | if !ours.as_u64().is_some_and(|n| n < 1 << 53) { |
| 630 | problems.push(format!("{path}: GitHub's is a whole number, g1t's {ours}")); |
| 631 | } |
| 632 | } |
| 633 | Value::Object(map) => { |
| 634 | for (key, inner) in map { |
| 635 | let at = if path.is_empty() { key.clone() } else { format!("{path}.{key}") }; |
| 636 | compare(event, inner, ours.get(key), &at, problems); |
| 637 | } |
| 638 | } |
| 639 | Value::Array(items) => { |
| 640 | if let (Some(first), Some(our_first)) = (items.first(), ours.as_array().and_then(|o| o.first())) { |
| 641 | compare(event, first, Some(our_first), &format!("{path}[]"), problems); |
| 642 | } |
| 643 | } |
| 644 | _ => {} |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | fn repo() -> Repo { |
| 649 | serde_json::from_value(json!({ |
| 650 | "id": "rep_01kpw0aaaaaaaaaaaaaaaaaaaa", "namespace": "acme", "name": "web", "description": null, "isPrivate": false, |
| 651 | "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-05-15T15:19:25Z" |
| 652 | })) |
| 653 | .unwrap() |
| 654 | } |
| 655 | |
| 656 | fn someone(id: &str, username: &str) -> User { |
| 657 | User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() } |
| 658 | } |
| 659 | |
| 660 | fn a_pull() -> Pull { |
| 661 | serde_json::from_value(json!({ |
| 662 | "id": "pr_01kq000000000000000000000a", "repoId": "rep_1", "number": 2, "issue": null, |
| 663 | "title": "Update the README with new information.", "body": "A simple change.", |
| 664 | "agent": "", "runtime": "external", "status": "open", |
| 665 | "fork": null, "forkRepoId": null, "branch": "changes", |
| 666 | "headCommit": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", "mergeBase": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e", |
| 667 | "mergedBy": null, "mergedAt": null, "supersededBy": null, "checkStatus": null, |
| 668 | "files": [{ "path": "README.md", "additions": 1, "deletions": 1 }], |
| 669 | "assignees": ["bo"], "reviewers": ["cy"], "labels": ["bug"], "milestone": { "number": 1, "title": "v1.0" }, |
| 670 | "author": someone("usr_1", "ada"), "requestedBy": null, |
| 671 | "createdAt": "2026-05-15T15:20:33Z", "updatedAt": "2026-05-15T15:20:33Z" |
| 672 | })) |
| 673 | .unwrap() |
| 674 | } |
| 675 | |
| 676 | fn an_issue() -> Issue { |
| 677 | serde_json::from_value(json!({ |
| 678 | "id": "iss_01kq000000000000000000000a", "repoId": "rep_1", "number": 1, |
| 679 | "title": "Spelling error in the README file", "body": "Two t's.", "labels": ["bug"], "state": "open", |
| 680 | "reason": null, "resolvedBy": null, "author": someone("usr_1", "ada"), "requestedBy": null, |
| 681 | "createdAt": "2026-05-15T15:20:18Z", "updatedAt": "2026-05-15T15:20:18Z", "closedAt": null, |
| 682 | "pullCount": 0, "commentCount": 0, "assignees": ["bo"], "milestone": { "number": 1, "title": "v1.0" } |
| 683 | })) |
| 684 | .unwrap() |
| 685 | } |
| 686 | |
| 687 | fn a_comment(verdict: Option<Verdict>) -> Comment { |
| 688 | serde_json::from_value(json!({ |
| 689 | "id": "cmt_01kq000000000000000000000a", "author": someone("usr_3", "cy"), "body": "Looks good.", |
| 690 | "path": null, "line": null, "verdict": verdict, "createdAt": "2026-05-15T15:20:38Z" |
| 691 | })) |
| 692 | .unwrap() |
| 693 | } |
| 694 | |
| 695 | fn a_commit() -> Commit { |
| 696 | serde_json::from_value(json!({ |
| 697 | "hash": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", "treeHash": "f9d2a07e9488b91af2641b26b9407fe22a451433", |
| 698 | "message": "Update README.md", "author": { "name": "Ada", "email": "ada@example.com" }, |
| 699 | "parents": ["6113728f27ae82c7b1a177c8d03f9e96e0adf246"], "authoredAt": "2026-05-15T15:20:30Z" |
| 700 | })) |
| 701 | .unwrap() |
| 702 | } |
| 703 | |
| 704 | /// g1t's payload for each event, put together as trigger.rs does. |
| 705 | fn payloads() -> Vec<(&'static str, Value)> { |
| 706 | let repo = repo(); |
| 707 | let pull = a_pull(); |
| 708 | let push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", false); |
| 709 | let pull_request = json!({ |
| 710 | "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull), |
| 711 | "repository": repository(&repo), "sender": user("ada"), |
| 712 | }); |
| 713 | let review = json!({ |
| 714 | "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))), |
| 715 | "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"), |
| 716 | }); |
| 717 | let mut issues = json!({ "action": "labeled", "issue": issue(&repo, &an_issue()), "repository": repository(&repo), "sender": user("ada") }); |
| 718 | changed(&repo, &mut issues, &json!({ "label": { "name": "bug", "color": "d73a4a" } })); |
| 719 | let mut on_pull = pull.clone(); |
| 720 | on_pull.labels.clear(); |
| 721 | on_pull.assignees.clear(); |
| 722 | on_pull.milestone = None; |
| 723 | let mut comment = a_comment(None); |
| 724 | comment.author = someone("usr_1", "ada"); |
| 725 | let issue_comment = json!({ |
| 726 | "action": "created", "issue": pull_as_issue(&repo, &on_pull), "comment": self::comment(&repo, 2, &comment, true), |
| 727 | "repository": repository(&repo), "sender": user("ada"), |
| 728 | }); |
| 729 | let data = json!({ |
| 730 | "runId": "run_01kq000000000000000000000a", "workflow": "Build", "path": ".g1t/workflows/build.yml", "number": 562, |
| 731 | "event": "push", "conclusion": "success", "ref": "refs/heads/main", "sha": "acb5820ced9479c074f688cc328bf03f341a511d", "pull": 2, |
| 732 | }); |
| 733 | let head = Commit { hash: "acb5820ced9479c074f688cc328bf03f341a511d".into(), ..a_commit() }; |
| 734 | let followed = FollowedRun { |
| 735 | data: &data, |
| 736 | workflow_id: Some("wfl_1"), |
| 737 | attempt: Some(1), |
| 738 | created_at: Some("2026-05-15T15:20:35Z"), |
| 739 | started_at: Some("2026-05-15T15:20:35Z"), |
| 740 | updated_at: Some("2026-05-15T15:21:10Z"), |
| 741 | actor: Some("ada"), |
| 742 | triggering_actor: None, |
| 743 | title: Some("Update README.md"), |
| 744 | head_commit: Some(&head), |
| 745 | }; |
| 746 | // As trigger.rs gives it. |
| 747 | let workflow_run = json!({ |
| 748 | "action": "completed", |
| 749 | "workflow_run": self::workflow_run(&repo, &followed), |
| 750 | "workflow": { "id": "wfl_1", "node_id": "wfl_1", "name": "Build", "path": data["path"], "state": "active", "html_url": "https://g1t.sh/acme/web/actions" }, |
| 751 | "repository": repository(&repo), |
| 752 | "sender": user("ada"), |
| 753 | }); |
| 754 | let release = json!({ |
| 755 | "action": "published", |
| 756 | "release": self::release(&repo, &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "main", "name": "v1.2.0", "body": "Notes", |
| 757 | "draft": false, "prerelease": false, "author": "ada", "createdAt": "2026-05-15T15:19:53Z", "publishedAt": "2026-05-15T15:20:05Z" })), |
| 758 | "repository": repository(&repo), |
| 759 | "sender": user("ada"), |
| 760 | }); |
| 761 | vec![ |
| 762 | ("push", push), |
| 763 | ("pull_request", pull_request), |
| 764 | ("pull_request_review", review), |
| 765 | ("issues", issues), |
| 766 | ("issue_comment", issue_comment), |
| 767 | ("workflow_run", workflow_run), |
| 768 | ("release", release), |
| 769 | ] |
| 770 | } |
| 771 | |
| 772 | fn fixture(event: &str) -> Value { |
| 773 | let text = match event { |
| 774 | "push" => include_str!("../tests/fixtures/push.json"), |
| 775 | "pull_request" => include_str!("../tests/fixtures/pull_request.json"), |
| 776 | "pull_request_review" => include_str!("../tests/fixtures/pull_request_review.json"), |
| 777 | "issues" => include_str!("../tests/fixtures/issues.json"), |
| 778 | "issue_comment" => include_str!("../tests/fixtures/issue_comment.json"), |
| 779 | "workflow_run" => include_str!("../tests/fixtures/workflow_run.json"), |
| 780 | "release" => include_str!("../tests/fixtures/release.json"), |
| 781 | other => panic!("no fixture for {other}"), |
| 782 | }; |
| 783 | serde_json::from_str(text).unwrap() |
| 784 | } |
| 785 | |
| 786 | #[test] |
| 787 | fn payloads_have_githubs_keys_and_types() { |
| 788 | let mut problems = Vec::new(); |
| 789 | for (event, ours) in payloads() { |
| 790 | let mut found = Vec::new(); |
| 791 | compare(event, &fixture(event), Some(&ours), "", &mut found); |
| 792 | problems.extend(found.into_iter().map(|problem| format!("{event}: {problem}"))); |
| 793 | } |
| 794 | assert!(problems.is_empty(), "g1t's payloads differ from GitHub's:\n{}", problems.join("\n")); |
| 795 | } |
| 796 | |
| 797 | #[test] |
| 798 | fn the_comparison_finds_what_differs() { |
| 799 | let mut found = Vec::new(); |
| 800 | let github = json!({ "id": 1, "user": { "login": "a" }, "labels": [{ "name": "x" }], "body": null }); |
| 801 | compare("x", &github, Some(&json!({ "id": "pr_1", "labels": [{}], "body": 3 })), "", &mut found); |
| 802 | assert_eq!(found, ["id: GitHub's is number, g1t's string", "user: missing", "labels[].name: missing"]); |
| 803 | found.clear(); |
| 804 | compare("x", &json!({ "id": 1 }), Some(&json!({ "id": 1.5 })), "", &mut found); |
| 805 | assert_eq!(found, ["id: GitHub's is a whole number, g1t's 1.5"]); |
| 806 | } |
| 807 | |
| 808 | #[test] |
| 809 | fn the_same_object_has_the_same_number_everywhere() { |
| 810 | let all = payloads(); |
| 811 | let by = |event: &str| all.iter().find(|(e, _)| *e == event).map(|(_, v)| v.clone()).unwrap(); |
| 812 | let (pr, issues, run) = (by("pull_request"), by("issues"), by("workflow_run")); |
| 813 | let repo_id = &pr["repository"]["id"]; |
| 814 | assert_eq!(repo_id, &json!(github_id(&repo().id)), "github.repository_id is the same number"); |
| 815 | assert_eq!(&pr["pull_request"]["base"]["repo"]["id"], repo_id); |
| 816 | assert_eq!(&run["workflow_run"]["repository"]["id"], repo_id); |
| 817 | // ada as sender, author and run actor; acme as owner and head user. |
| 818 | assert_eq!(pr["sender"]["id"], pr["pull_request"]["user"]["id"]); |
| 819 | assert_eq!(pr["sender"]["id"], run["workflow_run"]["actor"]["id"]); |
| 820 | assert_eq!(pr["sender"]["id"], json!(account_id("ada")), "github.actor_id is the same number"); |
| 821 | assert_eq!(pr["repository"]["owner"]["id"], pr["pull_request"]["head"]["user"]["id"]); |
| 822 | assert_eq!(pr["repository"]["owner"]["id"], json!(account_id("acme"))); |
| 823 | assert_eq!(pr["pull_request"]["labels"][0]["id"], issues["label"]["id"]); |
| 824 | // A pull request's own id and its issue's are different objects. |
| 825 | assert_ne!(pull_as_issue(&repo(), &a_pull())["id"], pr["pull_request"]["id"]); |
| 826 | assert_eq!(pr["pull_request"]["node_id"], a_pull().id); |
| 827 | } |
| 828 | |
| 829 | #[test] |
| 830 | fn a_personal_workspaces_repositories_are_a_users() { |
| 831 | let mut event = json!({ "pull_request": pull(&repo(), &a_pull()), "repository": repository(&repo()), "sender": user("ada") }); |
| 832 | assert_eq!(event["repository"]["owner"]["type"], "Organization"); |
| 833 | owned_by_person(&mut event, "Acme"); |
| 834 | assert_eq!(event["repository"]["owner"]["type"], "User"); |
| 835 | assert_eq!(event["pull_request"]["base"]["repo"]["owner"]["type"], "User"); |
| 836 | assert_eq!(event["pull_request"]["head"]["user"]["type"], "User"); |
| 837 | // Nobody else changes, a bot least of all. |
| 838 | assert_eq!(event["sender"]["type"], "User"); |
| 839 | let mut bot = json!({ "user": { "login": "acme", "type": "Bot" } }); |
| 840 | owned_by_person(&mut bot, "acme"); |
| 841 | assert_eq!(bot["user"]["type"], "Bot"); |
| 842 | } |
| 843 | |
| 844 | #[test] |
| 845 | fn a_push_says_whether_it_made_deleted_or_forced_its_ref() { |
| 846 | let repo = repo(); |
| 847 | let after = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c"; |
| 848 | let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", false); |
| 849 | assert_eq!((made["created"].clone(), made["deleted"].clone(), made["forced"].clone()), (json!(true), json!(false), json!(false))); |
| 850 | assert_eq!(made["before"], ZERO); |
| 851 | let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", true); |
| 852 | assert_eq!(forced["forced"], true); |
| 853 | assert_eq!(forced["compare"], format!("https://g1t.sh/acme/web/compare/6113728f27ae82c7b1a177c8d03f9e96e0adf246...{after}")); |
| 854 | assert_eq!(forced["head_commit"]["id"], after); |
| 855 | let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", true); |
| 856 | assert_eq!((gone["deleted"].clone(), gone["forced"].clone()), (json!(true), json!(false))); |
| 857 | } |
| 858 | |
| 859 | #[test] |
| 860 | fn a_pull_request_from_a_fork_names_the_fork() { |
| 861 | let mut pull = a_pull(); |
| 862 | pull.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() }); |
| 863 | pull.fork_repo_id = Some("rep_f".into()); |
| 864 | pull.branch = None; |
| 865 | let event = self::pull(&repo(), &pull); |
| 866 | assert_eq!(event["head"]["repo"]["full_name"], "ana/web"); |
| 867 | assert_eq!(event["head"]["repo"]["fork"], true); |
| 868 | assert_eq!(event["head"]["repo"]["id"], json!(github_id("rep_f"))); |
| 869 | assert_eq!(event["head"]["label"], "ana:pull/2"); |
| 870 | assert_eq!(event["head"]["user"]["login"], "ana"); |
| 871 | assert_eq!(event["merge_commit_sha"], Value::Null, "only once merged"); |
| 872 | } |
| 873 | } |