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