1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
24#[serde(rename_all = "UPPERCASE")]
25pub enum ViewerPermission {
26 Admin,
28 Maintain,
30 Write,
32 Triage,
34 Read,
36}
37
38#[derive(Debug, Clone, Deserialize, Serialize)]
40pub struct IssueNode {
41 pub number: u64,
43 pub title: String,
45 #[serde(rename = "createdAt")]
47 pub created_at: String,
48 pub labels: Labels,
50 pub url: String,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
56pub struct Labels {
57 pub nodes: Vec<LabelNode>,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
63pub struct LabelNode {
64 pub name: String,
66}
67
68#[derive(Debug, Deserialize)]
70pub struct RepoIssues {
71 #[serde(rename = "nameWithOwner")]
73 pub name_with_owner: String,
74 pub issues: IssuesConnection,
76}
77
78#[derive(Debug, Deserialize)]
80pub struct IssuesConnection {
81 pub nodes: Vec<IssueNode>,
83}
84
85fn 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#[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 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 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 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
201pub struct RepoLabelNode {
202 pub name: String,
204 pub description: Option<String>,
206 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#[derive(Debug, Clone, Deserialize, Serialize)]
222pub struct RepoLabelsConnection {
223 pub nodes: Vec<RepoLabelNode>,
225}
226
227#[derive(Debug, Clone, Deserialize, Serialize)]
229pub struct RepoMilestoneNode {
230 pub number: u64,
232 pub title: String,
234 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#[derive(Debug, Clone, Deserialize, Serialize)]
250pub struct RepoMilestonesConnection {
251 pub nodes: Vec<RepoMilestoneNode>,
253}
254
255#[derive(Debug, Clone, Deserialize, Serialize)]
257pub struct IssueCommentNode {
258 pub id: u64,
260 pub author: Author,
262 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#[derive(Debug, Clone, Deserialize, Serialize)]
278pub struct Author {
279 pub login: String,
281}
282
283#[derive(Debug, Clone, Deserialize, Serialize)]
285pub struct CommentsConnection {
286 #[serde(rename = "totalCount")]
288 pub total_count: u32,
289 pub nodes: Vec<IssueCommentNode>,
291}
292
293#[derive(Debug, Clone, Deserialize, Serialize)]
295pub struct IssueNodeDetailed {
296 pub number: u64,
298 pub title: String,
300 pub body: Option<String>,
302 pub url: String,
304 pub labels: Labels,
306 pub comments: CommentsConnection,
308 pub author: Option<Author>,
310 #[serde(rename = "createdAt")]
312 pub created_at: String,
313 #[serde(rename = "updatedAt")]
315 pub updated_at: String,
316}
317
318#[derive(Debug, Clone, Deserialize, Serialize)]
320pub struct RepositoryData {
321 #[serde(rename = "nameWithOwner")]
323 pub name_with_owner: String,
324 pub labels: RepoLabelsConnection,
326 pub milestones: RepoMilestonesConnection,
328 #[serde(rename = "primaryLanguage")]
330 pub primary_language: Option<LanguageNode>,
331 #[serde(rename = "viewerPermission")]
333 pub viewer_permission: Option<ViewerPermission>,
334}
335
336#[derive(Debug, Clone, Deserialize, Serialize)]
338pub struct LanguageNode {
339 pub name: String,
341}
342
343#[derive(Debug, Clone, Deserialize, Serialize)]
345pub struct IssueWithRepoContextResponse {
346 pub issue: IssueNodeDetailed,
348 pub repository: RepositoryData,
350}
351
352fn 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
410fn 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#[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 if let Some(errors) = response.get("errors") {
449 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
450
451 if is_not_found_error(errors) {
453 debug!("GraphQL NOT_FOUND error, checking if reference is a PR");
454
455 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 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 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 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
553#[serde(untagged)]
554pub enum RefTarget {
555 Tag(TagTarget),
557 Commit(CommitTarget),
559}
560
561#[derive(Debug, Clone, Deserialize, Serialize)]
563pub struct TagTarget {
564 pub target: CommitTarget,
566}
567
568#[derive(Debug, Clone, Deserialize, Serialize)]
570pub struct CommitTarget {
571 pub oid: String,
573}
574
575fn 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#[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 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 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}