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::retry::retry_backoff;
17
18/// Viewer permission level on a repository.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
20#[serde(rename_all = "UPPERCASE")]
21pub enum ViewerPermission {
22    /// Admin permission.
23    Admin,
24    /// Maintain permission.
25    Maintain,
26    /// Write permission.
27    Write,
28    /// Triage permission.
29    Triage,
30    /// Read permission.
31    Read,
32}
33
34/// A GitHub issue from the GraphQL response.
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct IssueNode {
37    /// Issue number.
38    pub number: u64,
39    /// Issue title.
40    pub title: String,
41    /// Creation timestamp (ISO 8601).
42    #[serde(rename = "createdAt")]
43    pub created_at: String,
44    /// Issue labels.
45    pub labels: Labels,
46    /// Issue URL (used by triage command).
47    #[allow(dead_code)]
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/// Fetches an issue with repository context (labels, milestones) in a single GraphQL call.
404///
405/// # Errors
406///
407/// Returns an error if the GraphQL query fails or the issue is not found.
408#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
409pub async fn fetch_issue_with_repo_context(
410    client: &Octocrab,
411    owner: &str,
412    repo: &str,
413    number: u64,
414) -> Result<(IssueNodeDetailed, RepositoryData)> {
415    debug!("Fetching issue with repository context");
416
417    let query = build_issue_with_repo_context_query(owner, repo, number);
418    debug!("Executing GraphQL query for issue with repo context");
419
420    let response: Value = client
421        .graphql(&query)
422        .await
423        .context("Failed to execute GraphQL query")?;
424
425    // Check for GraphQL errors
426    if let Some(errors) = response.get("errors") {
427        let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
428        anyhow::bail!("GraphQL error: {error_msg}");
429    }
430
431    let data = response
432        .get("data")
433        .context("Missing 'data' field in GraphQL response")?;
434
435    // Extract issue from nested structure
436    let issue_data = data
437        .get("issue")
438        .and_then(|v| v.get("issue"))
439        .context("Issue not found in GraphQL response")?;
440
441    let issue: IssueNodeDetailed =
442        serde_json::from_value(issue_data.clone()).context("Failed to parse issue data")?;
443
444    let repo_data = data
445        .get("repository")
446        .context("Repository not found in GraphQL response")?;
447
448    let repository: RepositoryData =
449        serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
450
451    debug!(
452        issue_number = issue.number,
453        labels_count = repository.labels.nodes.len(),
454        milestones_count = repository.milestones.nodes.len(),
455        "Fetched issue with repository context"
456    );
457
458    Ok((issue, repository))
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn build_query_single_repo() {
467        let repos = [("block", "goose")];
468
469        let query = build_issues_query(&repos);
470        let query_str = query["query"].as_str().unwrap();
471
472        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
473        assert!(query_str.contains("labels: [\"good first issue\"]"));
474        assert!(query_str.contains("states: OPEN"));
475    }
476
477    #[test]
478    fn build_query_multiple_repos() {
479        let repos = [("block", "goose"), ("astral-sh", "ruff")];
480
481        let query = build_issues_query(&repos);
482        let query_str = query["query"].as_str().unwrap();
483
484        assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
485        assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
486    }
487
488    #[test]
489    fn build_query_empty_repos() {
490        let repos: [(&str, &str); 0] = [];
491        let query = build_issues_query(&repos);
492        let query_str = query["query"].as_str().unwrap();
493
494        assert_eq!(query_str, "query {  }");
495    }
496}
497
498/// Target of a reference (either a Tag or Commit).
499#[derive(Debug, Clone, Deserialize, Serialize)]
500#[serde(untagged)]
501pub enum RefTarget {
502    /// A tag object.
503    Tag(TagTarget),
504    /// A commit object.
505    Commit(CommitTarget),
506}
507
508/// A tag object from the GraphQL response.
509#[derive(Debug, Clone, Deserialize, Serialize)]
510pub struct TagTarget {
511    /// The commit that this tag points to.
512    pub target: CommitTarget,
513}
514
515/// A commit object from the GraphQL response.
516#[derive(Debug, Clone, Deserialize, Serialize)]
517pub struct CommitTarget {
518    /// The commit SHA.
519    pub oid: String,
520}
521
522/// Build a GraphQL query to resolve a tag to its commit SHA.
523///
524/// Uses inline fragments to handle both Tag and Commit target types.
525fn build_tag_resolution_query(owner: &str, repo: &str, ref_name: &str) -> Value {
526    let query = format!(
527        r#"query {{
528  repository(owner: "{owner}", name: "{repo}") {{
529    ref(qualifiedName: "refs/tags/{ref_name}") {{
530      target {{
531        ... on Tag {{
532          target {{
533            oid
534          }}
535        }}
536        ... on Commit {{
537          oid
538        }}
539      }}
540    }}
541  }}
542}}"#
543    );
544
545    json!({
546        "query": query,
547    })
548}
549
550/// Resolve a tag to its commit SHA using GraphQL.
551///
552/// Handles both lightweight tags (which point directly to commits) and
553/// annotated tags (which have a Tag object that points to a commit).
554///
555/// # Arguments
556///
557/// * `client` - Octocrab GitHub client
558/// * `owner` - Repository owner
559/// * `repo` - Repository name
560/// * `tag_name` - Tag name to resolve
561///
562/// # Returns
563///
564/// The commit SHA for the tag, or None if the tag doesn't exist.
565#[instrument(skip(client))]
566pub async fn resolve_tag_to_commit_sha(
567    client: &Octocrab,
568    owner: &str,
569    repo: &str,
570    tag_name: &str,
571) -> Result<Option<String>> {
572    let query = build_tag_resolution_query(owner, repo, tag_name);
573
574    let response = (|| async {
575        client
576            .graphql::<serde_json::Value>(&query)
577            .await
578            .context("GraphQL query failed")
579    })
580    .retry(&retry_backoff())
581    .await?;
582
583    debug!("GraphQL response: {:?}", response);
584
585    // Extract the target from the response
586    let target = response
587        .get("data")
588        .and_then(|data| data.get("repository"))
589        .and_then(|repo| repo.get("ref"))
590        .and_then(|ref_obj| ref_obj.get("target"));
591
592    match target {
593        Some(target_value) => {
594            // Try to deserialize as RefTarget to handle both Tag and Commit cases
595            match serde_json::from_value::<RefTarget>(target_value.clone()) {
596                Ok(RefTarget::Tag(tag)) => Ok(Some(tag.target.oid)),
597                Ok(RefTarget::Commit(commit)) => Ok(Some(commit.oid)),
598                Err(_) => Ok(None),
599            }
600        }
601        None => Ok(None),
602    }
603}
604
605#[cfg(test)]
606mod tag_resolution_tests {
607    use super::*;
608
609    #[test]
610    fn build_tag_resolution_query_correct_syntax() {
611        let query = build_tag_resolution_query("owner", "repo", "v1.0.0");
612        let query_str = query["query"].as_str().unwrap();
613
614        assert!(query_str.contains("repository(owner: \"owner\", name: \"repo\")"));
615        assert!(query_str.contains("ref(qualifiedName: \"refs/tags/v1.0.0\")"));
616        assert!(query_str.contains("... on Tag"));
617        assert!(query_str.contains("... on Commit"));
618        assert!(query_str.contains("oid"));
619    }
620}