1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
21#[serde(rename_all = "UPPERCASE")]
22pub enum ViewerPermission {
23 Admin,
25 Maintain,
27 Write,
29 Triage,
31 Read,
33}
34
35#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct IssueNode {
38 pub number: u64,
40 pub title: String,
42 #[serde(rename = "createdAt")]
44 pub created_at: String,
45 pub labels: Labels,
47 pub url: String,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
53pub struct Labels {
54 pub nodes: Vec<LabelNode>,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct LabelNode {
61 pub name: String,
63}
64
65#[derive(Debug, Deserialize)]
67pub struct RepoIssues {
68 #[serde(rename = "nameWithOwner")]
70 pub name_with_owner: String,
71 pub issues: IssuesConnection,
73}
74
75#[derive(Debug, Deserialize)]
77pub struct IssuesConnection {
78 pub nodes: Vec<IssueNode>,
80}
81
82fn 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#[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 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 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 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct RepoLabelNode {
198 pub name: String,
200 pub description: Option<String>,
202 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#[derive(Debug, Clone, Deserialize, Serialize)]
218pub struct RepoLabelsConnection {
219 pub nodes: Vec<RepoLabelNode>,
221}
222
223#[derive(Debug, Clone, Deserialize, Serialize)]
225pub struct RepoMilestoneNode {
226 pub number: u64,
228 pub title: String,
230 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#[derive(Debug, Clone, Deserialize, Serialize)]
246pub struct RepoMilestonesConnection {
247 pub nodes: Vec<RepoMilestoneNode>,
249}
250
251#[derive(Debug, Clone, Deserialize, Serialize)]
253pub struct IssueCommentNode {
254 pub id: u64,
256 pub author: Author,
258 pub body: String,
260}
261
262impl From<IssueCommentNode> for IssueComment {
263 fn from(node: IssueCommentNode) -> Self {
264 IssueComment {
265 id: node.id,
266 author: node.author.login,
267 body: node.body,
268 }
269 }
270}
271
272#[derive(Debug, Clone, Deserialize, Serialize)]
274pub struct Author {
275 pub login: String,
277}
278
279#[derive(Debug, Clone, Deserialize, Serialize)]
281pub struct CommentsConnection {
282 #[serde(rename = "totalCount")]
284 pub total_count: u32,
285 pub nodes: Vec<IssueCommentNode>,
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
291pub struct IssueNodeDetailed {
292 pub number: u64,
294 pub title: String,
296 pub body: Option<String>,
298 pub url: String,
300 pub labels: Labels,
302 pub comments: CommentsConnection,
304 pub author: Option<Author>,
306 #[serde(rename = "createdAt")]
308 pub created_at: String,
309 #[serde(rename = "updatedAt")]
311 pub updated_at: String,
312}
313
314#[derive(Debug, Clone, Deserialize, Serialize)]
316pub struct RepositoryData {
317 #[serde(rename = "nameWithOwner")]
319 pub name_with_owner: String,
320 pub labels: RepoLabelsConnection,
322 pub milestones: RepoMilestonesConnection,
324 #[serde(rename = "primaryLanguage")]
326 pub primary_language: Option<LanguageNode>,
327 #[serde(rename = "viewerPermission")]
329 pub viewer_permission: Option<ViewerPermission>,
330}
331
332#[derive(Debug, Clone, Deserialize, Serialize)]
334pub struct LanguageNode {
335 pub name: String,
337}
338
339#[derive(Debug, Clone, Deserialize, Serialize)]
341pub struct IssueWithRepoContextResponse {
342 pub issue: IssueNodeDetailed,
344 pub repository: RepositoryData,
346}
347
348fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
350 let query = format!(
351 r#"query {{
352 issue: repository(owner: "{owner}", name: "{repo}") {{
353 issue(number: {number}) {{
354 number
355 title
356 body
357 url
358 author {{
359 login
360 }}
361 createdAt
362 updatedAt
363 labels(first: 10) {{
364 nodes {{
365 name
366 }}
367 }}
368 comments(first: 5) {{
369 totalCount
370 nodes {{
371 author {{
372 login
373 }}
374 body
375 }}
376 }}
377 }}
378 }}
379 repository(owner: "{owner}", name: "{repo}") {{
380 nameWithOwner
381 viewerPermission
382 labels(first: 100) {{
383 nodes {{
384 name
385 description
386 color
387 }}
388 }}
389 milestones(first: 50, states: OPEN) {{
390 nodes {{
391 number
392 title
393 description
394 }}
395 }}
396 primaryLanguage {{
397 name
398 }}
399 }}
400 }}"#
401 );
402
403 json!({ "query": query })
404}
405
406fn is_not_found_error(errors: &Value) -> bool {
408 if let Some(arr) = errors.as_array() {
409 arr.iter().any(|err| {
410 err.get("type")
411 .and_then(|t| t.as_str())
412 .is_some_and(|t| t == "NOT_FOUND")
413 })
414 } else {
415 false
416 }
417}
418
419#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
426pub async fn fetch_issue_with_repo_context(
427 client: &Octocrab,
428 owner: &str,
429 repo: &str,
430 number: u64,
431) -> Result<(IssueNodeDetailed, RepositoryData)> {
432 debug!("Fetching issue with repository context");
433
434 let query = build_issue_with_repo_context_query(owner, repo, number);
435 debug!("Executing GraphQL query for issue with repo context");
436
437 let response: Value = client
438 .graphql(&query)
439 .await
440 .context("Failed to execute GraphQL query")?;
441
442 if let Some(errors) = response.get("errors") {
444 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
445
446 if is_not_found_error(errors) {
448 debug!("GraphQL NOT_FOUND error, checking if reference is a PR");
449
450 if (client.pulls(owner, repo).get(number).await).is_ok() {
452 return Err(AptuError::TypeMismatch {
453 number,
454 expected: ResourceType::Issue,
455 actual: ResourceType::PullRequest,
456 }
457 .into());
458 }
459 }
460
461 anyhow::bail!("GraphQL error: {error_msg}");
463 }
464
465 let data = response
466 .get("data")
467 .context("Missing 'data' field in GraphQL response")?;
468
469 let issue_data = data.get("issue").and_then(|v| v.get("issue"));
471
472 let Some(issue_val) = issue_data.filter(|v| !v.is_null()) else {
473 debug!("Issue not found in GraphQL response, checking if reference is a PR");
474
475 if (client.pulls(owner, repo).get(number).await).is_ok() {
477 return Err(AptuError::TypeMismatch {
478 number,
479 expected: ResourceType::Issue,
480 actual: ResourceType::PullRequest,
481 }
482 .into());
483 }
484
485 anyhow::bail!("Issue not found in GraphQL response");
487 };
488
489 let issue: IssueNodeDetailed =
490 serde_json::from_value(issue_val.clone()).context("Failed to parse issue data")?;
491
492 let repo_data = data
493 .get("repository")
494 .context("Repository not found in GraphQL response")?;
495
496 let repository: RepositoryData =
497 serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
498
499 debug!(
500 issue_number = issue.number,
501 labels_count = repository.labels.nodes.len(),
502 milestones_count = repository.milestones.nodes.len(),
503 "Fetched issue with repository context"
504 );
505
506 Ok((issue, repository))
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn build_query_single_repo() {
515 let repos = [("block", "goose")];
516
517 let query = build_issues_query(&repos);
518 let query_str = query["query"].as_str().unwrap();
519
520 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
521 assert!(query_str.contains("labels: [\"good first issue\"]"));
522 assert!(query_str.contains("states: OPEN"));
523 }
524
525 #[test]
526 fn build_query_multiple_repos() {
527 let repos = [("block", "goose"), ("astral-sh", "ruff")];
528
529 let query = build_issues_query(&repos);
530 let query_str = query["query"].as_str().unwrap();
531
532 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
533 assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
534 }
535
536 #[test]
537 fn build_query_empty_repos() {
538 let repos: [(&str, &str); 0] = [];
539 let query = build_issues_query(&repos);
540 let query_str = query["query"].as_str().unwrap();
541
542 assert_eq!(query_str, "query { }");
543 }
544}
545
546#[derive(Debug, Clone, Deserialize, Serialize)]
548#[serde(untagged)]
549pub enum RefTarget {
550 Tag(TagTarget),
552 Commit(CommitTarget),
554}
555
556#[derive(Debug, Clone, Deserialize, Serialize)]
558pub struct TagTarget {
559 pub target: CommitTarget,
561}
562
563#[derive(Debug, Clone, Deserialize, Serialize)]
565pub struct CommitTarget {
566 pub oid: String,
568}
569
570fn build_tag_resolution_query(owner: &str, repo: &str, ref_name: &str) -> Value {
574 let query = format!(
575 r#"query {{
576 repository(owner: "{owner}", name: "{repo}") {{
577 ref(qualifiedName: "refs/tags/{ref_name}") {{
578 target {{
579 ... on Tag {{
580 target {{
581 oid
582 }}
583 }}
584 ... on Commit {{
585 oid
586 }}
587 }}
588 }}
589 }}
590}}"#
591 );
592
593 json!({
594 "query": query,
595 })
596}
597
598#[instrument(skip(client))]
614pub async fn resolve_tag_to_commit_sha(
615 client: &Octocrab,
616 owner: &str,
617 repo: &str,
618 tag_name: &str,
619) -> Result<Option<String>> {
620 let query = build_tag_resolution_query(owner, repo, tag_name);
621
622 let response = (|| async {
623 client
624 .graphql::<serde_json::Value>(&query)
625 .await
626 .context("GraphQL query failed")
627 })
628 .retry(&retry_backoff())
629 .await?;
630
631 debug!("GraphQL response: {:?}", response);
632
633 let target = response
635 .get("data")
636 .and_then(|data| data.get("repository"))
637 .and_then(|repo| repo.get("ref"))
638 .and_then(|ref_obj| ref_obj.get("target"));
639
640 match target {
641 Some(target_value) => {
642 match serde_json::from_value::<RefTarget>(target_value.clone()) {
644 Ok(RefTarget::Tag(tag)) => Ok(Some(tag.target.oid)),
645 Ok(RefTarget::Commit(commit)) => Ok(Some(commit.oid)),
646 Err(_) => Ok(None),
647 }
648 }
649 None => Ok(None),
650 }
651}
652
653#[cfg(test)]
654mod tag_resolution_tests {
655 use super::*;
656
657 #[test]
658 fn build_tag_resolution_query_correct_syntax() {
659 let query = build_tag_resolution_query("owner", "repo", "v1.0.0");
660 let query_str = query["query"].as_str().unwrap();
661
662 assert!(query_str.contains("repository(owner: \"owner\", name: \"repo\")"));
663 assert!(query_str.contains("ref(qualifiedName: \"refs/tags/v1.0.0\")"));
664 assert!(query_str.contains("... on Tag"));
665 assert!(query_str.contains("... on Commit"));
666 assert!(query_str.contains("oid"));
667 }
668}