g1t/crates/contracts/src/events.rs

601 lines20,717 bytesCodeBlame
1//! Events published on the bus, and the events service that carries them.
2//! Mirrors `packages/contracts/src/events.ts`.
3
4use serde::Serialize;
5
6/// What a publisher supplies; the bus fills in the id and time.
7#[derive(Debug, Serialize)]
8#[serde(rename_all = "camelCase")]
9pub struct NewEvent<T: Serialize> {
10 #[serde(rename = "type")]
11 pub kind: &'static str,
12 /// The service that published it.
13 pub source: &'static str,
14 /// The repo the event concerns.
15 pub repo_id: Option<String>,
16 /// The user or agent that caused it, if any.
17 pub actor: Option<String>,
18 pub data: T,
19}
20
21#[derive(Debug, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct RepoCreated {
24 pub repo_id: String,
25 pub namespace: String,
26 pub name: String,
27 pub is_private: bool,
28}
29
30/// The payload of `repo.collaborator_added`, `repo.collaborator_removed`
31/// and `repo.collaborator_role_changed`: a person's own role on one
32/// repository (see `access`). `role` is the role they have now (null once
33/// removed); `previous_role` what they had before (null when added).
34#[derive(Debug, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct RepoCollaborator {
37 pub repo_id: String,
38 pub namespace: String,
39 pub name: String,
40 pub username: String,
41 pub role: Option<crate::access::RepoRole>,
42 pub previous_role: Option<crate::access::RepoRole>,
43}
44
45#[derive(Debug, Serialize)]
46#[serde(rename_all = "camelCase")]
47pub struct RepoForked {
48 pub repo_id: String,
49 pub source_repo_id: String,
50 pub pull_id: String,
51}
52
53/// One branch or tag moved by a push. `after` is the commit it points to now.
54#[derive(Debug, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct GitPush {
57 pub repo_id: String,
58 /// The full ref, such as `refs/heads/main`.
59 #[serde(rename = "ref")]
60 pub git_ref: String,
61 /// Where it pointed before; absent for a new branch or tag.
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub before: Option<String>,
64 pub after: String,
65 /// Whether the ref is the repository's default branch.
66 pub default_branch: bool,
67}
68
69/// The payload of `issue.opened`, `issue.updated`, `issue.assigned`,
70/// `issue.closed` and `issue.reopened`; each uses the fields that apply to it.
71#[derive(Debug, Default, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct IssueEvent {
74 pub issue_id: String,
75 pub repo_id: String,
76 pub number: u32,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub title: Option<String>,
79 /// On close: `completed` or `not_planned`.
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub reason: Option<&'static str>,
82 /// On close: the number of the pull request whose merge closed it.
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub resolved_by: Option<u32>,
85 /// On `issue.assigned`: the people it is now assigned to.
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub assignees: Option<Vec<String>>,
88}
89
90/// The payload of `pull.opened`, `pull.ready`, `pull.updated` (its head
91/// moved), `pull.closed` and `pull.merged`; each uses the fields that apply to it.
92#[derive(Debug, Default, Serialize)]
93#[serde(rename_all = "camelCase")]
94pub struct PullEvent {
95 pub pull_id: String,
96 pub repo_id: String,
97 pub number: u32,
98 /// The number of the issue it is for.
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub issue: Option<u32>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub agent: Option<String>,
103 /// On merge: the commit the branch now points to. On update and when
104 /// marked ready: the head of the change.
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub commit: Option<String>,
107 /// On close: the pull request that was merged instead.
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub superseded_by: Option<u32>,
110 /// How sure g1t is of a g1t agent's change, once it has worked that out.
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub confidence: Option<crate::work::Confidence>,
113}
114
115/// `checks.completed`: a run of an issue's acceptance checks against a pull
116/// request finished.
117#[derive(Debug, Serialize)]
118#[serde(rename_all = "camelCase")]
119pub struct ChecksEvent {
120 pub pull_id: String,
121 pub repo_id: String,
122 pub number: u32,
123 /// `passed`, `failed` or `errored`.
124 pub status: &'static str,
125 /// The commit that was checked.
126 pub commit: String,
127}
128
129/// `workflow.completed`: a GitHub Actions run finished.
130#[derive(Debug, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct WorkflowEvent {
133 pub run_id: String,
134 pub repo_id: String,
135 /// The workflow's name, and its file.
136 pub workflow: String,
137 pub path: String,
138 /// The run's number among the workflow's runs.
139 pub number: u64,
140 /// The GitHub event that started it, such as `push`.
141 pub event: String,
142 /// `success`, `failure`, `cancelled` or `skipped`.
143 pub conclusion: String,
144 #[serde(rename = "ref")]
145 pub git_ref: String,
146 pub sha: String,
147 /// The pull request it ran for, if any.
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub pull: Option<u32>,
150}
151
152/// `review.completed`: a g1t agent finished reviewing a pull request, or
153/// could not.
154#[derive(Debug, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct ReviewEvent {
157 pub pull_id: String,
158 pub repo_id: String,
159 pub number: u32,
160 /// `approve` or `request_changes`; absent when no review was written.
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub verdict: Option<&'static str>,
163}
164
165/// `comment.created`. `number` is the issue or pull request commented on.
166#[derive(Debug, Serialize)]
167#[serde(rename_all = "camelCase")]
168pub struct CommentCreated {
169 pub comment_id: String,
170 pub repo_id: String,
171 pub number: u32,
172 /// Set when the comment is on a pull request.
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub pull_id: Option<String>,
175 /// Set when the comment is a review: approve or request changes.
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub verdict: Option<crate::work::Verdict>,
178}
179
180#[derive(Debug, Serialize)]
181#[serde(rename_all = "camelCase")]
182pub struct SessionAppended {
183 pub pull_id: String,
184 pub repo_id: String,
185 pub number: u32,
186 pub count: u32,
187}
188
189/// An event as stored in the log and delivered to subscribers. `data` is
190/// left as JSON; each reader decodes the types it cares about.
191#[derive(Clone, Debug, Serialize, serde::Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct Event {
194 /// Sorts by the time the event was published.
195 pub id: String,
196 #[serde(rename = "type")]
197 pub kind: String,
198 /// The service that published it.
199 pub source: String,
200 /// RFC 3339.
201 pub time: String,
202 /// The repo the event concerns.
203 pub repo_id: Option<String>,
204 /// The user or agent that caused it, if any.
205 pub actor: Option<String>,
206 pub data: serde_json::Value,
207}
208
209/// `publish`, as a publisher sends it. Returns nothing.
210#[derive(Debug, Serialize)]
211pub struct Publish<T: Serialize> {
212 pub events: Vec<NewEvent<T>>,
213}
214
215/// `publish`, as the events service reads it.
216#[derive(Debug, serde::Deserialize)]
217pub struct PublishArgs {
218 pub events: Vec<Published>,
219}
220
221/// A [`NewEvent`] of any type, as received.
222#[derive(Debug, serde::Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct Published {
225 #[serde(rename = "type")]
226 pub kind: String,
227 pub source: String,
228 #[serde(default)]
229 pub repo_id: Option<String>,
230 #[serde(default)]
231 pub actor: Option<String>,
232 pub data: serde_json::Value,
233}
234
235/// `list`: events from the log, newest first. Returns `Vec<Event>`.
236#[derive(Debug, Default, Serialize, serde::Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct ListArgs {
239 #[serde(default)]
240 pub repo_id: Option<String>,
241 /// Only these types; all types when empty.
242 #[serde(default)]
243 pub types: Vec<String>,
244 /// Only events older than this event id.
245 #[serde(default)]
246 pub before: Option<String>,
247 #[serde(default)]
248 pub limit: Option<u32>,
249}
250
251/// `workspace.renamed`: a workspace's slug changed from `from` to `to`.
252/// Every service that stores a slug moves its rows to the workspace's
253/// *current* slug (ask identity by `workspace_id`), so that a repeated or
254/// late delivery after a second rename still lands in the right place.
255#[derive(Clone, Debug, Serialize, serde::Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct WorkspaceRenamed {
258 pub workspace_id: String,
259 pub from: String,
260 pub to: String,
261}
262
263impl WorkspaceRenamed {
264 /// The slugs whose rows move to `current`: the two this rename names,
265 /// minus `current` itself. Moving rows keyed by either converges on the
266 /// current slug whatever order renames are delivered in.
267 pub fn stale_slugs(&self, current: &str) -> Vec<String> {
268 let mut slugs: Vec<String> = Vec::new();
269 for slug in [&self.from, &self.to] {
270 if slug != current && !slugs.contains(slug) {
271 slugs.push(slug.clone());
272 }
273 }
274 slugs
275 }
276}
277
278/// `repo.updated`: a repository's description, topics or visibility
279/// changed. `visibility_changed` says whether it went public or private,
280/// which `repo.visibility_changed` also announces on its own.
281#[derive(Debug, Serialize, serde::Deserialize)]
282#[serde(rename_all = "camelCase")]
283pub struct RepoUpdated {
284 pub repo_id: String,
285 pub namespace: String,
286 pub name: String,
287 pub is_private: bool,
288 #[serde(default)]
289 pub visibility_changed: bool,
290}
291
292/// `repo.visibility_changed`: a repository went public or private.
293#[derive(Debug, Serialize, serde::Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct RepoVisibilityChanged {
296 pub repo_id: String,
297 pub is_private: bool,
298}
299
300/// `repo.renamed`: a repository's name changed within its workspace,
301/// keeping its id and its git store key. Like `repo.transferred`, a path
302/// change: every service that keeps rows under the repository's path moves
303/// them to its *current* path (ask repos `path_by_id`), so a repeated or
304/// late delivery after a second rename or a transfer still lands in the
305/// right place. `g1t_kit::transfer::on_event` handles both.
306#[derive(Clone, Debug, Serialize, serde::Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct RepoRenamed {
309 pub repo_id: String,
310 /// The workspace it is in.
311 pub namespace: String,
312 /// Its old name.
313 pub from: String,
314 /// Its new name.
315 pub to: String,
316}
317
318impl RepoRenamed {
319 /// The paths whose rows move to `current` (`namespace/name`): the two
320 /// this rename names, minus `current`.
321 pub fn stale_paths(&self, current: &str) -> Vec<String> {
322 let mut paths: Vec<String> = Vec::new();
323 for name in [&self.from, &self.to] {
324 let path = format!("{}/{name}", self.namespace);
325 if path != current && !paths.contains(&path) {
326 paths.push(path);
327 }
328 }
329 paths
330 }
331}
332
333/// `repo.deleted`: a repository was deleted. It is hidden everywhere and
334/// git refuses it, but it can be restored until `purge_after`, so services
335/// stop what runs for it (agents, workflows, deployments, indexing,
336/// webhook deliveries) and hide it, and keep what they hold until
337/// `repo.purged`. `repo.restored` brings it back.
338#[derive(Clone, Debug, Serialize, serde::Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct RepoDeleted {
341 pub repo_id: String,
342 #[serde(default)]
343 pub namespace: String,
344 #[serde(default)]
345 pub name: String,
346 #[serde(default)]
347 pub is_private: bool,
348 /// RFC 3339: when it is purged unless restored first.
349 #[serde(default)]
350 pub purge_after: String,
351}
352
353/// `repo.restored`: a deleted repository is back, at its path, as it was.
354/// Services start again what `repo.deleted` stopped: index it, deploy its
355/// production, show it.
356#[derive(Clone, Debug, Serialize, serde::Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct RepoRestored {
359 pub repo_id: String,
360 pub namespace: String,
361 pub name: String,
362 pub is_private: bool,
363}
364
365/// `repo.purged`: a deleted repository is gone for good, its git data
366/// with it. Services drop every row they keep for it by `repo_id`, except
367/// history that belongs to its workspace: ledgers, invoices and the audit
368/// log. Its path is free for a new repository.
369#[derive(Clone, Debug, Serialize, serde::Deserialize)]
370#[serde(rename_all = "camelCase")]
371pub struct RepoPurged {
372 pub repo_id: String,
373 pub namespace: String,
374 pub name: String,
375}
376
377/// `repo.archived` and `repo.unarchived`: a repository became read-only,
378/// or writable again. While archived, pushes are refused, issues and pull
379/// requests are locked, and agents and workflows do not run for it; its
380/// deployments keep serving.
381#[derive(Clone, Debug, Serialize, serde::Deserialize)]
382#[serde(rename_all = "camelCase")]
383pub struct RepoArchived {
384 pub repo_id: String,
385 pub namespace: String,
386 pub name: String,
387 pub archived: bool,
388}
389
390/// `repo.default_branch_changed`: the branch everything lands on is now
391/// `to`. `renamed` says whether `from` was renamed to `to` (open pull
392/// requests into it now target `to`) rather than another branch chosen.
393#[derive(Clone, Debug, Serialize, serde::Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct RepoDefaultBranchChanged {
396 pub repo_id: String,
397 pub from: String,
398 pub to: String,
399 #[serde(default)]
400 pub renamed: bool,
401}
402
403/// `branch.renamed`: a branch was renamed. Pull requests from or into
404/// `from` follow it to `to`, and web addresses that name `from` redirect.
405#[derive(Clone, Debug, Serialize, serde::Deserialize)]
406#[serde(rename_all = "camelCase")]
407pub struct BranchRenamed {
408 pub repo_id: String,
409 pub from: String,
410 pub to: String,
411 /// Whether it is the default branch.
412 #[serde(default)]
413 pub default_branch: bool,
414}
415
416/// `repo.transferred`: a repository moved from one workspace to another,
417/// keeping its id and its name. Every service that keeps a repository
418/// under its path (`namespace/name`) or its workspace's slug moves those
419/// rows to the repository's *current* path (ask repos `path_by_id`), so a
420/// repeated or late delivery after a second transfer still lands in the
421/// right place. What was charged or recorded before the transfer stays
422/// with the workspace it happened in.
423#[derive(Clone, Debug, Serialize, serde::Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct RepoTransferred {
426 pub repo_id: String,
427 pub name: String,
428 /// The workspace it left.
429 pub from: String,
430 /// The workspace it went to.
431 pub to: String,
432}
433
434impl RepoTransferred {
435 /// The paths whose rows move to `current` (`namespace/name`): the two
436 /// this transfer names, minus `current`. Moving rows keyed by either
437 /// converges whatever order transfers are delivered in.
438 pub fn stale_paths(&self, current: &str) -> Vec<String> {
439 let mut paths: Vec<String> = Vec::new();
440 for namespace in [&self.from, &self.to] {
441 let path = format!("{namespace}/{}", self.name);
442 if path != current && !paths.contains(&path) {
443 paths.push(path);
444 }
445 }
446 paths
447 }
448}
449
450/// `workspace.deleted`: a workspace is gone. Services drop what they keep
451/// for it alone (its webhooks, integrations, secrets, memory, guardrails,
452/// agent queue) and keep what is history: ledgers, invoices and the audit
453/// log stay under its slug, which is never given to another workspace.
454#[derive(Clone, Debug, Serialize, serde::Deserialize)]
455#[serde(rename_all = "camelCase")]
456pub struct WorkspaceDeleted {
457 pub workspace_id: String,
458 pub slug: String,
459}
460
461/// `user.updated`: an account was made, or changed what its profile shows
462/// (name, bio, avatar). Nothing private: ask identity for the profile.
463#[derive(Debug, Serialize, serde::Deserialize)]
464#[serde(rename_all = "camelCase")]
465pub struct UserUpdated {
466 pub username: String,
467}
468
469/// `user.email_added`, `user.email_verified`, `user.email_removed` and
470/// `user.primary_email_changed`: an account's addresses changed. Never the
471/// address itself; ask identity, as the person, for that.
472#[derive(Debug, Serialize, serde::Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct UserEmailChanged {
475 pub user_id: String,
476 /// Whether g1t staff made the change.
477 #[serde(default)]
478 pub by_staff: bool,
479}
480
481/// `workspace.updated`: a workspace was made, or its name, description or
482/// icon changed. Ask identity for it by slug.
483#[derive(Debug, Serialize, serde::Deserialize)]
484#[serde(rename_all = "camelCase")]
485pub struct WorkspaceUpdated {
486 pub workspace_id: String,
487 pub slug: String,
488}
489
490/// `invite.created`: someone (or staff) made an invite. Never the code or
491/// the address it is for.
492#[derive(Debug, Serialize, serde::Deserialize)]
493#[serde(rename_all = "camelCase")]
494pub struct InviteCreated {
495 pub invite_id: String,
496 /// The account that made it; null when staff did.
497 pub inviter_id: Option<String>,
498 /// The workspace it joins.
499 pub workspace_id: Option<String>,
500 /// Whether it is bound to one email address.
501 pub bound: bool,
502}
503
504/// `invite.redeemed`: an invite was used, by a new account or by an
505/// existing one joining a workspace.
506#[derive(Debug, Serialize, serde::Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct InviteRedeemed {
509 pub invite_id: String,
510 pub user_id: String,
511 pub inviter_id: Option<String>,
512 pub workspace_id: Option<String>,
513 /// Whether it made the account.
514 pub created_account: bool,
515}
516
517/// `waitlist.requested`: someone asked for access. Ask identity's staff
518/// methods for the entry; the address is not in the event.
519#[derive(Debug, Serialize, serde::Deserialize)]
520#[serde(rename_all = "camelCase")]
521pub struct WaitlistRequested {
522 pub entry_id: String,
523}
524
525/// `queue.changed`: a repository's merge queue gained, lost or settled an
526/// entry, so the next batch may be ready to test.
527#[derive(Debug, Serialize)]
528#[serde(rename_all = "camelCase")]
529pub struct QueueChanged {
530 pub repo_id: String,
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 fn renamed(from: &str, to: &str) -> WorkspaceRenamed {
538 WorkspaceRenamed {
539 workspace_id: "wsp_1".into(),
540 from: from.into(),
541 to: to.into(),
542 }
543 }
544
545 #[test]
546 fn stale_slugs_leave_out_the_current_one() {
547 assert_eq!(renamed("a", "b").stale_slugs("b"), vec!["a"]);
548 // Delivered after a second rename, b → c: both move to c.
549 assert_eq!(renamed("a", "b").stale_slugs("c"), vec!["a", "b"]);
550 // Renamed back: a → b → a.
551 assert_eq!(renamed("a", "b").stale_slugs("a"), vec!["b"]);
552 }
553
554 fn transferred(from: &str, to: &str) -> RepoTransferred {
555 RepoTransferred {
556 repo_id: "rep_1".into(),
557 name: "rocket".into(),
558 from: from.into(),
559 to: to.into(),
560 }
561 }
562
563 #[test]
564 fn stale_paths_leave_out_the_current_one() {
565 assert_eq!(transferred("a", "b").stale_paths("b/rocket"), vec!["a/rocket"]);
566 // Delivered after a second transfer, b → c: both move to c.
567 assert_eq!(
568 transferred("a", "b").stale_paths("c/rocket"),
569 vec!["a/rocket", "b/rocket"]
570 );
571 // Transferred back: a → b → a.
572 assert_eq!(transferred("a", "b").stale_paths("a/rocket"), vec!["b/rocket"]);
573 }
574
575 #[test]
576 fn a_transfer_reads_as_published() {
577 let data = serde_json::json!({ "repoId": "rep_1", "name": "rocket", "from": "a", "to": "b" });
578 let event: RepoTransferred = serde_json::from_value(data).unwrap();
579 assert_eq!((event.from.as_str(), event.to.as_str()), ("a", "b"));
580 }
581
582 #[test]
583 fn a_rename_names_both_paths_in_its_workspace() {
584 let renamed = RepoRenamed {
585 repo_id: "rep_1".into(),
586 namespace: "acme".into(),
587 from: "old".into(),
588 to: "new".into(),
589 };
590 assert_eq!(renamed.stale_paths("acme/new"), vec!["acme/old"]);
591 // Delivered after a transfer: both names in acme move.
592 assert_eq!(renamed.stale_paths("flagon/new"), vec!["acme/old", "acme/new"]);
593 }
594
595 #[test]
596 fn reads_the_published_payload() {
597 let data = serde_json::json!({ "workspaceId": "wsp_1", "from": "a", "to": "b" });
598 let event: WorkspaceRenamed = serde_json::from_value(data).unwrap();
599 assert_eq!((event.from.as_str(), event.to.as_str()), ("a", "b"));
600 }
601}