Skip to main content

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::error::{AptuError, ResourceType};
17use crate::retry::retry_backoff;
18
19/// Viewer permission level on a repository.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
21#[serde(rename_all = "UPPERCASE")]
22pub enum ViewerPermission {
23    /// Admin permission.
24    Admin,
25    /// Maintain permission.
26    Maintain,
27    /// Write permission.
28    Write,
29    /// Triage permission.
30    Triage,
31    /// Read permission.
32    Read,
33}
34
35/// A GitHub issue from the GraphQL response.
36#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct IssueNode {
38    /// Issue number.
39    pub number: u64,
40    /// Issue title.
41    pub title: String,
42    /// Creation timestamp (ISO 8601).
43    #[serde(rename = "createdAt")]
44    pub created_at: String,
45    /// Issue labels.
46    pub labels: Labels,
47    /// Issue URL (used by triage command).
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    /// Total count of comments.
280    #[serde(rename = "totalCount")]
281    pub total_count: u32,
282    /// List of comment nodes.
283    pub nodes: Vec<IssueCommentNode>,
284}
285
286/// Issue from GraphQL response for triage.
287#[derive(Debug, Clone, Deserialize, Serialize)]
288pub struct IssueNodeDetailed {
289    /// Issue number.
290    pub number: u64,
291    /// Issue title.
292    pub title: String,
293    /// Issue body.
294    pub body: Option<String>,
295    /// Issue URL.
296    pub url: String,
297    /// Issue labels.
298    pub labels: Labels,
299    /// Issue comments.
300    pub comments: CommentsConnection,
301    /// Issue author.
302    pub author: Option<Author>,
303    /// Issue creation timestamp (ISO 8601).
304    #[serde(rename = "createdAt")]
305    pub created_at: String,
306    /// Issue last update timestamp (ISO 8601).
307    #[serde(rename = "updatedAt")]
308    pub updated_at: String,
309}
310
311/// Repository data from GraphQL response for triage.
312#[derive(Debug, Clone, Deserialize, Serialize)]
313pub struct RepositoryData {
314    /// Repository name with owner.
315    #[serde(rename = "nameWithOwner")]
316    pub name_with_owner: String,
317    /// Repository labels.
318    pub labels: RepoLabelsConnection,
319    /// Repository milestones.
320    pub milestones: RepoMilestonesConnection,
321    /// Repository primary language.
322    #[serde(rename = "primaryLanguage")]
323    pub primary_language: Option<LanguageNode>,
324    /// Viewer permission level on the repository.
325    #[serde(rename = "viewerPermission")]
326    pub viewer_permission: Option<ViewerPermission>,
327}
328
329/// Language information from GraphQL response.
330#[derive(Debug, Clone, Deserialize, Serialize)]
331pub struct LanguageNode {
332    /// Language name.
333    pub name: String,
334}
335
336/// Full response for issue with repo context.
337#[derive(Debug, Clone, Deserialize, Serialize)]
338pub struct IssueWithRepoContextResponse {
339    /// The issue.
340    pub issue: IssueNodeDetailed,
341    /// The repository.
342    pub repository: RepositoryData,
343}
344
345/// Builds a GraphQL query to fetch an issue with repository context.
346fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
347    let query = format!(
348        r#"query {{
349            issue: repository(owner: "{owner}", name: "{repo}") {{
350                issue(number: {number}) {{
351                    number
352                    title
353                    body
354                    url
355                    author {{
356                        login
357                    }}
358                    createdAt
359                    updatedAt
360                    labels(first: 10) {{
361                        nodes {{
362                            name
363                        }}
364                    }}
365                    comments(first: 5) {{
366                        totalCount
367                        nodes {{
368                            author {{
369                                login
370                            }}
371                            body
372                        }}
373                    }}
374                }}
375            }}
376            repository(owner: "{owner}", name: "{repo}") {{
377                nameWithOwner
378                viewerPermission
379                labels(first: 100) {{
380                    nodes {{
381                        name
382                        description
383                        color
384                    }}
385                }}
386                milestones(first: 50, states: OPEN) {{
387                    nodes {{
388                        number
389                        title
390                        description
391                    }}
392                }}
393                primaryLanguage {{
394                    name
395                }}
396            }}
397        }}"#
398    );
399
400    json!({ "query": query })
401}
402
403/// Checks if any error in the GraphQL errors array has type=`NOT_FOUND`.
404fn is_not_found_error(errors: &Value) -> bool {
405    if let Some(arr) = errors.as_array() {
406        arr.iter().any(|err| {
407            err.get("type")
408                .and_then(|t| t.as_str())
409                .is_some_and(|t| t == "NOT_FOUND")
410        })
411    } else {
412        false
413    }
414}
415
416/// Fetches an issue with repository context (labels, milestones) in a single GraphQL call.
417///
418/// # Errors
419///
420/// Returns an error if the GraphQL query fails or the issue is not found.
421/// If the issue is not found but a PR with the same number exists, returns a `TypeMismatch` error.
422#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
423pub async fn fetch_issue_with_repo_context(
424    client: &Octocrab,
425    owner: &str,
426    repo: &str,
427    number: u64,
428) -> Result<(IssueNodeDetailed, RepositoryData)> {
429    debug!("Fetching issue with repository context");
430
431    let query = build_issue_with_repo_context_query(owner, repo, number);
432    debug!("Executing GraphQL query for issue with repo context");
433
434    let response: Value = client
435        .graphql(&query)
436        .await
437        .context("Failed to execute GraphQL query")?;
438
439    // Check for GraphQL errors
440    if let Some(errors) = response.get("errors") {
441        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
442
443        // Only attempt fallback for NOT_FOUND errors to avoid unnecessary API calls
444        if is_not_found_error(errors) {
445            debug!("GraphQL NOT_FOUND error, checking if reference is a PR");
446
447            // Try to fetch as a PR to provide a better error message
448            if (client.pulls(owner, repo).get(number).await).is_ok() {
449                return Err(AptuError::TypeMismatch {
450                    number,
451                    expected: ResourceType::Issue,
452                    actual: ResourceType::PullRequest,
453                }
454                .into());
455            }
456        }
457
458        // Not a PR or not a NOT_FOUND error, return the original GraphQL error
459        anyhow::bail!("GraphQL error: {error_msg}");
460    }
461
462    let data = response
463        .get("data")
464        .context("Missing 'data' field in GraphQL response")?;
465
466    // Extract issue from nested structure
467    let issue_data = data.get("issue").and_then(|v| v.get("issue"));
468
469    // Check if issue is null (not found)
470    if issue_data.is_none() || issue_data.is_some_and(serde_json::Value::is_null) {
471        debug!("Issue not found in GraphQL response, checking if reference is a PR");
472
473        // Try to fetch as a PR to provide a better error message
474        if (client.pulls(owner, repo).get(number).await).is_ok() {
475            return Err(AptuError::TypeMismatch {
476                number,
477                expected: ResourceType::Issue,
478                actual: ResourceType::PullRequest,
479            }
480            .into());
481        }
482
483        // Not a PR, return the original error
484        anyhow::bail!("Issue not found in GraphQL response");
485    }
486
487    let issue: IssueNodeDetailed = serde_json::from_value(issue_data.unwrap().clone())
488        .context("Failed to parse issue data")?;
489
490    let repo_data = data
491        .get("repository")
492        .context("Repository not found in GraphQL response")?;
493
494    let repository: RepositoryData =
495        serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
496
497    debug!(
498        issue_number = issue.number,
499        labels_count = repository.labels.nodes.len(),
500        milestones_count = repository.milestones.nodes.len(),
501        "Fetched issue with repository context"
502    );
503
504    Ok((issue, repository))
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn build_query_single_repo() {
513        let repos = [("block", "goose")];
514
515        let query = build_issues_query(&repos);
516        let query_str = query["query"].as_str().unwrap();
517
518        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
519        assert!(query_str.contains("labels: [\"good first issue\"]"));
520        assert!(query_str.contains("states: OPEN"));
521    }
522
523    #[test]
524    fn build_query_multiple_repos() {
525        let repos = [("block", "goose"), ("astral-sh", "ruff")];
526
527        let query = build_issues_query(&repos);
528        let query_str = query["query"].as_str().unwrap();
529
530        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
531        assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
532    }
533
534    #[test]
535    fn build_query_empty_repos() {
536        let repos: [(&str, &str); 0] = [];
537        let query = build_issues_query(&repos);
538        let query_str = query["query"].as_str().unwrap();
539
540        assert_eq!(query_str, "query {  }");
541    }
542}
543
544/// Target of a reference (either a Tag or Commit).
545#[derive(Debug, Clone, Deserialize, Serialize)]
546#[serde(untagged)]
547pub enum RefTarget {
548    /// A tag object.
549    Tag(TagTarget),
550    /// A commit object.
551    Commit(CommitTarget),
552}
553
554/// A tag object from the GraphQL response.
555#[derive(Debug, Clone, Deserialize, Serialize)]
556pub struct TagTarget {
557    /// The commit that this tag points to.
558    pub target: CommitTarget,
559}
560
561/// A commit object from the GraphQL response.
562#[derive(Debug, Clone, Deserialize, Serialize)]
563pub struct CommitTarget {
564    /// The commit SHA.
565    pub oid: String,
566}
567
568/// Build a GraphQL query to resolve a tag to its commit SHA.
569///
570/// Uses inline fragments to handle both Tag and Commit target types.
571fn build_tag_resolution_query(owner: &str, repo: &str, ref_name: &str) -> Value {
572    let query = format!(
573        r#"query {{
574  repository(owner: "{owner}", name: "{repo}") {{
575    ref(qualifiedName: "refs/tags/{ref_name}") {{
576      target {{
577        ... on Tag {{
578          target {{
579            oid
580          }}
581        }}
582        ... on Commit {{
583          oid
584        }}
585      }}
586    }}
587  }}
588}}"#
589    );
590
591    json!({
592        "query": query,
593    })
594}
595
596/// Resolve a tag to its commit SHA using GraphQL.
597///
598/// Handles both lightweight tags (which point directly to commits) and
599/// annotated tags (which have a Tag object that points to a commit).
600///
601/// # Arguments
602///
603/// * `client` - Octocrab GitHub client
604/// * `owner` - Repository owner
605/// * `repo` - Repository name
606/// * `tag_name` - Tag name to resolve
607///
608/// # Returns
609///
610/// The commit SHA for the tag, or None if the tag doesn't exist.
611#[instrument(skip(client))]
612pub async fn resolve_tag_to_commit_sha(
613    client: &Octocrab,
614    owner: &str,
615    repo: &str,
616    tag_name: &str,
617) -> Result<Option<String>> {
618    let query = build_tag_resolution_query(owner, repo, tag_name);
619
620    let response = (|| async {
621        client
622            .graphql::<serde_json::Value>(&query)
623            .await
624            .context("GraphQL query failed")
625    })
626    .retry(&retry_backoff())
627    .await?;
628
629    debug!("GraphQL response: {:?}", response);
630
631    // Extract the target from the response
632    let target = response
633        .get("data")
634        .and_then(|data| data.get("repository"))
635        .and_then(|repo| repo.get("ref"))
636        .and_then(|ref_obj| ref_obj.get("target"));
637
638    match target {
639        Some(target_value) => {
640            // Try to deserialize as RefTarget to handle both Tag and Commit cases
641            match serde_json::from_value::<RefTarget>(target_value.clone()) {
642                Ok(RefTarget::Tag(tag)) => Ok(Some(tag.target.oid)),
643                Ok(RefTarget::Commit(commit)) => Ok(Some(commit.oid)),
644                Err(_) => Ok(None),
645            }
646        }
647        None => Ok(None),
648    }
649}
650
651#[cfg(test)]
652mod tag_resolution_tests {
653    use super::*;
654
655    #[test]
656    fn build_tag_resolution_query_correct_syntax() {
657        let query = build_tag_resolution_query("owner", "repo", "v1.0.0");
658        let query_str = query["query"].as_str().unwrap();
659
660        assert!(query_str.contains("repository(owner: \"owner\", name: \"repo\")"));
661        assert!(query_str.contains("ref(qualifiedName: \"refs/tags/v1.0.0\")"));
662        assert!(query_str.contains("... on Tag"));
663        assert!(query_str.contains("... on Commit"));
664        assert!(query_str.contains("oid"));
665    }
666}