Skip to main content

autom8/gh/
context.rs

1//! PR context gathering for reviews.
2
3use std::process::Command;
4
5/// A single comment on a PR
6#[derive(Debug, Clone, PartialEq)]
7pub struct PRComment {
8    /// The author's username
9    pub author: String,
10    /// The comment body text
11    pub body: String,
12    /// The file path if this is a file comment
13    pub file_path: Option<String>,
14    /// The line number if applicable
15    pub line: Option<u32>,
16    /// Whether this is a review thread (vs conversation comment)
17    pub is_review_thread: bool,
18    /// The thread ID for review threads
19    pub thread_id: Option<String>,
20}
21
22/// Full context about a PR for review
23#[derive(Debug, Clone)]
24pub struct PRContext {
25    /// PR number
26    pub number: u32,
27    /// PR title
28    pub title: String,
29    /// PR body/description
30    pub body: String,
31    /// PR URL
32    pub url: String,
33    /// Unresolved comments that need attention
34    pub unresolved_comments: Vec<PRComment>,
35}
36
37/// Result of gathering PR context
38#[derive(Debug, Clone)]
39pub enum PRContextResult {
40    /// Successfully gathered PR context with unresolved comments
41    Success(PRContext),
42    /// PR has no unresolved comments
43    NoUnresolvedComments {
44        number: u32,
45        title: String,
46        body: String,
47        url: String,
48    },
49    /// Error occurred during gathering
50    Error(String),
51}
52
53/// Gather full context for a PR including unresolved comments
54pub fn gather_pr_context(pr_number: u32) -> PRContextResult {
55    // Get basic PR info
56    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    // Gather unresolved comments from review threads
98    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
118/// Gather unresolved comments from a PR
119fn gather_unresolved_comments(pr_number: u32) -> Vec<PRComment> {
120    let mut comments = Vec::new();
121
122    // Get review threads
123    // Note: This API call for review bodies is currently unused but kept for future use
124    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    // Get unresolved review threads using GraphQL
134    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}