~ 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

An owner hears all of its own repository until it chooses otherwise

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/owners-watch
crates/knotless-serve/src/web/notifications.rs+8−1
@@ -126,10 +126,17 @@ pub(super) async fn page(
Page { limit, after, kind }: Page<'_>,
) -> Result<(Vec<Notified>, Option<String>)> {
let orgs = super::orgs::joined(app, did);
let subs = index
let mut subs = index
.subscriptions(did)
.context("reading what it chose to hear of")
.map_err(AppError::internal)?;
// Its own repositories it chose nothing for, public and private, it
// hears all of, each once.
for kept in std::iter::once(index).chain(app.spaces.iter().map(|s| &**s)) {
let owned = kept.owned_watches(did, &subs);
let owned = owned.context("reading its repositories");
subs.extend(owned.map_err(AppError::internal)?);
}
let (mut found, cursor) = index
.notifications_as(did, &orgs, &subs, limit, after, kind)
.context("reading notifications")
crates/knotless-serve/src/web/threads.rs+2−2
@@ -77,15 +77,15 @@ pub(super) fn heard(
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());
let watching = index.and_then(|i| i.watching(did, &repo.did, &repo.key).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") {
} else if watching.as_deref() == Some("all") {
Some("watching")
} else {
None
crates/knotless-serve/src/index.rs+102−3
@@ -2102,7 +2102,9 @@ impl Index {
after: Option<&str>,
kind: Option<&str>,
) -> rusqlite::Result<(Vec<Notified>, Option<String>)> {
let subs = self.subscriptions(did)?;
let mut subs = self.subscriptions(did)?;
let owned = self.owned_watches(did, &subs)?;
subs.extend(owned);
self.notifications_as(did, orgs, &subs, limit, after, kind)
}
@@ -2676,6 +2678,41 @@ impl Index {
rows?.collect()
}
/// What `did` hears of each repository of its own it chose nothing
/// for, among `chosen`, its choices: all it does, as an owner looks
/// after all of it, from when it was made or [`OWNED_SINCE`], the
/// later.
pub fn owned_watches(
&self,
did: &str,
chosen: &[Subscription],
) -> rusqlite::Result<Vec<Subscription>> {
let db = self.readers.take(&self.db);
let mut stmt = db.prepare_cached(
"SELECT key, max(min(created_at), ?2) FROM repo WHERE did = ?1
GROUP BY key ORDER BY key",
)?;
let rows = stmt.query_map([did, OWNED_SINCE], |row| Ok((row.get(0)?, row.get(1)?)));
let rows: Vec<(String, String)> = rows?.collect::<rusqlite::Result<_>>()?;
let chose = |subject: &str| chosen.iter().any(|s| s.subject == subject);
let watched = rows.into_iter().map(|(key, at)| Subscription {
subject: Subscription::repo(did, &key),
level: "all".into(),
releases_at: Some(at.clone()),
at,
});
Ok(watched.filter(|s| !chose(&s.subject)).collect())
}
/// What `did` hears of `owner`'s repository `key`, past what it takes
/// part in: what it chose, else all of it, as its owner. None when
/// it hears only what it takes part in.
pub fn watching(&self, did: &str, owner: &str, key: &str) -> rusqlite::Result<Option<String>> {
let chosen = self.subscription(did, &Subscription::repo(owner, key))?;
let level = chosen.or_else(|| (did == owner).then(|| "all".to_string()));
Ok(level.filter(|l| l != PARTICIPATING))
}
/// What `did` chose to hear of `subject`, if it chose.
pub fn subscription(&self, did: &str, subject: &str) -> rusqlite::Result<Option<String>> {
let db = self.readers.take(&self.db);
@@ -2686,7 +2723,8 @@ impl Index {
/// Keeps that `did` hears of `subject` as `level` says, from `at`:
/// [`PARTICIPATING`] forgets what it chose, so it hears as its part in
/// it says. Choosing the level it chose already keeps it as of when it
/// it says, but of a repository of its own, which it hears all of
/// otherwise, it is kept as a choice. Choosing the level it chose already keeps it as of when it
/// first did; and moving between all of a repository and its releases
/// alone keeps its releases as of when it began to hear of them. False,
/// keeping nothing, for a new choice past `max`.
@@ -2699,7 +2737,12 @@ impl Index {
max: usize,
) -> rusqlite::Result<bool> {
let db = self.db.lock().unwrap();
if level == PARTICIPATING {
// A repository's subject is `did/key`; a pull's or an issue's, an
// at:// URI, whose part before its first `/` is no DID.
let own = subject
.split_once('/')
.is_some_and(|(owner, _)| owner == did);
if level == PARTICIPATING && !own {
db.execute(
"DELETE FROM subscription WHERE did = ?1 AND subject = ?2",
[did, subject],
@@ -4766,6 +4809,11 @@ fn after(cursor: Option<&str>) -> (&str, &str) {
.unwrap_or(("\u{10ffff}", ""))
}
/// When owners began to hear all of their own repositories by default:
/// what was said on one before then they hear of as they did, so the
/// default brings no past along, as no watch does.
pub const OWNED_SINCE: &str = "2026-11-08T00:00:00Z";
/// The level that is no choice at all: hearing of a repository, a pull or
/// an issue as one's part in it says.
pub const PARTICIPATING: &str = "participating";
@@ -5466,6 +5514,57 @@ mod tests {
assert_eq!((found[0].mentioned, found[0].line_comments), (false, None));
}
#[test]
fn an_owner_hears_all_of_its_own_repository_until_it_chooses() {
let dir = tempfile::tempdir().unwrap();
let index = Index::open(&dir.path().join("i.db")).unwrap();
let repo = json!({"uri": "at://did:a/dev.knotless.repo/r", "key": "k"});
let at = |day: u32| format!("2026-01-{day:02}T00:00:00Z");
let watched = Subscription::repo("did:a", "k");
index.put("did:a", nsid::REPO, "r", &owned()).unwrap();
let issue = json!({"repo": repo, "title": "I", "createdAt": at(2)});
index.put("did:b", nsid::ISSUE, "1", &issue).unwrap();
let on = |day: u32| {
json!({"repo": repo, "subject": "at://did:b/dev.knotless.issue/1",
"body": "hi", "createdAt": format!("2026-12-{day:02}T00:00:00Z")})
};
// One said before owners heard all by default, and one after.
let mut before = on(1);
before["createdAt"] = json!(at(3));
index.put("did:c", nsid::COMMENT, "2", &before).unwrap();
index.put("did:c", nsid::COMMENT, "3", &on(1)).unwrap();
let reasons = || {
let (found, _) = index.notifications("did:a", &[], 20, None, None).unwrap();
let each = found.into_iter().map(|n| n.reason);
each.collect::<Vec<_>>()
};
// A comment on a thread it never joined, as watching, from when
// owners began to: and so its pages say.
assert_eq!(reasons(), ["watching", "issue"]);
assert_eq!(
index.watching("did:a", "did:a", "k").unwrap().as_deref(),
Some("all")
);
assert_eq!(index.watching("did:b", "did:a", "k").unwrap(), None);
// Participating is kept, as it is no longer the default.
index
.subscribe("did:a", &watched, PARTICIPATING, &at(4), 9)
.unwrap();
assert_eq!(reasons(), ["issue"]);
assert_eq!(index.watching("did:a", "did:a", "k").unwrap(), None);
let kept = index.subscriptions("did:a").unwrap();
assert_eq!(kept[0].level, PARTICIPATING);
assert!(index.owned_watches("did:a", &kept).unwrap().is_empty());
// Another's participating is forgotten, as ever.
index
.subscribe("did:b", &watched, "all", &at(4), 9)
.unwrap();
index
.subscribe("did:b", &watched, PARTICIPATING, &at(5), 9)
.unwrap();
assert!(index.subscriptions("did:b").unwrap().is_empty());
}
#[test]
fn what_an_account_watches_subscribed_to_and_muted() {
let dir = tempfile::tempdir().unwrap();
crates/knotless-serve/src/web.rs+1−2
@@ -1706,11 +1706,10 @@ impl Repo {
.filter(|l| r.may_label(&self.did, viewer, l));
may.map(label_view)
});
let watched = crate::index::Subscription::repo(&self.did, &self.key);
let subscription = app
.index
.as_ref()
.and_then(|i| i.subscription(viewer, &watched).ok().flatten());
.and_then(|i| i.watching(viewer, &self.did, &self.key).ok().flatten());
RepoViewerState {
star: star.and_then(|s| s.ok().flatten()),
transfer,
docs/design/serve.md+7−1
@@ -1334,7 +1334,13 @@ releases (reason `watching`); at `releases`, the releases alone; and one
it ignores gives it nothing. A pull or an issue it subscribed to gives
it every comment (`subscribed`), and one it muted nothing, mentions
included. A review asked of it is heard of through either, as it waits
on the account. Each choice counts from when it was made, so watching
on the account. An owner watches each repository of its own at `all`
until it chooses otherwise, from when the repository was made or when
this default began (`OWNED_SINCE`), the later, so it brings no past
along: the index adds those choices to what it keeps as it works out its
notifications, from the main index and each index of a space. So for
an owner `participating` is kept as a choice, not forgotten as it is
for anyone else. Each choice counts from when it was made, so watching
a busy repository does not bring its past along; a repository's releases
count from when the account began to hear of them, which moving between
`all` and `releases` keeps (`releases_at`). Each is a reason
docs/reference/api.md+1−1
@@ -690,7 +690,7 @@ from this site` unless its `Origin` is the site's own.
| `POST /xrpc/dev.knotless.blockActor` | `{subject, repo}`, a handle or DID, and optionally the `at://` URI of one of the account's own repositories: blocks it from all of them or from that one, answering `{subject, blocking}`; 400 for the account itself, another's repository or a URI that is no repository's |
| `POST /xrpc/dev.knotless.unblockActor` | `{subject, repo}`: takes those blocks back, answering the same |
| `POST /xrpc/dev.knotless.putPreferences` | `{preferences}`: each takes the place of its kind whole, and the rest stay, answering all of them as `getPreferences`. A `#notificationPref`'s reasons not named are on, and `followedOnly` absent is false; an `#appearancePref` with no `theme` follows the system, and logging in to the pages copies its theme to the browser's `theme` cookie, and for `custom`, its `palette` to the `palette` cookie, and its `night`'s to `theme-night` and `palette-night`. A `#themesPref` holds 50 at most, each a copy, so choosing a theme leaves it be. With a service token too, and with the cookie only from the site; 400 for a kind there is none of, a reason there is none of, a theme not named in `a-z`, `0-9` and `-` (64 at most), `custom` with no palette, a palette not as its lexicon says (its contrast is not checked here), a `source` that is no `dev.knotless.theme`, or an added theme whose `uri` is no `dev.knotless.theme`, whose palette is not as its lexicon says, whose `name` is over 64 characters, whose `creator` is no DID or whose `addedAt` is no time |
| `POST /xrpc/dev.knotless.putSubscription` | `{subject, level}`: what the asker hears of a repository (`all`, `releases` or `ignore`) or a pull or an issue (`subscribed` or `muted`), from now; `participating` forgets the choice. Choosing the level it has keeps it as of then, and moving a repository between `all` and `releases` keeps its releases as of when it began to hear of them. Answers `{subject, level, createdAt}`. With a service token too, and with the cookie only from the site; 400 for a subject that is no repository, pull or issue, a level it cannot take, or past 1,000 choices; 404 for a repository the asker cannot read |
| `POST /xrpc/dev.knotless.putSubscription` | `{subject, level}`: what the asker hears of a repository (`all`, `releases` or `ignore`) or a pull or an issue (`subscribed` or `muted`), from now; `participating` forgets the choice, but on the asker's own repository, which it watches at `all` until it chooses, it is kept. Choosing the level it has keeps it as of then, and moving a repository between `all` and `releases` keeps its releases as of when it began to hear of them. Answers `{subject, level, createdAt}`. With a service token too, and with the cookie only from the site; 400 for a subject that is no repository, pull or issue, a level it cannot take, or past 1,000 choices; 404 for a repository the asker cannot read |
| `POST /xrpc/dev.knotless.createRepo` | `{name, description?, defaultBranch?, private?}`: creates a repository in the account logged in, public or with `private` a space of its own, as `knotless repo create [--private]` does, answering `{uri, name}`; 400 for a name no repository can have, one the account has already, or a private one on a PDS without spaces |
| `POST /xrpc/dev.knotless.updateRepo` | `{repo, description?, links?, topics?, defaultBranch?}`: as `knotless repo edit`, answering `{description, links, topics, defaultBranch}` as they now stand; 400 for a link that is not an http or https address, or more than five, and for a topic that is not letters, digits and dashes, or more than twenty |
| `POST /xrpc/dev.knotless.renameRepo` | `{repo, name}`: as `knotless repo rename`, answering `{uri, name}` |
docs/reference/cli.md+1−1
@@ -223,7 +223,7 @@ A pull's `<at-uri>` is the one `pull create` and `pull list` print.
| --- | --- |
| `notification list [--peek] [--kind <kind>]` | what others did that concerns you, the latest 50, from the appview: `new` (or nothing, once seen), when, why (`star`, `follow`, `pull`, `issue`: on a repository you own, collaborate on, or that an organization you joined owns; `comment`: on your pull or issue, or one you commented on; for a review, `approve` or `changes`; `mention`: a pull, issue or comment that names you as `@handle`; `invite`: an organization's invitation you have yet to answer, with the role it offers, as `as admin`; `checks-failed`: checks failing at your pull's latest run, and `ejected`: the merge queue taking your pull out, each from the runner, with the checks that failed, as `failed test, web`), who, and the repository and title, tab-separated; marks them seen unless `--peek`. `--kind` lists only `mentions`, `reviews`, `comments`, `pulls-and-issues`, `checks` or `stars-and-follows`, and marks none seen, as that would mark seen those of other kinds before them. With no appview, fails `notifications come from an appview, and none is set: set KNOTLESS_APPVIEW` |
| `notification settings [--on <reason>]... [--off <reason>]... [--followed-only \| --anyone]` | which notifications you hear of, kept by the appview: each reason turned on or off, and with `--followed-only` only of what accounts you follow did (`--anyone` undoes it). Prints every reason the appview gives, `<reason>`, a tab and `on` or `off`, then `from`, a tab and `anyone` or `accounts you follow`; with no flags, only prints them. A reason the appview does not give fails `"<reason>" is no reason: star, follow, …`. `notifications`, MCP and the pages leave out what you turned off. An OAuth login from before this asks for it with a new scope: log in again |
| `notification watch <url> [all \| releases \| ignore \| participating]` | what you hear of a repository, kept by the appview: `all` it does (pulls and issues opened, comments, releases; the default), its `releases` alone, nothing (`ignore`, but a review asked of you), or, `participating`, only what you take part in. Prints the level, a tab and the repository's record |
| `notification watch <url> [all \| releases \| ignore \| participating]` | what you hear of a repository, kept by the appview: `all` it does (pulls and issues opened, comments, releases; the default), its `releases` alone, nothing (`ignore`, but a review asked of you), or, `participating`, only what you take part in. Your own repositories are watched at `all` until you choose. Prints the level, a tab and the repository's record |
| `notification subscribe <at-uri>`, `notification mute <at-uri>`, `notification unsubscribe <at-uri>` | hear of every comment on a pull or issue (`subscribed`), nothing of it but a review asked of you (`muted`), or, with `unsubscribe`, as your part in it says again (`participating`). Prints the level, a tab and the URI |
| `notification subscriptions` | every choice `watch`, `subscribe` and `mute` made, the latest first: level, subject and when, tab-separated |
docs/reference/mcp.md+1−1
@@ -290,7 +290,7 @@ They answer `{org, name, organization, inside, gateway?, entries: [{did, handle?
| --- | --- | --- |
| `notification_list` | `knotless notification list` | what others did that concerns you, from the appview (a `mention` reason for a pull, issue or comment that names you as `@handle`, `invite` for an organization's invitation you have yet to answer, its `about` the role it offers, and `checks-failed` and `ejected` for checks failing at your pull's latest run and the merge queue taking it out, from the runner, with `checks` those that failed, as `{workflow, status, check}`), the latest `limit` (50 by default, 1 to 100), as `{notifications: [{reason, author, about, repo, uri, subject, verdict, mentioned, lineComments, checks, new, createdAt}], markedSeen}`: `repo` the repository as `handle/name`, for `pull_show`; `subject` what it is about, the pull or issue a comment is on, else the pull or issue itself, or for a star the repository's record (null for a follow or an invitation); `verdict` a review's; `mentioned` true when it names you though it is heard of for another reason, as a review of your pull that names you is heard of once, as a `comment`; `lineComments` how many line comments a review was sent with; and `new` whether it was new before this listing; marks them all seen after, unless `peek`, and `markedSeen` says it did, so the next listing has them `new: false`. `kind` lists only `mentions`, `reviews`, `comments`, `pulls-and-issues`, `checks` or `stars-and-follows`, and marks none seen |
| `notification_settings` | `knotless notification settings` | which notifications you hear of, kept by the appview: `on` and `off`, lists of reasons (`star`, `follow`, `pull`, `issue`, `comment`, `mention`, `review-requested`, `invite`, `checks-failed`, `ejected`, `subscribed`, `watching`), and `followed_only` or `anyone`, change them first; answers `{reasons: [{reason, enabled}], followed_only}` |
| `notification_watch` | `knotless notification watch` | what you hear of a repository (`repo`, `handle/name`) at `level` `all` (the default), `releases` or `ignore`; `participating` forgets the choice. Answers `{subject, level, createdAt}` |
| `notification_watch` | `knotless notification watch` | what you hear of a repository (`repo`, `handle/name`) at `level` `all` (the default), `releases` or `ignore`; `participating` forgets the choice, but is kept for your own repository, watched at `all` until you choose. Answers `{subject, level, createdAt}` |
| `notification_subscribe`, `notification_mute`, `notification_unsubscribe` | `knotless notification subscribe`, `mute`, `unsubscribe` | what you hear of a pull or an issue (`uri`): every comment, nothing but a review asked of you, or as your part in it says again. Answer `{subject, level, createdAt}` |
| `notification_subscriptions` | `knotless notification subscriptions` | every choice, the latest first, as `{subscriptions}` |
lexicons/dev/knotless/defs.json+1−1
@@ -154,7 +154,7 @@
"star": { "type": "string", "format": "at-uri", "description": "Its dev.knotless.star record naming the repository, if it starred it." },
"transfer": { "type": "ref", "ref": "#repoViewBasic", "description": "Where the owner has offered to move the repository, when it is offered to the account asking, which can take it up (dev.knotless.acceptTransfer)." },
"labels": { "type": "array", "items": { "type": "ref", "ref": "dev.knotless.repo#label" }, "description": "The labels it defines that the account asking may put on its pulls and issues, in its order: as its owner, a collaborator, or a triager of their scopes. Absent when there are none." },
"subscription": { "type": "string", "maxLength": 64, "knownValues": ["all", "releases", "ignore"], "description": "What the account asking chose to hear of it (dev.knotless.putSubscription): `all` it does, its `releases` alone, or nothing, as it `ignore`s it. Absent when it chose nothing, and hears only of what it takes part in." },
"subscription": { "type": "string", "maxLength": 64, "knownValues": ["all", "releases", "ignore"], "description": "What the account asking chose to hear of it (dev.knotless.putSubscription): `all` it does, its `releases` alone, or nothing, as it `ignore`s it. Its owner, until it chooses, hears `all`. Absent when it hears only of what it takes part in." },
"admin": { "type": "boolean", "description": "Whether the account asking may change its settings: as its owner, or as an admin who joined the organization that owns it, when that organization delegated its writes to this appview's gateway, which makes the changes as it. Renaming, deleting and transferring it stay the owner's own. Absent when it may not." }
}
},
lexicons/dev/knotless/putSubscription.json+1−1
@@ -13,7 +13,7 @@
"required": ["subject", "level"],
"properties": {
"subject": { "type": "string", "format": "at-uri", "description": "A repository's dev.knotless.repo record, or a dev.knotless.pull or dev.knotless.issue." },
"level": { "type": "string", "maxLength": 64, "knownValues": ["participating", "all", "releases", "ignore", "subscribed", "muted"], "description": "For a repository: `all` it does, its `releases` alone, or `ignore` it. For a pull or an issue: `subscribed` to every comment, or `muted`. Either way, `participating` forgets the choice, so the account hears as its part in it says." }
"level": { "type": "string", "maxLength": 64, "knownValues": ["participating", "all", "releases", "ignore", "subscribed", "muted"], "description": "For a repository: `all` it does, its `releases` alone, or `ignore` it. For a pull or an issue: `subscribed` to every comment, or `muted`. Either way, `participating` forgets the choice, so the account hears as its part in it says; but for the account's own repository, which it hears all of until it chooses, `participating` is kept as a choice." }
}
}
},