API: notifications over REST and MCP, with notifications scopes
Fourteen operations: list and mark notifications (yours, or one repository's), get a thread and mark it read, done, saved or snoozed, subscribe to or unsubscribe from an issue or pull request (by thread or by number), watch a repository and list what you watch. REST at /notifications, /notifications/threads/{id}, /repos/{owner}/{name}/ notifications, .../subscription, .../issues/{number}/subscription and /user/subscriptions; MCP as one notifications tool whose default action is list. notifications:read and notifications:write gate them, in the Read only and Agent presets; g1t's own agents never use them, and marking your own inbox is not audited. The OpenAPI copy and reference examples are updated.
| 187 | 187 | if !actor.records_reads() && is_read(op.name()) { | |
| 188 | 188 | return; | |
| 189 | 189 | } | |
| 190 | + | // A person's own inbox is nobody else's business. | |
| 191 | + | if op.personal() { | |
| 192 | + | return; | |
| 193 | + | } | |
| 190 | 194 | let mut entry = NewAuditEntry::new( | |
| 191 | 195 | actor, | |
| 192 | 196 | op.name(), |
| 9 | 9 | mod audit; | |
| 10 | 10 | mod blobs; | |
| 11 | 11 | mod mcp; | |
| 12 | + | mod notifications; | |
| 12 | 13 | mod oauth; | |
| 13 | 14 | mod openapi; | |
| 14 | 15 | mod operations; |
| 1 | + | //! Notifications: a person's inbox, its threads, their subscriptions to | |
| 2 | + | //! issues and pull requests, and how they watch repositories. The events | |
| 3 | + | //! service keeps all of it (`g1t_contracts::inbox`); this is its public | |
| 4 | + | //! shape, which follows the inbox's own: a thread is one item about one | |
| 5 | + | //! thing, brought back to the top as things happen to it. | |
| 6 | + | //! | |
| 7 | + | //! Every operation here is the person's own: a personal access token or a | |
| 8 | + | //! session, never a workspace's token or g1t's agents (which act as g1t, | |
| 9 | + | //! and g1t is never told anything). | |
| 10 | + | ||
| 11 | + | use g1t_contracts::inbox::*; | |
| 12 | + | use g1t_contracts::repos::{GetArgs, Repo, RepoPath}; | |
| 13 | + | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 14 | + | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 15 | + | use serde_json::{Value, json}; | |
| 16 | + | use worker::Result; | |
| 17 | + | ||
| 18 | + | use crate::operations::{Op, Services}; | |
| 19 | + | ||
| 20 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 21 | + | Ok(Outcome::fail(code, message)) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | fn ok<T: serde::Serialize>(value: &T) -> Result<Outcome<Value>> { | |
| 25 | + | Ok(Outcome::Ok(serde_json::to_value(value)?)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | const NO_THREAD: &str = "No such notification thread."; | |
| 29 | + | const NO_SUBJECT: &str = "Name a thread by id, or an issue or pull request by repo and number."; | |
| 30 | + | ||
| 31 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 32 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// A yes or no, given as a boolean or as `true`/`false` (a query string). | |
| 36 | + | fn flag(input: &Value, key: &str) -> Option<bool> { | |
| 37 | + | match &input[key] { | |
| 38 | + | Value::Bool(value) => Some(*value), | |
| 39 | + | Value::String(text) => match text.trim() { | |
| 40 | + | "true" | "1" => Some(true), | |
| 41 | + | "false" | "0" => Some(false), | |
| 42 | + | _ => None, | |
| 43 | + | }, | |
| 44 | + | _ => None, | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | fn whole(input: &Value, key: &str) -> Option<u32> { | |
| 49 | + | match &input[key] { | |
| 50 | + | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 51 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 52 | + | _ => None, | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// A time given as RFC 3339, as the inbox stores times (to the | |
| 57 | + | /// millisecond, in UTC), or why it is not one. | |
| 58 | + | pub(crate) fn instant(input: &Value, key: &str) -> std::result::Result<Option<String>, String> { | |
| 59 | + | match text(input, key) { | |
| 60 | + | None => Ok(None), | |
| 61 | + | Some(given) => parse_rfc3339(&given) | |
| 62 | + | .map(|ms| Some(rfc3339(ms))) | |
| 63 | + | .ok_or_else(|| format!("{key} is a time like 2026-10-07T12:00:00Z, not {given}.")), | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// The repository `repo` names, if the viewer may read it. | |
| 68 | + | async fn readable_repo(services: &Services, viewer: &Viewer, input: &Value) -> Result<Option<Outcome<Repo>>> { | |
| 69 | + | let Some(path) = crate::operations::repo_path(input) else { | |
| 70 | + | return Ok(None); | |
| 71 | + | }; | |
| 72 | + | let found: Outcome<Repo> = g1t_kit::call( | |
| 73 | + | &services.repos, | |
| 74 | + | "get", | |
| 75 | + | &GetArgs { | |
| 76 | + | path: RepoPath { | |
| 77 | + | namespace: path.namespace, | |
| 78 | + | name: path.name, | |
| 79 | + | }, | |
| 80 | + | viewer: viewer.clone(), | |
| 81 | + | }, | |
| 82 | + | ) | |
| 83 | + | .await?; | |
| 84 | + | Ok(Some(found)) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// What `list_notifications` reads from its input. | |
| 88 | + | pub(crate) fn list_args(viewer: &Viewer, input: &Value) -> std::result::Result<ListInboxArgs, String> { | |
| 89 | + | let view = match text(input, "view") { | |
| 90 | + | None => InboxView::Inbox, | |
| 91 | + | Some(view) => InboxView::parse(&view).ok_or_else(|| format!("view is inbox, saved or done, not {view}."))?, | |
| 92 | + | }; | |
| 93 | + | let reason = match text(input, "reason") { | |
| 94 | + | None => None, | |
| 95 | + | Some(reason) => Some(Reason::parse(&reason).ok_or_else(|| { | |
| 96 | + | format!( | |
| 97 | + | "{reason} is not a reason. Give one of {}.", | |
| 98 | + | Reason::ALL.map(Reason::as_str).join(", ") | |
| 99 | + | ) | |
| 100 | + | })?), | |
| 101 | + | }; | |
| 102 | + | let severity = match text(input, "severity") { | |
| 103 | + | None => None, | |
| 104 | + | Some(severity) => Some( | |
| 105 | + | Severity::parse(&severity).ok_or_else(|| format!("severity is error, warning, success or info, not {severity}."))?, | |
| 106 | + | ), | |
| 107 | + | }; | |
| 108 | + | // As it is elsewhere: what is unread, unless all is asked for. Saved | |
| 109 | + | // and done are lists of their own, read or not. | |
| 110 | + | let all = flag(input, "all").unwrap_or(false); | |
| 111 | + | let unread = flag(input, "unread").unwrap_or(view == InboxView::Inbox && !all); | |
| 112 | + | Ok(ListInboxArgs { | |
| 113 | + | viewer: viewer.clone(), | |
| 114 | + | view, | |
| 115 | + | severity, | |
| 116 | + | reason, | |
| 117 | + | participating: flag(input, "participating").unwrap_or(false), | |
| 118 | + | repo_id: None, | |
| 119 | + | unread, | |
| 120 | + | since: instant(input, "since")?, | |
| 121 | + | updated_before: instant(input, "before")?, | |
| 122 | + | before: text(input, "cursor"), | |
| 123 | + | limit: whole(input, "per_page").map(|n| n.clamp(1, MAX_INBOX_PAGE)), | |
| 124 | + | }) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// How a person watches a repository, as the API shows it: its level and | |
| 128 | + | /// kinds, and the plain answers to whether they get its activity and | |
| 129 | + | /// whether they ignore it. | |
| 130 | + | pub(crate) fn watching_json(watching: &Watching, repo: Option<&str>) -> Value { | |
| 131 | + | json!({ | |
| 132 | + | "repo": repo.map(str::to_owned).or_else(|| watching.repo.clone()), | |
| 133 | + | "level": watching.level, | |
| 134 | + | "events": watching.events, | |
| 135 | + | "subscribed": matches!(watching.level, WatchLevel::All | WatchLevel::Custom), | |
| 136 | + | "ignored": watching.level == WatchLevel::Ignore, | |
| 137 | + | "updated_at": watching.updated_at, | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// The level `set_repo_subscription` asks for: `level` (with `events`), or | |
| 142 | + | /// the yes-or-no of `subscribed` and `ignored`. | |
| 143 | + | pub(crate) fn watch_level(input: &Value) -> std::result::Result<(WatchLevel, Vec<String>), String> { | |
| 144 | + | let events: Vec<String> = input["events"] | |
| 145 | + | .as_array() | |
| 146 | + | .map(|events| events.iter().filter_map(|event| event.as_str().map(str::to_owned)).collect()) | |
| 147 | + | .unwrap_or_default(); | |
| 148 | + | if let Some(level) = text(input, "level") { | |
| 149 | + | let level = WatchLevel::parse(&level) | |
| 150 | + | .ok_or_else(|| format!("level is participating, all, ignore or custom, not {level}."))?; | |
| 151 | + | if level == WatchLevel::Custom { | |
| 152 | + | let unknown: Vec<&String> = events | |
| 153 | + | .iter() | |
| 154 | + | .filter(|event| !WATCH_EVENTS.contains(&event.trim().to_lowercase().as_str())) | |
| 155 | + | .collect(); | |
| 156 | + | if let Some(event) = unknown.first() { | |
| 157 | + | return Err(format!("{event} is not something to watch. Give some of {}.", WATCH_EVENTS.join(", "))); | |
| 158 | + | } | |
| 159 | + | if events.is_empty() { | |
| 160 | + | return Err(format!("A custom watch needs events: some of {}.", WATCH_EVENTS.join(", "))); | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return Ok((level, events)); | |
| 164 | + | } | |
| 165 | + | Ok(match (flag(input, "ignored"), flag(input, "subscribed")) { | |
| 166 | + | (Some(true), _) => (WatchLevel::Ignore, Vec::new()), | |
| 167 | + | (_, Some(false)) => (WatchLevel::Participating, Vec::new()), | |
| 168 | + | _ => (WatchLevel::All, Vec::new()), | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Which issue or pull request a subscription call names: a thread's id, | |
| 173 | + | /// or a repository and number. Checks the viewer can read the repository. | |
| 174 | + | async fn subscription_args(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<SubscriptionArgs>> { | |
| 175 | + | if let Some(id) = text(input, "id") { | |
| 176 | + | return Ok(Outcome::Ok(SubscriptionArgs { | |
| 177 | + | viewer: viewer.clone(), | |
| 178 | + | id: Some(id), | |
| 179 | + | ..SubscriptionArgs::default() | |
| 180 | + | })); | |
| 181 | + | } | |
| 182 | + | let (Some(found), Some(number)) = (readable_repo(services, viewer, input).await?, whole(input, "number")) else { | |
| 183 | + | return Ok(Outcome::fail(FailureCode::Invalid, NO_SUBJECT)); | |
| 184 | + | }; | |
| 185 | + | Ok(match found { | |
| 186 | + | Outcome::Ok(repo) => Outcome::Ok(SubscriptionArgs { | |
| 187 | + | viewer: viewer.clone(), | |
| 188 | + | id: None, | |
| 189 | + | repo_id: Some(repo.id), | |
| 190 | + | number: Some(number), | |
| 191 | + | }), | |
| 192 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// The viewer as a person: notifications are nobody else's. | |
| 197 | + | fn person(viewer: &Viewer) -> std::result::Result<&User, &'static str> { | |
| 198 | + | match viewer { | |
| 199 | + | Some(user) if user.kind == PrincipalKind::User => Ok(user), | |
| 200 | + | Some(_) => Err("Notifications are a person's own: use a personal access token, not a workspace's or an agent's."), | |
| 201 | + | None => Err("This needs a g1t access token."), | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// One thread, after a change, or not found. | |
| 206 | + | async fn thread_after(services: &Services, viewer: &Viewer, id: String) -> Result<Outcome<Value>> { | |
| 207 | + | let thread: Option<InboxThread> = g1t_kit::call(&services.events, "inbox_thread", &ThreadArgs { viewer: viewer.clone(), id }).await?; | |
| 208 | + | match thread { | |
| 209 | + | Some(thread) => ok(&thread), | |
| 210 | + | None => failed(FailureCode::NotFound, NO_THREAD), | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// Marks one of the person's threads, then returns it as it is now. | |
| 215 | + | async fn mark_one(services: &Services, viewer: &Viewer, user: &User, input: &Value, mark: InboxMark, until: Option<String>) -> Result<Outcome<Value>> { | |
| 216 | + | let Some(id) = text(input, "id") else { | |
| 217 | + | return failed(FailureCode::Invalid, "Give the thread's id."); | |
| 218 | + | }; | |
| 219 | + | // Only a thread the person can still see; one about a repository they | |
| 220 | + | // lost is gone. | |
| 221 | + | if let Outcome::Fail(failure) = thread_after(services, viewer, id.clone()).await? { | |
| 222 | + | return Ok(Outcome::Fail(failure)); | |
| 223 | + | } | |
| 224 | + | let _: u32 = g1t_kit::call( | |
| 225 | + | &services.events, | |
| 226 | + | "inbox_mark", | |
| 227 | + | &MarkInboxArgs { | |
| 228 | + | username: user.username.clone(), | |
| 229 | + | mark, | |
| 230 | + | ids: vec![id.clone()], | |
| 231 | + | all: false, | |
| 232 | + | severity: None, | |
| 233 | + | repo_id: None, | |
| 234 | + | last_read_at: None, | |
| 235 | + | until, | |
| 236 | + | }, | |
| 237 | + | ) | |
| 238 | + | .await?; | |
| 239 | + | thread_after(services, viewer, id).await | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// Runs one of the notification operations. | |
| 243 | + | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 244 | + | let user = match person(viewer) { | |
| 245 | + | Ok(user) => user, | |
| 246 | + | Err(message) => return failed(FailureCode::Forbidden, message), | |
| 247 | + | }; | |
| 248 | + | let username = user.username.to_lowercase(); | |
| 249 | + | match op { | |
| 250 | + | Op::ListNotifications => { | |
| 251 | + | let mut args = match list_args(viewer, input) { | |
| 252 | + | Ok(args) => args, | |
| 253 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 254 | + | }; | |
| 255 | + | if let Some(found) = readable_repo(services, viewer, input).await? { | |
| 256 | + | match found { | |
| 257 | + | Outcome::Ok(repo) => args.repo_id = Some(repo.id), | |
| 258 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | let page: InboxPage = g1t_kit::call(&services.events, "inbox_list", &args).await?; | |
| 262 | + | ok(&page) | |
| 263 | + | } | |
| 264 | + | Op::MarkNotificationsRead => { | |
| 265 | + | let last_read_at = match instant(input, "last_read_at") { | |
| 266 | + | Ok(at) => at.unwrap_or_else(|| rfc3339(g1t_kit::now_ms())), | |
| 267 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 268 | + | }; | |
| 269 | + | let repo_id = match readable_repo(services, viewer, input).await? { | |
| 270 | + | Some(Outcome::Ok(repo)) => Some(repo.id), | |
| 271 | + | Some(Outcome::Fail(failure)) => return Ok(Outcome::Fail(failure)), | |
| 272 | + | None => None, | |
| 273 | + | }; | |
| 274 | + | let mark = if flag(input, "read") == Some(false) { InboxMark::Unread } else { InboxMark::Read }; | |
| 275 | + | let marked: u32 = g1t_kit::call( | |
| 276 | + | &services.events, | |
| 277 | + | "inbox_mark", | |
| 278 | + | &MarkInboxArgs { | |
| 279 | + | username, | |
| 280 | + | mark, | |
| 281 | + | ids: Vec::new(), | |
| 282 | + | all: true, | |
| 283 | + | severity: None, | |
| 284 | + | repo_id, | |
| 285 | + | last_read_at: Some(last_read_at.clone()), | |
| 286 | + | until: None, | |
| 287 | + | }, | |
| 288 | + | ) | |
| 289 | + | .await?; | |
| 290 | + | ok(&json!({ "marked": marked, "last_read_at": last_read_at })) | |
| 291 | + | } | |
| 292 | + | Op::GetNotificationThread => match text(input, "id") { | |
| 293 | + | Some(id) => thread_after(services, viewer, id).await, | |
| 294 | + | None => failed(FailureCode::Invalid, "Give the thread's id."), | |
| 295 | + | }, | |
| 296 | + | Op::MarkThreadRead => { | |
| 297 | + | let mark = if flag(input, "read") == Some(false) { InboxMark::Unread } else { InboxMark::Read }; | |
| 298 | + | mark_one(services, viewer, user, input, mark, None).await | |
| 299 | + | } | |
| 300 | + | Op::MarkThreadDone => { | |
| 301 | + | let mark = if flag(input, "done") == Some(false) { InboxMark::Undone } else { InboxMark::Done }; | |
| 302 | + | mark_one(services, viewer, user, input, mark, None).await | |
| 303 | + | } | |
| 304 | + | Op::SaveThread => { | |
| 305 | + | let mark = if flag(input, "saved") == Some(false) { InboxMark::Unsave } else { InboxMark::Save }; | |
| 306 | + | mark_one(services, viewer, user, input, mark, None).await | |
| 307 | + | } | |
| 308 | + | Op::SnoozeThread => { | |
| 309 | + | let until = match instant(input, "until") { | |
| 310 | + | Ok(until) => until, | |
| 311 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 312 | + | }; | |
| 313 | + | match until { | |
| 314 | + | Some(until) if until.as_str() <= rfc3339(g1t_kit::now_ms()).as_str() => { | |
| 315 | + | failed(FailureCode::Invalid, "until is a time to come; leave it out to bring the thread back now.") | |
| 316 | + | } | |
| 317 | + | Some(until) => mark_one(services, viewer, user, input, InboxMark::Snooze, Some(until)).await, | |
| 318 | + | None => mark_one(services, viewer, user, input, InboxMark::Unsnooze, None).await, | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | Op::GetThreadSubscription | Op::SetThreadSubscription | Op::DeleteThreadSubscription => { | |
| 322 | + | let on = match subscription_args(services, viewer, input).await? { | |
| 323 | + | Outcome::Ok(on) => on, | |
| 324 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 325 | + | }; | |
| 326 | + | let found: Option<ThreadSubscription> = match op { | |
| 327 | + | Op::GetThreadSubscription => g1t_kit::call(&services.events, "inbox_subscription", &on).await?, | |
| 328 | + | _ => { | |
| 329 | + | let (subscribed, ignored) = match op { | |
| 330 | + | Op::SetThreadSubscription => (Some(flag(input, "subscribed").unwrap_or(true)), flag(input, "ignored").unwrap_or(false)), | |
| 331 | + | _ => (Some(false), false), | |
| 332 | + | }; | |
| 333 | + | g1t_kit::call(&services.events, "inbox_subscribe", &SubscribeArgs { on, subscribed, ignored }).await? | |
| 334 | + | } | |
| 335 | + | }; | |
| 336 | + | match found { | |
| 337 | + | Some(subscription) => ok(&subscription), | |
| 338 | + | None => failed(FailureCode::NotFound, "No such issue or pull request, or it is a thread nobody subscribes to."), | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | Op::GetRepoSubscription | Op::SetRepoSubscription | Op::DeleteRepoSubscription => { | |
| 342 | + | let repo = match readable_repo(services, viewer, input).await? { | |
| 343 | + | Some(Outcome::Ok(repo)) => repo, | |
| 344 | + | Some(Outcome::Fail(failure)) => return Ok(Outcome::Fail(failure)), | |
| 345 | + | None => return failed(FailureCode::Invalid, "Give the repository as \"owner/name\"."), | |
| 346 | + | }; | |
| 347 | + | let path = format!("{}/{}", repo.namespace, repo.name); | |
| 348 | + | let watching: Watching = match op { | |
| 349 | + | Op::GetRepoSubscription => { | |
| 350 | + | g1t_kit::call(&services.events, "inbox_watching", &WatchingArgs { username, repo_id: repo.id }).await? | |
| 351 | + | } | |
| 352 | + | _ => { | |
| 353 | + | let (level, events) = match op { | |
| 354 | + | Op::SetRepoSubscription => match watch_level(input) { | |
| 355 | + | Ok((level, events)) => (Some(level), events), | |
| 356 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 357 | + | }, | |
| 358 | + | _ => (None, Vec::new()), | |
| 359 | + | }; | |
| 360 | + | g1t_kit::call( | |
| 361 | + | &services.events, | |
| 362 | + | "inbox_watch", | |
| 363 | + | &WatchArgs { | |
| 364 | + | username, | |
| 365 | + | repo_id: repo.id, | |
| 366 | + | repo: Some(path.clone()), | |
| 367 | + | level, | |
| 368 | + | events, | |
| 369 | + | }, | |
| 370 | + | ) | |
| 371 | + | .await? | |
| 372 | + | } | |
| 373 | + | }; | |
| 374 | + | Ok(Outcome::Ok(watching_json(&watching, Some(&path)))) | |
| 375 | + | } | |
| 376 | + | Op::ListWatchedRepos => { | |
| 377 | + | let watched: Vec<Watching> = g1t_kit::call(&services.events, "inbox_watched", &InboxCountsArgs { username }).await?; | |
| 378 | + | Ok(Outcome::Ok(Value::Array(watched.iter().map(|watching| watching_json(watching, None)).collect()))) | |
| 379 | + | } | |
| 380 | + | _ => failed(FailureCode::NotFound, "No such endpoint."), | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | #[cfg(test)] | |
| 385 | + | mod tests { | |
| 386 | + | use super::*; | |
| 387 | + | ||
| 388 | + | fn viewer() -> Viewer { | |
| 389 | + | Some(User { | |
| 390 | + | id: "usr_1".into(), | |
| 391 | + | username: "ana".into(), | |
| 392 | + | ..User::default() | |
| 393 | + | }) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | #[test] | |
| 397 | + | fn a_list_shows_what_is_unread_unless_all_is_asked_for() { | |
| 398 | + | let args = list_args(&viewer(), &json!({})).unwrap(); | |
| 399 | + | assert!(args.unread); | |
| 400 | + | assert_eq!(args.view, InboxView::Inbox); | |
| 401 | + | let args = list_args(&viewer(), &json!({ "all": "true" })).unwrap(); | |
| 402 | + | assert!(!args.unread); | |
| 403 | + | // Saved and done show everything in them. | |
| 404 | + | assert!(!list_args(&viewer(), &json!({ "view": "done" })).unwrap().unread); | |
| 405 | + | let args = list_args( | |
| 406 | + | &viewer(), | |
| 407 | + | &json!({ "reason": "review_requested", "participating": true, "since": "2026-10-01T00:00:00Z", "before": "2026-10-07T00:00:00Z", "cursor": "ntf_9", "per_page": "500" }), | |
| 408 | + | ) | |
| 409 | + | .unwrap(); | |
| 410 | + | assert_eq!(args.reason, Some(Reason::ReviewRequested)); | |
| 411 | + | assert!(args.participating); | |
| 412 | + | assert_eq!(args.since.as_deref(), Some("2026-10-01T00:00:00.000Z")); | |
| 413 | + | assert_eq!(args.updated_before.as_deref(), Some("2026-10-07T00:00:00.000Z")); | |
| 414 | + | assert_eq!(args.before.as_deref(), Some("ntf_9")); | |
| 415 | + | assert_eq!(args.limit, Some(MAX_INBOX_PAGE)); | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | #[test] | |
| 419 | + | fn a_list_names_what_it_cannot_read() { | |
| 420 | + | assert!(list_args(&viewer(), &json!({ "reason": "gossip" })).unwrap_err().contains("not a reason")); | |
| 421 | + | assert!(list_args(&viewer(), &json!({ "view": "archive" })).unwrap_err().contains("inbox, saved or done")); | |
| 422 | + | assert!(list_args(&viewer(), &json!({ "since": "yesterday" })).unwrap_err().contains("since is a time")); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | #[test] | |
| 426 | + | fn watching_is_a_level_or_a_yes_or_no() { | |
| 427 | + | assert_eq!(watch_level(&json!({ "level": "all" })).unwrap().0, WatchLevel::All); | |
| 428 | + | assert_eq!(watch_level(&json!({ "ignored": true })).unwrap().0, WatchLevel::Ignore); | |
| 429 | + | assert_eq!(watch_level(&json!({ "subscribed": false })).unwrap().0, WatchLevel::Participating); | |
| 430 | + | assert_eq!(watch_level(&json!({})).unwrap().0, WatchLevel::All); | |
| 431 | + | let (level, events) = watch_level(&json!({ "level": "custom", "events": ["pulls", "deployments"] })).unwrap(); | |
| 432 | + | assert_eq!((level, events.len()), (WatchLevel::Custom, 2)); | |
| 433 | + | assert!(watch_level(&json!({ "level": "custom" })).unwrap_err().contains("needs events")); | |
| 434 | + | assert!(watch_level(&json!({ "level": "custom", "events": ["releases"] })).unwrap_err().contains("releases")); | |
| 435 | + | assert!(watch_level(&json!({ "level": "loud" })).is_err()); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | #[test] | |
| 439 | + | fn watching_says_plainly_whether_activity_comes() { | |
| 440 | + | let custom = Watching { | |
| 441 | + | repo_id: "rep_1".into(), | |
| 442 | + | repo: None, | |
| 443 | + | level: WatchLevel::Custom, | |
| 444 | + | events: vec!["pulls".into()], | |
| 445 | + | updated_at: None, | |
| 446 | + | }; | |
| 447 | + | let shown = watching_json(&custom, Some("acme/rocket")); | |
| 448 | + | assert_eq!(shown["repo"], "acme/rocket"); | |
| 449 | + | assert_eq!(shown["level"], "custom"); | |
| 450 | + | assert_eq!(shown["subscribed"], true); | |
| 451 | + | assert_eq!(shown["ignored"], false); | |
| 452 | + | assert!(shown.get("repo_id").is_none()); | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | #[test] | |
| 456 | + | fn notifications_are_a_persons_own() { | |
| 457 | + | assert!(person(&viewer()).is_ok()); | |
| 458 | + | let workspace = Some(User { kind: PrincipalKind::Workspace, ..User::default() }); | |
| 459 | + | assert!(person(&workspace).unwrap_err().contains("person's own")); | |
| 460 | + | assert!(person(&None).is_err()); | |
| 461 | + | } | |
| 462 | + | } |
| 19 | 19 | &[Op::Whoami, Op::ListEmails, Op::AddEmail, Op::RemoveEmail, Op::UpdateEmailSettings], | |
| 20 | 20 | ), | |
| 21 | 21 | ( | |
| 22 | + | "Notifications", | |
| 23 | + | "Your inbox: a thread for each thing you were told about (an issue, a pull request, a workflow on a branch, a deployment), why you were told, and what you subscribe to and watch. Your own: personal tokens and sessions only.", | |
| 24 | + | &[ | |
| 25 | + | Op::ListNotifications, | |
| 26 | + | Op::MarkNotificationsRead, | |
| 27 | + | Op::GetNotificationThread, | |
| 28 | + | Op::MarkThreadRead, | |
| 29 | + | Op::MarkThreadDone, | |
| 30 | + | Op::SaveThread, | |
| 31 | + | Op::SnoozeThread, | |
| 32 | + | Op::GetThreadSubscription, | |
| 33 | + | Op::SetThreadSubscription, | |
| 34 | + | Op::DeleteThreadSubscription, | |
| 35 | + | Op::GetRepoSubscription, | |
| 36 | + | Op::SetRepoSubscription, | |
| 37 | + | Op::DeleteRepoSubscription, | |
| 38 | + | Op::ListWatchedRepos, | |
| 39 | + | ], | |
| 40 | + | ), | |
| 41 | + | ( | |
| 22 | 42 | "Workspaces", | |
| 23 | 43 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 24 | 44 | &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 339 | 359 | Op::ListSecurityAlerts => "List security alerts", | |
| 340 | 360 | Op::DismissSecurityAlert => "Dismiss a security alert", | |
| 341 | 361 | Op::ReopenSecurityAlert => "Reopen a security alert", | |
| 362 | + | Op::ListNotifications => "List notifications", | |
| 363 | + | Op::MarkNotificationsRead => "Mark notifications read", | |
| 364 | + | Op::GetNotificationThread => "Get a thread", | |
| 365 | + | Op::MarkThreadRead => "Mark a thread read", | |
| 366 | + | Op::MarkThreadDone => "Mark a thread done", | |
| 367 | + | Op::SaveThread => "Save a thread", | |
| 368 | + | Op::SnoozeThread => "Snooze a thread", | |
| 369 | + | Op::GetThreadSubscription => "Get a thread subscription", | |
| 370 | + | Op::SetThreadSubscription => "Set a thread subscription", | |
| 371 | + | Op::DeleteThreadSubscription => "Unsubscribe from a thread", | |
| 372 | + | Op::GetRepoSubscription => "Get how you watch a repository", | |
| 373 | + | Op::SetRepoSubscription => "Watch a repository", | |
| 374 | + | Op::DeleteRepoSubscription => "Stop watching a repository", | |
| 375 | + | Op::ListWatchedRepos => "List repositories you watch", | |
| 342 | 376 | } | |
| 343 | 377 | } | |
| 344 | 378 | ||
| 422 | 456 | ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(), | |
| 423 | 457 | ("PATCH", ":setting") => "update_actions_variable".to_owned(), | |
| 424 | 458 | ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(), | |
| 459 | + | // One repository's notifications, and an issue's subscription by | |
| 460 | + | // its number rather than a thread's id. | |
| 461 | + | (_, "notifications") if route.path.starts_with("/repos/") => match op { | |
| 462 | + | Op::ListNotifications => "list_repo_notifications".to_owned(), | |
| 463 | + | _ => "mark_repo_notifications_read".to_owned(), | |
| 464 | + | }, | |
| 465 | + | (method, "subscription") if route.path.contains("/issues/:number/") => match method { | |
| 466 | + | "GET" => "get_issue_subscription".to_owned(), | |
| 467 | + | "PUT" => "set_issue_subscription".to_owned(), | |
| 468 | + | _ => "delete_issue_subscription".to_owned(), | |
| 469 | + | }, | |
| 470 | + | ("DELETE", "saved") => "unsave_thread".to_owned(), | |
| 471 | + | ("DELETE", "snooze") => "unsnooze_thread".to_owned(), | |
| 425 | 472 | _ => op.name().to_owned(), | |
| 426 | 473 | }; | |
| 427 | 474 | if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) { | |
| 440 | 487 | "rerun_failed_jobs" => "Re-run failed jobs", | |
| 441 | 488 | "update_actions_variable" => "Update a variable", | |
| 442 | 489 | "list_runs_of_workflow" => "List a workflow's runs", | |
| 490 | + | "list_repo_notifications" => "List a repository's notifications", | |
| 491 | + | "mark_repo_notifications_read" => "Mark a repository's notifications read", | |
| 492 | + | "get_issue_subscription" => "Get your subscription to an issue", | |
| 493 | + | "set_issue_subscription" => "Subscribe to an issue", | |
| 494 | + | "delete_issue_subscription" => "Unsubscribe from an issue", | |
| 495 | + | "unsave_thread" => "Unsave a thread", | |
| 496 | + | "unsnooze_thread" => "Bring a snoozed thread back", | |
| 443 | 497 | _ => title(route.op), | |
| 444 | 498 | }; | |
| 445 | 499 | if id.ends_with("_for_workspace") { |
| 19 | 19 | }; | |
| 20 | 20 | ||
| 21 | 21 | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 22 | + | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| 22 | 23 | use g1t_contracts::work::*; | |
| 23 | 24 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 24 | 25 | use serde::Serialize; | |
| 192 | 193 | ListSecurityAlerts, | |
| 193 | 194 | DismissSecurityAlert, | |
| 194 | 195 | ReopenSecurityAlert, | |
| 196 | + | ListNotifications, | |
| 197 | + | MarkNotificationsRead, | |
| 198 | + | GetNotificationThread, | |
| 199 | + | MarkThreadRead, | |
| 200 | + | MarkThreadDone, | |
| 201 | + | SaveThread, | |
| 202 | + | SnoozeThread, | |
| 203 | + | GetThreadSubscription, | |
| 204 | + | SetThreadSubscription, | |
| 205 | + | DeleteThreadSubscription, | |
| 206 | + | GetRepoSubscription, | |
| 207 | + | SetRepoSubscription, | |
| 208 | + | DeleteRepoSubscription, | |
| 209 | + | ListWatchedRepos, | |
| 195 | 210 | } | |
| 196 | 211 | ||
| 197 | 212 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 279 | 294 | } | |
| 280 | 295 | ||
| 281 | 296 | /// The repository named by `repo`, written `owner/name`. | |
| 282 | − | fn repo_path(input: &Value) -> Option<RepoPath> { | |
| 297 | + | pub(crate) fn repo_path(input: &Value) -> Option<RepoPath> { | |
| 283 | 298 | let mut parts = input["repo"].as_str()?.split('/'); | |
| 284 | 299 | match (parts.next(), parts.next(), parts.next()) { | |
| 285 | 300 | (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => { | |
| 413 | 428 | }) | |
| 414 | 429 | } | |
| 415 | 430 | ||
| 431 | + | fn thread_id_schema() -> Value { | |
| 432 | + | json!({ "type": "string", "description": "The thread's id, from list_notifications." }) | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | /// The inputs that name an issue or pull request to subscribe to: a | |
| 436 | + | /// thread's id, or a repository and number; with `more` added. | |
| 437 | + | fn subscription_target(more: Value) -> Value { | |
| 438 | + | let mut properties = json!({ | |
| 439 | + | "id": { "type": "string", "description": "A thread's id, from list_notifications. Or give repo and number." }, | |
| 440 | + | "repo": { "type": "string", "description": "Instead of id: the repository, as \"owner/name\"." }, | |
| 441 | + | "number": { "type": "integer", "description": "With repo: the issue or pull request's number." }, | |
| 442 | + | }); | |
| 443 | + | if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) { | |
| 444 | + | all.extend(more); | |
| 445 | + | } | |
| 446 | + | properties | |
| 447 | + | } | |
| 448 | + | ||
| 416 | 449 | fn alert_id_schema() -> Value { | |
| 417 | 450 | json!({ | |
| 418 | 451 | "type": "string", | |
| 421 | 454 | } | |
| 422 | 455 | ||
| 423 | 456 | impl Op { | |
| 424 | − | pub const ALL: [Op; 117] = [ | |
| 457 | + | pub const ALL: [Op; 131] = [ | |
| 425 | 458 | Op::Whoami, | |
| 426 | 459 | Op::CreateWorkspace, | |
| 427 | 460 | Op::DeleteWorkspace, | |
| 539 | 572 | Op::ListSecurityAlerts, | |
| 540 | 573 | Op::DismissSecurityAlert, | |
| 541 | 574 | Op::ReopenSecurityAlert, | |
| 575 | + | Op::ListNotifications, | |
| 576 | + | Op::MarkNotificationsRead, | |
| 577 | + | Op::GetNotificationThread, | |
| 578 | + | Op::MarkThreadRead, | |
| 579 | + | Op::MarkThreadDone, | |
| 580 | + | Op::SaveThread, | |
| 581 | + | Op::SnoozeThread, | |
| 582 | + | Op::GetThreadSubscription, | |
| 583 | + | Op::SetThreadSubscription, | |
| 584 | + | Op::DeleteThreadSubscription, | |
| 585 | + | Op::GetRepoSubscription, | |
| 586 | + | Op::SetRepoSubscription, | |
| 587 | + | Op::DeleteRepoSubscription, | |
| 588 | + | Op::ListWatchedRepos, | |
| 542 | 589 | ]; | |
| 543 | 590 | ||
| 544 | 591 | pub fn by_name(name: &str) -> Option<Op> { | |
| 665 | 712 | Op::ListSecurityAlerts => "list_security_alerts", | |
| 666 | 713 | Op::DismissSecurityAlert => "dismiss_security_alert", | |
| 667 | 714 | Op::ReopenSecurityAlert => "reopen_security_alert", | |
| 715 | + | Op::ListNotifications => "list_notifications", | |
| 716 | + | Op::MarkNotificationsRead => "mark_notifications_read", | |
| 717 | + | Op::GetNotificationThread => "get_notification_thread", | |
| 718 | + | Op::MarkThreadRead => "mark_thread_read", | |
| 719 | + | Op::MarkThreadDone => "mark_thread_done", | |
| 720 | + | Op::SaveThread => "save_thread", | |
| 721 | + | Op::SnoozeThread => "snooze_thread", | |
| 722 | + | Op::GetThreadSubscription => "get_thread_subscription", | |
| 723 | + | Op::SetThreadSubscription => "set_thread_subscription", | |
| 724 | + | Op::DeleteThreadSubscription => "delete_thread_subscription", | |
| 725 | + | Op::GetRepoSubscription => "get_repo_subscription", | |
| 726 | + | Op::SetRepoSubscription => "set_repo_subscription", | |
| 727 | + | Op::DeleteRepoSubscription => "delete_repo_subscription", | |
| 728 | + | Op::ListWatchedRepos => "list_watched_repos", | |
| 668 | 729 | } | |
| 669 | 730 | } | |
| 670 | 731 | ||
| 987 | 1048 | Op::ReopenSecurityAlert => { | |
| 988 | 1049 | "Open a dismissed alert again. A reopened secret stops pushes that carry it again. The same roles as dismissing: Admin for a secret, Write for a dependency. Returns the alert as it is now." | |
| 989 | 1050 | } | |
| 1051 | + | Op::ListNotifications => { | |
| 1052 | + | "Your notifications: one thread for each thing you were told about (an issue, a pull request, a workflow on a branch, a deployment), latest activity first. As in your inbox, only unread threads unless `all` is true; `view` `saved` or `done` lists those instead, read or not. Each thread has a `reason`, why you were told (`agent`, `review_requested`, `assign`, `mention`, `ci_activity`, `security_alert`, `state_change`, `author`, `comment`, `manual` or `subscribed`), a `severity`, the latest activity's `title`, and `count`, how many things have happened on it. Filter by `reason` or `severity`, by `participating` (leaving out what you only watch or subscribed to by hand), by `since` and `before` (RFC 3339, the latest activity), or to one repository. A page holds `per_page` threads, 30 unless you say (at most 100); pass `next` back as `cursor` for the next. Threads about repositories you can no longer read are left out. Your own: a personal access token or a session, never a workspace's." | |
| 1053 | + | } | |
| 1054 | + | Op::MarkNotificationsRead => { | |
| 1055 | + | "Mark every thread in your inbox read, or every thread about one repository. Threads whose latest activity came after `last_read_at` (now, when left out) stay unread, so nothing that arrived while you looked is lost. With `read` false they are marked unread instead. Returns how many changed." | |
| 1056 | + | } | |
| 1057 | + | Op::GetNotificationThread => { | |
| 1058 | + | "One of your threads: what it is about, its latest activity, its last 10 things that happened (`activity`, newest first), and for an issue or pull request your `subscription` to it." | |
| 1059 | + | } | |
| 1060 | + | Op::MarkThreadRead => { | |
| 1061 | + | "Mark one thread read, or with `read` false, unread. Returns the thread." | |
| 1062 | + | } | |
| 1063 | + | Op::MarkThreadDone => { | |
| 1064 | + | "Mark one thread done: it leaves your inbox for Done, read. New activity on it brings it back. With `done` false it moves back now. Done threads are removed after 30 days unless saved. Returns the thread." | |
| 1065 | + | } | |
| 1066 | + | Op::SaveThread => { | |
| 1067 | + | "Save one thread, which keeps it under Saved, and kept, even once it is done. With `saved` false it is unsaved. Returns the thread." | |
| 1068 | + | } | |
| 1069 | + | Op::SnoozeThread => { | |
| 1070 | + | "Snooze one thread out of your inbox until `until` (RFC 3339, a time to come); it is marked read and comes back at that time. Leave `until` out to bring it back now. Returns the thread." | |
| 1071 | + | } | |
| 1072 | + | Op::GetThreadSubscription => { | |
| 1073 | + | "Your subscription to an issue or pull request, named by a thread's `id`, or by `repo` and `number`. `subscribed` says whether you hear of what happens on it, `ignored` whether you hear of nothing at all, and `reason` why you are subscribed: you opened it or asked g1t for it (`author`), are assigned (`assign`), were asked to review (`review_requested`), commented (`comment`), were mentioned (`mention`), or subscribed by hand (`manual`)." | |
| 1074 | + | } | |
| 1075 | + | Op::SetThreadSubscription => { | |
| 1076 | + | "Subscribe to an issue or pull request (`subscribed`, true unless you say), unsubscribe (`subscribed` false), or ignore it (`ignored` true): hear of nothing on it, not even a mention. Unsubscribed, you still hear of what is asked of you (a review, an assignment, a mention, an agent waiting on you), and commenting or being mentioned subscribes you again. Name it by a thread's `id`, or by `repo` and `number`. Returns your subscription." | |
| 1077 | + | } | |
| 1078 | + | Op::DeleteThreadSubscription => { | |
| 1079 | + | "Unsubscribe from an issue or pull request until you comment on it or are mentioned. What is asked of you directly (a review, an assignment, a mention, an agent waiting on you) still reaches you. Name it by a thread's `id`, or by `repo` and `number`. Returns your subscription." | |
| 1080 | + | } | |
| 1081 | + | Op::GetRepoSubscription => { | |
| 1082 | + | "How you watch a repository. `level` is `participating` (the default: only what you take part in or are mentioned in), `all` (every issue and pull request opened, commented on, closed or merged, and every deployment), `ignore` (nothing, not even a mention) or `custom` (what you take part in, and the kinds in `events`: `issues`, `pulls`, `deployments`, `security`). `subscribed` is true for `all` and `custom`, and `ignored` for `ignore`." | |
| 1083 | + | } | |
| 1084 | + | Op::SetRepoSubscription => { | |
| 1085 | + | "Watch a repository you can read: give `level`, with `events` for `custom`; or, as booleans, `subscribed` (all its activity, or with false, only what you take part in) and `ignored` (nothing at all). Returns how you watch it now." | |
| 1086 | + | } | |
| 1087 | + | Op::DeleteRepoSubscription => { | |
| 1088 | + | "Stop watching a repository: back to the default, hearing only of what you take part in or are mentioned in. Returns how you watch it now." | |
| 1089 | + | } | |
| 1090 | + | Op::ListWatchedRepos => { | |
| 1091 | + | "The repositories you watch other than the default way: all activity, custom or ignored, each with its `level` and `events`." | |
| 1092 | + | } | |
| 990 | 1093 | } | |
| 991 | 1094 | } | |
| 992 | 1095 | ||
| 1876 | 1979 | &["repo", "id", "reason"], | |
| 1877 | 1980 | ), | |
| 1878 | 1981 | Op::ReopenSecurityAlert => object(json!({ "repo": repo_schema(), "id": alert_id_schema() }), &["repo", "id"]), | |
| 1982 | + | Op::ListNotifications => object( | |
| 1983 | + | json!({ | |
| 1984 | + | "repo": { | |
| 1985 | + | "type": "string", | |
| 1986 | + | "description": "Only threads about this repository, as \"owner/name\".", | |
| 1987 | + | }, | |
| 1988 | + | "all": { | |
| 1989 | + | "type": "boolean", | |
| 1990 | + | "description": "Read threads too. Left out: only unread ones, in the inbox view.", | |
| 1991 | + | }, | |
| 1992 | + | "participating": { | |
| 1993 | + | "type": "boolean", | |
| 1994 | + | "description": "Only threads you take part in: not those you only watch or subscribed to by hand.", | |
| 1995 | + | }, | |
| 1996 | + | "view": { | |
| 1997 | + | "type": "string", | |
| 1998 | + | "enum": ["inbox", "saved", "done"], | |
| 1999 | + | "description": "inbox (the default): not done and not snoozed. saved: what you saved. done: what you marked done.", | |
| 2000 | + | }, | |
| 2001 | + | "reason": { | |
| 2002 | + | "type": "string", | |
| 2003 | + | "enum": Reason::ALL.map(Reason::as_str), | |
| 2004 | + | "description": "Only threads you were told of for this reason.", | |
| 2005 | + | }, | |
| 2006 | + | "severity": { | |
| 2007 | + | "type": "string", | |
| 2008 | + | "enum": Severity::ALL.map(Severity::as_str), | |
| 2009 | + | "description": "Only threads of this severity. warning is what is waiting on you: an agent, or a review.", | |
| 2010 | + | }, | |
| 2011 | + | "since": { "type": "string", "description": "RFC 3339: only threads with activity at or after this time." }, | |
| 2012 | + | "before": { "type": "string", "description": "RFC 3339: only threads whose latest activity was before this time." }, | |
| 2013 | + | "cursor": { "type": "string", "description": "The next page: the `next` of the page before." }, | |
| 2014 | + | "per_page": { "type": "integer", "description": "Threads a page: 30 unless you say, at most 100." }, | |
| 2015 | + | }), | |
| 2016 | + | &[], | |
| 2017 | + | ), | |
| 2018 | + | Op::MarkNotificationsRead => object( | |
| 2019 | + | json!({ | |
| 2020 | + | "repo": { | |
| 2021 | + | "type": "string", | |
| 2022 | + | "description": "Only threads about this repository, as \"owner/name\".", | |
| 2023 | + | }, | |
| 2024 | + | "last_read_at": { | |
| 2025 | + | "type": "string", | |
| 2026 | + | "description": "RFC 3339: threads with activity after this stay unread. Now, when left out.", | |
| 2027 | + | }, | |
| 2028 | + | "read": { "type": "boolean", "description": "False marks them unread instead." }, | |
| 2029 | + | }), | |
| 2030 | + | &[], | |
| 2031 | + | ), | |
| 2032 | + | Op::GetNotificationThread => object(json!({ "id": thread_id_schema() }), &["id"]), | |
| 2033 | + | Op::MarkThreadRead => object( | |
| 2034 | + | json!({ "id": thread_id_schema(), "read": { "type": "boolean", "description": "False marks it unread." } }), | |
| 2035 | + | &["id"], | |
| 2036 | + | ), | |
| 2037 | + | Op::MarkThreadDone => object( | |
| 2038 | + | json!({ "id": thread_id_schema(), "done": { "type": "boolean", "description": "False moves it back to the inbox." } }), | |
| 2039 | + | &["id"], | |
| 2040 | + | ), | |
| 2041 | + | Op::SaveThread => object( | |
| 2042 | + | json!({ "id": thread_id_schema(), "saved": { "type": "boolean", "description": "False unsaves it." } }), | |
| 2043 | + | &["id"], | |
| 2044 | + | ), | |
| 2045 | + | Op::SnoozeThread => object( | |
| 2046 | + | json!({ | |
| 2047 | + | "id": thread_id_schema(), | |
| 2048 | + | "until": { | |
| 2049 | + | "type": "string", | |
| 2050 | + | "description": "RFC 3339, a time to come. Left out: back in the inbox now.", | |
| 2051 | + | }, | |
| 2052 | + | }), | |
| 2053 | + | &["id"], | |
| 2054 | + | ), | |
| 2055 | + | Op::GetThreadSubscription | Op::DeleteThreadSubscription => object(subscription_target(json!({})), &[]), | |
| 2056 | + | Op::SetThreadSubscription => object( | |
| 2057 | + | subscription_target(json!({ | |
| 2058 | + | "subscribed": { "type": "boolean", "description": "True (the default) to subscribe, false to unsubscribe." }, | |
| 2059 | + | "ignored": { "type": "boolean", "description": "True to hear of nothing on it, not even a mention." }, | |
| 2060 | + | })), | |
| 2061 | + | &[], | |
| 2062 | + | ), | |
| 2063 | + | Op::GetRepoSubscription | Op::DeleteRepoSubscription => repo_only(), | |
| 2064 | + | Op::SetRepoSubscription => object( | |
| 2065 | + | json!({ | |
| 2066 | + | "repo": repo_schema(), | |
| 2067 | + | "level": { | |
| 2068 | + | "type": "string", | |
| 2069 | + | "enum": WatchLevel::ALL.map(WatchLevel::as_str), | |
| 2070 | + | "description": "participating: only what you take part in. all: all its activity. ignore: nothing. custom: what you take part in, and events.", | |
| 2071 | + | }, | |
| 2072 | + | "events": { | |
| 2073 | + | "type": "array", | |
| 2074 | + | "items": { "type": "string", "enum": WATCH_EVENTS }, | |
| 2075 | + | "description": "With custom: the kinds of activity to hear of.", | |
| 2076 | + | }, | |
| 2077 | + | "subscribed": { "type": "boolean", "description": "Instead of level: true for all its activity, false for only what you take part in." }, | |
| 2078 | + | "ignored": { "type": "boolean", "description": "Instead of level: true to hear of nothing on it." }, | |
| 2079 | + | }), | |
| 2080 | + | &["repo"], | |
| 2081 | + | ), | |
| 2082 | + | Op::ListWatchedRepos => object(json!({}), &[]), | |
| 1879 | 2083 | } | |
| 1880 | 2084 | } | |
| 1881 | 2085 | ||
| 1962 | 2166 | | Op::DeclineRepoInvitation | |
| 1963 | 2167 | | Op::SetBasePermission | |
| 1964 | 2168 | | Op::ListOutsideCollaborators | |
| 2169 | + | | Op::ListNotifications | |
| 2170 | + | | Op::MarkNotificationsRead | |
| 2171 | + | | Op::GetNotificationThread | |
| 2172 | + | | Op::MarkThreadRead | |
| 2173 | + | | Op::MarkThreadDone | |
| 2174 | + | | Op::SaveThread | |
| 2175 | + | | Op::SnoozeThread | |
| 2176 | + | | Op::GetThreadSubscription | |
| 2177 | + | | Op::SetThreadSubscription | |
| 2178 | + | | Op::DeleteThreadSubscription | |
| 2179 | + | | Op::ListWatchedRepos | |
| 2180 | + | ) | |
| 2181 | + | } | |
| 2182 | + | ||
| 2183 | + | /// Whether the operation is about the caller's own inbox: notifications, | |
| 2184 | + | /// subscriptions and watching. Nobody else's business, so not audited. | |
| 2185 | + | pub(crate) fn personal(self) -> bool { | |
| 2186 | + | matches!( | |
| 2187 | + | self, | |
| 2188 | + | Op::ListNotifications | |
| 2189 | + | | Op::MarkNotificationsRead | |
| 2190 | + | | Op::GetNotificationThread | |
| 2191 | + | | Op::MarkThreadRead | |
| 2192 | + | | Op::MarkThreadDone | |
| 2193 | + | | Op::SaveThread | |
| 2194 | + | | Op::SnoozeThread | |
| 2195 | + | | Op::GetThreadSubscription | |
| 2196 | + | | Op::SetThreadSubscription | |
| 2197 | + | | Op::DeleteThreadSubscription | |
| 2198 | + | | Op::GetRepoSubscription | |
| 2199 | + | | Op::SetRepoSubscription | |
| 2200 | + | | Op::DeleteRepoSubscription | |
| 2201 | + | | Op::ListWatchedRepos | |
| 1965 | 2202 | ) | |
| 1966 | 2203 | } | |
| 1967 | 2204 | ||
| 3365 | 3602 | .await?; | |
| 3366 | 3603 | changed_alert(changed) | |
| 3367 | 3604 | } | |
| 3605 | + | // A person's own inbox: the events service keeps it. | |
| 3606 | + | Op::ListNotifications | |
| 3607 | + | | Op::MarkNotificationsRead | |
| 3608 | + | | Op::GetNotificationThread | |
| 3609 | + | | Op::MarkThreadRead | |
| 3610 | + | | Op::MarkThreadDone | |
| 3611 | + | | Op::SaveThread | |
| 3612 | + | | Op::SnoozeThread | |
| 3613 | + | | Op::GetThreadSubscription | |
| 3614 | + | | Op::SetThreadSubscription | |
| 3615 | + | | Op::DeleteThreadSubscription | |
| 3616 | + | | Op::GetRepoSubscription | |
| 3617 | + | | Op::SetRepoSubscription | |
| 3618 | + | | Op::DeleteRepoSubscription | |
| 3619 | + | | Op::ListWatchedRepos => crate::notifications::run(self, services, viewer, input).await, | |
| 3368 | 3620 | Op::ReopenSecurityAlert => { | |
| 3369 | 3621 | let changed: Outcome<AlertChange> = call( | |
| 3370 | 3622 | &services.security, |
| 4234 | 4234 | "dismissed_at": null | |
| 4235 | 4235 | }, | |
| 4236 | 4236 | "notes": "The alert is `open` again, and a reopened secret stops pushes that carry it. The same roles as dismissing: Admin for a secret, Write for a dependency." | |
| 4237 | + | }, | |
| 4238 | + | "list_notifications": { | |
| 4239 | + | "query": { | |
| 4240 | + | "participating": "true" | |
| 4241 | + | }, | |
| 4242 | + | "response": { | |
| 4243 | + | "items": [ | |
| 4244 | + | { | |
| 4245 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4246 | + | "reason": "review_requested", | |
| 4247 | + | "severity": "warning", | |
| 4248 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4249 | + | "body": "Add a greeting to the README", | |
| 4250 | + | "event": "pull.review_requested", | |
| 4251 | + | "repo": "flagon-io/hello", | |
| 4252 | + | "workspace": "flagon-io", | |
| 4253 | + | "subject": "pull", | |
| 4254 | + | "number": 14, | |
| 4255 | + | "url": "/flagon-io/hello/pull/14", | |
| 4256 | + | "actor": "ada", | |
| 4257 | + | "count": 3, | |
| 4258 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4259 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4260 | + | "read_at": null, | |
| 4261 | + | "done_at": null, | |
| 4262 | + | "saved": false, | |
| 4263 | + | "snoozed_until": null | |
| 4264 | + | }, | |
| 4265 | + | { | |
| 4266 | + | "id": "ntf_01kp7k9a8b7c6d5e4f3g2h1j0k", | |
| 4267 | + | "reason": "ci_activity", | |
| 4268 | + | "severity": "error", | |
| 4269 | + | "title": "Production of hello failed to deploy", | |
| 4270 | + | "body": "The build failed: npm run build exited with 1.", | |
| 4271 | + | "event": "deployment.failed", | |
| 4272 | + | "repo": "flagon-io/hello", | |
| 4273 | + | "workspace": "flagon-io", | |
| 4274 | + | "subject": "deploy", | |
| 4275 | + | "number": null, | |
| 4276 | + | "url": "/flagon-io/hello/deployments/dpl_01kp7k8z7y6x5w4v3t2s1r0q9p", | |
| 4277 | + | "actor": "syntaqx", | |
| 4278 | + | "count": 1, | |
| 4279 | + | "created_at": "2026-10-07T08:12:40.000Z", | |
| 4280 | + | "updated_at": "2026-10-07T08:12:40.000Z", | |
| 4281 | + | "read_at": null, | |
| 4282 | + | "done_at": null, | |
| 4283 | + | "saved": false, | |
| 4284 | + | "snoozed_until": null | |
| 4285 | + | } | |
| 4286 | + | ], | |
| 4287 | + | "next": null | |
| 4288 | + | }, | |
| 4289 | + | "notes": "Unread threads only, unless `all=true`. Threads that wait on you (an agent, or a review asked of you) have severity `warning`.\n\n| `reason` | You were told because |\n| --- | --- |\n| `agent` | An agent is waiting on you: it asked a question, or g1t stopped until a person steps in |\n| `review_requested` | You were asked to review |\n| `assign` | You were assigned |\n| `mention` | Someone mentioned you by `@username` |\n| `ci_activity` | Checks, a workflow or a deployment on your work finished |\n| `security_alert` | A security alert on a repository you look after |\n| `state_change` | It was closed, reopened or merged |\n| `author` | You opened it, or asked g1t for it |\n| `comment` | You commented on it |\n| `manual` | You subscribed to it by hand |\n| `subscribed` | You watch its repository |\n\nWith `participating=true`, threads you only follow (`manual`, `subscribed`) are left out. For the next page, pass `next` as `cursor`; `next` is null on the last." | |
| 4290 | + | }, | |
| 4291 | + | "list_repo_notifications": { | |
| 4292 | + | "params": { | |
| 4293 | + | "owner": "flagon-io", | |
| 4294 | + | "name": "hello" | |
| 4295 | + | }, | |
| 4296 | + | "query": { | |
| 4297 | + | "all": "true" | |
| 4298 | + | }, | |
| 4299 | + | "response": { | |
| 4300 | + | "items": [ | |
| 4301 | + | { | |
| 4302 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4303 | + | "reason": "review_requested", | |
| 4304 | + | "severity": "warning", | |
| 4305 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4306 | + | "body": "Add a greeting to the README", | |
| 4307 | + | "event": "pull.review_requested", | |
| 4308 | + | "repo": "flagon-io/hello", | |
| 4309 | + | "workspace": "flagon-io", | |
| 4310 | + | "subject": "pull", | |
| 4311 | + | "number": 14, | |
| 4312 | + | "url": "/flagon-io/hello/pull/14", | |
| 4313 | + | "actor": "ada", | |
| 4314 | + | "count": 3, | |
| 4315 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4316 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4317 | + | "read_at": "2026-10-07T09:50:00.000Z", | |
| 4318 | + | "done_at": null, | |
| 4319 | + | "saved": false, | |
| 4320 | + | "snoozed_until": null | |
| 4321 | + | }, | |
| 4322 | + | { | |
| 4323 | + | "id": "ntf_01kp7k9a8b7c6d5e4f3g2h1j0k", | |
| 4324 | + | "reason": "ci_activity", | |
| 4325 | + | "severity": "error", | |
| 4326 | + | "title": "Production of hello failed to deploy", | |
| 4327 | + | "body": "The build failed: npm run build exited with 1.", | |
| 4328 | + | "event": "deployment.failed", | |
| 4329 | + | "repo": "flagon-io/hello", | |
| 4330 | + | "workspace": "flagon-io", | |
| 4331 | + | "subject": "deploy", | |
| 4332 | + | "number": null, | |
| 4333 | + | "url": "/flagon-io/hello/deployments/dpl_01kp7k8z7y6x5w4v3t2s1r0q9p", | |
| 4334 | + | "actor": "syntaqx", | |
| 4335 | + | "count": 1, | |
| 4336 | + | "created_at": "2026-10-07T08:12:40.000Z", | |
| 4337 | + | "updated_at": "2026-10-07T08:12:40.000Z", | |
| 4338 | + | "read_at": null, | |
| 4339 | + | "done_at": null, | |
| 4340 | + | "saved": false, | |
| 4341 | + | "snoozed_until": null | |
| 4342 | + | } | |
| 4343 | + | ], | |
| 4344 | + | "next": "ntf_01kp7k9a8b7c6d5e4f3g2h1j0k" | |
| 4345 | + | }, | |
| 4346 | + | "notes": "The same as `GET /notifications`, for one repository you can read." | |
| 4347 | + | }, | |
| 4348 | + | "mark_notifications_read": { | |
| 4349 | + | "request": { | |
| 4350 | + | "last_read_at": "2026-10-07T10:00:00Z" | |
| 4351 | + | }, | |
| 4352 | + | "response": { | |
| 4353 | + | "marked": 12, | |
| 4354 | + | "last_read_at": "2026-10-07T10:00:00.000Z" | |
| 4355 | + | }, | |
| 4356 | + | "notes": "Threads with activity after `last_read_at` stay unread, so send the time you last listed them." | |
| 4357 | + | }, | |
| 4358 | + | "mark_repo_notifications_read": { | |
| 4359 | + | "params": { | |
| 4360 | + | "owner": "flagon-io", | |
| 4361 | + | "name": "hello" | |
| 4362 | + | }, | |
| 4363 | + | "request": {}, | |
| 4364 | + | "response": { | |
| 4365 | + | "marked": 3, | |
| 4366 | + | "last_read_at": "2026-10-07T10:02:41.512Z" | |
| 4367 | + | } | |
| 4368 | + | }, | |
| 4369 | + | "get_notification_thread": { | |
| 4370 | + | "params": { | |
| 4371 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4372 | + | }, | |
| 4373 | + | "response": { | |
| 4374 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4375 | + | "reason": "review_requested", | |
| 4376 | + | "severity": "warning", | |
| 4377 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4378 | + | "body": "Add a greeting to the README", | |
| 4379 | + | "event": "pull.review_requested", | |
| 4380 | + | "repo": "flagon-io/hello", | |
| 4381 | + | "workspace": "flagon-io", | |
| 4382 | + | "subject": "pull", | |
| 4383 | + | "number": 14, | |
| 4384 | + | "url": "/flagon-io/hello/pull/14", | |
| 4385 | + | "actor": "ada", | |
| 4386 | + | "count": 3, | |
| 4387 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4388 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4389 | + | "read_at": null, | |
| 4390 | + | "done_at": null, | |
| 4391 | + | "saved": false, | |
| 4392 | + | "snoozed_until": null, | |
| 4393 | + | "activity": [ | |
| 4394 | + | { | |
| 4395 | + | "reason": "review_requested", | |
| 4396 | + | "severity": "warning", | |
| 4397 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4398 | + | "body": "Add a greeting to the README", | |
| 4399 | + | "event": "pull.review_requested", | |
| 4400 | + | "actor": "ada", | |
| 4401 | + | "created_at": "2026-10-07T09:41:30.000Z" | |
| 4402 | + | }, | |
| 4403 | + | { | |
| 4404 | + | "reason": "comment", | |
| 4405 | + | "severity": "info", | |
| 4406 | + | "title": "ada commented on flagon-io/hello#14", | |
| 4407 | + | "body": "Pushed the fix for the heading.", | |
| 4408 | + | "event": "comment.created", | |
| 4409 | + | "actor": "ada", | |
| 4410 | + | "created_at": "2026-10-06T18:20:02.000Z" | |
| 4411 | + | }, | |
| 4412 | + | { | |
| 4413 | + | "reason": "comment", | |
| 4414 | + | "severity": "info", | |
| 4415 | + | "title": "g1t commented on flagon-io/hello#14", | |
| 4416 | + | "body": "The heading level is off by one.", | |
| 4417 | + | "event": "comment.created", | |
| 4418 | + | "actor": "g1t", | |
| 4419 | + | "created_at": "2026-10-06T15:02:11.000Z" | |
| 4420 | + | } | |
| 4421 | + | ], | |
| 4422 | + | "subscription": { | |
| 4423 | + | "subscribed": true, | |
| 4424 | + | "ignored": false, | |
| 4425 | + | "reason": "review_requested", | |
| 4426 | + | "repo": "flagon-io/hello", | |
| 4427 | + | "number": 14, | |
| 4428 | + | "updated_at": null | |
| 4429 | + | } | |
| 4430 | + | }, | |
| 4431 | + | "notes": "`activity` keeps the last 10 things that happened on the thread, newest first; `count` is how many there have been. `subscription` is null for a workflow or a deployment, which nobody subscribes to." | |
| 4432 | + | }, | |
| 4433 | + | "mark_thread_read": { | |
| 4434 | + | "params": { | |
| 4435 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4436 | + | }, | |
| 4437 | + | "request": {}, | |
| 4438 | + | "response": { | |
| 4439 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4440 | + | "reason": "review_requested", | |
| 4441 | + | "severity": "warning", | |
| 4442 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4443 | + | "body": "Add a greeting to the README", | |
| 4444 | + | "event": "pull.review_requested", | |
| 4445 | + | "repo": "flagon-io/hello", | |
| 4446 | + | "workspace": "flagon-io", | |
| 4447 | + | "subject": "pull", | |
| 4448 | + | "number": 14, | |
| 4449 | + | "url": "/flagon-io/hello/pull/14", | |
| 4450 | + | "actor": "ada", | |
| 4451 | + | "count": 3, | |
| 4452 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4453 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4454 | + | "read_at": "2026-10-07T10:03:00.000Z", | |
| 4455 | + | "done_at": null, | |
| 4456 | + | "saved": false, | |
| 4457 | + | "snoozed_until": null, | |
| 4458 | + | "activity": [ | |
| 4459 | + | { | |
| 4460 | + | "reason": "review_requested", | |
| 4461 | + | "severity": "warning", | |
| 4462 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4463 | + | "body": "Add a greeting to the README", | |
| 4464 | + | "event": "pull.review_requested", | |
| 4465 | + | "actor": "ada", | |
| 4466 | + | "created_at": "2026-10-07T09:41:30.000Z" | |
| 4467 | + | }, | |
| 4468 | + | { | |
| 4469 | + | "reason": "comment", | |
| 4470 | + | "severity": "info", | |
| 4471 | + | "title": "ada commented on flagon-io/hello#14", | |
| 4472 | + | "body": "Pushed the fix for the heading.", | |
| 4473 | + | "event": "comment.created", | |
| 4474 | + | "actor": "ada", | |
| 4475 | + | "created_at": "2026-10-06T18:20:02.000Z" | |
| 4476 | + | }, | |
| 4477 | + | { | |
| 4478 | + | "reason": "comment", | |
| 4479 | + | "severity": "info", | |
| 4480 | + | "title": "g1t commented on flagon-io/hello#14", | |
| 4481 | + | "body": "The heading level is off by one.", | |
| 4482 | + | "event": "comment.created", | |
| 4483 | + | "actor": "g1t", | |
| 4484 | + | "created_at": "2026-10-06T15:02:11.000Z" | |
| 4485 | + | } | |
| 4486 | + | ], | |
| 4487 | + | "subscription": { | |
| 4488 | + | "subscribed": true, | |
| 4489 | + | "ignored": false, | |
| 4490 | + | "reason": "review_requested", | |
| 4491 | + | "repo": "flagon-io/hello", | |
| 4492 | + | "number": 14, | |
| 4493 | + | "updated_at": null | |
| 4494 | + | } | |
| 4495 | + | } | |
| 4496 | + | }, | |
| 4497 | + | "mark_thread_done": { | |
| 4498 | + | "params": { | |
| 4499 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4500 | + | }, | |
| 4501 | + | "response": { | |
| 4502 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4503 | + | "reason": "review_requested", | |
| 4504 | + | "severity": "warning", | |
| 4505 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4506 | + | "body": "Add a greeting to the README", | |
| 4507 | + | "event": "pull.review_requested", | |
| 4508 | + | "repo": "flagon-io/hello", | |
| 4509 | + | "workspace": "flagon-io", | |
| 4510 | + | "subject": "pull", | |
| 4511 | + | "number": 14, | |
| 4512 | + | "url": "/flagon-io/hello/pull/14", | |
| 4513 | + | "actor": "ada", | |
| 4514 | + | "count": 3, | |
| 4515 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4516 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4517 | + | "read_at": "2026-10-07T10:03:00.000Z", | |
| 4518 | + | "done_at": "2026-10-07T10:03:00.000Z", | |
| 4519 | + | "saved": false, | |
| 4520 | + | "snoozed_until": null, | |
| 4521 | + | "activity": [ | |
| 4522 | + | { | |
| 4523 | + | "reason": "review_requested", | |
| 4524 | + | "severity": "warning", | |
| 4525 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4526 | + | "body": "Add a greeting to the README", | |
| 4527 | + | "event": "pull.review_requested", | |
| 4528 | + | "actor": "ada", | |
| 4529 | + | "created_at": "2026-10-07T09:41:30.000Z" | |
| 4530 | + | }, | |
| 4531 | + | { | |
| 4532 | + | "reason": "comment", | |
| 4533 | + | "severity": "info", | |
| 4534 | + | "title": "ada commented on flagon-io/hello#14", | |
| 4535 | + | "body": "Pushed the fix for the heading.", | |
| 4536 | + | "event": "comment.created", | |
| 4537 | + | "actor": "ada", | |
| 4538 | + | "created_at": "2026-10-06T18:20:02.000Z" | |
| 4539 | + | }, | |
| 4540 | + | { | |
| 4541 | + | "reason": "comment", | |
| 4542 | + | "severity": "info", | |
| 4543 | + | "title": "g1t commented on flagon-io/hello#14", | |
| 4544 | + | "body": "The heading level is off by one.", | |
| 4545 | + | "event": "comment.created", | |
| 4546 | + | "actor": "g1t", | |
| 4547 | + | "created_at": "2026-10-06T15:02:11.000Z" | |
| 4548 | + | } | |
| 4549 | + | ], | |
| 4550 | + | "subscription": { | |
| 4551 | + | "subscribed": true, | |
| 4552 | + | "ignored": false, | |
| 4553 | + | "reason": "review_requested", | |
| 4554 | + | "repo": "flagon-io/hello", | |
| 4555 | + | "number": 14, | |
| 4556 | + | "updated_at": null | |
| 4557 | + | } | |
| 4558 | + | }, | |
| 4559 | + | "notes": "New activity on a done thread brings it back to the inbox, unread." | |
| 4560 | + | }, | |
| 4561 | + | "save_thread": { | |
| 4562 | + | "params": { | |
| 4563 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4564 | + | }, | |
| 4565 | + | "request": {}, | |
| 4566 | + | "response": { | |
| 4567 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4568 | + | "reason": "review_requested", | |
| 4569 | + | "severity": "warning", | |
| 4570 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4571 | + | "body": "Add a greeting to the README", | |
| 4572 | + | "event": "pull.review_requested", | |
| 4573 | + | "repo": "flagon-io/hello", | |
| 4574 | + | "workspace": "flagon-io", | |
| 4575 | + | "subject": "pull", | |
| 4576 | + | "number": 14, | |
| 4577 | + | "url": "/flagon-io/hello/pull/14", | |
| 4578 | + | "actor": "ada", | |
| 4579 | + | "count": 3, | |
| 4580 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4581 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4582 | + | "read_at": null, | |
| 4583 | + | "done_at": null, | |
| 4584 | + | "saved": true, | |
| 4585 | + | "snoozed_until": null, | |
| 4586 | + | "activity": [ | |
| 4587 | + | { | |
| 4588 | + | "reason": "review_requested", | |
| 4589 | + | "severity": "warning", | |
| 4590 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4591 | + | "body": "Add a greeting to the README", | |
| 4592 | + | "event": "pull.review_requested", | |
| 4593 | + | "actor": "ada", | |
| 4594 | + | "created_at": "2026-10-07T09:41:30.000Z" | |
| 4595 | + | }, | |
| 4596 | + | { | |
| 4597 | + | "reason": "comment", | |
| 4598 | + | "severity": "info", | |
| 4599 | + | "title": "ada commented on flagon-io/hello#14", | |
| 4600 | + | "body": "Pushed the fix for the heading.", | |
| 4601 | + | "event": "comment.created", | |
| 4602 | + | "actor": "ada", | |
| 4603 | + | "created_at": "2026-10-06T18:20:02.000Z" | |
| 4604 | + | }, | |
| 4605 | + | { | |
| 4606 | + | "reason": "comment", | |
| 4607 | + | "severity": "info", | |
| 4608 | + | "title": "g1t commented on flagon-io/hello#14", | |
| 4609 | + | "body": "The heading level is off by one.", | |
| 4610 | + | "event": "comment.created", | |
| 4611 | + | "actor": "g1t", | |
| 4612 | + | "created_at": "2026-10-06T15:02:11.000Z" | |
| 4613 | + | } | |
| 4614 | + | ], | |
| 4615 | + | "subscription": { | |
| 4616 | + | "subscribed": true, | |
| 4617 | + | "ignored": false, | |
| 4618 | + | "reason": "review_requested", | |
| 4619 | + | "repo": "flagon-io/hello", | |
| 4620 | + | "number": 14, | |
| 4621 | + | "updated_at": null | |
| 4622 | + | } | |
| 4623 | + | } | |
| 4624 | + | }, | |
| 4625 | + | "unsave_thread": { | |
| 4626 | + | "params": { | |
| 4627 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4628 | + | }, | |
| 4629 | + | "response": { | |
| 4630 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4631 | + | "reason": "review_requested", | |
| 4632 | + | "severity": "warning", | |
| 4633 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4634 | + | "body": "Add a greeting to the README", | |
| 4635 | + | "event": "pull.review_requested", | |
| 4636 | + | "repo": "flagon-io/hello", | |
| 4637 | + | "workspace": "flagon-io", | |
| 4638 | + | "subject": "pull", | |
| 4639 | + | "number": 14, | |
| 4640 | + | "url": "/flagon-io/hello/pull/14", | |
| 4641 | + | "actor": "ada", | |
| 4642 | + | "count": 3, | |
| 4643 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4644 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4645 | + | "read_at": null, | |
| 4646 | + | "done_at": null, | |
| 4647 | + | "saved": false, | |
| 4648 | + | "snoozed_until": null, | |
| 4649 | + | "activity": [ | |
| 4650 | + | { | |
| 4651 | + | "reason": "review_requested", | |
| 4652 | + | "severity": "warning", | |
| 4653 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4654 | + | "body": "Add a greeting to the README", | |
| 4655 | + | "event": "pull.review_requested", | |
| 4656 | + | "actor": "ada", | |
| 4657 | + | "created_at": "2026-10-07T09:41:30.000Z" | |
| 4658 | + | }, | |
| 4659 | + | { | |
| 4660 | + | "reason": "comment", | |
| 4661 | + | "severity": "info", | |
| 4662 | + | "title": "ada commented on flagon-io/hello#14", | |
| 4663 | + | "body": "Pushed the fix for the heading.", | |
| 4664 | + | "event": "comment.created", | |
| 4665 | + | "actor": "ada", | |
| 4666 | + | "created_at": "2026-10-06T18:20:02.000Z" | |
| 4667 | + | }, | |
| 4668 | + | { | |
| 4669 | + | "reason": "comment", | |
| 4670 | + | "severity": "info", | |
| 4671 | + | "title": "g1t commented on flagon-io/hello#14", | |
| 4672 | + | "body": "The heading level is off by one.", | |
| 4673 | + | "event": "comment.created", | |
| 4674 | + | "actor": "g1t", | |
| 4675 | + | "created_at": "2026-10-06T15:02:11.000Z" | |
| 4676 | + | } | |
| 4677 | + | ], | |
| 4678 | + | "subscription": { | |
| 4679 | + | "subscribed": true, | |
| 4680 | + | "ignored": false, | |
| 4681 | + | "reason": "review_requested", | |
| 4682 | + | "repo": "flagon-io/hello", | |
| 4683 | + | "number": 14, | |
| 4684 | + | "updated_at": null | |
| 4685 | + | } | |
| 4686 | + | } | |
| 4687 | + | }, | |
| 4688 | + | "snooze_thread": { | |
| 4689 | + | "params": { | |
| 4690 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4691 | + | }, | |
| 4692 | + | "request": { | |
| 4693 | + | "until": "2026-10-08T09:00:00Z" | |
| 4694 | + | }, | |
| 4695 | + | "response": { | |
| 4696 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4697 | + | "reason": "review_requested", | |
| 4698 | + | "severity": "warning", | |
| 4699 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4700 | + | "body": "Add a greeting to the README", | |
| 4701 | + | "event": "pull.review_requested", | |
| 4702 | + | "repo": "flagon-io/hello", | |
| 4703 | + | "workspace": "flagon-io", | |
| 4704 | + | "subject": "pull", | |
| 4705 | + | "number": 14, | |
| 4706 | + | "url": "/flagon-io/hello/pull/14", | |
| 4707 | + | "actor": "ada", | |
| 4708 | + | "count": 3, | |
| 4709 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4710 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4711 | + | "read_at": "2026-10-07T10:03:00.000Z", | |
| 4712 | + | "done_at": null, | |
| 4713 | + | "saved": false, | |
| 4714 | + | "snoozed_until": "2026-10-08T09:00:00.000Z", | |
| 4715 | + | "activity": [ | |
| 4716 | + | { | |
| 4717 | + | "reason": "review_requested", | |
| 4718 | + | "severity": "warning", | |
| 4719 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4720 | + | "body": "Add a greeting to the README", | |
| 4721 | + | "event": "pull.review_requested", | |
| 4722 | + | "actor": "ada", | |
| 4723 | + | "created_at": "2026-10-07T09:41:30.000Z" | |
| 4724 | + | }, | |
| 4725 | + | { | |
| 4726 | + | "reason": "comment", | |
| 4727 | + | "severity": "info", | |
| 4728 | + | "title": "ada commented on flagon-io/hello#14", | |
| 4729 | + | "body": "Pushed the fix for the heading.", | |
| 4730 | + | "event": "comment.created", | |
| 4731 | + | "actor": "ada", | |
| 4732 | + | "created_at": "2026-10-06T18:20:02.000Z" | |
| 4733 | + | }, | |
| 4734 | + | { | |
| 4735 | + | "reason": "comment", | |
| 4736 | + | "severity": "info", | |
| 4737 | + | "title": "g1t commented on flagon-io/hello#14", | |
| 4738 | + | "body": "The heading level is off by one.", | |
| 4739 | + | "event": "comment.created", | |
| 4740 | + | "actor": "g1t", | |
| 4741 | + | "created_at": "2026-10-06T15:02:11.000Z" | |
| 4742 | + | } | |
| 4743 | + | ], | |
| 4744 | + | "subscription": { | |
| 4745 | + | "subscribed": true, | |
| 4746 | + | "ignored": false, | |
| 4747 | + | "reason": "review_requested", | |
| 4748 | + | "repo": "flagon-io/hello", | |
| 4749 | + | "number": 14, | |
| 4750 | + | "updated_at": null | |
| 4751 | + | } | |
| 4752 | + | } | |
| 4753 | + | }, | |
| 4754 | + | "unsnooze_thread": { | |
| 4755 | + | "params": { | |
| 4756 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4757 | + | }, | |
| 4758 | + | "response": { | |
| 4759 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a", | |
| 4760 | + | "reason": "review_requested", | |
| 4761 | + | "severity": "warning", | |
| 4762 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4763 | + | "body": "Add a greeting to the README", | |
| 4764 | + | "event": "pull.review_requested", | |
| 4765 | + | "repo": "flagon-io/hello", | |
| 4766 | + | "workspace": "flagon-io", | |
| 4767 | + | "subject": "pull", | |
| 4768 | + | "number": 14, | |
| 4769 | + | "url": "/flagon-io/hello/pull/14", | |
| 4770 | + | "actor": "ada", | |
| 4771 | + | "count": 3, | |
| 4772 | + | "created_at": "2026-10-06T15:02:11.000Z", | |
| 4773 | + | "updated_at": "2026-10-07T09:41:30.000Z", | |
| 4774 | + | "read_at": "2026-10-07T10:03:00.000Z", | |
| 4775 | + | "done_at": null, | |
| 4776 | + | "saved": false, | |
| 4777 | + | "snoozed_until": null, | |
| 4778 | + | "activity": [ | |
| 4779 | + | { | |
| 4780 | + | "reason": "review_requested", | |
| 4781 | + | "severity": "warning", | |
| 4782 | + | "title": "ada asked you to review flagon-io/hello#14", | |
| 4783 | + | "body": "Add a greeting to the README", | |
| 4784 | + | "event": "pull.review_requested", | |
| 4785 | + | "actor": "ada", | |
| 4786 | + | "created_at": "2026-10-07T09:41:30.000Z" | |
| 4787 | + | }, | |
| 4788 | + | { | |
| 4789 | + | "reason": "comment", | |
| 4790 | + | "severity": "info", | |
| 4791 | + | "title": "ada commented on flagon-io/hello#14", | |
| 4792 | + | "body": "Pushed the fix for the heading.", | |
| 4793 | + | "event": "comment.created", | |
| 4794 | + | "actor": "ada", | |
| 4795 | + | "created_at": "2026-10-06T18:20:02.000Z" | |
| 4796 | + | }, | |
| 4797 | + | { | |
| 4798 | + | "reason": "comment", | |
| 4799 | + | "severity": "info", | |
| 4800 | + | "title": "g1t commented on flagon-io/hello#14", | |
| 4801 | + | "body": "The heading level is off by one.", | |
| 4802 | + | "event": "comment.created", | |
| 4803 | + | "actor": "g1t", | |
| 4804 | + | "created_at": "2026-10-06T15:02:11.000Z" | |
| 4805 | + | } | |
| 4806 | + | ], | |
| 4807 | + | "subscription": { | |
| 4808 | + | "subscribed": true, | |
| 4809 | + | "ignored": false, | |
| 4810 | + | "reason": "review_requested", | |
| 4811 | + | "repo": "flagon-io/hello", | |
| 4812 | + | "number": 14, | |
| 4813 | + | "updated_at": null | |
| 4814 | + | } | |
| 4815 | + | } | |
| 4816 | + | }, | |
| 4817 | + | "get_thread_subscription": { | |
| 4818 | + | "params": { | |
| 4819 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4820 | + | }, | |
| 4821 | + | "response": { | |
| 4822 | + | "subscribed": true, | |
| 4823 | + | "ignored": false, | |
| 4824 | + | "reason": "review_requested", | |
| 4825 | + | "repo": "flagon-io/hello", | |
| 4826 | + | "number": 14, | |
| 4827 | + | "updated_at": null | |
| 4828 | + | } | |
| 4829 | + | }, | |
| 4830 | + | "set_thread_subscription": { | |
| 4831 | + | "params": { | |
| 4832 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4833 | + | }, | |
| 4834 | + | "request": { | |
| 4835 | + | "ignored": true | |
| 4836 | + | }, | |
| 4837 | + | "response": { | |
| 4838 | + | "subscribed": false, | |
| 4839 | + | "ignored": true, | |
| 4840 | + | "reason": null, | |
| 4841 | + | "repo": "flagon-io/hello", | |
| 4842 | + | "number": 14, | |
| 4843 | + | "updated_at": "2026-10-07T10:04:00.000Z" | |
| 4844 | + | }, | |
| 4845 | + | "notes": "| Body | Then |\n| --- | --- |\n| `{}` or `{\"subscribed\": true}` | You hear of what happens on it |\n| `{\"subscribed\": false}` | You hear only of what is asked of you |\n| `{\"ignored\": true}` | You hear of nothing on it, not even a mention |" | |
| 4846 | + | }, | |
| 4847 | + | "delete_thread_subscription": { | |
| 4848 | + | "params": { | |
| 4849 | + | "id": "ntf_01kp7m2q3r4s5t6v7w8x9y0z1a" | |
| 4850 | + | }, | |
| 4851 | + | "response": { | |
| 4852 | + | "subscribed": false, | |
| 4853 | + | "ignored": false, | |
| 4854 | + | "reason": null, | |
| 4855 | + | "repo": "flagon-io/hello", | |
| 4856 | + | "number": 14, | |
| 4857 | + | "updated_at": "2026-10-07T10:04:00.000Z" | |
| 4858 | + | } | |
| 4859 | + | }, | |
| 4860 | + | "get_issue_subscription": { | |
| 4861 | + | "params": { | |
| 4862 | + | "owner": "flagon-io", | |
| 4863 | + | "name": "hello", | |
| 4864 | + | "number": 14 | |
| 4865 | + | }, | |
| 4866 | + | "response": { | |
| 4867 | + | "subscribed": true, | |
| 4868 | + | "ignored": false, | |
| 4869 | + | "reason": "author", | |
| 4870 | + | "repo": null, | |
| 4871 | + | "number": 14, | |
| 4872 | + | "updated_at": null | |
| 4873 | + | } | |
| 4874 | + | }, | |
| 4875 | + | "set_issue_subscription": { | |
| 4876 | + | "params": { | |
| 4877 | + | "owner": "flagon-io", | |
| 4878 | + | "name": "hello", | |
| 4879 | + | "number": 14 | |
| 4880 | + | }, | |
| 4881 | + | "request": { | |
| 4882 | + | "subscribed": true | |
| 4883 | + | }, | |
| 4884 | + | "response": { | |
| 4885 | + | "subscribed": true, | |
| 4886 | + | "ignored": false, | |
| 4887 | + | "reason": "manual", | |
| 4888 | + | "repo": null, | |
| 4889 | + | "number": 14, | |
| 4890 | + | "updated_at": "2026-10-07T10:05:00.000Z" | |
| 4891 | + | } | |
| 4892 | + | }, | |
| 4893 | + | "delete_issue_subscription": { | |
| 4894 | + | "params": { | |
| 4895 | + | "owner": "flagon-io", | |
| 4896 | + | "name": "hello", | |
| 4897 | + | "number": 14 | |
| 4898 | + | }, | |
| 4899 | + | "response": { | |
| 4900 | + | "subscribed": false, | |
| 4901 | + | "ignored": false, | |
| 4902 | + | "reason": null, | |
| 4903 | + | "repo": null, | |
| 4904 | + | "number": 14, | |
| 4905 | + | "updated_at": "2026-10-07T10:05:30.000Z" | |
| 4906 | + | } | |
| 4907 | + | }, | |
| 4908 | + | "get_repo_subscription": { | |
| 4909 | + | "params": { | |
| 4910 | + | "owner": "flagon-io", | |
| 4911 | + | "name": "hello" | |
| 4912 | + | }, | |
| 4913 | + | "response": { | |
| 4914 | + | "repo": "flagon-io/hello", | |
| 4915 | + | "level": "participating", | |
| 4916 | + | "events": [], | |
| 4917 | + | "subscribed": false, | |
| 4918 | + | "ignored": false, | |
| 4919 | + | "updated_at": null | |
| 4920 | + | } | |
| 4921 | + | }, | |
| 4922 | + | "set_repo_subscription": { | |
| 4923 | + | "params": { | |
| 4924 | + | "owner": "flagon-io", | |
| 4925 | + | "name": "hello" | |
| 4926 | + | }, | |
| 4927 | + | "request": { | |
| 4928 | + | "level": "custom", | |
| 4929 | + | "events": [ | |
| 4930 | + | "pulls", | |
| 4931 | + | "deployments" | |
| 4932 | + | ] | |
| 4933 | + | }, | |
| 4934 | + | "response": { | |
| 4935 | + | "repo": "flagon-io/hello", | |
| 4936 | + | "level": "custom", | |
| 4937 | + | "events": [ | |
| 4938 | + | "pulls", | |
| 4939 | + | "deployments" | |
| 4940 | + | ], | |
| 4941 | + | "subscribed": true, | |
| 4942 | + | "ignored": false, | |
| 4943 | + | "updated_at": "2026-10-07T10:06:00.000Z" | |
| 4944 | + | }, | |
| 4945 | + | "notes": "| `level` | You hear of |\n| --- | --- |\n| `participating` | What you take part in, or are mentioned in (the default) |\n| `all` | Every issue and pull request opened, commented on, closed or merged, and every deployment |\n| `custom` | What you take part in, and the kinds in `events`: `issues`, `pulls`, `deployments`, `security` |\n| `ignore` | Nothing, not even a mention |\n\nInstead of `level`, `{\"subscribed\": true}` is `all`, `{\"subscribed\": false}` is `participating` and `{\"ignored\": true}` is `ignore`." | |
| 4946 | + | }, | |
| 4947 | + | "delete_repo_subscription": { | |
| 4948 | + | "params": { | |
| 4949 | + | "owner": "flagon-io", | |
| 4950 | + | "name": "hello" | |
| 4951 | + | }, | |
| 4952 | + | "response": { | |
| 4953 | + | "repo": "flagon-io/hello", | |
| 4954 | + | "level": "participating", | |
| 4955 | + | "events": [], | |
| 4956 | + | "subscribed": false, | |
| 4957 | + | "ignored": false, | |
| 4958 | + | "updated_at": null | |
| 4959 | + | } | |
| 4960 | + | }, | |
| 4961 | + | "list_watched_repos": { | |
| 4962 | + | "response": [ | |
| 4963 | + | { | |
| 4964 | + | "repo": "flagon-io/hello", | |
| 4965 | + | "level": "all", | |
| 4966 | + | "events": [], | |
| 4967 | + | "subscribed": true, | |
| 4968 | + | "ignored": false, | |
| 4969 | + | "updated_at": "2026-10-07T10:00:00.000Z" | |
| 4970 | + | }, | |
| 4971 | + | { | |
| 4972 | + | "repo": "flagon-io/docs", | |
| 4973 | + | "level": "ignore", | |
| 4974 | + | "events": [], | |
| 4975 | + | "subscribed": false, | |
| 4976 | + | "ignored": true, | |
| 4977 | + | "updated_at": "2026-10-05T14:30:00.000Z" | |
| 4978 | + | } | |
| 4979 | + | ] | |
| 4237 | 4980 | } | |
| 4238 | 4981 | } |
| 142 | 142 | through::<g1t_contracts::identity::Invite>(op, sent) | |
| 143 | 143 | } | |
| 144 | 144 | Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent), | |
| 145 | + | Op::ListNotifications => through::<g1t_contracts::inbox::InboxPage>(op, sent), | |
| 146 | + | Op::GetNotificationThread | Op::MarkThreadRead | Op::MarkThreadDone | Op::SaveThread | Op::SnoozeThread => { | |
| 147 | + | through::<g1t_contracts::inbox::InboxThread>(op, sent) | |
| 148 | + | } | |
| 149 | + | Op::GetThreadSubscription | Op::SetThreadSubscription | Op::DeleteThreadSubscription => { | |
| 150 | + | through::<g1t_contracts::inbox::ThreadSubscription>(op, sent) | |
| 151 | + | } | |
| 145 | 152 | _ => sent, | |
| 146 | 153 | } | |
| 147 | 154 | } |
| 56 | 56 | route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]), | |
| 57 | 57 | route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]), | |
| 58 | 58 | route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]), | |
| 59 | + | // Your notifications: threads, marking them, and what you subscribe | |
| 60 | + | // to and watch. GitHub's addresses, with g1t's saved and snoozed. | |
| 61 | + | route("GET", "/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]), | |
| 62 | + | route("PUT", "/notifications", Op::MarkNotificationsRead, &[]), | |
| 63 | + | route("GET", "/notifications/threads/:id", Op::GetNotificationThread, &[]), | |
| 64 | + | route("PATCH", "/notifications/threads/:id", Op::MarkThreadRead, &[]), | |
| 65 | + | route("DELETE", "/notifications/threads/:id", Op::MarkThreadDone, &[]), | |
| 66 | + | route("PUT", "/notifications/threads/:id/saved", Op::SaveThread, &[]), | |
| 67 | + | route("DELETE", "/notifications/threads/:id/saved", Op::SaveThread, &[]), | |
| 68 | + | route("PUT", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]), | |
| 69 | + | route("DELETE", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]), | |
| 70 | + | route("GET", "/notifications/threads/:id/subscription", Op::GetThreadSubscription, &[]), | |
| 71 | + | route("PUT", "/notifications/threads/:id/subscription", Op::SetThreadSubscription, &[]), | |
| 72 | + | route("DELETE", "/notifications/threads/:id/subscription", Op::DeleteThreadSubscription, &[]), | |
| 73 | + | route("GET", "/repos/:owner/:name/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]), | |
| 74 | + | route("PUT", "/repos/:owner/:name/notifications", Op::MarkNotificationsRead, &[]), | |
| 75 | + | route("GET", "/repos/:owner/:name/subscription", Op::GetRepoSubscription, &[]), | |
| 76 | + | route("PUT", "/repos/:owner/:name/subscription", Op::SetRepoSubscription, &[]), | |
| 77 | + | route("DELETE", "/repos/:owner/:name/subscription", Op::DeleteRepoSubscription, &[]), | |
| 78 | + | route("GET", "/repos/:owner/:name/issues/:number/subscription", Op::GetThreadSubscription, &[]), | |
| 79 | + | route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 80 | + | route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 81 | + | route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]), | |
| 59 | 82 | route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]), | |
| 60 | 83 | route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]), | |
| 61 | 84 | route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]), | |
| 691 | 714 | if route.path.ends_with("/rerun-failed-jobs") { | |
| 692 | 715 | input.insert("failed_only".to_owned(), Value::Bool(true)); | |
| 693 | 716 | } | |
| 717 | + | // Unsaving and waking a thread are a DELETE of what PUT made. | |
| 718 | + | if route.method == "DELETE" && route.path.ends_with("/saved") { | |
| 719 | + | input.insert("saved".to_owned(), Value::Bool(false)); | |
| 720 | + | } | |
| 721 | + | if route.method == "DELETE" && route.path.ends_with("/snooze") { | |
| 722 | + | input.remove("until"); | |
| 723 | + | } | |
| 694 | 724 | if let Some(number) = param("number") { | |
| 695 | 725 | // Not a number: zero, which no issue or pull request has. | |
| 696 | 726 | input.insert( | |
| 759 | 789 | } | |
| 760 | 790 | ||
| 761 | 791 | #[test] | |
| 792 | + | fn notifications_are_addressed_as_threads_and_by_issue() { | |
| 793 | + | let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap(); | |
| 794 | + | assert_eq!(route.op, Op::MarkThreadDone); | |
| 795 | + | assert_eq!(input, json!({ "id": "ntf_1" })); | |
| 796 | + | let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/saved", &[], Value::Null).unwrap(); | |
| 797 | + | assert_eq!(route.op, Op::SaveThread); | |
| 798 | + | assert_eq!(input, json!({ "id": "ntf_1", "saved": false })); | |
| 799 | + | let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/snooze", &[], json!({ "until": "x" })).unwrap(); | |
| 800 | + | assert_eq!(route.op, Op::SnoozeThread); | |
| 801 | + | assert_eq!(input, json!({ "id": "ntf_1" })); | |
| 802 | + | let query = [("all".to_owned(), "true".to_owned()), ("per_page".to_owned(), "50".to_owned())]; | |
| 803 | + | let (route, input) = resolve("GET", "/repos/acme/rocket/notifications", &query, Value::Null).unwrap(); | |
| 804 | + | assert_eq!(route.op, Op::ListNotifications); | |
| 805 | + | assert_eq!(input, json!({ "all": "true", "per_page": "50", "repo": "acme/rocket" })); | |
| 806 | + | let (route, input) = resolve("PUT", "/repos/acme/rocket/issues/7/subscription", &[], json!({ "ignored": true })).unwrap(); | |
| 807 | + | assert_eq!(route.op, Op::SetThreadSubscription); | |
| 808 | + | assert_eq!(input, json!({ "ignored": true, "number": 7, "repo": "acme/rocket" })); | |
| 809 | + | assert_eq!(resolve("GET", "/user/subscriptions", &[], Value::Null).unwrap().0.op, Op::ListWatchedRepos); | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | #[test] | |
| 762 | 813 | fn query_parameters_are_renamed() { | |
| 763 | 814 | let query = [ | |
| 764 | 815 | ("q".to_owned(), "parser".to_owned()), |
| 245 | 245 | ], | |
| 246 | 246 | }, | |
| 247 | 247 | Tool { | |
| 248 | + | name: "notifications", | |
| 249 | + | title: "Notifications", | |
| 250 | + | description: "Your inbox: what needs you, and what you follow. One thread per issue, pull request, workflow or deployment, with why you were told (`reason`): an agent waiting on you, a review asked of you, an assignment, a mention, your work's checks, or what you subscribe to and watch. Mark threads read or done once handled, and choose what you hear of with subscribe, unsubscribe and watch. Your own: a personal token.", | |
| 251 | + | default_action: Some("list"), | |
| 252 | + | actions: &[ | |
| 253 | + | a("list", Op::ListNotifications, "Unread threads, latest first; all, a view, a reason, a repository"), | |
| 254 | + | a("get", Op::GetNotificationThread, "One thread with its recent activity and your subscription"), | |
| 255 | + | a("mark_read", Op::MarkThreadRead, "Mark a thread read, or unread"), | |
| 256 | + | a("mark_all_read", Op::MarkNotificationsRead, "Mark everything read up to a time, or one repository's"), | |
| 257 | + | a("done", Op::MarkThreadDone, "Mark a thread done; new activity brings it back"), | |
| 258 | + | a("save", Op::SaveThread, "Save a thread, or unsave it"), | |
| 259 | + | a("snooze", Op::SnoozeThread, "Snooze a thread until a time, or bring it back"), | |
| 260 | + | a("subscription", Op::GetThreadSubscription, "Your subscription to an issue or pull request"), | |
| 261 | + | a("subscribe", Op::SetThreadSubscription, "Subscribe to an issue or pull request, or ignore it"), | |
| 262 | + | a("unsubscribe", Op::DeleteThreadSubscription, "Unsubscribe until you comment or are mentioned"), | |
| 263 | + | a("watching", Op::GetRepoSubscription, "How you watch a repository"), | |
| 264 | + | a("watch", Op::SetRepoSubscription, "Watch a repository: participating, all, ignore or custom"), | |
| 265 | + | a("unwatch", Op::DeleteRepoSubscription, "Stop watching a repository"), | |
| 266 | + | a("watched", Op::ListWatchedRepos, "Repositories you watch other than the default way"), | |
| 267 | + | ], | |
| 268 | + | }, | |
| 269 | + | Tool { | |
| 248 | 270 | name: "account", | |
| 249 | 271 | title: "Your account", | |
| 250 | 272 | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.", | |
| 613 | 635 | let access = token(Some(vec![Scope::IssuesWrite])); | |
| 614 | 636 | let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect(); | |
| 615 | 637 | assert_eq!(names, vec![json!("issue"), json!("plan"), json!("account")]); | |
| 638 | + | // Notifications are a resource of their own: reading them lists | |
| 639 | + | // only what reads. | |
| 640 | + | let reader = token(Some(vec![Scope::NotificationsRead])); | |
| 641 | + | let tools = listed(&Gate::Token(&reader)); | |
| 642 | + | let notifications = tools.iter().find(|tool| tool["name"] == "notifications").unwrap(); | |
| 643 | + | assert_eq!( | |
| 644 | + | notifications["inputSchema"]["properties"]["action"]["enum"], | |
| 645 | + | json!(["list", "get", "subscription", "watching", "watched"]) | |
| 646 | + | ); | |
| 647 | + | assert_eq!(notifications["annotations"]["readOnlyHint"], true); | |
| 616 | 648 | let full = token(None); | |
| 617 | 649 | assert_eq!(listed(&Gate::Token(&full)).len(), TOOLS.len()); | |
| 618 | 650 | assert_eq!(listed(&Gate::Everything).len(), TOOLS.len()); |
Binary or large file; its contents are not shown.
| 263 | 263 | "list_actions_secrets", | |
| 264 | 264 | "list_actions_variables", | |
| 265 | 265 | "list_security_alerts", | |
| 266 | + | "list_notifications", | |
| 267 | + | "get_notification_thread", | |
| 268 | + | "get_thread_subscription", | |
| 269 | + | "get_repo_subscription", | |
| 270 | + | "list_watched_repos", | |
| 266 | 271 | ]; | |
| 267 | 272 | ||
| 268 | 273 | /// What no agent's token may ever do, whatever its scope says: workspaces, | |
| 329 | 334 | // Dismissing a secret lets it through push protection. | |
| 330 | 335 | "dismiss_security_alert", | |
| 331 | 336 | "reopen_security_alert", | |
| 337 | + | // A person's own inbox: g1t's agents act as g1t, which has none. | |
| 338 | + | "list_notifications", | |
| 339 | + | "get_notification_thread", | |
| 340 | + | "mark_notifications_read", | |
| 341 | + | "mark_thread_read", | |
| 342 | + | "mark_thread_done", | |
| 343 | + | "save_thread", | |
| 344 | + | "snooze_thread", | |
| 345 | + | "get_thread_subscription", | |
| 346 | + | "set_thread_subscription", | |
| 347 | + | "delete_thread_subscription", | |
| 348 | + | "get_repo_subscription", | |
| 349 | + | "set_repo_subscription", | |
| 350 | + | "delete_repo_subscription", | |
| 351 | + | "list_watched_repos", | |
| 332 | 352 | ]; | |
| 333 | 353 | ||
| 334 | 354 | /// Reading what an agent needs to know about its repository. |
| 23 | 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 24 | 24 | pub enum Resource { | |
| 25 | 25 | Account, | |
| 26 | + | Notifications, | |
| 26 | 27 | Workspace, | |
| 27 | 28 | Repo, | |
| 28 | 29 | Code, | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | impl Resource { | |
| 42 | − | pub const ALL: [Resource; 14] = [ | |
| 43 | + | pub const ALL: [Resource; 15] = [ | |
| 43 | 44 | Resource::Repo, | |
| 44 | 45 | Resource::Code, | |
| 45 | 46 | Resource::Packages, | |
| 49 | 50 | Resource::Workflows, | |
| 50 | 51 | Resource::Memory, | |
| 51 | 52 | Resource::Account, | |
| 53 | + | Resource::Notifications, | |
| 52 | 54 | Resource::Workspace, | |
| 53 | 55 | Resource::Access, | |
| 54 | 56 | Resource::Webhooks, | |
| 59 | 61 | pub fn as_str(self) -> &'static str { | |
| 60 | 62 | match self { | |
| 61 | 63 | Resource::Account => "account", | |
| 64 | + | Resource::Notifications => "notifications", | |
| 62 | 65 | Resource::Workspace => "workspace", | |
| 63 | 66 | Resource::Repo => "repo", | |
| 64 | 67 | Resource::Code => "code", | |
| 79 | 82 | pub fn label(self) -> &'static str { | |
| 80 | 83 | match self { | |
| 81 | 84 | Resource::Account => "Your account", | |
| 85 | + | Resource::Notifications => "Notifications", | |
| 82 | 86 | Resource::Workspace => "Workspaces", | |
| 83 | 87 | Resource::Repo => "Repositories", | |
| 84 | 88 | Resource::Code => "Code", | |
| 143 | 147 | MemoryWrite, | |
| 144 | 148 | AccountRead, | |
| 145 | 149 | AccountWrite, | |
| 150 | + | NotificationsRead, | |
| 151 | + | NotificationsWrite, | |
| 146 | 152 | WorkspaceRead, | |
| 147 | 153 | WorkspaceAdmin, | |
| 148 | 154 | AccessRead, | |
| 157 | 163 | ||
| 158 | 164 | impl Scope { | |
| 159 | 165 | /// Every scope, grouped by resource, least first. | |
| 160 | − | pub const ALL: [Scope; 29] = [ | |
| 166 | + | pub const ALL: [Scope; 31] = [ | |
| 161 | 167 | Scope::RepoRead, | |
| 162 | 168 | Scope::RepoWrite, | |
| 163 | 169 | Scope::RepoAdmin, | |
| 177 | 183 | Scope::MemoryWrite, | |
| 178 | 184 | Scope::AccountRead, | |
| 179 | 185 | Scope::AccountWrite, | |
| 186 | + | Scope::NotificationsRead, | |
| 187 | + | Scope::NotificationsWrite, | |
| 180 | 188 | Scope::WorkspaceRead, | |
| 181 | 189 | Scope::WorkspaceAdmin, | |
| 182 | 190 | Scope::AccessRead, | |
| 210 | 218 | Scope::MemoryWrite => "memory:write", | |
| 211 | 219 | Scope::AccountRead => "account:read", | |
| 212 | 220 | Scope::AccountWrite => "account:write", | |
| 221 | + | Scope::NotificationsRead => "notifications:read", | |
| 222 | + | Scope::NotificationsWrite => "notifications:write", | |
| 213 | 223 | Scope::WorkspaceRead => "workspace:read", | |
| 214 | 224 | Scope::WorkspaceAdmin => "workspace:admin", | |
| 215 | 225 | Scope::AccessRead => "access:read", | |
| 280 | 290 | Scope::MemoryWrite => "Save memory for the next agent", | |
| 281 | 291 | Scope::AccountRead => "Read your email addresses, invites and invitations", | |
| 282 | 292 | Scope::AccountWrite => "Change your email addresses, make invites and answer invitations", | |
| 293 | + | Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch", | |
| 294 | + | Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories", | |
| 283 | 295 | Scope::WorkspaceRead => "Read workspace invites, integrations and model routes", | |
| 284 | 296 | Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations", | |
| 285 | 297 | Scope::AccessRead => "See who has access to repositories", | |
| 373 | 385 | // Not the machines work runs on: an agent has no business | |
| 374 | 386 | // knowing a workspace's own runners. | |
| 375 | 387 | let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect(); | |
| 388 | + | // And answering what needs the person it works for: marking | |
| 389 | + | // it done, subscribing, watching. | |
| 376 | 390 | scopes.extend([ | |
| 377 | 391 | Scope::CodeWrite, | |
| 378 | 392 | Scope::IssuesWrite, | |
| 379 | 393 | Scope::PullRequestsWrite, | |
| 380 | 394 | Scope::AgentsRun, | |
| 381 | 395 | Scope::MemoryWrite, | |
| 396 | + | Scope::NotificationsWrite, | |
| 382 | 397 | ]); | |
| 383 | 398 | normalize(&mut scopes); | |
| 384 | 399 | Some(scopes) | |
| 460 | 475 | ("list_my_repo_invitations", Scope::AccountRead), | |
| 461 | 476 | ("accept_repo_invitation", Scope::AccountWrite), | |
| 462 | 477 | ("decline_repo_invitation", Scope::AccountWrite), | |
| 478 | + | // Your inbox: notifications, subscriptions and watching. | |
| 479 | + | ("list_notifications", Scope::NotificationsRead), | |
| 480 | + | ("get_notification_thread", Scope::NotificationsRead), | |
| 481 | + | ("get_thread_subscription", Scope::NotificationsRead), | |
| 482 | + | ("get_repo_subscription", Scope::NotificationsRead), | |
| 483 | + | ("list_watched_repos", Scope::NotificationsRead), | |
| 484 | + | ("mark_notifications_read", Scope::NotificationsWrite), | |
| 485 | + | ("mark_thread_read", Scope::NotificationsWrite), | |
| 486 | + | ("mark_thread_done", Scope::NotificationsWrite), | |
| 487 | + | ("save_thread", Scope::NotificationsWrite), | |
| 488 | + | ("snooze_thread", Scope::NotificationsWrite), | |
| 489 | + | ("set_thread_subscription", Scope::NotificationsWrite), | |
| 490 | + | ("delete_thread_subscription", Scope::NotificationsWrite), | |
| 491 | + | ("set_repo_subscription", Scope::NotificationsWrite), | |
| 492 | + | ("delete_repo_subscription", Scope::NotificationsWrite), | |
| 463 | 493 | // Workspaces, their invites and integrations. | |
| 464 | 494 | ("create_workspace", Scope::WorkspaceAdmin), | |
| 465 | 495 | ("delete_workspace", Scope::WorkspaceAdmin), |
| 18 | 18 | | "workflows" | |
| 19 | 19 | | "memory" | |
| 20 | 20 | | "account" | |
| 21 | + | | "notifications" | |
| 21 | 22 | | "workspace" | |
| 22 | 23 | | "access" | |
| 23 | 24 | | "webhooks" | |
| 47 | 48 | { scope: "memory:write", description: "Save memory for the next agent" }, | |
| 48 | 49 | { scope: "account:read", description: "Read your email addresses, invites and invitations" }, | |
| 49 | 50 | { scope: "account:write", description: "Change your email addresses, make invites and answer invitations" }, | |
| 51 | + | { scope: "notifications:read", description: "See your inbox, its threads, and what you subscribe to and watch" }, | |
| 52 | + | { scope: "notifications:write", description: "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories" }, | |
| 50 | 53 | { scope: "workspace:read", description: "Read workspace invites, integrations and model routes" }, | |
| 51 | 54 | { scope: "workspace:admin", description: "Create and delete workspaces, invite members, connect integrations" }, | |
| 52 | 55 | { scope: "access:read", description: "See who has access to repositories" }, | |
| 72 | 75 | { resource: "workflows", label: "Workflows" }, | |
| 73 | 76 | { resource: "memory", label: "Memory and context" }, | |
| 74 | 77 | { resource: "account", label: "Your account" }, | |
| 78 | + | { resource: "notifications", label: "Notifications" }, | |
| 75 | 79 | { resource: "workspace", label: "Workspaces" }, | |
| 76 | 80 | { resource: "access", label: "Who has access" }, | |
| 77 | 81 | { resource: "webhooks", label: "Webhooks" }, | |
| 130 | 134 | /** Starting points for choosing scopes. `*` is full access. */ | |
| 131 | 135 | export const PRESET_SCOPES = { | |
| 132 | 136 | read_only: [ | |
| 133 | − | "repo:read", "code:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "memory:read", "account:read", "workspace:read", "access:read", "webhooks:read", "secrets:read", "runners:read", | |
| 137 | + | "repo:read", "code:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "memory:read", "account:read", "notifications:read", "workspace:read", "access:read", "webhooks:read", "secrets:read", "runners:read", | |
| 134 | 138 | ] as const, | |
| 135 | 139 | agent: [ | |
| 136 | − | "repo:read", "code:read", "code:write", "packages:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "agents:run", "workflows:read", "memory:read", "memory:write", "account:read", "workspace:read", "access:read", "webhooks:read", "secrets:read", | |
| 140 | + | "repo:read", "code:read", "code:write", "packages:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "agents:run", "workflows:read", "memory:read", "memory:write", "account:read", "notifications:read", "notifications:write", "workspace:read", "access:read", "webhooks:read", "secrets:read", | |
| 137 | 141 | ] as const, | |
| 138 | 142 | ci: [ | |
| 139 | 143 | "repo:read", "code:read", "code:write", "packages:read", "packages:write", "workflows:read", "workflows:write", | |
| 171 | 175 | ["list_my_repo_invitations", "account:read"], | |
| 172 | 176 | ["accept_repo_invitation", "account:write"], | |
| 173 | 177 | ["decline_repo_invitation", "account:write"], | |
| 178 | + | // Your inbox: notifications, subscriptions and watching. | |
| 179 | + | ["list_notifications", "notifications:read"], | |
| 180 | + | ["get_notification_thread", "notifications:read"], | |
| 181 | + | ["get_thread_subscription", "notifications:read"], | |
| 182 | + | ["get_repo_subscription", "notifications:read"], | |
| 183 | + | ["list_watched_repos", "notifications:read"], | |
| 184 | + | ["mark_notifications_read", "notifications:write"], | |
| 185 | + | ["mark_thread_read", "notifications:write"], | |
| 186 | + | ["mark_thread_done", "notifications:write"], | |
| 187 | + | ["save_thread", "notifications:write"], | |
| 188 | + | ["snooze_thread", "notifications:write"], | |
| 189 | + | ["set_thread_subscription", "notifications:write"], | |
| 190 | + | ["delete_thread_subscription", "notifications:write"], | |
| 191 | + | ["set_repo_subscription", "notifications:write"], | |
| 192 | + | ["delete_repo_subscription", "notifications:write"], | |
| 174 | 193 | ["create_workspace", "workspace:admin"], | |
| 175 | 194 | ["delete_workspace", "workspace:admin"], | |
| 176 | 195 | ["update_workspace", "workspace:admin"], | |
| 303 | 322 | { id: "workflows", label: "Workflows", scopes: ["workflows:read", "workflows:write"] }, | |
| 304 | 323 | { id: "memory", label: "Memory & search", scopes: ["memory:read", "memory:write"] }, | |
| 305 | 324 | { id: "account", label: "Account", scopes: ["account:read", "account:write"] }, | |
| 325 | + | { id: "notifications", label: "Notifications", scopes: ["notifications:read", "notifications:write"] }, | |
| 306 | 326 | { id: "workspace", label: "Workspace", scopes: ["workspace:read", "access:read", "webhooks:read", "secrets:read"] }, | |
| 307 | 327 | { id: "runners", label: "Runners", scopes: ["runners:read"] }, | |
| 308 | 328 | ]; |