aptu_core/github/
graphql.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GraphQL queries for GitHub API.
4//!
5//! Uses a single GraphQL query to fetch issues from multiple repositories
6//! efficiently, avoiding multiple REST API calls.
7
8use 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/// A GitHub issue from the GraphQL response.
17#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct IssueNode {
19    /// Issue number.
20    pub number: u64,
21    /// Issue title.
22    pub title: String,
23    /// Creation timestamp (ISO 8601).
24    #[serde(rename = "createdAt")]
25    pub created_at: String,
26    /// Issue labels.
27    pub labels: Labels,
28    /// Issue URL (used by triage command).
29    #[allow(dead_code)]
30    pub url: String,
31}
32
33/// Labels container from GraphQL response.
34#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct Labels {
36    /// List of label nodes.
37    pub nodes: Vec<LabelNode>,
38}
39
40/// A single label.
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct LabelNode {
43    /// Label name.
44    pub name: String,
45}
46
47/// Issues response for a single repository.
48#[derive(Debug, Deserialize)]
49pub struct RepoIssues {
50    /// Repository name with owner (e.g., "block/goose").
51    #[serde(rename = "nameWithOwner")]
52    pub name_with_owner: String,
53    /// Issues container.
54    pub issues: IssuesConnection,
55}
56
57/// Issues connection from GraphQL.
58#[derive(Debug, Deserialize)]
59pub struct IssuesConnection {
60    /// List of issue nodes.
61    pub nodes: Vec<IssueNode>,
62}
63
64/// Builds a GraphQL query to fetch issues from multiple repositories.
65///
66/// Uses GraphQL aliases to query all repos in a single request.
67fn 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/// Fetches open "good first issue" issues from all curated repositories.
104///
105/// Returns a vector of (`repo_name`, issues) tuples.
106#[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    // Execute the GraphQL query
119    let response: Value = client
120        .graphql(&query)
121        .await
122        .context("Failed to execute GraphQL query")?;
123
124    // Check for GraphQL errors
125    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    // Parse the response
131    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            // Repository might not exist or be private
141            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}