Skip to content

Commit

API: notifications over REST and MCP, with notifications scopes

Fourteen operations: list and mark notifications (yours, or one repository's), get a thread and mark it read, done, saved or snoozed, subscribe to or unsubscribe from an issue or pull request (by thread or by number), watch a repository and list what you watch. REST at /notifications, /notifications/threads/{id}, /repos/{owner}/{name}/ notifications, .../subscription, .../issues/{number}/subscription and /user/subscriptions; MCP as one notifications tool whose default action is list. notifications:read and notifications:write gate them, in the Read only and Agent presets; g1t's own agents never use them, and marking your own inbox is not audited. The OpenAPI copy and reference examples are updated.

syntaqxcommitted Parent296c251Browse files
13 files+1682−60/13 viewed
+4−0
187187 if !actor.records_reads() && is_read(op.name()) {
188188 return;
189189 }
190+ // A person's own inbox is nobody else's business.
191+ if op.personal() {
192+ return;
193+ }
190194 let mut entry = NewAuditEntry::new(
191195 actor,
192196 op.name(),
+1−0
99 mod audit;
1010 mod blobs;
1111 mod mcp;
12+mod notifications;
1213 mod oauth;
1314 mod openapi;
1415 mod operations;
+462−0
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+}
+54−0
1919 &[Op::Whoami, Op::ListEmails, Op::AddEmail, Op::RemoveEmail, Op::UpdateEmailSettings],
2020 ),
2121 (
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+ (
2242 "Workspaces",
2343 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
2444 &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
339359 Op::ListSecurityAlerts => "List security alerts",
340360 Op::DismissSecurityAlert => "Dismiss a security alert",
341361 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",
342376 }
343377 }
344378
422456 ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(),
423457 ("PATCH", ":setting") => "update_actions_variable".to_owned(),
424458 ("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(),
425472 _ => op.name().to_owned(),
426473 };
427474 if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
440487 "rerun_failed_jobs" => "Re-run failed jobs",
441488 "update_actions_variable" => "Update a variable",
442489 "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",
443497 _ => title(route.op),
444498 };
445499 if id.ends_with("_for_workspace") {
+254−2
1919 };
2020
2121 use crate::alerts::{AlertKind, SecurityAlert};
22+use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel};
2223 use g1t_contracts::work::*;
2324 use g1t_contracts::{FailureCode, Outcome, Viewer};
2425 use serde::Serialize;
192193 ListSecurityAlerts,
193194 DismissSecurityAlert,
194195 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,
195210 }
196211
197212 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
279294 }
280295
281296 /// 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> {
283298 let mut parts = input["repo"].as_str()?.split('/');
284299 match (parts.next(), parts.next(), parts.next()) {
285300 (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => {
413428 })
414429 }
415430
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+
416449 fn alert_id_schema() -> Value {
417450 json!({
418451 "type": "string",
421454 }
422455
423456 impl Op {
424− pub const ALL: [Op; 117] = [
457+ pub const ALL: [Op; 131] = [
425458 Op::Whoami,
426459 Op::CreateWorkspace,
427460 Op::DeleteWorkspace,
539572 Op::ListSecurityAlerts,
540573 Op::DismissSecurityAlert,
541574 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,
542589 ];
543590
544591 pub fn by_name(name: &str) -> Option<Op> {
665712 Op::ListSecurityAlerts => "list_security_alerts",
666713 Op::DismissSecurityAlert => "dismiss_security_alert",
667714 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",
668729 }
669730 }
670731
9871048 Op::ReopenSecurityAlert => {
9881049 "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."
9891050 }
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+ }
9901093 }
9911094 }
9921095
18761979 &["repo", "id", "reason"],
18771980 ),
18781981 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!({}), &[]),
18792083 }
18802084 }
18812085
19622166 | Op::DeclineRepoInvitation
19632167 | Op::SetBasePermission
19642168 | 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
19652202 )
19662203 }
19672204
33653602 .await?;
33663603 changed_alert(changed)
33673604 }
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,
33683620 Op::ReopenSecurityAlert => {
33693621 let changed: Outcome<AlertChange> = call(
33703622 &services.security,
+743−0
42344234 "dismissed_at": null
42354235 },
42364236 "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+ ]
42374980 }
42384981 }
+7−0
142142 through::<g1t_contracts::identity::Invite>(op, sent)
143143 }
144144 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+ }
145152 _ => sent,
146153 }
147154 }
+51−0
5656 route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]),
5757 route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]),
5858 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, &[]),
5982 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
6083 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
6184 route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]),
691714 if route.path.ends_with("/rerun-failed-jobs") {
692715 input.insert("failed_only".to_owned(), Value::Bool(true));
693716 }
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+ }
694724 if let Some(number) = param("number") {
695725 // Not a number: zero, which no issue or pull request has.
696726 input.insert(
759789 }
760790
761791 #[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]
762813 fn query_parameters_are_renamed() {
763814 let query = [
764815 ("q".to_owned(), "parser".to_owned()),
+32−0
245245 ],
246246 },
247247 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 {
248270 name: "account",
249271 title: "Your account",
250272 description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.",
613635 let access = token(Some(vec![Scope::IssuesWrite]));
614636 let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect();
615637 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);
616648 let full = token(None);
617649 assert_eq!(listed(&Gate::Token(&full)).len(), TOOLS.len());
618650 assert_eq!(listed(&Gate::Everything).len(), TOOLS.len());
+0−0

Binary or large file; its contents are not shown.

+20−0
263263 "list_actions_secrets",
264264 "list_actions_variables",
265265 "list_security_alerts",
266+ "list_notifications",
267+ "get_notification_thread",
268+ "get_thread_subscription",
269+ "get_repo_subscription",
270+ "list_watched_repos",
266271 ];
267272
268273 /// What no agent's token may ever do, whatever its scope says: workspaces,
329334 // Dismissing a secret lets it through push protection.
330335 "dismiss_security_alert",
331336 "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",
332352 ];
333353
334354 /// Reading what an agent needs to know about its repository.
+32−2
2323 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2424 pub enum Resource {
2525 Account,
26+ Notifications,
2627 Workspace,
2728 Repo,
2829 Code,
3940 }
4041
4142 impl Resource {
42− pub const ALL: [Resource; 14] = [
43+ pub const ALL: [Resource; 15] = [
4344 Resource::Repo,
4445 Resource::Code,
4546 Resource::Packages,
4950 Resource::Workflows,
5051 Resource::Memory,
5152 Resource::Account,
53+ Resource::Notifications,
5254 Resource::Workspace,
5355 Resource::Access,
5456 Resource::Webhooks,
5961 pub fn as_str(self) -> &'static str {
6062 match self {
6163 Resource::Account => "account",
64+ Resource::Notifications => "notifications",
6265 Resource::Workspace => "workspace",
6366 Resource::Repo => "repo",
6467 Resource::Code => "code",
7982 pub fn label(self) -> &'static str {
8083 match self {
8184 Resource::Account => "Your account",
85+ Resource::Notifications => "Notifications",
8286 Resource::Workspace => "Workspaces",
8387 Resource::Repo => "Repositories",
8488 Resource::Code => "Code",
143147 MemoryWrite,
144148 AccountRead,
145149 AccountWrite,
150+ NotificationsRead,
151+ NotificationsWrite,
146152 WorkspaceRead,
147153 WorkspaceAdmin,
148154 AccessRead,
157163
158164 impl Scope {
159165 /// Every scope, grouped by resource, least first.
160− pub const ALL: [Scope; 29] = [
166+ pub const ALL: [Scope; 31] = [
161167 Scope::RepoRead,
162168 Scope::RepoWrite,
163169 Scope::RepoAdmin,
177183 Scope::MemoryWrite,
178184 Scope::AccountRead,
179185 Scope::AccountWrite,
186+ Scope::NotificationsRead,
187+ Scope::NotificationsWrite,
180188 Scope::WorkspaceRead,
181189 Scope::WorkspaceAdmin,
182190 Scope::AccessRead,
210218 Scope::MemoryWrite => "memory:write",
211219 Scope::AccountRead => "account:read",
212220 Scope::AccountWrite => "account:write",
221+ Scope::NotificationsRead => "notifications:read",
222+ Scope::NotificationsWrite => "notifications:write",
213223 Scope::WorkspaceRead => "workspace:read",
214224 Scope::WorkspaceAdmin => "workspace:admin",
215225 Scope::AccessRead => "access:read",
280290 Scope::MemoryWrite => "Save memory for the next agent",
281291 Scope::AccountRead => "Read your email addresses, invites and invitations",
282292 Scope::AccountWrite => "Change your email addresses, make invites and answer invitations",
293+ Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
294+ Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
283295 Scope::WorkspaceRead => "Read workspace invites, integrations and model routes",
284296 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations",
285297 Scope::AccessRead => "See who has access to repositories",
373385 // Not the machines work runs on: an agent has no business
374386 // knowing a workspace's own runners.
375387 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
388+ // And answering what needs the person it works for: marking
389+ // it done, subscribing, watching.
376390 scopes.extend([
377391 Scope::CodeWrite,
378392 Scope::IssuesWrite,
379393 Scope::PullRequestsWrite,
380394 Scope::AgentsRun,
381395 Scope::MemoryWrite,
396+ Scope::NotificationsWrite,
382397 ]);
383398 normalize(&mut scopes);
384399 Some(scopes)
460475 ("list_my_repo_invitations", Scope::AccountRead),
461476 ("accept_repo_invitation", Scope::AccountWrite),
462477 ("decline_repo_invitation", Scope::AccountWrite),
478+ // Your inbox: notifications, subscriptions and watching.
479+ ("list_notifications", Scope::NotificationsRead),
480+ ("get_notification_thread", Scope::NotificationsRead),
481+ ("get_thread_subscription", Scope::NotificationsRead),
482+ ("get_repo_subscription", Scope::NotificationsRead),
483+ ("list_watched_repos", Scope::NotificationsRead),
484+ ("mark_notifications_read", Scope::NotificationsWrite),
485+ ("mark_thread_read", Scope::NotificationsWrite),
486+ ("mark_thread_done", Scope::NotificationsWrite),
487+ ("save_thread", Scope::NotificationsWrite),
488+ ("snooze_thread", Scope::NotificationsWrite),
489+ ("set_thread_subscription", Scope::NotificationsWrite),
490+ ("delete_thread_subscription", Scope::NotificationsWrite),
491+ ("set_repo_subscription", Scope::NotificationsWrite),
492+ ("delete_repo_subscription", Scope::NotificationsWrite),
463493 // Workspaces, their invites and integrations.
464494 ("create_workspace", Scope::WorkspaceAdmin),
465495 ("delete_workspace", Scope::WorkspaceAdmin),
+22−2
1818 | "workflows"
1919 | "memory"
2020 | "account"
21+ | "notifications"
2122 | "workspace"
2223 | "access"
2324 | "webhooks"
4748 { scope: "memory:write", description: "Save memory for the next agent" },
4849 { scope: "account:read", description: "Read your email addresses, invites and invitations" },
4950 { scope: "account:write", description: "Change your email addresses, make invites and answer invitations" },
51+ { scope: "notifications:read", description: "See your inbox, its threads, and what you subscribe to and watch" },
52+ { scope: "notifications:write", description: "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories" },
5053 { scope: "workspace:read", description: "Read workspace invites, integrations and model routes" },
5154 { scope: "workspace:admin", description: "Create and delete workspaces, invite members, connect integrations" },
5255 { scope: "access:read", description: "See who has access to repositories" },
7275 { resource: "workflows", label: "Workflows" },
7376 { resource: "memory", label: "Memory and context" },
7477 { resource: "account", label: "Your account" },
78+ { resource: "notifications", label: "Notifications" },
7579 { resource: "workspace", label: "Workspaces" },
7680 { resource: "access", label: "Who has access" },
7781 { resource: "webhooks", label: "Webhooks" },
130134 /** Starting points for choosing scopes. `*` is full access. */
131135 export const PRESET_SCOPES = {
132136 read_only: [
133− "repo:read", "code:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "memory:read", "account:read", "workspace:read", "access:read", "webhooks:read", "secrets:read", "runners:read",
137+ "repo:read", "code:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "memory:read", "account:read", "notifications:read", "workspace:read", "access:read", "webhooks:read", "secrets:read", "runners:read",
134138 ] as const,
135139 agent: [
136− "repo:read", "code:read", "code:write", "packages:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "agents:run", "workflows:read", "memory:read", "memory:write", "account:read", "workspace:read", "access:read", "webhooks:read", "secrets:read",
140+ "repo:read", "code:read", "code:write", "packages:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "agents:run", "workflows:read", "memory:read", "memory:write", "account:read", "notifications:read", "notifications:write", "workspace:read", "access:read", "webhooks:read", "secrets:read",
137141 ] as const,
138142 ci: [
139143 "repo:read", "code:read", "code:write", "packages:read", "packages:write", "workflows:read", "workflows:write",
171175 ["list_my_repo_invitations", "account:read"],
172176 ["accept_repo_invitation", "account:write"],
173177 ["decline_repo_invitation", "account:write"],
178+ // Your inbox: notifications, subscriptions and watching.
179+ ["list_notifications", "notifications:read"],
180+ ["get_notification_thread", "notifications:read"],
181+ ["get_thread_subscription", "notifications:read"],
182+ ["get_repo_subscription", "notifications:read"],
183+ ["list_watched_repos", "notifications:read"],
184+ ["mark_notifications_read", "notifications:write"],
185+ ["mark_thread_read", "notifications:write"],
186+ ["mark_thread_done", "notifications:write"],
187+ ["save_thread", "notifications:write"],
188+ ["snooze_thread", "notifications:write"],
189+ ["set_thread_subscription", "notifications:write"],
190+ ["delete_thread_subscription", "notifications:write"],
191+ ["set_repo_subscription", "notifications:write"],
192+ ["delete_repo_subscription", "notifications:write"],
174193 ["create_workspace", "workspace:admin"],
175194 ["delete_workspace", "workspace:admin"],
176195 ["update_workspace", "workspace:admin"],
303322 { id: "workflows", label: "Workflows", scopes: ["workflows:read", "workflows:write"] },
304323 { id: "memory", label: "Memory & search", scopes: ["memory:read", "memory:write"] },
305324 { id: "account", label: "Account", scopes: ["account:read", "account:write"] },
325+ { id: "notifications", label: "Notifications", scopes: ["notifications:read", "notifications:write"] },
306326 { id: "workspace", label: "Workspace", scopes: ["workspace:read", "access:read", "webhooks:read", "secrets:read"] },
307327 { id: "runners", label: "Runners", scopes: ["runners:read"] },
308328 ];