Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
5 commits
- Docs: inbox threads, reasons, subscriptions, watching, email, API and MCPChase Pierce26b4364
- Inbox on the site: reasons, a reason filter, Subscribe, Watch and settingsChase Pierce24841cb
- API: notifications over REST and MCP, with notifications scopesChase Pierce73496a0
- Inbox: threads, reasons, subscriptions and watchingChase Pierce296c251
- Events: review requests, assignments, stops and deployments are publishedChase Pierce3c8b38c
| 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()); |
| 323 | 323 | | Workflows | `workflows:read`, `workflows:write` | | |
| 324 | 324 | | Memory & search | `memory:read`, `memory:write` | | |
| 325 | 325 | | Account | `account:read`, `account:write` | | |
| 326 | + | | Notifications | `notifications:read`, `notifications:write` | | |
| 326 | 327 | | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` | | |
| 327 | 328 | | Runners | `runners:read` | | |
| 328 | 329 | | Dangerous | `repo:admin`, `packages:delete`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` | | |
| 352 | 353 | | `memory:write` | Save memory for the next agent | | |
| 353 | 354 | | `account:read` | Read your email addresses, invites and invitations | | |
| 354 | 355 | | `account:write` | Change your email addresses, make invites and answer invitations | | |
| 356 | + | | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch | | |
| 357 | + | | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories | | |
| 355 | 358 | | `workspace:read` | Read workspace invites, integrations and model routes | | |
| 356 | 359 | | `workspace:admin` | Create and delete workspaces, invite members, connect integrations | | |
| 357 | 360 | | `access:read` | See who has access to repositories | | |
| 398 | 401 | | Preset | Scopes | | |
| 399 | 402 | | --- | --- | | |
| 400 | 403 | | Read only | Every `read` scope. Changes nothing. | | |
| 401 | − | | Agent | Every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Reads everything, works on issues and pull requests, pushes code and puts g1t to work. No admin scope. | | |
| 404 | + | | Agent | Every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and `notifications:write`. Reads everything, works on issues and pull requests, pushes code, puts g1t to work, and answers your inbox. No admin scope. | | |
| 402 | 405 | | CI | `repo:read`, `code:read`, `code:write`, `packages:read`, `packages:write`, `workflows:read` and `workflows:write`. Clones and pushes code, pushes and pulls packages, and runs workflows. | | |
| 403 | 406 | | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. | | |
| 404 | 407 | ||
| 474 | 477 | ||
| 475 | 478 | An application that asks for no scopes in particular gets the | |
| 476 | 479 | [Agent preset](#presets): every `read` scope except `runners:read`, and `code:write`, | |
| 477 | − | `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. | |
| 480 | + | `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and | |
| 481 | + | `notifications:write`. | |
| 478 | 482 | It never gets an admin scope unless it asks for one and you leave it | |
| 479 | 483 | ticked. | |
| 480 | 484 |
| 45 | 45 | ||
| 46 | 46 | | Choose | For an agent | | |
| 47 | 47 | | --- | --- | | |
| 48 | − | | Scopes | The **Agent** preset: every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Untick `agents:run` if it should not start g1t's agents, which spends the workspace's money. | | |
| 48 | + | | Scopes | The **Agent** preset: every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and `notifications:write`. Untick `agents:run` if it should not start g1t's agents, which spends the workspace's money. | | |
| 49 | 49 | | Expires | The shortest that fits the work, such as 30 days. | | |
| 50 | 50 | ||
| 51 | 51 | A token reaches every workspace and repository you can. To keep an agent |
| 1 | 1 | --- | |
| 2 | 2 | title: Your inbox | |
| 3 | − | description: What needs you, and what you follow, as it happens. An agent waiting on you comes first; failures, merges, mentions and comments follow. Mark items read or done, save them, or snooze them. | |
| 3 | + | description: What needs you, and what you follow, as it happens. One thread per issue, pull request, workflow or deployment, with why you were told. Choose what you hear of with subscriptions, watching and email settings. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | Your **inbox** tells you when something needs you, or when something | |
| 7 | − | happens to work you answer for: an agent is waiting on you, checks failed | |
| 8 | − | on your pull request, g1t finished a change you asked for, someone mentioned | |
| 9 | − | you. You are never told about what you did yourself. | |
| 7 | + | happens to work you answer for or follow: an agent is waiting on you, | |
| 8 | + | someone asked you to review a pull request, checks failed on your pull | |
| 9 | + | request, a deployment failed, someone mentioned you. You are never told | |
| 10 | + | about what you did yourself. | |
| 10 | 11 | ||
| 11 | 12 | Open it from the bell in the top bar. The number on the bell is what is | |
| 12 | − | unread. It is amber while an agent is waiting on you, red while a failure is | |
| 13 | − | unread, and green otherwise. | |
| 13 | + | unread. It is amber while something is waiting on you, red while a failure | |
| 14 | + | is unread, and green otherwise. | |
| 15 | + | ||
| 16 | + | ## Threads | |
| 17 | + | ||
| 18 | + | Your inbox holds one **thread** for each thing you were told about: | |
| 19 | + | ||
| 20 | + | - an issue | |
| 21 | + | - a pull request | |
| 22 | + | - a workflow on one branch | |
| 23 | + | - a deployment: a project's production, or one pull request's preview | |
| 24 | + | ||
| 25 | + | When something new happens on a thread, it comes back to the top of your | |
| 26 | + | inbox, unread, even if you had marked it done. It is not added a second | |
| 27 | + | time. A thread that is snoozed stays snoozed until its time. | |
| 28 | + | ||
| 29 | + | Each card shows the latest activity's title, why you were told (see | |
| 30 | + | [reasons](#reasons)), and, when more than one thing has happened, how many, | |
| 31 | + | such as **3 updates**. g1t keeps the last 10 activities of each thread. | |
| 32 | + | ||
| 33 | + | While a thread is unread it keeps the most urgent of what happened since | |
| 34 | + | you last read it. A failure followed by a comment still shows as a failure | |
| 35 | + | until you read it. | |
| 36 | + | ||
| 37 | + | ## Reasons | |
| 38 | + | ||
| 39 | + | Every thread says why you were told of its latest activity. When you are | |
| 40 | + | told of one thing for more than one reason, the first that applies in this | |
| 41 | + | table is shown. | |
| 42 | + | ||
| 43 | + | | Reason | Shown as | Why you were told | | |
| 44 | + | | --- | --- | --- | | |
| 45 | + | | `agent` | agent waiting | An agent is waiting on you: it asked a question, or it stopped until a person steps in. | | |
| 46 | + | | `review_requested` | review requested | Someone asked you to review a pull request, or you are one of its reviewers. | | |
| 47 | + | | `assign` | assigned | You were assigned, or you are an assignee. | | |
| 48 | + | | `mention` | mentioned | Someone mentioned you with `@username`, or you were mentioned on it before. | | |
| 49 | + | | `ci_activity` | CI activity | A check, workflow or deployment on your work finished badly, or recovered. | | |
| 50 | + | | `security_alert` | security alert | A security alert on a repository you look after. g1t does not send these to the inbox yet. | | |
| 51 | + | | `state_change` | state changed | It was closed, reopened or merged. | | |
| 52 | + | | `author` | your work | You opened it, or you asked g1t for it. | | |
| 53 | + | | `comment` | commented | You commented on it. | | |
| 54 | + | | `manual` | subscribed | You subscribed to it yourself. | | |
| 55 | + | | `subscribed` | watching | You watch its repository. | | |
| 14 | 56 | ||
| 15 | 57 | ## What lands there | |
| 16 | 58 | ||
| 17 | 59 | Each item comes from something that happened on g1t. Who is told depends on | |
| 18 | 60 | what it was: | |
| 19 | 61 | ||
| 20 | − | | What happened | Who is told | Shown as | | |
| 21 | − | | --- | --- | --- | | |
| 22 | − | | Another agent asked a question of the agent on a pull request, or handed it work | The person the pull request belongs to, and the issue's author and assignees | Needs you | | |
| 23 | − | | Checks failed, or could not run, on a pull request | The person the pull request belongs to | Error | | |
| 24 | − | | A workflow failed on a pull request | The person the pull request belongs to | Error | | |
| 25 | − | | A workflow failed on a branch | Whoever pushed the commit it ran on | Error | | |
| 26 | − | | g1t finished a change and marked it ready for review | The person who asked g1t for it | Success | | |
| 27 | − | | g1t reviewed a pull request | The person the pull request belongs to | Success when approved, Info when it asks for changes | | |
| 28 | − | | A pull request was merged | The person the pull request belongs to | Success | | |
| 29 | − | | Someone mentioned you with `@username` in a comment | You | Info | | |
| 30 | − | | Someone commented on an issue or pull request you opened | You | Info, or Success for an approval | | |
| 62 | + | | What happened | Who is told | Reason | Shown as | | |
| 63 | + | | --- | --- | --- | --- | | |
| 64 | + | | An agent asked a question of the agent on a pull request, or handed it work | The person the pull request belongs to, and its issue's author and assignees | `agent` | Needs you | | |
| 65 | + | | g1t stopped on a pull request until a person steps in | The same people | `agent` | Needs you | | |
| 66 | + | | Someone asked for reviews on a pull request, or opened one with reviewers | The reviewers asked | `review_requested` | Needs you | | |
| 67 | + | | Someone assigned people to an issue or pull request, or opened one with assignees | The people newly assigned | `assign` | Info | | |
| 68 | + | | Checks failed, or could not run, on a pull request | The person the pull request belongs to | `ci_activity` | Error | | |
| 69 | + | | A workflow failed on a pull request | The person the pull request belongs to | `ci_activity` | Error | | |
| 70 | + | | A workflow failed on a branch | Whoever pushed the commit it ran on | `ci_activity` | Error | | |
| 71 | + | | A preview of a pull request failed to deploy | The person the pull request belongs to, and people watching deployments | `ci_activity`, or `subscribed` for watchers | Error | | |
| 72 | + | | Production failed to deploy | Whoever pushed or started it, and people watching deployments | `ci_activity`, or `subscribed` for watchers | Error | | |
| 73 | + | | A deployment went live | People watching deployments; after a failure, also whoever was told of the failure | `ci_activity`, or `subscribed` for watchers | Success | | |
| 74 | + | | g1t finished a change and marked it ready for review | The person who asked g1t for it | `author` | Success | | |
| 75 | + | | g1t reviewed a pull request | The person the pull request belongs to | `author` | Success when approved, Info when it asks for changes | | |
| 76 | + | | A pull request was merged | Everyone subscribed to it, and people watching pull requests | `state_change`, or `subscribed` for watchers | Success | | |
| 77 | + | | An issue or pull request was closed, or an issue was reopened | Everyone subscribed to it, and people watching its kind | `state_change`, or `subscribed` for watchers | Info | | |
| 78 | + | | Someone mentioned you with `@username` in a comment | You | `mention` | Info | | |
| 79 | + | | Someone commented on an issue or pull request | Everyone subscribed to it, and people watching its kind | Why each is subscribed, or `subscribed` for watchers | Info, or Success for an approval | | |
| 80 | + | | An issue or pull request was opened | People watching its kind | `subscribed` | Info | | |
| 31 | 81 | ||
| 32 | 82 | "The person a pull request belongs to" is its author, or, for a change g1t | |
| 33 | − | made, the person who asked for it. g1t itself is never told. A mention in | |
| 34 | − | code or in a quoted line does not count. | |
| 83 | + | made, the person who asked for it. An approval or a request for changes | |
| 84 | + | always reaches that person. g1t itself is never told. A mention in code or | |
| 85 | + | in a quoted line does not count. | |
| 86 | + | ||
| 87 | + | Some threads close themselves once they no longer need you: | |
| 88 | + | ||
| 89 | + | - When an agent was waiting on you, the thread moves to Done as soon as the | |
| 90 | + | agent picks back up: it resumes, its pull request changes, a merge is | |
| 91 | + | asked for, or the pull request is merged or closed. | |
| 92 | + | - When a review request to you is removed, that thread moves to Done. | |
| 93 | + | ||
| 94 | + | New activity brings either back, as with any thread. | |
| 35 | 95 | ||
| 36 | 96 | You only see items about repositories you can read. If you lose access to a | |
| 37 | 97 | repository, its items leave your inbox the next time you open it. | |
| 38 | 98 | ||
| 99 | + | ## Subscriptions | |
| 100 | + | ||
| 101 | + | You are **subscribed** to an issue or pull request, and hear of what | |
| 102 | + | happens on it, without doing anything when you: | |
| 103 | + | ||
| 104 | + | - opened it, or asked g1t for it | |
| 105 | + | - are assigned to it | |
| 106 | + | - are one of its reviewers | |
| 107 | + | - commented on it | |
| 108 | + | - were mentioned in it | |
| 109 | + | ||
| 110 | + | You can also subscribe to any issue or pull request yourself, or | |
| 111 | + | unsubscribe from one. | |
| 112 | + | ||
| 113 | + | | You are | You hear of | | |
| 114 | + | | --- | --- | | |
| 115 | + | | Subscribed | Everything in [What lands there](#what-lands-there) that goes to everyone subscribed: comments, closes, reopens and merges. | | |
| 116 | + | | Unsubscribed | Only what is asked of you or is about your own work: an agent waiting on you, a review request, an assignment, a mention, and failed checks, workflows and deployments. Commenting on it, or being mentioned in it, subscribes you again. | | |
| 117 | + | | Ignoring it | Nothing on it at all, not even a mention. Only you can undo this. | | |
| 118 | + | ||
| 119 | + | To subscribe to an issue or pull request, or unsubscribe: | |
| 120 | + | ||
| 121 | + | 1. Open the issue or pull request. | |
| 122 | + | 2. In the sidebar, under **Notifications**, select **Subscribe** or | |
| 123 | + | **Unsubscribe**. | |
| 124 | + | ||
| 125 | + | The line under the button says where you stand, such as "You're subscribed | |
| 126 | + | because you were assigned.", "You're not subscribed. You'll still hear if | |
| 127 | + | you're mentioned or asked to review." or "You ignore this thread." | |
| 128 | + | ||
| 129 | + | To ignore an issue or pull request, use the API: | |
| 130 | + | `PUT /repos/{owner}/{name}/issues/{number}/subscription` with | |
| 131 | + | `"ignored": true`, or the `notifications` tool's `subscribe` action with | |
| 132 | + | `ignored`. See [from the API and agents](#from-the-api-and-agents). While | |
| 133 | + | you ignore one, its button reads **Stop ignoring**, which puts you back to | |
| 134 | + | the default: subscribed only while you take part. | |
| 135 | + | ||
| 136 | + | ## Watching a repository | |
| 137 | + | ||
| 138 | + | How you **watch** a repository decides what you hear of on it beyond what | |
| 139 | + | you take part in. | |
| 140 | + | ||
| 141 | + | | Level | You hear of | | |
| 142 | + | | --- | --- | | |
| 143 | + | | **Participating and @mentions** | Only what you take part in or are mentioned in. The default. | | |
| 144 | + | | **All activity** | Also every issue and pull request opened, commented on, closed, reopened or merged, and every deployment. | | |
| 145 | + | | **Ignore** | Nothing on the repository at all, not even a mention or a review request. | | |
| 146 | + | | **Custom** | What you take part in, and the kinds you choose: **Issues**, **Pull requests**, **Deployments** and **Security alerts**. g1t does not send security alerts to the inbox yet. | | |
| 147 | + | ||
| 148 | + | To change how you watch a repository: | |
| 149 | + | ||
| 150 | + | 1. Open the repository. | |
| 151 | + | 2. In the header, open the **Watch** menu. | |
| 152 | + | 3. Select a level. For **Custom**, tick the kinds you want. Unticking | |
| 153 | + | every kind puts you back on **Participating and @mentions**. | |
| 154 | + | ||
| 155 | + | A repository you create is watched the way you choose in | |
| 156 | + | [your settings](#settings): **All activity** unless you change it. | |
| 157 | + | ||
| 158 | + | ||
| 159 | + | ||
| 160 | + | You can also be emailed when you are told of something. By default, g1t | |
| 161 | + | emails you for three reasons: **agent waiting**, **review requested** and | |
| 162 | + | **mentioned**. Each time you are told of something for a reason you chose, | |
| 163 | + | g1t sends one email with what happened and a link to it. | |
| 164 | + | ||
| 165 | + | An email is sent only when: | |
| 166 | + | ||
| 167 | + | - your account's email address is confirmed, and | |
| 168 | + | - you can still read the repository it is about. | |
| 169 | + | ||
| 170 | + | The foot of each email says why you got it, and links to | |
| 171 | + | [g1t.sh/settings/notifications](https://g1t.sh/settings/notifications). | |
| 172 | + | ||
| 173 | + | ## Settings | |
| 174 | + | ||
| 175 | + | **Settings → Notifications**, at | |
| 176 | + | [g1t.sh/settings/notifications](https://g1t.sh/settings/notifications), | |
| 177 | + | holds your choices: | |
| 178 | + | ||
| 179 | + | | Setting | What it does | Default | | |
| 180 | + | | --- | --- | --- | | |
| 181 | + | | **Email** | One checkbox per reason: you are also emailed when you are told of something for it. | agent waiting, review requested, mentioned | | |
| 182 | + | | **Repositories you create** | How you watch a new repository you create: **Participating and @mentions** or **All activity**. | All activity | | |
| 183 | + | | **Watched repositories** | Every repository you watch other than the default way, with how. | | | |
| 184 | + | ||
| 39 | 185 | ## Tabs | |
| 40 | 186 | ||
| 41 | 187 | | Tab | Shows | | |
| 42 | 188 | | --- | --- | | |
| 43 | − | | **All** | Everything, with what an agent is waiting on first | | |
| 44 | − | | **Needs you** | What an agent is waiting on you for | | |
| 45 | − | | **Errors** | Failed checks and workflows | | |
| 46 | − | | **Success** | Merges, approvals and finished agent work | | |
| 47 | − | | **Info** | Mentions and comments | | |
| 189 | + | | **All** | Everything, with what is waiting on you first | | |
| 190 | + | | **Needs you** | What is waiting on you: an agent, or a review asked of you | | |
| 191 | + | | **Errors** | Failed checks, workflows and deployments | | |
| 192 | + | | **Success** | Merges, approvals, finished agent work, and deployments that went live | | |
| 193 | + | | **Info** | Mentions, comments, assignments, and what you watch | | |
| 48 | 194 | ||
| 49 | 195 | The count beside each tab is what is unread under it. | |
| 50 | 196 | ||
| 58 | 204 | ||
| 59 | 205 | | Action | What it does | | |
| 60 | 206 | | --- | --- | | |
| 61 | − | | **Done** (✓) | Moves the item out of the inbox and into Done | | |
| 207 | + | | **Done** (✓) | Moves the thread out of the inbox and into Done, until something new happens on it | | |
| 62 | 208 | | **Mark as read** / **Mark as unread** | Changes whether it counts as unread | | |
| 63 | 209 | | **Save** | Keeps it under Saved, even after it is done | | |
| 64 | 210 | | **Snooze until** | Hides it for 3 hours, until tomorrow, or for a week, then brings it back | | |
| 68 | 214 | ## The full inbox | |
| 69 | 215 | ||
| 70 | 216 | **Open inbox**, at the foot of the panel, goes to | |
| 71 | − | [g1t.sh/inbox](https://g1t.sh/inbox). It has the same tabs, every item a page | |
| 72 | − | at a time, and two more views: | |
| 217 | + | [g1t.sh/inbox](https://g1t.sh/inbox). It has the same tabs, every thread a | |
| 218 | + | page at a time, and two more views: | |
| 73 | 219 | ||
| 74 | 220 | | View | Shows | | |
| 75 | 221 | | --- | --- | | |
| 76 | − | | **Saved** | Items you saved, done or not | | |
| 77 | − | | **Done** | Items you marked done. Select ↶ on one to move it back | | |
| 222 | + | | **Saved** | Threads you saved, done or not | | |
| 223 | + | | **Done** | Threads you marked done. Select ↶ on one to move it back | | |
| 224 | + | ||
| 225 | + | Beside the tabs, the **Reason** filter shows only threads told for one | |
| 226 | + | reason: select **Any reason** or one of the [reasons](#reasons). It is kept | |
| 227 | + | in the address as `?reason=`, such as | |
| 228 | + | [g1t.sh/inbox?reason=review_requested](https://g1t.sh/inbox?reason=review_requested), | |
| 229 | + | so you can bookmark it. | |
| 230 | + | ||
| 231 | + | Mission control shows a **Needs you** card with the newest unread threads | |
| 232 | + | waiting on you, then failures. It is hidden when there are none. | |
| 78 | 233 | ||
| 79 | − | Mission control shows a **Needs you** card with the newest unread items an | |
| 80 | − | agent is waiting on, then failures. It is hidden when there are none. | |
| 234 | + | ## From the API and agents | |
| 81 | 235 | ||
| 236 | + | Everything here is also in the REST API and the MCP server, for a personal | |
| 237 | + | access token or an OAuth sign-in. A workspace's token cannot use it, and | |
| 238 | + | neither can g1t's own agents: they act as `g1t`, which has no inbox. | |
| 239 | + | Reading needs the `notifications:read` scope, and changing anything | |
| 240 | + | `notifications:write`; the Agent [preset](/guides/authentication/#presets) | |
| 241 | + | has both. | |
| 242 | + | ||
| 243 | + | | Route | MCP action | What it does | | |
| 244 | + | | --- | --- | --- | | |
| 245 | + | | [`GET /notifications`](/reference/api/notifications/list-notifications/) | `list` | Your unread threads, latest first. Filter by `reason`, `severity`, `participating`, `since` and `before`; `all` adds read ones; `view` lists `saved` or `done`. | | |
| 246 | + | | [`PUT /notifications`](/reference/api/notifications/mark-notifications-read/) | `mark_all_read` | Mark everything read up to `last_read_at`. | | |
| 247 | + | | [`GET /notifications/threads/{id}`](/reference/api/notifications/get-notification-thread/) | `get` | One thread, its last 10 activities, and your subscription. | | |
| 248 | + | | [`PATCH /notifications/threads/{id}`](/reference/api/notifications/mark-thread-read/) | `mark_read` | Mark a thread read, or unread. | | |
| 249 | + | | [`DELETE /notifications/threads/{id}`](/reference/api/notifications/mark-thread-done/) | `done` | Mark a thread done. | | |
| 250 | + | | [`PUT /notifications/threads/{id}/saved`](/reference/api/notifications/save-thread/) | `save` | Save a thread; `DELETE` unsaves it. | | |
| 251 | + | | [`PUT /notifications/threads/{id}/snooze`](/reference/api/notifications/snooze-thread/) | `snooze` | Snooze a thread until `until`; `DELETE` brings it back. | | |
| 252 | + | | [`PUT /repos/{owner}/{name}/issues/{number}/subscription`](/reference/api/notifications/set-issue-subscription/) | `subscribe` | Subscribe to an issue or pull request, unsubscribe, or ignore it. `GET` reads it and `DELETE` unsubscribes. The same works at `/notifications/threads/{id}/subscription`. | | |
| 253 | + | | [`PUT /repos/{owner}/{name}/subscription`](/reference/api/notifications/set-repo-subscription/) | `watch` | Watch a repository at a `level`. `GET` reads it and `DELETE` goes back to the default. | | |
| 254 | + | | [`GET /user/subscriptions`](/reference/api/notifications/list-watched-repos/) | `watched` | The repositories you watch other than the default way. | | |
| 255 | + | ||
| 256 | + | `GET /repos/{owner}/{name}/notifications` and | |
| 257 | + | `PUT /repos/{owner}/{name}/notifications` list and mark one repository's | |
| 258 | + | threads. Every action of the `notifications` tool is in | |
| 259 | + | [MCP tools](/reference/mcp/#notifications). | |
| 260 | + | ||
| 261 | + | For example, to list the reviews waiting on you: | |
| 262 | + | ||
| 263 | + | ```sh | |
| 264 | + | curl "https://api.g1t.sh/notifications?reason=review_requested" \ | |
| 265 | + | -H "Authorization: Bearer $G1T_TOKEN" | |
| 266 | + | ``` | |
| 267 | + | ||
| 82 | 268 | ## How long items are kept | |
| 83 | 269 | ||
| 84 | − | Items you mark done are removed after 30 days. Any item is removed after 180 | |
| 85 | − | days. Saved items are kept until you unsave them. | |
| 270 | + | Threads you mark done are removed 30 days after you mark them. Any other | |
| 271 | + | thread is removed once nothing has happened on it for 180 days. Saved | |
| 272 | + | threads are kept until you unsave them. |
| 75 | 75 | | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had their role changed, or lost it. `data.username`, `data.role`, `data.previous_role`. See [access and roles](/guides/access-and-roles/). | | |
| 76 | 76 | | `repo.archived`, `repo.unarchived` | It was made read-only, or writable again. | | |
| 77 | 77 | | `repo.deleted`, `repo.restored`, `repo.purged` | It was deleted, restored within its 30 days, or removed for good. | | |
| 78 | − | | `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by`. For an issue g1t's agent filed while at work, `data.author` is g1t and `data.requested_by` is the person it was working for. | | |
| 78 | + | | `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by`; on assignment, `data.assignees` and the newly assigned `data.added`. For an issue g1t's agent filed while at work, `data.author` is g1t and `data.requested_by` is the person it was working for. | | |
| 79 | 79 | | `comment.created` | A comment or review on an issue or pull request. | | |
| 80 | 80 | | `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. `data.number`, `data.issue` and `data.author` (`id` and `username`); on merge, `data.commit`. For a change g1t made, `data.author` is g1t and `data.requested_by` is the person who asked for it; `actor` is still whoever caused the event. On a change by g1t, once g1t has worked it out, `data.confidence`: `level` (`high`, `medium` or `low`), `reasons`, `self_reported`, `uncertain_about`, `run_id` and `assessed_at`. See [how sure the agent is](/guides/working-with-g1t/#how-sure-the-agent-is). | | |
| 81 | + | | `pull.assigned` | People were assigned to a pull request. `data.assignees` is everyone assigned now, `data.added` those newly assigned. | | |
| 82 | + | | `pull.review_requested`, `pull.review_request_removed` | Reviewers were asked for a pull request, or no longer are. `data.reviewers` names them. | | |
| 83 | + | | `pull.stalled`, `pull.resumed` | g1t stopped seeing a pull request through until a person steps in, with why in `data.detail`; or it picked back up. | | |
| 81 | 84 | | `checks.completed` | A pull request's checks finished: every status on its head has reported and none is still pending, or the merge queue took it out. `data.number`, `data.commit`, and `data.status`, `passed` or `failed`. | | |
| 82 | 85 | | `review.completed` | g1t reviewed a pull request. `data.verdict`. | | |
| 83 | 86 | | `workflow.completed` | A [workflow](/guides/actions/) run finished. `data.workflow`, `data.conclusion`, `data.run_id`, `data.sha`, `data.pull`. | | |
| 87 | + | | `deployment.succeeded`, `deployment.failed` | A build of a [project](/guides/deployments/) finished, for production or a pull request's preview. `data.deployment_id`, `data.project`, `data.kind` (`production` or `preview`), `data.number` for a preview, `data.commit`, `data.path`, `data.error` on failure, and `data.recovered` when a success follows a failure. | | |
| 84 | 88 | | `queue.changed` | The merge queue gained, lost or settled an entry. | | |
| 85 | 89 | | `session.appended` | An agent's session grew. Busy: choose it only if you need it. | | |
| 86 | 90 | | `agent.asked` | An agent asked the agent on another pull request a question, or handed it work, while that one was not at work; g1t wakes it to answer. | |
| 176 | 176 | | [List repository events](/reference/api/repositories/list-events/) | 50 | `before`: the id of the last event you have | | |
| 177 | 177 | | [List workflow runs](/reference/api/actions/list-workflow-runs/) | `per_page`, at most 100 and 50 if not given | None | | |
| 178 | 178 | | [List webhook deliveries](/reference/api/webhooks/list-webhook-deliveries/) | 50 | None | | |
| 179 | + | | [List notifications](/reference/api/notifications/list-notifications/) | `per_page`, at most 100 and 30 if not given | `cursor`: the `next` of the page before | | |
| 179 | 180 | | [Read a session](/reference/api/sessions/read-session/) | None | `after`: the last `seq` you have | | |
| 180 | 181 | | [Get a job's log](/reference/api/actions/get-job-logs/) | 500 chunks | `after`: the last `seq` you have | | |
| 181 | 182 | ||
| 195 | 196 | | --- | --- | | |
| 196 | 197 | | [Accounts](/reference/api/accounts/whoami/) | Signing in from a tool, and who a token acts as. | | |
| 197 | 198 | | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. | | |
| 199 | + | | [Notifications](/reference/api/notifications/list-notifications/) | Your inbox: its threads, why you were told of each, marking them read, done, saved or snoozed, and what you subscribe to and watch. See [your inbox](/guides/inbox/). | | |
| 198 | 200 | | [Invites](/reference/api/invites/list-invites/) | Your invites while g1t is invite-only, and inviting people into a workspace by email. | | |
| 199 | 201 | | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. | | |
| 200 | 202 | | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. | |
| 3 | 3 | description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | The MCP server at `https://mcp.g1t.sh` exposes 13 tools, one per kind of | |
| 6 | + | The MCP server at `https://mcp.g1t.sh` exposes 14 tools, one per kind of | |
| 7 | 7 | thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`, | |
| 8 | − | `plan`, `memory`, `workflow`, `secret`, `webhook`, `access`, `workspace` | |
| 9 | − | and `account`. Each tool takes an `action` that says what to do. Every | |
| 8 | + | `plan`, `memory`, `workflow`, `secret`, `webhook`, `access`, `workspace`, | |
| 9 | + | `notifications` and `account`. Each tool takes an `action` that says what to do. Every | |
| 10 | 10 | action is the same operation as a route of the [REST API](/reference/api/), | |
| 11 | 11 | with the same inputs, permissions and results, so the two always agree. | |
| 12 | 12 | ||
| 51 | 51 | } | |
| 52 | 52 | ``` | |
| 53 | 53 | ||
| 54 | − | - `action` is required, except on two tools that have a default: | |
| 55 | − | `search` runs `code`, and `account` runs `whoami`, when it is left out. | |
| 54 | + | - `action` is required, except on three tools that have a default: | |
| 55 | + | `search` runs `code`, `notifications` runs `list`, and `account` runs | |
| 56 | + | `whoami`, when it is left out. | |
| 56 | 57 | - The input schema that `tools/list` returns is one flat object: `action`, | |
| 57 | 58 | then every field any of the tool's actions takes. The `action` field's | |
| 58 | 59 | description lists each action with the fields it needs, such as | |
| 450 | 451 | | [`get_model_routes`](/reference/api/integrations/get-model-routes/) | Which provider and model each kind of work goes to. Members only. | `workspace` | `workspace:read` | | |
| 451 | 452 | | [`set_model_routes`](/reference/api/integrations/set-model-routes/) | Replace them: each route has `task`, `connection_id` (null for g1t's models) and `model`. Owners only. | `workspace`, `routes` | `workspace:admin` | | |
| 452 | 453 | ||
| 454 | + | ## `notifications` | |
| 455 | + | ||
| 456 | + | Your [inbox](/guides/inbox/): one thread per issue, pull request, workflow | |
| 457 | + | on a branch or deployment, with why you were told (`reason`), and what you | |
| 458 | + | subscribe to and watch. `list` is the default action. It is your own: a | |
| 459 | + | personal access token or an OAuth sign-in can use it, a workspace's token | |
| 460 | + | cannot. Name an issue or pull request by a thread's `id`, or by `repo` and | |
| 461 | + | `number`. | |
| 462 | + | ||
| 463 | + | | Action | What it does | Required | Scope | | |
| 464 | + | | --- | --- | --- | --- | | |
| 465 | + | | [`list`](/reference/api/notifications/list-notifications/) | Your unread threads, latest first. With `all`, read ones too; `view` `saved` or `done` lists those instead. Filter by `reason`, `severity`, `participating`, `since`, `before` or `repo`. | None | `notifications:read` | | |
| 466 | + | | [`get`](/reference/api/notifications/get-notification-thread/) | One thread, its last 10 activities, and your subscription to it. | `id` | `notifications:read` | | |
| 467 | + | | [`mark_read`](/reference/api/notifications/mark-thread-read/) | Mark a thread read, or with `read` false, unread. | `id` | `notifications:write` | | |
| 468 | + | | [`mark_all_read`](/reference/api/notifications/mark-notifications-read/) | Mark every thread read, or one repository's with `repo`. Threads with activity after `last_read_at` (now, when left out) stay unread. | None | `notifications:write` | | |
| 469 | + | | [`done`](/reference/api/notifications/mark-thread-done/) | Move a thread to Done; new activity brings it back. With `done` false, move it back now. | `id` | `notifications:write` | | |
| 470 | + | | [`save`](/reference/api/notifications/save-thread/) | Save a thread so it is kept, or with `saved` false, unsave it. | `id` | `notifications:write` | | |
| 471 | + | | [`snooze`](/reference/api/notifications/snooze-thread/) | Hide a thread until `until` (RFC 3339). Leave `until` out to bring it back now. | `id` | `notifications:write` | | |
| 472 | + | | [`subscription`](/reference/api/notifications/get-thread-subscription/) | Whether you are subscribed to an issue or pull request, or ignore it, and why. | `id`, or `repo` and `number` | `notifications:read` | | |
| 473 | + | | [`subscribe`](/reference/api/notifications/set-thread-subscription/) | Subscribe (`subscribed`, true unless you say), unsubscribe (`subscribed` false), or ignore it (`ignored` true). | `id`, or `repo` and `number` | `notifications:write` | | |
| 474 | + | | [`unsubscribe`](/reference/api/notifications/delete-thread-subscription/) | Unsubscribe until you comment or are mentioned. What is asked of you directly still reaches you. | `id`, or `repo` and `number` | `notifications:write` | | |
| 475 | + | | [`watching`](/reference/api/notifications/get-repo-subscription/) | How you watch a repository: `participating`, `all`, `ignore` or `custom`, with `events`. | `repo` | `notifications:read` | | |
| 476 | + | | [`watch`](/reference/api/notifications/set-repo-subscription/) | Watch a repository at a `level`, with `events` (`issues`, `pulls`, `deployments`, `security`) for `custom`. | `repo` | `notifications:write` | | |
| 477 | + | | [`unwatch`](/reference/api/notifications/delete-repo-subscription/) | Go back to the default: only what you take part in or are mentioned in. | `repo` | `notifications:write` | | |
| 478 | + | | [`watched`](/reference/api/notifications/list-watched-repos/) | The repositories you watch other than the default way. | None | `notifications:read` | | |
| 479 | + | ||
| 453 | 480 | ## `account` | |
| 454 | 481 | ||
| 455 | 482 | Who the token acts as and its workspaces, your email addresses, your | |
| 489 | 516 | | Plan | The same reading actions, and `issue` `create` and `search` `ticket`. | | |
| 490 | 517 | | Catch up | The reading actions only. | | |
| 491 | 518 | ||
| 492 | − | No agent's token can use the `workspace`, `access`, `secret` or `webhook` | |
| 493 | − | tools, the controls of `workflow`, or `pull_request` `merge`, `agent` | |
| 519 | + | No agent's token can use the `workspace`, `access`, `secret`, `webhook` or | |
| 520 | + | `notifications` tools (g1t acts as `g1t`, which has no inbox), the controls of `workflow`, or `pull_request` `merge`, `agent` | |
| 494 | 521 | `assign` and `delegate`, `plan` `create` and `apply`, `issue` `import`, or | |
| 495 | 522 | any `repository` action that creates, changes, renames, archives, | |
| 496 | 523 | transfers, deletes, restores or purges a repository, or dismisses or |
Binary or large file; its contents are not shown.
| 13 | 13 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 14 | 14 | import { cn } from "../lib/cn"; | |
| 15 | 15 | import { isPending } from "../lib/pending"; | |
| 16 | − | import { INBOX_TABS, type InboxTab, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, whenShort } from "../lib/inbox"; | |
| 16 | + | import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox"; | |
| 17 | 17 | import type { InboxPanelData } from "../routes/inbox-json"; | |
| 18 | 18 | ||
| 19 | 19 | /** Where every inbox form posts (routes/inbox.tsx). */ | |
| 69 | 69 | const unread = isUnread(item); | |
| 70 | 70 | const leaving = isPending(fetcher, { id: item.id }) && ["done", "snooze", "undone"].includes(String(fetcher.formData?.get("intent"))); | |
| 71 | 71 | const [now, setNow] = useState(() => Date.now()); | |
| 72 | − | useEffect(() => setNow(Date.now()), [item.createdAt]); | |
| 72 | + | useEffect(() => setNow(Date.now()), [item.updatedAt]); | |
| 73 | 73 | if (leaving) return null; | |
| 74 | 74 | ||
| 75 | 75 | const submit = (intent: string, extra?: Record<string, string>) => | |
| 96 | 96 | {item.title} | |
| 97 | 97 | </Link> | |
| 98 | 98 | {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>} | |
| 99 | − | <div className="mt-2 flex items-center gap-2 text-xs text-faint"> | |
| 100 | − | <time dateTime={item.createdAt} suppressHydrationWarning> | |
| 101 | − | {whenShort(item.createdAt, now)} | |
| 99 | + | <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint"> | |
| 100 | + | <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap"> | |
| 101 | + | {whenShort(item.updatedAt, now)} | |
| 102 | 102 | </time> | |
| 103 | − | <Badge tone={TONE[item.severity]}>{SEVERITY_LABEL[item.severity]}</Badge> | |
| 103 | + | <Badge tone={TONE[item.severity]} className="shrink-0">{SEVERITY_LABEL[item.severity]}</Badge> | |
| 104 | + | {/* Why they were told, and how much has happened, said quietly. */} | |
| 105 | + | <span className="truncate">{REASON_LABEL[item.reason] ?? item.reason}</span> | |
| 106 | + | {updatesLabel(item.count) && ( | |
| 107 | + | <> | |
| 108 | + | <span aria-hidden="true">·</span> | |
| 109 | + | <span className="shrink-0 tabular-nums">{updatesLabel(item.count)}</span> | |
| 110 | + | </> | |
| 111 | + | )} | |
| 104 | 112 | {item.saved && ( | |
| 105 | 113 | <span className="inline-flex items-center gap-1 text-faint"> | |
| 106 | 114 | <Bookmark size={12} aria-hidden="true" /> |
| 1 | + | /** | |
| 2 | + | * Choosing what you hear of: subscribing to one issue or pull request, from | |
| 3 | + | * its sidebar, and watching a repository, from its header. Both post to | |
| 4 | + | * the repository's `notifications` route (routes/repo/notifications.ts); | |
| 5 | + | * the events service keeps the choice and the inbox follows it. | |
| 6 | + | */ | |
| 7 | + | import { Bell, BellOff, Check, ChevronDown, Eye, EyeOff } from "lucide-react"; | |
| 8 | + | import { useState } from "react"; | |
| 9 | + | import { useFetcher } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { WATCH_EVENTS, type ThreadSubscription, type WatchEvent, type WatchLevel } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | import { SubmitButton } from "./ui"; | |
| 14 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 15 | + | import { cn } from "../lib/cn"; | |
| 16 | + | import { WATCH_CHOICES, WATCH_EVENT_LABEL, subscriptionLine, watchLabel } from "../lib/inbox"; | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * The sidebar's Notifications box on an issue or pull request: one button | |
| 20 | + | * to subscribe or unsubscribe (or stop ignoring it), and a line saying | |
| 21 | + | * whether you hear of it, and why. While the choice is on its way, it | |
| 22 | + | * shows as made. | |
| 23 | + | */ | |
| 24 | + | export function SubscriptionBox({ | |
| 25 | + | action, | |
| 26 | + | number, | |
| 27 | + | kind, | |
| 28 | + | subscription, | |
| 29 | + | }: { | |
| 30 | + | /** The repository's notifications route: `/<owner>/<repo>/notifications`. */ | |
| 31 | + | action: string; | |
| 32 | + | number: number; | |
| 33 | + | kind: "issue" | "pull"; | |
| 34 | + | subscription: ThreadSubscription | null; | |
| 35 | + | }) { | |
| 36 | + | const fetcher = useFetcher<{ error?: string }>(); | |
| 37 | + | const asked = fetcher.formData?.get("intent"); | |
| 38 | + | // What it will be once the choice lands. | |
| 39 | + | const shown: ThreadSubscription | null = | |
| 40 | + | asked === "subscribe" | |
| 41 | + | ? { ...(subscription ?? EMPTY), subscribed: true, ignored: false, reason: subscription?.subscribed ? subscription.reason : "manual" } | |
| 42 | + | : asked === "unsubscribe" || asked === "default" | |
| 43 | + | ? { ...(subscription ?? EMPTY), subscribed: false, ignored: false, reason: null } | |
| 44 | + | : subscription; | |
| 45 | + | const intent = shown?.ignored ? "default" : shown?.subscribed ? "unsubscribe" : "subscribe"; | |
| 46 | + | const label = intent === "default" ? "Stop ignoring" : intent === "unsubscribe" ? "Unsubscribe" : "Subscribe"; | |
| 47 | + | return ( | |
| 48 | + | <section> | |
| 49 | + | <h3 className="text-sm font-medium">Notifications</h3> | |
| 50 | + | <fetcher.Form method="post" action={action} className="mt-2"> | |
| 51 | + | <input type="hidden" name="intent" value={intent} /> | |
| 52 | + | <input type="hidden" name="number" value={number} /> | |
| 53 | + | <SubmitButton | |
| 54 | + | variant="quiet" | |
| 55 | + | fetcher={fetcher} | |
| 56 | + | className="inline-flex w-full items-center justify-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-60" | |
| 57 | + | > | |
| 58 | + | {intent === "subscribe" ? <Bell size={14} /> : <BellOff size={14} />} | |
| 59 | + | {label} | |
| 60 | + | </SubmitButton> | |
| 61 | + | </fetcher.Form> | |
| 62 | + | <p className="mt-2 text-xs text-muted">{subscriptionLine(shown, kind)}</p> | |
| 63 | + | {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>} | |
| 64 | + | </section> | |
| 65 | + | ); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | const EMPTY: ThreadSubscription = { subscribed: false, ignored: false, reason: null, repo: null, number: null, updatedAt: null }; | |
| 69 | + | ||
| 70 | + | /** | |
| 71 | + | * The repository header's Watch menu: participating and @mentions (the | |
| 72 | + | * default), all activity, ignore, or custom, with the kinds a custom watch | |
| 73 | + | * follows checked under it. Each choice is saved as it is made. | |
| 74 | + | */ | |
| 75 | + | export function WatchMenu({ action, level, events }: { action: string; level: WatchLevel; events: WatchEvent[] }) { | |
| 76 | + | const fetcher = useFetcher<{ error?: string }>(); | |
| 77 | + | // The choice on its way, as it will be. | |
| 78 | + | const pendingLevel = fetcher.formData?.get("level"); | |
| 79 | + | const shownLevel = (typeof pendingLevel === "string" ? pendingLevel : level) as WatchLevel; | |
| 80 | + | const shownEvents = fetcher.formData ? (fetcher.formData.getAll("event").map(String) as WatchEvent[]) : events; | |
| 81 | + | // Custom opened to choose, before anything is chosen. | |
| 82 | + | const [choosing, setChoosing] = useState(false); | |
| 83 | + | const custom = shownLevel === "custom" || choosing; | |
| 84 | + | ||
| 85 | + | const save = (next: WatchLevel, kinds: WatchEvent[] = []) => { | |
| 86 | + | const form = new FormData(); | |
| 87 | + | form.set("intent", "watch"); | |
| 88 | + | form.set("level", next); | |
| 89 | + | for (const kind of kinds) form.append("event", kind); | |
| 90 | + | fetcher.submit(form, { method: "post", action }); | |
| 91 | + | }; | |
| 92 | + | const toggle = (kind: WatchEvent) => { | |
| 93 | + | const had = shownLevel === "custom" ? shownEvents : []; | |
| 94 | + | const next = had.includes(kind) ? had.filter((event) => event !== kind) : [...had, kind]; | |
| 95 | + | const ordered = WATCH_EVENTS.filter((event) => next.includes(event)); | |
| 96 | + | // Nothing left to follow is the default. | |
| 97 | + | save(ordered.length > 0 ? "custom" : "participating", ordered); | |
| 98 | + | }; | |
| 99 | + | ||
| 100 | + | return ( | |
| 101 | + | <DropdownMenu onOpenChange={(open) => !open && setChoosing(false)}> | |
| 102 | + | <DropdownMenuTrigger | |
| 103 | + | aria-label={`Watch: ${WATCH_CHOICES.find((choice) => choice.level === shownLevel)?.label ?? "Participating and @mentions"}`} | |
| 104 | + | className={cn( | |
| 105 | + | "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-2.5 text-[0.8125rem] text-fg/80 transition-colors outline-none hover:border-line-strong hover:bg-surface hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:border-line-strong", | |
| 106 | + | fetcher.state !== "idle" && "opacity-70", | |
| 107 | + | )} | |
| 108 | + | > | |
| 109 | + | {shownLevel === "ignore" ? <EyeOff size={14} /> : <Eye size={14} />} | |
| 110 | + | {/* The eye says it on a phone; the aria-label says it in full. */} | |
| 111 | + | <span className="hidden sm:inline">{watchLabel(shownLevel)}</span> | |
| 112 | + | <ChevronDown size={13} className="text-faint" /> | |
| 113 | + | </DropdownMenuTrigger> | |
| 114 | + | <DropdownMenuContent align="end" className="w-72"> | |
| 115 | + | <DropdownMenuLabel>Notifications from this repository</DropdownMenuLabel> | |
| 116 | + | {WATCH_CHOICES.map((choice) => { | |
| 117 | + | const selected = choice.level === "custom" ? custom : !choosing && shownLevel === choice.level; | |
| 118 | + | return ( | |
| 119 | + | <DropdownMenuItem | |
| 120 | + | key={choice.level} | |
| 121 | + | onSelect={(event) => { | |
| 122 | + | if (choice.level === "custom") { | |
| 123 | + | // Stays open, to choose what to follow. | |
| 124 | + | event.preventDefault(); | |
| 125 | + | setChoosing(true); | |
| 126 | + | return; | |
| 127 | + | } | |
| 128 | + | setChoosing(false); | |
| 129 | + | if (choice.level !== shownLevel) save(choice.level); | |
| 130 | + | }} | |
| 131 | + | className="items-start" | |
| 132 | + | > | |
| 133 | + | <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center"> | |
| 134 | + | {selected && <Check size={14} className="!text-accent" />} | |
| 135 | + | </span> | |
| 136 | + | <span className="min-w-0"> | |
| 137 | + | <span className="block text-sm text-fg">{choice.label}</span> | |
| 138 | + | <span className="block text-xs text-muted">{choice.detail}</span> | |
| 139 | + | </span> | |
| 140 | + | </DropdownMenuItem> | |
| 141 | + | ); | |
| 142 | + | })} | |
| 143 | + | {custom && ( | |
| 144 | + | <> | |
| 145 | + | <DropdownMenuSeparator /> | |
| 146 | + | <DropdownMenuLabel>Also tell me about</DropdownMenuLabel> | |
| 147 | + | {WATCH_EVENTS.map((kind) => { | |
| 148 | + | const on = shownLevel === "custom" && shownEvents.includes(kind); | |
| 149 | + | return ( | |
| 150 | + | <DropdownMenuItem | |
| 151 | + | key={kind} | |
| 152 | + | role="menuitemcheckbox" | |
| 153 | + | aria-checked={on} | |
| 154 | + | onSelect={(event) => { | |
| 155 | + | event.preventDefault(); | |
| 156 | + | toggle(kind); | |
| 157 | + | }} | |
| 158 | + | > | |
| 159 | + | <span | |
| 160 | + | aria-hidden="true" | |
| 161 | + | className={cn( | |
| 162 | + | "flex size-4 shrink-0 items-center justify-center rounded border", | |
| 163 | + | on ? "border-accent bg-accent text-bg" : "border-line-strong", | |
| 164 | + | )} | |
| 165 | + | > | |
| 166 | + | {on && <Check size={11} className="!text-bg" />} | |
| 167 | + | </span> | |
| 168 | + | {WATCH_EVENT_LABEL[kind]} | |
| 169 | + | </DropdownMenuItem> | |
| 170 | + | ); | |
| 171 | + | })} | |
| 172 | + | </> | |
| 173 | + | )} | |
| 174 | + | {fetcher.data?.error && <p className="px-2 py-1.5 text-xs text-danger">{fetcher.data.error}</p>} | |
| 175 | + | </DropdownMenuContent> | |
| 176 | + | </DropdownMenu> | |
| 177 | + | ); | |
| 178 | + | } |
| 6 | 6 | export type AccountSettingsPage = | |
| 7 | 7 | | "profile" | |
| 8 | 8 | | "emails" | |
| 9 | + | | "notifications" | |
| 9 | 10 | | "invites" | |
| 10 | 11 | | "keys" | |
| 11 | 12 | | "tokens" | |
| 21 | 22 | about: | |
| 22 | 23 | "Your primary address gets account mail and password resets. Any confirmed address signs you in and can reset your password, and commits that carry it are shown as yours.", | |
| 23 | 24 | }, | |
| 25 | + | notifications: { | |
| 26 | + | title: "Notifications", | |
| 27 | + | about: "What you are also emailed for, and how you watch repositories. Everything comes to your inbox either way.", | |
| 28 | + | }, | |
| 24 | 29 | invites: { title: "Invites", about: "Bring people to g1t, and see which invites were used." }, | |
| 25 | 30 | keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." }, | |
| 26 | 31 | tokens: { |
| 3 | 3 | ||
| 4 | 4 | import type { InboxItem } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { bellCount, emptyFor, inboxTab, inboxView, markFromForm, needsYou, severityOf, snoozeUntil, tabCount, whenShort } from "./inbox.ts"; | |
| 6 | + | import { | |
| 7 | + | EMAIL_REASONS, | |
| 8 | + | REASON_FILTERS, | |
| 9 | + | REASON_LABEL, | |
| 10 | + | bellCount, | |
| 11 | + | emailReasonsFromForm, | |
| 12 | + | emptyFor, | |
| 13 | + | inboxReason, | |
| 14 | + | inboxTab, | |
| 15 | + | inboxView, | |
| 16 | + | markFromForm, | |
| 17 | + | needsYou, | |
| 18 | + | severityOf, | |
| 19 | + | snoozeUntil, | |
| 20 | + | subscriptionFromForm, | |
| 21 | + | subscriptionLine, | |
| 22 | + | tabCount, | |
| 23 | + | updatesLabel, | |
| 24 | + | watchFromForm, | |
| 25 | + | watchLabel, | |
| 26 | + | whenShort, | |
| 27 | + | } from "./inbox.ts"; | |
| 7 | 28 | ||
| 8 | 29 | const NOW = Date.parse("2026-10-07T12:00:00.000Z"); | |
| 9 | 30 | ||
| 69 | 90 | test("what needs you puts a waiting agent before a failure, and leaves out the rest", () => { | |
| 70 | 91 | const item = (id: string, severity: InboxItem["severity"], createdAt: string, readAt: string | null = null): InboxItem => ({ | |
| 71 | 92 | id, | |
| 72 | − | reason: "x", | |
| 93 | + | reason: "agent", | |
| 73 | 94 | severity, | |
| 74 | 95 | title: id, | |
| 75 | 96 | body: "", | |
| 80 | 101 | url: "/acme/rocket/pull/1", | |
| 81 | 102 | actor: null, | |
| 82 | 103 | createdAt, | |
| 104 | + | updatedAt: createdAt, | |
| 105 | + | event: null, | |
| 106 | + | count: 1, | |
| 83 | 107 | readAt, | |
| 84 | 108 | doneAt: null, | |
| 85 | 109 | saved: false, | |
| 107 | 131 | assert.equal(emptyFor("needs").title, "Nothing needs you"); | |
| 108 | 132 | assert.equal(emptyFor("all", "saved").title, "Nothing saved"); | |
| 109 | 133 | }); | |
| 134 | + | ||
| 135 | + | test("every reason has words, a filter and an email choice", () => { | |
| 136 | + | const reasons = Object.keys(REASON_LABEL); | |
| 137 | + | assert.equal(reasons.length, 11); | |
| 138 | + | assert.deepEqual(REASON_FILTERS.slice(1).map((entry) => entry.reason), reasons); | |
| 139 | + | assert.equal(REASON_FILTERS[0].label, "Any reason"); | |
| 140 | + | assert.equal(REASON_FILTERS.find((entry) => entry.reason === "review_requested")?.label, "Review requested"); | |
| 141 | + | // Every reason someone can be told for can be emailed, but a security | |
| 142 | + | // alert, which nothing sends yet. | |
| 143 | + | assert.deepEqual( | |
| 144 | + | EMAIL_REASONS.map((entry) => entry.reason).sort(), | |
| 145 | + | reasons.filter((reason) => reason !== "security_alert").sort(), | |
| 146 | + | ); | |
| 147 | + | assert.equal(inboxReason("mention"), "mention"); | |
| 148 | + | assert.equal(inboxReason("gossip"), null); | |
| 149 | + | assert.equal(inboxReason(null), null); | |
| 150 | + | }); | |
| 151 | + | ||
| 152 | + | test("a thread says how much happened on it once more than one thing has", () => { | |
| 153 | + | assert.equal(updatesLabel(1), ""); | |
| 154 | + | assert.equal(updatesLabel(null), ""); | |
| 155 | + | assert.equal(updatesLabel(4), "4 updates"); | |
| 156 | + | }); | |
| 157 | + | ||
| 158 | + | test("watching is read from the menu, and a custom watch of nothing is the default", () => { | |
| 159 | + | const form = (fields: [string, string][]) => { | |
| 160 | + | const data = new FormData(); | |
| 161 | + | for (const [name, value] of fields) data.append(name, value); | |
| 162 | + | return data; | |
| 163 | + | }; | |
| 164 | + | assert.deepEqual(watchFromForm(form([["level", "all"]])), { level: "all", events: [] }); | |
| 165 | + | assert.deepEqual(watchFromForm(form([["level", "custom"], ["event", "deployments"], ["event", "pulls"], ["event", "releases"]])), { | |
| 166 | + | level: "custom", | |
| 167 | + | events: ["pulls", "deployments"], | |
| 168 | + | }); | |
| 169 | + | assert.deepEqual(watchFromForm(form([["level", "custom"]])), { level: "participating", events: [] }); | |
| 170 | + | assert.equal(watchFromForm(form([["level", "loud"]])), null); | |
| 171 | + | assert.equal(watchLabel("all"), "Watching"); | |
| 172 | + | assert.equal(watchLabel("ignore"), "Ignoring"); | |
| 173 | + | assert.equal(watchLabel("participating"), "Watch"); | |
| 174 | + | }); | |
| 175 | + | ||
| 176 | + | test("the subscribe button says whether, and why", () => { | |
| 177 | + | const sub = (changes: object) => ({ subscribed: true, ignored: false, reason: null, repo: null, number: 7, updatedAt: null, ...changes }); | |
| 178 | + | assert.equal(subscriptionLine(sub({ reason: "assign" }), "issue"), "You're subscribed because you were assigned."); | |
| 179 | + | assert.equal(subscriptionLine(sub({ reason: "author" }), "pull"), "You're subscribed because you opened this pull request, or asked g1t for it."); | |
| 180 | + | assert.match(subscriptionLine(sub({ subscribed: false }), "issue"), /still hear if you're mentioned/); | |
| 181 | + | assert.match(subscriptionLine(sub({ subscribed: false, ignored: true }), "issue"), /ignore this issue/); | |
| 182 | + | assert.match(subscriptionLine(null, "issue"), /^Subscribe/); | |
| 183 | + | const intent = (value: string) => { | |
| 184 | + | const data = new FormData(); | |
| 185 | + | data.set("intent", value); | |
| 186 | + | return subscriptionFromForm(data); | |
| 187 | + | }; | |
| 188 | + | assert.deepEqual(intent("subscribe"), { subscribed: true, ignored: false }); | |
| 189 | + | assert.deepEqual(intent("unsubscribe"), { subscribed: false, ignored: false }); | |
| 190 | + | assert.deepEqual(intent("ignore"), { subscribed: false, ignored: true }); | |
| 191 | + | assert.deepEqual(intent("default"), { subscribed: null, ignored: false }); | |
| 192 | + | assert.equal(intent("explode"), null); | |
| 193 | + | }); | |
| 194 | + | ||
| 195 | + | test("email reasons are read from the settings form in rank order", () => { | |
| 196 | + | const data = new FormData(); | |
| 197 | + | for (const value of ["mention", "nope", "agent", "mention"]) data.append("email", value); | |
| 198 | + | assert.deepEqual(emailReasonsFromForm(data), ["agent", "mention"]); | |
| 199 | + | assert.deepEqual(emailReasonsFromForm(new FormData()), []); | |
| 200 | + | }); |
| 1 | 1 | /** | |
| 2 | − | * The inbox's tabs, times and actions, shared by the panel in the top bar | |
| 3 | − | * (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx). The | |
| 4 | − | * events service keeps the items and decides who is told of what. | |
| 2 | + | * The inbox's tabs, reasons, times and actions, shared by the panel in the | |
| 3 | + | * top bar (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx), | |
| 4 | + | * and how people subscribe to threads and watch repositories | |
| 5 | + | * (components/notifications.tsx). The events service keeps the items and | |
| 6 | + | * decides who is told of what. | |
| 5 | 7 | */ | |
| 6 | − | import type { InboxCounts, InboxItem, InboxMarkArgs, InboxSeverity, InboxView } from "@g1t/contracts"; | |
| 8 | + | import type { | |
| 9 | + | InboxCounts, | |
| 10 | + | InboxItem, | |
| 11 | + | InboxMarkArgs, | |
| 12 | + | InboxReason, | |
| 13 | + | InboxSeverity, | |
| 14 | + | InboxView, | |
| 15 | + | ThreadSubscription, | |
| 16 | + | WatchEvent, | |
| 17 | + | WatchLevel, | |
| 18 | + | } from "@g1t/contracts"; | |
| 19 | + | ||
| 20 | + | // The contracts' lists, as types only, so this file runs under `node --test`. | |
| 21 | + | // Typed by the contract: a reason or kind added there must be added here. | |
| 22 | + | const INBOX_REASONS: InboxReason[] = [ | |
| 23 | + | "agent", | |
| 24 | + | "review_requested", | |
| 25 | + | "assign", | |
| 26 | + | "mention", | |
| 27 | + | "ci_activity", | |
| 28 | + | "security_alert", | |
| 29 | + | "state_change", | |
| 30 | + | "author", | |
| 31 | + | "comment", | |
| 32 | + | "manual", | |
| 33 | + | "subscribed", | |
| 34 | + | ]; | |
| 35 | + | const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"]; | |
| 7 | 36 | ||
| 8 | 37 | export type InboxTab = "all" | "needs" | "error" | "success" | "info"; | |
| 9 | 38 | ||
| 10 | 39 | /** The tabs, in order, and the severity each shows. */ | |
| 11 | 40 | export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [ | |
| 12 | 41 | { tab: "all", label: "All", severity: null }, | |
| 13 | − | // Warnings are what an agent is waiting on a person for. | |
| 42 | + | // Warnings are what is waiting on a person: an agent, or a review asked of them. | |
| 14 | 43 | { tab: "needs", label: "Needs you", severity: "warning" }, | |
| 15 | 44 | { tab: "error", label: "Errors", severity: "error" }, | |
| 16 | 45 | { tab: "success", label: "Success", severity: "success" }, | |
| 56 | 85 | if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." }; | |
| 57 | 86 | switch (tab) { | |
| 58 | 87 | case "needs": | |
| 59 | − | return { title: "Nothing needs you", detail: "When an agent is waiting on you, it shows up here first." }; | |
| 88 | + | return { title: "Nothing needs you", detail: "When an agent is waiting on you, or someone asks for your review, it shows up here first." }; | |
| 60 | 89 | case "error": | |
| 61 | 90 | return { title: "No failures", detail: "Failed checks and workflows on your work show up here." }; | |
| 62 | 91 | case "success": | |
| 136 | 165 | const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1); | |
| 137 | 166 | const needs = items | |
| 138 | 167 | .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error")) | |
| 139 | − | .sort((a, b) => rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt)); | |
| 168 | + | .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt)); | |
| 140 | 169 | return { items: needs.slice(0, max), total: needs.length }; | |
| 141 | 170 | } | |
| 142 | 171 | ||
| 144 | 173 | export function isUnread(item: Pick<InboxItem, "readAt">): boolean { | |
| 145 | 174 | return item.readAt == null; | |
| 146 | 175 | } | |
| 176 | + | ||
| 177 | + | /** Why someone was told, in a few quiet words on the card. */ | |
| 178 | + | export const REASON_LABEL: Record<InboxReason, string> = { | |
| 179 | + | agent: "agent waiting", | |
| 180 | + | review_requested: "review requested", | |
| 181 | + | assign: "assigned", | |
| 182 | + | mention: "mentioned", | |
| 183 | + | ci_activity: "CI activity", | |
| 184 | + | security_alert: "security alert", | |
| 185 | + | state_change: "state changed", | |
| 186 | + | author: "your work", | |
| 187 | + | comment: "commented", | |
| 188 | + | manual: "subscribed", | |
| 189 | + | subscribed: "watching", | |
| 190 | + | }; | |
| 191 | + | ||
| 192 | + | /** The reason filter on /inbox, from the address: null for any reason. */ | |
| 193 | + | export function inboxReason(value: string | null | undefined): InboxReason | null { | |
| 194 | + | return INBOX_REASONS.find((reason) => reason === value) ?? null; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /** The reason filter's choices, in the order reasons rank. */ | |
| 198 | + | export const REASON_FILTERS: { reason: InboxReason | null; label: string }[] = [ | |
| 199 | + | { reason: null, label: "Any reason" }, | |
| 200 | + | ...INBOX_REASONS.map((reason) => ({ reason, label: REASON_LABEL[reason].replace(/^./, (c) => c.toUpperCase()) })), | |
| 201 | + | ]; | |
| 202 | + | ||
| 203 | + | /** How much has happened on a thread, when more than one thing has: "3 updates". */ | |
| 204 | + | export function updatesLabel(count: number | null | undefined): string { | |
| 205 | + | return count && count > 1 ? `${count} updates` : ""; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /** The ways to watch a repository, in the order the menu shows them. */ | |
| 209 | + | export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [ | |
| 210 | + | { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." }, | |
| 211 | + | { level: "all", label: "All activity", detail: "Every issue and pull request, and every deployment." }, | |
| 212 | + | { level: "ignore", label: "Ignore", detail: "Nothing at all, not even a mention." }, | |
| 213 | + | { level: "custom", label: "Custom", detail: "What you take part in, and the kinds you choose." }, | |
| 214 | + | ]; | |
| 215 | + | ||
| 216 | + | /** The kinds a custom watch can follow, with their names for people. */ | |
| 217 | + | export const WATCH_EVENT_LABEL: Record<WatchEvent, string> = { | |
| 218 | + | issues: "Issues", | |
| 219 | + | pulls: "Pull requests", | |
| 220 | + | deployments: "Deployments", | |
| 221 | + | security: "Security alerts", | |
| 222 | + | }; | |
| 223 | + | ||
| 224 | + | /** The Watch button's words for how someone watches. */ | |
| 225 | + | export function watchLabel(level: WatchLevel | null | undefined): string { | |
| 226 | + | switch (level) { | |
| 227 | + | case "all": | |
| 228 | + | return "Watching"; | |
| 229 | + | case "custom": | |
| 230 | + | return "Watching some"; | |
| 231 | + | case "ignore": | |
| 232 | + | return "Ignoring"; | |
| 233 | + | default: | |
| 234 | + | return "Watch"; | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | /** | |
| 239 | + | * A posted watch form as the level and kinds it asks for: `level`, and for | |
| 240 | + | * `custom` the `event` fields checked. A custom watch with nothing checked | |
| 241 | + | * is participating. Null when the level is not one. | |
| 242 | + | */ | |
| 243 | + | export function watchFromForm(form: FormData): { level: WatchLevel; events: WatchEvent[] } | null { | |
| 244 | + | const level = String(form.get("level") ?? ""); | |
| 245 | + | if (!["participating", "all", "ignore", "custom"].includes(level)) return null; | |
| 246 | + | if (level !== "custom") return { level: level as WatchLevel, events: [] }; | |
| 247 | + | const checked = form.getAll("event").map(String); | |
| 248 | + | const events = WATCH_EVENTS.filter((event) => checked.includes(event)); | |
| 249 | + | return events.length > 0 ? { level: "custom", events } : { level: "participating", events: [] }; | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /** The line under the subscribe button: whether, and why. */ | |
| 253 | + | export function subscriptionLine(subscription: ThreadSubscription | null | undefined, kind: "issue" | "pull"): string { | |
| 254 | + | const thing = kind === "pull" ? "pull request" : "issue"; | |
| 255 | + | if (!subscription) return `Subscribe to hear of what happens on this ${thing}.`; | |
| 256 | + | if (subscription.ignored) return `You ignore this ${thing}: you hear of nothing on it, not even a mention.`; | |
| 257 | + | if (!subscription.subscribed) return "You're not subscribed. You'll still hear if you're mentioned or asked to review."; | |
| 258 | + | switch (subscription.reason) { | |
| 259 | + | case "author": | |
| 260 | + | return `You're subscribed because you opened this ${thing}, or asked g1t for it.`; | |
| 261 | + | case "assign": | |
| 262 | + | return "You're subscribed because you were assigned."; | |
| 263 | + | case "review_requested": | |
| 264 | + | return "You're subscribed because you were asked to review."; | |
| 265 | + | case "comment": | |
| 266 | + | return "You're subscribed because you commented."; | |
| 267 | + | case "mention": | |
| 268 | + | return "You're subscribed because you were mentioned."; | |
| 269 | + | default: | |
| 270 | + | return `You're subscribed to this ${thing}.`; | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | /** What a posted subscription form asks for: subscribe, unsubscribe, ignore, or the default. */ | |
| 275 | + | export function subscriptionFromForm(form: FormData): { subscribed: boolean | null; ignored: boolean } | null { | |
| 276 | + | switch (String(form.get("intent") ?? "")) { | |
| 277 | + | case "subscribe": | |
| 278 | + | return { subscribed: true, ignored: false }; | |
| 279 | + | case "unsubscribe": | |
| 280 | + | return { subscribed: false, ignored: false }; | |
| 281 | + | case "ignore": | |
| 282 | + | return { subscribed: false, ignored: true }; | |
| 283 | + | case "default": | |
| 284 | + | return { subscribed: null, ignored: false }; | |
| 285 | + | default: | |
| 286 | + | return null; | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /** The reasons someone can be emailed for, in the settings' order, with what each is. */ | |
| 291 | + | export const EMAIL_REASONS: { reason: InboxReason; label: string; detail: string }[] = [ | |
| 292 | + | { reason: "agent", label: "An agent is waiting on you", detail: "It asked you something, or stopped until you step in." }, | |
| 293 | + | { reason: "review_requested", label: "You're asked to review", detail: "Someone asked for your review of a pull request." }, | |
| 294 | + | { reason: "mention", label: "You're mentioned", detail: "Someone wrote your @username in a comment." }, | |
| 295 | + | { reason: "assign", label: "You're assigned", detail: "Someone assigned you an issue or a pull request." }, | |
| 296 | + | { reason: "ci_activity", label: "Your work's checks and deployments", detail: "Checks, a workflow or a deployment failed on your work." }, | |
| 297 | + | { reason: "state_change", label: "What you follow closes or merges", detail: "An issue or pull request you're subscribed to was closed, reopened or merged." }, | |
| 298 | + | { reason: "author", label: "News on your work", detail: "An approval, changes asked for, or g1t finishing what you asked for." }, | |
| 299 | + | { reason: "comment", label: "Conversations you're in", detail: "Comments on issues and pull requests you commented on." }, | |
| 300 | + | { reason: "manual", label: "Threads you subscribed to", detail: "Activity on issues and pull requests you subscribed to by hand." }, | |
| 301 | + | { reason: "subscribed", label: "Repositories you watch", detail: "Activity in repositories you watch." }, | |
| 302 | + | ]; | |
| 303 | + | ||
| 304 | + | /** The reasons checked on the settings form, each once, in rank order. */ | |
| 305 | + | export function emailReasonsFromForm(form: FormData): InboxReason[] { | |
| 306 | + | const checked = form.getAll("email").map(String); | |
| 307 | + | return INBOX_REASONS.filter((reason) => checked.includes(reason)); | |
| 308 | + | } |
| 27 | 27 | index("routes/settings/index.tsx"), | |
| 28 | 28 | route("profile", "routes/settings/profile.tsx"), | |
| 29 | 29 | route("emails", "routes/settings/emails.tsx"), | |
| 30 | + | route("notifications", "routes/settings/notifications.tsx"), | |
| 30 | 31 | route("invites", "routes/settings/invites.tsx"), | |
| 31 | 32 | route("keys", "routes/settings/keys.tsx"), | |
| 32 | 33 | route("tokens", "routes/settings/tokens.tsx"), | |
| 93 | 94 | route(":owner/:repo/audit.json", "routes/repo/audit-live.ts"), | |
| 94 | 95 | // An invitation to a repository, answered by someone who cannot see it yet. | |
| 95 | 96 | route(":owner/:repo/invitations", "routes/repo/invitations.tsx"), | |
| 97 | + | // Subscribing to its issues and pull requests, and watching it. | |
| 98 | + | route(":owner/:repo/notifications", "routes/repo/notifications.ts"), | |
| 96 | 99 | // A starter CI workflow, opened as a pull request (components/add-ci.tsx). | |
| 97 | 100 | route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"), | |
| 98 | 101 | // A screenshot of a project's production, for its overview. |
| 8 | 8 | ||
| 9 | 9 | import type { Route } from "./+types/inbox"; | |
| 10 | 10 | import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox"; | |
| 11 | + | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 11 | 12 | import { cn } from "../lib/cn"; | |
| 12 | − | import { inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox"; | |
| 13 | + | import { REASON_FILTERS, inboxReason, inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox"; | |
| 13 | 14 | import { page } from "../lib/meta"; | |
| 14 | 15 | import { inbox } from "../lib/services.server"; | |
| 15 | 16 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 25 | 26 | const url = new URL(request.url); | |
| 26 | 27 | const tab = inboxTab(url.searchParams.get("tab")); | |
| 27 | 28 | const view = inboxView(url.searchParams.get("view")); | |
| 29 | + | const reason = inboxReason(url.searchParams.get("reason")); | |
| 28 | 30 | const before = url.searchParams.get("before"); | |
| 29 | 31 | const [list, counts] = await Promise.all([ | |
| 30 | − | inbox.list(user, { view, severity: severityOf(tab), before, limit: PAGE_ITEMS }).catch(() => null), | |
| 32 | + | inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }).catch(() => null), | |
| 31 | 33 | inbox.counts(user.username).catch(() => null), | |
| 32 | 34 | ]); | |
| 33 | − | return { tab, view, before, items: list?.items ?? null, next: list?.next ?? null, counts }; | |
| 35 | + | return { tab, view, reason, before, items: list?.items ?? null, next: list?.next ?? null, counts }; | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | export async function action({ request, context }: Route.ActionArgs) { | |
| 53 | 55 | ] as const; | |
| 54 | 56 | ||
| 55 | 57 | export default function InboxPage({ loaderData }: Route.ComponentProps) { | |
| 56 | − | const { tab, view, before, items, next, counts } = loaderData; | |
| 58 | + | const { tab, view, reason, before, items, next, counts } = loaderData; | |
| 57 | 59 | const navigate = useNavigate(); | |
| 58 | 60 | const address = (changes: Record<string, string | null>) => { | |
| 59 | 61 | const params = new URLSearchParams(); | |
| 60 | − | const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, ...changes }; | |
| 62 | + | const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, reason, ...changes }; | |
| 61 | 63 | for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value); | |
| 62 | 64 | const query = params.toString(); | |
| 63 | 65 | return query ? `/inbox?${query}` : "/inbox"; | |
| 68 | 70 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 69 | 71 | <div> | |
| 70 | 72 | <h1 className="text-2xl font-semibold tracking-tight">Inbox</h1> | |
| 71 | − | <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What an agent is waiting on comes first.</p> | |
| 73 | + | <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What is waiting on you comes first.</p> | |
| 72 | 74 | </div> | |
| 73 | 75 | {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />} | |
| 74 | 76 | </div> | |
| 90 | 92 | ))} | |
| 91 | 93 | </nav> | |
| 92 | 94 | ||
| 93 | − | <div className="mt-4"> | |
| 94 | − | <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} /> | |
| 95 | + | <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 96 | + | <div className="min-w-0 grow"> | |
| 97 | + | <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} /> | |
| 98 | + | </div> | |
| 99 | + | {/* Why you were told: a review asked of you, a mention, what you watch. */} | |
| 100 | + | <Select value={reason ?? "any"} onValueChange={(value) => navigate(address({ reason: value === "any" ? null : value, before: null }))}> | |
| 101 | + | <SelectTrigger size="sm" aria-label="Reason" className="sm:w-44"> | |
| 102 | + | <SelectValue /> | |
| 103 | + | </SelectTrigger> | |
| 104 | + | <SelectContent align="end"> | |
| 105 | + | {REASON_FILTERS.map((entry) => ( | |
| 106 | + | <SelectItem key={entry.reason ?? "any"} value={entry.reason ?? "any"}> | |
| 107 | + | {entry.label} | |
| 108 | + | </SelectItem> | |
| 109 | + | ))} | |
| 110 | + | </SelectContent> | |
| 111 | + | </Select> | |
| 95 | 112 | </div> | |
| 96 | 113 | ||
| 97 | 114 | <div className="mt-4"> |
| 37 | 37 | import { notFound } from "../../lib/not-found.server"; | |
| 38 | 38 | import { openedBy } from "../../lib/opened-by"; | |
| 39 | 39 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 40 | − | import { identity, integrations, work } from "../../lib/services.server"; | |
| 40 | + | import { identity, inbox, integrations, work } from "../../lib/services.server"; | |
| 41 | 41 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 42 | − | import { accessTo, refusal } from "../../lib/access.server"; | |
| 42 | + | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 43 | + | import { SubscriptionBox } from "../../components/notifications"; | |
| 43 | 44 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 44 | 45 | ||
| 45 | 46 | ||
| 66 | 67 | // At once: only the plan's note waits for the viewer's role. Putting an | |
| 67 | 68 | // agent on it needs Write: Read cannot spend compute. | |
| 68 | 69 | const access = accessTo(context, params); | |
| 69 | − | const [{ can }, found, labels, agentsEnabled, members, links, computeNote] = await Promise.all([ | |
| 70 | + | const [{ can }, found, labels, agentsEnabled, members, links, computeNote, subscription] = await Promise.all([ | |
| 70 | 71 | access, | |
| 71 | 72 | work.getIssue(path, number, viewer), | |
| 72 | 73 | work.listLabels(path, viewer), | |
| 77 | 78 | integrations.links(path, number).catch(() => []), | |
| 78 | 79 | // Before a member assigns g1t: whether the workspace's plan lets it start. | |
| 79 | 80 | access.then(({ can }) => (can.run ? computeNoteFor(params.owner, "agent") : null)), | |
| 81 | + | // Whether the viewer hears of it, for the sidebar's Notifications. | |
| 82 | + | viewer | |
| 83 | + | ? repoFor(context, params).then((repo) => | |
| 84 | + | repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null, | |
| 85 | + | ) | |
| 86 | + | : null, | |
| 80 | 87 | ]); | |
| 81 | 88 | if (!found.ok) { | |
| 82 | 89 | // Issues and pull requests share numbers; this one may be a pull request. | |
| 92 | 99 | agentsEnabled, | |
| 93 | 100 | computeNote, | |
| 94 | 101 | links, | |
| 102 | + | subscription, | |
| 95 | 103 | members: members?.ok ? members.value.map((member) => member.username) : [], | |
| 96 | 104 | // The author can close and reopen their own issue, and whoever g1t's | |
| 97 | 105 | // agent filed one for, that one; Triage and up, anyone's. | |
| 638 | 646 | </details> | |
| 639 | 647 | )} | |
| 640 | 648 | ||
| 649 | + | {viewer && ( | |
| 650 | + | <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} /> | |
| 651 | + | )} | |
| 641 | 652 | </aside> | |
| 642 | 653 | </div> | |
| 643 | 654 | ); |
| 1 | 1 | import { Box, Lock } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 2 | 3 | import { Link, NavLink, Outlet, type ShouldRevalidateFunctionArgs, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 4 | ||
| 4 | 5 | import type { Project } from "@g1t/contracts"; | |
| 8 | 9 | import { page } from "../../lib/meta"; | |
| 9 | 10 | import { type Tab as PageTab, tabsFor } from "../../lib/project-nav"; | |
| 10 | 11 | import { Pill } from "../../components/ui"; | |
| 12 | + | import { WatchMenu } from "../../components/notifications"; | |
| 11 | 13 | import { ArchivedBanner } from "../../components/repo-lifecycle"; | |
| 12 | 14 | import { WelcomeBanner } from "../../components/welcome"; | |
| 13 | 15 | import { clearWelcome, welcomes } from "../../lib/invites"; | |
| 14 | 16 | import { notFound } from "../../lib/not-found.server"; | |
| 15 | 17 | import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server"; | |
| 16 | 18 | import { accessFor, countsFor, repoFor } from "../../lib/access.server"; | |
| 17 | − | import { projects } from "../../lib/services.server"; | |
| 19 | + | import { inbox, projects } from "../../lib/services.server"; | |
| 18 | 20 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 19 | 21 | ||
| 20 | 22 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 24 | 26 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 25 | 27 | const viewer = getViewer(context); | |
| 26 | 28 | const path = { namespace: params.owner, name: params.repo }; | |
| 27 | − | const [repo, counts, found] = await Promise.all([ | |
| 29 | + | const [repo, counts, found, watching] = await Promise.all([ | |
| 28 | 30 | repoFor(context, params), | |
| 29 | 31 | countsFor(context, params), | |
| 30 | 32 | projects.get(params.owner, params.repo, viewer), | |
| 33 | + | // How the person watches it, for the header's Watch menu, as soon as | |
| 34 | + | // the repository is known. | |
| 35 | + | viewer | |
| 36 | + | ? repoFor(context, params).then((found) => (found.ok ? inbox.watching(viewer.username, found.value.id).catch(() => null) : null)) | |
| 37 | + | : null, | |
| 31 | 38 | ]); | |
| 32 | 39 | if (!repo.ok && !found.ok) { | |
| 33 | 40 | // Under a workspace's old name, after a rename: the project is at the new one. | |
| 56 | 63 | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 57 | 64 | access, | |
| 58 | 65 | member: access.insider, | |
| 66 | + | watching, | |
| 59 | 67 | }, { headers }); | |
| 60 | 68 | } | |
| 61 | 69 | ||
| 75 | 83 | } | |
| 76 | 84 | ||
| 77 | 85 | /** A repository's topics, each a way into Explore. */ | |
| 78 | − | function Header({ project, isPrivate, archived, namespace, name, description, large }: { | |
| 86 | + | function Header({ project, isPrivate, archived, namespace, name, description, large, actions }: { | |
| 79 | 87 | project: Project | null; | |
| 80 | 88 | isPrivate: boolean; | |
| 81 | 89 | namespace: string; | |
| 83 | 91 | description: string | null; | |
| 84 | 92 | archived?: boolean; | |
| 85 | 93 | large?: boolean; | |
| 94 | + | /** At the end of the row: the Watch menu. */ | |
| 95 | + | actions?: ReactNode; | |
| 86 | 96 | }) { | |
| 87 | 97 | const base = `/${namespace}/${name}`; | |
| 88 | 98 | return ( | |
| 103 | 113 | </h1> | |
| 104 | 114 | <Pill>{isPrivate ? "private" : "public"}</Pill> | |
| 105 | 115 | {archived && <Pill>archived</Pill>} | |
| 106 | − | {description && <p className="min-w-0 truncate text-sm text-muted">{description}</p>} | |
| 116 | + | {/* On a phone, on its own line under the name and the Watch menu. */} | |
| 117 | + | {description && <p className="order-last min-w-0 basis-full truncate text-sm text-muted sm:order-none sm:basis-0 sm:flex-1">{description}</p>} | |
| 118 | + | {actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>} | |
| 107 | 119 | </div> | |
| 108 | 120 | ); | |
| 109 | 121 | } | |
| 149 | 161 | } | |
| 150 | 162 | ||
| 151 | 163 | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 152 | − | const { repo, project, member, access, welcome } = loaderData; | |
| 164 | + | const { repo, project, member, access, welcome, watching } = loaderData; | |
| 153 | 165 | const base = `/${repo.namespace}/${repo.name}`; | |
| 154 | 166 | // The project's own description, else the repository's as it is now. | |
| 155 | 167 | const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description; | |
| 171 | 183 | name={repo.name} | |
| 172 | 184 | // The files' own About says it there, as the one place. | |
| 173 | 185 | description={filesPage ? null : description} | |
| 186 | + | actions={ | |
| 187 | + | watching ? ( | |
| 188 | + | <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} /> | |
| 189 | + | ) : null | |
| 190 | + | } | |
| 174 | 191 | /> | |
| 175 | 192 | {/* On the files' pages, About shows them. */} | |
| 176 | 193 | {!filesPage && <Topics topics={repo.topics} />} |
| 1 | + | /** | |
| 2 | + | * What a person hears of from one repository: subscribing to one of its | |
| 3 | + | * issues or pull requests (`intent` subscribe, unsubscribe, ignore or | |
| 4 | + | * default, with its `number`), and watching it (`intent` watch, with | |
| 5 | + | * `level` and, for custom, the `event` kinds). Posted by the sidebar's | |
| 6 | + | * Notifications box and the header's Watch menu (components/notifications.tsx). | |
| 7 | + | * Only someone who can read the repository may choose. | |
| 8 | + | */ | |
| 9 | + | import { data } from "react-router"; | |
| 10 | + | ||
| 11 | + | import type { Route } from "./+types/notifications"; | |
| 12 | + | import { subscriptionFromForm, watchFromForm } from "../../lib/inbox"; | |
| 13 | + | import { inbox, repos } from "../../lib/services.server"; | |
| 14 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 15 | + | ||
| 16 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 17 | + | assertSameOrigin(request); | |
| 18 | + | const user = requireUser(context, request); | |
| 19 | + | const form = await request.formData(); | |
| 20 | + | const repo = await repos.get({ namespace: params.owner, name: params.repo }, user); | |
| 21 | + | if (!repo.ok) return data({ error: "That repository could not be found." }, { status: 404 }); | |
| 22 | + | const path = `${repo.value.namespace}/${repo.value.name}`; | |
| 23 | + | try { | |
| 24 | + | if (form.get("intent") === "watch") { | |
| 25 | + | const watch = watchFromForm(form); | |
| 26 | + | if (!watch) return data({ error: "Choose how to watch it." }, { status: 400 }); | |
| 27 | + | // Participating is the default: no choice to keep. | |
| 28 | + | const level = watch.level === "participating" ? null : watch.level; | |
| 29 | + | return { error: null, watching: await inbox.watch(user.username, repo.value.id, path, level, watch.events) }; | |
| 30 | + | } | |
| 31 | + | const choice = subscriptionFromForm(form); | |
| 32 | + | const number = Number(form.get("number")); | |
| 33 | + | if (!choice || !Number.isInteger(number) || number < 1) return data({ error: "Nothing to do." }, { status: 400 }); | |
| 34 | + | const subscription = await inbox.subscribe(user, { repoId: repo.value.id, number }, choice.subscribed, choice.ignored); | |
| 35 | + | if (!subscription) return data({ error: "That issue or pull request could not be found." }, { status: 404 }); | |
| 36 | + | return { error: null, subscription }; | |
| 37 | + | } catch { | |
| 38 | + | return data({ error: "That did not save. Try again in a moment." }, { status: 503 }); | |
| 39 | + | } | |
| 40 | + | } |
| 75 | 75 | import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up"; | |
| 76 | 76 | import { notFound } from "../../lib/not-found.server"; | |
| 77 | 77 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 78 | − | import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server"; | |
| 78 | + | import { actions, deployments, identity, inbox, projects, repos, work } from "../../lib/services.server"; | |
| 79 | 79 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 80 | − | import { accessTo, refusal } from "../../lib/access.server"; | |
| 80 | + | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 81 | + | import { SubscriptionBox } from "../../components/notifications"; | |
| 81 | 82 | import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh"; | |
| 82 | 83 | ||
| 83 | 84 | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| 123 | 124 | const deps = projects.dependencies(params.owner, params.repo, viewer); | |
| 124 | 125 | // Awaited below, unless the pull request is missing first. | |
| 125 | 126 | deps.catch(() => null); | |
| 126 | − | const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed] = await Promise.all([ | |
| 127 | + | const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed, subscription] = await Promise.all([ | |
| 127 | 128 | access, | |
| 128 | 129 | pullFound, | |
| 129 | 130 | repos.get(path, viewer), | |
| 136 | 137 | tab === "session" ? work.readSession(path, number, viewer) : null, | |
| 137 | 138 | // Its preview, for people with a role here: beside the rest, not after. | |
| 138 | 139 | access.then(({ insider }) => (insider ? deployments.list(ref, viewer).catch(() => null) : null)), | |
| 140 | + | // Whether the viewer hears of it, for the sidebar's Notifications. | |
| 141 | + | viewer | |
| 142 | + | ? repoFor(context, params).then((found) => | |
| 143 | + | found.ok ? inbox.subscription(viewer, { repoId: found.value.id, number }).catch(() => null) : null, | |
| 144 | + | ) | |
| 145 | + | : null, | |
| 139 | 146 | ]); | |
| 140 | 147 | if (!found.ok) { | |
| 141 | 148 | // Issues and pull requests share numbers; this one may be an issue. | |
| 197 | 204 | canUpdate: pull.fork ? viewer?.id === workOwner(pull).id : can.push, | |
| 198 | 205 | agentsEnabled, | |
| 199 | 206 | computeNote, | |
| 207 | + | subscription, | |
| 200 | 208 | members: members?.ok ? members.value.map((person) => person.username) : [], | |
| 201 | 209 | requireUpToDate: settings.ok && settings.value.requireUpToDate, | |
| 202 | 210 | mergeQueue: settings.ok && settings.value.mergeQueue, | |
| 1343 | 1351 | {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"} | |
| 1344 | 1352 | </p> | |
| 1345 | 1353 | </section> | |
| 1354 | + | {loaderData.viewer && ( | |
| 1355 | + | <SubscriptionBox action={`${base}/notifications`} number={pull.number} kind="pull" subscription={loaderData.subscription} /> | |
| 1356 | + | )} | |
| 1346 | 1357 | </aside> | |
| 1347 | 1358 | </div> | |
| 1348 | 1359 | </div> |
| 1 | + | /** | |
| 2 | + | * Settings → Notifications: what you are also emailed for, how you watch | |
| 3 | + | * repositories you create, and the repositories you watch other than the | |
| 4 | + | * default way. The events service keeps all three (`inbox_settings`, | |
| 5 | + | * `inbox_watched`); the inbox itself is at /inbox. | |
| 6 | + | */ | |
| 7 | + | import { Eye, EyeOff } from "lucide-react"; | |
| 8 | + | import { Form, Link, data, useNavigation } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { WatchLevel } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import type { Route } from "./+types/notifications"; | |
| 13 | + | import { ErrorText, SubmitButton } from "../../components/ui"; | |
| 14 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 15 | + | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 16 | + | import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox"; | |
| 17 | + | import { page } from "../../lib/meta"; | |
| 18 | + | import { inbox } from "../../lib/services.server"; | |
| 19 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 20 | + | ||
| 21 | + | export function meta(args: Route.MetaArgs) { | |
| 22 | + | return page(args, { title: "Notifications · Settings · g1t" }); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 26 | + | const user = requireUser(context, request); | |
| 27 | + | const [settings, watched] = await Promise.all([ | |
| 28 | + | inbox.settings(user.username).catch(() => null), | |
| 29 | + | inbox.watched(user.username).catch(() => null), | |
| 30 | + | ]); | |
| 31 | + | return { settings, watched }; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 35 | + | assertSameOrigin(request); | |
| 36 | + | const user = requireUser(context, request); | |
| 37 | + | const form = await request.formData(); | |
| 38 | + | const intent = String(form.get("intent") ?? ""); | |
| 39 | + | // One shape for every answer: what was said, beside the form that asked. | |
| 40 | + | const answer = (saved: string | null, error: string | null = null) => ({ intent, saved, error }); | |
| 41 | + | try { | |
| 42 | + | switch (intent) { | |
| 43 | + | case "email": | |
| 44 | + | await inbox.updateSettings(user.username, { email: emailReasonsFromForm(form) }); | |
| 45 | + | return answer("Saved. You'll be emailed for what is checked."); | |
| 46 | + | case "default_watch": { | |
| 47 | + | const level = String(form.get("default_watch") ?? ""); | |
| 48 | + | if (level !== "participating" && level !== "all") return data(answer(null, "Choose one."), { status: 400 }); | |
| 49 | + | await inbox.updateSettings(user.username, { defaultWatch: level }); | |
| 50 | + | return answer("Saved. It applies to repositories you create from now on."); | |
| 51 | + | } | |
| 52 | + | case "unwatch": { | |
| 53 | + | const repoId = String(form.get("repo_id") ?? ""); | |
| 54 | + | if (!repoId) return data(answer(null, "Nothing to do."), { status: 400 }); | |
| 55 | + | await inbox.watch(user.username, repoId, String(form.get("repo") ?? ""), null); | |
| 56 | + | return answer(null); | |
| 57 | + | } | |
| 58 | + | default: | |
| 59 | + | return data(answer(null, "Nothing to do."), { status: 400 }); | |
| 60 | + | } | |
| 61 | + | } catch { | |
| 62 | + | return data(answer(null, "That did not save. Try again in a moment."), { status: 503 }); | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | const LEVEL_LABEL: Record<WatchLevel, string> = Object.fromEntries(WATCH_CHOICES.map((choice) => [choice.level, choice.label])) as Record< | |
| 67 | + | WatchLevel, | |
| 68 | + | string | |
| 69 | + | >; | |
| 70 | + | ||
| 71 | + | export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 72 | + | const { settings, watched } = loaderData; | |
| 73 | + | const navigation = useNavigation(); | |
| 74 | + | const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null); | |
| 75 | + | if (!settings) { | |
| 76 | + | return <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">Your notification settings could not be loaded. Try again in a moment.</p>; | |
| 77 | + | } | |
| 78 | + | return ( | |
| 79 | + | <div className="space-y-10"> | |
| 80 | + | <section aria-labelledby="email-heading"> | |
| 81 | + | <h2 id="email-heading" className="text-sm font-semibold"> | |
| 82 | + | ||
| 83 | + | </h2> | |
| 84 | + | <p className="mt-1 text-sm text-muted"> | |
| 85 | + | Besides your <Link to="/inbox" className="text-fg underline underline-offset-4">inbox</Link>, email me at my primary address when: | |
| 86 | + | </p> | |
| 87 | + | <Form method="post" className="mt-4 space-y-3"> | |
| 88 | + | <input type="hidden" name="intent" value="email" /> | |
| 89 | + | {EMAIL_REASONS.map((entry) => ( | |
| 90 | + | <CheckboxOption | |
| 91 | + | key={entry.reason} | |
| 92 | + | name="email" | |
| 93 | + | value={entry.reason} | |
| 94 | + | defaultChecked={settings.email.includes(entry.reason)} | |
| 95 | + | label={entry.label} | |
| 96 | + | description={entry.detail} | |
| 97 | + | /> | |
| 98 | + | ))} | |
| 99 | + | <div className="flex items-center gap-3 pt-2"> | |
| 100 | + | <SubmitButton variant="quiet" match={{ intent: "email" }} pending="Saving…"> | |
| 101 | + | Save email settings | |
| 102 | + | </SubmitButton> | |
| 103 | + | {said("email")?.saved && <p className="text-xs text-accent">{said("email")?.saved}</p>} | |
| 104 | + | </div> | |
| 105 | + | <ErrorText>{said("email")?.error}</ErrorText> | |
| 106 | + | </Form> | |
| 107 | + | <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p> | |
| 108 | + | </section> | |
| 109 | + | ||
| 110 | + | <section aria-labelledby="watching-heading"> | |
| 111 | + | <h2 id="watching-heading" className="text-sm font-semibold"> | |
| 112 | + | Repositories you create | |
| 113 | + | </h2> | |
| 114 | + | <p className="mt-1 text-sm text-muted">How you watch a repository when you make it. Change any one from its Watch menu.</p> | |
| 115 | + | <Form method="post" className="mt-4"> | |
| 116 | + | <input type="hidden" name="intent" value="default_watch" /> | |
| 117 | + | <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}> | |
| 118 | + | {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => ( | |
| 119 | + | <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} /> | |
| 120 | + | ))} | |
| 121 | + | </RadioGroup> | |
| 122 | + | <div className="mt-4 flex items-center gap-3"> | |
| 123 | + | <SubmitButton variant="quiet" match={{ intent: "default_watch" }} pending="Saving…"> | |
| 124 | + | Save | |
| 125 | + | </SubmitButton> | |
| 126 | + | {said("default_watch")?.saved && <p className="text-xs text-accent">{said("default_watch")?.saved}</p>} | |
| 127 | + | </div> | |
| 128 | + | <ErrorText>{said("default_watch")?.error}</ErrorText> | |
| 129 | + | </Form> | |
| 130 | + | </section> | |
| 131 | + | ||
| 132 | + | <section aria-labelledby="watched-heading"> | |
| 133 | + | <h2 id="watched-heading" className="text-sm font-semibold"> | |
| 134 | + | Repositories you watch | |
| 135 | + | </h2> | |
| 136 | + | <p className="mt-1 text-sm text-muted">Those you watch other than the default way, which is only what you take part in.</p> | |
| 137 | + | {watched == null ? ( | |
| 138 | + | <p className="mt-4 text-sm text-muted">This list could not be loaded. Try again in a moment.</p> | |
| 139 | + | ) : watched.length === 0 ? ( | |
| 140 | + | <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> | |
| 141 | + | You watch every repository the default way. | |
| 142 | + | </p> | |
| 143 | + | ) : ( | |
| 144 | + | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 145 | + | {watched.map((watching) => ( | |
| 146 | + | <li key={watching.repoId} className="flex items-center gap-3 px-4 py-3"> | |
| 147 | + | {watching.level === "ignore" ? ( | |
| 148 | + | <EyeOff size={15} className="shrink-0 text-faint" aria-hidden="true" /> | |
| 149 | + | ) : ( | |
| 150 | + | <Eye size={15} className="shrink-0 text-faint" aria-hidden="true" /> | |
| 151 | + | )} | |
| 152 | + | <div className="min-w-0 grow"> | |
| 153 | + | {watching.repo ? ( | |
| 154 | + | <Link to={`/${watching.repo}`} className="block truncate font-mono text-sm hover:underline"> | |
| 155 | + | {watching.repo} | |
| 156 | + | </Link> | |
| 157 | + | ) : ( | |
| 158 | + | <span className="block truncate font-mono text-sm text-muted">{watching.repoId}</span> | |
| 159 | + | )} | |
| 160 | + | <p className="truncate text-xs text-muted"> | |
| 161 | + | {LEVEL_LABEL[watching.level]} | |
| 162 | + | {watching.level === "custom" && watching.events.length > 0 && `: ${watching.events.map((kind) => WATCH_EVENT_LABEL[kind]).join(", ")}`} | |
| 163 | + | </p> | |
| 164 | + | </div> | |
| 165 | + | <Form method="post" className="shrink-0"> | |
| 166 | + | <input type="hidden" name="intent" value="unwatch" /> | |
| 167 | + | <input type="hidden" name="repo_id" value={watching.repoId} /> | |
| 168 | + | <input type="hidden" name="repo" value={watching.repo ?? ""} /> | |
| 169 | + | <SubmitButton | |
| 170 | + | variant="quiet" | |
| 171 | + | match={{ intent: "unwatch", repo_id: watching.repoId }} | |
| 172 | + | pending="…" | |
| 173 | + | className="rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-60" | |
| 174 | + | > | |
| 175 | + | {watching.level === "ignore" ? "Stop ignoring" : "Stop watching"} | |
| 176 | + | </SubmitButton> | |
| 177 | + | </Form> | |
| 178 | + | </li> | |
| 179 | + | ))} | |
| 180 | + | </ul> | |
| 181 | + | )} | |
| 182 | + | </section> | |
| 183 | + | </div> | |
| 184 | + | ); | |
| 185 | + | } |
| 267 | 267 | Every one of these is also on the MCP server. Its tools are resources, | |
| 268 | 268 | each with an `action`: `search`, `repository`, `issue`, `pull_request`, | |
| 269 | 269 | `agent`, `plan`, `memory`, `workflow`, `secret`, `webhook`, `access`, | |
| 270 | − | `workspace` and `account`. Call `tools/call` with the tool's name and | |
| 270 | + | `workspace`, `notifications` and `account`. Call `tools/call` with the tool's name and | |
| 271 | 271 | `arguments` holding `action` and its inputs, such as | |
| 272 | 272 | `{"name": "issue", "arguments": {"action": "get", "repo": "acme/web", "number": 12}}`. | |
| 273 | 273 | The flow above is: `issue` `get`, `memory` `recall`, `pull_request` | |
| 276 | 276 | `statuses` and `required_checks` on `pull_request` `get`, and | |
| 277 | 277 | `repository` `check_names` for the names a branch can require. `agent` `delegate` and `agent` `assign` hand work | |
| 278 | 278 | to g1t's agent; `plan` `create`, `get` and `apply` plan an outcome; | |
| 279 | − | `memory` `remember` saves a fact. `search` runs `code` and `account` runs | |
| 280 | − | `whoami` when `action` is left out. A call missing a required field says | |
| 279 | + | `memory` `remember` saves a fact. `notifications` reads and answers the | |
| 280 | + | inbox of the person a token acts for: `list` (unread threads, each with a | |
| 281 | + | `reason` such as `agent` or `review_requested`), `done`, `subscribe`, | |
| 282 | + | `watch` and more; g1t's own token cannot use it. `search` runs `code`, | |
| 283 | + | `notifications` runs `list` and `account` runs `whoami` when `action` is | |
| 284 | + | left out. A call missing a required field says | |
| 281 | 285 | which, such as "issue.get needs number.". The earlier one-tool-per-operation | |
| 282 | 286 | names (`get_issue`, `create_pull_request`, …) still answer for now but are | |
| 283 | 287 | no longer listed. Every tool and action, with its required fields and | |
| 292 | 296 | ||
| 293 | 297 | Every access token and OAuth sign-in has scopes, `resource:level`: | |
| 294 | 298 | `repo`, `code`, `issues`, `pull_requests`, `workflows`, `memory`, | |
| 295 | − | `account`, `access`, `webhooks`, `secrets`, `runners` (read, write or admin | |
| 299 | + | `account`, `notifications`, `access`, `webhooks`, `secrets`, `runners` (read, write or admin | |
| 296 | 300 | as each has them), `agents:run`, `workspace:read` and `workspace:admin`. A higher | |
| 297 | 301 | level includes the lower. A token may expire. It reaches every workspace | |
| 298 | 302 | and repository its owner can (a workspace's token, that workspace only); | |
| 301 | 305 | `{"error": {"code": "forbidden", "message": "This access token needs the issues:write scope to use create_issue.", "needed_scope": "issues:write"}}`. | |
| 302 | 306 | Pushing needs `code:write`; cloning a private repository `code:read`. For | |
| 303 | 307 | an agent, use the Agent preset (every read scope but `runners:read`, plus `code:write`, | |
| 304 | − | `issues:write`, `pull_requests:write`, `agents:run`, `memory:write`). | |
| 308 | + | `issues:write`, `pull_requests:write`, `agents:run`, `memory:write`, | |
| 309 | + | `notifications:write`). | |
| 305 | 310 | OAuth clients may send `scope`; | |
| 306 | 311 | the person can untick any; asking for none gives the Agent preset. Tokens | |
| 307 | 312 | from device sign-in (above) have full access. Guide: |
| 15 | 15 | "pull.updated" => pull("synchronize"), | |
| 16 | 16 | "pull.ready" => pull("ready_for_review"), | |
| 17 | 17 | "pull.closed" | "pull.merged" => pull("closed"), | |
| 18 | + | "pull.assigned" => pull("assigned"), | |
| 19 | + | "pull.review_requested" => pull("review_requested"), | |
| 20 | + | "pull.review_request_removed" => pull("review_request_removed"), | |
| 18 | 21 | "issue.opened" => vec![("issues", Some("opened"))], | |
| 19 | 22 | "issue.updated" => vec![("issues", Some("edited"))], | |
| 20 | 23 | "issue.closed" => vec![("issues", Some("closed"))], |
| 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. |
| 96 | 96 | /// On `issue.assigned`: the people it is now assigned to. | |
| 97 | 97 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 98 | 98 | pub assignees: Option<Vec<String>>, | |
| 99 | + | /// On `issue.assigned`: those of them who were not before. | |
| 100 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 101 | + | pub added: Option<Vec<String>>, | |
| 99 | 102 | } | |
| 100 | 103 | ||
| 101 | 104 | /// The payload of `pull.opened`, `pull.ready`, `pull.updated` (its head | |
| 102 | − | /// moved), `pull.closed` and `pull.merged`; each uses the fields that apply to it. | |
| 105 | + | /// moved), `pull.closed`, `pull.merged`, `pull.assigned`, | |
| 106 | + | /// `pull.review_requested` and `pull.review_request_removed` (reviewers | |
| 107 | + | /// asked, or no longer), `pull.stalled` (g1t stopped seeing it through | |
| 108 | + | /// until a person steps in) and `pull.resumed` (it picked back up); each | |
| 109 | + | /// uses the fields that apply to it. | |
| 103 | 110 | #[derive(Debug, Default, Serialize)] | |
| 104 | 111 | #[serde(rename_all = "camelCase")] | |
| 105 | 112 | pub struct PullEvent { | |
| 127 | 134 | /// How sure g1t is of a g1t agent's change, once it has worked that out. | |
| 128 | 135 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 129 | 136 | pub confidence: Option<crate::work::Confidence>, | |
| 137 | + | /// On `pull.assigned`: the people it is now assigned to. | |
| 138 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 139 | + | pub assignees: Option<Vec<String>>, | |
| 140 | + | /// On `pull.assigned`: those newly assigned. | |
| 141 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 142 | + | pub added: Option<Vec<String>>, | |
| 143 | + | /// On `pull.review_requested`: the reviewers newly asked; on | |
| 144 | + | /// `pull.review_request_removed`, those no longer asked. | |
| 145 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 146 | + | pub reviewers: Option<Vec<String>>, | |
| 147 | + | /// On `pull.stalled`: why g1t stopped, and what would start it again. | |
| 148 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 149 | + | pub detail: Option<String>, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// `deployment.succeeded` and `deployment.failed`: a build of a project | |
| 153 | + | /// finished, for production or for one pull request's preview. | |
| 154 | + | #[derive(Debug, Serialize)] | |
| 155 | + | #[serde(rename_all = "camelCase")] | |
| 156 | + | pub struct DeploymentEvent { | |
| 157 | + | pub deployment_id: String, | |
| 158 | + | pub project_id: String, | |
| 159 | + | pub repo_id: String, | |
| 160 | + | pub workspace: String, | |
| 161 | + | /// The project's slug. | |
| 162 | + | pub project: String, | |
| 163 | + | /// `production` or `preview`. | |
| 164 | + | pub kind: String, | |
| 165 | + | pub branch: Option<String>, | |
| 166 | + | /// For a preview: its pull request. | |
| 167 | + | pub number: Option<u32>, | |
| 168 | + | pub commit: String, | |
| 169 | + | /// Where the deployment is on the site, such as | |
| 170 | + | /// `/acme/rocket/deployments/dpl_1`. | |
| 171 | + | pub path: String, | |
| 172 | + | /// For a failure: what went wrong. | |
| 173 | + | pub error: Option<String>, | |
| 174 | + | /// For a success: whether the deployment before it, of the same app, failed. | |
| 175 | + | pub recovered: bool, | |
| 176 | + | /// Who started it, by username, or `g1t`. | |
| 177 | + | pub triggered_by: String, | |
| 130 | 178 | } | |
| 131 | 179 | ||
| 132 | 180 | /// `checks.completed`: a run of an issue's acceptance checks against a pull |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.