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