chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The feed leaves out what a repository's bots say and push
.changeset/feed-without-bots.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: patch | ||
--- | ||
| ||
# The feed leaves out bots | ||
| ||
The front page's feed no longer shows what a repository's runners and triagers say or push on it, which repeated what people did and crowded them out. A runner that is the repository's owner or a collaborator still shows. A pull the merge queue landed shows as merged by whoever queued it. A bot's own profile still shows everything it did. | ||
crates/knotless-serve/src/web/feed.rs+129−35
| @@ -3,7 +3,7 @@ | ||
//! the viewer follows did. | ||
| ||
use super::*; | ||
use crate::index::{Among, Index, Push, cursor_at}; | ||
use crate::index::{Among, Assembled, Index, Push, cursor_at}; | ||
use crate::viewer::Viewer; | ||
use axum::Extension; | ||
use knotless::records::nsid; | ||
| @@ -157,7 +157,10 @@ fn owner_and_name(uri: &str) -> Option<(String, String)> { | ||
/// and those it follows', with anything on its repositories and those it | ||
/// starred, if named; else everyone's but their stars, which anyone can | ||
/// write as many of as they like. Of every kind, or only `kind`: a kind | ||
/// this server does not know is none. | ||
/// this server does not know is none. Unless `by` is named, of each | ||
/// repository's bots, the runners and triagers it names that are not | ||
/// people who may push, only the pulls they merged, as whoever queued | ||
/// each: what they say and push restates what people did. | ||
#[derive(Clone, Copy)] | ||
struct Whose<'a> { | ||
by: Option<&'a str>, | ||
| @@ -191,6 +194,8 @@ async fn events( | ||
kind, | ||
} = whose; | ||
let wants = |k: &str| kind.is_none_or(|kind| kind == k); | ||
// One account's feed is all it did; the rest leave out bots. | ||
let quiet = by.is_none(); | ||
// Pulls and issues are their records and changes of their state. | ||
let only = match kind { | ||
Some("pulls") => Some("pull"), | ||
| @@ -212,13 +217,13 @@ async fn events( | ||
} | ||
if wants("pushes") { | ||
pushes = index | ||
.pushes(limit, cursor, by, following) | ||
.pushes(limit, cursor, by, following, quiet) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
} | ||
if kind.is_none() || only.is_some() { | ||
said = index | ||
.recent(limit, cursor, by, following, only) | ||
.recent(limit, cursor, by, following, only, quiet) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
} | ||
| @@ -263,6 +268,49 @@ async fn events( | ||
stars.retain(|s| held(&s.created_at, &s.uri)); | ||
} | ||
| ||
// Pulls and issues, as their repositories' owners let them stand, by | ||
// title, and the repositories they are on. | ||
let mut titles: HashMap<String, String> = HashMap::new(); | ||
let on: HashSet<(&str, &str)> = said | ||
.iter() | ||
.map(|(_, did, key)| (did.as_str(), key.as_str())) | ||
.collect(); | ||
let mut on_repos: HashMap<(&str, &str), (String, RepoRecord, Arc<Assembled>)> = HashMap::new(); | ||
for (did, key) in on { | ||
let name = index | ||
.name(did, key) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)? | ||
.unwrap_or_default(); | ||
let record = index | ||
.repo_of(did, key) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let record: RepoRecord = record | ||
.and_then(|v| serde_json::from_value(v).ok()) | ||
.unwrap_or_default(); | ||
let made = index | ||
.assembled(did, key, &record) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
for e in &made.entries { | ||
let collection = forge::parse_uri(&e.uri).unwrap_or_default().1; | ||
if matches!(collection, nsid::PULL | nsid::ISSUE) { | ||
let title = e.value["title"].as_str().unwrap_or_default().to_string(); | ||
titles.insert(e.uri.clone(), title); | ||
} | ||
} | ||
on_repos.insert((did, key), (name, record, made)); | ||
} | ||
// Who each is shown as said or done by. | ||
let authors: Vec<&str> = said | ||
.iter() | ||
.map(|(e, did, key)| { | ||
let (_, record, made) = &on_repos[&(did.as_str(), key.as_str())]; | ||
shown_by(e, did, record, made) | ||
}) | ||
.collect(); | ||
| ||
// Each account named, resolved together. | ||
let parents: Vec<(String, String)> = repos | ||
.iter() | ||
| @@ -272,10 +320,8 @@ async fn events( | ||
let mut dids: HashSet<&str> = parents.iter().map(|(did, _)| did.as_str()).collect(); | ||
dids.extend(repos.iter().map(|r| r.did.as_str())); | ||
dids.extend(pushes.iter().flat_map(|p| [p.did.as_str(), p.by.as_str()])); | ||
dids.extend( | ||
said.iter() | ||
.flat_map(|(e, did, _)| [e.author.as_str(), did.as_str()]), | ||
); | ||
dids.extend(said.iter().map(|(_, did, _)| did.as_str())); | ||
dids.extend(authors.iter().copied()); | ||
dids.extend( | ||
stars | ||
.iter() | ||
| @@ -425,34 +471,11 @@ async fn events( | ||
events.push((last.uri.clone(), event)); | ||
} | ||
| ||
// Pulls and issues, as their repositories' owners let them stand, by | ||
// title. | ||
let mut titles: HashMap<String, String> = HashMap::new(); | ||
let on: HashSet<(&str, &str)> = said | ||
let named: HashMap<(&str, &str), RepoView> = on_repos | ||
.iter() | ||
.map(|(_, did, key)| (did.as_str(), key.as_str())) | ||
.map(|(&(did, key), (name, ..))| ((did, key), repo(did, name))) | ||
.collect(); | ||
let mut named = HashMap::new(); | ||
for (did, key) in on { | ||
let name = index | ||
.name(did, key) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)? | ||
.unwrap_or_default(); | ||
let entries = index | ||
.forge(did, key) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
for e in entries { | ||
let collection = forge::parse_uri(&e.uri).unwrap_or_default().1; | ||
if matches!(collection, nsid::PULL | nsid::ISSUE) { | ||
let title = e.value["title"].as_str().unwrap_or_default().to_string(); | ||
titles.insert(e.uri, title); | ||
} | ||
} | ||
named.insert((did, key), repo(did, &name)); | ||
} | ||
for (e, did, key) in &said { | ||
for ((e, did, key), author) in said.iter().zip(&authors) { | ||
// A pull or an issue is its own subject. One the repository's owner | ||
// keeps out is not shown, nor what is said about it. | ||
let subject = if titles.contains_key(&e.uri) { | ||
| @@ -480,7 +503,7 @@ async fn events( | ||
}; | ||
let event = Event { | ||
created_at: text("createdAt"), | ||
actor: actor(&e.author), | ||
actor: actor(author), | ||
repo: named[&(did.as_str(), key.as_str())].clone(), | ||
action, | ||
}; | ||
| @@ -501,6 +524,28 @@ async fn events( | ||
Ok((events.into_iter().map(|(_, e)| e).collect(), next)) | ||
} | ||
| ||
/// Who `e`, on `owner`'s repository of `record`, is shown as written by: | ||
/// its author, or, for a pull a runner the record names marked merged | ||
/// landing the merge queue, whoever queued it, as the pull's standing in | ||
/// the queue says. A runner that is the owner or a collaborator is a | ||
/// person, and keeps it. | ||
fn shown_by<'a>( | ||
e: &'a forge::Entry, | ||
owner: &str, | ||
record: &RepoRecord, | ||
made: &'a Assembled, | ||
) -> &'a str { | ||
let person = e.author == owner || record.is_collaborator(&e.author); | ||
let bot = record.is_runner(&e.author) && !person; | ||
let merged = e.value["state"] == "merged" && bot; | ||
let subject = e.value["subject"].as_str().unwrap_or_default(); | ||
let item = made.items.iter().find(|i| merged && i.uri == subject); | ||
let queued = item | ||
.and_then(|i| i.queue.as_ref()) | ||
.filter(|q| q.state == "queued"); | ||
queued.map_or(&e.author, |q| &q.by) | ||
} | ||
| ||
/// What a rebase or a rewrite keeps of a commit: who wrote it, when, and | ||
/// what it says. | ||
fn same(c: &read::Commit) -> (&str, &str, &str, &str) { | ||
| @@ -615,6 +660,55 @@ mod tests { | ||
); | ||
} | ||
| ||
#[test] | ||
fn merges_shown_as_queued() { | ||
let pull = "at://did:o/dev.knotless.pull/p"; | ||
let queued = |state: &str| knotless::forge::Queued { | ||
state: state.into(), | ||
reason: None, | ||
commit: None, | ||
checks: vec![], | ||
by: "did:o".into(), | ||
at: "q".into(), | ||
created_at: "t".into(), | ||
}; | ||
let made = |queue| Assembled { | ||
entries: vec![], | ||
items: vec![knotless::forge::Item { | ||
uri: pull.into(), | ||
queue, | ||
..Default::default() | ||
}], | ||
}; | ||
let state = |author: &str, state: &str| forge::Entry { | ||
uri: format!("at://{author}/dev.knotless.state/s"), | ||
author: author.into(), | ||
value: serde_json::json!({"subject": pull, "state": state}), | ||
}; | ||
let record: RepoRecord = serde_json::from_value(serde_json::json!({ | ||
"name": "r", "key": "k", "createdAt": "t", | ||
"collaborators": [{"did": "did:c"}], | ||
"runners": [{"did": "did:bot"}, {"did": "did:o"}, {"did": "did:c"}], | ||
})) | ||
.unwrap(); | ||
let shown = |author: &str, s: &str, made: &Assembled| { | ||
shown_by(&state(author, s), "did:o", &record, made).to_string() | ||
}; | ||
let queue = made(Some(queued("queued"))); | ||
// A runner's merge is whoever queued the pull's. | ||
assert_eq!(shown("did:bot", "merged", &queue), "did:o"); | ||
// Anyone else's, a runner's that is the owner or a collaborator, | ||
// and a runner's other changes, their own. | ||
for who in ["did:m", "did:o", "did:c"] { | ||
assert_eq!(shown(who, "merged", &queue), who); | ||
} | ||
assert_eq!(shown("did:bot", "closed", &queue), "did:bot"); | ||
// A pull no one queued, or that left the queue, is the runner's. | ||
for made in [made(None), made(Some(queued("left")))] { | ||
assert_eq!(shown("did:bot", "merged", &made), "did:bot"); | ||
} | ||
} | ||
| ||
#[test] | ||
fn owners_and_names() { | ||
assert_eq!( | ||
crates/knotless-serve/src/index.rs+135−32
| @@ -2806,6 +2806,18 @@ impl Index { | ||
.optional() | ||
} | ||
| ||
/// The record of `did`'s repository `key`. | ||
pub fn repo_of(&self, did: &str, key: &str) -> rusqlite::Result<Option<Value>> { | ||
let db = self.db.lock().unwrap(); | ||
let found = db.query_row( | ||
"SELECT value FROM repo WHERE did = ?1 AND key = ?2", | ||
[did, key], | ||
|row| row.get::<_, String>(0), | ||
); | ||
let found = found.optional()?; | ||
Ok(found.and_then(|v| serde_json::from_str(&v).ok())) | ||
} | ||
| ||
/// The repository record at `uri`. | ||
pub fn repo(&self, uri: &str) -> rusqlite::Result<Option<Value>> { | ||
let db = self.db.lock().unwrap(); | ||
| @@ -2949,7 +2961,8 @@ impl Index { | ||
/// `author`'s alone, if named; or with `followed_by`, what it and whom | ||
/// it follows wrote, and anything on its repositories and those it | ||
/// starred. With `only`, `pull` or `issue`, those and changes of their | ||
/// state alone; `comment`, comments alone. | ||
/// state alone; `comment`, comments alone. With `quiet`, of a | ||
/// repository's bots ([`bot_on`]) changes of state alone. | ||
pub fn recent( | ||
&self, | ||
limit: usize, | ||
| @@ -2957,6 +2970,7 @@ impl Index { | ||
author: Option<&str>, | ||
followed_by: Option<&str>, | ||
only: Option<&str>, | ||
quiet: bool, | ||
) -> rusqlite::Result<Vec<(Entry, String, String)>> { | ||
let (at, uri) = after(cursor); | ||
// One account's by forge_author_created, which a test of whether | ||
| @@ -2997,27 +3011,31 @@ impl Index { | ||
AND json_extract(f.value, '$.subject') LIKE '%/dev.knotless.' || ?6 || '/%')))) | ||
AND coalesce(json_extract(f.value, '$.subject'), '') NOT IN down | ||
AND f.author NOT IN (SELECT subject FROM block WHERE did = f.repo_did) | ||
AND (NOT ?7 OR f.uri LIKE '%/dev.knotless.state/%' OR NOT {BOT}) | ||
AND NOT EXISTS (SELECT 1 FROM owned o | ||
WHERE o.repo_did = f.repo_did AND o.repo_key = f.repo_key | ||
AND ((o.blocks AND o.subject = f.author) | ||
OR (NOT o.blocks AND o.subject | ||
IN (f.uri, coalesce(json_extract(f.value, '$.subject'), ''))))) | ||
ORDER BY json_extract(f.value, '$.createdAt') DESC, f.uri DESC LIMIT ?1" | ||
.replace("{BY}", by), | ||
.replace("{BY}", by) | ||
.replace("{BOT}", &bot_on("f.author", "f.repo_did", "f.repo_key")), | ||
)?; | ||
let found = params![limit as i64, at, uri, author, followed_by, only]; | ||
let found = params![limit as i64, at, uri, author, followed_by, only, quiet]; | ||
stmt.query_map(found, recent)?.collect() | ||
} | ||
| ||
/// Refs that moved on repositories that stand, newest first; after | ||
/// `cursor`, as [`cursor_at`] makes one, if given: `pusher`'s alone, | ||
/// or those of `followed_by` and whom it follows, if named. | ||
/// or those of `followed_by` and whom it follows, if named; with | ||
/// `quiet`, none by the repository's bots ([`bot_on`]). | ||
pub fn pushes( | ||
&self, | ||
limit: usize, | ||
cursor: Option<&str>, | ||
pusher: Option<&str>, | ||
followed_by: Option<&str>, | ||
quiet: bool, | ||
) -> rusqlite::Result<Vec<Push>> { | ||
let db = self.db.lock().unwrap(); | ||
let (at, uri) = after(cursor); | ||
| @@ -3028,18 +3046,22 @@ impl Index { | ||
Some(_) => "(p.pusher = ?4 OR p.did = ?4)", | ||
None => "?4 IS NULL", | ||
}; | ||
// A pusher is credited only while the repository names them, so | ||
// one it names as a bot is the one credited. | ||
let bot = bot_on("coalesce(p.pusher, p.did)", "p.did", "p.key"); | ||
let mut stmt = db.prepare_cached(&format!( | ||
"SELECT * FROM ( | ||
SELECT r.uri, p.did, r.name, r.fork_of, p.ref, p.before, p.after, p.created_at, | ||
p.uri, {BY} AS by | ||
FROM push p CROSS JOIN repo r ON r.did = p.did AND r.key = p.key | ||
WHERE p.did NOT IN down AND r.uri NOT IN down AND {whose} | ||
AND (p.created_at < ?2 OR (p.created_at = ?2 AND p.uri < ?3))) | ||
AND (p.created_at < ?2 OR (p.created_at = ?2 AND p.uri < ?3)) | ||
AND (NOT ?6 OR NOT {bot})) | ||
WHERE by NOT IN down AND (?4 IS NULL OR by = ?4) | ||
AND (?5 IS NULL OR by = ?5 OR by IN (SELECT subject FROM follow WHERE did = ?5)) | ||
ORDER BY created_at DESC, uri DESC LIMIT ?1" | ||
))?; | ||
let found = params![limit as i64, at, uri, pusher, followed_by]; | ||
let found = params![limit as i64, at, uri, pusher, followed_by, quiet]; | ||
stmt.query_map(found, push)?.collect() | ||
} | ||
| ||
| @@ -3451,6 +3473,19 @@ fn recent(row: &rusqlite::Row) -> rusqlite::Result<(Entry, String, String)> { | ||
Ok((entry(row)?, row.get(3)?, row.get(4)?)) | ||
} | ||
| ||
/// SQL saying whether the account `author` is a bot on the repository of | ||
/// owner `did` and key `key`: one its record names as a runner or a | ||
/// triager that is neither its owner nor a collaborator, who are people | ||
/// a bot may act as. | ||
fn bot_on(author: &str, did: &str, key: &str) -> String { | ||
format!( | ||
"EXISTS (SELECT 1 FROM repo b WHERE b.did = {did} AND b.key = {key} AND b.did != {author} | ||
AND {author} IN (SELECT value ->> '$.did' FROM json_each(b.value, '$.runners') | ||
UNION ALL SELECT value ->> '$.did' FROM json_each(b.value, '$.triagers')) | ||
AND {author} NOT IN (SELECT value ->> '$.did' FROM json_each(b.value, '$.collaborators')))" | ||
) | ||
} | ||
| ||
/// Where a page cut after the record `uri`, made at `at`, starts the next: | ||
/// newest first, ties broken by URI, so the next starts strictly after it. | ||
pub fn cursor_at(at: &str, uri: &str) -> String { | ||
| @@ -3746,7 +3781,10 @@ mod tests { | ||
let mut days = BTreeMap::new(); | ||
days.insert("2026-01-02".to_string(), Day::default()); | ||
let seen = |days: &mut BTreeMap<String, Day>| { | ||
let recent = index.recent(10, None, None, None, None).unwrap().len(); | ||
let recent = index | ||
.recent(10, None, None, None, None, false) | ||
.unwrap() | ||
.len(); | ||
let heard = index | ||
.notifications("did:a", &[], 10, None, None) | ||
.unwrap() | ||
| @@ -4430,19 +4468,25 @@ mod tests { | ||
assert_eq!(index.stars(10, Some(&at), None, None).unwrap(), all[1..]); | ||
// What anyone wrote on a repository it starred, but not elsewhere | ||
// by those it does not follow. | ||
let said = index.recent(10, None, None, a, None).unwrap(); | ||
let said = index.recent(10, None, None, a, None, false).unwrap(); | ||
let said: Vec<_> = said.into_iter().map(|(e, ..)| e.uri).collect(); | ||
assert_eq!(said, ["at://did:c/dev.knotless.issue/5"]); | ||
// Issues alone are both; pulls and comments, none. | ||
let only = |kind| { | ||
index | ||
.recent(10, None, None, None, Some(kind)) | ||
.recent(10, None, None, None, Some(kind), false) | ||
.unwrap() | ||
.len() | ||
}; | ||
assert_eq!((only("issue"), only("pull"), only("comment")), (2, 0, 0)); | ||
assert_eq!(index.recent(10, None, None, None, None).unwrap().len(), 2); | ||
assert!(index.pushes(10, None, None, a).unwrap().is_empty()); | ||
assert_eq!( | ||
index | ||
.recent(10, None, None, None, None, false) | ||
.unwrap() | ||
.len(), | ||
2 | ||
); | ||
assert!(index.pushes(10, None, None, a, false).unwrap().is_empty()); | ||
} | ||
| ||
#[test] | ||
| @@ -4466,8 +4510,8 @@ mod tests { | ||
assert!(index.forks("did:a", "k").is_err()); | ||
assert!(index.forge("did:a", "k").is_err()); | ||
assert!(index.name("did:a", "k").is_err()); | ||
assert!(index.recent(1, None, None, None, None).is_err()); | ||
assert!(index.pushes(1, None, None, None).is_err()); | ||
assert!(index.recent(1, None, None, None, None, false).is_err()); | ||
assert!(index.pushes(1, None, None, None, false).is_err()); | ||
assert!(index.actor_days("did:a", None, "UTC").is_err()); | ||
assert!(index.actor_pushes("did:a", None, "UTC").is_err()); | ||
} | ||
| @@ -4495,7 +4539,13 @@ mod tests { | ||
index.keep_profile("did:a", &[]).unwrap(); | ||
assert_eq!(index.profile("did:a").unwrap(), Some(vec![])); | ||
// Nothing of it is in what the feed shows. | ||
assert_eq!(index.recent(10, None, None, None, None).unwrap().len(), 0); | ||
assert_eq!( | ||
index | ||
.recent(10, None, None, None, None, false) | ||
.unwrap() | ||
.len(), | ||
0 | ||
); | ||
} | ||
| ||
#[test] | ||
| @@ -4683,7 +4733,8 @@ mod tests { | ||
drop(db); | ||
let index = Index::open(&path).unwrap(); | ||
let repo = json!({"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z", | ||
"collaborators": [{"did": "did:b"}], "runners": [{"did": "did:r"}]}); | ||
"collaborators": [{"did": "did:b"}], | ||
"runners": [{"did": "did:r"}, {"did": "did:b"}, {"did": "did:a"}]}); | ||
index.put("did:a", nsid::REPO, "r", &repo).unwrap(); | ||
// did:b made main, and moved it; did:r landed a batch on it; did:c, | ||
// whom the repository does not name, moved it; and the owner did. | ||
| @@ -4703,19 +4754,36 @@ mod tests { | ||
claim("k.m.3", "2026-01-05T00:00:00Z", Some("did:c")); | ||
claim("k.m.4", "2026-01-06T00:00:00Z", None); | ||
let by: Vec<String> = index | ||
.pushes(10, None, None, None) | ||
.pushes(10, None, None, None, false) | ||
.unwrap() | ||
.into_iter() | ||
.map(|p| p.by) | ||
.collect(); | ||
assert_eq!(by, ["did:a", "did:a", "did:r", "did:b", "did:b"]); | ||
let of = |did| index.pushes(10, None, Some(did), None).unwrap().len(); | ||
// Quiet, none of the runner's: the owner and a collaborator it | ||
// names as runners too are people. | ||
let by: Vec<String> = index | ||
.pushes(10, None, None, None, true) | ||
.unwrap() | ||
.into_iter() | ||
.map(|p| p.by) | ||
.collect(); | ||
assert_eq!(by, ["did:a", "did:a", "did:b", "did:b"]); | ||
let of = |did| { | ||
index | ||
.pushes(10, None, Some(did), None, false) | ||
.unwrap() | ||
.len() | ||
}; | ||
assert_eq!((of("did:a"), of("did:b"), of("did:c")), (2, 2, 0)); | ||
// Followers of did:b see its pushes to the owner's repository. | ||
let follow = json!({"subject": "did:b", "createdAt": "2026-01-01T00:00:00Z"}); | ||
index.put("did:f", nsid::FOLLOW, "f", &follow).unwrap(); | ||
assert_eq!( | ||
index.pushes(10, None, None, Some("did:f")).unwrap().len(), | ||
index | ||
.pushes(10, None, None, Some("did:f"), false) | ||
.unwrap() | ||
.len(), | ||
2 | ||
); | ||
// Each account's days count the pushes it made; the owner's | ||
| @@ -4738,7 +4806,7 @@ mod tests { | ||
index | ||
.take_down("did:web:me", "did:b", &json!({"did": "did:b"}), true) | ||
.unwrap(); | ||
assert_eq!(index.pushes(10, None, None, None).unwrap().len(), 3); | ||
assert_eq!(index.pushes(10, None, None, None, false).unwrap().len(), 3); | ||
} | ||
| ||
#[test] | ||
| @@ -4747,6 +4815,9 @@ mod tests { | ||
let index = Index::open(&dir.path().join("i.db")).unwrap(); | ||
let repo = json!({"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z"}); | ||
index.put("did:a", nsid::REPO, "r", &repo).unwrap(); | ||
// Found by its key, not its record's. | ||
assert_eq!(index.repo_of("did:a", "k").unwrap(), Some(repo.clone())); | ||
assert_eq!(index.repo_of("did:a", "r").unwrap(), None); | ||
let claim = |rkey: &str, from: Option<&str>, to: Option<&str>, at: &str| { | ||
let mut c = | ||
json!({"repo": "k", "name": "refs/heads/main", "generation": 0, "createdAt": at}); | ||
| @@ -4796,7 +4867,7 @@ mod tests { | ||
) | ||
.unwrap(); | ||
assert_eq!(days, (Some("2026-01-03".into()), None)); | ||
let pushes = index.pushes(10, None, None, None).unwrap(); | ||
let pushes = index.pushes(10, None, None, None, false).unwrap(); | ||
assert_eq!( | ||
pushes, | ||
[ | ||
| @@ -4836,11 +4907,14 @@ mod tests { | ||
index.delete("did:a", nsid::CLAIM, "k.m.2").unwrap(); | ||
// Compaction drops claims; the history stays. | ||
index.delete("did:a", nsid::CLAIM, "k.m.0").unwrap(); | ||
assert_eq!(index.pushes(10, None, None, None).unwrap().len(), 3); | ||
assert_eq!(index.pushes(1, None, None, None).unwrap().len(), 1); | ||
let all = index.pushes(10, None, None, None).unwrap(); | ||
assert_eq!(index.pushes(10, None, None, None, false).unwrap().len(), 3); | ||
assert_eq!(index.pushes(1, None, None, None, false).unwrap().len(), 1); | ||
let all = index.pushes(10, None, None, None, false).unwrap(); | ||
let at = cursor_at(&all[0].created_at, &all[0].uri); | ||
assert_eq!(index.pushes(10, Some(&at), None, None).unwrap(), all[1..]); | ||
assert_eq!( | ||
index.pushes(10, Some(&at), None, None, false).unwrap(), | ||
all[1..] | ||
); | ||
// The other refs' tips, a deleted one's where it was. | ||
let side = |rkey: &str, c: Value| { | ||
let mut c = c; | ||
| @@ -4877,7 +4951,7 @@ mod tests { | ||
version("k.t", "refs/tags/v1", 1, "t1"); | ||
version("k.t", "refs/tags/v1", 2, "t2"); | ||
version("k.m", main, 1, "a1"); | ||
let pushes = index.pushes(10, None, None, None).unwrap(); | ||
let pushes = index.pushes(10, None, None, None, false).unwrap(); | ||
let tagged: Vec<_> = pushes | ||
.iter() | ||
.filter(|p| p.refname == "refs/tags/v1") | ||
| @@ -4969,22 +5043,32 @@ mod tests { | ||
let thread = at(nsid::COMMENT, "3"); | ||
let resolved = json!({"repo": named, "subject": thread, "state": "resolved", "createdAt": "2026-01-08"}); | ||
index.put("did:b", nsid::STATE, "18", &resolved).unwrap(); | ||
let recent = index.recent(10, None, None, None, None).unwrap(); | ||
let recent = index.recent(10, None, None, None, None, false).unwrap(); | ||
let kinds: Vec<&str> = recent | ||
.iter() | ||
.map(|(e, _, _)| e.uri.rsplit('/').nth(1).unwrap()) | ||
.collect(); | ||
assert_eq!(kinds, [nsid::COMMENT, nsid::PULL, nsid::ISSUE, nsid::STATE]); | ||
assert_eq!((recent[0].1.as_str(), recent[0].2.as_str()), ("did:a", "k")); | ||
assert_eq!(index.recent(1, None, None, None, None).unwrap().len(), 1); | ||
assert_eq!( | ||
index | ||
.recent(1, None, None, None, None, false) | ||
.unwrap() | ||
.len(), | ||
1 | ||
); | ||
// Paged by time and URI, the next page starting strictly after. | ||
let first = &index.recent(1, None, None, None, None).unwrap()[0].0; | ||
let first = &index.recent(1, None, None, None, None, false).unwrap()[0].0; | ||
let at = cursor_at(first.value["createdAt"].as_str().unwrap(), &first.uri); | ||
let rest = index.recent(10, Some(&at), None, None, None).unwrap(); | ||
let rest = index | ||
.recent(10, Some(&at), None, None, None, false) | ||
.unwrap(); | ||
assert_eq!(rest.len(), 3); | ||
assert_eq!(rest[0].0.uri, recent[1].0.uri); | ||
// One account's alone. | ||
let theirs = index.recent(10, None, Some("did:b"), None, None).unwrap(); | ||
let theirs = index | ||
.recent(10, None, Some("did:b"), None, None, false) | ||
.unwrap(); | ||
let theirs: Vec<_> = theirs.iter().map(|(e, ..)| e.uri.as_str()).collect(); | ||
let all: Vec<_> = recent.iter().map(|(e, ..)| e.uri.as_str()).collect(); | ||
let by_b: Vec<_> = all | ||
| @@ -4993,10 +5077,29 @@ mod tests { | ||
.collect(); | ||
assert!(!by_b.is_empty()); | ||
assert_eq!(theirs, by_b); | ||
// Quiet, of a bot of the repository, a triager it names, changes of | ||
// state alone; of its owner, a runner it names too, all. | ||
let repo = json!({"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z", | ||
"runners": [{"did": "did:a"}], "triagers": [{"did": "did:b"}]}); | ||
index.put("did:a", nsid::REPO, "r", &repo).unwrap(); | ||
let quiet = index.recent(10, None, None, None, None, true).unwrap(); | ||
let quiet: Vec<_> = quiet.iter().map(|(e, ..)| e.uri.as_str()).collect(); | ||
let kept: Vec<_> = recent | ||
.iter() | ||
.map(|(e, ..)| e.uri.as_str()) | ||
.filter(|u| !u.starts_with("at://did:b/") || u.contains("/dev.knotless.state/")) | ||
.collect(); | ||
assert!(kept.len() < recent.len()); | ||
assert_eq!(quiet, kept); | ||
| ||
// Gone with the repository. | ||
index.delete("did:a", nsid::REPO, "r").unwrap(); | ||
assert!(index.pushes(10, None, None, None).unwrap().is_empty()); | ||
assert!( | ||
index | ||
.pushes(10, None, None, None, false) | ||
.unwrap() | ||
.is_empty() | ||
); | ||
} | ||
| ||
#[test] | ||
| @@ -5158,7 +5261,7 @@ mod tests { | ||
"createdAt": took, "pusher": pusher}); | ||
index.put("did:o", nsid::CLAIM, rkey, &claim).unwrap(); | ||
} | ||
let pushes = index.pushes(10, None, None, None).unwrap(); | ||
let pushes = index.pushes(10, None, None, None, false).unwrap(); | ||
let mut by: Vec<String> = pushes.into_iter().map(|p| p.by).collect(); | ||
by.sort(); | ||
assert_eq!(by, ["did:m", "did:o"]); | ||
crates/knotless-serve/tests/forge.rs+1−1
| @@ -2784,7 +2784,7 @@ fn a_deleted_repository_leaves_nothing() { | ||
// nor its mirror; the other repository keeps its own. | ||
wait_until(|| said(&key) == 0 && !mirror.exists()); | ||
assert_eq!(said(&kept_key), 1); | ||
let recent = index.recent(50, None, None, None, None).unwrap(); | ||
let recent = index.recent(50, None, None, None, None, false).unwrap(); | ||
assert!(recent.iter().all(|(_, _, k)| k != &key)); | ||
fails(&owner, &["refs", &url]); | ||
} | ||
docs/design/serve.md+7−2
| @@ -281,8 +281,13 @@ answers](../reference/api.md#appview)), and the web pages read the same answers: | ||
runners the repository record names now count, so naming or dropping a runner | ||
takes effect on the next read. [runner.md](runner.md) describes checks. | ||
- `dev.knotless.getFeed`: the front page's recent activity, everyone's or what | ||
the viewer and those it follows did, or one account's. The page puts it | ||
together further: one account's pushes to a repository within half an hour | ||
the viewer and those it follows did, or one account's. Everyone's and the | ||
followed hold nothing a repository's bots said or pushed on it: the | ||
runners and triagers its record names, but its owner and collaborators, | ||
who are people a bot may act as. What a bot says restates what people did | ||
and would crowd them out. A pull a runner merged landing the merge queue | ||
shows as merged by whoever queued it. A bot's own feed holds it all. The | ||
page puts it together further: one account's pushes to a repository within half an hour | ||
are one line, a merged pull takes in the push that landed it, and stars in | ||
a row of one repository, or by one account, are one line. | ||
- `dev.knotless.getActorActivity`: how much one account did each day, and when | ||
lexicons/dev/knotless/getFeed.json+1−1
| @@ -5,7 +5,7 @@ | ||
"defs": { | ||
"main": { | ||
"type": "query", | ||
"description": "What happened on the public repositories an appview has indexed, newest first: repositories created and forked, refs pushed and deleted, pulls and issues opened, discussed and moved between states, and repositories starred. With filter `following`, what the account asking and those it follows did, and anything done on its repositories and those it starred; without, the same for anyone, but no stars.", | ||
"description": "What happened on the public repositories an appview has indexed, newest first: repositories created and forked, refs pushed and deleted, pulls and issues opened, discussed and moved between states, and repositories starred. With filter `following`, what the account asking and those it follows did, and anything done on its repositories and those it starred; without, the same for anyone, but no stars. Without `actor`, what a repository's bots said or pushed on it is left out: the runners and triagers its record names, but its owner and collaborators. A pull a runner merged landing the merge queue is shown as merged by whoever queued it.", | ||
"parameters": { | ||
"type": "params", | ||
"properties": { | ||