Skip to content
239 linesCodeBlameRaw
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
15use serde::{Deserialize, Serialize};
16
17use crate::events::Event;
18
19/// The notify service's method that takes [`RepoChangedArgs`].
20pub 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.
24pub const RUN_UPDATED: &str = "run.updated";
25
26/// The most changes one call carries; a busier batch sends the rest in more.
27pub 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)]
31pub 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)]
57pub 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.
64const 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.
88pub fn is_live(kind: &str) -> bool {
89 LIVE.iter().any(|live| if live.ends_with('.') { kind.starts_with(live) } else { kind == *live })
90}
91
92fn 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
96fn 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.
102pub 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`].
139pub 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)]
145mod 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}