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