~ 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 account's pulls and issues, across repositories

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/actor-items
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" }
}
}
}
}