A workflow's event payload fills the last of the gaps it had against GitHub's. Labels on issues and pull requests carry their `color`, `description` and `default` as the repository has them (one read of its labels, only when the event has any); a push's `pusher.email` is the pusher's noreply address, or g1t's for g1t; a one-commit push lists the paths it `added`, `removed` and `modified` when a `paths` filter has the push compared anyway, which now happens before any of its runs start so every run sees the same payload; and `workflow_run.pull_requests[]` has each pull request's `id`, `number`, `url` and its `head` and `base` with `ref`, `sha` and `repo`, leaving out one from a fork as GitHub does. The fixtures carry GitHub's shapes for these, the known differences are down to the ids the API takes back, and the Actions guide says what each field holds.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
6 files+292−410/6 viewed
| 1492 | 1492 | is true when the commit before the push is not in the new commit's | |
| 1493 | 1493 | history. g1t reads the 20 latest commits to tell, so a forced push onto | |
| 1494 | 1494 | a longer history says `false`. `head_commit` is the commit pushed and | |
| 1495 | − | `commits` lists at most 20, oldest first. | |
| 1495 | + | `commits` lists at most 20, oldest first. `pusher.email` is the | |
| 1496 | + | pusher's noreply address (`<id>+<username>@users.noreply.g1t.sh`), or | |
| 1497 | + | `g1t@users.noreply.g1t.sh` for g1t. | |
| 1498 | + | - **A commit's `added`, `removed` and `modified`** list the paths the | |
| 1499 | + | push changed when it pushed one commit and a workflow on `push` has a | |
| 1500 | + | `paths` or `paths-ignore` filter, which makes g1t compare the push. | |
| 1501 | + | Otherwise, and for a push of several commits, they are empty lists. | |
| 1496 | 1502 | - **On pull request events,** `pull_request.base.sha` is the tip of the | |
| 1497 | 1503 | branch it merges into when the event happened, and | |
| 1498 | 1504 | `pull_request.merge_commit_sha` is set only once it is merged, to the | |
| ⋯ | |||
| 1500 | 1506 | - **On `workflow_run`,** `workflow_run` has the followed run's `name`, | |
| 1501 | 1507 | `path`, `event`, `status`, `conclusion`, `head_branch`, `head_sha`, | |
| 1502 | 1508 | `head_commit`, `run_number`, `run_attempt`, `actor`, `created_at`, | |
| 1503 | − | `updated_at`, `html_url`, `repository` and `pull_requests` (each with | |
| 1504 | − | its `number` and `url`). | |
| 1509 | + | `updated_at`, `html_url`, `repository` and `pull_requests`. Each pull | |
| 1510 | + | request there has its `id`, `number`, `url`, and `head` and `base` | |
| 1511 | + | with their `ref`, `sha` and `repo` (`id`, `url`, `name`). As on | |
| 1512 | + | GitHub, a pull request from a fork is not listed. | |
| 1505 | 1513 | ||
| 1506 | − | Labels on `issue` and `pull_request` have their `id` and `name` but no | |
| 1507 | − | `color`; a `labeled` event's own `label` has its color. | |
| 1514 | + | Labels on `issue`, `pull_request` and a `labeled` event's `label` have | |
| 1515 | + | their `id`, `name`, `color` and `description` as the repository has | |
| 1516 | + | them, and `default`, which is true for the labels a repository starts | |
| 1517 | + | with. | |
| 1508 | 1518 | ||
| 1509 | 1519 | ## Checks | |
| 1510 | 1520 | ||
| 13 | 13 | use g1t_actions::events::{account_id, github_id}; | |
| 14 | 14 | use g1t_contracts::{PrincipalKind, User}; | |
| 15 | 15 | use g1t_contracts::repos::{Commit, Repo, RepoPath}; | |
| 16 | − | use g1t_contracts::work::{Comment, Issue, MilestoneRef, Pull, PullStatus, State}; | |
| 16 | + | use g1t_contracts::repos::FileStatus; | |
| 17 | + | use g1t_contracts::work::{Comment, DEFAULT_LABELS, Issue, Label, MilestoneRef, Pull, PullStatus, State, label_color_for}; | |
| 17 | 18 | use serde_json::{Value, json}; | |
| 18 | 19 | ||
| 19 | 20 | use crate::{API, SITE}; | |
| ⋯ | |||
| 119 | 120 | Value::Array(names.iter().map(|name| label(repo, name, None)).collect()) | |
| 120 | 121 | } | |
| 121 | 122 | ||
| 122 | − | /// A label, by its name in `repo`. | |
| 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. | |
| 123 | 126 | fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value { | |
| 124 | − | let mut label = json!({ "id": github_id(&format!("{}:label:{name}", repo.id)), "name": name }); | |
| 125 | − | if let Some(color) = color { | |
| 126 | − | label["color"] = color.clone(); | |
| 127 | + | 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, | |
| 127 | 165 | } | |
| 128 | − | label | |
| 129 | 166 | } | |
| 130 | 167 | ||
| 131 | 168 | /// A milestone as an issue or a pull request names it. | |
| ⋯ | |||
| 158 | 195 | }) | |
| 159 | 196 | } | |
| 160 | 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 | + | ||
| 161 | 217 | /// A push. `forced` is whether `before` is not in `after`'s history, when | |
| 162 | 218 | /// that is known; a push whose history is too long to read says `false`. | |
| 163 | − | pub fn push(repo: &Repo, git_ref: &str, before: Option<&str>, after: &str, commits: &[Commit], pusher: &str, forced: bool) -> Value { | |
| 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 { | |
| 164 | 232 | let before = before.unwrap_or(ZERO); | |
| 165 | 233 | let created = before == ZERO; | |
| 166 | 234 | let deleted = after == ZERO; | |
| ⋯ | |||
| 183 | 251 | // GitHub's head commit is the one pushed: the last. | |
| 184 | 252 | "head_commit": commits.last().cloned().unwrap_or(Value::Null), | |
| 185 | 253 | "commits": commits, | |
| 186 | − | "pusher": { "name": pusher, "email": null }, | |
| 254 | + | "pusher": { "name": pusher, "email": pusher_email }, | |
| 187 | 255 | "repository": repository(repo), | |
| 188 | 256 | "sender": user(pusher), | |
| 189 | 257 | }) | |
| ⋯ | |||
| 420 | 488 | pub triggering_actor: Option<&'a str>, | |
| 421 | 489 | pub title: Option<&'a str>, | |
| 422 | 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>, | |
| 423 | 495 | } | |
| 424 | 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 | + | ||
| 425 | 524 | pub fn workflow_run(repo: &Repo, run: &FollowedRun) -> Value { | |
| 426 | 525 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 427 | 526 | let data = run.data; | |
| ⋯ | |||
| 463 | 562 | "logs_url": format!("{url}/logs"), | |
| 464 | 563 | "artifacts_url": format!("{url}/artifacts"), | |
| 465 | 564 | "workflow_url": run.workflow_id.map(|w| format!("{API}/repos/{full_name}/actions/workflows/{w}")), | |
| 466 | − | "pull_requests": data["pull"].as_u64().map(|n| vec![json!({ | |
| 467 | − | "number": n, | |
| 468 | − | "url": format!("{API}/repos/{full_name}/pulls/{n}"), | |
| 469 | − | })]).unwrap_or_default(), | |
| 565 | + | "pull_requests": run_pulls(repo, run), | |
| 470 | 566 | "repository": repository(repo), | |
| 471 | 567 | "head_repository": repository(repo), | |
| 472 | 568 | }) | |
| ⋯ | |||
| 586 | 682 | ("workflow_run", "workflow_run.id", "string"), | |
| 587 | 683 | ("workflow_run", "workflow_run.workflow_id", "string"), | |
| 588 | 684 | ("workflow_run", "workflow.id", "string"), | |
| 589 | − | // g1t keeps labels by name on issues and pull requests; only a | |
| 590 | − | // `labeled` event's own `label` has its color. | |
| 591 | − | ("pull_request", "pull_request.labels[].color", "absent"), | |
| 592 | − | ("issues", "issue.labels[].color", "absent"), | |
| 593 | − | // A push names who pushed, not their address. | |
| 594 | − | ("push", "pusher.email", "null"), | |
| 595 | 685 | ]; | |
| 596 | 686 | ||
| 597 | 687 | fn kind(value: &Value) -> &'static str { | |
| ⋯ | |||
| 705 | 795 | fn payloads() -> Vec<(&'static str, Value)> { | |
| 706 | 796 | let repo = repo(); | |
| 707 | 797 | let pull = a_pull(); | |
| 708 | − | let push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", false); | |
| 709 | − | let pull_request = json!({ | |
| 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!({ | |
| 710 | 802 | "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull), | |
| 711 | 803 | "repository": repository(&repo), "sender": user("ada"), | |
| 712 | 804 | }); | |
| 805 | + | color_labels(&mut pull_request, &known); | |
| 713 | 806 | let review = json!({ | |
| 714 | 807 | "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))), | |
| 715 | 808 | "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"), | |
| 716 | 809 | }); | |
| 717 | 810 | let mut issues = json!({ "action": "labeled", "issue": issue(&repo, &an_issue()), "repository": repository(&repo), "sender": user("ada") }); | |
| 718 | 811 | changed(&repo, &mut issues, &json!({ "label": { "name": "bug", "color": "d73a4a" } })); | |
| 812 | + | color_labels(&mut issues, &known); | |
| 719 | 813 | let mut on_pull = pull.clone(); | |
| 720 | 814 | on_pull.labels.clear(); | |
| 721 | 815 | on_pull.assignees.clear(); | |
| ⋯ | |||
| 742 | 836 | triggering_actor: None, | |
| 743 | 837 | title: Some("Update README.md"), | |
| 744 | 838 | head_commit: Some(&head), | |
| 839 | + | pull: Some(&pull), | |
| 840 | + | base_sha: Some("f95f852bd8fca8fcc58a9a2d6c842781e32a215e"), | |
| 745 | 841 | }; | |
| 746 | 842 | // As trigger.rs gives it. | |
| 747 | 843 | let workflow_run = json!({ | |
| ⋯ | |||
| 845 | 941 | fn a_push_says_whether_it_made_deleted_or_forced_its_ref() { | |
| 846 | 942 | let repo = repo(); | |
| 847 | 943 | let after = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c"; | |
| 848 | − | let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", false); | |
| 944 | + | let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", None, false); | |
| 849 | 945 | assert_eq!((made["created"].clone(), made["deleted"].clone(), made["forced"].clone()), (json!(true), json!(false), json!(false))); | |
| 850 | 946 | assert_eq!(made["before"], ZERO); | |
| 851 | − | let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", true); | |
| 947 | + | let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", None, true); | |
| 852 | 948 | assert_eq!(forced["forced"], true); | |
| 853 | 949 | assert_eq!(forced["compare"], format!("https://g1t.sh/acme/web/compare/6113728f27ae82c7b1a177c8d03f9e96e0adf246...{after}")); | |
| 854 | 950 | assert_eq!(forced["head_commit"]["id"], after); | |
| 855 | − | let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", true); | |
| 951 | + | let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", None, true); | |
| 856 | 952 | assert_eq!((gone["deleted"].clone(), gone["forced"].clone()), (json!(true), json!(false))); | |
| 857 | 953 | } | |
| 858 | 954 | ||
| 859 | 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] | |
| 860 | 1019 | fn a_pull_request_from_a_fork_names_the_fork() { | |
| 861 | 1020 | let mut pull = a_pull(); | |
| 862 | 1021 | pull.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() }); | |
| 8 | 8 | use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun}; | |
| 9 | 9 | use g1t_contracts::events::{Event, caused_by_job}; | |
| 10 | 10 | use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs}; | |
| 11 | − | use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath}; | |
| 12 | − | use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs}; | |
| 11 | + | use g1t_contracts::repos::{Commit, CompareArgs, Comparison, FileStatus, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath}; | |
| 12 | + | use g1t_contracts::work::{IssueDetail, Label, PullDetail, ViewArgs}; | |
| 13 | 13 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 14 | 14 | use g1t_contracts::time::rfc3339; | |
| 15 | 15 | use g1t_kit::now_ms; | |
| ⋯ | |||
| 53 | 53 | merge: Option<String>, | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | + | impl Subject { | |
| 57 | + | /// What the event changes, once compared: the paths for `paths` | |
| 58 | + | /// filters, and a push's commit's `added`, `removed` and `modified`. | |
| 59 | + | fn changed(&mut self, changes: Vec<(String, FileStatus)>) { | |
| 60 | + | payload::push_changes(&mut self.payload, &changes); | |
| 61 | + | self.paths = Some(changes.into_iter().map(|(path, _)| path).collect()); | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// The address a push's `pusher.email` gives: g1t's for its agent and | |
| 66 | + | /// itself, a person's noreply address (which needs no lookup) for them, | |
| 67 | + | /// and none for anyone else. | |
| 68 | + | fn pusher_email(actor: Option<&str>, username: &str) -> Option<String> { | |
| 69 | + | match actor? { | |
| 70 | + | id if id == AGENT_ID || id == g1t_contracts::system::ID => Some(g1t_contracts::system::EMAIL.to_owned()), | |
| 71 | + | id if id.starts_with("usr_") => Some(g1t_contracts::accounts::noreply_address(id, username)), | |
| 72 | + | _ => None, | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 56 | 76 | /// Whether a deployment's `ref` is a commit's full hash rather than a | |
| 57 | 77 | /// branch or tag. | |
| 58 | 78 | fn is_commit(name: &str) -> bool { | |
| ⋯ | |||
| 206 | 226 | Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next())) | |
| 207 | 227 | } | |
| 208 | 228 | ||
| 209 | − | async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> { | |
| 229 | + | /// What `head` changes from `base`: each path and how. | |
| 230 | + | async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<(String, FileStatus)>> { | |
| 210 | 231 | let compared: Outcome<Comparison> = g1t_kit::call( | |
| 211 | 232 | &self.repos, | |
| 212 | 233 | "compare", | |
| ⋯ | |||
| 219 | 240 | }, | |
| 220 | 241 | ) | |
| 221 | 242 | .await?; | |
| 222 | − | Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| f.path).collect()).unwrap_or_default()) | |
| 243 | + | Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| (f.path, f.status)).collect()).unwrap_or_default()) | |
| 223 | 244 | } | |
| 224 | 245 | ||
| 246 | + | /// The repository's labels, for their colors, when `payload` has any. | |
| 247 | + | async fn label_colors(&self, repo: &Repo, ws: &User, payload: &mut Value) -> Result<()> { | |
| 248 | + | if !payload::has_labels(payload) { | |
| 249 | + | return Ok(()); | |
| 250 | + | } | |
| 251 | + | let args = ViewArgs { repo: Self::repo_path(repo), number: 0, viewer: Some(ws.clone()), after_seq: 0 }; | |
| 252 | + | let labels: Outcome<Vec<Label>> = g1t_kit::call(&self.work, "list_labels", &args).await?; | |
| 253 | + | if let Outcome::Ok(labels) = labels { | |
| 254 | + | payload::color_labels(payload, &labels); | |
| 255 | + | } | |
| 256 | + | Ok(()) | |
| 257 | + | } | |
| 258 | + | ||
| 225 | 259 | async fn default_head(&self, repo: &Repo) -> Result<Option<String>> { | |
| 226 | 260 | self.branch_head(repo, &repo.default_branch).await | |
| 227 | 261 | } | |
| ⋯ | |||
| 318 | 352 | let before = data["before"].as_str(); | |
| 319 | 353 | let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?; | |
| 320 | 354 | let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default(); | |
| 321 | − | let payload = payload::push(repo, git_ref, before, after, &commits, sender, forced); | |
| 355 | + | let email = pusher_email(event.actor.as_deref(), sender); | |
| 356 | + | let payload = payload::push(repo, git_ref, before, after, &commits, sender, email.as_deref(), forced); | |
| 322 | 357 | Some(Subject { | |
| 323 | 358 | source: path.clone(), | |
| 324 | 359 | source_ref: Some(after.to_owned()), | |
| ⋯ | |||
| 408 | 443 | "sender": payload::user(sender), | |
| 409 | 444 | }); | |
| 410 | 445 | payload::changed(repo, &mut payload, data); | |
| 446 | + | self.label_colors(repo, ws, &mut payload).await?; | |
| 411 | 447 | // `base.sha` is the base branch's tip now, as GitHub gives | |
| 412 | 448 | // it, not where the two last agreed. | |
| 413 | 449 | if let Some(tip) = self.branch_head(repo, &base_ref).await? { | |
| ⋯ | |||
| 521 | 557 | Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head), | |
| 522 | 558 | None => None, | |
| 523 | 559 | }; | |
| 560 | + | // Its pull request, for `pull_requests[]`. | |
| 561 | + | let pull = match data["pull"].as_u64() { | |
| 562 | + | Some(number) => match g1t_kit::call::<_, Outcome<PullDetail>>(&self.work, "get_pull", &view(number as u32)).await? { | |
| 563 | + | Outcome::Ok(detail) => Some(detail.pull), | |
| 564 | + | Outcome::Fail(_) => None, | |
| 565 | + | }, | |
| 566 | + | None => None, | |
| 567 | + | }; | |
| 568 | + | let base_sha = match &pull { | |
| 569 | + | Some(pull) if pull.fork.is_none() => self.branch_head(repo, pull.base_branch(&repo.default_branch)).await?, | |
| 570 | + | _ => None, | |
| 571 | + | }; | |
| 524 | 572 | let followed = payload::FollowedRun { | |
| 573 | + | pull: pull.as_ref(), | |
| 574 | + | base_sha: base_sha.as_deref(), | |
| 525 | 575 | data, | |
| 526 | 576 | workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()), | |
| 527 | 577 | attempt: row.as_ref().map(|r| r.attempt), | |
| ⋯ | |||
| 574 | 624 | "sender": payload::user(sender), | |
| 575 | 625 | }); | |
| 576 | 626 | payload::changed(repo, &mut payload, data); | |
| 627 | + | self.label_colors(repo, ws, &mut payload).await?; | |
| 577 | 628 | if event_name == "issue_comment" { | |
| 578 | 629 | let comment_id = data["commentId"].as_str(); | |
| 579 | 630 | if action == Some("deleted") { | |
| ⋯ | |||
| 764 | 815 | if copied_in && matches!(event_name, "push" | "create") { | |
| 765 | 816 | if subject.paths.is_none() { | |
| 766 | 817 | let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone())); | |
| 767 | − | subject.paths = Some(self.changed_paths(&repo, &ws, base, head).await?); | |
| 818 | + | subject.changed(self.changed_paths(&repo, &ws, base, head).await?); | |
| 768 | 819 | } | |
| 769 | 820 | if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default()) | |
| 770 | 821 | && let Some(mirror) = &repo.mirror | |
| ⋯ | |||
| 818 | 869 | actor_id: Option<&str>, | |
| 819 | 870 | sender: &str, | |
| 820 | 871 | ) -> Result<()> { | |
| 821 | − | for file in read.files { | |
| 822 | − | let parsed = workflow::parse(&file.source); | |
| 872 | + | let parsed: Vec<_> = read.files.into_iter().map(|file| (workflow::parse(&file.source), file)).collect(); | |
| 873 | + | // A push some workflow filters by path is compared before any run | |
| 874 | + | // starts, so every run's payload lists what its commit changed. | |
| 875 | + | let filters_paths = |(parsed, _): &(std::result::Result<Workflow, _>, _)| { | |
| 876 | + | parsed.as_ref().ok().and_then(|w: &Workflow| w.trigger(event_name)).is_some_and(|t| t.paths.is_set()) | |
| 877 | + | }; | |
| 878 | + | if event_name == "push" && subject.paths.is_none() && parsed.iter().any(filters_paths) { | |
| 879 | + | let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone())); | |
| 880 | + | subject.changed(self.changed_paths(repo, ws, base, head).await?); | |
| 881 | + | } | |
| 882 | + | for (parsed, file) in parsed { | |
| 823 | 883 | let workflow = match parsed { | |
| 824 | 884 | Ok(workflow) => workflow, | |
| 825 | 885 | Err(problem) => { | |
| ⋯ | |||
| 904 | 964 | if trigger.paths.is_set() { | |
| 905 | 965 | if subject.paths.is_none() { | |
| 906 | 966 | let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone())); | |
| 907 | − | subject.paths = Some(self.changed_paths(repo, ws, base, head).await?); | |
| 967 | + | subject.changed(self.changed_paths(repo, ws, base, head).await?); | |
| 908 | 968 | } | |
| 909 | 969 | if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) { | |
| 910 | 970 | return Ok(false); | |
| ⋯ | |||
| 1453 | 1513 | use g1t_contracts::{Membership, PrincipalKind}; | |
| 1454 | 1514 | ||
| 1455 | 1515 | #[test] | |
| 1516 | + | fn a_pushers_email_is_their_noreply_address() { | |
| 1517 | + | assert_eq!(pusher_email(Some("usr_01j9zq4m8x7k2v5n3b6c1d0efg"), "Ada").as_deref(), Some("6c1d0efg+ada@users.noreply.g1t.sh")); | |
| 1518 | + | assert_eq!(pusher_email(Some(AGENT_ID), AGENT_NAME).as_deref(), Some(g1t_contracts::system::EMAIL)); | |
| 1519 | + | assert_eq!(pusher_email(Some("wsp_1"), "acme"), None); | |
| 1520 | + | assert_eq!(pusher_email(None, "acme"), None); | |
| 1521 | + | } | |
| 1522 | + | ||
| 1523 | + | #[test] | |
| 1456 | 1524 | fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() { | |
| 1457 | 1525 | let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() }; | |
| 1458 | 1526 | let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] }; | |
| 9 | 9 | "state": "open", | |
| 10 | 10 | "state_reason": null, | |
| 11 | 11 | "locked": false, | |
| 12 | − | "labels": [{ "id": 1362934389, "name": "bug", "color": "d73a4a" }], | |
| 12 | + | "labels": [{ "id": 1362934389, "node_id": "MDU6TGFiZWwxMzYyOTM0Mzg5", "name": "bug", "color": "d73a4a", "description": "Something isn't working", "default": true }], | |
| 13 | 13 | "milestone": { "id": 4317517, "number": 1, "title": "v1.0", "html_url": "https://github.com/acme/web/milestone/1" }, | |
| 14 | 14 | "user": { "login": "ada", "id": 21031068, "node_id": "MDQ6VXNlcjIxMDMxMDY4", "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" }, | |
| 15 | 15 | "assignee": { "login": "bo", "id": 21031069, "type": "User", "html_url": "https://github.com/bo", "url": "https://api.github.com/users/bo" }, | |
| ⋯ | |||
| 22 | 22 | "url": "https://api.github.com/repos/acme/web/issues/1", | |
| 23 | 23 | "repository_url": "https://api.github.com/repos/acme/web" | |
| 24 | 24 | }, | |
| 25 | − | "label": { "id": 1362934389, "name": "bug", "color": "d73a4a" }, | |
| 25 | + | "label": { "id": 1362934389, "node_id": "MDU6TGFiZWwxMzYyOTM0Mzg5", "name": "bug", "color": "d73a4a", "description": "Something isn't working", "default": true }, | |
| 26 | 26 | "repository": { "id": 186853002, "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", "full_name": "acme/web", "private": false, "owner": { "login": "acme", "id": 21031067, "type": "Organization" } }, | |
| 27 | 27 | "sender": { "login": "ada", "id": 21031068, "type": "User" } | |
| 28 | 28 | } | |
| 15 | 15 | "merged_by": null, | |
| 16 | 16 | "merge_commit_sha": null, | |
| 17 | 17 | "labels": [ | |
| 18 | − | { "id": 1362934389, "name": "bug", "color": "d73a4a" } | |
| 18 | + | { "id": 1362934389, "node_id": "MDU6TGFiZWwxMzYyOTM0Mzg5", "name": "bug", "color": "d73a4a", "description": "Something isn't working", "default": true } | |
| 19 | 19 | ], | |
| 20 | 20 | "milestone": { | |
| 21 | 21 | "id": 4317517, |
| 34 | 34 | "artifacts_url": "https://api.github.com/repos/acme/web/actions/runs/30433642/artifacts", | |
| 35 | 35 | "workflow_url": "https://api.github.com/repos/acme/web/actions/workflows/159038", | |
| 36 | 36 | "pull_requests": [ | |
| 37 | − | { "number": 2, "url": "https://api.github.com/repos/acme/web/pulls/2" } | |
| 37 | + | { | |
| 38 | + | "url": "https://api.github.com/repos/acme/web/pulls/2", | |
| 39 | + | "id": 279147437, | |
| 40 | + | "number": 2, | |
| 41 | + | "head": { | |
| 42 | + | "ref": "changes", | |
| 43 | + | "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", | |
| 44 | + | "repo": { "id": 186853002, "url": "https://api.github.com/repos/acme/web", "name": "web" } | |
| 45 | + | }, | |
| 46 | + | "base": { | |
| 47 | + | "ref": "main", | |
| 48 | + | "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e", | |
| 49 | + | "repo": { "id": 186853002, "url": "https://api.github.com/repos/acme/web", "name": "web" } | |
| 50 | + | } | |
| 51 | + | } | |
| 38 | 52 | ], | |
| 39 | 53 | "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } }, | |
| 40 | 54 | "head_repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } } |
Comments
Sign in to comment.