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
1,525 linesCodeBlameRaw
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/// The key in an event's `data` that marks what a workflow job's own token
7/// (`G1T_TOKEN`) did, with the run's id as its value. The actions service
8/// starts no workflows for such an event, as GitHub starts none for what
9/// its `GITHUB_TOKEN` does, so a workflow cannot set itself off.
10pub const CAUSED_BY_JOB: &str = "causedByJob";
11
12/// The run whose job's token caused an event, if one did.
13pub fn caused_by_job(data: &serde_json::Value) -> Option<&str> {
14 data[CAUSED_BY_JOB].as_str().filter(|run| !run.is_empty())
15}
16
17/// The run whose job's token `actor` is acting with, if it is one.
18pub fn job_run_of(actor: &crate::User) -> Option<&str> {
19 actor.token.as_deref().and_then(|token| token.job.as_ref()).map(|job| job.run_id.as_str())
20}
21
22/// `data` as JSON, marked as a workflow job's doing when `actor` acted
23/// with a job's token (see [`CAUSED_BY_JOB`]).
24pub fn marked<T: Serialize>(data: T, actor: Option<&crate::User>) -> serde_json::Value {
25 let mut value = serde_json::to_value(data).unwrap_or(serde_json::Value::Null);
26 if let (Some(run), serde_json::Value::Object(map)) = (actor.and_then(job_run_of), &mut value) {
27 map.insert(CAUSED_BY_JOB.to_owned(), serde_json::Value::String(run.to_owned()));
28 }
29 value
30}
31
32/// `data` as JSON, marked as a workflow job's doing when the event it
33/// follows from (`cause`, its data) was: a push by a job's token moves its
34/// pull request, and that starts no workflows either.
35pub fn carried<T: Serialize>(data: T, cause: &serde_json::Value) -> serde_json::Value {
36 let mut value = serde_json::to_value(data).unwrap_or(serde_json::Value::Null);
37 if let (Some(run), serde_json::Value::Object(map)) = (caused_by_job(cause), &mut value) {
38 map.insert(CAUSED_BY_JOB.to_owned(), serde_json::Value::String(run.to_owned()));
39 }
40 value
41}
42
43/// What a publisher supplies; the bus fills in the id and time.
44#[derive(Debug, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct NewEvent<T: Serialize> {
47 #[serde(rename = "type")]
48 pub kind: &'static str,
49 /// The service that published it.
50 pub source: &'static str,
51 /// The repo the event concerns.
52 pub repo_id: Option<String>,
53 /// The user or agent that caused it, if any.
54 pub actor: Option<String>,
55 pub data: T,
56}
57
58#[derive(Debug, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct RepoCreated {
61 pub repo_id: String,
62 pub namespace: String,
63 pub name: String,
64 pub is_private: bool,
65 /// The repository it was forked from, when it is a fork.
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub parent_id: Option<String>,
68}
69
70/// The payload of `repo.collaborator_added`, `repo.collaborator_removed`
71/// and `repo.collaborator_role_changed`: a person's own role on one
72/// repository (see `access`). `role` is the role they have now (null once
73/// removed); `previous_role` what they had before (null when added).
74#[derive(Debug, Serialize)]
75#[serde(rename_all = "camelCase")]
76pub struct RepoCollaborator {
77 pub repo_id: String,
78 pub namespace: String,
79 pub name: String,
80 pub username: String,
81 pub role: Option<crate::access::RepoRole>,
82 pub previous_role: Option<crate::access::RepoRole>,
83}
84
85#[derive(Debug, Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct RepoForked {
88 pub repo_id: String,
89 pub source_repo_id: String,
90 /// The pull request a working copy was made for; absent for a fork
91 /// someone made into a workspace.
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub pull_id: Option<String>,
94}
95
96/// The payload of `repo.detached`: a fork left its fork network, because
97/// it was detached or its parent changed who can see it.
98#[derive(Debug, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct RepoDetached {
101 pub repo_id: String,
102 pub parent_id: String,
103}
104
105/// One branch or tag moved by a push. `after` is the commit it points to now.
106#[derive(Debug, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub struct GitPush {
109 pub repo_id: String,
110 /// The full ref, such as `refs/heads/main`.
111 #[serde(rename = "ref")]
112 pub git_ref: String,
113 /// Where it pointed before; absent for a new branch or tag.
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub before: Option<String>,
116 pub after: String,
117 /// Whether the ref is the repository's default branch.
118 pub default_branch: bool,
119 /// Set when the push was too large to scan for secrets before it was
120 /// stored, and was let through: the security service scans
121 /// `before..after` after it landed. Absent otherwise.
122 #[serde(skip_serializing_if = "std::ops::Not::not")]
123 pub unscanned: bool,
124 /// Set when a workflow job's token pushed: the run's id (see
125 /// [`CAUSED_BY_JOB`]). Absent otherwise.
126 #[serde(rename = "causedByJob", skip_serializing_if = "Option::is_none")]
127 pub caused_by_job: Option<String>,
128 /// How many commits the push brought to the branch, counted along its
129 /// first-parent line from `after` back to `before` (a merge commit is
130 /// one), at most 50; one for a new branch. Absent when it was not
131 /// counted: a tag, a branch deleted, a pull request landed, a push the
132 /// store could not read back, or one from before this was recorded.
133 /// Home's digest (`activity_digest`) sums it.
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub commits: Option<u32>,
136}
137
138/// A branch or tag deleted by a push. `before` is the commit it pointed to.
139#[derive(Debug, Serialize)]
140#[serde(rename_all = "camelCase")]
141pub struct GitDeleted {
142 pub repo_id: String,
143 /// The full ref, such as `refs/heads/feature`.
144 #[serde(rename = "ref")]
145 pub git_ref: String,
146 pub before: String,
147 /// Set when a workflow job's token deleted it: the run's id (see
148 /// [`CAUSED_BY_JOB`]). Absent otherwise.
149 #[serde(rename = "causedByJob", skip_serializing_if = "Option::is_none")]
150 pub caused_by_job: Option<String>,
151}
152
153/// The payload of `issue.opened`, `issue.updated`, `issue.assigned`,
154/// `issue.labeled`, `issue.unlabeled`, `issue.milestoned`,
155/// `issue.demilestoned`, `issue.closed` and `issue.reopened`; each uses
156/// the fields that apply to it.
157#[derive(Debug, Default, Serialize)]
158#[serde(rename_all = "camelCase")]
159pub struct IssueEvent {
160 pub issue_id: String,
161 pub repo_id: String,
162 pub number: u32,
163 /// Who opened it: g1t, for one its agent filed while at work.
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub author: Option<crate::credentials::Principal>,
166 /// For an issue g1t's agent filed: the person it was working for.
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub requested_by: Option<crate::credentials::Principal>,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub title: Option<String>,
171 /// On close: `completed` or `not_planned`.
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub reason: Option<&'static str>,
174 /// On close: the number of the pull request whose merge closed it.
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub resolved_by: Option<u32>,
177 /// On `issue.assigned`: the people it is now assigned to.
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub assignees: Option<Vec<String>>,
180 /// On `issue.assigned`: those of them who were not before.
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub added: Option<Vec<String>>,
183 /// On `issue.labeled` and `issue.unlabeled`: the label put on or taken
184 /// off. One event for each.
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub label: Option<EventLabel>,
187 /// On `issue.milestoned`: the milestone it was put in; on
188 /// `issue.demilestoned`, the one it was taken out of.
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub milestone: Option<crate::work::MilestoneRef>,
191}
192
193/// A label, as `issue.labeled`, `pull.labeled` and their `unlabeled` say.
194#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
195pub struct EventLabel {
196 pub name: String,
197 /// Six hex digits.
198 pub color: String,
199}
200
201/// The payload of `pull.opened`, `pull.ready`, `pull.updated` (its head
202/// moved), `pull.closed`, `pull.reopened`, `pull.converted_to_draft`,
203/// `pull.merged`, `pull.assigned`,
204/// `pull.review_requested` and `pull.review_request_removed` (reviewers
205/// asked, or no longer), `pull.labeled` and `pull.unlabeled`,
206/// `pull.milestoned` and `pull.demilestoned`, `pull.base_changed` (the
207/// branch it merges into changed), `pull.stalled` (g1t stopped seeing it
208/// through until a person steps in) and `pull.resumed` (it picked back
209/// up); each uses the fields that apply to it.
210#[derive(Debug, Default, Serialize)]
211#[serde(rename_all = "camelCase")]
212pub struct PullEvent {
213 pub pull_id: String,
214 pub repo_id: String,
215 pub number: u32,
216 /// Who opened it: g1t, for a change g1t made.
217 #[serde(skip_serializing_if = "Option::is_none")]
218 pub author: Option<crate::credentials::Principal>,
219 /// For a change g1t made: the person who asked for it.
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub requested_by: Option<crate::credentials::Principal>,
222 /// The number of the issue it is for.
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub issue: Option<u32>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub agent: Option<String>,
227 /// On merge: the commit the branch now points to. On update, when
228 /// marked ready and when reopened: the head of the change.
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub commit: Option<String>,
231 /// On close: the pull request that was merged instead.
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub superseded_by: Option<u32>,
234 /// On `pull.assigned`: the people it is now assigned to.
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub assignees: Option<Vec<String>>,
237 /// On `pull.assigned`: those newly assigned.
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub added: Option<Vec<String>>,
240 /// On `pull.review_requested`: the reviewers newly asked; on
241 /// `pull.review_request_removed`, those no longer asked.
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub reviewers: Option<Vec<String>>,
244 /// On `pull.review_requested` and `pull.review_request_removed`: the
245 /// teams newly asked, or no longer, each with the people it asks.
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub teams: Option<Vec<TeamRequested>>,
248 /// On `pull.review_requested`: asked because they own files it changes
249 /// (its CODEOWNERS file), not by a person.
250 #[serde(skip_serializing_if = "std::ops::Not::not")]
251 pub code_owners: bool,
252 /// On `pull.stalled`: why g1t stopped, and what would start it again.
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub detail: Option<String>,
255 /// On `pull.auto_merge_disabled`: the username of who had turned
256 /// auto-merge on, who is told it is off.
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub auto_merge_by: Option<String>,
259 /// On `pull.auto_merge_disabled`: g1t turned it off because the pull
260 /// request joined the merge queue, which lands it. Nothing is left for
261 /// anyone to do, so nobody's inbox is told.
262 #[serde(skip_serializing_if = "std::ops::Not::not")]
263 pub queued: bool,
264 /// On `pull.labeled` and `pull.unlabeled`: the label put on or taken off.
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub label: Option<EventLabel>,
267 /// On `pull.milestoned`: the milestone it was put in; on
268 /// `pull.demilestoned`, the one it was taken out of.
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub milestone: Option<crate::work::MilestoneRef>,
271 /// On `pull.opened` and `pull.base_changed`: the branch it merges into.
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub base: Option<String>,
274}
275
276/// A team asked to review a pull request.
277#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct TeamRequested {
280 /// `workspace/slug`.
281 pub team: String,
282 /// Everyone in it who is told: the whole team, or, with review
283 /// assignment, the people picked (and the rest when it says to tell
284 /// them). Never the pull request's author.
285 pub notified: Vec<String>,
286 /// With review assignment: the people picked, who are asked as
287 /// reviewers themselves.
288 #[serde(default)]
289 pub assigned: Vec<String>,
290}
291
292/// The payload of every `team.*` event: `team.created`, `team.edited`,
293/// `team.deleted`; `team.member_added`, `team.member_role_changed`,
294/// `team.member_removed` (with `username`, `role` and `previousRole`);
295/// and `team.repo_added`, `team.repo_role_changed`, `team.repo_removed`
296/// (with `repoId`, `repo`, `repoRole` and `previousRepoRole`), which also
297/// name the repository as the event's own.
298#[derive(Clone, Debug, Default, Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct TeamChanged {
301 pub workspace: String,
302 pub team_id: String,
303 /// The team's slug, as it is now.
304 pub team: String,
305 pub name: String,
306 pub visibility: Option<crate::teams::TeamVisibility>,
307 /// The parent's slug.
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub parent: Option<String>,
310 /// On `team.edited`: what changed, such as `name` or `parent`.
311 #[serde(skip_serializing_if = "Vec::is_empty")]
312 pub changes: Vec<String>,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 pub username: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub role: Option<crate::teams::TeamRole>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub previous_role: Option<crate::teams::TeamRole>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub repo_id: Option<String>,
321 /// `workspace/name`.
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub repo: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub repo_role: Option<crate::access::RepoRole>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub previous_repo_role: Option<crate::access::RepoRole>,
328}
329
330/// `deployment.succeeded` and `deployment.failed`: a build of a project
331/// finished, for production or for one pull request's preview.
332#[derive(Debug, Serialize)]
333#[serde(rename_all = "camelCase")]
334pub struct DeploymentEvent {
335 pub deployment_id: String,
336 pub project_id: String,
337 pub repo_id: String,
338 pub workspace: String,
339 /// The project's slug.
340 pub project: String,
341 /// `production` or `preview`.
342 pub kind: String,
343 pub branch: Option<String>,
344 /// For a preview: its pull request.
345 pub number: Option<u32>,
346 pub commit: String,
347 /// Where the deployment is on the site, such as
348 /// `/acme/rocket/deployments/dpl_1`.
349 pub path: String,
350 /// For a failure: what went wrong.
351 pub error: Option<String>,
352 /// For a success: whether the deployment before it, of the same app, failed.
353 pub recovered: bool,
354 /// Who started it, by username, or `g1t`.
355 pub triggered_by: String,
356}
357
358/// `checks.completed`: a run of an issue's acceptance checks against a pull
359/// request finished.
360#[derive(Debug, Serialize)]
361#[serde(rename_all = "camelCase")]
362pub struct ChecksEvent {
363 pub pull_id: String,
364 pub repo_id: String,
365 pub number: u32,
366 /// `passed`, `failed` or `errored`.
367 pub status: &'static str,
368 /// The commit that was checked.
369 pub commit: String,
370}
371
372/// `workflow.completed`: a GitHub Actions run finished.
373#[derive(Debug, Serialize)]
374#[serde(rename_all = "camelCase")]
375pub struct WorkflowEvent {
376 pub run_id: String,
377 pub repo_id: String,
378 /// The workflow's name, and its file.
379 pub workflow: String,
380 pub path: String,
381 /// The run's number among the workflow's runs.
382 pub number: u64,
383 /// The GitHub event that started it, such as `push`.
384 pub event: String,
385 /// `success`, `failure`, `cancelled` or `skipped`.
386 pub conclusion: String,
387 #[serde(rename = "ref")]
388 pub git_ref: String,
389 pub sha: String,
390 /// The pull request it ran for, if any.
391 #[serde(skip_serializing_if = "Option::is_none")]
392 pub pull: Option<u32>,
393}
394
395/// `comment.created`. `number` is the issue or pull request commented on.
396#[derive(Debug, Default, Serialize)]
397#[serde(rename_all = "camelCase")]
398pub struct CommentCreated {
399 pub comment_id: String,
400 pub repo_id: String,
401 pub number: u32,
402 /// Set when the comment is on a pull request.
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub pull_id: Option<String>,
405 /// Set when the comment is a review: approve or request changes.
406 #[serde(skip_serializing_if = "Option::is_none")]
407 pub verdict: Option<crate::work::Verdict>,
408 /// Set when one of the workspace's agents wrote it, as itself. The
409 /// event's actor is then the person it acted for (`actingFor`).
410 #[serde(skip_serializing_if = "Option::is_none")]
411 pub agent: Option<crate::work::AgentRef>,
412 /// Who the agent acted for, set with `agent`.
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub acting_for: Option<crate::credentials::Principal>,
415 /// An agent's review, advisory: its verdict counts toward nothing.
416 #[serde(skip_serializing_if = "std::ops::Not::not")]
417 pub advisory: bool,
418}
419
420/// `comment.edited`: a comment's text changed. `changes.body.from` is what
421/// it said before; the comment as it is now is read by `commentId`.
422#[derive(Debug, Serialize)]
423#[serde(rename_all = "camelCase")]
424pub struct CommentEdited {
425 pub comment_id: String,
426 pub repo_id: String,
427 pub number: u32,
428 /// Set when the comment is on a pull request.
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub pull_id: Option<String>,
431 pub changes: CommentChanges,
432}
433
434/// What an edit changed, as `comment.edited` says it.
435#[derive(Debug, Serialize)]
436pub struct CommentChanges {
437 pub body: ChangedFrom,
438}
439
440/// A field's value before a change.
441#[derive(Debug, Serialize)]
442pub struct ChangedFrom {
443 pub from: String,
444}
445
446/// `comment.deleted`: a comment was deleted. It no longer exists when the
447/// event is read, so `comment` is the comment as it was.
448#[derive(Debug, Serialize)]
449#[serde(rename_all = "camelCase")]
450pub struct CommentDeleted {
451 pub comment_id: String,
452 pub repo_id: String,
453 pub number: u32,
454 /// Set when the comment was on a pull request.
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub pull_id: Option<String>,
457 pub comment: DeletedComment,
458}
459
460/// A deleted comment, as it was.
461#[derive(Debug, Serialize)]
462#[serde(rename_all = "camelCase")]
463pub struct DeletedComment {
464 pub id: String,
465 pub body: String,
466 pub author: crate::credentials::Principal,
467 /// RFC 3339.
468 pub created_at: String,
469 /// For a comment on one line of a pull request's change: the file.
470 pub path: Option<String>,
471 /// And the line, as numbered after the change.
472 pub line: Option<u32>,
473}
474
475/// The payload of `commit_comment.created`, `commit_comment.edited` and
476/// `commit_comment.deleted`. `comment` is the comment as it is now, or as
477/// it was when deleted; `changes.body.from` is what an edit replaced.
478#[derive(Debug, Serialize)]
479#[serde(rename_all = "camelCase")]
480pub struct CommitCommentEvent {
481 pub comment_id: String,
482 pub repo_id: String,
483 /// The commit's full hash.
484 pub commit: String,
485 pub comment: crate::work::CommitComment,
486 #[serde(skip_serializing_if = "Option::is_none")]
487 pub changes: Option<CommentChanges>,
488 /// Where it is on g1t.sh: `/owner/name/commit/<hash>`.
489 pub link: String,
490 /// For the inbox, on `commit_comment.created`: the first line it says
491 /// something on, the people it mentions by name (never `g1t`), the
492 /// teams it mentions with the people each tells, and the commit's
493 /// author when their address is a g1t account's (a username).
494 #[serde(skip_serializing_if = "Option::is_none")]
495 pub excerpt: Option<String>,
496 #[serde(skip_serializing_if = "Vec::is_empty")]
497 pub mentions: Vec<String>,
498 #[serde(skip_serializing_if = "Vec::is_empty")]
499 pub team_mentions: Vec<crate::inbox::TeamMentioned>,
500 #[serde(skip_serializing_if = "Option::is_none")]
501 pub commit_author: Option<String>,
502}
503
504/// The payload of every `release.*` event: `release.created`,
505/// `release.published`, `release.released`, `release.prereleased`,
506/// `release.edited`, `release.unpublished` and `release.deleted`. One change
507/// to a release can be several of them (see [`release_actions`]).
508#[derive(Debug, Serialize)]
509#[serde(rename_all = "camelCase")]
510pub struct ReleaseEvent {
511 pub release_id: String,
512 pub repo_id: String,
513 pub tag_name: String,
514 /// As it is now; as it was, for `release.deleted`.
515 pub release: crate::about::Release,
516 /// On `release.edited`: `{ "name": { "from" }, "body": { "from" } }`
517 /// for what changed.
518 #[serde(skip_serializing_if = "Option::is_none")]
519 pub changes: Option<serde_json::Value>,
520}
521
522/// A release's state, for [`release_actions`]: whether it is a draft, and
523/// whether it is a prerelease.
524#[derive(Clone, Copy, Debug, PartialEq, Eq)]
525pub struct ReleaseState {
526 pub draft: bool,
527 pub prerelease: bool,
528}
529
530/// What a change to a release is, as GitHub's release activity types, in
531/// the order they are sent: from `before` (None: it did not exist) to
532/// `after` (None: deleted). A release made published is `created`,
533/// `published` and `released` (or `prereleased`); a draft published is
534/// `published` and `released` (or `prereleased`); made a draft again,
535/// `unpublished`; anything else `edited`, and a prerelease made a full
536/// release is `released` too.
537pub fn release_actions(before: Option<ReleaseState>, after: Option<ReleaseState>) -> Vec<&'static str> {
538 let published = |state: ReleaseState| if state.prerelease { "prereleased" } else { "released" };
539 match (before, after) {
540 (None, None) => Vec::new(),
541 (None, Some(after)) if after.draft => vec!["created"],
542 (None, Some(after)) => vec!["created", "published", published(after)],
543 (Some(_), None) => vec!["deleted"],
544 (Some(before), Some(after)) if before.draft && !after.draft => vec!["published", published(after)],
545 (Some(before), Some(after)) if !before.draft && after.draft => vec!["unpublished"],
546 (Some(before), Some(after)) if !after.draft && before.prerelease && !after.prerelease => vec!["edited", "released"],
547 (Some(_), Some(_)) => vec!["edited"],
548 }
549}
550
551/// The event kind for a release activity type from [`release_actions`].
552pub fn release_kind(action: &str) -> Option<&'static str> {
553 Some(match action {
554 "created" => "release.created",
555 "published" => "release.published",
556 "released" => "release.released",
557 "prereleased" => "release.prereleased",
558 "edited" => "release.edited",
559 "unpublished" => "release.unpublished",
560 "deleted" => "release.deleted",
561 _ => return None,
562 })
563}
564
565#[derive(Debug, Serialize)]
566#[serde(rename_all = "camelCase")]
567pub struct SessionAppended {
568 pub pull_id: String,
569 pub repo_id: String,
570 pub number: u32,
571 pub count: u32,
572}
573
574/// An event as stored in the log and delivered to subscribers. `data` is
575/// left as JSON; each reader decodes the types it cares about.
576#[derive(Clone, Debug, Serialize, serde::Deserialize)]
577#[serde(rename_all = "camelCase")]
578pub struct Event {
579 /// Sorts by the time the event was published.
580 pub id: String,
581 #[serde(rename = "type")]
582 pub kind: String,
583 /// The service that published it.
584 pub source: String,
585 /// RFC 3339.
586 pub time: String,
587 /// The repo the event concerns.
588 pub repo_id: Option<String>,
589 /// The user or agent that caused it, if any.
590 pub actor: Option<String>,
591 pub data: serde_json::Value,
592}
593
594/// `publish`, as a publisher sends it. Returns nothing.
595#[derive(Debug, Serialize)]
596pub struct Publish<T: Serialize> {
597 pub events: Vec<NewEvent<T>>,
598}
599
600/// `publish`, as the events service reads it.
601#[derive(Debug, serde::Deserialize)]
602pub struct PublishArgs {
603 pub events: Vec<Published>,
604}
605
606/// A [`NewEvent`] of any type, as received.
607#[derive(Debug, serde::Deserialize)]
608#[serde(rename_all = "camelCase")]
609pub struct Published {
610 #[serde(rename = "type")]
611 pub kind: String,
612 pub source: String,
613 #[serde(default)]
614 pub repo_id: Option<String>,
615 #[serde(default)]
616 pub actor: Option<String>,
617 pub data: serde_json::Value,
618}
619
620/// `list`: events from the log, newest first, or with `after` oldest first.
621/// Returns `Vec<Event>`.
622#[derive(Debug, Default, Serialize, serde::Deserialize)]
623#[serde(rename_all = "camelCase")]
624pub struct ListArgs {
625 #[serde(default)]
626 pub repo_id: Option<String>,
627 /// Only events of these repositories, by id: a workspace's activity
628 /// across its repositories. The caller has checked the viewer may read
629 /// each. At most [`MAX_LIST_REPOS`] are read.
630 #[serde(default, skip_serializing_if = "Vec::is_empty")]
631 pub repo_ids: Vec<String>,
632 /// Only these types; all types when empty.
633 #[serde(default)]
634 pub types: Vec<String>,
635 /// Only events older than this event id.
636 #[serde(default)]
637 pub before: Option<String>,
638 /// Only events the log recorded after this event id, and then oldest
639 /// first: a cursor to follow the log with. Events are ordered by when
640 /// the log wrote them, not by their ids, so one published a moment
641 /// before another but written after it is not skipped.
642 #[serde(default)]
643 pub after: Option<String>,
644 /// Only events by this account id.
645 #[serde(default)]
646 pub actor: Option<String>,
647 /// Only events about these issues or pull requests: their `number`, or
648 /// the `issue` a comment, review or link is on. All when empty.
649 #[serde(default)]
650 pub numbers: Vec<u32>,
651 /// Only events at or after this RFC 3339 time.
652 #[serde(default)]
653 pub since: Option<String>,
654 #[serde(default)]
655 pub limit: Option<u32>,
656}
657
658/// The most repositories one `list` reads with `repo_ids`.
659pub const MAX_LIST_REPOS: usize = 100;
660
661/// `workspace.renamed`: a workspace's slug changed from `from` to `to`.
662/// Every service that stores a slug moves its rows to the workspace's
663/// *current* slug (ask identity by `workspace_id`), so that a repeated or
664/// late delivery after a second rename still lands in the right place.
665#[derive(Clone, Debug, Serialize, serde::Deserialize)]
666#[serde(rename_all = "camelCase")]
667pub struct WorkspaceRenamed {
668 pub workspace_id: String,
669 pub from: String,
670 pub to: String,
671}
672
673impl WorkspaceRenamed {
674 /// The slugs whose rows move to `current`: the two this rename names,
675 /// minus `current` itself. Moving rows keyed by either converges on the
676 /// current slug whatever order renames are delivered in.
677 pub fn stale_slugs(&self, current: &str) -> Vec<String> {
678 let mut slugs: Vec<String> = Vec::new();
679 for slug in [&self.from, &self.to] {
680 if slug != current && !slugs.contains(slug) {
681 slugs.push(slug.clone());
682 }
683 }
684 slugs
685 }
686}
687
688/// `repo.updated`: a repository's website, topics or visibility
689/// changed. `visibility_changed` says whether it went public or private,
690/// which `repo.visibility_changed` also announces on its own.
691#[derive(Debug, Serialize, serde::Deserialize)]
692#[serde(rename_all = "camelCase")]
693pub struct RepoUpdated {
694 pub repo_id: String,
695 pub namespace: String,
696 pub name: String,
697 pub is_private: bool,
698 #[serde(default)]
699 pub visibility_changed: bool,
700}
701
702/// `project.updated`: a project's name or description changed. `primary`
703/// says whether it is its repository's own project, whose description
704/// search and lists of repositories show.
705#[derive(Debug, Serialize, serde::Deserialize)]
706#[serde(rename_all = "camelCase")]
707pub struct ProjectUpdated {
708 pub project_id: String,
709 pub workspace: String,
710 pub slug: String,
711 pub repo_id: String,
712 #[serde(default)]
713 pub primary: bool,
714 #[serde(default)]
715 pub description: Option<String>,
716}
717
718/// `repo.visibility_changed`: a repository went public or private.
719#[derive(Debug, Serialize, serde::Deserialize)]
720#[serde(rename_all = "camelCase")]
721pub struct RepoVisibilityChanged {
722 pub repo_id: String,
723 pub is_private: bool,
724}
725
726/// `repo.renamed`: a repository's name changed within its workspace,
727/// keeping its id and its git store key. Like `repo.transferred`, a path
728/// change: every service that keeps rows under the repository's path moves
729/// them to its *current* path (ask repos `path_by_id`), so a repeated or
730/// late delivery after a second rename or a transfer still lands in the
731/// right place. `g1t_kit::transfer::on_event` handles both.
732#[derive(Clone, Debug, Serialize, serde::Deserialize)]
733#[serde(rename_all = "camelCase")]
734pub struct RepoRenamed {
735 pub repo_id: String,
736 /// The workspace it is in.
737 pub namespace: String,
738 /// Its old name.
739 pub from: String,
740 /// Its new name.
741 pub to: String,
742}
743
744impl RepoRenamed {
745 /// The paths whose rows move to `current` (`namespace/name`): the two
746 /// this rename names, minus `current`.
747 pub fn stale_paths(&self, current: &str) -> Vec<String> {
748 let mut paths: Vec<String> = Vec::new();
749 for name in [&self.from, &self.to] {
750 let path = format!("{}/{name}", self.namespace);
751 if path != current && !paths.contains(&path) {
752 paths.push(path);
753 }
754 }
755 paths
756 }
757}
758
759/// `repo.deleted`: a repository was deleted. It is hidden everywhere and
760/// git refuses it, but it can be restored until `purge_after`, so services
761/// stop what runs for it (agents, workflows, deployments, indexing,
762/// webhook deliveries) and hide it, and keep what they hold until
763/// `repo.purged`. `repo.restored` brings it back.
764#[derive(Clone, Debug, Serialize, serde::Deserialize)]
765#[serde(rename_all = "camelCase")]
766pub struct RepoDeleted {
767 pub repo_id: String,
768 #[serde(default)]
769 pub namespace: String,
770 #[serde(default)]
771 pub name: String,
772 #[serde(default)]
773 pub is_private: bool,
774 /// RFC 3339: when it is purged unless restored first.
775 #[serde(default)]
776 pub purge_after: String,
777 /// It went with its workspace (`workspace.deleting`). Services that
778 /// handle the workspace as a whole (deployments pauses its apps rather
779 /// than taking them down) leave this one to that.
780 #[serde(default)]
781 pub with_workspace: bool,
782}
783
784/// `repo.restored`: a deleted repository is back, at its path, as it was.
785/// Services start again what `repo.deleted` stopped: index it, deploy its
786/// production, show it.
787#[derive(Clone, Debug, Serialize, serde::Deserialize)]
788#[serde(rename_all = "camelCase")]
789pub struct RepoRestored {
790 pub repo_id: String,
791 pub namespace: String,
792 pub name: String,
793 pub is_private: bool,
794 /// It came back with its workspace (`workspace.restored`).
795 #[serde(default)]
796 pub with_workspace: bool,
797}
798
799/// `repo.purged`: a deleted repository is gone for good, its git data
800/// with it. Services drop every row they keep for it by `repo_id`, except
801/// history that belongs to its workspace: ledgers, invoices and the audit
802/// log. Its path is free for a new repository.
803#[derive(Clone, Debug, Serialize, serde::Deserialize)]
804#[serde(rename_all = "camelCase")]
805pub struct RepoPurged {
806 pub repo_id: String,
807 pub namespace: String,
808 pub name: String,
809}
810
811/// `repo.archived` and `repo.unarchived`: a repository became read-only,
812/// or writable again. While archived, pushes are refused, issues and pull
813/// requests are locked, and agents and workflows do not run for it; its
814/// deployments keep serving.
815#[derive(Clone, Debug, Serialize, serde::Deserialize)]
816#[serde(rename_all = "camelCase")]
817pub struct RepoArchived {
818 pub repo_id: String,
819 pub namespace: String,
820 pub name: String,
821 pub archived: bool,
822}
823
824/// `repo.default_branch_changed`: the branch everything lands on is now
825/// `to`. `renamed` says whether `from` was renamed to `to` (open pull
826/// requests into it now target `to`) rather than another branch chosen.
827#[derive(Clone, Debug, Serialize, serde::Deserialize)]
828#[serde(rename_all = "camelCase")]
829pub struct RepoDefaultBranchChanged {
830 pub repo_id: String,
831 pub from: String,
832 pub to: String,
833 #[serde(default)]
834 pub renamed: bool,
835}
836
837/// `branch.renamed`: a branch was renamed. Pull requests from or into
838/// `from` follow it to `to`, and web addresses that name `from` redirect.
839#[derive(Clone, Debug, Serialize, serde::Deserialize)]
840#[serde(rename_all = "camelCase")]
841pub struct BranchRenamed {
842 pub repo_id: String,
843 pub from: String,
844 pub to: String,
845 /// Whether it is the default branch.
846 #[serde(default)]
847 pub default_branch: bool,
848}
849
850/// `repo.transferred`: a repository moved from one workspace to another,
851/// keeping its id and its name. Every service that keeps a repository
852/// under its path (`namespace/name`) or its workspace's slug moves those
853/// rows to the repository's *current* path (ask repos `path_by_id`), so a
854/// repeated or late delivery after a second transfer still lands in the
855/// right place. What was charged or recorded before the transfer stays
856/// with the workspace it happened in.
857#[derive(Clone, Debug, Serialize, serde::Deserialize)]
858#[serde(rename_all = "camelCase")]
859pub struct RepoTransferred {
860 pub repo_id: String,
861 pub name: String,
862 /// The workspace it left.
863 pub from: String,
864 /// The workspace it went to.
865 pub to: String,
866}
867
868impl RepoTransferred {
869 /// The paths whose rows move to `current` (`namespace/name`): the two
870 /// this transfer names, minus `current`. Moving rows keyed by either
871 /// converges whatever order transfers are delivered in.
872 pub fn stale_paths(&self, current: &str) -> Vec<String> {
873 let mut paths: Vec<String> = Vec::new();
874 for namespace in [&self.from, &self.to] {
875 let path = format!("{namespace}/{}", self.name);
876 if path != current && !paths.contains(&path) {
877 paths.push(path);
878 }
879 }
880 paths
881 }
882}
883
884/// `workspace.deleted`: a workspace is gone. Services drop what they keep
885/// for it alone (its webhooks, integrations, secrets, guardrails,
886/// agent queue) and keep what is history: ledgers, invoices and the audit
887/// log stay under its slug, which is never given to another workspace.
888#[derive(Clone, Debug, Serialize, serde::Deserialize)]
889#[serde(rename_all = "camelCase")]
890pub struct WorkspaceDeleted {
891 pub workspace_id: String,
892 pub slug: String,
893}
894
895/// `workspace.deleting`: an owner deleted a workspace, and it can be
896/// restored by g1t's staff until `purge_after`. Nobody can reach it in the
897/// meantime. Services hide what they keep for it and stop what runs for it,
898/// keeping their rows: repos deletes its repositories softly (each with a
899/// `repo.deleted` whose `with_workspace` is set), deployments pauses its
900/// apps, search drops it from results. `workspace.restored` undoes exactly
901/// that; once `purge_after` passes, `workspace.deleted` follows and
902/// services purge as for any deleted workspace.
903#[derive(Clone, Debug, Serialize, serde::Deserialize)]
904#[serde(rename_all = "camelCase")]
905pub struct WorkspaceDeleting {
906 pub workspace_id: String,
907 pub slug: String,
908 /// The username of the owner who deleted it.
909 pub by: String,
910 /// RFC 3339: when it is purged unless restored first.
911 pub purge_after: String,
912}
913
914/// `workspace.restored`: staff brought a deleted workspace back, with its
915/// members and tokens. Services undo what they did on `workspace.deleting`,
916/// and only that: a repository deleted on its own before stays deleted.
917#[derive(Clone, Debug, Serialize, serde::Deserialize)]
918#[serde(rename_all = "camelCase")]
919pub struct WorkspaceRestored {
920 pub workspace_id: String,
921 pub slug: String,
922}
923
924/// `user.deleting`: an account was deleted, by the person or by g1t's
925/// staff, and can be restored by staff until `purge_after`. Its sessions,
926/// tokens and keys have ended and it has left every workspace; services
927/// stop what they do for it (search drops its profile, nothing more is
928/// sent to it) and keep their rows. `user.restored` undoes that; once
929/// `purge_after` passes, `user.deleted` follows.
930#[derive(Clone, Debug, Serialize, serde::Deserialize)]
931#[serde(rename_all = "camelCase")]
932pub struct UserDeleting {
933 pub user_id: String,
934 pub username: String,
935 /// Whether g1t's staff deleted it rather than the person.
936 #[serde(default)]
937 pub by_staff: bool,
938 /// RFC 3339: when it is purged unless restored first.
939 pub purge_after: String,
940}
941
942/// `user.restored`: staff brought a deleted account back. It signs in
943/// again with its password; its old sessions and tokens stay ended.
944/// Services undo what they did on `user.deleting`.
945#[derive(Clone, Debug, Serialize, serde::Deserialize)]
946#[serde(rename_all = "camelCase")]
947pub struct UserRestored {
948 pub user_id: String,
949 pub username: String,
950}
951
952/// `user.deleted`: an account is gone for good. Services drop what they
953/// keep for it alone (its inbox, subscriptions, settings) and show what it
954/// wrote as `ghost` (`account_deletion::GHOST_USERNAME`, id
955/// `account_deletion::GHOST_ID`). Ledgers, invoices and audit logs keep
956/// its username. The username is never given to anyone again.
957#[derive(Clone, Debug, Serialize, serde::Deserialize)]
958#[serde(rename_all = "camelCase")]
959pub struct UserDeleted {
960 pub user_id: String,
961 pub username: String,
962}
963
964/// `user.updated`: an account was made, or changed what its profile shows
965/// (name, bio, avatar). Nothing private: ask identity for the profile.
966#[derive(Debug, Serialize, serde::Deserialize)]
967#[serde(rename_all = "camelCase")]
968pub struct UserUpdated {
969 pub username: String,
970}
971
972/// `user.email_added`, `user.email_verified`, `user.email_removed` and
973/// `user.primary_email_changed`: an account's addresses changed. Never the
974/// address itself; ask identity, as the person, for that.
975#[derive(Debug, Serialize, serde::Deserialize)]
976#[serde(rename_all = "camelCase")]
977pub struct UserEmailChanged {
978 pub user_id: String,
979 /// Whether g1t staff made the change.
980 #[serde(default)]
981 pub by_staff: bool,
982}
983
984/// `workspace.updated`: a workspace was made, or its name, description or
985/// icon changed. Ask identity for it by slug.
986#[derive(Debug, Serialize, serde::Deserialize)]
987#[serde(rename_all = "camelCase")]
988pub struct WorkspaceUpdated {
989 pub workspace_id: String,
990 pub slug: String,
991}
992
993/// How someone came to be a member of a workspace (`workspace.member_joined`).
994#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
995#[serde(rename_all = "snake_case")]
996pub enum JoinedHow {
997 /// They accepted a workspace invitation sent to them by name.
998 Invitation,
999 /// They made the workspace, and are its first owner.
1000 Created,
1001 /// They used an invite code or link that named the workspace.
1002 Joined,
1003}
1004
1005/// `workspace.member_joined`: a person became a member of a workspace,
1006/// published by identity as the membership is written. `how` says by
1007/// which door; the workspace's creator is `created`, so a listener that
1008/// greets new members can leave them out (the agents service's @g1t
1009/// welcomes everyone else, once). Never published for an agent or a
1010/// token: only a person joins.
1011#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
1012#[serde(rename_all = "camelCase")]
1013pub struct MemberJoined {
1014 pub workspace_id: String,
1015 /// The workspace's slug.
1016 pub workspace: String,
1017 pub user_id: String,
1018 pub username: String,
1019 pub role: crate::Role,
1020 pub how: JoinedHow,
1021}
1022
1023/// `invite.created`: someone (or staff) made an invite. Never the code or
1024/// the address it is for.
1025#[derive(Debug, Serialize, serde::Deserialize)]
1026#[serde(rename_all = "camelCase")]
1027pub struct InviteCreated {
1028 pub invite_id: String,
1029 /// The account that made it; null when staff did.
1030 pub inviter_id: Option<String>,
1031 /// The workspace it joins.
1032 pub workspace_id: Option<String>,
1033 /// Whether it is bound to one email address.
1034 pub bound: bool,
1035}
1036
1037/// `invite.redeemed`: an invite was used, by a new account or by an
1038/// existing one joining a workspace.
1039#[derive(Debug, Serialize, serde::Deserialize)]
1040#[serde(rename_all = "camelCase")]
1041pub struct InviteRedeemed {
1042 /// The invite's id, or a shared invite link's (`sinv_…`) when one made
1043 /// the account.
1044 pub invite_id: String,
1045 pub user_id: String,
1046 pub inviter_id: Option<String>,
1047 pub workspace_id: Option<String>,
1048 /// Whether it made the account.
1049 pub created_account: bool,
1050}
1051
1052/// The payload of `package.published`, `package.version_deleted`,
1053/// `package.deleted` and `package.visibility_changed`; each uses the fields
1054/// that apply to it. `repo_id` is the repository the package is linked to.
1055#[derive(Clone, Debug, Default, Serialize)]
1056#[serde(rename_all = "camelCase")]
1057pub struct PackageEvent {
1058 pub package_id: String,
1059 pub workspace: String,
1060 pub ecosystem: String,
1061 pub name: String,
1062 pub repo_id: Option<String>,
1063 /// The version published or deleted: for a container image, its
1064 /// manifest's digest.
1065 #[serde(skip_serializing_if = "Option::is_none")]
1066 pub version: Option<String>,
1067 #[serde(skip_serializing_if = "Option::is_none")]
1068 pub digest: Option<String>,
1069 #[serde(skip_serializing_if = "Option::is_none")]
1070 pub size: Option<u64>,
1071 /// On publish: the tags that now point to the version.
1072 #[serde(skip_serializing_if = "Option::is_none")]
1073 pub tags: Option<Vec<String>>,
1074 /// On `package.visibility_changed`: `public` or `private`.
1075 #[serde(skip_serializing_if = "Option::is_none")]
1076 pub visibility: Option<String>,
1077}
1078
1079/// `queue.changed`: a repository's merge queue gained, lost or settled an
1080/// entry, so the next batch may be ready to test.
1081#[derive(Debug, Serialize)]
1082#[serde(rename_all = "camelCase")]
1083pub struct QueueChanged {
1084 pub repo_id: String,
1085}
1086
1087/// The `doc.page.*` types the artifacts service (services/artifacts, TypeScript)
1088/// publishes, with no `repoId` on the event: a page may be in a private
1089/// space, so it never reaches a repository's timeline or webhooks.
1090pub const DOC_PAGE_EVENTS: [&str; 4] = ["doc.page.created", "doc.page.updated", "doc.page.archived", "doc.page.stale"];
1091
1092/// The `folio.*` types (Artifacts mode), with their payloads, live in
1093/// [`crate::folios`]; nothing publishes or subscribes to them yet.
1094pub use crate::folios::FOLIO_EVENTS;
1095
1096/// What every `doc.page.*` event carries (`DocPageEventData` in
1097/// events.ts). `doc.page.updated` adds `versionId`, `kind` and `authors`;
1098/// `doc.page.stale` adds `repoId`, `repo`, `commit`, `pull`, `paths` and
1099/// `owners` ([`DocPageStale`]).
1100#[derive(Clone, Debug, Serialize, serde::Deserialize)]
1101#[serde(rename_all = "camelCase")]
1102pub struct DocPageEvent {
1103 pub workspace: String,
1104 pub workspace_id: String,
1105 pub page_id: String,
1106 pub space_id: String,
1107 pub title: String,
1108 /// The page's address on the site.
1109 pub path: String,
1110}
1111
1112/// `doc.page.stale`: code a page cites changed.
1113#[derive(Clone, Debug, Serialize, serde::Deserialize)]
1114#[serde(rename_all = "camelCase")]
1115pub struct DocPageStale {
1116 #[serde(flatten)]
1117 pub page: DocPageEvent,
1118 pub repo_id: String,
1119 /// `owner/name`.
1120 pub repo: String,
1121 pub commit: String,
1122 /// The merged pull request's number, when a pull request made the change.
1123 pub pull: Option<u32>,
1124 pub paths: Vec<String>,
1125 /// Member keys: `user:<id>`, `agent:<id>`.
1126 pub owners: Vec<String>,
1127}
1128
1129/// `activity_digest`: what happened in some repositories, and to a
1130/// workspace's artifacts, over a span, counted. Returns [`ActivityDigest`].
1131/// Home reads it for what people and agents did since you were last there.
1132/// Fields are snake_case, as every service's arguments are.
1133///
1134/// The span is `[from, until)` in RFC 3339. Event ids sort by time, so the
1135/// service reads each repository's events of each type it counts as one
1136/// range of the `(repo_id, type, id)` index, from `id_floor(from)` to
1137/// `id_floor(until)`; a workspace's folio events (which carry no
1138/// repository) are the same range under a null repository, narrowed to
1139/// the workspace. That is indexed range scans per repository and type, and
1140/// it suits a workspace of tens of repositories. When a workspace has
1141/// hundreds, the next step is a daily rollup per workspace that this
1142/// method reads instead of the log; it is not built yet.
1143#[derive(Debug, Default, Serialize, serde::Deserialize)]
1144pub struct ActivityDigestArgs {
1145 /// The repositories to count, by id. The caller has checked the
1146 /// viewer may read each. At most `MAX_DIGEST_REPOS` are read.
1147 #[serde(default)]
1148 pub repo_ids: Vec<String>,
1149 /// The workspace's slug, for its `folio.*` events; none leaves
1150 /// artifacts out.
1151 #[serde(default)]
1152 pub workspace: Option<String>,
1153 /// The start of the span, inclusive. RFC 3339.
1154 pub from: String,
1155 /// The end of the span, exclusive. RFC 3339.
1156 pub until: String,
1157}
1158
1159/// The most repositories one digest reads.
1160pub const MAX_DIGEST_REPOS: usize = 50;
1161/// The most events one digest reads of repositories, and of folios; past
1162/// either the digest says it is not complete.
1163pub const MAX_DIGEST_EVENTS: u32 = 5000;
1164/// The most releases, packages and edited folios a digest lists.
1165pub const MAX_DIGEST_LISTED: usize = 50;
1166
1167/// What `activity_digest` returns.
1168#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq)]
1169pub struct ActivityDigest {
1170 pub from: String,
1171 pub until: String,
1172 /// One per repository that had any event counted; none for a quiet one.
1173 pub repos: Vec<RepoDigest>,
1174 /// The workspace's artifacts, when a workspace was named.
1175 pub folios: Option<FolioDigest>,
1176 /// False when more events fell in the span than were read, or more
1177 /// repositories were named than are read, so counts are low.
1178 pub complete: bool,
1179}
1180
1181/// How many times one actor did something. The actor is a member key:
1182/// `user:<id>` for a person (or g1t, `user:usr_g1t_agent`), `agent:<id>`
1183/// for one of the workspace's agents acting as itself, or empty when the
1184/// event named nobody.
1185#[derive(Debug, Clone, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1186pub struct ActorCount {
1187 pub actor: String,
1188 pub count: u32,
1189}
1190
1191/// Pushes by one actor, with the commits they brought.
1192#[derive(Debug, Clone, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1193pub struct PushesBy {
1194 pub actor: String,
1195 pub count: u32,
1196 pub commits: u32,
1197}
1198
1199/// Pushes to a repository's branches (`git.push` events for `refs/heads/`;
1200/// tags are not pushes here). A push whose commits were not counted
1201/// counts as one commit.
1202#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1203pub struct PushDigest {
1204 pub count: u32,
1205 pub commits: u32,
1206 /// The branches pushed to, by name, sorted.
1207 pub branches: Vec<String>,
1208 /// Most pushes first.
1209 pub by: Vec<PushesBy>,
1210 /// Of those, the pushes straight to the default branch: what landed
1211 /// without a pull request.
1212 pub default_branch: DefaultBranchPushes,
1213}
1214
1215#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1216pub struct DefaultBranchPushes {
1217 pub count: u32,
1218 pub commits: u32,
1219 pub by: Vec<PushesBy>,
1220 /// When the newest of them was. RFC 3339.
1221 pub last_at: Option<String>,
1222}
1223
1224/// Pull requests opened, merged and closed without merging, each by who did it.
1225#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1226pub struct PullDigest {
1227 pub opened: Vec<ActorCount>,
1228 pub merged: Vec<ActorCount>,
1229 pub closed: Vec<ActorCount>,
1230}
1231
1232/// Issues opened and closed, each by who did it.
1233#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1234pub struct IssueDigest {
1235 pub opened: Vec<ActorCount>,
1236 pub closed: Vec<ActorCount>,
1237}
1238
1239/// Builds that finished, by who set them off, and how many of the
1240/// successes were production.
1241#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1242pub struct DeploymentDigest {
1243 pub succeeded: Vec<ActorCount>,
1244 pub failed: Vec<ActorCount>,
1245 pub production: u32,
1246}
1247
1248/// A release published in the span.
1249#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1250pub struct ReleaseDigest {
1251 pub tag: String,
1252 pub name: Option<String>,
1253 pub actor: String,
1254 /// RFC 3339.
1255 pub at: String,
1256}
1257
1258/// A package version published in the span.
1259#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1260pub struct PackageDigest {
1261 pub ecosystem: String,
1262 pub name: String,
1263 pub version: String,
1264 pub actor: String,
1265 /// RFC 3339.
1266 pub at: String,
1267}
1268
1269/// One repository's span.
1270#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq)]
1271pub struct RepoDigest {
1272 pub repo_id: String,
1273 pub pushes: PushDigest,
1274 pub pulls: PullDigest,
1275 pub issues: IssueDigest,
1276 /// Reviews: `review.completed` (g1t's), and a comment with a verdict (a person's).
1277 pub reviews: Vec<ActorCount>,
1278 /// Comments without a verdict; one by an agent as itself is the agent's.
1279 pub comments: Vec<ActorCount>,
1280 pub deployments: DeploymentDigest,
1281 /// Newest first, at most `MAX_DIGEST_LISTED`.
1282 pub releases: Vec<ReleaseDigest>,
1283 /// Newest first, at most `MAX_DIGEST_LISTED`.
1284 pub packages: Vec<PackageDigest>,
1285}
1286
1287/// One folio (an artifact: a doc, slides, a design, a dashboard) whose
1288/// content changed, with everyone whose changes are in it, as member
1289/// keys. Never its title.
1290#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1291pub struct FolioEdited {
1292 pub folio_id: String,
1293 pub kind: String,
1294 pub authors: Vec<String>,
1295}
1296
1297/// A workspace's artifacts over the span: `folio.created` by who made
1298/// them, and the folios whose content changed (`folio.updated`).
1299#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1300pub struct FolioDigest {
1301 pub created: Vec<ActorCount>,
1302 /// Each folio once, at most `MAX_DIGEST_LISTED`.
1303 pub edited: Vec<FolioEdited>,
1304 /// How many folios changed, counting those past the cap.
1305 pub edited_count: u32,
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311
1312 #[test]
1313 fn comment_edits_and_deletes_say_what_it_was() {
1314 let edited = CommentEdited {
1315 comment_id: "cmt_1".into(),
1316 repo_id: "rep_1".into(),
1317 number: 7,
1318 pull_id: Some("pr_1".into()),
1319 changes: CommentChanges { body: ChangedFrom { from: "Before".into() } },
1320 };
1321 assert_eq!(
1322 serde_json::to_value(edited).unwrap(),
1323 serde_json::json!({
1324 "commentId": "cmt_1", "repoId": "rep_1", "number": 7, "pullId": "pr_1",
1325 "changes": { "body": { "from": "Before" } },
1326 })
1327 );
1328 let deleted = CommentDeleted {
1329 comment_id: "cmt_1".into(),
1330 repo_id: "rep_1".into(),
1331 number: 7,
1332 pull_id: None,
1333 comment: DeletedComment {
1334 id: "cmt_1".into(),
1335 body: "Gone".into(),
1336 author: crate::credentials::Principal { id: "usr_1".into(), username: "ana".into() },
1337 created_at: "2026-10-08T00:00:00Z".into(),
1338 path: None,
1339 line: None,
1340 },
1341 };
1342 assert_eq!(
1343 serde_json::to_value(deleted).unwrap(),
1344 serde_json::json!({
1345 "commentId": "cmt_1", "repoId": "rep_1", "number": 7,
1346 "comment": {
1347 "id": "cmt_1", "body": "Gone", "author": { "id": "usr_1", "username": "ana" },
1348 "createdAt": "2026-10-08T00:00:00Z", "path": null, "line": null,
1349 },
1350 })
1351 );
1352 }
1353
1354 #[test]
1355 fn a_release_change_is_githubs_activity_types() {
1356 let state = |draft, prerelease| Some(ReleaseState { draft, prerelease });
1357 assert_eq!(release_actions(None, state(true, false)), ["created"]);
1358 assert_eq!(release_actions(None, state(false, false)), ["created", "published", "released"]);
1359 assert_eq!(release_actions(None, state(false, true)), ["created", "published", "prereleased"]);
1360 assert_eq!(release_actions(state(true, true), state(false, true)), ["published", "prereleased"]);
1361 assert_eq!(release_actions(state(false, false), state(true, false)), ["unpublished"]);
1362 assert_eq!(release_actions(state(false, true), state(false, false)), ["edited", "released"]);
1363 assert_eq!(release_actions(state(false, false), state(false, true)), ["edited"]);
1364 assert_eq!(release_actions(state(true, false), state(true, false)), ["edited"]);
1365 assert_eq!(release_actions(state(false, false), None), ["deleted"]);
1366 for action in ["created", "published", "released", "prereleased", "edited", "unpublished", "deleted"] {
1367 assert_eq!(release_kind(action).map(|kind| kind.trim_start_matches("release.")), Some(action));
1368 }
1369 assert_eq!(release_kind("archived"), None);
1370 }
1371
1372 #[test]
1373 fn what_a_job_token_did_is_marked_and_carried_on() {
1374 let mut actor = crate::User { id: "wsp_1".into(), username: "acme".into(), ..crate::User::default() };
1375 let plain = marked(serde_json::json!({ "number": 4 }), Some(&actor));
1376 assert_eq!(caused_by_job(&plain), None);
1377 actor.token = Some(Box::new(crate::scopes::TokenAccess {
1378 job: Some(crate::scopes::JobToken { run_id: "run_9".into(), job_id: "job_1".into(), pull_requests: false }),
1379 ..Default::default()
1380 }));
1381 let by_job = marked(serde_json::json!({ "number": 4 }), Some(&actor));
1382 assert_eq!(caused_by_job(&by_job), Some("run_9"));
1383 assert_eq!(by_job["number"], 4);
1384 // A push by the job's token, and the pull request it moves.
1385 let push = GitPush {
1386 repo_id: "rep_1".into(),
1387 git_ref: "refs/heads/fix".into(),
1388 before: Some("a".into()),
1389 after: "b".into(),
1390 default_branch: false,
1391 unscanned: false,
1392 caused_by_job: job_run_of(&actor).map(str::to_owned),
1393 commits: Some(3),
1394 };
1395 let push = serde_json::to_value(push).unwrap();
1396 assert_eq!(caused_by_job(&push), Some("run_9"));
1397 assert_eq!(caused_by_job(&carried(serde_json::json!({ "number": 4 }), &push)), Some("run_9"));
1398 assert_eq!(caused_by_job(&carried(serde_json::json!({ "number": 4 }), &serde_json::json!({}))), None);
1399 }
1400
1401 #[test]
1402 fn a_push_says_it_was_unscanned_only_when_it_was() {
1403 let push = |unscanned| GitPush {
1404 repo_id: "rep_1".into(),
1405 git_ref: "refs/heads/import".into(),
1406 before: None,
1407 after: "abc".into(),
1408 default_branch: false,
1409 unscanned,
1410 caused_by_job: None,
1411 commits: None,
1412 };
1413 let quiet = serde_json::to_value(push(false)).unwrap();
1414 assert!(quiet.get("unscanned").is_none());
1415 let flagged = serde_json::to_value(push(true)).unwrap();
1416 assert_eq!(flagged["unscanned"], true);
1417 assert_eq!(flagged["ref"], "refs/heads/import");
1418 }
1419
1420 fn renamed(from: &str, to: &str) -> WorkspaceRenamed {
1421 WorkspaceRenamed {
1422 workspace_id: "wsp_1".into(),
1423 from: from.into(),
1424 to: to.into(),
1425 }
1426 }
1427
1428 #[test]
1429 fn stale_slugs_leave_out_the_current_one() {
1430 assert_eq!(renamed("a", "b").stale_slugs("b"), vec!["a"]);
1431 // Delivered after a second rename, b → c: both move to c.
1432 assert_eq!(renamed("a", "b").stale_slugs("c"), vec!["a", "b"]);
1433 // Renamed back: a → b → a.
1434 assert_eq!(renamed("a", "b").stale_slugs("a"), vec!["b"]);
1435 }
1436
1437 fn transferred(from: &str, to: &str) -> RepoTransferred {
1438 RepoTransferred {
1439 repo_id: "rep_1".into(),
1440 name: "rocket".into(),
1441 from: from.into(),
1442 to: to.into(),
1443 }
1444 }
1445
1446 #[test]
1447 fn stale_paths_leave_out_the_current_one() {
1448 assert_eq!(transferred("a", "b").stale_paths("b/rocket"), vec!["a/rocket"]);
1449 // Delivered after a second transfer, b → c: both move to c.
1450 assert_eq!(
1451 transferred("a", "b").stale_paths("c/rocket"),
1452 vec!["a/rocket", "b/rocket"]
1453 );
1454 // Transferred back: a → b → a.
1455 assert_eq!(transferred("a", "b").stale_paths("a/rocket"), vec!["b/rocket"]);
1456 }
1457
1458 #[test]
1459 fn a_transfer_reads_as_published() {
1460 let data = serde_json::json!({ "repoId": "rep_1", "name": "rocket", "from": "a", "to": "b" });
1461 let event: RepoTransferred = serde_json::from_value(data).unwrap();
1462 assert_eq!((event.from.as_str(), event.to.as_str()), ("a", "b"));
1463 }
1464
1465 #[test]
1466 fn a_rename_names_both_paths_in_its_workspace() {
1467 let renamed = RepoRenamed {
1468 repo_id: "rep_1".into(),
1469 namespace: "acme".into(),
1470 from: "old".into(),
1471 to: "new".into(),
1472 };
1473 assert_eq!(renamed.stale_paths("acme/new"), vec!["acme/old"]);
1474 // Delivered after a transfer: both names in acme move.
1475 assert_eq!(renamed.stale_paths("flagon/new"), vec!["acme/old", "acme/new"]);
1476 }
1477
1478 #[test]
1479 fn a_member_joining_says_by_which_door() {
1480 let joined = MemberJoined {
1481 workspace_id: "wsp_1".into(),
1482 workspace: "acme".into(),
1483 user_id: "usr_ada".into(),
1484 username: "ada".into(),
1485 role: crate::Role::Member,
1486 how: JoinedHow::Invitation,
1487 };
1488 let data = serde_json::to_value(&joined).unwrap();
1489 assert_eq!(
1490 data,
1491 serde_json::json!({
1492 "workspaceId": "wsp_1",
1493 "workspace": "acme",
1494 "userId": "usr_ada",
1495 "username": "ada",
1496 "role": "member",
1497 "how": "invitation",
1498 })
1499 );
1500 let back: MemberJoined = serde_json::from_value(data).unwrap();
1501 assert_eq!(back, joined);
1502 assert_eq!(serde_json::to_value(JoinedHow::Created).unwrap(), "created");
1503 assert_eq!(serde_json::to_value(JoinedHow::Joined).unwrap(), "joined");
1504 }
1505
1506 #[test]
1507 fn reads_the_published_payload() {
1508 let data = serde_json::json!({ "workspaceId": "wsp_1", "from": "a", "to": "b" });
1509 let event: WorkspaceRenamed = serde_json::from_value(data).unwrap();
1510 assert_eq!((event.from.as_str(), event.to.as_str()), ("a", "b"));
1511 }
1512
1513 #[test]
1514 fn a_stale_page_reads_as_published() {
1515 let data = serde_json::json!({
1516 "workspace": "acme", "workspaceId": "wsp_1", "pageId": "pag_1", "spaceId": "spc_1",
1517 "title": "Exports", "path": "/acme/-/docs/general/exports-pag_1",
1518 "repoId": "rep_1", "repo": "acme/web", "commit": "abc", "pull": 431,
1519 "paths": ["src/export.ts"], "owners": ["user:usr_1"]
1520 });
1521 let event: DocPageStale = serde_json::from_value(data).unwrap();
1522 assert_eq!((event.page.page_id.as_str(), event.pull), ("pag_1", Some(431)));
1523 assert!(DOC_PAGE_EVENTS.contains(&"doc.page.stale"));
1524 }
1525}