use serde::{Deserialize, Serialize};
use crate::api::browse::{RepoSummary, Results};
use crate::api::model::{Capped, NodeId, RepoId};
use crate::api::{ApiError, GitHub};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StarList {
pub id: NodeId,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub private: bool,
pub count: u64,
pub last_added: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListPage {
pub list: StarList,
pub repos: Results<RepoSummary>,
}
impl crate::api::browse::Page for ListPage {
fn counts(&self) -> (usize, u64) {
self.repos.counts()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mine {
pub id: NodeId,
pub name: String,
pub private: bool,
pub count: u64,
pub has: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MyLists {
pub repo: NodeId,
pub starred: bool,
pub viewer: String,
pub lists: Capped<Mine>,
}
pub const MEMBERS_MAX: usize = 1_000;
impl GitHub {
pub async fn star_lists(&self, login: &str) -> Result<Capped<StarList>, ApiError> {
let vars = serde_json::json!({ "login": login });
let found: wire::Counted<wire::List> = self
.graphql_json(crate::api::raw::STAR_LISTS, vars, login, "/user/lists")
.await?;
let mut lists = found.into_capped(|l| Some(StarList::from(l)));
lists.items.sort_by(|a, b| b.last_added.cmp(&a.last_added));
Ok(lists)
}
pub async fn star_list(
&self,
login: &str,
slug: &str,
after: Option<String>,
) -> Result<ListPage, ApiError> {
let lists = self.star_lists(login).await?;
let Some(list) = lists.iter().find(|l| l.slug == slug).cloned() else {
return Err(ApiError::NotFound(format!("@{login}'s list {slug}")));
};
let cursor = after
.as_deref()
.and_then(|a| a.split_once(' '))
.map(|(_, c)| c);
let vars = serde_json::json!({ "id": list.id.as_str(), "after": cursor });
let items: wire::Connection<wire::Repo> = self
.graphql_json(
&crate::api::raw::star_list_items(),
vars,
&list.name,
"/node/items",
)
.await?;
let mut repos = items.filter_results(wire::Repo::summary);
repos.next = repos.next.map(|c| format!("{} {c}", list.id.as_str()));
let page = ListPage { list, repos };
Ok(self.checked(page, format!("@{login}'s list {slug}")))
}
pub async fn my_lists(&self, repo: &RepoId) -> Result<MyLists, ApiError> {
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name });
let found: wire::Mine = self
.graphql_data(crate::api::raw::MY_LISTS, vars, true)
.await?;
let Some(item) = found.repository else {
return Err(ApiError::NotFound(repo.to_string()));
};
let mut lists = Vec::new();
for list in found.viewer.lists.nodes.into_iter().flatten() {
let has = self.has(&list.id, &item.id, list.items).await?;
lists.push(Mine {
id: list.id,
name: list.name,
private: list.is_private,
count: has.1,
has: has.0,
});
}
let mine = MyLists {
repo: item.id,
starred: item.viewer_has_starred,
viewer: found.viewer.login,
lists: Capped::new(lists, found.viewer.lists.total_count),
};
Ok(mine)
}
async fn has(
&self,
list: &NodeId,
item: &NodeId,
mut page: wire::Connection<wire::Member>,
) -> Result<(Option<bool>, u64), ApiError> {
let total = page.total_count;
let mut read = 0;
loop {
read += page.nodes.len();
if page
.nodes
.iter()
.flatten()
.any(|n| n.id.as_ref() == Some(item))
{
return Ok((Some(true), total));
}
let next = page
.page_info
.end_cursor
.filter(|_| page.page_info.has_next_page);
let Some(after) = next else {
return Ok((Some(false), total));
};
if read >= MEMBERS_MAX {
return Ok((None, total));
}
let vars = serde_json::json!({ "id": list.as_str(), "after": after });
page = self
.graphql_json(
crate::api::raw::LIST_MEMBERS,
vars,
list.as_str(),
"/node/items",
)
.await?;
}
}
pub async fn set_lists(&self, item: &NodeId, lists: &[NodeId]) -> Result<(), ApiError> {
let lists: Vec<&str> = lists.iter().map(NodeId::as_str).collect();
let vars = serde_json::json!({ "item": item.as_str(), "lists": lists });
self.mutate_json(crate::api::raw::SET_LISTS, vars).await
}
pub async fn create_list(&self, name: &str) -> Result<NodeId, ApiError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Answer {
create_user_list: Made,
}
#[derive(Deserialize)]
struct Made {
list: Id,
}
#[derive(Deserialize)]
struct Id {
id: NodeId,
}
let vars = serde_json::json!({ "name": name });
let made: Answer = (self.graphql_data(crate::api::raw::CREATE_LIST, vars, false)).await?;
Ok(made.create_user_list.list.id)
}
pub async fn delete_list(&self, list: &NodeId) -> Result<(), ApiError> {
let vars = serde_json::json!({ "list": list.as_str() });
self.mutate_json(crate::api::raw::DELETE_LIST, vars).await
}
}
mod wire {
use serde::Deserialize;
use crate::api::browse::RepoSummary;
pub use crate::api::browse::wire::{Connection, Count, Counted};
use crate::api::model::{NodeId, RepoId};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct List {
pub id: NodeId,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub is_private: bool,
pub last_added_at: Option<String>,
pub items: Count,
}
impl From<List> for super::StarList {
fn from(l: List) -> Self {
super::StarList {
id: l.id,
name: l.name,
slug: l.slug,
description: l.description.filter(|d| !d.trim().is_empty()),
private: l.is_private,
count: l.items.total_count,
last_added: l.last_added_at,
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Repo {
name_with_owner: Option<String>,
description: Option<String>,
#[serde(default)]
stargazer_count: u64,
#[serde(default)]
fork_count: u64,
primary_language: Option<Language>,
pushed_at: Option<String>,
#[serde(default)]
is_private: bool,
#[serde(default)]
is_fork: bool,
#[serde(default)]
is_archived: bool,
}
#[derive(Deserialize)]
pub struct Language {
name: String,
color: Option<String>,
}
impl Repo {
pub fn summary(self) -> Option<RepoSummary> {
Some(RepoSummary {
repo: RepoId::parse(self.name_with_owner.as_deref()?)?,
description: self.description.filter(|d| !d.trim().is_empty()),
stars: self.stargazer_count,
forks: self.fork_count,
language: self.primary_language.as_ref().map(|l| l.name.clone()),
language_color: self.primary_language.and_then(|l| l.color),
pushed_at: self.pushed_at,
private: self.is_private,
fork: self.is_fork,
archived: self.is_archived,
})
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Mine {
pub repository: Option<Item>,
pub viewer: Viewer,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Item {
pub id: NodeId,
pub viewer_has_starred: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Viewer {
pub login: String,
pub lists: Counted<MyList>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MyList {
pub id: NodeId,
pub name: String,
pub is_private: bool,
pub items: Connection<Member>,
}
#[derive(Deserialize)]
pub struct Member {
pub id: Option<NodeId>,
}
}