| 1 | //! Live repository pages: what the notify service is told when something |
| 2 | //! happens in a repository, so the pages open on it refresh themselves. |
| 3 | //! |
| 4 | //! The events service tells it of every event a repository page shows |
| 5 | //! (`repo_changed`, with [`change_of`] for each), and the actions service |
| 6 | //! of a run's jobs starting and finishing, which are not events. Notify |
| 7 | //! passes each change to the people watching that repository's pages: a |
| 8 | //! tab on one asks to (`watch_repo`, through the site, which checks they |
| 9 | //! may read it first). See docs.g1t.sh/guides/notifications/, "Pages that |
| 10 | //! update live". |
| 11 | //! |
| 12 | //! A change says only what kind of thing moved and where: never a title, a |
| 13 | //! body, a name or anything else written in the repository. |
| 14 | |
| 15 | use serde::{Deserialize, Serialize}; |
| 16 | |
| 17 | use crate::events::Event; |
| 18 | |
| 19 | /// The notify service's method that takes [`RepoChangedArgs`]. |
| 20 | pub const REPO_CHANGED: &str = "repo_changed"; |
| 21 | |
| 22 | /// What a run's job starting or finishing is told as: not an event, as |
| 23 | /// nothing else hears of it. |
| 24 | pub const RUN_UPDATED: &str = "run.updated"; |
| 25 | |
| 26 | /// The most changes one call carries; a busier batch sends the rest in more. |
| 27 | pub const MAX_CHANGES: usize = 200; |
| 28 | |
| 29 | /// Something that happened in a repository, as live pages hear of it. |
| 30 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 31 | pub struct RepoChange { |
| 32 | pub repo_id: String, |
| 33 | /// The event's type (`git.push`, `pull.updated`, `checks.completed`, …), |
| 34 | /// or [`RUN_UPDATED`]. |
| 35 | pub kind: String, |
| 36 | /// The full ref a push or deletion moved (`refs/heads/main`), or a run's. |
| 37 | #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")] |
| 38 | pub git_ref: Option<String>, |
| 39 | /// The issue or pull request it is on, or the pull request a run ran for. |
| 40 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 41 | pub number: Option<u32>, |
| 42 | /// The commit: where a push left its ref, the one checked or reported on. |
| 43 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 44 | pub sha: Option<String>, |
| 45 | /// A workflow run's id. |
| 46 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 47 | pub run: Option<String>, |
| 48 | /// How many commits a push brought to its branch, when it was counted. |
| 49 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 50 | pub commits: Option<u32>, |
| 51 | /// RFC 3339. |
| 52 | pub at: String, |
| 53 | } |
| 54 | |
| 55 | /// `repo_changed`. |
| 56 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] |
| 57 | pub struct RepoChangedArgs { |
| 58 | pub changes: Vec<RepoChange>, |
| 59 | } |
| 60 | |
| 61 | /// The events a repository page shows: exact types, and families ending |
| 62 | /// in a dot. Anything else (reads, fetches, settings, audit) changes no |
| 63 | /// page anyone is looking at. |
| 64 | const LIVE: [&str; 20] = [ |
| 65 | "git.push", |
| 66 | "git.deleted", |
| 67 | "branch.renamed", |
| 68 | "repo.default_branch_changed", |
| 69 | "pull.", |
| 70 | "issue.", |
| 71 | "comment.", |
| 72 | "review.", |
| 73 | "checks.", |
| 74 | "check_run.", |
| 75 | "check_suite.", |
| 76 | "status.created", |
| 77 | "workflow.", |
| 78 | "release.", |
| 79 | "queue.changed", |
| 80 | "deployment.", |
| 81 | "deployment_status.", |
| 82 | "secret_scanning_alert.", |
| 83 | "code_scanning_alert.", |
| 84 | "vulnerability_alert.", |
| 85 | ]; |
| 86 | |
| 87 | /// Whether an event of type `kind` changes a repository page. |
| 88 | pub fn is_live(kind: &str) -> bool { |
| 89 | LIVE.iter().any(|live| if live.ends_with('.') { kind.starts_with(live) } else { kind == *live }) |
| 90 | } |
| 91 | |
| 92 | fn text(value: &serde_json::Value) -> Option<String> { |
| 93 | value.as_str().filter(|text| !text.is_empty()).map(|text| text.chars().take(200).collect()) |
| 94 | } |
| 95 | |
| 96 | fn number(value: &serde_json::Value) -> Option<u32> { |
| 97 | value.as_u64().and_then(|n| u32::try_from(n).ok()) |
| 98 | } |
| 99 | |
| 100 | /// What live pages hear of an event, or None when it is not about a |
| 101 | /// repository or changes no page. |
| 102 | pub fn change_of(event: &Event) -> Option<RepoChange> { |
| 103 | if !is_live(&event.kind) { |
| 104 | return None; |
| 105 | } |
| 106 | let data = &event.data; |
| 107 | let repo_id = event.repo_id.clone().filter(|id| !id.is_empty()).or_else(|| text(&data["repoId"]))?; |
| 108 | let family = event.kind.split('.').next().unwrap_or_default(); |
| 109 | let mut change = RepoChange { repo_id, kind: event.kind.clone(), at: event.time.clone(), ..RepoChange::default() }; |
| 110 | match family { |
| 111 | "git" => { |
| 112 | change.git_ref = text(&data["ref"]); |
| 113 | change.sha = text(&data["after"]); |
| 114 | change.commits = number(&data["commits"]); |
| 115 | } |
| 116 | "workflow" => { |
| 117 | change.git_ref = text(&data["ref"]); |
| 118 | change.sha = text(&data["sha"]); |
| 119 | change.run = text(&data["runId"]); |
| 120 | change.number = number(&data["pull"]); |
| 121 | } |
| 122 | "checks" => { |
| 123 | change.number = number(&data["number"]); |
| 124 | change.sha = text(&data["commit"]); |
| 125 | } |
| 126 | "status" => change.sha = text(&data["sha"]), |
| 127 | "check_run" => change.sha = text(&data["checkRun"]["headSha"]), |
| 128 | "check_suite" => change.sha = text(&data["checkSuite"]["headSha"]), |
| 129 | "code_scanning_alert" => change.number = number(&data["alertNumber"]), |
| 130 | "secret_scanning_alert" | "vulnerability_alert" | "release" | "queue" | "deployment" | "deployment_status" | "repo" | "branch" => {} |
| 131 | // Issues, pull requests, comments and reviews name their number. |
| 132 | _ => change.number = number(&data["number"]), |
| 133 | } |
| 134 | Some(change) |
| 135 | } |
| 136 | |
| 137 | /// The changes a batch of events makes, in order, in calls of at most |
| 138 | /// [`MAX_CHANGES`]. |
| 139 | pub fn changes_of(events: &[Event]) -> Vec<Vec<RepoChange>> { |
| 140 | let changes: Vec<RepoChange> = events.iter().filter_map(change_of).collect(); |
| 141 | changes.chunks(MAX_CHANGES).map(<[RepoChange]>::to_vec).collect() |
| 142 | } |
| 143 | |
| 144 | #[cfg(test)] |
| 145 | mod tests { |
| 146 | use super::*; |
| 147 | use serde_json::json; |
| 148 | |
| 149 | fn event(kind: &str, repo_id: Option<&str>, data: serde_json::Value) -> Event { |
| 150 | Event { |
| 151 | id: "evt_1".into(), |
| 152 | kind: kind.into(), |
| 153 | source: "test".into(), |
| 154 | time: "2026-10-11T10:00:00Z".into(), |
| 155 | repo_id: repo_id.map(Into::into), |
| 156 | actor: Some("usr_1".into()), |
| 157 | data, |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | #[test] |
| 162 | fn a_push_says_its_ref_where_it_went_and_how_many_commits() { |
| 163 | let push = event( |
| 164 | "git.push", |
| 165 | Some("rep_1"), |
| 166 | json!({ "repoId": "rep_1", "ref": "refs/heads/main", "before": "a", "after": "b", "defaultBranch": true, "commits": 3 }), |
| 167 | ); |
| 168 | assert_eq!( |
| 169 | change_of(&push), |
| 170 | Some(RepoChange { |
| 171 | repo_id: "rep_1".into(), |
| 172 | kind: "git.push".into(), |
| 173 | git_ref: Some("refs/heads/main".into()), |
| 174 | sha: Some("b".into()), |
| 175 | commits: Some(3), |
| 176 | at: "2026-10-11T10:00:00Z".into(), |
| 177 | ..RepoChange::default() |
| 178 | }) |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn pull_requests_issues_comments_and_checks_name_their_number() { |
| 184 | for kind in ["pull.updated", "pull.merged", "issue.closed", "comment.created", "review.completed"] { |
| 185 | let change = change_of(&event(kind, Some("rep_1"), json!({ "number": 7, "title": "secret plans" }))).unwrap(); |
| 186 | assert_eq!(change.number, Some(7), "{kind}"); |
| 187 | } |
| 188 | let checks = change_of(&event("checks.completed", Some("rep_1"), json!({ "number": 4, "commit": "abc", "status": "passed" }))).unwrap(); |
| 189 | assert_eq!((checks.number, checks.sha.as_deref()), (Some(4), Some("abc"))); |
| 190 | } |
| 191 | |
| 192 | #[test] |
| 193 | fn runs_name_their_id_commit_and_pull_request() { |
| 194 | let run = change_of(&event( |
| 195 | "workflow.completed", |
| 196 | Some("rep_1"), |
| 197 | json!({ "runId": "run_9", "number": 12, "ref": "refs/heads/x", "sha": "def", "pull": 5, "conclusion": "success" }), |
| 198 | )) |
| 199 | .unwrap(); |
| 200 | assert_eq!(run.run.as_deref(), Some("run_9")); |
| 201 | assert_eq!(run.number, Some(5), "the pull request, not the run's own number"); |
| 202 | assert_eq!(run.sha.as_deref(), Some("def")); |
| 203 | let status = change_of(&event("status.created", Some("rep_1"), json!({ "sha": "def", "context": "ci", "state": "pending" }))).unwrap(); |
| 204 | assert_eq!(status.sha.as_deref(), Some("def")); |
| 205 | let check = change_of(&event("check_run.completed", Some("rep_1"), json!({ "checkRun": { "headSha": "fff" } }))).unwrap(); |
| 206 | assert_eq!(check.sha.as_deref(), Some("fff")); |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn nothing_written_in_the_repository_goes_along() { |
| 211 | let change = change_of(&event("issue.opened", Some("rep_1"), json!({ "number": 1, "title": "Leaked title", "body": "Leaked body" }))).unwrap(); |
| 212 | let sent = serde_json::to_string(&change).unwrap(); |
| 213 | assert!(!sent.contains("Leaked")); |
| 214 | assert_eq!(sent, r#"{"repo_id":"rep_1","kind":"issue.opened","number":1,"at":"2026-10-11T10:00:00Z"}"#); |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn only_what_a_page_shows_and_only_with_a_repository() { |
| 219 | assert!(change_of(&event("git.fetch", Some("rep_1"), json!({}))).is_none()); |
| 220 | assert!(change_of(&event("repo.collaborator_added", Some("rep_1"), json!({}))).is_none()); |
| 221 | assert!(change_of(&event("pull.opened", None, json!({ "number": 1 }))).is_none()); |
| 222 | // The repository from the data when the event has none. |
| 223 | assert_eq!(change_of(&event("pull.opened", None, json!({ "repoId": "rep_2", "number": 1 }))).unwrap().repo_id, "rep_2"); |
| 224 | for kind in ["git.push", "git.deleted", "release.published", "secret_scanning_alert.created", "queue.changed", "deployment_status.created"] { |
| 225 | assert!(is_live(kind), "{kind}"); |
| 226 | } |
| 227 | for kind in ["git.pushed", "repo.created", "user.deleted", "workspace.renamed"] { |
| 228 | assert!(!is_live(kind), "{kind}"); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn a_busy_batch_goes_in_several_calls() { |
| 234 | let events: Vec<Event> = (0..450).map(|n| event("pull.updated", Some("rep_1"), json!({ "number": n }))).collect(); |
| 235 | let calls = changes_of(&events); |
| 236 | assert_eq!(calls.iter().map(Vec::len).collect::<Vec<_>>(), vec![200, 200, 50]); |
| 237 | assert!(changes_of(&[event("git.fetch", Some("rep_1"), json!({}))]).is_empty()); |
| 238 | } |
| 239 | } |