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::retry::retry_backoff;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
20#[serde(rename_all = "UPPERCASE")]
21pub enum ViewerPermission {
22 Admin,
24 Maintain,
26 Write,
28 Triage,
30 Read,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct IssueNode {
37 pub number: u64,
39 pub title: String,
41 #[serde(rename = "createdAt")]
43 pub created_at: String,
44 pub labels: Labels,
46 #[allow(dead_code)]
48 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 author: Author,
256 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#[derive(Debug, Clone, Deserialize, Serialize)]
271pub struct Author {
272 pub login: String,
274}
275
276#[derive(Debug, Clone, Deserialize, Serialize)]
278pub struct CommentsConnection {
279 pub nodes: Vec<IssueCommentNode>,
281}
282
283#[derive(Debug, Clone, Deserialize, Serialize)]
285pub struct IssueNodeDetailed {
286 pub number: u64,
288 pub title: String,
290 pub body: Option<String>,
292 pub url: String,
294 pub labels: Labels,
296 pub comments: CommentsConnection,
298}
299
300#[derive(Debug, Clone, Deserialize, Serialize)]
302pub struct RepositoryData {
303 #[serde(rename = "nameWithOwner")]
305 pub name_with_owner: String,
306 pub labels: RepoLabelsConnection,
308 pub milestones: RepoMilestonesConnection,
310 #[serde(rename = "primaryLanguage")]
312 pub primary_language: Option<LanguageNode>,
313 #[serde(rename = "viewerPermission")]
315 pub viewer_permission: Option<ViewerPermission>,
316}
317
318#[derive(Debug, Clone, Deserialize, Serialize)]
320pub struct LanguageNode {
321 pub name: String,
323}
324
325#[derive(Debug, Clone, Deserialize, Serialize)]
327pub struct IssueWithRepoContextResponse {
328 pub issue: IssueNodeDetailed,
330 pub repository: RepositoryData,
332}
333
334fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
336 let query = format!(
337 r#"query {{
338 issue: repository(owner: "{owner}", name: "{repo}") {{
339 issue(number: {number}) {{
340 number
341 title
342 body
343 url
344 labels(first: 10) {{
345 nodes {{
346 name
347 }}
348 }}
349 comments(first: 5) {{
350 nodes {{
351 author {{
352 login
353 }}
354 body
355 }}
356 }}
357 }}
358 }}
359 repository(owner: "{owner}", name: "{repo}") {{
360 nameWithOwner
361 viewerPermission
362 labels(first: 100) {{
363 nodes {{
364 name
365 description
366 color
367 }}
368 }}
369 milestones(first: 50, states: OPEN) {{
370 nodes {{
371 number
372 title
373 description
374 }}
375 }}
376 primaryLanguage {{
377 name
378 }}
379 }}
380 }}"#
381 );
382
383 json!({ "query": query })
384}
385
386#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
392pub async fn fetch_issue_with_repo_context(
393 client: &Octocrab,
394 owner: &str,
395 repo: &str,
396 number: u64,
397) -> Result<(IssueNodeDetailed, RepositoryData)> {
398 debug!("Fetching issue with repository context");
399
400 let query = build_issue_with_repo_context_query(owner, repo, number);
401 debug!("Executing GraphQL query for issue with repo context");
402
403 let response: Value = client
404 .graphql(&query)
405 .await
406 .context("Failed to execute GraphQL query")?;
407
408 if let Some(errors) = response.get("errors") {
410 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
411 anyhow::bail!("GraphQL error: {error_msg}");
412 }
413
414 let data = response
415 .get("data")
416 .context("Missing 'data' field in GraphQL response")?;
417
418 let issue_data = data
420 .get("issue")
421 .and_then(|v| v.get("issue"))
422 .context("Issue not found in GraphQL response")?;
423
424 let issue: IssueNodeDetailed =
425 serde_json::from_value(issue_data.clone()).context("Failed to parse issue data")?;
426
427 let repo_data = data
428 .get("repository")
429 .context("Repository not found in GraphQL response")?;
430
431 let repository: RepositoryData =
432 serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
433
434 debug!(
435 issue_number = issue.number,
436 labels_count = repository.labels.nodes.len(),
437 milestones_count = repository.milestones.nodes.len(),
438 "Fetched issue with repository context"
439 );
440
441 Ok((issue, repository))
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn build_query_single_repo() {
450 let repos = [("block", "goose")];
451
452 let query = build_issues_query(&repos);
453 let query_str = query["query"].as_str().unwrap();
454
455 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
456 assert!(query_str.contains("labels: [\"good first issue\"]"));
457 assert!(query_str.contains("states: OPEN"));
458 }
459
460 #[test]
461 fn build_query_multiple_repos() {
462 let repos = [("block", "goose"), ("astral-sh", "ruff")];
463
464 let query = build_issues_query(&repos);
465 let query_str = query["query"].as_str().unwrap();
466
467 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
468 assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
469 }
470
471 #[test]
472 fn build_query_empty_repos() {
473 let repos: [(&str, &str); 0] = [];
474 let query = build_issues_query(&repos);
475 let query_str = query["query"].as_str().unwrap();
476
477 assert_eq!(query_str, "query { }");
478 }
479}