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::ai::types::{IssueComment, RepoLabel, RepoMilestone};
15
16/// Viewer permission level on a repository.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
18#[serde(rename_all = "UPPERCASE")]
19pub enum ViewerPermission {
20    /// Admin permission.
21    Admin,
22    /// Maintain permission.
23    Maintain,
24    /// Write permission.
25    Write,
26    /// Triage permission.
27    Triage,
28    /// Read permission.
29    Read,
30}
31
32/// A GitHub issue from the GraphQL response.
33#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct IssueNode {
35    /// Issue number.
36    pub number: u64,
37    /// Issue title.
38    pub title: String,
39    /// Creation timestamp (ISO 8601).
40    #[serde(rename = "createdAt")]
41    pub created_at: String,
42    /// Issue labels.
43    pub labels: Labels,
44    /// Issue URL (used by triage command).
45    #[allow(dead_code)]
46    pub url: String,
47}
48
49/// Labels container from GraphQL response.
50#[derive(Debug, Clone, Deserialize, Serialize)]
51pub struct Labels {
52    /// List of label nodes.
53    pub nodes: Vec<LabelNode>,
54}
55
56/// A single label.
57#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct LabelNode {
59    /// Label name.
60    pub name: String,
61}
62
63/// Issues response for a single repository.
64#[derive(Debug, Deserialize)]
65pub struct RepoIssues {
66    /// Repository name with owner (e.g., "block/goose").
67    #[serde(rename = "nameWithOwner")]
68    pub name_with_owner: String,
69    /// Issues container.
70    pub issues: IssuesConnection,
71}
72
73/// Issues connection from GraphQL.
74#[derive(Debug, Deserialize)]
75pub struct IssuesConnection {
76    /// List of issue nodes.
77    pub nodes: Vec<IssueNode>,
78}
79
80/// Builds a GraphQL query to fetch issues from multiple repositories.
81///
82/// Uses GraphQL aliases to query all repos in a single request.
83fn build_issues_query<R: AsRef<str>>(repos: &[(R, R)]) -> Value {
84    let fragments: Vec<String> = repos
85        .iter()
86        .enumerate()
87        .map(|(i, (owner, name))| {
88            format!(
89                r#"repo{i}: repository(owner: "{owner}", name: "{name}") {{
90                    nameWithOwner
91                    issues(
92                        first: 10
93                        states: OPEN
94                        labels: ["good first issue"]
95                        filterBy: {{ assignee: null }}
96                        orderBy: {{ field: CREATED_AT, direction: DESC }}
97                    ) {{
98                        nodes {{
99                            number
100                            title
101                            createdAt
102                            labels(first: 5) {{ nodes {{ name }} }}
103                            url
104                        }}
105                    }}
106                }}"#,
107                i = i,
108                owner = owner.as_ref(),
109                name = name.as_ref()
110            )
111        })
112        .collect();
113
114    let query = format!("query {{ {} }}", fragments.join("\n"));
115    debug!(query_length = query.len(), "Built GraphQL query");
116    json!({ "query": query })
117}
118
119/// Fetches open "good first issue" issues from multiple repositories.
120///
121/// Accepts a slice of (owner, name) tuples.
122/// Returns a vector of (`repo_name`, issues) tuples.
123#[instrument(skip(client, repos), fields(repo_count = repos.len()))]
124pub async fn fetch_issues<R: AsRef<str>>(
125    client: &Octocrab,
126    repos: &[(R, R)],
127) -> Result<Vec<(String, Vec<IssueNode>)>> {
128    if repos.is_empty() {
129        return Ok(vec![]);
130    }
131
132    let query = build_issues_query(repos);
133    debug!("Executing GraphQL query");
134
135    // Execute the GraphQL query
136    let response: Value = client
137        .graphql(&query)
138        .await
139        .context("Failed to execute GraphQL query")?;
140
141    // Check for GraphQL errors
142    if let Some(errors) = response.get("errors") {
143        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
144        anyhow::bail!("GraphQL error: {error_msg}");
145    }
146
147    // Parse the response
148    let data = response
149        .get("data")
150        .context("Missing 'data' field in GraphQL response")?;
151
152    let mut results = Vec::with_capacity(repos.len());
153
154    for i in 0..repos.len() {
155        let key = format!("repo{i}");
156        if let Some(repo_data) = data.get(&key) {
157            // Repository might not exist or be private
158            if repo_data.is_null() {
159                debug!(repo = key, "Repository not found or inaccessible");
160                continue;
161            }
162
163            let repo_issues: RepoIssues = serde_json::from_value(repo_data.clone())
164                .with_context(|| format!("Failed to parse repository data for {key}"))?;
165
166            let issue_count = repo_issues.issues.nodes.len();
167            if issue_count > 0 {
168                debug!(
169                    repo = %repo_issues.name_with_owner,
170                    issues = issue_count,
171                    "Found issues"
172                );
173                results.push((repo_issues.name_with_owner, repo_issues.issues.nodes));
174            }
175        }
176    }
177
178    debug!(
179        total_repos = results.len(),
180        "Fetched issues from repositories"
181    );
182    Ok(results)
183}
184
185/// Repository label from GraphQL response.
186#[derive(Debug, Clone, Deserialize, Serialize)]
187pub struct RepoLabelNode {
188    /// Label name.
189    pub name: String,
190    /// Label description.
191    pub description: Option<String>,
192    /// Label color (hex code without #).
193    pub color: String,
194}
195
196impl From<RepoLabelNode> for RepoLabel {
197    fn from(node: RepoLabelNode) -> Self {
198        RepoLabel {
199            name: node.name,
200            description: node.description.unwrap_or_default(),
201            color: node.color,
202        }
203    }
204}
205
206/// Repository labels connection from GraphQL.
207#[derive(Debug, Clone, Deserialize, Serialize)]
208pub struct RepoLabelsConnection {
209    /// List of label nodes.
210    pub nodes: Vec<RepoLabelNode>,
211}
212
213/// Repository milestone from GraphQL response.
214#[derive(Debug, Clone, Deserialize, Serialize)]
215pub struct RepoMilestoneNode {
216    /// Milestone number.
217    pub number: u64,
218    /// Milestone title.
219    pub title: String,
220    /// Milestone description.
221    pub description: Option<String>,
222}
223
224impl From<RepoMilestoneNode> for RepoMilestone {
225    fn from(node: RepoMilestoneNode) -> Self {
226        RepoMilestone {
227            number: node.number,
228            title: node.title,
229            description: node.description.unwrap_or_default(),
230        }
231    }
232}
233
234/// Repository milestones connection from GraphQL.
235#[derive(Debug, Clone, Deserialize, Serialize)]
236pub struct RepoMilestonesConnection {
237    /// List of milestone nodes.
238    pub nodes: Vec<RepoMilestoneNode>,
239}
240
241/// Issue comment from GraphQL response.
242#[derive(Debug, Clone, Deserialize, Serialize)]
243pub struct IssueCommentNode {
244    /// Comment author login.
245    pub author: Author,
246    /// Comment body.
247    pub body: String,
248}
249
250impl From<IssueCommentNode> for IssueComment {
251    fn from(node: IssueCommentNode) -> Self {
252        IssueComment {
253            author: node.author.login,
254            body: node.body,
255        }
256    }
257}
258
259/// Author information from GraphQL response.
260#[derive(Debug, Clone, Deserialize, Serialize)]
261pub struct Author {
262    /// Author login.
263    pub login: String,
264}
265
266/// Comments connection from GraphQL.
267#[derive(Debug, Clone, Deserialize, Serialize)]
268pub struct CommentsConnection {
269    /// List of comment nodes.
270    pub nodes: Vec<IssueCommentNode>,
271}
272
273/// Issue from GraphQL response for triage.
274#[derive(Debug, Clone, Deserialize, Serialize)]
275pub struct IssueNodeDetailed {
276    /// Issue number.
277    pub number: u64,
278    /// Issue title.
279    pub title: String,
280    /// Issue body.
281    pub body: Option<String>,
282    /// Issue URL.
283    pub url: String,
284    /// Issue labels.
285    pub labels: Labels,
286    /// Issue comments.
287    pub comments: CommentsConnection,
288}
289
290/// Repository data from GraphQL response for triage.
291#[derive(Debug, Clone, Deserialize, Serialize)]
292pub struct RepositoryData {
293    /// Repository name with owner.
294    #[serde(rename = "nameWithOwner")]
295    pub name_with_owner: String,
296    /// Repository labels.
297    pub labels: RepoLabelsConnection,
298    /// Repository milestones.
299    pub milestones: RepoMilestonesConnection,
300    /// Repository primary language.
301    #[serde(rename = "primaryLanguage")]
302    pub primary_language: Option<LanguageNode>,
303    /// Viewer permission level on the repository.
304    #[serde(rename = "viewerPermission")]
305    pub viewer_permission: Option<ViewerPermission>,
306}
307
308/// Language information from GraphQL response.
309#[derive(Debug, Clone, Deserialize, Serialize)]
310pub struct LanguageNode {
311    /// Language name.
312    pub name: String,
313}
314
315/// Full response for issue with repo context.
316#[derive(Debug, Clone, Deserialize, Serialize)]
317pub struct IssueWithRepoContextResponse {
318    /// The issue.
319    pub issue: IssueNodeDetailed,
320    /// The repository.
321    pub repository: RepositoryData,
322}
323
324/// Builds a GraphQL query to fetch an issue with repository context.
325fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
326    let query = format!(
327        r#"query {{
328            issue: repository(owner: "{owner}", name: "{repo}") {{
329                issue(number: {number}) {{
330                    number
331                    title
332                    body
333                    url
334                    labels(first: 10) {{
335                        nodes {{
336                            name
337                        }}
338                    }}
339                    comments(first: 5) {{
340                        nodes {{
341                            author {{
342                                login
343                            }}
344                            body
345                        }}
346                    }}
347                }}
348            }}
349            repository(owner: "{owner}", name: "{repo}") {{
350                nameWithOwner
351                viewerPermission
352                labels(first: 100) {{
353                    nodes {{
354                        name
355                        description
356                        color
357                    }}
358                }}
359                milestones(first: 50, states: OPEN) {{
360                    nodes {{
361                        number
362                        title
363                        description
364                    }}
365                }}
366                primaryLanguage {{
367                    name
368                }}
369            }}
370        }}"#
371    );
372
373    json!({ "query": query })
374}
375
376/// Fetches an issue with repository context (labels, milestones) in a single GraphQL call.
377///
378/// # Errors
379///
380/// Returns an error if the GraphQL query fails or the issue is not found.
381#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
382pub async fn fetch_issue_with_repo_context(
383    client: &Octocrab,
384    owner: &str,
385    repo: &str,
386    number: u64,
387) -> Result<(IssueNodeDetailed, RepositoryData)> {
388    debug!("Fetching issue with repository context");
389
390    let query = build_issue_with_repo_context_query(owner, repo, number);
391    debug!("Executing GraphQL query for issue with repo context");
392
393    let response: Value = client
394        .graphql(&query)
395        .await
396        .context("Failed to execute GraphQL query")?;
397
398    // Check for GraphQL errors
399    if let Some(errors) = response.get("errors") {
400        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
401        anyhow::bail!("GraphQL error: {error_msg}");
402    }
403
404    let data = response
405        .get("data")
406        .context("Missing 'data' field in GraphQL response")?;
407
408    // Extract issue from nested structure
409    let issue_data = data
410        .get("issue")
411        .and_then(|v| v.get("issue"))
412        .context("Issue not found in GraphQL response")?;
413
414    let issue: IssueNodeDetailed =
415        serde_json::from_value(issue_data.clone()).context("Failed to parse issue data")?;
416
417    let repo_data = data
418        .get("repository")
419        .context("Repository not found in GraphQL response")?;
420
421    let repository: RepositoryData =
422        serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
423
424    debug!(
425        issue_number = issue.number,
426        labels_count = repository.labels.nodes.len(),
427        milestones_count = repository.milestones.nodes.len(),
428        "Fetched issue with repository context"
429    );
430
431    Ok((issue, repository))
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn build_query_single_repo() {
440        let repos = [("block", "goose")];
441
442        let query = build_issues_query(&repos);
443        let query_str = query["query"].as_str().unwrap();
444
445        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
446        assert!(query_str.contains("labels: [\"good first issue\"]"));
447        assert!(query_str.contains("states: OPEN"));
448    }
449
450    #[test]
451    fn build_query_multiple_repos() {
452        let repos = [("block", "goose"), ("astral-sh", "ruff")];
453
454        let query = build_issues_query(&repos);
455        let query_str = query["query"].as_str().unwrap();
456
457        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
458        assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
459    }
460
461    #[test]
462    fn build_query_empty_repos() {
463        let repos: [(&str, &str); 0] = [];
464        let query = build_issues_query(&repos);
465        let query_str = query["query"].as_str().unwrap();
466
467        assert_eq!(query_str, "query {  }");
468    }
469}