1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use tracing::{debug, instrument};
13
14use crate::ai::types::{IssueComment, RepoLabel, RepoMilestone};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
18#[serde(rename_all = "UPPERCASE")]
19pub enum ViewerPermission {
20 Admin,
22 Maintain,
24 Write,
26 Triage,
28 Read,
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct IssueNode {
35 pub number: u64,
37 pub title: String,
39 #[serde(rename = "createdAt")]
41 pub created_at: String,
42 pub labels: Labels,
44 #[allow(dead_code)]
46 pub url: String,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
51pub struct Labels {
52 pub nodes: Vec<LabelNode>,
54}
55
56#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct LabelNode {
59 pub name: String,
61}
62
63#[derive(Debug, Deserialize)]
65pub struct RepoIssues {
66 #[serde(rename = "nameWithOwner")]
68 pub name_with_owner: String,
69 pub issues: IssuesConnection,
71}
72
73#[derive(Debug, Deserialize)]
75pub struct IssuesConnection {
76 pub nodes: Vec<IssueNode>,
78}
79
80fn build_issues_query<R: AsRef<str>>(repos: &[(R, R)]) -> Value {
84 let fragments: Vec<String> = repos
85 .iter()
86 .enumerate()
87 .map(|(i, (owner, name))| {
88 format!(
89 r#"repo{i}: repository(owner: "{owner}", name: "{name}") {{
90 nameWithOwner
91 issues(
92 first: 10
93 states: OPEN
94 labels: ["good first issue"]
95 filterBy: {{ assignee: null }}
96 orderBy: {{ field: CREATED_AT, direction: DESC }}
97 ) {{
98 nodes {{
99 number
100 title
101 createdAt
102 labels(first: 5) {{ nodes {{ name }} }}
103 url
104 }}
105 }}
106 }}"#,
107 i = i,
108 owner = owner.as_ref(),
109 name = name.as_ref()
110 )
111 })
112 .collect();
113
114 let query = format!("query {{ {} }}", fragments.join("\n"));
115 debug!(query_length = query.len(), "Built GraphQL query");
116 json!({ "query": query })
117}
118
119#[instrument(skip(client, repos), fields(repo_count = repos.len()))]
124pub async fn fetch_issues<R: AsRef<str>>(
125 client: &Octocrab,
126 repos: &[(R, R)],
127) -> Result<Vec<(String, Vec<IssueNode>)>> {
128 if repos.is_empty() {
129 return Ok(vec![]);
130 }
131
132 let query = build_issues_query(repos);
133 debug!("Executing GraphQL query");
134
135 let response: Value = client
137 .graphql(&query)
138 .await
139 .context("Failed to execute GraphQL query")?;
140
141 if let Some(errors) = response.get("errors") {
143 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
144 anyhow::bail!("GraphQL error: {error_msg}");
145 }
146
147 let data = response
149 .get("data")
150 .context("Missing 'data' field in GraphQL response")?;
151
152 let mut results = Vec::with_capacity(repos.len());
153
154 for i in 0..repos.len() {
155 let key = format!("repo{i}");
156 if let Some(repo_data) = data.get(&key) {
157 if repo_data.is_null() {
159 debug!(repo = key, "Repository not found or inaccessible");
160 continue;
161 }
162
163 let repo_issues: RepoIssues = serde_json::from_value(repo_data.clone())
164 .with_context(|| format!("Failed to parse repository data for {key}"))?;
165
166 let issue_count = repo_issues.issues.nodes.len();
167 if issue_count > 0 {
168 debug!(
169 repo = %repo_issues.name_with_owner,
170 issues = issue_count,
171 "Found issues"
172 );
173 results.push((repo_issues.name_with_owner, repo_issues.issues.nodes));
174 }
175 }
176 }
177
178 debug!(
179 total_repos = results.len(),
180 "Fetched issues from repositories"
181 );
182 Ok(results)
183}
184
185#[derive(Debug, Clone, Deserialize, Serialize)]
187pub struct RepoLabelNode {
188 pub name: String,
190 pub description: Option<String>,
192 pub color: String,
194}
195
196impl From<RepoLabelNode> for RepoLabel {
197 fn from(node: RepoLabelNode) -> Self {
198 RepoLabel {
199 name: node.name,
200 description: node.description.unwrap_or_default(),
201 color: node.color,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Deserialize, Serialize)]
208pub struct RepoLabelsConnection {
209 pub nodes: Vec<RepoLabelNode>,
211}
212
213#[derive(Debug, Clone, Deserialize, Serialize)]
215pub struct RepoMilestoneNode {
216 pub number: u64,
218 pub title: String,
220 pub description: Option<String>,
222}
223
224impl From<RepoMilestoneNode> for RepoMilestone {
225 fn from(node: RepoMilestoneNode) -> Self {
226 RepoMilestone {
227 number: node.number,
228 title: node.title,
229 description: node.description.unwrap_or_default(),
230 }
231 }
232}
233
234#[derive(Debug, Clone, Deserialize, Serialize)]
236pub struct RepoMilestonesConnection {
237 pub nodes: Vec<RepoMilestoneNode>,
239}
240
241#[derive(Debug, Clone, Deserialize, Serialize)]
243pub struct IssueCommentNode {
244 pub author: Author,
246 pub body: String,
248}
249
250impl From<IssueCommentNode> for IssueComment {
251 fn from(node: IssueCommentNode) -> Self {
252 IssueComment {
253 author: node.author.login,
254 body: node.body,
255 }
256 }
257}
258
259#[derive(Debug, Clone, Deserialize, Serialize)]
261pub struct Author {
262 pub login: String,
264}
265
266#[derive(Debug, Clone, Deserialize, Serialize)]
268pub struct CommentsConnection {
269 pub nodes: Vec<IssueCommentNode>,
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
275pub struct IssueNodeDetailed {
276 pub number: u64,
278 pub title: String,
280 pub body: Option<String>,
282 pub url: String,
284 pub labels: Labels,
286 pub comments: CommentsConnection,
288}
289
290#[derive(Debug, Clone, Deserialize, Serialize)]
292pub struct RepositoryData {
293 #[serde(rename = "nameWithOwner")]
295 pub name_with_owner: String,
296 pub labels: RepoLabelsConnection,
298 pub milestones: RepoMilestonesConnection,
300 #[serde(rename = "primaryLanguage")]
302 pub primary_language: Option<LanguageNode>,
303 #[serde(rename = "viewerPermission")]
305 pub viewer_permission: Option<ViewerPermission>,
306}
307
308#[derive(Debug, Clone, Deserialize, Serialize)]
310pub struct LanguageNode {
311 pub name: String,
313}
314
315#[derive(Debug, Clone, Deserialize, Serialize)]
317pub struct IssueWithRepoContextResponse {
318 pub issue: IssueNodeDetailed,
320 pub repository: RepositoryData,
322}
323
324fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
326 let query = format!(
327 r#"query {{
328 issue: repository(owner: "{owner}", name: "{repo}") {{
329 issue(number: {number}) {{
330 number
331 title
332 body
333 url
334 labels(first: 10) {{
335 nodes {{
336 name
337 }}
338 }}
339 comments(first: 5) {{
340 nodes {{
341 author {{
342 login
343 }}
344 body
345 }}
346 }}
347 }}
348 }}
349 repository(owner: "{owner}", name: "{repo}") {{
350 nameWithOwner
351 viewerPermission
352 labels(first: 100) {{
353 nodes {{
354 name
355 description
356 color
357 }}
358 }}
359 milestones(first: 50, states: OPEN) {{
360 nodes {{
361 number
362 title
363 description
364 }}
365 }}
366 primaryLanguage {{
367 name
368 }}
369 }}
370 }}"#
371 );
372
373 json!({ "query": query })
374}
375
376#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
382pub async fn fetch_issue_with_repo_context(
383 client: &Octocrab,
384 owner: &str,
385 repo: &str,
386 number: u64,
387) -> Result<(IssueNodeDetailed, RepositoryData)> {
388 debug!("Fetching issue with repository context");
389
390 let query = build_issue_with_repo_context_query(owner, repo, number);
391 debug!("Executing GraphQL query for issue with repo context");
392
393 let response: Value = client
394 .graphql(&query)
395 .await
396 .context("Failed to execute GraphQL query")?;
397
398 if let Some(errors) = response.get("errors") {
400 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
401 anyhow::bail!("GraphQL error: {error_msg}");
402 }
403
404 let data = response
405 .get("data")
406 .context("Missing 'data' field in GraphQL response")?;
407
408 let issue_data = data
410 .get("issue")
411 .and_then(|v| v.get("issue"))
412 .context("Issue not found in GraphQL response")?;
413
414 let issue: IssueNodeDetailed =
415 serde_json::from_value(issue_data.clone()).context("Failed to parse issue data")?;
416
417 let repo_data = data
418 .get("repository")
419 .context("Repository not found in GraphQL response")?;
420
421 let repository: RepositoryData =
422 serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
423
424 debug!(
425 issue_number = issue.number,
426 labels_count = repository.labels.nodes.len(),
427 milestones_count = repository.milestones.nodes.len(),
428 "Fetched issue with repository context"
429 );
430
431 Ok((issue, repository))
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn build_query_single_repo() {
440 let repos = [("block", "goose")];
441
442 let query = build_issues_query(&repos);
443 let query_str = query["query"].as_str().unwrap();
444
445 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
446 assert!(query_str.contains("labels: [\"good first issue\"]"));
447 assert!(query_str.contains("states: OPEN"));
448 }
449
450 #[test]
451 fn build_query_multiple_repos() {
452 let repos = [("block", "goose"), ("astral-sh", "ruff")];
453
454 let query = build_issues_query(&repos);
455 let query_str = query["query"].as_str().unwrap();
456
457 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
458 assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
459 }
460
461 #[test]
462 fn build_query_empty_repos() {
463 let repos: [(&str, &str); 0] = [];
464 let query = build_issues_query(&repos);
465 let query_str = query["query"].as_str().unwrap();
466
467 assert_eq!(query_str, "query { }");
468 }
469}