1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use tracing::{debug, instrument};
13
14use crate::repos::CuratedRepo;
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(repos: &[CuratedRepo]) -> Value {
84 let fragments: Vec<String> = repos
85 .iter()
86 .enumerate()
87 .map(|(i, repo)| {
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 = repo.owner,
109 name = repo.name
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()))]
123pub async fn fetch_issues(
124 client: &Octocrab,
125 repos: &[CuratedRepo],
126) -> Result<Vec<(String, Vec<IssueNode>)>> {
127 if repos.is_empty() {
128 return Ok(vec![]);
129 }
130
131 let query = build_issues_query(repos);
132 debug!("Executing GraphQL query");
133
134 let response: Value = client
136 .graphql(&query)
137 .await
138 .context("Failed to execute GraphQL query")?;
139
140 if let Some(errors) = response.get("errors") {
142 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
143 anyhow::bail!("GraphQL error: {error_msg}");
144 }
145
146 let data = response
148 .get("data")
149 .context("Missing 'data' field in GraphQL response")?;
150
151 let mut results = Vec::with_capacity(repos.len());
152
153 for i in 0..repos.len() {
154 let key = format!("repo{i}");
155 if let Some(repo_data) = data.get(&key) {
156 if repo_data.is_null() {
158 debug!(repo = key, "Repository not found or inaccessible");
159 continue;
160 }
161
162 let repo_issues: RepoIssues = serde_json::from_value(repo_data.clone())
163 .with_context(|| format!("Failed to parse repository data for {key}"))?;
164
165 let issue_count = repo_issues.issues.nodes.len();
166 if issue_count > 0 {
167 debug!(
168 repo = %repo_issues.name_with_owner,
169 issues = issue_count,
170 "Found issues"
171 );
172 results.push((repo_issues.name_with_owner, repo_issues.issues.nodes));
173 }
174 }
175 }
176
177 debug!(
178 total_repos = results.len(),
179 "Fetched issues from repositories"
180 );
181 Ok(results)
182}
183
184#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct RepoLabelNode {
187 pub name: String,
189 pub description: Option<String>,
191 pub color: String,
193}
194
195#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct RepoLabelsConnection {
198 pub nodes: Vec<RepoLabelNode>,
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
204pub struct RepoMilestoneNode {
205 pub number: u64,
207 pub title: String,
209 pub description: Option<String>,
211}
212
213#[derive(Debug, Clone, Deserialize, Serialize)]
215pub struct RepoMilestonesConnection {
216 pub nodes: Vec<RepoMilestoneNode>,
218}
219
220#[derive(Debug, Clone, Deserialize, Serialize)]
222pub struct IssueCommentNode {
223 pub author: Author,
225 pub body: String,
227}
228
229#[derive(Debug, Clone, Deserialize, Serialize)]
231pub struct Author {
232 pub login: String,
234}
235
236#[derive(Debug, Clone, Deserialize, Serialize)]
238pub struct CommentsConnection {
239 pub nodes: Vec<IssueCommentNode>,
241}
242
243#[derive(Debug, Clone, Deserialize, Serialize)]
245pub struct IssueNodeDetailed {
246 pub number: u64,
248 pub title: String,
250 pub body: Option<String>,
252 pub url: String,
254 pub labels: Labels,
256 pub comments: CommentsConnection,
258}
259
260#[derive(Debug, Clone, Deserialize, Serialize)]
262pub struct RepositoryData {
263 #[serde(rename = "nameWithOwner")]
265 pub name_with_owner: String,
266 pub labels: RepoLabelsConnection,
268 pub milestones: RepoMilestonesConnection,
270 #[serde(rename = "primaryLanguage")]
272 pub primary_language: Option<LanguageNode>,
273 #[serde(rename = "viewerPermission")]
275 pub viewer_permission: Option<ViewerPermission>,
276}
277
278#[derive(Debug, Clone, Deserialize, Serialize)]
280pub struct LanguageNode {
281 pub name: String,
283}
284
285#[derive(Debug, Clone, Deserialize, Serialize)]
287pub struct IssueWithRepoContextResponse {
288 pub issue: IssueNodeDetailed,
290 pub repository: RepositoryData,
292}
293
294fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
296 let query = format!(
297 r#"query {{
298 issue: repository(owner: "{owner}", name: "{repo}") {{
299 issue(number: {number}) {{
300 number
301 title
302 body
303 url
304 labels(first: 10) {{
305 nodes {{
306 name
307 }}
308 }}
309 comments(first: 5) {{
310 nodes {{
311 author {{
312 login
313 }}
314 body
315 }}
316 }}
317 }}
318 }}
319 repository(owner: "{owner}", name: "{repo}") {{
320 nameWithOwner
321 viewerPermission
322 labels(first: 100) {{
323 nodes {{
324 name
325 description
326 color
327 }}
328 }}
329 milestones(first: 50, states: OPEN) {{
330 nodes {{
331 number
332 title
333 description
334 }}
335 }}
336 primaryLanguage {{
337 name
338 }}
339 }}
340 }}"#
341 );
342
343 json!({ "query": query })
344}
345
346#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
352pub async fn fetch_issue_with_repo_context(
353 client: &Octocrab,
354 owner: &str,
355 repo: &str,
356 number: u64,
357) -> Result<(IssueNodeDetailed, RepositoryData)> {
358 debug!("Fetching issue with repository context");
359
360 let query = build_issue_with_repo_context_query(owner, repo, number);
361 debug!("Executing GraphQL query for issue with repo context");
362
363 let response: Value = client
364 .graphql(&query)
365 .await
366 .context("Failed to execute GraphQL query")?;
367
368 if let Some(errors) = response.get("errors") {
370 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
371 anyhow::bail!("GraphQL error: {error_msg}");
372 }
373
374 let data = response
375 .get("data")
376 .context("Missing 'data' field in GraphQL response")?;
377
378 let issue_data = data
380 .get("issue")
381 .and_then(|v| v.get("issue"))
382 .context("Issue not found in GraphQL response")?;
383
384 let issue: IssueNodeDetailed =
385 serde_json::from_value(issue_data.clone()).context("Failed to parse issue data")?;
386
387 let repo_data = data
388 .get("repository")
389 .context("Repository not found in GraphQL response")?;
390
391 let repository: RepositoryData =
392 serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
393
394 debug!(
395 issue_number = issue.number,
396 labels_count = repository.labels.nodes.len(),
397 milestones_count = repository.milestones.nodes.len(),
398 "Fetched issue with repository context"
399 );
400
401 Ok((issue, repository))
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn build_query_single_repo() {
410 let repos = [CuratedRepo {
411 owner: "block",
412 name: "goose",
413 language: "Rust",
414 description: "AI agent",
415 }];
416
417 let query = build_issues_query(&repos);
418 let query_str = query["query"].as_str().unwrap();
419
420 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
421 assert!(query_str.contains("labels: [\"good first issue\"]"));
422 assert!(query_str.contains("states: OPEN"));
423 }
424
425 #[test]
426 fn build_query_multiple_repos() {
427 let repos = [
428 CuratedRepo {
429 owner: "block",
430 name: "goose",
431 language: "Rust",
432 description: "AI agent",
433 },
434 CuratedRepo {
435 owner: "astral-sh",
436 name: "ruff",
437 language: "Rust",
438 description: "Linter",
439 },
440 ];
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("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
447 }
448
449 #[test]
450 fn build_query_empty_repos() {
451 let repos: [CuratedRepo; 0] = [];
452 let query = build_issues_query(&repos);
453 let query_str = query["query"].as_str().unwrap();
454
455 assert_eq!(query_str, "query { }");
456 }
457}