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