1use std::process::Command;
4
5#[derive(Debug, Clone, PartialEq)]
7pub struct PRComment {
8 pub author: String,
10 pub body: String,
12 pub file_path: Option<String>,
14 pub line: Option<u32>,
16 pub is_review_thread: bool,
18 pub thread_id: Option<String>,
20}
21
22#[derive(Debug, Clone)]
24pub struct PRContext {
25 pub number: u32,
27 pub title: String,
29 pub body: String,
31 pub url: String,
33 pub unresolved_comments: Vec<PRComment>,
35}
36
37#[derive(Debug, Clone)]
39pub enum PRContextResult {
40 Success(PRContext),
42 NoUnresolvedComments {
44 number: u32,
45 title: String,
46 body: String,
47 url: String,
48 },
49 Error(String),
51}
52
53pub fn gather_pr_context(pr_number: u32) -> PRContextResult {
55 let output = match Command::new("gh")
57 .args([
58 "pr",
59 "view",
60 &pr_number.to_string(),
61 "--json",
62 "title,body,url",
63 ])
64 .output()
65 {
66 Ok(o) => o,
67 Err(e) => return PRContextResult::Error(format!("Failed to get PR info: {}", e)),
68 };
69
70 if !output.status.success() {
71 let stderr = String::from_utf8_lossy(&output.stderr);
72 return PRContextResult::Error(format!("Failed to get PR info: {}", stderr.trim()));
73 }
74
75 let stdout = String::from_utf8_lossy(&output.stdout);
76 let parsed: serde_json::Value = match serde_json::from_str(stdout.trim()) {
77 Ok(v) => v,
78 Err(e) => return PRContextResult::Error(format!("Failed to parse PR info: {}", e)),
79 };
80
81 let title = parsed
82 .get("title")
83 .and_then(|v| v.as_str())
84 .unwrap_or("")
85 .to_string();
86 let body = parsed
87 .get("body")
88 .and_then(|v| v.as_str())
89 .unwrap_or("")
90 .to_string();
91 let url = parsed
92 .get("url")
93 .and_then(|v| v.as_str())
94 .unwrap_or("")
95 .to_string();
96
97 let unresolved_comments = gather_unresolved_comments(pr_number);
99
100 if unresolved_comments.is_empty() {
101 return PRContextResult::NoUnresolvedComments {
102 number: pr_number,
103 title,
104 body,
105 url,
106 };
107 }
108
109 PRContextResult::Success(PRContext {
110 number: pr_number,
111 title,
112 body,
113 url,
114 unresolved_comments,
115 })
116}
117
118fn gather_unresolved_comments(pr_number: u32) -> Vec<PRComment> {
120 let mut comments = Vec::new();
121
122 let _output = Command::new("gh")
125 .args([
126 "api",
127 &format!("repos/{{owner}}/{{repo}}/pulls/{}/reviews", pr_number),
128 "--jq",
129 ".[].body",
130 ])
131 .output();
132
133 let graphql_query = format!(
135 r#"{{
136 repository(owner: "{{owner}}", name: "{{repo}}") {{
137 pullRequest(number: {}) {{
138 reviewThreads(first: 100) {{
139 nodes {{
140 id
141 isResolved
142 path
143 line
144 comments(first: 10) {{
145 nodes {{
146 author {{ login }}
147 body
148 }}
149 }}
150 }}
151 }}
152 }}
153 }}
154 }}"#,
155 pr_number
156 );
157
158 let output = match Command::new("gh")
159 .args(["api", "graphql", "-f", &format!("query={}", graphql_query)])
160 .output()
161 {
162 Ok(o) => o,
163 Err(_) => return comments,
164 };
165
166 if !output.status.success() {
167 return comments;
168 }
169
170 let stdout = String::from_utf8_lossy(&output.stdout);
171 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
172 if let Some(threads) = parsed
173 .pointer("/data/repository/pullRequest/reviewThreads/nodes")
174 .and_then(|v| v.as_array())
175 {
176 for thread in threads {
177 let is_resolved = thread
178 .get("isResolved")
179 .and_then(|v| v.as_bool())
180 .unwrap_or(true);
181
182 if is_resolved {
183 continue;
184 }
185
186 let path = thread
187 .get("path")
188 .and_then(|v| v.as_str())
189 .map(|s| s.to_string());
190 let line = thread
191 .get("line")
192 .and_then(|v| v.as_u64())
193 .map(|n| n as u32);
194 let thread_id = thread
195 .get("id")
196 .and_then(|v| v.as_str())
197 .map(|s| s.to_string());
198
199 if let Some(thread_comments) =
200 thread.pointer("/comments/nodes").and_then(|v| v.as_array())
201 {
202 for comment in thread_comments {
203 let author = comment
204 .pointer("/author/login")
205 .and_then(|v| v.as_str())
206 .unwrap_or("unknown")
207 .to_string();
208 let body = comment
209 .get("body")
210 .and_then(|v| v.as_str())
211 .unwrap_or("")
212 .to_string();
213
214 if !body.is_empty() {
215 comments.push(PRComment {
216 author,
217 body,
218 file_path: path.clone(),
219 line,
220 is_review_thread: true,
221 thread_id: thread_id.clone(),
222 });
223 }
224 }
225 }
226 }
227 }
228 }
229
230 comments
231}