From 6b8e7729df0559c419f42633f0bdec69cc591b5b Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Wed, 11 Feb 2026 09:28:55 +0000 Subject: [PATCH 1/8] feat: add GitHub issue ingestion with admin sync endpoint (#74) --- backend/Cargo.lock | 204 ++++++++- backend/Cargo.toml | 1 + .../006_create_github_issues_table.sql | 22 + backend/src/application/commands/mod.rs | 1 + .../commands/sync_github_issues.rs | 113 +++++ backend/src/application/dtos/github_dtos.rs | 15 + backend/src/application/dtos/mod.rs | 1 + backend/src/domain/entities/github_issue.rs | 21 + backend/src/domain/entities/mod.rs | 1 + .../repositories/github_issue_repository.rs | 16 + backend/src/domain/repositories/mod.rs | 2 + backend/src/domain/services/github_service.rs | 45 ++ backend/src/domain/services/mod.rs | 1 + .../src/infrastructure/repositories/mod.rs | 2 + .../postgres_github_issue_repository.rs | 112 +++++ backend/src/infrastructure/services/mod.rs | 1 + .../services/rest_github_service.rs | 58 +++ backend/src/presentation/api.rs | 17 +- backend/src/presentation/handlers.rs | 47 ++ backend/tests/github_sync_tests.rs | 408 ++++++++++++++++++ backend/tests/integration_github_handle.rs | 17 + 21 files changed, 1092 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/006_create_github_issues_table.sql create mode 100644 backend/src/application/commands/sync_github_issues.rs create mode 100644 backend/src/application/dtos/github_dtos.rs create mode 100644 backend/src/domain/entities/github_issue.rs create mode 100644 backend/src/domain/repositories/github_issue_repository.rs create mode 100644 backend/src/domain/services/github_service.rs create mode 100644 backend/src/infrastructure/repositories/postgres_github_issue_repository.rs create mode 100644 backend/src/infrastructure/services/rest_github_service.rs create mode 100644 backend/tests/github_sync_tests.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a14472f..d80a23e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -413,6 +413,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -1024,7 +1030,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "syn 2.0.106", @@ -1086,7 +1092,7 @@ checksum = "e79e5973c26d4baf0ce55520bd732314328cabe53193286671b47144145b9649" dependencies = [ "chrono", "ethers-core", - "reqwest", + "reqwest 0.11.27", "semver", "serde", "serde_json", @@ -1111,7 +1117,7 @@ dependencies = [ "futures-locks", "futures-util", "instant", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "thiserror 1.0.69", @@ -1143,7 +1149,7 @@ dependencies = [ "jsonwebtoken 8.3.0", "once_cell", "pin-project", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "thiserror 1.0.69", @@ -1503,9 +1509,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.4+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1557,7 +1565,8 @@ dependencies = [ "hyper 0.14.32", "jsonwebtoken 9.3.1", "regex", - "reqwest", + "reqwest 0.11.27", + "reqwest 0.12.28", "serde", "serde_json", "sha3", @@ -1565,7 +1574,7 @@ dependencies = [ "sqlx", "tokio", "tower 0.4.13", - "tower-http", + "tower-http 0.5.2", "tracing", "tracing-subscriber", "uuid 1.18.1", @@ -1775,6 +1784,7 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", ] [[package]] @@ -1788,7 +1798,24 @@ dependencies = [ "hyper 0.14.32", "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper 1.7.0", + "hyper-util", + "rustls 0.23.31", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.2", ] [[package]] @@ -1810,14 +1837,22 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http 1.3.1", "http-body 1.0.1", "hyper 1.7.0", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.0", "tokio", "tower-service", + "tracing", ] [[package]] @@ -2244,6 +2279,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -2857,6 +2898,61 @@ dependencies = [ "unarray", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.31", + "socket2 0.6.0", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring 0.17.14", + "rustc-hash", + "rustls 0.23.31", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.0", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -3030,7 +3126,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", - "hyper-rustls", + "hyper-rustls 0.24.2", "hyper-tls", "ipnet", "js-sys", @@ -3049,7 +3145,7 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", @@ -3059,6 +3155,44 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.31", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.2", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.2", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -3155,6 +3289,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc-hex" version = "2.1.0" @@ -3225,6 +3365,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -3916,7 +4057,7 @@ dependencies = [ "fs2", "hex", "once_cell", - "reqwest", + "reqwest 0.11.27", "semver", "serde", "serde_json", @@ -3959,6 +4100,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -4186,6 +4330,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.31", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -4207,7 +4361,7 @@ dependencies = [ "log", "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tungstenite", "webpki-roots 0.25.4", ] @@ -4314,6 +4468,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4679,6 +4851,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 99a93d5..9dddd76 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -36,6 +36,7 @@ anyhow = "1.0" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } regex = "1.0" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } # Environment dotenvy = "0.15" diff --git a/backend/migrations/006_create_github_issues_table.sql b/backend/migrations/006_create_github_issues_table.sql new file mode 100644 index 0000000..9bb084b --- /dev/null +++ b/backend/migrations/006_create_github_issues_table.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS github_issues ( + repo_id BIGINT NOT NULL, + github_issue_id BIGINT NOT NULL, + repo TEXT NOT NULL, + number INT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('open', 'closed')), + labels JSONB NOT NULL DEFAULT '[]', + points INT NOT NULL DEFAULT 0, + assignee_logins JSONB NOT NULL DEFAULT '[]', + html_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + closed_at TIMESTAMPTZ, + rewarded BOOLEAN NOT NULL DEFAULT false, + distribution_id TEXT, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (repo_id, github_issue_id) +); + +CREATE INDEX IF NOT EXISTS idx_github_issues_repo ON github_issues(repo); +CREATE INDEX IF NOT EXISTS idx_github_issues_state ON github_issues(state); +CREATE INDEX IF NOT EXISTS idx_github_issues_rewarded ON github_issues(rewarded); diff --git a/backend/src/application/commands/mod.rs b/backend/src/application/commands/mod.rs index 2b63c7a..af4e93e 100644 --- a/backend/src/application/commands/mod.rs +++ b/backend/src/application/commands/mod.rs @@ -2,5 +2,6 @@ pub mod create_profile; pub mod create_project; pub mod delete_project; pub mod login; +pub mod sync_github_issues; pub mod update_profile; pub mod update_project; diff --git a/backend/src/application/commands/sync_github_issues.rs b/backend/src/application/commands/sync_github_issues.rs new file mode 100644 index 0000000..6b81ee6 --- /dev/null +++ b/backend/src/application/commands/sync_github_issues.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use regex::Regex; + +use crate::domain::{ + entities::github_issue::GithubIssue, + repositories::github_issue_repository::GithubIssueRepository, + services::github_service::{GitHubApiIssue, GithubService}, +}; + +/// Derive points from labels matching the pattern `points:N`. +/// Label names are normalized to lower-case. +pub fn derive_points(labels: &[crate::domain::services::github_service::GitHubApiLabel]) -> i32 { + let re = Regex::new(r"^points:(\d+)$").expect("Invalid regex"); + for label in labels { + let name = label.name.to_lowercase(); + if let Some(caps) = re.captures(&name) { + if let Ok(pts) = caps[1].parse::() { + return pts; + } + } + } + 0 +} + +/// Transform a GitHub API issue into a domain GithubIssue entity. +pub fn transform_issue( + repo: &str, + repo_id: i64, + api_issue: &GitHubApiIssue, +) -> Result { + let labels_normalized: Vec = api_issue + .labels + .iter() + .map(|l| serde_json::Value::String(l.name.to_lowercase())) + .collect(); + + let assignee_logins: Vec = api_issue + .assignees + .iter() + .map(|a| serde_json::Value::String(a.login.clone())) + .collect(); + + let points = derive_points(&api_issue.labels); + + let created_at = chrono::DateTime::parse_from_rfc3339(&api_issue.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&chrono::Utc); + + let closed_at = api_issue + .closed_at + .as_ref() + .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.with_timezone(&chrono::Utc))) + .transpose() + .map_err(|e| format!("Invalid closed_at: {}", e))?; + + let updated_at = chrono::DateTime::parse_from_rfc3339(&api_issue.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&chrono::Utc); + + Ok(GithubIssue { + repo_id, + github_issue_id: api_issue.id, + repo: repo.to_string(), + number: api_issue.number, + title: api_issue.title.clone(), + state: api_issue.state.clone(), + labels: serde_json::Value::Array(labels_normalized), + points, + assignee_logins: serde_json::Value::Array(assignee_logins), + html_url: api_issue.html_url.clone(), + created_at, + closed_at, + rewarded: false, + distribution_id: None, + updated_at, + }) +} + +/// Sync GitHub issues for the given repos. +pub async fn sync_github_issues( + github_service: Arc, + issue_repository: Arc, + repos: Vec, + since: Option, +) -> Result { + let mut total_synced: usize = 0; + + for repo in &repos { + let (repo_id, api_issues) = github_service + .fetch_issues(repo, since.as_deref()) + .await + .map_err(|e| format!("Failed to fetch issues for {}: {}", repo, e))?; + + for api_issue in &api_issues { + // Ignore PRs + if api_issue.pull_request.is_some() { + continue; + } + + let issue = transform_issue(repo, repo_id, api_issue)?; + + issue_repository + .upsert(&issue) + .await + .map_err(|e| format!("Failed to upsert issue {}: {}", api_issue.id, e))?; + + total_synced += 1; + } + } + + Ok(total_synced) +} diff --git a/backend/src/application/dtos/github_dtos.rs b/backend/src/application/dtos/github_dtos.rs new file mode 100644 index 0000000..4eacc58 --- /dev/null +++ b/backend/src/application/dtos/github_dtos.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +/// Request DTO for POST /admin/github/sync +#[derive(Debug, Deserialize)] +pub struct GithubSyncRequest { + pub repos: Vec, + pub since: Option, +} + +/// Response DTO for POST /admin/github/sync +#[derive(Debug, Serialize)] +pub struct GithubSyncResponse { + pub synced: usize, + pub repos: Vec, +} diff --git a/backend/src/application/dtos/mod.rs b/backend/src/application/dtos/mod.rs index 1e7059a..bb5526b 100644 --- a/backend/src/application/dtos/mod.rs +++ b/backend/src/application/dtos/mod.rs @@ -1,4 +1,5 @@ pub mod auth_dtos; +pub mod github_dtos; pub mod profile_dtos; pub mod project_dtos; pub use auth_dtos::*; diff --git a/backend/src/domain/entities/github_issue.rs b/backend/src/domain/entities/github_issue.rs new file mode 100644 index 0000000..f4edcc1 --- /dev/null +++ b/backend/src/domain/entities/github_issue.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GithubIssue { + pub repo_id: i64, + pub github_issue_id: i64, + pub repo: String, + pub number: i32, + pub title: String, + pub state: String, + pub labels: serde_json::Value, + pub points: i32, + pub assignee_logins: serde_json::Value, + pub html_url: String, + pub created_at: DateTime, + pub closed_at: Option>, + pub rewarded: bool, + pub distribution_id: Option, + pub updated_at: DateTime, +} diff --git a/backend/src/domain/entities/mod.rs b/backend/src/domain/entities/mod.rs index 703c49d..56b2f21 100644 --- a/backend/src/domain/entities/mod.rs +++ b/backend/src/domain/entities/mod.rs @@ -1,3 +1,4 @@ +pub mod github_issue; pub mod profile; pub mod projects; diff --git a/backend/src/domain/repositories/github_issue_repository.rs b/backend/src/domain/repositories/github_issue_repository.rs new file mode 100644 index 0000000..cf683ca --- /dev/null +++ b/backend/src/domain/repositories/github_issue_repository.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; + +use crate::domain::entities::github_issue::GithubIssue; + +#[async_trait] +pub trait GithubIssueRepository: Send + Sync { + /// Upsert a GitHub issue (insert or update based on composite key repo_id + github_issue_id) + async fn upsert(&self, issue: &GithubIssue) -> Result<(), Box>; + + /// Find an issue by its composite key + async fn find_by_key( + &self, + repo_id: i64, + github_issue_id: i64, + ) -> Result, Box>; +} diff --git a/backend/src/domain/repositories/mod.rs b/backend/src/domain/repositories/mod.rs index fe48bd4..0bd393e 100644 --- a/backend/src/domain/repositories/mod.rs +++ b/backend/src/domain/repositories/mod.rs @@ -1,5 +1,7 @@ +pub mod github_issue_repository; pub mod profile_repository; pub mod project_repository; +pub use github_issue_repository::GithubIssueRepository; pub use profile_repository::ProfileRepository; pub use project_repository::ProjectRepository; diff --git a/backend/src/domain/services/github_service.rs b/backend/src/domain/services/github_service.rs new file mode 100644 index 0000000..e18d3bb --- /dev/null +++ b/backend/src/domain/services/github_service.rs @@ -0,0 +1,45 @@ +use async_trait::async_trait; +use serde::Deserialize; + +/// Raw issue data returned from the GitHub API +#[derive(Debug, Clone, Deserialize)] +pub struct GitHubApiIssue { + pub id: i64, + pub number: i32, + pub title: String, + pub state: String, + pub html_url: String, + pub labels: Vec, + pub assignees: Vec, + pub created_at: String, + pub closed_at: Option, + pub updated_at: String, + pub pull_request: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GitHubApiLabel { + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GitHubApiUser { + pub login: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GitHubApiRepo { + pub id: i64, +} + +#[async_trait] +pub trait GithubService: Send + Sync { + /// Fetch issues from a GitHub repository via REST API. + /// `repo` is in the format "org/repo". + /// `since` is an optional ISO 8601 timestamp to filter issues updated since that time. + async fn fetch_issues( + &self, + repo: &str, + since: Option<&str>, + ) -> Result<(i64, Vec), Box>; +} diff --git a/backend/src/domain/services/mod.rs b/backend/src/domain/services/mod.rs index 3fe88a6..d990cac 100644 --- a/backend/src/domain/services/mod.rs +++ b/backend/src/domain/services/mod.rs @@ -1 +1,2 @@ pub mod auth_service; +pub mod github_service; diff --git a/backend/src/infrastructure/repositories/mod.rs b/backend/src/infrastructure/repositories/mod.rs index ac8a7e3..cb7e757 100644 --- a/backend/src/infrastructure/repositories/mod.rs +++ b/backend/src/infrastructure/repositories/mod.rs @@ -1,5 +1,7 @@ +pub mod postgres_github_issue_repository; pub mod postgres_profile_repository; pub mod postgres_project_repository; +pub use postgres_github_issue_repository::PostgresGithubIssueRepository; pub use postgres_profile_repository::PostgresProfileRepository; pub use postgres_project_repository::PostgresProjectRepository; diff --git a/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs new file mode 100644 index 0000000..7e984fc --- /dev/null +++ b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs @@ -0,0 +1,112 @@ +use async_trait::async_trait; +use sqlx::PgPool; + +use crate::domain::{ + entities::github_issue::GithubIssue, + repositories::github_issue_repository::GithubIssueRepository, +}; + +#[derive(Clone)] +pub struct PostgresGithubIssueRepository { + pool: PgPool, +} + +impl PostgresGithubIssueRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl GithubIssueRepository for PostgresGithubIssueRepository { + async fn upsert(&self, issue: &GithubIssue) -> Result<(), Box> { + sqlx::query( + r#" + INSERT INTO github_issues ( + repo_id, github_issue_id, repo, number, title, state, + labels, points, assignee_logins, html_url, + created_at, closed_at, rewarded, distribution_id, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (repo_id, github_issue_id) DO UPDATE SET + repo = EXCLUDED.repo, + number = EXCLUDED.number, + title = EXCLUDED.title, + state = EXCLUDED.state, + labels = EXCLUDED.labels, + points = EXCLUDED.points, + assignee_logins = EXCLUDED.assignee_logins, + html_url = EXCLUDED.html_url, + created_at = EXCLUDED.created_at, + closed_at = EXCLUDED.closed_at, + updated_at = EXCLUDED.updated_at + "#, + ) + .bind(issue.repo_id) + .bind(issue.github_issue_id) + .bind(&issue.repo) + .bind(issue.number) + .bind(&issue.title) + .bind(&issue.state) + .bind(&issue.labels) + .bind(issue.points) + .bind(&issue.assignee_logins) + .bind(&issue.html_url) + .bind(issue.created_at) + .bind(issue.closed_at) + .bind(issue.rewarded) + .bind(&issue.distribution_id) + .bind(issue.updated_at) + .execute(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(()) + } + + async fn find_by_key( + &self, + repo_id: i64, + github_issue_id: i64, + ) -> Result, Box> { + let row = sqlx::query_as::<_, ( + i64, i64, String, i32, String, String, + serde_json::Value, i32, serde_json::Value, String, + chrono::DateTime, + Option>, + bool, Option, + chrono::DateTime, + )>( + r#" + SELECT repo_id, github_issue_id, repo, number, title, state, + labels, points, assignee_logins, html_url, + created_at, closed_at, rewarded, distribution_id, updated_at + FROM github_issues + WHERE repo_id = $1 AND github_issue_id = $2 + "#, + ) + .bind(repo_id) + .bind(github_issue_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(row.map(|r| GithubIssue { + repo_id: r.0, + github_issue_id: r.1, + repo: r.2, + number: r.3, + title: r.4, + state: r.5, + labels: r.6, + points: r.7, + assignee_logins: r.8, + html_url: r.9, + created_at: r.10, + closed_at: r.11, + rewarded: r.12, + distribution_id: r.13, + updated_at: r.14, + })) + } +} diff --git a/backend/src/infrastructure/services/mod.rs b/backend/src/infrastructure/services/mod.rs index f0cf358..8c8b813 100644 --- a/backend/src/infrastructure/services/mod.rs +++ b/backend/src/infrastructure/services/mod.rs @@ -1 +1,2 @@ pub mod ethereum_address_verification_service; +pub mod rest_github_service; diff --git a/backend/src/infrastructure/services/rest_github_service.rs b/backend/src/infrastructure/services/rest_github_service.rs new file mode 100644 index 0000000..15572b7 --- /dev/null +++ b/backend/src/infrastructure/services/rest_github_service.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; + +use crate::domain::services::github_service::{GitHubApiIssue, GitHubApiRepo, GithubService}; + +pub struct RestGithubService { + client: reqwest::Client, +} + +impl RestGithubService { + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .user_agent("guild-backend") + .build() + .expect("Failed to build HTTP client"), + } + } +} + +#[async_trait] +impl GithubService for RestGithubService { + async fn fetch_issues( + &self, + repo: &str, + since: Option<&str>, + ) -> Result<(i64, Vec), Box> { + // Fetch repo metadata to get repo_id + let repo_url = format!("https://api.github.com/repos/{}", repo); + let repo_resp: GitHubApiRepo = self + .client + .get(&repo_url) + .send() + .await? + .error_for_status()? + .json() + .await?; + + // Fetch issues (no pagination per scope) + let mut issues_url = format!( + "https://api.github.com/repos/{}/issues?state=all&per_page=100", + repo + ); + if let Some(since_val) = since { + issues_url.push_str(&format!("&since={}", since_val)); + } + + let issues: Vec = self + .client + .get(&issues_url) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok((repo_resp.id, issues)) + } +} diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index bef05ef..a0e748a 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -1,12 +1,15 @@ use std::sync::Arc; -use crate::domain::repositories::{ProfileRepository, ProjectRepository}; +use crate::domain::repositories::{GithubIssueRepository, ProfileRepository, ProjectRepository}; use crate::domain::services::auth_service::AuthService; +use crate::domain::services::github_service::GithubService; use crate::infrastructure::{ repositories::{ + postgres_github_issue_repository::PostgresGithubIssueRepository, postgres_project_repository::PostgresProjectRepository, PostgresProfileRepository, }, services::ethereum_address_verification_service::EthereumAddressVerificationService, + services::rest_github_service::RestGithubService, }; use axum::middleware::{from_fn, from_fn_with_state}; use axum::{ @@ -35,6 +38,8 @@ use super::handlers::{ get_profile_handler, get_project_handler, get_user_projects_handler, + // GitHub sync handler + github_sync_handler, list_projects_handler, login_handler, update_profile_handler, @@ -45,13 +50,17 @@ use super::middlewares::{admin_auth_layer, eth_auth_layer, test_auth_layer}; pub async fn create_app(pool: sqlx::PgPool) -> Router { let profile_repository = Arc::from(PostgresProfileRepository::new(pool.clone())); - let project_repository = Arc::from(PostgresProjectRepository::new(pool)); + let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool)); let auth_service = EthereumAddressVerificationService::new(profile_repository.clone()); + let github_service: Arc = Arc::from(RestGithubService::new()); let state: AppState = AppState { profile_repository, project_repository, auth_service: Arc::from(auth_service), + github_issue_repository, + github_service, }; // Protected routes (require authentication) @@ -80,6 +89,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { "/admin/profiles/:address", delete(admin_delete_profile_handler), ) + .route("/admin/github/sync", post(github_sync_handler)) .with_state(state.clone()); let admin_with_auth = if std::env::var("TEST_MODE").is_ok() { @@ -130,6 +140,8 @@ pub struct AppState { pub profile_repository: Arc, pub project_repository: Arc, pub auth_service: Arc, + pub github_issue_repository: Arc, + pub github_service: Arc, } pub fn test_api(state: AppState) -> Router { @@ -153,6 +165,7 @@ pub fn test_api(state: AppState) -> Router { "/admin/profiles/:address", delete(admin_delete_profile_handler), ) + .route("/admin/github/sync", post(github_sync_handler)) .with_state(state.clone()) .layer(from_fn(test_auth_layer)); diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 16cf092..8f6192c 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -35,6 +35,12 @@ use crate::application::{ }, }; +// GitHub sync imports +use crate::application::{ + commands::sync_github_issues::sync_github_issues, + dtos::github_dtos::{GithubSyncRequest, GithubSyncResponse}, +}; + use super::{api::AppState, middlewares::VerifiedWallet}; /// Query parameters for listing projects @@ -307,3 +313,44 @@ pub async fn admin_delete_profile_handler( } } } + +// ============================================================================ +// GitHub Sync Handlers +// ============================================================================ + +/// POST /admin/github/sync - Sync GitHub issues for specified repos (Admin only) +pub async fn github_sync_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + if request.repos.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "repos must not be empty"})), + ) + .into_response(); + } + + match sync_github_issues( + state.github_service.clone(), + state.github_issue_repository.clone(), + request.repos.clone(), + request.since, + ) + .await + { + Ok(synced) => ( + StatusCode::OK, + Json(GithubSyncResponse { + synced, + repos: request.repos, + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e})), + ) + .into_response(), + } +} diff --git a/backend/tests/github_sync_tests.rs b/backend/tests/github_sync_tests.rs new file mode 100644 index 0000000..65d7a81 --- /dev/null +++ b/backend/tests/github_sync_tests.rs @@ -0,0 +1,408 @@ +#[cfg(test)] +mod github_sync_tests { + use async_trait::async_trait; + use std::sync::Arc; + + use guild_backend::application::commands::sync_github_issues::{ + derive_points, sync_github_issues, transform_issue, + }; + use guild_backend::domain::entities::github_issue::GithubIssue; + use guild_backend::domain::repositories::github_issue_repository::GithubIssueRepository; + use guild_backend::domain::services::github_service::{ + GitHubApiIssue, GitHubApiLabel, GitHubApiUser, GithubService, + }; + + // ======================================================================== + // Fake implementations for testing + // ======================================================================== + + struct FakeGithubIssueRepo { + issues: std::sync::Mutex>, + } + + #[async_trait] + impl GithubIssueRepository for FakeGithubIssueRepo { + async fn upsert(&self, issue: &GithubIssue) -> Result<(), Box> { + let mut list = self.issues.lock().unwrap(); + // Upsert: replace if exists, otherwise insert + if let Some(existing) = list + .iter_mut() + .find(|i| i.repo_id == issue.repo_id && i.github_issue_id == issue.github_issue_id) + { + *existing = issue.clone(); + } else { + list.push(issue.clone()); + } + Ok(()) + } + + async fn find_by_key( + &self, + repo_id: i64, + github_issue_id: i64, + ) -> Result, Box> { + let list = self.issues.lock().unwrap(); + Ok(list + .iter() + .find(|i| i.repo_id == repo_id && i.github_issue_id == github_issue_id) + .cloned()) + } + } + + struct FakeGithubService { + issues: Vec, + repo_id: i64, + } + + #[async_trait] + impl GithubService for FakeGithubService { + async fn fetch_issues( + &self, + _repo: &str, + _since: Option<&str>, + ) -> Result<(i64, Vec), Box> { + Ok((self.repo_id, self.issues.clone())) + } + } + + fn make_api_issue( + id: i64, + number: i32, + title: &str, + state: &str, + labels: Vec<&str>, + assignees: Vec<&str>, + is_pr: bool, + closed_at: Option<&str>, + ) -> GitHubApiIssue { + GitHubApiIssue { + id, + number, + title: title.to_string(), + state: state.to_string(), + html_url: format!("https://github.com/test/repo/issues/{}", number), + labels: labels + .into_iter() + .map(|l| GitHubApiLabel { + name: l.to_string(), + }) + .collect(), + assignees: assignees + .into_iter() + .map(|a| GitHubApiUser { + login: a.to_string(), + }) + .collect(), + created_at: "2025-01-01T00:00:00Z".to_string(), + closed_at: closed_at.map(|s| s.to_string()), + updated_at: "2025-01-02T00:00:00Z".to_string(), + pull_request: if is_pr { + Some(serde_json::json!({})) + } else { + None + }, + } + } + + // ======================================================================== + // Test: derive_points from labels + // ======================================================================== + + #[test] + fn test_derive_points_with_points_label() { + let labels = vec![ + GitHubApiLabel { + name: "bug".to_string(), + }, + GitHubApiLabel { + name: "points:3".to_string(), + }, + ]; + assert_eq!(derive_points(&labels), 3); + } + + #[test] + fn test_derive_points_case_insensitive() { + let labels = vec![GitHubApiLabel { + name: "Points:5".to_string(), + }]; + assert_eq!(derive_points(&labels), 5); + } + + #[test] + fn test_derive_points_no_match_defaults_to_zero() { + let labels = vec![ + GitHubApiLabel { + name: "bug".to_string(), + }, + GitHubApiLabel { + name: "enhancement".to_string(), + }, + ]; + assert_eq!(derive_points(&labels), 0); + } + + #[test] + fn test_derive_points_empty_labels() { + let labels: Vec = vec![]; + assert_eq!(derive_points(&labels), 0); + } + + // ======================================================================== + // Test: transform_issue normalizes labels to lower-case + // ======================================================================== + + #[test] + fn test_transform_issue_normalizes_labels() { + let api_issue = make_api_issue( + 1, + 1, + "Test Issue", + "open", + vec!["Bug", "Points:3"], + vec!["alice"], + false, + None, + ); + + let result = transform_issue("org/repo", 42, &api_issue).unwrap(); + + let labels = result.labels.as_array().unwrap(); + assert_eq!(labels[0].as_str().unwrap(), "bug"); + assert_eq!(labels[1].as_str().unwrap(), "points:3"); + assert_eq!(result.points, 3); + } + + // ======================================================================== + // Test: PRs are ignored + // ======================================================================== + + #[tokio::test] + async fn test_sync_ignores_pull_requests() { + let issues = vec![ + make_api_issue(1, 1, "Real Issue", "open", vec![], vec![], false, None), + make_api_issue(2, 2, "A PR", "open", vec![], vec![], true, None), + ]; + + let github_service: Arc = Arc::new(FakeGithubService { + issues, + repo_id: 100, + }); + let issue_repo: Arc = Arc::new(FakeGithubIssueRepo { + issues: std::sync::Mutex::new(vec![]), + }); + + let synced = sync_github_issues( + github_service, + issue_repo.clone(), + vec!["org/repo".to_string()], + None, + ) + .await + .unwrap(); + + assert_eq!(synced, 1); + // Only the real issue should be persisted + assert!(issue_repo.find_by_key(100, 1).await.unwrap().is_some()); + assert!(issue_repo.find_by_key(100, 2).await.unwrap().is_none()); + } + + // ======================================================================== + // Test: idempotent upsert (running sync twice yields no duplicates) + // ======================================================================== + + #[tokio::test] + async fn test_sync_idempotent_upsert() { + let issues = vec![make_api_issue( + 1, + 1, + "Issue 1", + "open", + vec!["points:2"], + vec!["bob"], + false, + None, + )]; + + let github_service: Arc = Arc::new(FakeGithubService { + issues, + repo_id: 200, + }); + let issue_repo = Arc::new(FakeGithubIssueRepo { + issues: std::sync::Mutex::new(vec![]), + }); + let issue_repo_trait: Arc = issue_repo.clone(); + + // Sync twice + sync_github_issues( + github_service.clone(), + issue_repo_trait.clone(), + vec!["org/repo".to_string()], + None, + ) + .await + .unwrap(); + + sync_github_issues( + github_service.clone(), + issue_repo_trait.clone(), + vec!["org/repo".to_string()], + None, + ) + .await + .unwrap(); + + // Should still only have 1 issue (no duplicates) + let count = issue_repo.issues.lock().unwrap().len(); + assert_eq!(count, 1); + } + + // ======================================================================== + // Test: closed issue is reflected + // ======================================================================== + + #[tokio::test] + async fn test_sync_closed_issue_reflected() { + let issues = vec![make_api_issue( + 10, + 10, + "Closed Issue", + "closed", + vec!["points:5"], + vec!["charlie"], + false, + Some("2025-06-01T12:00:00Z"), + )]; + + let github_service: Arc = Arc::new(FakeGithubService { + issues, + repo_id: 300, + }); + let issue_repo: Arc = Arc::new(FakeGithubIssueRepo { + issues: std::sync::Mutex::new(vec![]), + }); + + sync_github_issues( + github_service, + issue_repo.clone(), + vec!["org/repo".to_string()], + None, + ) + .await + .unwrap(); + + let issue = issue_repo.find_by_key(300, 10).await.unwrap().unwrap(); + assert_eq!(issue.state, "closed"); + assert!(issue.closed_at.is_some()); + assert_eq!(issue.points, 5); + // rewarded should not be set during sync + assert!(!issue.rewarded); + } + + // ======================================================================== + // Test: assignees are persisted + // ======================================================================== + + #[tokio::test] + async fn test_sync_persists_assignees() { + let issues = vec![make_api_issue( + 20, + 20, + "Issue with assignees", + "open", + vec![], + vec!["alice", "bob"], + false, + None, + )]; + + let github_service: Arc = Arc::new(FakeGithubService { + issues, + repo_id: 400, + }); + let issue_repo: Arc = Arc::new(FakeGithubIssueRepo { + issues: std::sync::Mutex::new(vec![]), + }); + + sync_github_issues( + github_service, + issue_repo.clone(), + vec!["org/repo".to_string()], + None, + ) + .await + .unwrap(); + + let issue = issue_repo.find_by_key(400, 20).await.unwrap().unwrap(); + let logins = issue.assignee_logins.as_array().unwrap(); + assert_eq!(logins.len(), 2); + assert_eq!(logins[0].as_str().unwrap(), "alice"); + assert_eq!(logins[1].as_str().unwrap(), "bob"); + } + + // ======================================================================== + // Test: upsert updates changed fields (title change on re-sync) + // ======================================================================== + + #[tokio::test] + async fn test_sync_upsert_updates_title() { + let issue_repo = Arc::new(FakeGithubIssueRepo { + issues: std::sync::Mutex::new(vec![]), + }); + let issue_repo_trait: Arc = issue_repo.clone(); + + // First sync with original title + let issues_v1 = vec![make_api_issue( + 1, + 1, + "Original Title", + "open", + vec![], + vec![], + false, + None, + )]; + let svc1: Arc = Arc::new(FakeGithubService { + issues: issues_v1, + repo_id: 500, + }); + sync_github_issues( + svc1, + issue_repo_trait.clone(), + vec!["org/repo".to_string()], + None, + ) + .await + .unwrap(); + + // Second sync with updated title + let issues_v2 = vec![make_api_issue( + 1, + 1, + "Updated Title", + "open", + vec![], + vec![], + false, + None, + )]; + let svc2: Arc = Arc::new(FakeGithubService { + issues: issues_v2, + repo_id: 500, + }); + sync_github_issues( + svc2, + issue_repo_trait.clone(), + vec!["org/repo".to_string()], + None, + ) + .await + .unwrap(); + + let count = issue_repo.issues.lock().unwrap().len(); + assert_eq!(count, 1); + + let issue = issue_repo_trait.find_by_key(500, 1).await.unwrap().unwrap(); + assert_eq!(issue.title, "Updated Title"); + } +} diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index cdb7bfa..009da69 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -1,5 +1,7 @@ use guild_backend::application::dtos::profile_dtos::ProfileResponse; +use guild_backend::infrastructure::repositories::postgres_github_issue_repository::PostgresGithubIssueRepository; use guild_backend::infrastructure::repositories::postgres_project_repository::PostgresProjectRepository; +use guild_backend::infrastructure::services::rest_github_service::RestGithubService; use guild_backend::presentation::api::{test_api, AppState}; use serde_json::json; use std::sync::Arc; @@ -20,11 +22,16 @@ async fn valid_github_handle_works() { ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); + let github_service: Arc = + Arc::from(RestGithubService::new()); let state = AppState { profile_repository, project_repository, auth_service: std::sync::Arc::new(auth_service), + github_issue_repository, + github_service, }; let app = test_api(state); @@ -90,11 +97,16 @@ async fn invalid_format_rejected() { ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); + let github_service: Arc = + Arc::from(RestGithubService::new()); let state = AppState { profile_repository, project_repository, auth_service: std::sync::Arc::new(auth_service), + github_issue_repository, + github_service, }; let app = test_api(state); @@ -166,11 +178,16 @@ async fn conflict_case_insensitive() { ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); + let github_service: Arc = + Arc::from(RestGithubService::new()); let state = AppState { profile_repository, project_repository, auth_service: std::sync::Arc::new(auth_service), + github_issue_repository, + github_service, }; let app = test_api(state); From 16f83bac999728c1e75380e61ff7f3ddaa1c7313 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Wed, 11 Feb 2026 11:00:00 +0000 Subject: [PATCH 2/8] fix: resolve clippy warnings for CI --- .../application/commands/create_project.rs | 2 +- .../application/commands/delete_project.rs | 2 +- .../commands/sync_github_issues.rs | 10 ++++----- .../application/commands/update_project.rs | 2 +- .../application/queries/get_all_projects.rs | 6 +++-- .../application/queries/get_login_nonce.rs | 2 +- .../queries/get_projects_by_creator.rs | 2 +- backend/src/bin/migrate.rs | 2 +- backend/src/domain/entities/projects.rs | 2 +- backend/src/infrastructure/jwt.rs | 4 ++-- .../postgres_project_repository.rs | 4 ++-- .../services/rest_github_service.rs | 13 +++++++---- backend/tests/github_sync_tests.rs | 3 ++- backend/tests/integration_github_handle.rs | 22 +++++++++---------- 14 files changed, 42 insertions(+), 34 deletions(-) diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs index a157f7d..3fb3b81 100644 --- a/backend/src/application/commands/create_project.rs +++ b/backend/src/application/commands/create_project.rs @@ -15,7 +15,7 @@ pub async fn create_project( ) -> Result { // Validate and create WalletAddress let creator = WalletAddress::new(creator_address) - .map_err(|e| format!("Invalid wallet address: {}", e))?; + .map_err(|e| format!("Invalid wallet address: {e}"))?; // Verify creator has a profile if !repository diff --git a/backend/src/application/commands/delete_project.rs b/backend/src/application/commands/delete_project.rs index eccfd77..30da94f 100644 --- a/backend/src/application/commands/delete_project.rs +++ b/backend/src/application/commands/delete_project.rs @@ -16,7 +16,7 @@ pub async fn delete_project( // Validate requester address let requester = WalletAddress::new(requester_address) - .map_err(|e| format!("Invalid wallet address: {}", e))?; + .map_err(|e| format!("Invalid wallet address: {e}"))?; // Get existing project let project = repository diff --git a/backend/src/application/commands/sync_github_issues.rs b/backend/src/application/commands/sync_github_issues.rs index 6b81ee6..25f3302 100644 --- a/backend/src/application/commands/sync_github_issues.rs +++ b/backend/src/application/commands/sync_github_issues.rs @@ -44,7 +44,7 @@ pub fn transform_issue( let points = derive_points(&api_issue.labels); let created_at = chrono::DateTime::parse_from_rfc3339(&api_issue.created_at) - .map_err(|e| format!("Invalid created_at: {}", e))? + .map_err(|e| format!("Invalid created_at: {e}"))? .with_timezone(&chrono::Utc); let closed_at = api_issue @@ -52,10 +52,10 @@ pub fn transform_issue( .as_ref() .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.with_timezone(&chrono::Utc))) .transpose() - .map_err(|e| format!("Invalid closed_at: {}", e))?; + .map_err(|e| format!("Invalid closed_at: {e}"))?; let updated_at = chrono::DateTime::parse_from_rfc3339(&api_issue.updated_at) - .map_err(|e| format!("Invalid updated_at: {}", e))? + .map_err(|e| format!("Invalid updated_at: {e}"))? .with_timezone(&chrono::Utc); Ok(GithubIssue { @@ -90,7 +90,7 @@ pub async fn sync_github_issues( let (repo_id, api_issues) = github_service .fetch_issues(repo, since.as_deref()) .await - .map_err(|e| format!("Failed to fetch issues for {}: {}", repo, e))?; + .map_err(|e| format!("Failed to fetch issues for {repo}: {e}"))?; for api_issue in &api_issues { // Ignore PRs @@ -103,7 +103,7 @@ pub async fn sync_github_issues( issue_repository .upsert(&issue) .await - .map_err(|e| format!("Failed to upsert issue {}: {}", api_issue.id, e))?; + .map_err(|e| format!("Failed to upsert issue {}: {e}", api_issue.id))?; total_synced += 1; } diff --git a/backend/src/application/commands/update_project.rs b/backend/src/application/commands/update_project.rs index ebc2e90..3118a05 100644 --- a/backend/src/application/commands/update_project.rs +++ b/backend/src/application/commands/update_project.rs @@ -21,7 +21,7 @@ pub async fn update_project( // Validate requester address let requester = WalletAddress::new(requester_address) - .map_err(|e| format!("Invalid wallet address: {}", e))?; + .map_err(|e| format!("Invalid wallet address: {e}"))?; // Get existing project let mut project = repository diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs index 73fd935..bb51a8b 100644 --- a/backend/src/application/queries/get_all_projects.rs +++ b/backend/src/application/queries/get_all_projects.rs @@ -20,7 +20,8 @@ pub async fn get_all_projects( Some( status_str .parse::() - .map_err(|e| format!("Invalid status: {}", e))?, + .map_err(|e| format!("Invalid status: {e}"))?, + ) } else { None @@ -30,7 +31,8 @@ pub async fn get_all_projects( let creator_filter = if let Some(creator_str) = creator { Some( WalletAddress::new(creator_str) - .map_err(|e| format!("Invalid creator address: {}", e))?, + .map_err(|e| format!("Invalid creator address: {e}"))?, + ) } else { None diff --git a/backend/src/application/queries/get_login_nonce.rs b/backend/src/application/queries/get_login_nonce.rs index 6494d03..8f3c43c 100644 --- a/backend/src/application/queries/get_login_nonce.rs +++ b/backend/src/application/queries/get_login_nonce.rs @@ -14,6 +14,6 @@ pub async fn get_login_nonce( { Ok(Some(nonce)) => Ok(nonce), Ok(None) => Ok(1), // Return default nonce for new addresses - Err(e) => Err(format!("Error fetching nonce: {}", e)), + Err(e) => Err(format!("Error fetching nonce: {e}")), } } diff --git a/backend/src/application/queries/get_projects_by_creator.rs b/backend/src/application/queries/get_projects_by_creator.rs index c6dc605..e1c933f 100644 --- a/backend/src/application/queries/get_projects_by_creator.rs +++ b/backend/src/application/queries/get_projects_by_creator.rs @@ -11,7 +11,7 @@ pub async fn get_projects_by_creator( ) -> Result, String> { // Validate creator address let creator = WalletAddress::new(creator_address) - .map_err(|e| format!("Invalid wallet address: {}", e))?; + .map_err(|e| format!("Invalid wallet address: {e}"))?; // Get projects let projects = repository diff --git a/backend/src/bin/migrate.rs b/backend/src/bin/migrate.rs index 9361c8f..7b6bc16 100644 --- a/backend/src/bin/migrate.rs +++ b/backend/src/bin/migrate.rs @@ -10,7 +10,7 @@ async fn main() -> Result<(), Box> { "postgresql://guild_user:guild_password@localhost:5432/guild_genesis".to_string() }); - println!("🔌 Connecting to database: {}", database_url); + println!("🔌 Connecting to database: {database_url}"); let pool = PgPool::connect(&database_url).await?; diff --git a/backend/src/domain/entities/projects.rs b/backend/src/domain/entities/projects.rs index af8f157..fcfd397 100644 --- a/backend/src/domain/entities/projects.rs +++ b/backend/src/domain/entities/projects.rs @@ -68,7 +68,7 @@ impl std::str::FromStr for ProjectStatus { "proposal" => Ok(ProjectStatus::Proposal), "ongoing" => Ok(ProjectStatus::Ongoing), "rejected" => Ok(ProjectStatus::Rejected), - _ => Err(format!("Invalid project status: {}", s)), + _ => Err(format!("Invalid project status: {s}")), } } } diff --git a/backend/src/infrastructure/jwt.rs b/backend/src/infrastructure/jwt.rs index 22f38af..4274fd2 100644 --- a/backend/src/infrastructure/jwt.rs +++ b/backend/src/infrastructure/jwt.rs @@ -36,7 +36,7 @@ impl JwtManager { &claims, &EncodingKey::from_secret(self.secret.as_bytes()), ) - .map_err(|e| format!("Failed to generate token: {}", e)) + .map_err(|e| format!("Failed to generate token: {e}")) } pub fn validate_token(&self, token: &str) -> Result { @@ -46,7 +46,7 @@ impl JwtManager { &Validation::default(), ) .map(|data| data.claims) - .map_err(|e| format!("Invalid token: {}", e)) + .map_err(|e| format!("Invalid token: {e}")) } } diff --git a/backend/src/infrastructure/repositories/postgres_project_repository.rs b/backend/src/infrastructure/repositories/postgres_project_repository.rs index acd6a21..06833ae 100644 --- a/backend/src/infrastructure/repositories/postgres_project_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_project_repository.rs @@ -103,7 +103,7 @@ impl ProjectRepository for PostgresProjectRepository { (true, false) | (false, true) => 2, (false, false) => 1, }; - query.push_str(&format!(" LIMIT ${}", param_num)); + query.push_str(&format!(" LIMIT ${param_num}")); } if offset.is_some() { @@ -114,7 +114,7 @@ impl ProjectRepository for PostgresProjectRepository { (true, false, false) | (false, true, false) | (false, false, true) => 2, (false, false, false) => 1, }; - query.push_str(&format!(" OFFSET ${}", param_num)); + query.push_str(&format!(" OFFSET ${param_num}")); } let mut query_builder = sqlx::query_as::< diff --git a/backend/src/infrastructure/services/rest_github_service.rs b/backend/src/infrastructure/services/rest_github_service.rs index 15572b7..66f8127 100644 --- a/backend/src/infrastructure/services/rest_github_service.rs +++ b/backend/src/infrastructure/services/rest_github_service.rs @@ -6,6 +6,12 @@ pub struct RestGithubService { client: reqwest::Client, } +impl Default for RestGithubService { + fn default() -> Self { + Self::new() + } +} + impl RestGithubService { pub fn new() -> Self { Self { @@ -25,7 +31,7 @@ impl GithubService for RestGithubService { since: Option<&str>, ) -> Result<(i64, Vec), Box> { // Fetch repo metadata to get repo_id - let repo_url = format!("https://api.github.com/repos/{}", repo); + let repo_url = format!("https://api.github.com/repos/{repo}"); let repo_resp: GitHubApiRepo = self .client .get(&repo_url) @@ -37,11 +43,10 @@ impl GithubService for RestGithubService { // Fetch issues (no pagination per scope) let mut issues_url = format!( - "https://api.github.com/repos/{}/issues?state=all&per_page=100", - repo + "https://api.github.com/repos/{repo}/issues?state=all&per_page=100" ); if let Some(since_val) = since { - issues_url.push_str(&format!("&since={}", since_val)); + issues_url.push_str(&format!("&since={since_val}")); } let issues: Vec = self diff --git a/backend/tests/github_sync_tests.rs b/backend/tests/github_sync_tests.rs index 65d7a81..840eb6d 100644 --- a/backend/tests/github_sync_tests.rs +++ b/backend/tests/github_sync_tests.rs @@ -65,6 +65,7 @@ mod github_sync_tests { } } + #[allow(clippy::too_many_arguments)] fn make_api_issue( id: i64, number: i32, @@ -80,7 +81,7 @@ mod github_sync_tests { number, title: title.to_string(), state: state.to_string(), - html_url: format!("https://github.com/test/repo/issues/{}", number), + html_url: format!("https://github.com/test/repo/issues/{number}"), labels: labels .into_iter() .map(|l| GitHubApiLabel { diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index 009da69..cec8f1b 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -38,7 +38,7 @@ async fn valid_github_handle_works() { let server = axum::serve(listener, app); tokio::spawn(async move { server.await.unwrap() }); - let base = format!("http://{}", addr); + let base = format!("http://{addr}"); let client = reqwest::Client::new(); let address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; @@ -50,7 +50,7 @@ async fn valid_github_handle_works() { // Create profile let create_resp = client - .post(format!("{}/profiles", base)) + .post(format!("{base}/profiles")) .header("x-eth-address", address) .json(&json!({ "name": "Alice", @@ -68,7 +68,7 @@ async fn valid_github_handle_works() { // Update with valid GitHub handle let update_resp = client - .put(format!("{}/profiles/{}", base, address)) + .put(format!("{base}/profiles/{address}")) .header("x-eth-address", address) .json(&json!({ "github_login": "ValidUser123test" @@ -113,7 +113,7 @@ async fn invalid_format_rejected() { let server = axum::serve(listener, app); tokio::spawn(async move { server.await.unwrap() }); - let base = format!("http://{}", addr); + let base = format!("http://{addr}"); let client = reqwest::Client::new(); let address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44f"; @@ -125,7 +125,7 @@ async fn invalid_format_rejected() { // Create profile let create_resp = client - .post(format!("{}/profiles", base)) + .post(format!("{base}/profiles")) .header("x-eth-address", address) .json(&json!({ "name": "Bob", @@ -146,7 +146,7 @@ async fn invalid_format_rejected() { // Update with invalid handle let update_resp = client - .put(format!("{}/profiles/{}", base, address)) + .put(format!("{base}/profiles/{address}")) .header("x-eth-address", address) .json(&json!({ "github_login": "bad@name" @@ -194,7 +194,7 @@ async fn conflict_case_insensitive() { let server = axum::serve(listener, app); tokio::spawn(async move { server.await.unwrap() }); - let base = format!("http://{}", addr); + let base = format!("http://{addr}"); let client = reqwest::Client::new(); let addr1 = "0x742d35Cc6634C0532925a3b844Bc454e4438f44g"; @@ -208,7 +208,7 @@ async fn conflict_case_insensitive() { // Create first profile let create1 = client - .post(format!("{}/profiles", base)) + .post(format!("{base}/profiles")) .header("x-eth-address", addr1) .json(&json!({ "name": "Carol", @@ -229,7 +229,7 @@ async fn conflict_case_insensitive() { // Create second profile let create2 = client - .post(format!("{}/profiles", base)) + .post(format!("{base}/profiles")) .header("x-eth-address", addr2) .json(&json!({ "name": "Dave", @@ -250,7 +250,7 @@ async fn conflict_case_insensitive() { // Update first with "Alice" let _ = client - .put(format!("{}/profiles/{}", base, addr1)) + .put(format!("{base}/profiles/{addr1}")) .header("x-eth-address", addr1) .json(&json!({ "github_login": "Alice" })) .send() @@ -259,7 +259,7 @@ async fn conflict_case_insensitive() { // Update second with "alice" (lowercase) should conflict let conflict_resp = client - .put(format!("{}/profiles/{}", base, addr2)) + .put(format!("{base}/profiles/{addr2}")) .header("x-eth-address", addr2) .json(&json!({ "github_login": "alice" })) .send() From a7064d97eb2c892099362c3b7f8d17870383d833 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Wed, 11 Feb 2026 11:03:21 +0000 Subject: [PATCH 3/8] chores: clippy errors --- backend/src/application/commands/create_project.rs | 4 ++-- backend/src/application/queries/get_all_projects.rs | 7 +------ backend/src/application/queries/get_projects_by_creator.rs | 4 ++-- backend/src/infrastructure/services/rest_github_service.rs | 5 ++--- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs index 3fb3b81..f81fbf8 100644 --- a/backend/src/application/commands/create_project.rs +++ b/backend/src/application/commands/create_project.rs @@ -14,8 +14,8 @@ pub async fn create_project( request: CreateProjectRequest, ) -> Result { // Validate and create WalletAddress - let creator = WalletAddress::new(creator_address) - .map_err(|e| format!("Invalid wallet address: {e}"))?; + let creator = + WalletAddress::new(creator_address).map_err(|e| format!("Invalid wallet address: {e}"))?; // Verify creator has a profile if !repository diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs index bb51a8b..651c0bc 100644 --- a/backend/src/application/queries/get_all_projects.rs +++ b/backend/src/application/queries/get_all_projects.rs @@ -21,7 +21,6 @@ pub async fn get_all_projects( status_str .parse::() .map_err(|e| format!("Invalid status: {e}"))?, - ) } else { None @@ -29,11 +28,7 @@ pub async fn get_all_projects( // Parse creator if provided let creator_filter = if let Some(creator_str) = creator { - Some( - WalletAddress::new(creator_str) - .map_err(|e| format!("Invalid creator address: {e}"))?, - - ) + Some(WalletAddress::new(creator_str).map_err(|e| format!("Invalid creator address: {e}"))?) } else { None }; diff --git a/backend/src/application/queries/get_projects_by_creator.rs b/backend/src/application/queries/get_projects_by_creator.rs index e1c933f..7d86fe8 100644 --- a/backend/src/application/queries/get_projects_by_creator.rs +++ b/backend/src/application/queries/get_projects_by_creator.rs @@ -10,8 +10,8 @@ pub async fn get_projects_by_creator( creator_address: String, ) -> Result, String> { // Validate creator address - let creator = WalletAddress::new(creator_address) - .map_err(|e| format!("Invalid wallet address: {e}"))?; + let creator = + WalletAddress::new(creator_address).map_err(|e| format!("Invalid wallet address: {e}"))?; // Get projects let projects = repository diff --git a/backend/src/infrastructure/services/rest_github_service.rs b/backend/src/infrastructure/services/rest_github_service.rs index 66f8127..3c71ce6 100644 --- a/backend/src/infrastructure/services/rest_github_service.rs +++ b/backend/src/infrastructure/services/rest_github_service.rs @@ -42,9 +42,8 @@ impl GithubService for RestGithubService { .await?; // Fetch issues (no pagination per scope) - let mut issues_url = format!( - "https://api.github.com/repos/{repo}/issues?state=all&per_page=100" - ); + let mut issues_url = + format!("https://api.github.com/repos/{repo}/issues?state=all&per_page=100"); if let Some(since_val) = since { issues_url.push_str(&format!("&since={since_val}")); } From 6fcc87797dabb06086006cb774f35c30b40dccb3 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Wed, 11 Feb 2026 11:16:19 +0000 Subject: [PATCH 4/8] fix: format code and resolve reqwest version conflict --- backend/Cargo.lock | 135 ------------------ backend/Cargo.toml | 1 - .../postgres_github_issue_repository.rs | 28 ++-- 3 files changed, 20 insertions(+), 144 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d80a23e..bceab9e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1309,21 +1309,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1565,7 +1550,6 @@ dependencies = [ "hyper 0.14.32", "jsonwebtoken 9.3.1", "regex", - "reqwest 0.11.27", "reqwest 0.12.28", "serde", "serde_json", @@ -1818,19 +1802,6 @@ dependencies = [ "webpki-roots 1.0.2", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-util" version = "0.1.16" @@ -2342,23 +2313,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2509,50 +2463,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3127,12 +3037,10 @@ dependencies = [ "http-body 0.4.6", "hyper 0.14.32", "hyper-rustls 0.24.2", - "hyper-tls", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", @@ -3144,7 +3052,6 @@ dependencies = [ "sync_wrapper 0.1.2", "system-configuration", "tokio", - "tokio-native-tls", "tokio-rustls 0.24.1", "tower-service", "url", @@ -3444,15 +3351,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.0", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3495,29 +3393,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.4", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.26" @@ -4310,16 +4185,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.24.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9dddd76..88e908a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -43,5 +43,4 @@ dotenvy = "0.15" [dev-dependencies] tokio = { version = "1.0", features = ["macros"] } -reqwest = { version = "0.11", features = ["json"] } hyper = { version = "0.14", features = ["full"] } \ No newline at end of file diff --git a/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs index 7e984fc..8047ca5 100644 --- a/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs @@ -69,14 +69,26 @@ impl GithubIssueRepository for PostgresGithubIssueRepository { repo_id: i64, github_issue_id: i64, ) -> Result, Box> { - let row = sqlx::query_as::<_, ( - i64, i64, String, i32, String, String, - serde_json::Value, i32, serde_json::Value, String, - chrono::DateTime, - Option>, - bool, Option, - chrono::DateTime, - )>( + let row = sqlx::query_as::< + _, + ( + i64, + i64, + String, + i32, + String, + String, + serde_json::Value, + i32, + serde_json::Value, + String, + chrono::DateTime, + Option>, + bool, + Option, + chrono::DateTime, + ), + >( r#" SELECT repo_id, github_issue_id, repo, number, title, state, labels, points, assignee_logins, html_url, From 5411943e7b2771b0e4f729d9dcc0fd18e7e4be14 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Thu, 12 Feb 2026 14:11:01 +0000 Subject: [PATCH 5/8] refactor: address PR feedback - field renames, env vars, auth, and docs --- backend/README.md | 55 +++++++++++++++++++ .../006_create_github_issues_table.sql | 8 +-- .../application/commands/create_project.rs | 4 +- .../application/commands/delete_project.rs | 2 +- .../commands/sync_github_issues.rs | 6 +- .../application/commands/update_project.rs | 2 +- .../application/queries/get_all_projects.rs | 7 ++- .../application/queries/get_login_nonce.rs | 2 +- .../queries/get_projects_by_creator.rs | 4 +- backend/src/bin/migrate.rs | 2 +- backend/src/domain/entities/github_issue.rs | 6 +- backend/src/domain/entities/projects.rs | 2 +- backend/src/domain/services/github_service.rs | 2 +- backend/src/infrastructure/jwt.rs | 4 +- .../postgres_github_issue_repository.rs | 28 +++++----- .../postgres_project_repository.rs | 4 +- .../services/rest_github_service.rs | 30 +++++++++- backend/tests/github_sync_tests.rs | 4 +- 18 files changed, 127 insertions(+), 45 deletions(-) diff --git a/backend/README.md b/backend/README.md index 2aec9a3..e982205 100644 --- a/backend/README.md +++ b/backend/README.md @@ -411,3 +411,58 @@ docker run -e DATABASE_URL=postgresql://... guild-backend - `src/application`: commands, queries, and DTOs - `migrations/`: SQLx migration files - `.sqlx/`: SQLx offline query metadata (committed to repo) + +## 12) GitHub Issue Ingestion + +The backend can sync GitHub issues into the database via an admin-protected endpoint. + +### Required Environment Variables + +Add these to `backend/.env`: + +``` +GITHUB_TOKEN=ghp_your_personal_access_token +GITHUB_OWNER=TheSoftwareDevGuild +GITHUB_API_URL=https://api.github.com +``` + +| Variable | Required | Description | +|---|---|---| +| `GITHUB_TOKEN` | Yes | GitHub personal access token (PAT) with `repo` scope | +| `GITHUB_OWNER` | Yes | GitHub organization or user that owns the repos | +| `GITHUB_API_URL` | No | API base URL (defaults to `https://api.github.com`) | + +### Trigger Sync (Admin) + +The sync endpoint is protected by admin authentication. You need a wallet address listed in the `ADMIN_ADDRESSES` environment variable. + +```bash +# Sync issues for one or more repositories +curl -X POST http://localhost:3001/admin/github/sync \ + -H "Content-Type: application/json" \ + -H "x-eth-address: " \ + -d '{ + "repos": ["TheGuildGenesis"], + "since": "2025-01-01T00:00:00Z" + }' +``` + +**Request body**: +- `repos` (required): List of repository names under `GITHUB_OWNER` to sync +- `since` (optional): ISO 8601 timestamp — only sync issues updated after this date + +**Response**: +```json +{ + "synced": 42, + "repos": ["TheGuildGenesis"] +} +``` + +### How It Works +- Fetches issues via `{GITHUB_API_URL}/repos/{GITHUB_OWNER}/{repo}/issues` +- Ignores pull requests (GitHub returns PRs in the issues endpoint) +- Derives `points` from labels matching the pattern `points:N` (case-insensitive) +- Normalizes all labels to lowercase +- Upserts using composite key `(repo_id, github_issue_id)` for idempotency +- Preserves `rewarded_sepolia` and `distribution_id` across re-syncs diff --git a/backend/migrations/006_create_github_issues_table.sql b/backend/migrations/006_create_github_issues_table.sql index 9bb084b..0ff537b 100644 --- a/backend/migrations/006_create_github_issues_table.sql +++ b/backend/migrations/006_create_github_issues_table.sql @@ -2,16 +2,16 @@ CREATE TABLE IF NOT EXISTS github_issues ( repo_id BIGINT NOT NULL, github_issue_id BIGINT NOT NULL, repo TEXT NOT NULL, - number INT NOT NULL, + issue_number INT NOT NULL, title TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('open', 'closed')), labels JSONB NOT NULL DEFAULT '[]', points INT NOT NULL DEFAULT 0, assignee_logins JSONB NOT NULL DEFAULT '[]', - html_url TEXT NOT NULL, + url TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, closed_at TIMESTAMPTZ, - rewarded BOOLEAN NOT NULL DEFAULT false, + rewarded_sepolia BOOLEAN NOT NULL DEFAULT false, distribution_id TEXT, updated_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (repo_id, github_issue_id) @@ -19,4 +19,4 @@ CREATE TABLE IF NOT EXISTS github_issues ( CREATE INDEX IF NOT EXISTS idx_github_issues_repo ON github_issues(repo); CREATE INDEX IF NOT EXISTS idx_github_issues_state ON github_issues(state); -CREATE INDEX IF NOT EXISTS idx_github_issues_rewarded ON github_issues(rewarded); +CREATE INDEX IF NOT EXISTS idx_github_issues_rewarded_sepolia ON github_issues(rewarded_sepolia); diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs index f81fbf8..a157f7d 100644 --- a/backend/src/application/commands/create_project.rs +++ b/backend/src/application/commands/create_project.rs @@ -14,8 +14,8 @@ pub async fn create_project( request: CreateProjectRequest, ) -> Result { // Validate and create WalletAddress - let creator = - WalletAddress::new(creator_address).map_err(|e| format!("Invalid wallet address: {e}"))?; + let creator = WalletAddress::new(creator_address) + .map_err(|e| format!("Invalid wallet address: {}", e))?; // Verify creator has a profile if !repository diff --git a/backend/src/application/commands/delete_project.rs b/backend/src/application/commands/delete_project.rs index 30da94f..eccfd77 100644 --- a/backend/src/application/commands/delete_project.rs +++ b/backend/src/application/commands/delete_project.rs @@ -16,7 +16,7 @@ pub async fn delete_project( // Validate requester address let requester = WalletAddress::new(requester_address) - .map_err(|e| format!("Invalid wallet address: {e}"))?; + .map_err(|e| format!("Invalid wallet address: {}", e))?; // Get existing project let project = repository diff --git a/backend/src/application/commands/sync_github_issues.rs b/backend/src/application/commands/sync_github_issues.rs index 25f3302..1ea9142 100644 --- a/backend/src/application/commands/sync_github_issues.rs +++ b/backend/src/application/commands/sync_github_issues.rs @@ -62,16 +62,16 @@ pub fn transform_issue( repo_id, github_issue_id: api_issue.id, repo: repo.to_string(), - number: api_issue.number, + issue_number: api_issue.number, title: api_issue.title.clone(), state: api_issue.state.clone(), labels: serde_json::Value::Array(labels_normalized), points, assignee_logins: serde_json::Value::Array(assignee_logins), - html_url: api_issue.html_url.clone(), + url: api_issue.html_url.clone(), created_at, closed_at, - rewarded: false, + rewarded_sepolia: false, distribution_id: None, updated_at, }) diff --git a/backend/src/application/commands/update_project.rs b/backend/src/application/commands/update_project.rs index 3118a05..ebc2e90 100644 --- a/backend/src/application/commands/update_project.rs +++ b/backend/src/application/commands/update_project.rs @@ -21,7 +21,7 @@ pub async fn update_project( // Validate requester address let requester = WalletAddress::new(requester_address) - .map_err(|e| format!("Invalid wallet address: {e}"))?; + .map_err(|e| format!("Invalid wallet address: {}", e))?; // Get existing project let mut project = repository diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs index 651c0bc..73fd935 100644 --- a/backend/src/application/queries/get_all_projects.rs +++ b/backend/src/application/queries/get_all_projects.rs @@ -20,7 +20,7 @@ pub async fn get_all_projects( Some( status_str .parse::() - .map_err(|e| format!("Invalid status: {e}"))?, + .map_err(|e| format!("Invalid status: {}", e))?, ) } else { None @@ -28,7 +28,10 @@ pub async fn get_all_projects( // Parse creator if provided let creator_filter = if let Some(creator_str) = creator { - Some(WalletAddress::new(creator_str).map_err(|e| format!("Invalid creator address: {e}"))?) + Some( + WalletAddress::new(creator_str) + .map_err(|e| format!("Invalid creator address: {}", e))?, + ) } else { None }; diff --git a/backend/src/application/queries/get_login_nonce.rs b/backend/src/application/queries/get_login_nonce.rs index 8f3c43c..6494d03 100644 --- a/backend/src/application/queries/get_login_nonce.rs +++ b/backend/src/application/queries/get_login_nonce.rs @@ -14,6 +14,6 @@ pub async fn get_login_nonce( { Ok(Some(nonce)) => Ok(nonce), Ok(None) => Ok(1), // Return default nonce for new addresses - Err(e) => Err(format!("Error fetching nonce: {e}")), + Err(e) => Err(format!("Error fetching nonce: {}", e)), } } diff --git a/backend/src/application/queries/get_projects_by_creator.rs b/backend/src/application/queries/get_projects_by_creator.rs index 7d86fe8..c6dc605 100644 --- a/backend/src/application/queries/get_projects_by_creator.rs +++ b/backend/src/application/queries/get_projects_by_creator.rs @@ -10,8 +10,8 @@ pub async fn get_projects_by_creator( creator_address: String, ) -> Result, String> { // Validate creator address - let creator = - WalletAddress::new(creator_address).map_err(|e| format!("Invalid wallet address: {e}"))?; + let creator = WalletAddress::new(creator_address) + .map_err(|e| format!("Invalid wallet address: {}", e))?; // Get projects let projects = repository diff --git a/backend/src/bin/migrate.rs b/backend/src/bin/migrate.rs index 7b6bc16..9361c8f 100644 --- a/backend/src/bin/migrate.rs +++ b/backend/src/bin/migrate.rs @@ -10,7 +10,7 @@ async fn main() -> Result<(), Box> { "postgresql://guild_user:guild_password@localhost:5432/guild_genesis".to_string() }); - println!("🔌 Connecting to database: {database_url}"); + println!("🔌 Connecting to database: {}", database_url); let pool = PgPool::connect(&database_url).await?; diff --git a/backend/src/domain/entities/github_issue.rs b/backend/src/domain/entities/github_issue.rs index f4edcc1..c55fbf3 100644 --- a/backend/src/domain/entities/github_issue.rs +++ b/backend/src/domain/entities/github_issue.rs @@ -6,16 +6,16 @@ pub struct GithubIssue { pub repo_id: i64, pub github_issue_id: i64, pub repo: String, - pub number: i32, + pub issue_number: i32, pub title: String, pub state: String, pub labels: serde_json::Value, pub points: i32, pub assignee_logins: serde_json::Value, - pub html_url: String, + pub url: String, pub created_at: DateTime, pub closed_at: Option>, - pub rewarded: bool, + pub rewarded_sepolia: bool, pub distribution_id: Option, pub updated_at: DateTime, } diff --git a/backend/src/domain/entities/projects.rs b/backend/src/domain/entities/projects.rs index fcfd397..af8f157 100644 --- a/backend/src/domain/entities/projects.rs +++ b/backend/src/domain/entities/projects.rs @@ -68,7 +68,7 @@ impl std::str::FromStr for ProjectStatus { "proposal" => Ok(ProjectStatus::Proposal), "ongoing" => Ok(ProjectStatus::Ongoing), "rejected" => Ok(ProjectStatus::Rejected), - _ => Err(format!("Invalid project status: {s}")), + _ => Err(format!("Invalid project status: {}", s)), } } } diff --git a/backend/src/domain/services/github_service.rs b/backend/src/domain/services/github_service.rs index e18d3bb..cc22dba 100644 --- a/backend/src/domain/services/github_service.rs +++ b/backend/src/domain/services/github_service.rs @@ -35,7 +35,7 @@ pub struct GitHubApiRepo { #[async_trait] pub trait GithubService: Send + Sync { /// Fetch issues from a GitHub repository via REST API. - /// `repo` is in the format "org/repo". + /// `repo` is the repository name (e.g. "TheGuildGenesis"); owner comes from GITHUB_OWNER env var. /// `since` is an optional ISO 8601 timestamp to filter issues updated since that time. async fn fetch_issues( &self, diff --git a/backend/src/infrastructure/jwt.rs b/backend/src/infrastructure/jwt.rs index 4274fd2..22f38af 100644 --- a/backend/src/infrastructure/jwt.rs +++ b/backend/src/infrastructure/jwt.rs @@ -36,7 +36,7 @@ impl JwtManager { &claims, &EncodingKey::from_secret(self.secret.as_bytes()), ) - .map_err(|e| format!("Failed to generate token: {e}")) + .map_err(|e| format!("Failed to generate token: {}", e)) } pub fn validate_token(&self, token: &str) -> Result { @@ -46,7 +46,7 @@ impl JwtManager { &Validation::default(), ) .map(|data| data.claims) - .map_err(|e| format!("Invalid token: {e}")) + .map_err(|e| format!("Invalid token: {}", e)) } } diff --git a/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs index 8047ca5..96374b0 100644 --- a/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs @@ -23,20 +23,20 @@ impl GithubIssueRepository for PostgresGithubIssueRepository { sqlx::query( r#" INSERT INTO github_issues ( - repo_id, github_issue_id, repo, number, title, state, - labels, points, assignee_logins, html_url, - created_at, closed_at, rewarded, distribution_id, updated_at + repo_id, github_issue_id, repo, issue_number, title, state, + labels, points, assignee_logins, url, + created_at, closed_at, rewarded_sepolia, distribution_id, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (repo_id, github_issue_id) DO UPDATE SET repo = EXCLUDED.repo, - number = EXCLUDED.number, + issue_number = EXCLUDED.issue_number, title = EXCLUDED.title, state = EXCLUDED.state, labels = EXCLUDED.labels, points = EXCLUDED.points, assignee_logins = EXCLUDED.assignee_logins, - html_url = EXCLUDED.html_url, + url = EXCLUDED.url, created_at = EXCLUDED.created_at, closed_at = EXCLUDED.closed_at, updated_at = EXCLUDED.updated_at @@ -45,16 +45,16 @@ impl GithubIssueRepository for PostgresGithubIssueRepository { .bind(issue.repo_id) .bind(issue.github_issue_id) .bind(&issue.repo) - .bind(issue.number) + .bind(issue.issue_number) .bind(&issue.title) .bind(&issue.state) .bind(&issue.labels) .bind(issue.points) .bind(&issue.assignee_logins) - .bind(&issue.html_url) + .bind(&issue.url) .bind(issue.created_at) .bind(issue.closed_at) - .bind(issue.rewarded) + .bind(issue.rewarded_sepolia) .bind(&issue.distribution_id) .bind(issue.updated_at) .execute(&self.pool) @@ -90,9 +90,9 @@ impl GithubIssueRepository for PostgresGithubIssueRepository { ), >( r#" - SELECT repo_id, github_issue_id, repo, number, title, state, - labels, points, assignee_logins, html_url, - created_at, closed_at, rewarded, distribution_id, updated_at + SELECT repo_id, github_issue_id, repo, issue_number, title, state, + labels, points, assignee_logins, url, + created_at, closed_at, rewarded_sepolia, distribution_id, updated_at FROM github_issues WHERE repo_id = $1 AND github_issue_id = $2 "#, @@ -107,16 +107,16 @@ impl GithubIssueRepository for PostgresGithubIssueRepository { repo_id: r.0, github_issue_id: r.1, repo: r.2, - number: r.3, + issue_number: r.3, title: r.4, state: r.5, labels: r.6, points: r.7, assignee_logins: r.8, - html_url: r.9, + url: r.9, created_at: r.10, closed_at: r.11, - rewarded: r.12, + rewarded_sepolia: r.12, distribution_id: r.13, updated_at: r.14, })) diff --git a/backend/src/infrastructure/repositories/postgres_project_repository.rs b/backend/src/infrastructure/repositories/postgres_project_repository.rs index 06833ae..acd6a21 100644 --- a/backend/src/infrastructure/repositories/postgres_project_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_project_repository.rs @@ -103,7 +103,7 @@ impl ProjectRepository for PostgresProjectRepository { (true, false) | (false, true) => 2, (false, false) => 1, }; - query.push_str(&format!(" LIMIT ${param_num}")); + query.push_str(&format!(" LIMIT ${}", param_num)); } if offset.is_some() { @@ -114,7 +114,7 @@ impl ProjectRepository for PostgresProjectRepository { (true, false, false) | (false, true, false) | (false, false, true) => 2, (false, false, false) => 1, }; - query.push_str(&format!(" OFFSET ${param_num}")); + query.push_str(&format!(" OFFSET ${}", param_num)); } let mut query_builder = sqlx::query_as::< diff --git a/backend/src/infrastructure/services/rest_github_service.rs b/backend/src/infrastructure/services/rest_github_service.rs index 3c71ce6..1cd0be6 100644 --- a/backend/src/infrastructure/services/rest_github_service.rs +++ b/backend/src/infrastructure/services/rest_github_service.rs @@ -4,6 +4,8 @@ use crate::domain::services::github_service::{GitHubApiIssue, GitHubApiRepo, Git pub struct RestGithubService { client: reqwest::Client, + api_url: String, + owner: String, } impl Default for RestGithubService { @@ -14,11 +16,31 @@ impl Default for RestGithubService { impl RestGithubService { pub fn new() -> Self { + let token = std::env::var("GITHUB_TOKEN").unwrap_or_default(); + let api_url = std::env::var("GITHUB_API_URL") + .unwrap_or_else(|_| "https://api.github.com".to_string()); + let owner = std::env::var("GITHUB_OWNER").unwrap_or_default(); + + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::ACCEPT, + "application/vnd.github+json".parse().unwrap(), + ); + if !token.is_empty() { + headers.insert( + reqwest::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + } + Self { client: reqwest::Client::builder() .user_agent("guild-backend") + .default_headers(headers) .build() .expect("Failed to build HTTP client"), + api_url, + owner, } } } @@ -31,7 +53,7 @@ impl GithubService for RestGithubService { since: Option<&str>, ) -> Result<(i64, Vec), Box> { // Fetch repo metadata to get repo_id - let repo_url = format!("https://api.github.com/repos/{repo}"); + let repo_url = format!("{}/repos/{}/{}", self.api_url, self.owner, repo); let repo_resp: GitHubApiRepo = self .client .get(&repo_url) @@ -42,8 +64,10 @@ impl GithubService for RestGithubService { .await?; // Fetch issues (no pagination per scope) - let mut issues_url = - format!("https://api.github.com/repos/{repo}/issues?state=all&per_page=100"); + let mut issues_url = format!( + "{}/repos/{}/{}/issues?state=all&per_page=100", + self.api_url, self.owner, repo + ); if let Some(since_val) = since { issues_url.push_str(&format!("&since={since_val}")); } diff --git a/backend/tests/github_sync_tests.rs b/backend/tests/github_sync_tests.rs index 840eb6d..a13c20b 100644 --- a/backend/tests/github_sync_tests.rs +++ b/backend/tests/github_sync_tests.rs @@ -296,8 +296,8 @@ mod github_sync_tests { assert_eq!(issue.state, "closed"); assert!(issue.closed_at.is_some()); assert_eq!(issue.points, 5); - // rewarded should not be set during sync - assert!(!issue.rewarded); + // rewarded_sepolia should not be set during sync + assert!(!issue.rewarded_sepolia); } // ======================================================================== From 7ec4e9ba8b992767e4c8a131e8233e86592679d2 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Tue, 17 Feb 2026 06:54:20 +0000 Subject: [PATCH 6/8] chore: apply PR review fixes and add GET /github/issues --- backend/README.md | 35 +++++++++ backend/src/application/dtos/github_dtos.rs | 7 ++ .../repositories/github_issue_repository.rs | 7 ++ .../postgres_github_issue_repository.rs | 74 +++++++++++++++++++ backend/src/presentation/api.rs | 5 ++ backend/src/presentation/handlers.rs | 21 +++++- backend/tests/github_sync_tests.rs | 13 ++++ 7 files changed, 161 insertions(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index e982205..82d6b8c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -466,3 +466,38 @@ curl -X POST http://localhost:3001/admin/github/sync \ - Normalizes all labels to lowercase - Upserts using composite key `(repo_id, github_issue_id)` for idempotency - Preserves `rewarded_sepolia` and `distribution_id` across re-syncs + +### Fetch Synced Issues (Public) + +After syncing, query the stored issues to verify: + +```bash +# List all synced issues for a repo +curl http://localhost:3001/github/issues?repo=TheGuildGenesis + +# Filter by state +curl "http://localhost:3001/github/issues?repo=TheGuildGenesis&state=closed" +``` + +**Response** (array of `GithubIssue`): +```json +[ + { + "repo_id": 123456, + "github_issue_id": 789, + "repo": "TheGuildGenesis", + "issue_number": 42, + "title": "Implement feature X", + "state": "open", + "labels": ["bug", "points:3"], + "points": 3, + "assignee_logins": ["alice"], + "url": "https://github.com/TheSoftwareDevGuild/TheGuildGenesis/issues/42", + "created_at": "2025-01-15T10:00:00Z", + "closed_at": null, + "rewarded_sepolia": false, + "distribution_id": null, + "updated_at": "2025-01-20T12:00:00Z" + } +] +``` diff --git a/backend/src/application/dtos/github_dtos.rs b/backend/src/application/dtos/github_dtos.rs index 4eacc58..a4773cf 100644 --- a/backend/src/application/dtos/github_dtos.rs +++ b/backend/src/application/dtos/github_dtos.rs @@ -13,3 +13,10 @@ pub struct GithubSyncResponse { pub synced: usize, pub repos: Vec, } + +/// Query parameters for GET /github/issues +#[derive(Debug, Deserialize)] +pub struct GithubIssuesQuery { + pub repo: String, + pub state: Option, +} diff --git a/backend/src/domain/repositories/github_issue_repository.rs b/backend/src/domain/repositories/github_issue_repository.rs index cf683ca..fac9857 100644 --- a/backend/src/domain/repositories/github_issue_repository.rs +++ b/backend/src/domain/repositories/github_issue_repository.rs @@ -13,4 +13,11 @@ pub trait GithubIssueRepository: Send + Sync { repo_id: i64, github_issue_id: i64, ) -> Result, Box>; + + /// List issues filtered by repo name and optional state + async fn list_by_repo( + &self, + repo: &str, + state: Option<&str>, + ) -> Result, Box>; } diff --git a/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs index 96374b0..247fe76 100644 --- a/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs @@ -121,4 +121,78 @@ impl GithubIssueRepository for PostgresGithubIssueRepository { updated_at: r.14, })) } + + async fn list_by_repo( + &self, + repo: &str, + state: Option<&str>, + ) -> Result, Box> { + let rows: Vec<( + i64, + i64, + String, + i32, + String, + String, + serde_json::Value, + i32, + serde_json::Value, + String, + chrono::DateTime, + Option>, + bool, + Option, + chrono::DateTime, + )> = if let Some(st) = state { + sqlx::query_as( + r#" + SELECT repo_id, github_issue_id, repo, issue_number, title, state, + labels, points, assignee_logins, url, + created_at, closed_at, rewarded_sepolia, distribution_id, updated_at + FROM github_issues + WHERE repo = $1 AND state = $2 + ORDER BY created_at DESC + "#, + ) + .bind(repo) + .bind(st) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query_as( + r#" + SELECT repo_id, github_issue_id, repo, issue_number, title, state, + labels, points, assignee_logins, url, + created_at, closed_at, rewarded_sepolia, distribution_id, updated_at + FROM github_issues + WHERE repo = $1 + ORDER BY created_at DESC + "#, + ) + .bind(repo) + .fetch_all(&self.pool) + .await? + }; + + Ok(rows + .into_iter() + .map(|r| GithubIssue { + repo_id: r.0, + github_issue_id: r.1, + repo: r.2, + issue_number: r.3, + title: r.4, + state: r.5, + labels: r.6, + points: r.7, + assignee_logins: r.8, + url: r.9, + created_at: r.10, + closed_at: r.11, + rewarded_sepolia: r.12, + distribution_id: r.13, + updated_at: r.14, + }) + .collect()) + } } diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index a0e748a..c4cb791 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -40,6 +40,7 @@ use super::handlers::{ get_user_projects_handler, // GitHub sync handler github_sync_handler, + list_github_issues_handler, list_projects_handler, login_handler, update_profile_handler, @@ -109,6 +110,8 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { .route("/projects", get(list_projects_handler)) .route("/projects/:id", get(get_project_handler)) .route("/users/:address/projects", get(get_user_projects_handler)) + // GitHub issues public route + .route("/github/issues", get(list_github_issues_handler)) .with_state(state.clone()); Router::new() @@ -179,6 +182,8 @@ pub fn test_api(state: AppState) -> Router { .route("/projects", get(list_projects_handler)) .route("/projects/:id", get(get_project_handler)) .route("/users/:address/projects", get(get_user_projects_handler)) + // GitHub issues public route + .route("/github/issues", get(list_github_issues_handler)) .with_state(state.clone()); Router::new() diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 8f6192c..f93b9cb 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -38,7 +38,7 @@ use crate::application::{ // GitHub sync imports use crate::application::{ commands::sync_github_issues::sync_github_issues, - dtos::github_dtos::{GithubSyncRequest, GithubSyncResponse}, + dtos::github_dtos::{GithubIssuesQuery, GithubSyncRequest, GithubSyncResponse}, }; use super::{api::AppState, middlewares::VerifiedWallet}; @@ -354,3 +354,22 @@ pub async fn github_sync_handler( .into_response(), } } + +/// GET /github/issues?repo=&state= - List synced GitHub issues (Public) +pub async fn list_github_issues_handler( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + match state + .github_issue_repository + .list_by_repo(¶ms.repo, params.state.as_deref()) + .await + { + Ok(issues) => (StatusCode::OK, Json(issues)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to fetch issues: {e}")})), + ) + .into_response(), + } +} diff --git a/backend/tests/github_sync_tests.rs b/backend/tests/github_sync_tests.rs index a13c20b..5c8916f 100644 --- a/backend/tests/github_sync_tests.rs +++ b/backend/tests/github_sync_tests.rs @@ -47,6 +47,19 @@ mod github_sync_tests { .find(|i| i.repo_id == repo_id && i.github_issue_id == github_issue_id) .cloned()) } + + async fn list_by_repo( + &self, + repo: &str, + state: Option<&str>, + ) -> Result, Box> { + let list = self.issues.lock().unwrap(); + Ok(list + .iter() + .filter(|i| i.repo == repo && state.map_or(true, |s| i.state == s)) + .cloned() + .collect()) + } } struct FakeGithubService { From 61f60a726af57e3dd2a7a103808c2d87caed724e Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Tue, 17 Feb 2026 10:31:34 +0000 Subject: [PATCH 7/8] fix: resolve clippy unnecessary_map_or in github sync tests --- backend/tests/github_sync_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/github_sync_tests.rs b/backend/tests/github_sync_tests.rs index 5c8916f..8a8d41c 100644 --- a/backend/tests/github_sync_tests.rs +++ b/backend/tests/github_sync_tests.rs @@ -56,7 +56,7 @@ mod github_sync_tests { let list = self.issues.lock().unwrap(); Ok(list .iter() - .filter(|i| i.repo == repo && state.map_or(true, |s| i.state == s)) + .filter(|i| i.repo == repo && state.is_none_or(|s| i.state == s)) .cloned() .collect()) } From 7fb942465dcf942f124e73b8e67aa0ebf3b8c2cf Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Wed, 18 Feb 2026 04:27:42 +0000 Subject: [PATCH 8/8] fix: use Npts label format per notebook and clean integration test diff --- backend/README.md | 4 ++-- .../commands/sync_github_issues.rs | 4 ++-- backend/tests/github_sync_tests.rs | 12 +++++----- backend/tests/integration_github_handle.rs | 23 ++++++++++--------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/backend/README.md b/backend/README.md index 82d6b8c..f42f578 100644 --- a/backend/README.md +++ b/backend/README.md @@ -462,7 +462,7 @@ curl -X POST http://localhost:3001/admin/github/sync \ ### How It Works - Fetches issues via `{GITHUB_API_URL}/repos/{GITHUB_OWNER}/{repo}/issues` - Ignores pull requests (GitHub returns PRs in the issues endpoint) -- Derives `points` from labels matching the pattern `points:N` (case-insensitive) +- Derives `points` from labels matching the pattern `Npts` (e.g. `3pts`, `10pts`, case-insensitive) - Normalizes all labels to lowercase - Upserts using composite key `(repo_id, github_issue_id)` for idempotency - Preserves `rewarded_sepolia` and `distribution_id` across re-syncs @@ -489,7 +489,7 @@ curl "http://localhost:3001/github/issues?repo=TheGuildGenesis&state=closed" "issue_number": 42, "title": "Implement feature X", "state": "open", - "labels": ["bug", "points:3"], + "labels": ["bug", "3pts"], "points": 3, "assignee_logins": ["alice"], "url": "https://github.com/TheSoftwareDevGuild/TheGuildGenesis/issues/42", diff --git a/backend/src/application/commands/sync_github_issues.rs b/backend/src/application/commands/sync_github_issues.rs index 1ea9142..af34bc7 100644 --- a/backend/src/application/commands/sync_github_issues.rs +++ b/backend/src/application/commands/sync_github_issues.rs @@ -8,10 +8,10 @@ use crate::domain::{ services::github_service::{GitHubApiIssue, GithubService}, }; -/// Derive points from labels matching the pattern `points:N`. +/// Derive points from labels matching the pattern `Npts` (e.g. `3pts`, `10pts`). /// Label names are normalized to lower-case. pub fn derive_points(labels: &[crate::domain::services::github_service::GitHubApiLabel]) -> i32 { - let re = Regex::new(r"^points:(\d+)$").expect("Invalid regex"); + let re = Regex::new(r"^(\d+)pts$").expect("Invalid regex"); for label in labels { let name = label.name.to_lowercase(); if let Some(caps) = re.captures(&name) { diff --git a/backend/tests/github_sync_tests.rs b/backend/tests/github_sync_tests.rs index 8a8d41c..fd708b5 100644 --- a/backend/tests/github_sync_tests.rs +++ b/backend/tests/github_sync_tests.rs @@ -129,7 +129,7 @@ mod github_sync_tests { name: "bug".to_string(), }, GitHubApiLabel { - name: "points:3".to_string(), + name: "3pts".to_string(), }, ]; assert_eq!(derive_points(&labels), 3); @@ -138,7 +138,7 @@ mod github_sync_tests { #[test] fn test_derive_points_case_insensitive() { let labels = vec![GitHubApiLabel { - name: "Points:5".to_string(), + name: "5Pts".to_string(), }]; assert_eq!(derive_points(&labels), 5); } @@ -173,7 +173,7 @@ mod github_sync_tests { 1, "Test Issue", "open", - vec!["Bug", "Points:3"], + vec!["Bug", "3Pts"], vec!["alice"], false, None, @@ -183,7 +183,7 @@ mod github_sync_tests { let labels = result.labels.as_array().unwrap(); assert_eq!(labels[0].as_str().unwrap(), "bug"); - assert_eq!(labels[1].as_str().unwrap(), "points:3"); + assert_eq!(labels[1].as_str().unwrap(), "3pts"); assert_eq!(result.points, 3); } @@ -232,7 +232,7 @@ mod github_sync_tests { 1, "Issue 1", "open", - vec!["points:2"], + vec!["2pts"], vec!["bob"], false, None, @@ -282,7 +282,7 @@ mod github_sync_tests { 10, "Closed Issue", "closed", - vec!["points:5"], + vec!["5pts"], vec!["charlie"], false, Some("2025-06-01T12:00:00Z"), diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index cec8f1b..5980468 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -22,6 +22,7 @@ async fn valid_github_handle_works() { ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); let github_service: Arc = Arc::from(RestGithubService::new()); @@ -38,7 +39,7 @@ async fn valid_github_handle_works() { let server = axum::serve(listener, app); tokio::spawn(async move { server.await.unwrap() }); - let base = format!("http://{addr}"); + let base = format!("http://{}", addr); let client = reqwest::Client::new(); let address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; @@ -50,7 +51,7 @@ async fn valid_github_handle_works() { // Create profile let create_resp = client - .post(format!("{base}/profiles")) + .post(format!("{}/profiles", base)) .header("x-eth-address", address) .json(&json!({ "name": "Alice", @@ -68,7 +69,7 @@ async fn valid_github_handle_works() { // Update with valid GitHub handle let update_resp = client - .put(format!("{base}/profiles/{address}")) + .put(format!("{}/profiles/{}", base, address)) .header("x-eth-address", address) .json(&json!({ "github_login": "ValidUser123test" @@ -113,7 +114,7 @@ async fn invalid_format_rejected() { let server = axum::serve(listener, app); tokio::spawn(async move { server.await.unwrap() }); - let base = format!("http://{addr}"); + let base = format!("http://{}", addr); let client = reqwest::Client::new(); let address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44f"; @@ -125,7 +126,7 @@ async fn invalid_format_rejected() { // Create profile let create_resp = client - .post(format!("{base}/profiles")) + .post(format!("{}/profiles", base)) .header("x-eth-address", address) .json(&json!({ "name": "Bob", @@ -146,7 +147,7 @@ async fn invalid_format_rejected() { // Update with invalid handle let update_resp = client - .put(format!("{base}/profiles/{address}")) + .put(format!("{}/profiles/{}", base, address)) .header("x-eth-address", address) .json(&json!({ "github_login": "bad@name" @@ -194,7 +195,7 @@ async fn conflict_case_insensitive() { let server = axum::serve(listener, app); tokio::spawn(async move { server.await.unwrap() }); - let base = format!("http://{addr}"); + let base = format!("http://{}", addr); let client = reqwest::Client::new(); let addr1 = "0x742d35Cc6634C0532925a3b844Bc454e4438f44g"; @@ -208,7 +209,7 @@ async fn conflict_case_insensitive() { // Create first profile let create1 = client - .post(format!("{base}/profiles")) + .post(format!("{}/profiles", base)) .header("x-eth-address", addr1) .json(&json!({ "name": "Carol", @@ -229,7 +230,7 @@ async fn conflict_case_insensitive() { // Create second profile let create2 = client - .post(format!("{base}/profiles")) + .post(format!("{}/profiles", base)) .header("x-eth-address", addr2) .json(&json!({ "name": "Dave", @@ -250,7 +251,7 @@ async fn conflict_case_insensitive() { // Update first with "Alice" let _ = client - .put(format!("{base}/profiles/{addr1}")) + .put(format!("{}/profiles/{}", base, addr1)) .header("x-eth-address", addr1) .json(&json!({ "github_login": "Alice" })) .send() @@ -259,7 +260,7 @@ async fn conflict_case_insensitive() { // Update second with "alice" (lowercase) should conflict let conflict_resp = client - .put(format!("{base}/profiles/{addr2}")) + .put(format!("{}/profiles/{}", base, addr2)) .header("x-eth-address", addr2) .json(&json!({ "github_login": "alice" })) .send()