chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Watch a repository, and subscribe to or mute a pull or issue
.changeset/subscriptions.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Watch a repository, and subscribe to or mute a pull or issue | ||
| ||
`knotless notifications watch <url> [all | releases | ignore | participating]` chooses what you hear of a repository: all it does, its releases alone, or nothing. `knotless notifications subscribe <at-uri>` hears of every comment on a pull or issue, `mute` hears nothing of it but a review asked of you, and `unsubscribe` goes back to hearing of it as your part in it says. `knotless notifications subscriptions` lists what you chose. The MCP `subscribe` tool does the same. Notifications gain the reasons `subscribed` and `watching`. They call two new appview methods, so a login made before this needs `knotless login` again. | ||
crates/knotless/src/cli.rs+50−0
| @@ -185,6 +185,17 @@ const USAGE: &str = "usage: knotless <command> | ||
[--followed-only | --anyone] which reasons you hear of (star, follow, | ||
pull, issue, comment, mention, ...), and | ||
whether only from accounts you follow | ||
notifications watch <url> [all | releases | ignore | participating] | ||
hear of all a repository does (all), its | ||
releases alone, or nothing of it; or, | ||
participating, only what you take part in | ||
notifications subscribe <at-uri> hear of every comment on a pull or issue | ||
notifications mute <at-uri> hear nothing of a pull or issue, but a | ||
review asked of you | ||
notifications unsubscribe <at-uri> hear of a pull or issue as your part in | ||
it says, as before subscribe or mute | ||
notifications subscriptions the repositories you watch or ignore, | ||
and the pulls and issues you chose for | ||
follow <handle> follow an account on knotless | ||
unfollow <handle> | ||
follows [<handle>] the accounts you, or an account, follow | ||
| @@ -936,6 +947,42 @@ async fn notification_settings(ctx: &Ctx, args: &mut Args<'_>, out: &mut dyn Wri | ||
Ok(()) | ||
} | ||
| ||
/// `knotless notifications watch`, `subscribe`, `mute`, `unsubscribe` and | ||
/// `subscriptions`: chooses what you hear of a repository, a pull or an | ||
/// issue, and prints what you hear of it now; or prints every choice. | ||
async fn subscription( | ||
ctx: &Ctx, | ||
cmd: &str, | ||
args: &mut Args<'_>, | ||
out: &mut dyn Write, | ||
) -> Result<()> { | ||
use crate::notifications::{repo_subject, subscribe, subscriptions}; | ||
let chose = match cmd { | ||
"watch" => { | ||
let subject = repo_subject(ctx, &args.url()?).await?; | ||
let level = args.rest.next().map_or("all", String::as_str); | ||
subscribe(ctx, &subject, level).await? | ||
} | ||
"subscriptions" => { | ||
for s in subscriptions(ctx).await? { | ||
let at = s.created_at.unwrap_or_default(); | ||
writeln!(out, "{}\t{}\t{at}", s.level, s.subject)?; | ||
} | ||
return Ok(()); | ||
} | ||
_ => { | ||
let level = match cmd { | ||
"subscribe" => "subscribed", | ||
"mute" => "muted", | ||
_ => "participating", | ||
}; | ||
subscribe(ctx, args.next("<at-uri>")?, level).await? | ||
} | ||
}; | ||
writeln!(out, "{}\t{}", chose.level, chose.subject)?; | ||
Ok(()) | ||
} | ||
| ||
/// A palette, from the file `path`, or stdin for `-`, as JSON: the palette | ||
/// itself, or a `dev.knotless.theme` record holding one. | ||
fn palette_from(path: &str, stdin: &mut dyn std::io::Read) -> Result<serde_json::Value> { | ||
| @@ -1111,6 +1158,9 @@ async fn command( | ||
while let Some(arg) = args.rest.next() { | ||
match arg.as_str() { | ||
"settings" => return notification_settings(ctx, &mut args, out).await, | ||
"watch" | "subscribe" | "mute" | "unsubscribe" | "subscriptions" => { | ||
return subscription(ctx, arg, &mut args, out).await; | ||
} | ||
"--peek" => peek = true, | ||
"--kind" => kind = Some(args.next("<kind>")?), | ||
other => bail!("unexpected {other}"), | ||
crates/knotless/src/notifications.rs+63−2
| @@ -6,7 +6,9 @@ | ||
//! Which reasons it hears of, and whether only from accounts it follows, | ||
//! the appview keeps too, among the account's preferences | ||
//! (`dev.knotless.getPreferences` and `putPreferences`), as Bluesky's keeps | ||
//! its own: they are a reader's, not something to publish. | ||
//! its own: they are a reader's, not something to publish. So are the | ||
//! repositories it watches and the pulls and issues it subscribed to or | ||
//! muted (`dev.knotless.putSubscription` and `listSubscriptions`). | ||
| ||
use crate::auth; | ||
use crate::env::Ctx; | ||
| @@ -20,6 +22,8 @@ pub const LIST: &str = "dev.knotless.listNotifications"; | ||
pub const SEEN: &str = "dev.knotless.updateSeen"; | ||
pub const GET_PREFERENCES: &str = "dev.knotless.getPreferences"; | ||
pub const PUT_PREFERENCES: &str = "dev.knotless.putPreferences"; | ||
pub const PUT_SUBSCRIPTION: &str = "dev.knotless.putSubscription"; | ||
pub const LIST_SUBSCRIPTIONS: &str = "dev.knotless.listSubscriptions"; | ||
/// The kind of preference that says which notifications are heard of. | ||
const NOTIFICATION_PREF: &str = "dev.knotless.getPreferences#notificationPref"; | ||
| ||
| @@ -42,7 +46,8 @@ pub struct Notification { | ||
pub uri: String, | ||
pub author: Actor, | ||
/// `star`, `follow`, `pull`, `issue`, `comment`, `mention`, | ||
/// `review-requested`, `invite`, `checks-failed` or `ejected`. | ||
/// `review-requested`, `invite`, `checks-failed`, `ejected`, | ||
/// `subscribed` or `watching`. | ||
pub reason: String, | ||
pub reason_subject: Option<String>, | ||
pub repo: Option<Repo>, | ||
| @@ -194,6 +199,62 @@ fn notification_pref(all: &serde_json::Value) -> Result<Preferences> { | ||
Ok(serde_json::from_value(pref.clone())?) | ||
} | ||
| ||
/// What an account chose to hear of a repository, a pull or an issue. | ||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct Subscription { | ||
/// The repository's record, by the name it has now, or the pull or | ||
/// issue. | ||
pub subject: String, | ||
/// `all`, `releases` or `ignore` for a repository; `subscribed` or | ||
/// `muted` for a pull or an issue; `participating` with no choice. | ||
pub level: String, | ||
/// When it chose, from which it counts. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub created_at: Option<String>, | ||
} | ||
| ||
/// The repository `url` names, as `putSubscription` takes it: its record's | ||
/// URI, by its owner's DID. | ||
pub async fn repo_subject(ctx: &Ctx, url: &crate::url::RepoUrl) -> Result<String> { | ||
let did = crate::identity::resolve(ctx, &url.authority).await?.did; | ||
Ok(crate::forge::repo_uri(&did, &url.name)) | ||
} | ||
| ||
/// Has whoever is logged in hear of `subject`, a repository's record or a | ||
/// pull or an issue, as `level` says; answers what it hears of it now. | ||
pub async fn subscribe(ctx: &Ctx, subject: &str, level: &str) -> Result<Subscription> { | ||
let me = session::me(ctx).await?; | ||
let (xrpc, did) = appview(ctx).await?; | ||
let auth = Auth::Bearer(auth::service_token(ctx, &me, &did, PUT_SUBSCRIPTION).await?); | ||
let body = json!({ "subject": subject, "level": level }); | ||
xrpc.procedure(PUT_SUBSCRIPTION, Some(&body), Some(&auth)) | ||
.await | ||
} | ||
| ||
/// Every choice whoever is logged in made of what it hears of, the latest | ||
/// first. | ||
pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Subscription>> { | ||
#[derive(Deserialize)] | ||
struct Page { | ||
subscriptions: Vec<Subscription>, | ||
cursor: Option<String>, | ||
} | ||
let me = session::me(ctx).await?; | ||
let (xrpc, did) = appview(ctx).await?; | ||
let auth = Auth::Bearer(auth::service_token(ctx, &me, &did, LIST_SUBSCRIPTIONS).await?); | ||
let mut all = Vec::new(); | ||
// An empty cursor starts at the first page. | ||
let mut cursor = Some(String::new()); | ||
while let Some(at) = cursor.take() { | ||
let params = [("limit", "100"), ("cursor", at.as_str())]; | ||
let page: Page = xrpc.query(LIST_SUBSCRIPTIONS, ¶ms, Some(&auth)).await?; | ||
all.extend(page.subscriptions); | ||
cursor = page.cursor; | ||
} | ||
Ok(all) | ||
} | ||
| ||
/// The newest `limit` notifications of whoever is logged in, of one `kind` | ||
/// as `listNotifications` takes it, or all; with `seen`, and all of them, | ||
/// marked seen up to the newest after. Those of one kind are never marked, | ||
crates/knotless/src/oauth.rs+2−0
| @@ -99,6 +99,8 @@ pub static SCOPE: LazyLock<String> = LazyLock::new(|| { | ||
crate::notifications::SEEN, | ||
crate::notifications::GET_PREFERENCES, | ||
crate::notifications::PUT_PREFERENCES, | ||
crate::notifications::PUT_SUBSCRIPTION, | ||
crate::notifications::LIST_SUBSCRIPTIONS, | ||
crate::account::FORGET, | ||
]; | ||
scope.extend(called.map(|lxm| format!("rpc:{lxm}?aud=*"))); | ||
crates/knotless-mcp/src/lib.rs+45−2
| @@ -665,6 +665,18 @@ pub struct NotificationSettings { | ||
pub followed_only: Option<bool>, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct Subscribe { | ||
/// A repository, as `handle/name`, to watch or ignore. | ||
pub repo: Option<String>, | ||
/// Or a pull's or issue's at:// URI, to subscribe to or mute. | ||
pub uri: Option<String>, | ||
/// For a repository, `all` (the default), `releases` or `ignore`; for | ||
/// a pull or an issue, `subscribed` (the default) or `muted`. Either | ||
/// way, `participating` forgets the choice. | ||
pub level: Option<String>, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct Star { | ||
/// The repository, as `handle/name`: a public one, since a star is. | ||
| @@ -2465,7 +2477,7 @@ impl Server { | ||
} | ||
| ||
#[tool( | ||
description = "Which notifications you hear of, as the appview keeps it: each reason on or off (star, follow, pull, issue, comment, mention, review-requested, invite, checks-failed, ejected), and whether only of what accounts you follow did. Changes what is given first; with nothing given, only says." | ||
description = "Which notifications you hear of, as the appview keeps it: each reason on or off (star, follow, pull, issue, comment, mention, review-requested, invite, checks-failed, ejected, subscribed, watching), and whether only of what accounts you follow did. Changes what is given first; with nothing given, only says." | ||
)] | ||
async fn notification_settings( | ||
&self, | ||
| @@ -2490,6 +2502,37 @@ impl Server { | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "Choose what you hear of a repository, a pull or an issue, as the appview keeps it for you alone: watch a repository for all it does (pulls and issues opened, comments, releases), for its releases alone, or ignore it; subscribe to every comment on a pull or an issue, or mute it, hearing nothing of it but a review asked of you. participating forgets the choice, so you hear as your part in it says. Says what you hear of it now; with neither repo nor uri, lists every choice, the latest first." | ||
)] | ||
async fn subscribe( | ||
&self, | ||
Parameters(p): Parameters<Subscribe>, | ||
) -> Result<CallToolResult, McpError> { | ||
use knotless::notifications::{repo_subject, subscribe, subscriptions}; | ||
answer( | ||
async { | ||
let chose = match (p.repo, p.uri) { | ||
(None, None) => { | ||
let all = subscriptions(&self.ctx).await?; | ||
return Ok(json!({ "subscriptions": all })); | ||
} | ||
(Some(repo), _) => { | ||
let subject = repo_subject(&self.ctx, &repo_url(&repo)?).await?; | ||
let level = p.level.as_deref().unwrap_or("all"); | ||
subscribe(&self.ctx, &subject, level).await? | ||
} | ||
(None, Some(uri)) => { | ||
let level = p.level.as_deref().unwrap_or("subscribed"); | ||
subscribe(&self.ctx, &uri, level).await? | ||
} | ||
}; | ||
Ok(serde_json::to_value(chose)?) | ||
} | ||
.await, | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "React to a pull, an issue or a comment on a public repository with +1, -1, laugh, hooray, confused, heart, rocket or eyes, or take the reaction back: a dev.knotless.reaction record in your account, which appviews count, and show_item shows. Reacting so again leaves the reaction there is." | ||
)] | ||
| @@ -3266,7 +3309,7 @@ impl Server { | ||
#[tool_handler( | ||
router = self.router(), | ||
name = "knotless", | ||
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label) and merge (with queue, into the merge queue: see merge_queue and leave_queue), and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. Make, choose and delete themes for knotless's pages with theme, and find others' with list_themes. See what others did that concerns you with notifications, and choose which you hear of with notification_settings. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on." | ||
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label) and merge (with queue, into the merge queue: see merge_queue and leave_queue), and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. Make, choose and delete themes for knotless's pages with theme, and find others' with list_themes. See what others did that concerns you with notifications, choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with subscribe. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on." | ||
)] | ||
impl ServerHandler for Server { | ||
async fn call_tool( | ||
crates/knotless-mcp/tests/mcp.rs+87−0
| @@ -1979,6 +1979,93 @@ fn agents_hear_what_others_did() { | ||
assert!(err && why.contains("KNOTLESS_APPVIEW"), "{why}"); | ||
} | ||
| ||
/// An agent watches a repository it has no part in and hears of an | ||
/// issue opened on it; subscribes to the issue and hears of its comments; | ||
/// mutes it and hears nothing of it; and lists what it chose, over MCP or | ||
/// the command line. | ||
#[test] | ||
fn agents_watch_subscribe_and_mute() { | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let (base, _appview, _proxy) = appview(&rt, ""); | ||
let [mut owner, mut fan, mut watcher] = [logged_in(), logged_in(), logged_in()]; | ||
for s in [&mut owner, &mut fan, &mut watcher] { | ||
s.set("KNOTLESS_APPVIEW", &base); | ||
s.set("KNOTLESS_DID_WEB_SCHEME", "http"); | ||
} | ||
let (o, f, w) = ( | ||
Client::new(&owner), | ||
Client::new(&fan), | ||
Client::new(&watcher), | ||
); | ||
let repo = c_repo(&o.json("create_repo", json!({"name": "watched"}))); | ||
o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]})); | ||
let record = format!("at://{}/dev.knotless.repo/watched", owner.account.did); | ||
let watching = w.json("subscribe", json!({"repo": repo})); | ||
assert_eq!( | ||
(&watching["subject"], &watching["level"]), | ||
(&json!(record), &json!("all")) | ||
); | ||
let issue = f.json("open_issue", json!({"repo": repo, "title": "Theirs"}))["uri"] | ||
.as_str() | ||
.unwrap() | ||
.to_string(); | ||
let peek = || w.json("notifications", json!({"seen": false}))["notifications"].clone(); | ||
let heard = |reason: &str| { | ||
let all = peek(); | ||
let all = all.as_array().unwrap().iter(); | ||
all.filter(|n| n["reason"] == reason).count() | ||
}; | ||
wait_until(|| heard("watching") == 1); | ||
assert_eq!(peek()[0]["subject"], issue.as_str()); | ||
| ||
// Subscribed, its comments are heard of as such, not as watching. | ||
let subscribed = w.json("subscribe", json!({"uri": issue})); | ||
assert_eq!(subscribed["level"], "subscribed"); | ||
f.json( | ||
"comment", | ||
json!({"repo": repo, "uri": issue, "body": "more"}), | ||
); | ||
wait_until(|| heard("subscribed") == 1); | ||
assert_eq!(heard("watching"), 1); | ||
| ||
// Muted on the command line, nothing of it is heard. | ||
let (code, said) = watcher.cli(&["notifications", "mute", &issue], b""); | ||
assert_eq!((code, said.trim()), (0, format!("muted\t{issue}").as_str())); | ||
assert!(peek().as_array().unwrap().is_empty()); | ||
let url = format!("knotless://{}/watched", owner.account.handle); | ||
let (code, said) = watcher.cli(&["notifications", "watch", &url, "releases"], b""); | ||
assert_eq!( | ||
(code, said.trim()), | ||
(0, format!("releases\t{record}").as_str()) | ||
); | ||
let (code, listed) = watcher.cli(&["notifications", "subscriptions"], b""); | ||
assert_eq!((code, listed.lines().count()), (0, 2), "{listed}"); | ||
assert!(listed.starts_with("releases\t"), "{listed}"); | ||
let all = w.json("subscribe", json!({})); | ||
assert_eq!(all["subscriptions"][1]["subject"], issue.as_str()); | ||
assert!(all["subscriptions"][1]["createdAt"].is_string()); | ||
// Each forgotten: the issue heard of again as watching does. | ||
let (code, said) = watcher.cli(&["notifications", "unsubscribe", &issue], b""); | ||
assert_eq!( | ||
(code, said.trim()), | ||
(0, format!("participating\t{issue}").as_str()) | ||
); | ||
let (code, said) = watcher.cli(&["notifications", "subscribe", &issue], b""); | ||
assert_eq!((code, said.split('\t').next()), (0, Some("subscribed"))); | ||
let (code, _) = watcher.cli(&["notifications", "watch", &url], b""); | ||
assert_eq!(code, 0); | ||
let forgot = w.json("subscribe", json!({"repo": repo, "level": "participating"})); | ||
assert_eq!(forgot.get("createdAt"), None); | ||
let muted = w.json("subscribe", json!({"uri": issue, "level": "muted"})); | ||
assert_eq!(muted["level"], "muted"); | ||
// Refused for what is no repository, pull or issue, or a level wrong | ||
// for it. | ||
let (why, err) = w.call("subscribe", json!({"uri": record, "level": "muted"})); | ||
assert!(err && why.contains("no level"), "{why}"); | ||
let (code, _) = watcher.cli(&["notifications", "mute"], b""); | ||
assert_eq!(code, 1); | ||
} | ||
| ||
/// What only the command line did before, as tools: an owner's | ||
/// house-keeping, from making a repository to deleting it. | ||
#[test] | ||
crates/knotless-serve/src/web/items.rs+6−1
| @@ -283,7 +283,12 @@ pub async fn get_thread( | ||
_ => None, | ||
}; | ||
let record = repo.record(&app).unwrap_or_default(); | ||
let viewer = asking.map(|did| threads::viewer(&record, &repo.did, &item, did)); | ||
let viewer = asking.map(|did| { | ||
let mut viewer = threads::viewer(&record, &repo.did, &item, did); | ||
let commented = comments.iter().any(|c| c.author == did); | ||
threads::heard(&app, &repo, &item, did, commented, &mut viewer); | ||
viewer | ||
}); | ||
let (head, checks, minutes) = match &item.head { | ||
Some(head) => { | ||
// Its own runs, by the target's runners, then those of its | ||
crates/knotless-serve/src/web/notifications.rs+6−2
| @@ -126,15 +126,19 @@ async fn page( | ||
Page { limit, after, kind }: Page<'_>, | ||
) -> Result<(Vec<Notified>, Option<String>)> { | ||
let orgs = super::orgs::joined(app, did); | ||
let subs = index | ||
.subscriptions(did) | ||
.context("reading what it chose to hear of") | ||
.map_err(AppError::internal)?; | ||
let (mut found, cursor) = index | ||
.notifications(did, &orgs, limit, after, kind) | ||
.notifications_as(did, &orgs, &subs, limit, after, kind) | ||
.context("reading notifications") | ||
.map_err(AppError::internal)?; | ||
let mut more = cursor.is_some(); | ||
// There is one wherever there is a gateway, and so a login. | ||
for spaces in app.spaces.iter() { | ||
let (private, cursor) = spaces | ||
.notifications(did, &orgs, limit, after, kind) | ||
.notifications_as(did, &orgs, &subs, limit, after, kind) | ||
.context("reading private notifications") | ||
.map_err(AppError::internal)?; | ||
more |= cursor.is_some(); | ||
crates/knotless-serve/src/web/subscriptions.rsnew+165−0
| @@ -0,0 +1,165 @@ | ||
//! What an account chose to hear of a repository, a pull or an issue: | ||
//! `dev.knotless.putSubscription` and `listSubscriptions`, for the account | ||
//! a service token or the pages' session cookie names. The appview keeps | ||
//! each choice for that account alone, as it keeps its preferences: it is | ||
//! how one reader wants its notifications read, nothing anyone else needs, | ||
//! and a record would publish it. [`Index::notifications`] reads them. | ||
| ||
use super::notifications::asking; | ||
use super::repo::{found, invalid}; | ||
use super::*; | ||
use crate::index::{PARTICIPATING, Subscription}; | ||
use crate::viewer::Viewer; | ||
use axum::Extension; | ||
| ||
/// The most choices the appview keeps for one account. | ||
pub const MAX: usize = 1000; | ||
/// What a choice past [`MAX`] is answered. | ||
const FULL: &str = "1000 choices at most: forget one first, as participating"; | ||
| ||
/// The levels a repository takes, past [`PARTICIPATING`]. | ||
const REPO_LEVELS: [&str; 3] = ["all", "releases", "ignore"]; | ||
/// The levels a pull or an issue takes, past [`PARTICIPATING`]. | ||
const THREAD_LEVELS: [&str; 2] = ["subscribed", "muted"]; | ||
| ||
/// The procedure and the query. | ||
pub fn routes() -> Router<Arc<App>> { | ||
Router::new() | ||
.route("/xrpc/dev.knotless.putSubscription", post(put_subscription)) | ||
.route( | ||
"/xrpc/dev.knotless.listSubscriptions", | ||
get(list_subscriptions), | ||
) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
pub struct PutInput { | ||
subject: String, | ||
level: String, | ||
} | ||
| ||
/// `dev.knotless.listSubscriptions#subscription`. | ||
#[derive(Serialize, Debug, PartialEq)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct SubscriptionView { | ||
subject: String, | ||
level: String, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
created_at: Option<String>, | ||
} | ||
| ||
/// What the index said, as the pages' error if it failed. | ||
fn indexed<T>(done: rusqlite::Result<T>) -> Result<T> { | ||
let done = done.map_err(anyhow::Error::from); | ||
Ok(done.map_err(AppError::internal)?) | ||
} | ||
| ||
/// `dev.knotless.putSubscription`. With the pages' cookie, only from the | ||
/// site itself. | ||
pub async fn put_subscription( | ||
State(app): State<Arc<App>>, | ||
Extension(viewer): Extension<Viewer>, | ||
headers: HeaderMap, | ||
axum::Json(input): axum::Json<PutInput>, | ||
) -> Result<Response> { | ||
let token = headers.contains_key(header::AUTHORIZATION); | ||
if !token && !crate::login::same_site(&headers) { | ||
return Err(Shown(StatusCode::FORBIDDEN, "not from this site".into())); | ||
} | ||
let (did, index) = asking(&app, viewer)?; | ||
let (subject, levels) = match forge::parse_uri(&input.subject) { | ||
Ok((_, nsid::REPO, _)) => { | ||
let repo = found(&app, &input.subject).await?; | ||
(Subscription::repo(&repo.did, &repo.key), &REPO_LEVELS[..]) | ||
} | ||
Ok((author, collection @ (nsid::PULL | nsid::ISSUE), rkey)) => ( | ||
format!("at://{author}/{collection}/{rkey}"), | ||
&THREAD_LEVELS[..], | ||
), | ||
_ => { | ||
let why = format!("{} is no repository, pull or issue", input.subject); | ||
return Err(invalid(why)); | ||
} | ||
}; | ||
let level = input.level.as_str(); | ||
if level != PARTICIPATING && !levels.contains(&level) { | ||
let why = format!( | ||
"{level:?} is no level for it: {PARTICIPATING}, {}", | ||
levels.join(", ") | ||
); | ||
return Err(invalid(why)); | ||
} | ||
let kept = indexed(index.subscribe(&did, &subject, level, &knotless::records::now(), MAX))?; | ||
kept.then_some(()).ok_or(invalid(FULL.into()))?; | ||
let kept = indexed(index.subscriptions(&did))?; | ||
let now = kept.into_iter().find(|s| s.subject == subject); | ||
Ok(answer(SubscriptionView { | ||
subject: input.subject, | ||
level: now | ||
.as_ref() | ||
.map_or(PARTICIPATING.into(), |s| s.level.clone()), | ||
created_at: now.map(|s| s.at), | ||
})) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
pub struct ListParams { | ||
limit: Option<usize>, | ||
cursor: Option<String>, | ||
} | ||
| ||
/// `dev.knotless.listSubscriptions`. | ||
pub async fn list_subscriptions( | ||
State(app): State<Arc<App>>, | ||
Extension(viewer): Extension<Viewer>, | ||
Query(p): Query<ListParams>, | ||
) -> Result<Response> { | ||
let (did, index) = asking(&app, viewer)?; | ||
let limit = p.limit.unwrap_or(50).clamp(1, 100); | ||
// An empty cursor is none, as a client paging from the start sends. | ||
let given = p.cursor.as_deref().filter(|c| !c.is_empty()); | ||
let skip = match given.map(str::parse::<usize>) { | ||
None => 0, | ||
Some(Ok(skip)) => skip, | ||
Some(Err(_)) => return Err(invalid("cursor is no count".into())), | ||
}; | ||
let all = indexed(index.subscriptions(&did))?; | ||
let more = all.len() > skip + limit; | ||
let mut subscriptions = Vec::new(); | ||
for s in all.into_iter().skip(skip).take(limit) { | ||
subscriptions.extend(shown(&app, &index, s)); | ||
} | ||
let cursor = more.then(|| (skip + limit).to_string()); | ||
Ok(answer(ListOutput { | ||
subscriptions, | ||
cursor, | ||
})) | ||
} | ||
| ||
#[derive(Serialize)] | ||
pub struct ListOutput { | ||
subscriptions: Vec<SubscriptionView>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
cursor: Option<String>, | ||
} | ||
| ||
/// `s` as `listSubscriptions` gives it: a repository by the name it has | ||
/// now, in the index or this server's index of its space; none for one | ||
/// neither has. | ||
fn shown(app: &App, index: &Index, s: Subscription) -> Option<SubscriptionView> { | ||
let subject = match s.repo_of() { | ||
Some((did, key)) => { | ||
let indexes = std::iter::once(index).chain(app.spaces.as_deref()); | ||
let name = indexes | ||
.filter_map(|i| i.name(did, key).ok().flatten()) | ||
.next()?; | ||
forge::repo_uri(did, &name) | ||
} | ||
None => s.subject.clone(), | ||
}; | ||
Some(SubscriptionView { | ||
subject, | ||
level: s.level, | ||
created_at: Some(s.at), | ||
}) | ||
} | ||
crates/knotless-serve/src/web/threads.rs+33−0
| @@ -47,6 +47,37 @@ pub struct ThreadViewer { | ||
revert: bool, | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
labels: Vec<LabelView>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
subscription: Option<String>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
notified_for: Option<&'static str>, | ||
} | ||
| ||
/// What `did` hears of `item`, a pull or an issue on `repo`, into | ||
/// `viewer`: what it chose, as the index keeps it, and why it hears of | ||
/// new comments on it without a choice: as its author, as one that | ||
/// `commented` on it, or as watching the repository. | ||
pub(super) fn heard( | ||
app: &App, | ||
repo: &Repo, | ||
item: &forge::Item, | ||
did: &str, | ||
commented: bool, | ||
viewer: &mut ThreadViewer, | ||
) { | ||
let watched = crate::index::Subscription::repo(&repo.did, &repo.key); | ||
let index = app.index.as_ref(); | ||
let chose = |subject: &str| index.and_then(|i| i.subscription(did, subject).ok().flatten()); | ||
viewer.subscription = chose(&item.uri); | ||
viewer.notified_for = if did == item.author { | ||
Some("author") | ||
} else if commented { | ||
Some("commented") | ||
} else if chose(&watched).as_deref() == Some("all") { | ||
Some("watching") | ||
} else { | ||
None | ||
}; | ||
} | ||
| ||
/// What `did` may do with `item`, a pull or an issue on the repository | ||
| @@ -67,6 +98,8 @@ pub(super) fn viewer( | ||
hide: did == owner, | ||
revert: maintainer && merged, | ||
labels: labels.map(label_view).collect(), | ||
subscription: None, | ||
notified_for: None, | ||
} | ||
} | ||
| ||
crates/knotless-serve/src/web/views.rs+3−0
crates/knotless-serve/src/index.rs+413−11
424 lines changed: not loaded until asked for.