~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Watch a repository, and subscribe to or mute a pull or issue

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/subscriptions
.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, &params, 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.
crates/knotless-serve/src/web.rs+8−0
crates/knotless-serve/tests/gateway/subscriptions.rsnew+116−0
crates/knotless-serve/tests/gateway/threads.rs+1−1
crates/knotless-serve/tests/gateway.rs+2−0
docs/design/access.md+1−1
docs/design/serve.md+28−0
docs/reference/api.md+3−1
docs/reference/cli.md+3−0
docs/reference/mcp.md+2−1
harness/e2e/flows.spec.mjs+22−0
lexicons/dev/knotless/defs.json+2−1
lexicons/dev/knotless/getPreferences.json+3−1
lexicons/dev/knotless/getThread.json+3−1
lexicons/dev/knotless/listNotifications.json+3−3
lexicons/dev/knotless/listSubscriptions.jsonnew+43−0
lexicons/dev/knotless/putSubscription.jsonnew+31−0
web/src/lib/molecules/SubscribeButton.sveltenew+43−0
web/src/lib/molecules/WatchMenu.sveltenew+42−0
web/src/lib/organisms/NotificationSettings.svelte+3−1
web/src/lib/organisms/RepoHeader.svelte+3−1
web/src/lib/notifications.svelte.ts+23−5
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.svelte+2−0
web/src/app.css+11−3