Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

5 commits

55 files+2720−860/55 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());
+6−2
323323 | Workflows | `workflows:read`, `workflows:write` |
324324 | Memory & search | `memory:read`, `memory:write` |
325325 | Account | `account:read`, `account:write` |
326+| Notifications | `notifications:read`, `notifications:write` |
326327 | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` |
327328 | Runners | `runners:read` |
328329 | Dangerous | `repo:admin`, `packages:delete`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` |
352353 | `memory:write` | Save memory for the next agent |
353354 | `account:read` | Read your email addresses, invites and invitations |
354355 | `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 |
355358 | `workspace:read` | Read workspace invites, integrations and model routes |
356359 | `workspace:admin` | Create and delete workspaces, invite members, connect integrations |
357360 | `access:read` | See who has access to repositories |
398401 | Preset | Scopes |
399402 | --- | --- |
400403 | 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. |
402405 | 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. |
403406 | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. |
404407
474477
475478 An application that asks for no scopes in particular gets the
476479 [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`.
478482 It never gets an admin scope unless it asks for one and you leave it
479483 ticked.
480484
+1−1
4545
4646 | Choose | For an agent |
4747 | --- | --- |
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. |
4949 | Expires | The shortest that fits the work, such as 30 days. |
5050
5151 A token reaches every workspace and repository you can. To keep an agent
+220−33
11 ---
22 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.
44 ---
55
66 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.
1011
1112 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. |
1456
1557 ## What lands there
1658
1759 Each item comes from something that happened on g1t. Who is told depends on
1860 what it was:
1961
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 |
3181
3282 "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.
3595
3696 You only see items about repositories you can read. If you lose access to a
3797 repository, its items leave your inbox the next time you open it.
3898
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+## Email
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+
39185 ## Tabs
40186
41187 | Tab | Shows |
42188 | --- | --- |
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 |
48194
49195 The count beside each tab is what is unread under it.
50196
58204
59205 | Action | What it does |
60206 | --- | --- |
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 |
62208 | **Mark as read** / **Mark as unread** | Changes whether it counts as unread |
63209 | **Save** | Keeps it under Saved, even after it is done |
64210 | **Snooze until** | Hides it for 3 hours, until tomorrow, or for a week, then brings it back |
68214 ## The full inbox
69215
70216 **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:
73219
74220 | View | Shows |
75221 | --- | --- |
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.
78233
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
81235
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+
82268 ## How long items are kept
83269
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.
+5−1
7575 | `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/). |
7676 | `repo.archived`, `repo.unarchived` | It was made read-only, or writable again. |
7777 | `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. |
7979 | `comment.created` | A comment or review on an issue or pull request. |
8080 | `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. |
8184 | `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`. |
8285 | `review.completed` | g1t reviewed a pull request. `data.verdict`. |
8386 | `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. |
8488 | `queue.changed` | The merge queue gained, lost or settled an entry. |
8589 | `session.appended` | An agent's session grew. Busy: choose it only if you need it. |
8690 | `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. |
+2−0
176176 | [List repository events](/reference/api/repositories/list-events/) | 50 | `before`: the id of the last event you have |
177177 | [List workflow runs](/reference/api/actions/list-workflow-runs/) | `per_page`, at most 100 and 50 if not given | None |
178178 | [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 |
179180 | [Read a session](/reference/api/sessions/read-session/) | None | `after`: the last `seq` you have |
180181 | [Get a job's log](/reference/api/actions/get-job-logs/) | 500 chunks | `after`: the last `seq` you have |
181182
195196 | --- | --- |
196197 | [Accounts](/reference/api/accounts/whoami/) | Signing in from a tool, and who a token acts as. |
197198 | [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/). |
198200 | [Invites](/reference/api/invites/list-invites/) | Your invites while g1t is invite-only, and inviting people into a workspace by email. |
199201 | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. |
200202 | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. |
+34−7
33 description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them.
44 ---
55
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
77 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
1010 action is the same operation as a route of the [REST API](/reference/api/),
1111 with the same inputs, permissions and results, so the two always agree.
1212
5151 }
5252 ```
5353
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.
5657 - The input schema that `tools/list` returns is one flat object: `action`,
5758 then every field any of the tool's actions takes. The `action` field's
5859 description lists each action with the fields it needs, such as
450451 | [`get_model_routes`](/reference/api/integrations/get-model-routes/) | Which provider and model each kind of work goes to. Members only. | `workspace` | `workspace:read` |
451452 | [`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` |
452453
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+
453480 ## `account`
454481
455482 Who the token acts as and its workspaces, your email addresses, your
489516 | Plan | The same reading actions, and `issue` `create` and `search` `ticket`. |
490517 | Catch up | The reading actions only. |
491518
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`
494521 `assign` and `delegate`, `plan` `create` and `apply`, `issue` `import`, or
495522 any `repository` action that creates, changes, renames, archives,
496523 transfers, deletes, restores or purges a repository, or dismisses or
+0−0

Binary or large file; its contents are not shown.

+14−6
1313 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
1414 import { cn } from "../lib/cn";
1515 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";
1717 import type { InboxPanelData } from "../routes/inbox-json";
1818
1919 /** Where every inbox form posts (routes/inbox.tsx). */
6969 const unread = isUnread(item);
7070 const leaving = isPending(fetcher, { id: item.id }) && ["done", "snooze", "undone"].includes(String(fetcher.formData?.get("intent")));
7171 const [now, setNow] = useState(() => Date.now());
72− useEffect(() => setNow(Date.now()), [item.createdAt]);
72+ useEffect(() => setNow(Date.now()), [item.updatedAt]);
7373 if (leaving) return null;
7474
7575 const submit = (intent: string, extra?: Record<string, string>) =>
9696 {item.title}
9797 </Link>
9898 {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)}
102102 </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+ )}
104112 {item.saved && (
105113 <span className="inline-flex items-center gap-1 text-faint">
106114 <Bookmark size={12} aria-hidden="true" />
+178−0
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+}
+5−0
66 export type AccountSettingsPage =
77 | "profile"
88 | "emails"
9+ | "notifications"
910 | "invites"
1011 | "keys"
1112 | "tokens"
2122 about:
2223 "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.",
2324 },
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+ },
2429 invites: { title: "Invites", about: "Bring people to g1t, and see which invites were used." },
2530 keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." },
2631 tokens: {
+93−2
33
44 import type { InboxItem } from "@g1t/contracts";
55
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";
728
829 const NOW = Date.parse("2026-10-07T12:00:00.000Z");
930
6990 test("what needs you puts a waiting agent before a failure, and leaves out the rest", () => {
7091 const item = (id: string, severity: InboxItem["severity"], createdAt: string, readAt: string | null = null): InboxItem => ({
7192 id,
72− reason: "x",
93+ reason: "agent",
7394 severity,
7495 title: id,
7596 body: "",
80101 url: "/acme/rocket/pull/1",
81102 actor: null,
82103 createdAt,
104+ updatedAt: createdAt,
105+ event: null,
106+ count: 1,
83107 readAt,
84108 doneAt: null,
85109 saved: false,
107131 assert.equal(emptyFor("needs").title, "Nothing needs you");
108132 assert.equal(emptyFor("all", "saved").title, "Nothing saved");
109133 });
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+});
+169−7
11 /**
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.
57 */
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"];
736
837 export type InboxTab = "all" | "needs" | "error" | "success" | "info";
938
1039 /** The tabs, in order, and the severity each shows. */
1140 export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [
1241 { 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.
1443 { tab: "needs", label: "Needs you", severity: "warning" },
1544 { tab: "error", label: "Errors", severity: "error" },
1645 { tab: "success", label: "Success", severity: "success" },
5685 if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." };
5786 switch (tab) {
5887 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." };
6089 case "error":
6190 return { title: "No failures", detail: "Failed checks and workflows on your work show up here." };
6291 case "success":
136165 const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1);
137166 const needs = items
138167 .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));
140169 return { items: needs.slice(0, max), total: needs.length };
141170 }
142171
144173 export function isUnread(item: Pick<InboxItem, "readAt">): boolean {
145174 return item.readAt == null;
146175 }
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+}
+3−0
2727 index("routes/settings/index.tsx"),
2828 route("profile", "routes/settings/profile.tsx"),
2929 route("emails", "routes/settings/emails.tsx"),
30+ route("notifications", "routes/settings/notifications.tsx"),
3031 route("invites", "routes/settings/invites.tsx"),
3132 route("keys", "routes/settings/keys.tsx"),
3233 route("tokens", "routes/settings/tokens.tsx"),
9394 route(":owner/:repo/audit.json", "routes/repo/audit-live.ts"),
9495 // An invitation to a repository, answered by someone who cannot see it yet.
9596 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"),
9699 // A starter CI workflow, opened as a pull request (components/add-ci.tsx).
97100 route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"),
98101 // A screenshot of a project's production, for its overview.
+25−8
88
99 import type { Route } from "./+types/inbox";
1010 import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox";
11+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
1112 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";
1314 import { page } from "../lib/meta";
1415 import { inbox } from "../lib/services.server";
1516 import { assertSameOrigin, requireUser } from "../lib/session.server";
2526 const url = new URL(request.url);
2627 const tab = inboxTab(url.searchParams.get("tab"));
2728 const view = inboxView(url.searchParams.get("view"));
29+ const reason = inboxReason(url.searchParams.get("reason"));
2830 const before = url.searchParams.get("before");
2931 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),
3133 inbox.counts(user.username).catch(() => null),
3234 ]);
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 };
3436 }
3537
3638 export async function action({ request, context }: Route.ActionArgs) {
5355 ] as const;
5456
5557 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;
5759 const navigate = useNavigate();
5860 const address = (changes: Record<string, string | null>) => {
5961 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 };
6163 for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value);
6264 const query = params.toString();
6365 return query ? `/inbox?${query}` : "/inbox";
6870 <div className="flex flex-wrap items-center justify-between gap-3">
6971 <div>
7072 <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>
7274 </div>
7375 {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />}
7476 </div>
9092 ))}
9193 </nav>
9294
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>
95112 </div>
96113
97114 <div className="mt-4">
+14−3
3737 import { notFound } from "../../lib/not-found.server";
3838 import { openedBy } from "../../lib/opened-by";
3939 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";
4141 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";
4344 import { useRefreshWhile } from "../../lib/refresh";
4445
4546
6667 // At once: only the plan's note waits for the viewer's role. Putting an
6768 // agent on it needs Write: Read cannot spend compute.
6869 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([
7071 access,
7172 work.getIssue(path, number, viewer),
7273 work.listLabels(path, viewer),
7778 integrations.links(path, number).catch(() => []),
7879 // Before a member assigns g1t: whether the workspace's plan lets it start.
7980 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,
8087 ]);
8188 if (!found.ok) {
8289 // Issues and pull requests share numbers; this one may be a pull request.
9299 agentsEnabled,
93100 computeNote,
94101 links,
102+ subscription,
95103 members: members?.ok ? members.value.map((member) => member.username) : [],
96104 // The author can close and reopen their own issue, and whoever g1t's
97105 // agent filed one for, that one; Triage and up, anyone's.
638646 </details>
639647 )}
640648
649+ {viewer && (
650+ <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} />
651+ )}
641652 </aside>
642653 </div>
643654 );
+22−5
11 import { Box, Lock } from "lucide-react";
2+import type { ReactNode } from "react";
23 import { Link, NavLink, Outlet, type ShouldRevalidateFunctionArgs, data, useLocation, useRouteLoaderData } from "react-router";
34
45 import type { Project } from "@g1t/contracts";
89 import { page } from "../../lib/meta";
910 import { type Tab as PageTab, tabsFor } from "../../lib/project-nav";
1011 import { Pill } from "../../components/ui";
12+import { WatchMenu } from "../../components/notifications";
1113 import { ArchivedBanner } from "../../components/repo-lifecycle";
1214 import { WelcomeBanner } from "../../components/welcome";
1315 import { clearWelcome, welcomes } from "../../lib/invites";
1416 import { notFound } from "../../lib/not-found.server";
1517 import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server";
1618 import { accessFor, countsFor, repoFor } from "../../lib/access.server";
17−import { projects } from "../../lib/services.server";
19+import { inbox, projects } from "../../lib/services.server";
1820 import { getViewer, unwrap } from "../../lib/session.server";
1921
2022 export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
2426 export async function loader({ params, context, request }: Route.LoaderArgs) {
2527 const viewer = getViewer(context);
2628 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([
2830 repoFor(context, params),
2931 countsFor(context, params),
3032 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,
3138 ]);
3239 if (!repo.ok && !found.ok) {
3340 // Under a workspace's old name, after a rename: the project is at the new one.
5663 open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
5764 access,
5865 member: access.insider,
66+ watching,
5967 }, { headers });
6068 }
6169
7583 }
7684
7785 /** 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 }: {
7987 project: Project | null;
8088 isPrivate: boolean;
8189 namespace: string;
8391 description: string | null;
8492 archived?: boolean;
8593 large?: boolean;
94+ /** At the end of the row: the Watch menu. */
95+ actions?: ReactNode;
8696 }) {
8797 const base = `/${namespace}/${name}`;
8898 return (
103113 </h1>
104114 <Pill>{isPrivate ? "private" : "public"}</Pill>
105115 {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>}
107119 </div>
108120 );
109121 }
149161 }
150162
151163 export default function ProjectLayout({ loaderData }: Route.ComponentProps) {
152− const { repo, project, member, access, welcome } = loaderData;
164+ const { repo, project, member, access, welcome, watching } = loaderData;
153165 const base = `/${repo.namespace}/${repo.name}`;
154166 // The project's own description, else the repository's as it is now.
155167 const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description;
171183 name={repo.name}
172184 // The files' own About says it there, as the one place.
173185 description={filesPage ? null : description}
186+ actions={
187+ watching ? (
188+ <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} />
189+ ) : null
190+ }
174191 />
175192 {/* On the files' pages, About shows them. */}
176193 {!filesPage && <Topics topics={repo.topics} />}
+40−0
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+}
+14−3
7575 import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up";
7676 import { notFound } from "../../lib/not-found.server";
7777 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";
7979 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";
8182 import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh";
8283
8384 const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
123124 const deps = projects.dependencies(params.owner, params.repo, viewer);
124125 // Awaited below, unless the pull request is missing first.
125126 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([
127128 access,
128129 pullFound,
129130 repos.get(path, viewer),
136137 tab === "session" ? work.readSession(path, number, viewer) : null,
137138 // Its preview, for people with a role here: beside the rest, not after.
138139 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,
139146 ]);
140147 if (!found.ok) {
141148 // Issues and pull requests share numbers; this one may be an issue.
197204 canUpdate: pull.fork ? viewer?.id === workOwner(pull).id : can.push,
198205 agentsEnabled,
199206 computeNote,
207+ subscription,
200208 members: members?.ok ? members.value.map((person) => person.username) : [],
201209 requireUpToDate: settings.ok && settings.value.requireUpToDate,
202210 mergeQueue: settings.ok && settings.value.mergeQueue,
13431351 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
13441352 </p>
13451353 </section>
1354+ {loaderData.viewer && (
1355+ <SubscriptionBox action={`${base}/notifications`} number={pull.number} kind="pull" subscription={loaderData.subscription} />
1356+ )}
13461357 </aside>
13471358 </div>
13481359 </div>
+185−0
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+ Email
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+}
+10−5
267267 Every one of these is also on the MCP server. Its tools are resources,
268268 each with an `action`: `search`, `repository`, `issue`, `pull_request`,
269269 `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
271271 `arguments` holding `action` and its inputs, such as
272272 `{"name": "issue", "arguments": {"action": "get", "repo": "acme/web", "number": 12}}`.
273273 The flow above is: `issue` `get`, `memory` `recall`, `pull_request`
276276 `statuses` and `required_checks` on `pull_request` `get`, and
277277 `repository` `check_names` for the names a branch can require. `agent` `delegate` and `agent` `assign` hand work
278278 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
281285 which, such as "issue.get needs number.". The earlier one-tool-per-operation
282286 names (`get_issue`, `create_pull_request`, …) still answer for now but are
283287 no longer listed. Every tool and action, with its required fields and
292296
293297 Every access token and OAuth sign-in has scopes, `resource:level`:
294298 `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
296300 as each has them), `agents:run`, `workspace:read` and `workspace:admin`. A higher
297301 level includes the lower. A token may expire. It reaches every workspace
298302 and repository its owner can (a workspace's token, that workspace only);
301305 `{"error": {"code": "forbidden", "message": "This access token needs the issues:write scope to use create_issue.", "needed_scope": "issues:write"}}`.
302306 Pushing needs `code:write`; cloning a private repository `code:read`. For
303307 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`).
305310 OAuth clients may send `scope`;
306311 the person can untick any; asking for none gives the Agent preset. Tokens
307312 from device sign-in (above) have full access. Guide:
+3−0
1515 "pull.updated" => pull("synchronize"),
1616 "pull.ready" => pull("ready_for_review"),
1717 "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"),
1821 "issue.opened" => vec![("issues", Some("opened"))],
1922 "issue.updated" => vec![("issues", Some("edited"))],
2023 "issue.closed" => vec![("issues", Some("closed"))],
+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.
+49−1
9696 /// On `issue.assigned`: the people it is now assigned to.
9797 #[serde(skip_serializing_if = "Option::is_none")]
9898 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>>,
99102 }
100103
101104 /// 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.
103110 #[derive(Debug, Default, Serialize)]
104111 #[serde(rename_all = "camelCase")]
105112 pub struct PullEvent {
127134 /// How sure g1t is of a g1t agent's change, once it has worked that out.
128135 #[serde(skip_serializing_if = "Option::is_none")]
129136 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,
130178 }
131179
132180 /// `checks.completed`: a run of an issue's acceptance checks against a pull
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.