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/// 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(repos: &[CuratedRepo]) -> Value {
84    let fragments: Vec<String> = repos
85        .iter()
86        .enumerate()
87        .map(|(i, repo)| {
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 = repo.owner,
109                name = repo.name
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 all curated repositories.
120///
121/// Returns a vector of (`repo_name`, issues) tuples.
122#[instrument(skip(client, repos), fields(repo_count = repos.len()))]
123pub async fn fetch_issues(
124    client: &Octocrab,
125    repos: &[CuratedRepo],
126) -> Result<Vec<(String, Vec<IssueNode>)>> {
127    if repos.is_empty() {
128        return Ok(vec![]);
129    }
130
131    let query = build_issues_query(repos);
132    debug!("Executing GraphQL query");
133
134    // Execute the GraphQL query
135    let response: Value = client
136        .graphql(&query)
137        .await
138        .context("Failed to execute GraphQL query")?;
139
140    // Check for GraphQL errors
141    if let Some(errors) = response.get("errors") {
142        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
143        anyhow::bail!("GraphQL error: {error_msg}");
144    }
145
146    // Parse the response
147    let data = response
148        .get("data")
149        .context("Missing 'data' field in GraphQL response")?;
150
151    let mut results = Vec::with_capacity(repos.len());
152
153    for i in 0..repos.len() {
154        let key = format!("repo{i}");
155        if let Some(repo_data) = data.get(&key) {
156            // Repository might not exist or be private
157            if repo_data.is_null() {
158                debug!(repo = key, "Repository not found or inaccessible");
159                continue;
160            }
161
162            let repo_issues: RepoIssues = serde_json::from_value(repo_data.clone())
163                .with_context(|| format!("Failed to parse repository data for {key}"))?;
164
165            let issue_count = repo_issues.issues.nodes.len();
166            if issue_count > 0 {
167                debug!(
168                    repo = %repo_issues.name_with_owner,
169                    issues = issue_count,
170                    "Found issues"
171                );
172                results.push((repo_issues.name_with_owner, repo_issues.issues.nodes));
173            }
174        }
175    }
176
177    debug!(
178        total_repos = results.len(),
179        "Fetched issues from repositories"
180    );
181    Ok(results)
182}
183
184/// Repository label from GraphQL response.
185#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct RepoLabelNode {
187    /// Label name.
188    pub name: String,
189    /// Label description.
190    pub description: Option<String>,
191    /// Label color (hex code without #).
192    pub color: String,
193}
194
195/// Repository labels connection from GraphQL.
196#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct RepoLabelsConnection {
198    /// List of label nodes.
199    pub nodes: Vec<RepoLabelNode>,
200}
201
202/// Repository milestone from GraphQL response.
203#[derive(Debug, Clone, Deserialize, Serialize)]
204pub struct RepoMilestoneNode {
205    /// Milestone number.
206    pub number: u64,
207    /// Milestone title.
208    pub title: String,
209    /// Milestone description.
210    pub description: Option<String>,
211}
212
213/// Repository milestones connection from GraphQL.
214#[derive(Debug, Clone, Deserialize, Serialize)]
215pub struct RepoMilestonesConnection {
216    /// List of milestone nodes.
217    pub nodes: Vec<RepoMilestoneNode>,
218}
219
220/// Issue comment from GraphQL response.
221#[derive(Debug, Clone, Deserialize, Serialize)]
222pub struct IssueCommentNode {
223    /// Comment author login.
224    pub author: Author,
225    /// Comment body.
226    pub body: String,
227}
228
229/// Author information from GraphQL response.
230#[derive(Debug, Clone, Deserialize, Serialize)]
231pub struct Author {
232    /// Author login.
233    pub login: String,
234}
235
236/// Comments connection from GraphQL.
237#[derive(Debug, Clone, Deserialize, Serialize)]
238pub struct CommentsConnection {
239    /// List of comment nodes.
240    pub nodes: Vec<IssueCommentNode>,
241}
242
243/// Issue from GraphQL response for triage.
244#[derive(Debug, Clone, Deserialize, Serialize)]
245pub struct IssueNodeDetailed {
246    /// Issue number.
247    pub number: u64,
248    /// Issue title.
249    pub title: String,
250    /// Issue body.
251    pub body: Option<String>,
252    /// Issue URL.
253    pub url: String,
254    /// Issue labels.
255    pub labels: Labels,
256    /// Issue comments.
257    pub comments: CommentsConnection,
258}
259
260/// Repository data from GraphQL response for triage.
261#[derive(Debug, Clone, Deserialize, Serialize)]
262pub struct RepositoryData {
263    /// Repository name with owner.
264    #[serde(rename = "nameWithOwner")]
265    pub name_with_owner: String,
266    /// Repository labels.
267    pub labels: RepoLabelsConnection,
268    /// Repository milestones.
269    pub milestones: RepoMilestonesConnection,
270    /// Repository primary language.
271    #[serde(rename = "primaryLanguage")]
272    pub primary_language: Option<LanguageNode>,
273    /// Viewer permission level on the repository.
274    #[serde(rename = "viewerPermission")]
275    pub viewer_permission: Option<ViewerPermission>,
276}
277
278/// Language information from GraphQL response.
279#[derive(Debug, Clone, Deserialize, Serialize)]
280pub struct LanguageNode {
281    /// Language name.
282    pub name: String,
283}
284
285/// Full response for issue with repo context.
286#[derive(Debug, Clone, Deserialize, Serialize)]
287pub struct IssueWithRepoContextResponse {
288    /// The issue.
289    pub issue: IssueNodeDetailed,
290    /// The repository.
291    pub repository: RepositoryData,
292}
293
294/// Builds a GraphQL query to fetch an issue with repository context.
295fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
296    let query = format!(
297        r#"query {{
298            issue: repository(owner: "{owner}", name: "{repo}") {{
299                issue(number: {number}) {{
300                    number
301                    title
302                    body
303                    url
304                    labels(first: 10) {{
305                        nodes {{
306                            name
307                        }}
308                    }}
309                    comments(first: 5) {{
310                        nodes {{
311                            author {{
312                                login
313                            }}
314                            body
315                        }}
316                    }}
317                }}
318            }}
319            repository(owner: "{owner}", name: "{repo}") {{
320                nameWithOwner
321                viewerPermission
322                labels(first: 100) {{
323                    nodes {{
324                        name
325                        description
326                        color
327                    }}
328                }}
329                milestones(first: 50, states: OPEN) {{
330                    nodes {{
331                        number
332                        title
333                        description
334                    }}
335                }}
336                primaryLanguage {{
337                    name
338                }}
339            }}
340        }}"#
341    );
342
343    json!({ "query": query })
344}
345
346/// Fetches an issue with repository context (labels, milestones) in a single GraphQL call.
347///
348/// # Errors
349///
350/// Returns an error if the GraphQL query fails or the issue is not found.
351#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
352pub async fn fetch_issue_with_repo_context(
353    client: &Octocrab,
354    owner: &str,
355    repo: &str,
356    number: u64,
357) -> Result<(IssueNodeDetailed, RepositoryData)> {
358    debug!("Fetching issue with repository context");
359
360    let query = build_issue_with_repo_context_query(owner, repo, number);
361    debug!("Executing GraphQL query for issue with repo context");
362
363    let response: Value = client
364        .graphql(&query)
365        .await
366        .context("Failed to execute GraphQL query")?;
367
368    // Check for GraphQL errors
369    if let Some(errors) = response.get("errors") {
370        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
371        anyhow::bail!("GraphQL error: {error_msg}");
372    }
373
374    let data = response
375        .get("data")
376        .context("Missing 'data' field in GraphQL response")?;
377
378    // Extract issue from nested structure
379    let issue_data = data
380        .get("issue")
381        .and_then(|v| v.get("issue"))
382        .context("Issue not found in GraphQL response")?;
383
384    let issue: IssueNodeDetailed =
385        serde_json::from_value(issue_data.clone()).context("Failed to parse issue data")?;
386
387    let repo_data = data
388        .get("repository")
389        .context("Repository not found in GraphQL response")?;
390
391    let repository: RepositoryData =
392        serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
393
394    debug!(
395        issue_number = issue.number,
396        labels_count = repository.labels.nodes.len(),
397        milestones_count = repository.milestones.nodes.len(),
398        "Fetched issue with repository context"
399    );
400
401    Ok((issue, repository))
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn build_query_single_repo() {
410        let repos = [CuratedRepo {
411            owner: "block",
412            name: "goose",
413            language: "Rust",
414            description: "AI agent",
415        }];
416
417        let query = build_issues_query(&repos);
418        let query_str = query["query"].as_str().unwrap();
419
420        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
421        assert!(query_str.contains("labels: [\"good first issue\"]"));
422        assert!(query_str.contains("states: OPEN"));
423    }
424
425    #[test]
426    fn build_query_multiple_repos() {
427        let repos = [
428            CuratedRepo {
429                owner: "block",
430                name: "goose",
431                language: "Rust",
432                description: "AI agent",
433            },
434            CuratedRepo {
435                owner: "astral-sh",
436                name: "ruff",
437                language: "Rust",
438                description: "Linter",
439            },
440        ];
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("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
447    }
448
449    #[test]
450    fn build_query_empty_repos() {
451        let repos: [CuratedRepo; 0] = [];
452        let query = build_issues_query(&repos);
453        let query_str = query["query"].as_str().unwrap();
454
455        assert_eq!(query_str, "query {  }");
456    }
457}