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    #[allow(dead_code)]
49    pub url: String,
50}
51
52/// Labels container from GraphQL response.
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Labels {
55    /// List of label nodes.
56    pub nodes: Vec<LabelNode>,
57}
58
59/// A single label.
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct LabelNode {
62    /// Label name.
63    pub name: String,
64}
65
66/// Issues response for a single repository.
67#[derive(Debug, Deserialize)]
68pub struct RepoIssues {
69    /// Repository name with owner (e.g., "block/goose").
70    #[serde(rename = "nameWithOwner")]
71    pub name_with_owner: String,
72    /// Issues container.
73    pub issues: IssuesConnection,
74}
75
76/// Issues connection from GraphQL.
77#[derive(Debug, Deserialize)]
78pub struct IssuesConnection {
79    /// List of issue nodes.
80    pub nodes: Vec<IssueNode>,
81}
82
83/// Builds a GraphQL query to fetch issues from multiple repositories.
84///
85/// Uses GraphQL aliases to query all repos in a single request.
86fn build_issues_query<R: AsRef<str>>(repos: &[(R, R)]) -> Value {
87    let fragments: Vec<String> = repos
88        .iter()
89        .enumerate()
90        .map(|(i, (owner, name))| {
91            format!(
92                r#"repo{i}: repository(owner: "{owner}", name: "{name}") {{
93                    nameWithOwner
94                    issues(
95                        first: 10
96                        states: OPEN
97                        labels: ["good first issue"]
98                        filterBy: {{ assignee: null }}
99                        orderBy: {{ field: CREATED_AT, direction: DESC }}
100                    ) {{
101                        nodes {{
102                            number
103                            title
104                            createdAt
105                            labels(first: 5) {{ nodes {{ name }} }}
106                            url
107                        }}
108                    }}
109                }}"#,
110                i = i,
111                owner = owner.as_ref(),
112                name = name.as_ref()
113            )
114        })
115        .collect();
116
117    let query = format!("query {{ {} }}", fragments.join("\n"));
118    debug!(query_length = query.len(), "Built GraphQL query");
119    json!({ "query": query })
120}
121
122/// Fetches open "good first issue" issues from multiple repositories.
123///
124/// Accepts a slice of (owner, name) tuples.
125/// Returns a vector of (`repo_name`, issues) tuples.
126#[instrument(skip(client, repos), fields(repo_count = repos.len()))]
127pub async fn fetch_issues<R: AsRef<str>>(
128    client: &Octocrab,
129    repos: &[(R, R)],
130) -> Result<Vec<(String, Vec<IssueNode>)>> {
131    if repos.is_empty() {
132        return Ok(vec![]);
133    }
134
135    let query = build_issues_query(repos);
136    debug!("Executing GraphQL query");
137
138    // Execute the GraphQL query with retry logic
139    let response: Value =
140        (|| async { client.graphql(&query).await.map_err(|e| anyhow::anyhow!(e)) })
141            .retry(retry_backoff())
142            .notify(|err, dur| {
143                tracing::warn!(
144                    error = %err,
145                    retry_after = ?dur,
146                    "Retrying fetch_issues (GraphQL query)"
147                );
148            })
149            .await
150            .context("Failed to execute GraphQL query")?;
151
152    // Check for GraphQL errors
153    if let Some(errors) = response.get("errors") {
154        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
155        anyhow::bail!("GraphQL error: {error_msg}");
156    }
157
158    // Parse the response
159    let data = response
160        .get("data")
161        .context("Missing 'data' field in GraphQL response")?;
162
163    let mut results = Vec::with_capacity(repos.len());
164
165    for i in 0..repos.len() {
166        let key = format!("repo{i}");
167        if let Some(repo_data) = data.get(&key) {
168            // Repository might not exist or be private
169            if repo_data.is_null() {
170                debug!(repo = key, "Repository not found or inaccessible");
171                continue;
172            }
173
174            let repo_issues: RepoIssues = serde_json::from_value(repo_data.clone())
175                .with_context(|| format!("Failed to parse repository data for {key}"))?;
176
177            let issue_count = repo_issues.issues.nodes.len();
178            if issue_count > 0 {
179                debug!(
180                    repo = %repo_issues.name_with_owner,
181                    issues = issue_count,
182                    "Found issues"
183                );
184                results.push((repo_issues.name_with_owner, repo_issues.issues.nodes));
185            }
186        }
187    }
188
189    debug!(
190        total_repos = results.len(),
191        "Fetched issues from repositories"
192    );
193    Ok(results)
194}
195
196/// Repository label from GraphQL response.
197#[derive(Debug, Clone, Deserialize, Serialize)]
198pub struct RepoLabelNode {
199    /// Label name.
200    pub name: String,
201    /// Label description.
202    pub description: Option<String>,
203    /// Label color (hex code without #).
204    pub color: String,
205}
206
207impl From<RepoLabelNode> for RepoLabel {
208    fn from(node: RepoLabelNode) -> Self {
209        RepoLabel {
210            name: node.name,
211            description: node.description.unwrap_or_default(),
212            color: node.color,
213        }
214    }
215}
216
217/// Repository labels connection from GraphQL.
218#[derive(Debug, Clone, Deserialize, Serialize)]
219pub struct RepoLabelsConnection {
220    /// List of label nodes.
221    pub nodes: Vec<RepoLabelNode>,
222}
223
224/// Repository milestone from GraphQL response.
225#[derive(Debug, Clone, Deserialize, Serialize)]
226pub struct RepoMilestoneNode {
227    /// Milestone number.
228    pub number: u64,
229    /// Milestone title.
230    pub title: String,
231    /// Milestone description.
232    pub description: Option<String>,
233}
234
235impl From<RepoMilestoneNode> for RepoMilestone {
236    fn from(node: RepoMilestoneNode) -> Self {
237        RepoMilestone {
238            number: node.number,
239            title: node.title,
240            description: node.description.unwrap_or_default(),
241        }
242    }
243}
244
245/// Repository milestones connection from GraphQL.
246#[derive(Debug, Clone, Deserialize, Serialize)]
247pub struct RepoMilestonesConnection {
248    /// List of milestone nodes.
249    pub nodes: Vec<RepoMilestoneNode>,
250}
251
252/// Issue comment from GraphQL response.
253#[derive(Debug, Clone, Deserialize, Serialize)]
254pub struct IssueCommentNode {
255    /// Comment author login.
256    pub author: Author,
257    /// Comment body.
258    pub body: String,
259}
260
261impl From<IssueCommentNode> for IssueComment {
262    fn from(node: IssueCommentNode) -> Self {
263        IssueComment {
264            author: node.author.login,
265            body: node.body,
266        }
267    }
268}
269
270/// Author information from GraphQL response.
271#[derive(Debug, Clone, Deserialize, Serialize)]
272pub struct Author {
273    /// Author login.
274    pub login: String,
275}
276
277/// Comments connection from GraphQL.
278#[derive(Debug, Clone, Deserialize, Serialize)]
279pub struct CommentsConnection {
280    /// Total count of comments.
281    #[serde(rename = "totalCount")]
282    pub total_count: u32,
283    /// List of comment nodes.
284    pub nodes: Vec<IssueCommentNode>,
285}
286
287/// Issue from GraphQL response for triage.
288#[derive(Debug, Clone, Deserialize, Serialize)]
289pub struct IssueNodeDetailed {
290    /// Issue number.
291    pub number: u64,
292    /// Issue title.
293    pub title: String,
294    /// Issue body.
295    pub body: Option<String>,
296    /// Issue URL.
297    pub url: String,
298    /// Issue labels.
299    pub labels: Labels,
300    /// Issue comments.
301    pub comments: CommentsConnection,
302    /// Issue author.
303    pub author: Option<Author>,
304    /// Issue creation timestamp (ISO 8601).
305    #[serde(rename = "createdAt")]
306    pub created_at: String,
307    /// Issue last update timestamp (ISO 8601).
308    #[serde(rename = "updatedAt")]
309    pub updated_at: String,
310}
311
312/// Repository data from GraphQL response for triage.
313#[derive(Debug, Clone, Deserialize, Serialize)]
314pub struct RepositoryData {
315    /// Repository name with owner.
316    #[serde(rename = "nameWithOwner")]
317    pub name_with_owner: String,
318    /// Repository labels.
319    pub labels: RepoLabelsConnection,
320    /// Repository milestones.
321    pub milestones: RepoMilestonesConnection,
322    /// Repository primary language.
323    #[serde(rename = "primaryLanguage")]
324    pub primary_language: Option<LanguageNode>,
325    /// Viewer permission level on the repository.
326    #[serde(rename = "viewerPermission")]
327    pub viewer_permission: Option<ViewerPermission>,
328}
329
330/// Language information from GraphQL response.
331#[derive(Debug, Clone, Deserialize, Serialize)]
332pub struct LanguageNode {
333    /// Language name.
334    pub name: String,
335}
336
337/// Full response for issue with repo context.
338#[derive(Debug, Clone, Deserialize, Serialize)]
339pub struct IssueWithRepoContextResponse {
340    /// The issue.
341    pub issue: IssueNodeDetailed,
342    /// The repository.
343    pub repository: RepositoryData,
344}
345
346/// Builds a GraphQL query to fetch an issue with repository context.
347fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
348    let query = format!(
349        r#"query {{
350            issue: repository(owner: "{owner}", name: "{repo}") {{
351                issue(number: {number}) {{
352                    number
353                    title
354                    body
355                    url
356                    author {{
357                        login
358                    }}
359                    createdAt
360                    updatedAt
361                    labels(first: 10) {{
362                        nodes {{
363                            name
364                        }}
365                    }}
366                    comments(first: 5) {{
367                        totalCount
368                        nodes {{
369                            author {{
370                                login
371                            }}
372                            body
373                        }}
374                    }}
375                }}
376            }}
377            repository(owner: "{owner}", name: "{repo}") {{
378                nameWithOwner
379                viewerPermission
380                labels(first: 100) {{
381                    nodes {{
382                        name
383                        description
384                        color
385                    }}
386                }}
387                milestones(first: 50, states: OPEN) {{
388                    nodes {{
389                        number
390                        title
391                        description
392                    }}
393                }}
394                primaryLanguage {{
395                    name
396                }}
397            }}
398        }}"#
399    );
400
401    json!({ "query": query })
402}
403
404/// Checks if any error in the GraphQL errors array has type=`NOT_FOUND`.
405fn is_not_found_error(errors: &Value) -> bool {
406    if let Some(arr) = errors.as_array() {
407        arr.iter().any(|err| {
408            err.get("type")
409                .and_then(|t| t.as_str())
410                .is_some_and(|t| t == "NOT_FOUND")
411        })
412    } else {
413        false
414    }
415}
416
417/// Fetches an issue with repository context (labels, milestones) in a single GraphQL call.
418///
419/// # Errors
420///
421/// Returns an error if the GraphQL query fails or the issue is not found.
422/// If the issue is not found but a PR with the same number exists, returns a `TypeMismatch` error.
423#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
424pub async fn fetch_issue_with_repo_context(
425    client: &Octocrab,
426    owner: &str,
427    repo: &str,
428    number: u64,
429) -> Result<(IssueNodeDetailed, RepositoryData)> {
430    debug!("Fetching issue with repository context");
431
432    let query = build_issue_with_repo_context_query(owner, repo, number);
433    debug!("Executing GraphQL query for issue with repo context");
434
435    let response: Value = client
436        .graphql(&query)
437        .await
438        .context("Failed to execute GraphQL query")?;
439
440    // Check for GraphQL errors
441    if let Some(errors) = response.get("errors") {
442        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
443
444        // Only attempt fallback for NOT_FOUND errors to avoid unnecessary API calls
445        if is_not_found_error(errors) {
446            debug!("GraphQL NOT_FOUND error, checking if reference is a PR");
447
448            // Try to fetch as a PR to provide a better error message
449            if (client.pulls(owner, repo).get(number).await).is_ok() {
450                return Err(AptuError::TypeMismatch {
451                    number,
452                    expected: ResourceType::Issue,
453                    actual: ResourceType::PullRequest,
454                }
455                .into());
456            }
457        }
458
459        // Not a PR or not a NOT_FOUND error, return the original GraphQL error
460        anyhow::bail!("GraphQL error: {error_msg}");
461    }
462
463    let data = response
464        .get("data")
465        .context("Missing 'data' field in GraphQL response")?;
466
467    // Extract issue from nested structure
468    let issue_data = data.get("issue").and_then(|v| v.get("issue"));
469
470    // Check if issue is null (not found)
471    if issue_data.is_none() || issue_data.is_some_and(serde_json::Value::is_null) {
472        debug!("Issue not found in GraphQL response, checking if reference is a PR");
473
474        // Try to fetch as a PR to provide a better error message
475        if (client.pulls(owner, repo).get(number).await).is_ok() {
476            return Err(AptuError::TypeMismatch {
477                number,
478                expected: ResourceType::Issue,
479                actual: ResourceType::PullRequest,
480            }
481            .into());
482        }
483
484        // Not a PR, return the original error
485        anyhow::bail!("Issue not found in GraphQL response");
486    }
487
488    let issue: IssueNodeDetailed = serde_json::from_value(issue_data.unwrap().clone())
489        .context("Failed to parse issue data")?;
490
491    let repo_data = data
492        .get("repository")
493        .context("Repository not found in GraphQL response")?;
494
495    let repository: RepositoryData =
496        serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
497
498    debug!(
499        issue_number = issue.number,
500        labels_count = repository.labels.nodes.len(),
501        milestones_count = repository.milestones.nodes.len(),
502        "Fetched issue with repository context"
503    );
504
505    Ok((issue, repository))
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn build_query_single_repo() {
514        let repos = [("block", "goose")];
515
516        let query = build_issues_query(&repos);
517        let query_str = query["query"].as_str().unwrap();
518
519        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
520        assert!(query_str.contains("labels: [\"good first issue\"]"));
521        assert!(query_str.contains("states: OPEN"));
522    }
523
524    #[test]
525    fn build_query_multiple_repos() {
526        let repos = [("block", "goose"), ("astral-sh", "ruff")];
527
528        let query = build_issues_query(&repos);
529        let query_str = query["query"].as_str().unwrap();
530
531        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
532        assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
533    }
534
535    #[test]
536    fn build_query_empty_repos() {
537        let repos: [(&str, &str); 0] = [];
538        let query = build_issues_query(&repos);
539        let query_str = query["query"].as_str().unwrap();
540
541        assert_eq!(query_str, "query {  }");
542    }
543}
544
545/// Target of a reference (either a Tag or Commit).
546#[derive(Debug, Clone, Deserialize, Serialize)]
547#[serde(untagged)]
548pub enum RefTarget {
549    /// A tag object.
550    Tag(TagTarget),
551    /// A commit object.
552    Commit(CommitTarget),
553}
554
555/// A tag object from the GraphQL response.
556#[derive(Debug, Clone, Deserialize, Serialize)]
557pub struct TagTarget {
558    /// The commit that this tag points to.
559    pub target: CommitTarget,
560}
561
562/// A commit object from the GraphQL response.
563#[derive(Debug, Clone, Deserialize, Serialize)]
564pub struct CommitTarget {
565    /// The commit SHA.
566    pub oid: String,
567}
568
569/// Build a GraphQL query to resolve a tag to its commit SHA.
570///
571/// Uses inline fragments to handle both Tag and Commit target types.
572fn build_tag_resolution_query(owner: &str, repo: &str, ref_name: &str) -> Value {
573    let query = format!(
574        r#"query {{
575  repository(owner: "{owner}", name: "{repo}") {{
576    ref(qualifiedName: "refs/tags/{ref_name}") {{
577      target {{
578        ... on Tag {{
579          target {{
580            oid
581          }}
582        }}
583        ... on Commit {{
584          oid
585        }}
586      }}
587    }}
588  }}
589}}"#
590    );
591
592    json!({
593        "query": query,
594    })
595}
596
597/// Resolve a tag to its commit SHA using GraphQL.
598///
599/// Handles both lightweight tags (which point directly to commits) and
600/// annotated tags (which have a Tag object that points to a commit).
601///
602/// # Arguments
603///
604/// * `client` - Octocrab GitHub client
605/// * `owner` - Repository owner
606/// * `repo` - Repository name
607/// * `tag_name` - Tag name to resolve
608///
609/// # Returns
610///
611/// The commit SHA for the tag, or None if the tag doesn't exist.
612#[instrument(skip(client))]
613pub async fn resolve_tag_to_commit_sha(
614    client: &Octocrab,
615    owner: &str,
616    repo: &str,
617    tag_name: &str,
618) -> Result<Option<String>> {
619    let query = build_tag_resolution_query(owner, repo, tag_name);
620
621    let response = (|| async {
622        client
623            .graphql::<serde_json::Value>(&query)
624            .await
625            .context("GraphQL query failed")
626    })
627    .retry(&retry_backoff())
628    .await?;
629
630    debug!("GraphQL response: {:?}", response);
631
632    // Extract the target from the response
633    let target = response
634        .get("data")
635        .and_then(|data| data.get("repository"))
636        .and_then(|repo| repo.get("ref"))
637        .and_then(|ref_obj| ref_obj.get("target"));
638
639    match target {
640        Some(target_value) => {
641            // Try to deserialize as RefTarget to handle both Tag and Commit cases
642            match serde_json::from_value::<RefTarget>(target_value.clone()) {
643                Ok(RefTarget::Tag(tag)) => Ok(Some(tag.target.oid)),
644                Ok(RefTarget::Commit(commit)) => Ok(Some(commit.oid)),
645                Err(_) => Ok(None),
646            }
647        }
648        None => Ok(None),
649    }
650}
651
652#[cfg(test)]
653mod tag_resolution_tests {
654    use super::*;
655
656    #[test]
657    fn build_tag_resolution_query_correct_syntax() {
658        let query = build_tag_resolution_query("owner", "repo", "v1.0.0");
659        let query_str = query["query"].as_str().unwrap();
660
661        assert!(query_str.contains("repository(owner: \"owner\", name: \"repo\")"));
662        assert!(query_str.contains("ref(qualifiedName: \"refs/tags/v1.0.0\")"));
663        assert!(query_str.contains("... on Tag"));
664        assert!(query_str.contains("... on Commit"));
665        assert!(query_str.contains("oid"));
666    }
667}