1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use tracing::{debug, instrument};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
16#[serde(rename_all = "UPPERCASE")]
17pub enum ViewerPermission {
18 Admin,
20 Maintain,
22 Write,
24 Triage,
26 Read,
28}
29
30#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct IssueNode {
33 pub number: u64,
35 pub title: String,
37 #[serde(rename = "createdAt")]
39 pub created_at: String,
40 pub labels: Labels,
42 #[allow(dead_code)]
44 pub url: String,
45}
46
47#[derive(Debug, Clone, Deserialize, Serialize)]
49pub struct Labels {
50 pub nodes: Vec<LabelNode>,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
56pub struct LabelNode {
57 pub name: String,
59}
60
61#[derive(Debug, Deserialize)]
63pub struct RepoIssues {
64 #[serde(rename = "nameWithOwner")]
66 pub name_with_owner: String,
67 pub issues: IssuesConnection,
69}
70
71#[derive(Debug, Deserialize)]
73pub struct IssuesConnection {
74 pub nodes: Vec<IssueNode>,
76}
77
78fn build_issues_query<R: AsRef<str>>(repos: &[(R, R)]) -> Value {
82 let fragments: Vec<String> = repos
83 .iter()
84 .enumerate()
85 .map(|(i, (owner, name))| {
86 format!(
87 r#"repo{i}: repository(owner: "{owner}", name: "{name}") {{
88 nameWithOwner
89 issues(
90 first: 10
91 states: OPEN
92 labels: ["good first issue"]
93 filterBy: {{ assignee: null }}
94 orderBy: {{ field: CREATED_AT, direction: DESC }}
95 ) {{
96 nodes {{
97 number
98 title
99 createdAt
100 labels(first: 5) {{ nodes {{ name }} }}
101 url
102 }}
103 }}
104 }}"#,
105 i = i,
106 owner = owner.as_ref(),
107 name = name.as_ref()
108 )
109 })
110 .collect();
111
112 let query = format!("query {{ {} }}", fragments.join("\n"));
113 debug!(query_length = query.len(), "Built GraphQL query");
114 json!({ "query": query })
115}
116
117#[instrument(skip(client, repos), fields(repo_count = repos.len()))]
122pub async fn fetch_issues<R: AsRef<str>>(
123 client: &Octocrab,
124 repos: &[(R, R)],
125) -> Result<Vec<(String, Vec<IssueNode>)>> {
126 if repos.is_empty() {
127 return Ok(vec![]);
128 }
129
130 let query = build_issues_query(repos);
131 debug!("Executing GraphQL query");
132
133 let response: Value = client
135 .graphql(&query)
136 .await
137 .context("Failed to execute GraphQL query")?;
138
139 if let Some(errors) = response.get("errors") {
141 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
142 anyhow::bail!("GraphQL error: {error_msg}");
143 }
144
145 let data = response
147 .get("data")
148 .context("Missing 'data' field in GraphQL response")?;
149
150 let mut results = Vec::with_capacity(repos.len());
151
152 for i in 0..repos.len() {
153 let key = format!("repo{i}");
154 if let Some(repo_data) = data.get(&key) {
155 if repo_data.is_null() {
157 debug!(repo = key, "Repository not found or inaccessible");
158 continue;
159 }
160
161 let repo_issues: RepoIssues = serde_json::from_value(repo_data.clone())
162 .with_context(|| format!("Failed to parse repository data for {key}"))?;
163
164 let issue_count = repo_issues.issues.nodes.len();
165 if issue_count > 0 {
166 debug!(
167 repo = %repo_issues.name_with_owner,
168 issues = issue_count,
169 "Found issues"
170 );
171 results.push((repo_issues.name_with_owner, repo_issues.issues.nodes));
172 }
173 }
174 }
175
176 debug!(
177 total_repos = results.len(),
178 "Fetched issues from repositories"
179 );
180 Ok(results)
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize)]
185pub struct RepoLabelNode {
186 pub name: String,
188 pub description: Option<String>,
190 pub color: String,
192}
193
194#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct RepoLabelsConnection {
197 pub nodes: Vec<RepoLabelNode>,
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct RepoMilestoneNode {
204 pub number: u64,
206 pub title: String,
208 pub description: Option<String>,
210}
211
212#[derive(Debug, Clone, Deserialize, Serialize)]
214pub struct RepoMilestonesConnection {
215 pub nodes: Vec<RepoMilestoneNode>,
217}
218
219#[derive(Debug, Clone, Deserialize, Serialize)]
221pub struct IssueCommentNode {
222 pub author: Author,
224 pub body: String,
226}
227
228#[derive(Debug, Clone, Deserialize, Serialize)]
230pub struct Author {
231 pub login: String,
233}
234
235#[derive(Debug, Clone, Deserialize, Serialize)]
237pub struct CommentsConnection {
238 pub nodes: Vec<IssueCommentNode>,
240}
241
242#[derive(Debug, Clone, Deserialize, Serialize)]
244pub struct IssueNodeDetailed {
245 pub number: u64,
247 pub title: String,
249 pub body: Option<String>,
251 pub url: String,
253 pub labels: Labels,
255 pub comments: CommentsConnection,
257}
258
259#[derive(Debug, Clone, Deserialize, Serialize)]
261pub struct RepositoryData {
262 #[serde(rename = "nameWithOwner")]
264 pub name_with_owner: String,
265 pub labels: RepoLabelsConnection,
267 pub milestones: RepoMilestonesConnection,
269 #[serde(rename = "primaryLanguage")]
271 pub primary_language: Option<LanguageNode>,
272 #[serde(rename = "viewerPermission")]
274 pub viewer_permission: Option<ViewerPermission>,
275}
276
277#[derive(Debug, Clone, Deserialize, Serialize)]
279pub struct LanguageNode {
280 pub name: String,
282}
283
284#[derive(Debug, Clone, Deserialize, Serialize)]
286pub struct IssueWithRepoContextResponse {
287 pub issue: IssueNodeDetailed,
289 pub repository: RepositoryData,
291}
292
293fn build_issue_with_repo_context_query(owner: &str, repo: &str, number: u64) -> Value {
295 let query = format!(
296 r#"query {{
297 issue: repository(owner: "{owner}", name: "{repo}") {{
298 issue(number: {number}) {{
299 number
300 title
301 body
302 url
303 labels(first: 10) {{
304 nodes {{
305 name
306 }}
307 }}
308 comments(first: 5) {{
309 nodes {{
310 author {{
311 login
312 }}
313 body
314 }}
315 }}
316 }}
317 }}
318 repository(owner: "{owner}", name: "{repo}") {{
319 nameWithOwner
320 viewerPermission
321 labels(first: 100) {{
322 nodes {{
323 name
324 description
325 color
326 }}
327 }}
328 milestones(first: 50, states: OPEN) {{
329 nodes {{
330 number
331 title
332 description
333 }}
334 }}
335 primaryLanguage {{
336 name
337 }}
338 }}
339 }}"#
340 );
341
342 json!({ "query": query })
343}
344
345#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
351pub async fn fetch_issue_with_repo_context(
352 client: &Octocrab,
353 owner: &str,
354 repo: &str,
355 number: u64,
356) -> Result<(IssueNodeDetailed, RepositoryData)> {
357 debug!("Fetching issue with repository context");
358
359 let query = build_issue_with_repo_context_query(owner, repo, number);
360 debug!("Executing GraphQL query for issue with repo context");
361
362 let response: Value = client
363 .graphql(&query)
364 .await
365 .context("Failed to execute GraphQL query")?;
366
367 if let Some(errors) = response.get("errors") {
369 let error_msg = serde_json::to_string_pretty(errors).unwrap_or_default();
370 anyhow::bail!("GraphQL error: {error_msg}");
371 }
372
373 let data = response
374 .get("data")
375 .context("Missing 'data' field in GraphQL response")?;
376
377 let issue_data = data
379 .get("issue")
380 .and_then(|v| v.get("issue"))
381 .context("Issue not found in GraphQL response")?;
382
383 let issue: IssueNodeDetailed =
384 serde_json::from_value(issue_data.clone()).context("Failed to parse issue data")?;
385
386 let repo_data = data
387 .get("repository")
388 .context("Repository not found in GraphQL response")?;
389
390 let repository: RepositoryData =
391 serde_json::from_value(repo_data.clone()).context("Failed to parse repository data")?;
392
393 debug!(
394 issue_number = issue.number,
395 labels_count = repository.labels.nodes.len(),
396 milestones_count = repository.milestones.nodes.len(),
397 "Fetched issue with repository context"
398 );
399
400 Ok((issue, repository))
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn build_query_single_repo() {
409 let repos = [("block", "goose")];
410
411 let query = build_issues_query(&repos);
412 let query_str = query["query"].as_str().unwrap();
413
414 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
415 assert!(query_str.contains("labels: [\"good first issue\"]"));
416 assert!(query_str.contains("states: OPEN"));
417 }
418
419 #[test]
420 fn build_query_multiple_repos() {
421 let repos = [("block", "goose"), ("astral-sh", "ruff")];
422
423 let query = build_issues_query(&repos);
424 let query_str = query["query"].as_str().unwrap();
425
426 assert!(query_str.contains("repo0: repository(owner: \"block\", name: \"goose\")"));
427 assert!(query_str.contains("repo1: repository(owner: \"astral-sh\", name: \"ruff\")"));
428 }
429
430 #[test]
431 fn build_query_empty_repos() {
432 let repos: [(&str, &str); 0] = [];
433 let query = build_issues_query(&repos);
434 let query_str = query["query"].as_str().unwrap();
435
436 assert_eq!(query_str, "query { }");
437 }
438}