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