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 #[allow(dead_code)]
49 pub url: String,
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Labels {
55 pub nodes: Vec<LabelNode>,
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct LabelNode {
62 pub name: String,
64}
65
66#[derive(Debug, Deserialize)]
68pub struct RepoIssues {
69 #[serde(rename = "nameWithOwner")]
71 pub name_with_owner: String,
72 pub issues: IssuesConnection,
74}
75
76#[derive(Debug, Deserialize)]
78pub struct IssuesConnection {
79 pub nodes: Vec<IssueNode>,
81}
82
83fn 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#[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 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 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 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
198pub struct RepoLabelNode {
199 pub name: String,
201 pub description: Option<String>,
203 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#[derive(Debug, Clone, Deserialize, Serialize)]
219pub struct RepoLabelsConnection {
220 pub nodes: Vec<RepoLabelNode>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
226pub struct RepoMilestoneNode {
227 pub number: u64,
229 pub title: String,
231 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#[derive(Debug, Clone, Deserialize, Serialize)]
247pub struct RepoMilestonesConnection {
248 pub nodes: Vec<RepoMilestoneNode>,
250}
251
252#[derive(Debug, Clone, Deserialize, Serialize)]
254pub struct IssueCommentNode {
255 pub author: Author,
257 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#[derive(Debug, Clone, Deserialize, Serialize)]
272pub struct Author {
273 pub login: String,
275}
276
277#[derive(Debug, Clone, Deserialize, Serialize)]
279pub struct CommentsConnection {
280 #[serde(rename = "totalCount")]
282 pub total_count: u32,
283 pub nodes: Vec<IssueCommentNode>,
285}
286
287#[derive(Debug, Clone, Deserialize, Serialize)]
289pub struct IssueNodeDetailed {
290 pub number: u64,
292 pub title: String,
294 pub body: Option<String>,
296 pub url: String,
298 pub labels: Labels,
300 pub comments: CommentsConnection,
302 pub author: Option<Author>,
304 #[serde(rename = "createdAt")]
306 pub created_at: String,
307 #[serde(rename = "updatedAt")]
309 pub updated_at: String,
310}
311
312#[derive(Debug, Clone, Deserialize, Serialize)]
314pub struct RepositoryData {
315 #[serde(rename = "nameWithOwner")]
317 pub name_with_owner: String,
318 pub labels: RepoLabelsConnection,
320 pub milestones: RepoMilestonesConnection,
322 #[serde(rename = "primaryLanguage")]
324 pub primary_language: Option<LanguageNode>,
325 #[serde(rename = "viewerPermission")]
327 pub viewer_permission: Option<ViewerPermission>,
328}
329
330#[derive(Debug, Clone, Deserialize, Serialize)]
332pub struct LanguageNode {
333 pub name: String,
335}
336
337#[derive(Debug, Clone, Deserialize, Serialize)]
339pub struct IssueWithRepoContextResponse {
340 pub issue: IssueNodeDetailed,
342 pub repository: RepositoryData,
344}
345
346fn 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
404fn 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#[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 if let Some(errors) = response.get("errors") {
442 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
443
444 if is_not_found_error(errors) {
446 debug!("GraphQL NOT_FOUND error, checking if reference is a PR");
447
448 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 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 let issue_data = data.get("issue").and_then(|v| v.get("issue"));
469
470 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 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
547#[serde(untagged)]
548pub enum RefTarget {
549 Tag(TagTarget),
551 Commit(CommitTarget),
553}
554
555#[derive(Debug, Clone, Deserialize, Serialize)]
557pub struct TagTarget {
558 pub target: CommitTarget,
560}
561
562#[derive(Debug, Clone, Deserialize, Serialize)]
564pub struct CommitTarget {
565 pub oid: String,
567}
568
569fn 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#[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 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 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}