chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
An account's pulls and issues, across repositories
crates/knotless-serve/src/web/actor.rs+105−0
| @@ -372,6 +372,111 @@ pub async fn get_actor_stars( | ||
} | ||
} | ||
| ||
/// The most items a page of `listActorItems` holds, and how many if not asked. | ||
const ACTOR_ITEMS_MAX: usize = 100; | ||
const ACTOR_ITEMS_PER_PAGE: usize = 50; | ||
| ||
#[derive(Deserialize)] | ||
pub struct ActorItemsParams { | ||
actor: String, | ||
kind: String, | ||
relation: Option<String>, | ||
state: Option<String>, | ||
limit: Option<usize>, | ||
cursor: Option<String>, | ||
} | ||
| ||
/// `dev.knotless.listActorItems`'s answer. | ||
#[derive(Serialize)] | ||
pub struct ActorItems { | ||
items: Vec<ActorItem>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
cursor: Option<String>, | ||
} | ||
| ||
/// A pull or an issue, and the repository it is on. | ||
#[derive(Serialize)] | ||
pub struct ActorItem { | ||
item: ItemView, | ||
repo: ActorItemRepo, | ||
} | ||
| ||
#[derive(Serialize)] | ||
pub struct ActorItemRepo { | ||
did: String, | ||
handle: String, | ||
name: String, | ||
} | ||
| ||
/// `dev.knotless.listActorItems`: the pulls or issues an account opened, | ||
/// is assigned or is asked to review, on any repository, newest first. | ||
pub async fn list_actor_items( | ||
State(app): State<Arc<App>>, | ||
Query(p): Query<ActorItemsParams>, | ||
) -> Response { | ||
let Some(index) = app.index.clone() else { | ||
return Shown(StatusCode::NOT_FOUND, "this server keeps no index".into()).into_response(); | ||
}; | ||
match knotless::identity::resolve(&app.ctx, &p.actor).await { | ||
Ok(id) => actor_items(&app, &index, &id.did, &p).await.into_response(), | ||
Err(e) => no_profile(e), | ||
} | ||
} | ||
| ||
async fn actor_items( | ||
app: &Arc<App>, | ||
index: &Index, | ||
did: &str, | ||
p: &ActorItemsParams, | ||
) -> Result<Response> { | ||
if p.kind != "pull" && p.kind != "issue" { | ||
return Err(super::repo::invalid(format!( | ||
"{} is no kind of item", | ||
p.kind | ||
))); | ||
} | ||
let skip = super::repo::skipped(p.cursor.as_deref())?; | ||
let limit = p | ||
.limit | ||
.unwrap_or(ACTOR_ITEMS_PER_PAGE) | ||
.clamp(1, ACTOR_ITEMS_MAX); | ||
let concerns = |i: &Item| match p.relation.as_deref() { | ||
Some("assigned") => i.assignees.iter().any(|a| a == did), | ||
Some("review") => i.reviewers.iter().any(|r| r == did), | ||
_ => i.author == did, | ||
}; | ||
let open = p.state.as_deref() != Some("closed"); | ||
let repos = index.repos_involving(did); | ||
let repos = repos | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let mut found: Vec<(&String, &RepoRecord, Item)> = Vec::new(); | ||
for (owner, record) in &repos { | ||
let made = index.assembled(owner, &record.key, record); | ||
let made = made | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let items = made.items.iter().filter(|i| i.kind == p.kind); | ||
let items = items.filter(|i| concerns(i) && (i.state == "open") == open); | ||
found.extend(items.map(|i| (owner, record, i.clone()))); | ||
} | ||
found.sort_by(|a, b| b.2.created_at.cmp(&a.2.created_at)); | ||
let cursor = (skip + limit < found.len()).then(|| (skip + limit).to_string()); | ||
let page = found.iter().skip(skip).take(limit); | ||
let views = page.map(|(owner, record, item)| async move { | ||
ActorItem { | ||
item: item_view(app, record, item).await, | ||
repo: ActorItemRepo { | ||
did: (*owner).clone(), | ||
handle: actor(app, owner).await.handle, | ||
name: record.name.clone(), | ||
}, | ||
} | ||
}); | ||
let items = futures::future::join_all(views).await; | ||
Ok(answer(ActorItems { items, cursor })) | ||
} | ||
| ||
async fn starred(app: &Arc<App>, index: &Index, did: &str) -> Result<axum::Json<Output>> { | ||
let found = index | ||
.actor_stars(did) | ||
crates/knotless-serve/src/index.rs+30−0
| @@ -1738,6 +1738,31 @@ impl Index { | ||
stmt.query_row([did], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) | ||
} | ||
| ||
/// The record of each repository holding a pull or an issue `did` | ||
/// opened, is assigned or is asked to review, with its owner, leaving | ||
/// out those taken down. | ||
pub fn repos_involving(&self, did: &str) -> rusqlite::Result<Vec<(String, RepoRecord)>> { | ||
let db = self.readers.take(&self.db); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT r.did, r.value FROM ( | ||
SELECT repo_did, repo_key FROM forge WHERE author = ?1 | ||
AND (instr(uri, '/dev.knotless.pull/') OR instr(uri, '/dev.knotless.issue/')) | ||
UNION SELECT f.repo_did, f.repo_key | ||
FROM assigned a CROSS JOIN forge f ON f.uri = a.uri WHERE a.did = ?1 | ||
UNION SELECT f.repo_did, f.repo_key | ||
FROM reviewer a CROSS JOIN forge f ON f.uri = a.uri WHERE a.did = ?1 | ||
) i CROSS JOIN repo r ON r.did = i.repo_did AND r.key = i.repo_key | ||
WHERE r.uri NOT IN down", | ||
)?; | ||
let rows = stmt.query_map([did], owner_and_record)?; | ||
let rows = rows.collect::<rusqlite::Result<Vec<_>>>()?; | ||
let parsed = rows.into_iter().filter_map(|(owner, value)| { | ||
let record = serde_json::from_str(&value).ok()?; | ||
Some((owner, record)) | ||
}); | ||
Ok(parsed.collect()) | ||
} | ||
| ||
/// The pulls `did` opened, on any repository. | ||
pub fn pulls_by(&self, did: &str) -> rusqlite::Result<Vec<Pulls>> { | ||
self.pulls_where("p.author = ?1", did) | ||
| @@ -4389,6 +4414,11 @@ pub struct Pulls { | ||
pub entries: Vec<Entry>, | ||
} | ||
| ||
/// A repository's owner and its record, as a row has them. | ||
fn owner_and_record(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> { | ||
Ok((row.get(0)?, row.get(1)?)) | ||
} | ||
| ||
/// Where an account lives, as its badges count it: whether its DID | ||
/// document ever named another PDS, and whether its PDS is its own. | ||
#[derive(Clone, Copy, Debug, Default, PartialEq)] | ||
crates/knotless-serve/src/web.rs+2−1
| @@ -80,7 +80,7 @@ use actor::{ActorView, RepoViewBasic, repo_basic}; | ||
use queue::InQueue; | ||
use views::*; | ||
| ||
pub use actor::{actor, get_actor_activity, get_actor_repos, get_actor_stars}; | ||
pub use actor::{actor, get_actor_activity, get_actor_repos, get_actor_stars, list_actor_items}; | ||
pub use feed::get_feed; | ||
use follows::{follow_actor, get_followers, get_follows, unfollow_actor}; | ||
use items::{get_pull_diff, get_thread, list_items}; | ||
| @@ -108,6 +108,7 @@ pub fn queries() -> Router<Arc<App>> { | ||
.route("/_live/repo", get(live::follow_repo)) | ||
.route("/_live/check", get(live::follow_check)) | ||
.route("/xrpc/dev.knotless.getActorStars", get(get_actor_stars)) | ||
.route("/xrpc/dev.knotless.listActorItems", get(list_actor_items)) | ||
.route("/xrpc/dev.knotless.starRepo", post(star_repo)) | ||
.route("/xrpc/dev.knotless.unstarRepo", post(unstar_repo)) | ||
.route("/xrpc/dev.knotless.createRepo", post(repos::create_repo)) | ||
crates/knotless-serve/tests/forge.rs+45−0
| @@ -2827,6 +2827,51 @@ fn assignees_and_reviews_maintainers_ask_for() { | ||
"asked by the pull's author", | ||
); | ||
wait_until(|| heard(&collab).contains("review-requested")); | ||
// Each finds what concerns it, across repositories, with where it is. | ||
let concerning = |who: &Sandbox, rest: &str| { | ||
let did = &who.account.did; | ||
let at = format!( | ||
"{}/xrpc/dev.knotless.listActorItems?actor={did}&{rest}", | ||
av.base | ||
); | ||
reqwest::blocking::get(at).unwrap() | ||
}; | ||
let titles = |who: &Sandbox, rest: &str| -> Vec<String> { | ||
let page: Value = concerning(who, rest).json().unwrap(); | ||
let items = page["items"].as_array().unwrap(); | ||
let title = |i: &Value| i["item"]["title"].as_str().unwrap().to_string(); | ||
items.iter().map(title).collect() | ||
}; | ||
assert_eq!(titles(&author, "kind=issue"), ["Broken"]); | ||
assert_eq!(titles(&author, "kind=pull"), ["Fix it"]); | ||
assert!(titles(&author, "kind=issue&state=closed").is_empty()); | ||
assert!(titles(&collab, "kind=issue").is_empty()); | ||
assert_eq!(titles(&collab, "kind=issue&relation=assigned"), ["Broken"]); | ||
assert_eq!(titles(&collab, "kind=pull&relation=review"), ["Fix it"]); | ||
let page: Value = concerning(&collab, "kind=pull&relation=review") | ||
.json() | ||
.unwrap(); | ||
assert_eq!(page["items"][0]["repo"]["name"], "assigned"); | ||
assert_eq!(page["items"][0]["repo"]["did"], owner.account.did.as_str()); | ||
assert_eq!(page["items"][0]["repo"]["handle"], mine.as_str()); | ||
assert!(page["cursor"].is_null()); | ||
// Across repositories, newest first, a page at a time. | ||
let another = owner.url("assigned-too"); | ||
ok(&owner, &["repo", "create", &another]); | ||
ok(&author, &["issue", "create", &another, "Also broken"]); | ||
wait_until(|| titles(&author, "kind=issue").len() == 2); | ||
assert_eq!(titles(&author, "kind=issue"), ["Also broken", "Broken"]); | ||
let first: Value = concerning(&author, "kind=issue&limit=1").json().unwrap(); | ||
assert_eq!(first["items"][0]["item"]["title"], "Also broken"); | ||
assert_eq!(first["cursor"], "1"); | ||
assert_eq!(titles(&author, "kind=issue&limit=1&cursor=1"), ["Broken"]); | ||
assert_eq!(concerning(&author, "kind=star").status(), 400); | ||
assert_eq!(concerning(&author, "kind=pull&cursor=x").status(), 400); | ||
let unknown = format!( | ||
"{}/xrpc/dev.knotless.listActorItems?actor=nobody.invalid&kind=pull", | ||
av.base | ||
); | ||
assert_eq!(reqwest::blocking::get(unknown).unwrap().status(), 400); | ||
assert_eq!( | ||
ok( | ||
&collab, | ||
crates/knotless-serve/tests/web.rs+1−0
| @@ -2237,6 +2237,7 @@ fn pulls_and_issues() { | ||
"/xrpc/dev.knotless.getFeed".to_string(), | ||
"/xrpc/dev.knotless.listRepos?q=x".to_string(), | ||
"/xrpc/dev.knotless.getActorActivity?actor=did:plc:x".to_string(), | ||
"/xrpc/dev.knotless.listActorItems?actor=did:plc:x&kind=pull".to_string(), | ||
at(&format!("{moved}/pulls")), | ||
] { | ||
let (status, body) = bare.status(&path); | ||
docs/reference/api.md+1−0
| @@ -604,6 +604,7 @@ name it has now. | ||
| `dev.knotless.getRepoStorage?repo=` | what it takes up on its owner's PDS, as `knotless repo storage` counts it: kept per repository, and counted again only once a write to its packs, claims or refs is seen (for a private one, once a sync of its space finds a write), or the pages compact it | | ||
| `dev.knotless.getOrg?actor=` | an organization's members, as the index has its `dev.knotless.org` and `dev.knotless.orgMember` records and its members' `dev.knotless.orgAcceptance` ones: `{org, organization, inside, gateway?, members}`, `gateway` the one its public `dev.knotless.org` record names, each member `{actor, role, standing, invitedAt, expiresAt?, joinedAt?}`, the first invited first, `standing` one of `invited`, `lapsed`, `public` and `hidden`. To anyone, its members in public; to the organization and its members, logged in or with a service token (`inside`), its invitations and hidden members too, from its space, which this server keeps an index of; to an account it invited, its own entry. `organization: false`, with no members, for an account that is none; `ProfileNotFound` for an unknown account | | ||
| `dev.knotless.getActorStars?actor=` | the public repositories an account starred, newest first, each with its `language`, `starCount` and `forkCount`: 404 with no index, `ProfileNotFound` for an unknown account | | ||
| `dev.knotless.listActorItems?actor=&kind=&relation=&state=&limit=&cursor=` | the open or closed pulls or issues an account opened, is assigned (`relation=assigned`) or is asked to review (`relation=review`), on any public repository, newest first, each with `repo`, its owner and name | | ||
| `dev.knotless.getMergeStatus?repo=&pull=` | whether an open pull can merge now, as `knotless pull merge --check` says ([below](#landing-and-checks)): 401 with no asker, 400 for a pull not open, 404 for none | | ||
| `dev.knotless.listWorkflows?repo=&ref=` | the workflows at a ref (the default branch), in file name order, and what runs each there: `push`, `pull`, `asked` (its owner may ask for it there by name), `error`; 404 for no such ref | | ||
| `dev.knotless.listRunners?repo=` | the runners the repository names, as accounts, for a public repository the minutes each counts for its owner (`dev.knotless.minutes`), and the key each publishes to seal secrets to (`dev.knotless.runnerKey`), as `keys`, `{runner, key}` | | ||
lexicons/dev/knotless/listActorItems.jsonnew+43−0
| @@ -0,0 +1,43 @@ | ||
{ | ||
"lexicon": 1, | ||
"id": "dev.knotless.listActorItems", | ||
"description": "The pulls or issues that concern an account, on any repository.", | ||
"defs": { | ||
"main": { | ||
"type": "query", | ||
"description": "The pulls or issues an account opened, is assigned or is asked to review, on any public repository an appview has indexed, newest first, a page at a time: follow `cursor` until it is absent. No authentication; responses are not personalized.", | ||
"parameters": { | ||
"type": "params", | ||
"required": ["actor", "kind"], | ||
"properties": { | ||
"actor": { "type": "string", "format": "at-identifier", "description": "The account, by DID or handle." }, | ||
"kind": { "type": "string", "knownValues": ["pull", "issue"], "maxLength": 16 }, | ||
"relation": { "type": "string", "knownValues": ["created", "assigned", "review"], "maxLength": 16, "description": "Those it opened, is assigned, or is asked to review; those it opened if absent." }, | ||
"state": { "type": "string", "knownValues": ["open", "closed"], "maxLength": 16, "description": "Open ones, or those closed and merged; open if absent." }, | ||
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, | ||
"cursor": { "type": "string", "maxLength": 32, "description": "How many the pages before held." } | ||
} | ||
}, | ||
"output": { | ||
"encoding": "application/json", | ||
"schema": { | ||
"type": "object", | ||
"required": ["items"], | ||
"properties": { | ||
"items": { "type": "array", "items": { "type": "ref", "ref": "#actorItem" } }, | ||
"cursor": { "type": "string", "description": "Absent on the last page." } | ||
} | ||
} | ||
} | ||
}, | ||
"actorItem": { | ||
"type": "object", | ||
"description": "A pull or an issue, and the repository it is on.", | ||
"required": ["item", "repo"], | ||
"properties": { | ||
"item": { "type": "ref", "ref": "dev.knotless.defs#itemView" }, | ||
"repo": { "type": "ref", "ref": "dev.knotless.getFeed#repoView" } | ||
} | ||
} | ||
} | ||
} | ||