diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 3e2af28..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 44f61ff..0000000 --- a/netlify.toml +++ /dev/null @@ -1,11 +0,0 @@ -[build] -publish = "dist/" -command = "rustup toolchain install nightly && rustup target add wasm32-unknown-unknown && cargo install cargo-make --force && cargo make build" - -[[plugins]] -package = "@netlify/plugin-lighthouse" - -[[redirects]] -from = "/github" -to = "https://github.com/NiklasEi/leptos-ssg-with-islands" -status = 302 diff --git a/package-lock.json b/package-lock.json index 4d39c4d..3e61465 100644 --- a/package-lock.json +++ b/package-lock.json @@ -412,6 +412,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" diff --git a/src/pages/contributors.rs b/src/pages/contributors.rs index a679d5c..726bc64 100644 --- a/src/pages/contributors.rs +++ b/src/pages/contributors.rs @@ -1,4 +1,4 @@ -use leptos::prelude::*; +use leptos::{prelude::*, serde_json}; #[cfg(feature = "ssr")] use crate::components::ContributorCard; @@ -13,12 +13,12 @@ use std::collections::HashMap; /// Test this query on: https://docs.github.com/es/graphql/overview/explorer #[cfg(feature = "ssr")] const GRAPH_QUERY: &str = r#" -query OrganizationContributors { - organization(login: "RustLangES") { - repositories(first: 100) { +query OrganizationContributors($login: String!, $first: Int!, $after: String, $collaboratorsFirst: Int!, $collaboratorsAfter: String) { + organization(login: $login) { + repositories(first: $first, after: $after) { nodes { name - collaborators(first: 100) { + collaborators(first: $collaboratorsFirst, after: $collaboratorsAfter) { nodes { login avatarUrl @@ -33,13 +33,95 @@ query OrganizationContributors { totalRepositoryContributions } } + pageInfo { + hasNextPage + endCursor + } } } + pageInfo { + hasNextPage + endCursor + } } } } "#; +#[cfg(feature = "ssr")] +#[derive(Debug, Deserialize)] +pub struct GithubResponse { + pub data: Option, +} + +#[cfg(feature = "ssr")] +fn empty_org_data() -> OrganizationData { + OrganizationData { + organization: OrganizationRepositories { + repositories: RepositoryConnection { + nodes: Some(Vec::new()), + page_info: PageInfo { + has_next_page: false, + has_previous_page: false, + start_cursor: None, + end_cursor: None, + }, + }, + }, + } +} + +#[cfg(feature = "ssr")] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PageInfo { + pub has_next_page: bool, + #[serde(default)] + pub has_previous_page: bool, + pub start_cursor: Option, + pub end_cursor: Option, +} + +#[cfg(feature = "ssr")] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryConnection { + #[serde(default)] + pub nodes: Option>, + pub page_info: PageInfo, +} + +#[cfg(feature = "ssr")] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrganizationData { + pub organization: OrganizationRepositories, +} + +#[cfg(feature = "ssr")] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrganizationRepositories { + pub repositories: RepositoryConnection, +} + +#[cfg(feature = "ssr")] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollaboratorConnection { + #[serde(default)] + pub nodes: Option>, + pub page_info: PageInfo, +} + +#[cfg(feature = "ssr")] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Repository { + name: String, + collaborators: Option, +} + #[cfg(feature = "ssr")] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -58,172 +140,272 @@ pub struct Contributor { #[serde(rename_all = "camelCase")] pub struct ContributionCollection { #[serde(rename = "totalCommitContributions")] - commits: u64, + commits: Option, #[serde(rename = "totalPullRequestContributions")] - pull_request: u64, + pull_request: Option, #[serde(rename = "totalIssueContributions")] - issues: u64, + issues: Option, #[serde(rename = "totalRepositoryContributions")] - repository: u64, + repository: Option, #[serde(skip)] total: u64, } #[cfg(feature = "ssr")] -pub async fn fetch_contributors() -> Vec { +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContributorsResponse { + pub contributors: Vec, + pub total: usize, +} + +#[cfg(feature = "ssr")] +mod config { + pub const ORGANIZATION_LOGIN: &str = "RustLangES"; + pub const REPO_PAGE_SIZE: i32 = 4; + pub const COLLAB_PAGE_SIZE: i32 = 40; + pub const API_DELAY_MS: u64 = 200; +} + +#[cfg(feature = "ssr")] +pub async fn fetch_contributors() -> ContributorsResponse { + let mut all_contributors = fetch_all_contributors_with_pagination().await; + + all_contributors.sort_by_key(|a| { + a.contributions_collection + .as_ref() + .map(|c| c.total) + .unwrap_or(1) + }); + all_contributors.reverse(); + + let total = all_contributors.len(); + + ContributorsResponse { + contributors: all_contributors, + total, + } +} + +#[cfg(feature = "ssr")] +async fn fetch_all_contributors_with_pagination() -> Vec { + let mut repo_cursor: Option = None; + let mut has_next_repo = true; + + let mut seen: HashMap = HashMap::new(); + + while has_next_repo { + let collab_cursor: Option = None; + + let response = execute_repository_query( + config::ORGANIZATION_LOGIN, + config::REPO_PAGE_SIZE, + repo_cursor.clone(), + config::COLLAB_PAGE_SIZE, + collab_cursor.clone(), + ) + .await; + + println!( + "Fetched a page of repositories with {} nodes", + response + .organization + .repositories + .nodes + .as_ref() + .map(|n| n.len()) + .unwrap_or(0) + ); + + let repos = response.organization.repositories; + + for repo in repos.nodes.unwrap_or_default() { + if let Some(collabs) = repo.collaborators { + for collaborator in collabs.nodes.unwrap_or_default() { + process_contributor(&mut seen, collaborator); + } + + let mut collab_page_info = collabs.page_info; + while collab_page_info.has_next_page { + let collab_cursor = collab_page_info.end_cursor.clone(); + + let collab_response = execute_repository_query( + config::ORGANIZATION_LOGIN, + 1, + None, // FIX: Use None to stay on the same repo, not the repo cursor + config::COLLAB_PAGE_SIZE, + collab_cursor, + ) + .await; + + if let Some(collab_repo) = collab_response + .organization + .repositories + .nodes + .and_then(|n| n.into_iter().next()) + { + if let Some(next_collabs) = collab_repo.collaborators { + for collaborator in next_collabs.nodes.unwrap_or_default() { + process_contributor(&mut seen, collaborator); + } + collab_page_info = next_collabs.page_info; + } else { + break; + } + } else { + break; + } + + tokio::time::sleep(tokio::time::Duration::from_millis(config::API_DELAY_MS)) + .await; + } + } + } + + has_next_repo = repos.page_info.has_next_page; + repo_cursor = repos.page_info.end_cursor; + + tokio::time::sleep(tokio::time::Duration::from_millis(config::API_DELAY_MS)).await; + } + + seen.into_values().collect() +} + +#[cfg(feature = "ssr")] +fn process_contributor(seen: &mut HashMap, mut contributor: Contributor) { + if let Some(cc) = contributor.contributions_collection.as_mut() { + cc.total = cc.commits.unwrap_or(0) + + cc.issues.unwrap_or(0) + + cc.pull_request.unwrap_or(0) + + cc.repository.unwrap_or(0); + if cc.total == 0 { + cc.total = 1; + } + } + + let login = contributor.login.clone(); + seen.entry(login) + .and_modify(|existing| { + if let (Some(o), Some(n)) = ( + existing.contributions_collection.as_mut(), + contributor.contributions_collection.as_ref(), + ) { + o.total = o.total.max(n.total); + } + }) + .or_insert(contributor); +} + +#[cfg(feature = "ssr")] +async fn execute_repository_query( + login: &str, + first: i32, + after: Option, + collaborators_first: i32, + collaborators_after: Option, +) -> OrganizationData { let token = std::env::var("COLLABORATORS_API_TOKEN").unwrap_or_default(); if token.is_empty() { - leptos::logging::warn!("COLLABORATORS_API_TOKEN not set, skipping fetch"); - return Vec::new(); + leptos::logging::warn!("COLLABORATORS_API_TOKEN is empty or not set!"); + return empty_org_data(); } let request_body = json!({ "query": GRAPH_QUERY, + "variables": { + "login": login, + "first": first, + "after": after, + "collaboratorsFirst": collaborators_first, + "collaboratorsAfter": collaborators_after, + }, }); - let mut headers = reqwest::header::HeaderMap::new(); - - headers.append("User-Agent", "RustLangES Automation Agent".parse().unwrap()); - headers.append( - "Authorization", - format!("Bearer {}", token).parse().unwrap(), - ); - - let client = reqwest::ClientBuilder::new() - .default_headers(headers) - .build() - .unwrap(); - - let res = match client + let client = reqwest::Client::new(); + let res = client .post("https://api.github.com/graphql") + .header("User-Agent", "RustLangES Automation Agent") + .header("Authorization", format!("Bearer {}", token)) .json(&request_body) .send() - .await - { - Ok(res) => res, + .await; + + let text = match res { + Ok(r) => r.text().await.unwrap_or_default(), Err(e) => { - leptos::logging::error!("Failed to send request: {}", e); - return Vec::new(); + leptos::logging::error!("Request failed: {:?}", e); + return empty_org_data(); } }; - let res = match res.text().await { - Ok(text) => text, + let parsed: GithubResponse = match serde_json::from_str(&text) { + Ok(v) => v, Err(e) => { - leptos::logging::error!("Failed to read response text: {}", e); - return Vec::new(); + leptos::logging::error!("JSON Error: {} en col {}", e, e.column()); + return empty_org_data(); } }; - leptos::logging::log!("Raw: {res:?}"); - - let res: leptos::serde_json::Value = - leptos::serde_json::from_str(&res).expect("Failed to parse JSON response"); - - let mut res = res["data"]["organization"]["repositories"]["nodes"] - .as_array() - .unwrap_or(&Vec::new()) - .iter() - .filter(|&repo| !repo["collaborators"].is_null()) - .flat_map(|repo| { - repo["collaborators"]["nodes"] - .as_array() - .expect("Collaborators nodes should be an array") - }) - .filter_map(|c| leptos::serde_json::from_value::(c.clone()).ok()) - .fold(HashMap::new(), |prev, c| { - let mut prev = prev; - prev.entry(c.login.clone()) - .and_modify(|o: &mut Contributor| { - match ( - o.contributions_collection.as_mut(), - c.contributions_collection.as_ref(), - ) { - (Some(o), Some(c)) => { - o.total = o.total.max( - (o.commits + o.issues + o.pull_request + o.repository) - .max(c.commits + c.issues + c.pull_request + c.repository), - ) - } - (Some(o), None) => o.total = 1, - _ => {} - } - }) - .or_insert(c); - prev - }) - .into_values() - .filter(|c| { - c.contributions_collection - .as_ref() - .is_some_and(|cc| cc.total != 0) - }) - .collect::>(); - - res.sort_by_key(|a| { - a.contributions_collection - .as_ref() - .map(|c| c.total) - .unwrap_or(1) - }); - - res.reverse(); + let org_data = parsed.data.unwrap_or_else(empty_org_data); - res + org_data } #[component] pub fn Contributors() -> impl IntoView { - view! { -
-
-

- "Nuestros " - "Colaboradores" -

-

- Gracias al esfuerzo y dedicación de estos extraordinarios colaboradores open source, los servicios y páginas de nuestra comunidad se mantienen activos y en constante evolución. Su pasión por el código abierto y el desarrollo de Rust es el corazón que impulsa nuestro crecimiento. -

-

- "Te invitamos a unirte a esta vibrante comunidad, explorar nuestros repositorios en " - - GitHub - " y contribuir con tu talento." -

-

- Juntos - , podemos seguir construyendo un ecosistema Rust más fuerte y accesible para todos. -

-
- { - #[cfg(feature = "ssr")] - view! { - - {contributors - .iter() - .map(|item| { - view! { - - } - }) - .collect::>()} - + { + view! { +
+
+

+ "Nuestros " + "Colaboradores" +

+

+ Gracias al esfuerzo y dedicación de estos extraordinarios colaboradores open source, los servicios y páginas de nuestra comunidad se mantienen activos y en constante evolución. Su pasión por el código abierto y el desarrollo de Rust es el corazón que impulsa nuestro crecimiento. +

+

+ "Te invitamos a unirte a esta vibrante comunidad, explorar nuestros repositorios en " + + GitHub + " y contribuir con tu talento." +

+

+ Juntos + , podemos seguir construyendo un ecosistema Rust más fuerte y accesible para todos. +

+
+ { + #[cfg(feature = "ssr")] + view! { + + {res + .contributors + .iter() + .map(|item| { + view! { + + } + }) + .collect::>()} + + } } - } +
-
-
+ + } } }