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.

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