aptu_core/github/
graphql.rs1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use tracing::{debug, instrument};
13
14use crate::repos::CuratedRepo;
15
16#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct IssueNode {
19 pub number: u64,
21 pub title: String,
23 #[serde(rename = "createdAt")]
25 pub created_at: String,
26 pub labels: Labels,
28 #[allow(dead_code)]
30 pub url: String,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct Labels {
36 pub nodes: Vec<LabelNode>,
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct LabelNode {
43 pub name: String,
45}
46
47#[derive(Debug, Deserialize)]
49pub struct RepoIssues {
50 #[serde(rename = "nameWithOwner")]
52 pub name_with_owner: String,
53 pub issues: IssuesConnection,
55}
56
57#[derive(Debug, Deserialize)]
59pub struct IssuesConnection {
60 pub nodes: Vec<IssueNode>,
62}
63
64fn build_issues_query(repos: &[CuratedRepo]) -> Value {
68 let fragments: Vec<String> = repos
69 .iter()
70 .enumerate()
71 .map(|(i, repo)| {
72 format!(
73 r#"repo{i}: repository(owner: "{owner}", name: "{name}") {{
74 nameWithOwner
75 issues(
76 first: 10
77 states: OPEN
78 labels: ["good first issue"]
79 filterBy: {{ assignee: null }}
80 orderBy: {{ field: CREATED_AT, direction: DESC }}
81 ) {{
82 nodes {{
83 number
84 title
85 createdAt
86 labels(first: 5) {{ nodes {{ name }} }}
87 url
88 }}
89 }}
90 }}"#,
91 i = i,
92 owner = repo.owner,
93 name = repo.name
94 )
95 })
96 .collect();
97
98 let query = format!("query {{ {} }}", fragments.join("\n"));
99 debug!(query_length = query.len(), "Built GraphQL query");
100 json!({ "query": query })
101}
102
103#[instrument(skip(client, repos), fields(repo_count = repos.len()))]
107pub async fn fetch_issues(
108 client: &Octocrab,
109 repos: &[CuratedRepo],
110) -> Result<Vec<(String, Vec<IssueNode>)>> {
111 if repos.is_empty() {
112 return Ok(vec![]);
113 }
114
115 let query = build_issues_query(repos);
116 debug!("Executing GraphQL query");
117
118 let response: Value = client
120 .graphql(&query)
121 .await
122 .context("Failed to execute GraphQL query")?;
123
124 if let Some(errors) = response.get("errors") {
126 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
127 anyhow::bail!("GraphQL error: {error_msg}");
128 }
129
130 let data = response
132 .get("data")
133 .context("Missing 'data' field in GraphQL response")?;
134
135 let mut results = Vec::with_capacity(repos.len());
136
137 for i in 0..repos.len() {
138 let key = format!("repo{i}");
139 if let Some(repo_data) = data.get(&key) {
140 if repo_data.is_null() {
142 debug!(repo = key, "Repository not found or inaccessible");
143 continue;
144 }
145
146 let repo_issues: RepoIssues = serde_json::from_value(repo_data.clone())
147 .with_context(|| format!("Failed to parse repository data for {key}"))?;
148
149 let issue_count = repo_issues.issues.nodes.len();
150 if issue_count > 0 {
151 debug!(
152 repo = %repo_issues.name_with_owner,
153 issues = issue_count,
154 "Found issues"
155 );
156 results.push((repo_issues.name_with_owner, repo_issues.issues.nodes));
157 }
158 }
159 }
160
161 debug!(
162 total_repos = results.len(),
163 "Fetched issues from repositories"
164 );
165 Ok(results)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn build_query_single_repo() {
174 let repos = [CuratedRepo {
175 owner: "block",
176 name: "goose",
177 language: "Rust",
178 description: "AI agent",
179 }];
180
181 let query = build_issues_query(&repos);
182 let query_str = query["query"].as_str().unwrap();
183
184 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
185 assert!(query_str.contains("labels: [\"good first issue\"]"));
186 assert!(query_str.contains("states: OPEN"));
187 }
188
189 #[test]
190 fn build_query_multiple_repos() {
191 let repos = [
192 CuratedRepo {
193 owner: "block",
194 name: "goose",
195 language: "Rust",
196 description: "AI agent",
197 },
198 CuratedRepo {
199 owner: "astral-sh",
200 name: "ruff",
201 language: "Rust",
202 description: "Linter",
203 },
204 ];
205
206 let query = build_issues_query(&repos);
207 let query_str = query["query"].as_str().unwrap();
208
209 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
210 assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
211 }
212
213 #[test]
214 fn build_query_empty_repos() {
215 let repos: [CuratedRepo; 0] = [];
216 let query = build_issues_query(&repos);
217 let query_str = query["query"].as_str().unwrap();
218
219 assert_eq!(query_str, "query { }");
220 }
221}