| 1 | //! The inbox: what needs a person, or what they follow, as it happens. |
| 2 | //! |
| 3 | //! The events service keeps it, beside the event log: as events arrive it |
| 4 | //! works out who should hear of each (see `services/events/src/inbox.rs`) |
| 5 | //! and writes one item per person. Items are kept by username, which never |
| 6 | //! changes. Methods, served at `POST /rpc/<method>` on the events service: |
| 7 | //! |
| 8 | //! - `inbox_list` takes `ListInboxArgs` and returns `InboxPage`. Items about |
| 9 | //! a repository the viewer can no longer read are dropped as they are |
| 10 | //! found. |
| 11 | //! - `inbox_counts` takes `InboxCountsArgs` and returns `InboxCounts`: the |
| 12 | //! unread items, by severity. One query, for every page's top bar. |
| 13 | //! - `inbox_mark` takes `MarkInboxArgs` and returns how many items changed. |
| 14 | //! |
| 15 | //! What an event is about (the issue or pull request, its people, the |
| 16 | //! comment) comes from the work service's `inbox_subject`, which takes |
| 17 | //! `InboxSubjectArgs` and returns `Option<InboxSubject>`. |
| 18 | |
| 19 | use serde::{Deserialize, Serialize}; |
| 20 | |
| 21 | use crate::Viewer; |
| 22 | use crate::credentials::Principal; |
| 23 | |
| 24 | /// How much an item matters, and how it is shown: a failure, something a |
| 25 | /// person must answer (an agent waiting on them), something that went |
| 26 | /// well, or something to know. |
| 27 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 28 | #[serde(rename_all = "lowercase")] |
| 29 | pub enum Severity { |
| 30 | Error, |
| 31 | Warning, |
| 32 | Success, |
| 33 | Info, |
| 34 | } |
| 35 | |
| 36 | impl Severity { |
| 37 | pub const ALL: [Severity; 4] = [Severity::Error, Severity::Warning, Severity::Success, Severity::Info]; |
| 38 | |
| 39 | pub fn as_str(self) -> &'static str { |
| 40 | match self { |
| 41 | Severity::Error => "error", |
| 42 | Severity::Warning => "warning", |
| 43 | Severity::Success => "success", |
| 44 | Severity::Info => "info", |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | pub fn parse(value: &str) -> Option<Severity> { |
| 49 | Severity::ALL.into_iter().find(|severity| severity.as_str() == value) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | /// What an item is about. |
| 54 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 55 | #[serde(rename_all = "lowercase")] |
| 56 | pub enum SubjectKind { |
| 57 | Issue, |
| 58 | Pull, |
| 59 | /// A workflow run. |
| 60 | Run, |
| 61 | } |
| 62 | |
| 63 | impl SubjectKind { |
| 64 | pub fn as_str(self) -> &'static str { |
| 65 | match self { |
| 66 | SubjectKind::Issue => "issue", |
| 67 | SubjectKind::Pull => "pull", |
| 68 | SubjectKind::Run => "run", |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | pub fn parse(value: &str) -> Option<SubjectKind> { |
| 73 | [SubjectKind::Issue, SubjectKind::Pull, SubjectKind::Run] |
| 74 | .into_iter() |
| 75 | .find(|kind| kind.as_str() == value) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// One thing a person was told. |
| 80 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 81 | #[serde(rename_all = "camelCase")] |
| 82 | pub struct InboxItem { |
| 83 | pub id: String, |
| 84 | /// Why they were told, such as `checks_failed` or `mentioned`. |
| 85 | pub reason: String, |
| 86 | pub severity: Severity, |
| 87 | /// One line: what happened, and where. |
| 88 | pub title: String, |
| 89 | /// One line: what it happened to, such as the pull request's title. |
| 90 | pub body: String, |
| 91 | /// `owner/name`. |
| 92 | pub repo: Option<String>, |
| 93 | /// The workspace it happened in. |
| 94 | pub workspace: Option<String>, |
| 95 | pub subject: Option<SubjectKind>, |
| 96 | /// The issue or pull request's number. |
| 97 | pub number: Option<u32>, |
| 98 | /// Where it is on g1t.sh: a path such as `/acme/rocket/pull/12`. |
| 99 | pub url: String, |
| 100 | /// Who did it: a username, or `g1t`. Absent when nobody did. |
| 101 | pub actor: Option<String>, |
| 102 | /// RFC 3339. |
| 103 | pub created_at: String, |
| 104 | pub read_at: Option<String>, |
| 105 | pub done_at: Option<String>, |
| 106 | pub saved: bool, |
| 107 | /// While this is in the future the item is out of the list. |
| 108 | pub snoozed_until: Option<String>, |
| 109 | } |
| 110 | |
| 111 | /// Which items a list shows. |
| 112 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 113 | #[serde(rename_all = "lowercase")] |
| 114 | pub enum InboxView { |
| 115 | /// Everything not done and not snoozed: the inbox itself. |
| 116 | #[default] |
| 117 | Inbox, |
| 118 | /// Saved items, done or not. |
| 119 | Saved, |
| 120 | /// Items marked done. |
| 121 | Done, |
| 122 | } |
| 123 | |
| 124 | /// `inbox_list`. Newest first, except that unread warnings (an agent |
| 125 | /// waiting on the person) come before everything else in the inbox. |
| 126 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 127 | #[serde(rename_all = "camelCase")] |
| 128 | pub struct ListInboxArgs { |
| 129 | /// Whose inbox: the person signed in. Their memberships decide which |
| 130 | /// repositories they can still read. |
| 131 | pub viewer: Viewer, |
| 132 | #[serde(default)] |
| 133 | pub view: InboxView, |
| 134 | /// Only items of this severity. |
| 135 | #[serde(default)] |
| 136 | pub severity: Option<Severity>, |
| 137 | #[serde(default)] |
| 138 | pub unread: bool, |
| 139 | /// The `next` of the page before. |
| 140 | #[serde(default)] |
| 141 | pub before: Option<String>, |
| 142 | #[serde(default)] |
| 143 | pub limit: Option<u32>, |
| 144 | } |
| 145 | |
| 146 | pub const DEFAULT_INBOX_PAGE: u32 = 30; |
| 147 | pub const MAX_INBOX_PAGE: u32 = 100; |
| 148 | |
| 149 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] |
| 150 | #[serde(rename_all = "camelCase")] |
| 151 | pub struct InboxPage { |
| 152 | pub items: Vec<InboxItem>, |
| 153 | /// Pass as `before` for the next page; absent on the last. |
| 154 | pub next: Option<String>, |
| 155 | } |
| 156 | |
| 157 | /// `inbox_counts`. |
| 158 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 159 | #[serde(rename_all = "camelCase")] |
| 160 | pub struct InboxCountsArgs { |
| 161 | pub username: String, |
| 162 | } |
| 163 | |
| 164 | /// Unread items in the inbox view, by severity. |
| 165 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 166 | #[serde(rename_all = "camelCase")] |
| 167 | pub struct InboxCounts { |
| 168 | pub unread: u32, |
| 169 | pub error: u32, |
| 170 | pub warning: u32, |
| 171 | pub success: u32, |
| 172 | pub info: u32, |
| 173 | } |
| 174 | |
| 175 | /// What `inbox_mark` does to the items it names. |
| 176 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 177 | #[serde(rename_all = "snake_case")] |
| 178 | pub enum InboxMark { |
| 179 | Read, |
| 180 | Unread, |
| 181 | /// Out of the inbox, into Done; read with it. |
| 182 | Done, |
| 183 | /// Back into the inbox. |
| 184 | Undone, |
| 185 | Save, |
| 186 | Unsave, |
| 187 | /// Out of the inbox until `until`. |
| 188 | Snooze, |
| 189 | } |
| 190 | |
| 191 | /// `inbox_mark`: changes the person's own items, by id, or every item in |
| 192 | /// their inbox when `ids` is empty and `all` is set (Mark all read). |
| 193 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 194 | #[serde(rename_all = "camelCase")] |
| 195 | pub struct MarkInboxArgs { |
| 196 | pub username: String, |
| 197 | pub mark: InboxMark, |
| 198 | #[serde(default)] |
| 199 | pub ids: Vec<String>, |
| 200 | #[serde(default)] |
| 201 | pub all: bool, |
| 202 | /// With `all`: only items of this severity. |
| 203 | #[serde(default)] |
| 204 | pub severity: Option<Severity>, |
| 205 | /// For `snooze`: RFC 3339. |
| 206 | #[serde(default)] |
| 207 | pub until: Option<String>, |
| 208 | } |
| 209 | |
| 210 | /// The most ids one `inbox_mark` call changes. |
| 211 | pub const MAX_MARK: usize = 100; |
| 212 | |
| 213 | /// `inbox_subject` on the work service: what an event names, for the |
| 214 | /// inbox. A service-to-service read: it checks nobody's access, and what |
| 215 | /// it returns is only ever shown to the people it names, or to those who |
| 216 | /// can read the repository. |
| 217 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 218 | #[serde(rename_all = "camelCase")] |
| 219 | pub struct InboxSubjectArgs { |
| 220 | pub repo_id: String, |
| 221 | pub number: u32, |
| 222 | /// The comment the event is about, if any. |
| 223 | #[serde(default)] |
| 224 | pub comment_id: Option<String>, |
| 225 | } |
| 226 | |
| 227 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 228 | #[serde(rename_all = "camelCase")] |
| 229 | pub struct InboxSubject { |
| 230 | /// `issue` or `pull`. |
| 231 | pub kind: Option<SubjectKind>, |
| 232 | pub title: String, |
| 233 | pub author: Principal, |
| 234 | /// For work g1t did: the person it was done for. |
| 235 | #[serde(default)] |
| 236 | pub requested_by: Option<Principal>, |
| 237 | /// Usernames. |
| 238 | #[serde(default)] |
| 239 | pub assignees: Vec<String>, |
| 240 | /// Usernames, and `g1t`. Pull requests only. |
| 241 | #[serde(default)] |
| 242 | pub reviewers: Vec<String>, |
| 243 | /// For a pull request: the issue it is for, with that issue's people. |
| 244 | #[serde(default)] |
| 245 | pub issue: Option<Box<InboxSubject>>, |
| 246 | #[serde(default)] |
| 247 | pub comment: Option<InboxComment>, |
| 248 | } |
| 249 | |
| 250 | impl InboxSubject { |
| 251 | /// Whose it is to answer for: whoever asked g1t for it, or its author. |
| 252 | pub fn owner(&self) -> &Principal { |
| 253 | self.requested_by.as_ref().unwrap_or(&self.author) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 258 | #[serde(rename_all = "camelCase")] |
| 259 | pub struct InboxComment { |
| 260 | pub author: Principal, |
| 261 | /// The first line or so, as written. |
| 262 | pub excerpt: String, |
| 263 | /// The people it mentions by name, outside code and quotes. Never `g1t`. |
| 264 | #[serde(default)] |
| 265 | pub mentions: Vec<String>, |
| 266 | /// A review's verdict: `approve` or `request_changes`. |
| 267 | #[serde(default)] |
| 268 | pub verdict: Option<String>, |
| 269 | /// Something that happened (an assignment, a close), not something written. |
| 270 | #[serde(default)] |
| 271 | pub event: bool, |
| 272 | } |
| 273 | |
| 274 | #[cfg(test)] |
| 275 | mod tests { |
| 276 | use super::*; |
| 277 | |
| 278 | #[test] |
| 279 | fn severities_and_subjects_read_back() { |
| 280 | for severity in Severity::ALL { |
| 281 | assert_eq!(Severity::parse(severity.as_str()), Some(severity)); |
| 282 | } |
| 283 | assert_eq!(Severity::parse("fatal"), None); |
| 284 | assert_eq!(SubjectKind::parse("pull"), Some(SubjectKind::Pull)); |
| 285 | assert_eq!(serde_json::to_value(InboxMark::Unsave).unwrap(), "unsave"); |
| 286 | } |
| 287 | } |