diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a14472f..bceab9e 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", @@ -1303,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" @@ -1503,9 +1494,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 +1550,7 @@ dependencies = [ "hyper 0.14.32", "jsonwebtoken 9.3.1", "regex", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sha3", @@ -1565,7 +1558,7 @@ dependencies = [ "sqlx", "tokio", "tower 0.4.13", - "tower-http", + "tower-http 0.5.2", "tracing", "tracing-subscriber", "uuid 1.18.1", @@ -1775,6 +1768,7 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", ] [[package]] @@ -1788,20 +1782,24 @@ dependencies = [ "hyper 0.14.32", "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "hyper-rustls" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", + "http 1.3.1", + "hyper 1.7.0", + "hyper-util", + "rustls 0.23.31", + "rustls-pki-types", "tokio", - "tokio-native-tls", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.2", ] [[package]] @@ -1810,14 +1808,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 +2250,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" @@ -2301,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" @@ -2468,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" @@ -2857,6 +2808,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,13 +3036,11 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", - "hyper-rustls", - "hyper-tls", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", @@ -3048,8 +3052,7 @@ dependencies = [ "sync_wrapper 0.1.2", "system-configuration", "tokio", - "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", @@ -3059,6 +3062,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 +3196,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 +3272,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -3303,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" @@ -3354,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" @@ -3916,7 +3932,7 @@ dependencies = [ "fs2", "hex", "once_cell", - "reqwest", + "reqwest 0.11.27", "semver", "serde", "serde_json", @@ -3959,6 +3975,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" @@ -4167,22 +4186,22 @@ dependencies = [ ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-rustls" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "native-tls", + "rustls 0.21.12", "tokio", ] [[package]] name = "tokio-rustls" -version = "0.24.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.21.12", + "rustls 0.23.31", "tokio", ] @@ -4207,7 +4226,7 @@ dependencies = [ "log", "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tungstenite", "webpki-roots 0.25.4", ] @@ -4314,6 +4333,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 +4716,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..88e908a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -36,11 +36,11 @@ 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" [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/README.md b/backend/README.md index 2aec9a3..f42f578 100644 --- a/backend/README.md +++ b/backend/README.md @@ -411,3 +411,93 @@ 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 `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 + +### 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", "3pts"], + "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/migrations/006_create_github_issues_table.sql b/backend/migrations/006_create_github_issues_table.sql new file mode 100644 index 0000000..0ff537b --- /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, + 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 '[]', + url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + closed_at TIMESTAMPTZ, + rewarded_sepolia 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_sepolia ON github_issues(rewarded_sepolia); 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..af34bc7 --- /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 `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"^(\d+)pts$").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(), + 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), + url: api_issue.html_url.clone(), + created_at, + closed_at, + rewarded_sepolia: 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 {}: {e}", api_issue.id))?; + + 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..a4773cf --- /dev/null +++ b/backend/src/application/dtos/github_dtos.rs @@ -0,0 +1,22 @@ +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, +} + +/// Query parameters for GET /github/issues +#[derive(Debug, Deserialize)] +pub struct GithubIssuesQuery { + pub repo: String, + pub state: Option, +} 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..c55fbf3 --- /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 issue_number: i32, + pub title: String, + pub state: String, + pub labels: serde_json::Value, + pub points: i32, + pub assignee_logins: serde_json::Value, + pub url: String, + pub created_at: DateTime, + pub closed_at: Option>, + pub rewarded_sepolia: 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..fac9857 --- /dev/null +++ b/backend/src/domain/repositories/github_issue_repository.rs @@ -0,0 +1,23 @@ +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>; + + /// 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/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..cc22dba --- /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 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, + 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..247fe76 --- /dev/null +++ b/backend/src/infrastructure/repositories/postgres_github_issue_repository.rs @@ -0,0 +1,198 @@ +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, 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, + issue_number = EXCLUDED.issue_number, + title = EXCLUDED.title, + state = EXCLUDED.state, + labels = EXCLUDED.labels, + points = EXCLUDED.points, + assignee_logins = EXCLUDED.assignee_logins, + url = EXCLUDED.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.issue_number) + .bind(&issue.title) + .bind(&issue.state) + .bind(&issue.labels) + .bind(issue.points) + .bind(&issue.assignee_logins) + .bind(&issue.url) + .bind(issue.created_at) + .bind(issue.closed_at) + .bind(issue.rewarded_sepolia) + .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, 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 + "#, + ) + .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, + 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, + })) + } + + 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/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..1cd0be6 --- /dev/null +++ b/backend/src/infrastructure/services/rest_github_service.rs @@ -0,0 +1,86 @@ +use async_trait::async_trait; + +use crate::domain::services::github_service::{GitHubApiIssue, GitHubApiRepo, GithubService}; + +pub struct RestGithubService { + client: reqwest::Client, + api_url: String, + owner: String, +} + +impl Default for RestGithubService { + fn default() -> Self { + Self::new() + } +} + +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, + } + } +} + +#[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!("{}/repos/{}/{}", self.api_url, self.owner, 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!( + "{}/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}")); + } + + 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..c4cb791 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,9 @@ use super::handlers::{ get_profile_handler, get_project_handler, get_user_projects_handler, + // GitHub sync handler + github_sync_handler, + list_github_issues_handler, list_projects_handler, login_handler, update_profile_handler, @@ -45,13 +51,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 +90,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() { @@ -99,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() @@ -130,6 +143,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 +168,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)); @@ -166,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 16cf092..f93b9cb 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::{GithubIssuesQuery, GithubSyncRequest, GithubSyncResponse}, +}; + use super::{api::AppState, middlewares::VerifiedWallet}; /// Query parameters for listing projects @@ -307,3 +313,63 @@ 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(), + } +} + +/// 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 new file mode 100644 index 0000000..fd708b5 --- /dev/null +++ b/backend/tests/github_sync_tests.rs @@ -0,0 +1,422 @@ +#[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()) + } + + 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.is_none_or(|s| i.state == s)) + .cloned() + .collect()) + } + } + + 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())) + } + } + + #[allow(clippy::too_many_arguments)] + 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: "3pts".to_string(), + }, + ]; + assert_eq!(derive_points(&labels), 3); + } + + #[test] + fn test_derive_points_case_insensitive() { + let labels = vec![GitHubApiLabel { + name: "5Pts".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", "3Pts"], + 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(), "3pts"); + 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!["2pts"], + 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!["5pts"], + 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_sepolia should not be set during sync + assert!(!issue.rewarded_sepolia); + } + + // ======================================================================== + // 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..5980468 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; @@ -21,10 +23,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 +98,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 +179,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);