Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
257 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; 27] = [
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 // Coordinating work: the issue, pull request and Work pages show them.
86 "work.",
87 "session.started",
88 "session.ended",
89 "dependency.changed",
90 "overlap.detected",
91 "integration.",
92 "decision.recorded",
93];
94
95/// Whether an event of type `kind` changes a repository page.
96pub fn is_live(kind: &str) -> bool {
97 LIVE.iter().any(|live| if live.ends_with('.') { kind.starts_with(live) } else { kind == *live })
98}
99
100fn text(value: &serde_json::Value) -> Option<String> {
101 value.as_str().filter(|text| !text.is_empty()).map(|text| text.chars().take(200).collect())
102}
103
104fn number(value: &serde_json::Value) -> Option<u32> {
105 value.as_u64().and_then(|n| u32::try_from(n).ok())
106}
107
108/// What live pages hear of an event, or None when it is not about a
109/// repository or changes no page.
110pub fn change_of(event: &Event) -> Option<RepoChange> {
111 if !is_live(&event.kind) {
112 return None;
113 }
114 let data = &event.data;
115 let repo_id = event.repo_id.clone().filter(|id| !id.is_empty()).or_else(|| text(&data["repoId"]))?;
116 let family = event.kind.split('.').next().unwrap_or_default();
117 let mut change = RepoChange { repo_id, kind: event.kind.clone(), at: event.time.clone(), ..RepoChange::default() };
118 match family {
119 "git" => {
120 change.git_ref = text(&data["ref"]);
121 change.sha = text(&data["after"]);
122 change.commits = number(&data["commits"]);
123 }
124 "workflow" => {
125 change.git_ref = text(&data["ref"]);
126 change.sha = text(&data["sha"]);
127 change.run = text(&data["runId"]);
128 change.number = number(&data["pull"]);
129 }
130 "checks" => {
131 change.number = number(&data["number"]);
132 change.sha = text(&data["commit"]);
133 }
134 "status" => change.sha = text(&data["sha"]),
135 "check_run" => change.sha = text(&data["checkRun"]["headSha"]),
136 "check_suite" => change.sha = text(&data["checkSuite"]["headSha"]),
137 "code_scanning_alert" => change.number = number(&data["alertNumber"]),
138 "secret_scanning_alert" | "vulnerability_alert" | "release" | "queue" | "deployment" | "deployment_status" | "repo" | "branch" => {}
139 // Issues, pull requests, comments and reviews name their number.
140 _ => change.number = number(&data["number"]),
141 }
142 Some(change)
143}
144
145/// The changes a batch of events makes, in order, in calls of at most
146/// [`MAX_CHANGES`].
147pub fn changes_of(events: &[Event]) -> Vec<Vec<RepoChange>> {
148 let changes: Vec<RepoChange> = events.iter().filter_map(change_of).collect();
149 changes.chunks(MAX_CHANGES).map(<[RepoChange]>::to_vec).collect()
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use serde_json::json;
156
157 fn event(kind: &str, repo_id: Option<&str>, data: serde_json::Value) -> Event {
158 Event {
159 id: "evt_1".into(),
160 kind: kind.into(),
161 source: "test".into(),
162 time: "2026-10-11T10:00:00Z".into(),
163 repo_id: repo_id.map(Into::into),
164 actor: Some("usr_1".into()),
165 data,
166 }
167 }
168
169 #[test]
170 fn a_push_says_its_ref_where_it_went_and_how_many_commits() {
171 let push = event(
172 "git.push",
173 Some("rep_1"),
174 json!({ "repoId": "rep_1", "ref": "refs/heads/main", "before": "a", "after": "b", "defaultBranch": true, "commits": 3 }),
175 );
176 assert_eq!(
177 change_of(&push),
178 Some(RepoChange {
179 repo_id: "rep_1".into(),
180 kind: "git.push".into(),
181 git_ref: Some("refs/heads/main".into()),
182 sha: Some("b".into()),
183 commits: Some(3),
184 at: "2026-10-11T10:00:00Z".into(),
185 ..RepoChange::default()
186 })
187 );
188 }
189
190 #[test]
191 fn pull_requests_issues_comments_and_checks_name_their_number() {
192 for kind in ["pull.updated", "pull.merged", "issue.closed", "comment.created", "review.completed"] {
193 let change = change_of(&event(kind, Some("rep_1"), json!({ "number": 7, "title": "secret plans" }))).unwrap();
194 assert_eq!(change.number, Some(7), "{kind}");
195 }
196 let checks = change_of(&event("checks.completed", Some("rep_1"), json!({ "number": 4, "commit": "abc", "status": "passed" }))).unwrap();
197 assert_eq!((checks.number, checks.sha.as_deref()), (Some(4), Some("abc")));
198 }
199
200 #[test]
201 fn runs_name_their_id_commit_and_pull_request() {
202 let run = change_of(&event(
203 "workflow.completed",
204 Some("rep_1"),
205 json!({ "runId": "run_9", "number": 12, "ref": "refs/heads/x", "sha": "def", "pull": 5, "conclusion": "success" }),
206 ))
207 .unwrap();
208 assert_eq!(run.run.as_deref(), Some("run_9"));
209 assert_eq!(run.number, Some(5), "the pull request, not the run's own number");
210 assert_eq!(run.sha.as_deref(), Some("def"));
211 let status = change_of(&event("status.created", Some("rep_1"), json!({ "sha": "def", "context": "ci", "state": "pending" }))).unwrap();
212 assert_eq!(status.sha.as_deref(), Some("def"));
213 let check = change_of(&event("check_run.completed", Some("rep_1"), json!({ "checkRun": { "headSha": "fff" } }))).unwrap();
214 assert_eq!(check.sha.as_deref(), Some("fff"));
215 }
216
217 #[test]
218 fn nothing_written_in_the_repository_goes_along() {
219 let change = change_of(&event("issue.opened", Some("rep_1"), json!({ "number": 1, "title": "Leaked title", "body": "Leaked body" }))).unwrap();
220 let sent = serde_json::to_string(&change).unwrap();
221 assert!(!sent.contains("Leaked"));
222 assert_eq!(sent, r#"{"repo_id":"rep_1","kind":"issue.opened","number":1,"at":"2026-10-11T10:00:00Z"}"#);
223 }
224
225 #[test]
226 fn only_what_a_page_shows_and_only_with_a_repository() {
227 assert!(change_of(&event("git.fetch", Some("rep_1"), json!({}))).is_none());
228 assert!(change_of(&event("repo.collaborator_added", Some("rep_1"), json!({}))).is_none());
229 assert!(change_of(&event("pull.opened", None, json!({ "number": 1 }))).is_none());
230 // The repository from the data when the event has none.
231 assert_eq!(change_of(&event("pull.opened", None, json!({ "repoId": "rep_2", "number": 1 }))).unwrap().repo_id, "rep_2");
232 for kind in ["git.push", "git.deleted", "release.published", "secret_scanning_alert.created", "queue.changed", "deployment_status.created"] {
233 assert!(is_live(kind), "{kind}");
234 }
235 for kind in ["git.pushed", "repo.created", "user.deleted", "workspace.renamed"] {
236 assert!(!is_live(kind), "{kind}");
237 }
238 }
239
240 #[test]
241 fn coordination_names_the_issue_or_pull_request_it_is_about() {
242 for kind in ["work.claimed", "work.claim_expired", "session.started", "dependency.changed", "overlap.detected", "integration.failed", "integration.passed", "decision.recorded"] {
243 let change = change_of(&event(kind, None, json!({ "repoId": "rep_1", "number": 12, "issue": 4 }))).unwrap();
244 assert_eq!((change.repo_id.as_str(), change.number), ("rep_1", Some(12)), "{kind}");
245 }
246 // Each recorded step of a session changes no page anyone is watching live.
247 assert!(change_of(&event("session.appended", Some("rep_1"), json!({ "number": 12 }))).is_none());
248 }
249
250 #[test]
251 fn a_busy_batch_goes_in_several_calls() {
252 let events: Vec<Event> = (0..450).map(|n| event("pull.updated", Some("rep_1"), json!({ "number": n }))).collect();
253 let calls = changes_of(&events);
254 assert_eq!(calls.iter().map(Vec::len).collect::<Vec<_>>(), vec![200, 200, 50]);
255 assert!(changes_of(&[event("git.fetch", Some("rep_1"), json!({}))]).is_empty());
256 }
257}