Skip to main content

autom8/gh/
pr.rs

1//! PR creation and management.
2
3use std::process::Command;
4
5use crate::error::Result;
6use crate::git::{self, PushResult};
7use crate::output::{
8    print_push_already_up_to_date, print_push_success, print_pushing_branch, print_warning,
9};
10use crate::spec::Spec;
11
12use super::detection::{get_existing_pr_number, get_existing_pr_url, pr_exists_for_branch};
13use super::format::{format_pr_description, format_pr_title};
14use super::template::{detect_pr_template, run_template_agent, TemplateAgentResult};
15use super::types::PRResult;
16
17/// Check if the GitHub CLI (gh) is installed and available in PATH
18pub fn is_gh_installed() -> bool {
19    Command::new("gh")
20        .arg("--version")
21        .output()
22        .map(|o| o.status.success())
23        .unwrap_or(false)
24}
25
26/// Check if the user is authenticated with GitHub CLI
27pub fn is_gh_authenticated() -> bool {
28    Command::new("gh")
29        .args(["auth", "status"])
30        .output()
31        .map(|o| o.status.success())
32        .unwrap_or(false)
33}
34
35/// Update the description of an existing pull request
36pub fn update_pr_description(spec: &Spec, pr_number: u32) -> Result<PRResult> {
37    // Check for PR template in the repository
38    let repo_root = std::env::current_dir().unwrap_or_default();
39    if let Some(template_content) = detect_pr_template(&repo_root) {
40        // Template found - use agent path
41        let title = format_pr_title(spec);
42        // Draft flag is not applicable when updating existing PRs
43        match run_template_agent(
44            spec,
45            &template_content,
46            &title,
47            Some(pr_number),
48            false,
49            |_| {},
50        ) {
51            Ok(TemplateAgentResult::Success(url)) => {
52                return Ok(PRResult::Updated(url));
53            }
54            Ok(TemplateAgentResult::Error(error_info)) => {
55                // Agent failed - fall back to generated description
56                print_warning(&format!(
57                    "Template agent failed ({}), using generated description",
58                    error_info.message
59                ));
60            }
61            Err(e) => {
62                // Agent error - fall back to generated description
63                print_warning(&format!(
64                    "Template agent error ({}), using generated description",
65                    e
66                ));
67            }
68        }
69    }
70
71    // No template or agent failed - use current generated description path
72    update_pr_description_direct(spec, pr_number)
73}
74
75/// Update PR description directly using generated format (internal fallback)
76fn update_pr_description_direct(spec: &Spec, pr_number: u32) -> Result<PRResult> {
77    let body = format_pr_description(spec);
78
79    let output = Command::new("gh")
80        .args(["pr", "edit", &pr_number.to_string(), "--body", &body])
81        .output()?;
82
83    if !output.status.success() {
84        let stderr = String::from_utf8_lossy(&output.stderr);
85        return Ok(PRResult::Error(format!(
86            "Failed to update PR: {}",
87            stderr.trim()
88        )));
89    }
90
91    let url_output = Command::new("gh")
92        .args(["pr", "view", &pr_number.to_string(), "--json", "url"])
93        .output()?;
94
95    if url_output.status.success() {
96        let stdout = String::from_utf8_lossy(&url_output.stdout);
97        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
98            if let Some(url) = parsed.get("url").and_then(|v| v.as_str()) {
99                return Ok(PRResult::Updated(url.to_string()));
100            }
101        }
102    }
103
104    Ok(PRResult::Updated(format!("PR #{}", pr_number)))
105}
106
107/// Create a pull request for the current branch using the GitHub CLI
108pub fn create_pull_request(spec: &Spec, commits_were_made: bool, draft: bool) -> Result<PRResult> {
109    if !commits_were_made {
110        return Ok(PRResult::Skipped(
111            "No commits were made in this session".to_string(),
112        ));
113    }
114
115    if !git::is_git_repo() {
116        return Ok(PRResult::Skipped("Not in a git repository".to_string()));
117    }
118
119    if !is_gh_installed() {
120        return Ok(PRResult::Skipped(
121            "GitHub CLI (gh) not installed. Install from https://cli.github.com".to_string(),
122        ));
123    }
124
125    if !is_gh_authenticated() {
126        return Ok(PRResult::Skipped(
127            "Not authenticated with GitHub CLI. Run 'gh auth login' first".to_string(),
128        ));
129    }
130
131    let branch = match git::current_branch() {
132        Ok(b) => b,
133        Err(e) => {
134            return Ok(PRResult::Error(format!(
135                "Failed to get current branch: {}",
136                e
137            )))
138        }
139    };
140
141    if branch == "main" || branch == "master" {
142        return Ok(PRResult::Skipped(format!(
143            "Cannot create PR from {} branch",
144            branch
145        )));
146    }
147
148    // Check if PR already exists
149    if pr_exists_for_branch(&branch)? {
150        // PR exists - update description instead
151        if let Some(pr_number) = get_existing_pr_number(&branch)? {
152            return update_pr_description(spec, pr_number);
153        } else if let Some(url) = get_existing_pr_url(&branch)? {
154            return Ok(PRResult::AlreadyExists(url));
155        }
156        return Ok(PRResult::AlreadyExists(format!("PR exists for {}", branch)));
157    }
158
159    // Ensure branch is pushed
160    let push_result = ensure_branch_pushed(&branch)?;
161    if let PushResult::Error(e) = push_result {
162        return Ok(PRResult::Error(format!("Failed to push branch: {}", e)));
163    }
164
165    // Check for PR template in the repository (after prerequisites pass)
166    let repo_root = std::env::current_dir().unwrap_or_default();
167    if let Some(template_content) = detect_pr_template(&repo_root) {
168        // Template found - use agent path
169        let title = format_pr_title(spec);
170        match run_template_agent(spec, &template_content, &title, None, draft, |_| {}) {
171            Ok(TemplateAgentResult::Success(url)) => {
172                return Ok(PRResult::Success(url));
173            }
174            Ok(TemplateAgentResult::Error(error_info)) => {
175                // Agent failed - fall back to generated description
176                print_warning(&format!(
177                    "Template agent failed ({}), using generated description",
178                    error_info.message
179                ));
180            }
181            Err(e) => {
182                // Agent error - fall back to generated description
183                print_warning(&format!(
184                    "Template agent error ({}), using generated description",
185                    e
186                ));
187            }
188        }
189    }
190
191    // No template or agent failed - use current generated description path
192    create_pull_request_direct(spec, draft)
193}
194
195#[cfg(test)]
196fn build_pr_create_args<'a>(title: &'a str, body: &'a str, draft: bool) -> Vec<&'a str> {
197    let mut args = vec!["pr", "create", "--title", title, "--body", body];
198    if draft {
199        args.push("--draft");
200    }
201    args
202}
203
204/// Create PR directly using generated format (internal fallback)
205fn create_pull_request_direct(spec: &Spec, draft: bool) -> Result<PRResult> {
206    let title = format_pr_title(spec);
207    let body = format_pr_description(spec);
208
209    let mut args = vec!["pr", "create", "--title", &title, "--body", &body];
210    if draft {
211        args.push("--draft");
212    }
213
214    let output = Command::new("gh").args(&args).output()?;
215
216    if !output.status.success() {
217        let stderr = String::from_utf8_lossy(&output.stderr);
218        return Ok(PRResult::Error(format!(
219            "Failed to create PR: {}",
220            stderr.trim()
221        )));
222    }
223
224    let stdout = String::from_utf8_lossy(&output.stdout);
225    let url = stdout.trim().to_string();
226
227    Ok(PRResult::Success(url))
228}
229
230/// Ensure the current branch is pushed to the remote
231pub fn ensure_branch_pushed(branch: &str) -> Result<PushResult> {
232    print_pushing_branch(branch);
233    let result = git::push_branch(branch)?;
234
235    match &result {
236        PushResult::Success => print_push_success(),
237        PushResult::AlreadyUpToDate => print_push_already_up_to_date(),
238        PushResult::Error(_) => {}
239    }
240
241    Ok(result)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::spec::UserStory;
248
249    fn make_test_spec() -> Spec {
250        Spec {
251            project: "TestProject".to_string(),
252            branch_name: "feature/test".to_string(),
253            description: "Test feature description.".to_string(),
254            user_stories: vec![UserStory {
255                id: "US-001".to_string(),
256                title: "Test Story".to_string(),
257                description: "Test story description".to_string(),
258                acceptance_criteria: vec!["Criterion 1".to_string()],
259                priority: 1,
260                passes: true,
261                notes: String::new(),
262            }],
263        }
264    }
265
266    // ========================================================================
267    // PRResult variant tests
268    // ========================================================================
269
270    #[test]
271    fn test_pr_result_success_variant() {
272        let result = PRResult::Success("https://github.com/owner/repo/pull/1".to_string());
273        assert!(matches!(result, PRResult::Success(_)));
274    }
275
276    #[test]
277    fn test_pr_result_updated_variant() {
278        let result = PRResult::Updated("https://github.com/owner/repo/pull/1".to_string());
279        assert!(matches!(result, PRResult::Updated(_)));
280    }
281
282    #[test]
283    fn test_pr_result_already_exists_variant() {
284        let result = PRResult::AlreadyExists("https://github.com/owner/repo/pull/1".to_string());
285        assert!(matches!(result, PRResult::AlreadyExists(_)));
286    }
287
288    #[test]
289    fn test_pr_result_skipped_variant() {
290        let result = PRResult::Skipped("reason".to_string());
291        assert!(matches!(result, PRResult::Skipped(_)));
292    }
293
294    #[test]
295    fn test_pr_result_error_variant() {
296        let result = PRResult::Error("error message".to_string());
297        assert!(matches!(result, PRResult::Error(_)));
298    }
299
300    // ========================================================================
301    // create_pull_request prerequisite tests
302    // ========================================================================
303
304    #[test]
305    fn test_create_pr_skips_when_no_commits() {
306        let spec = make_test_spec();
307        let result = create_pull_request(&spec, false, false);
308        assert!(result.is_ok());
309        match result.unwrap() {
310            PRResult::Skipped(msg) => {
311                assert!(msg.contains("No commits"));
312            }
313            _ => panic!("Expected Skipped result"),
314        }
315    }
316
317    // ========================================================================
318    // Template integration behavior tests (unit tests without mocking)
319    // ========================================================================
320
321    #[test]
322    fn test_detect_pr_template_integration_no_template_in_test_dir() {
323        // When running tests, there's no PR template in the repo root (or temp dir)
324        // This verifies the integration path where no template is found
325        use tempfile::TempDir;
326        let temp_dir = TempDir::new().unwrap();
327        let result = detect_pr_template(temp_dir.path());
328        assert!(result.is_none());
329    }
330
331    #[test]
332    fn test_detect_pr_template_integration_with_template() {
333        use std::fs;
334        use tempfile::TempDir;
335
336        let temp_dir = TempDir::new().unwrap();
337        let github_dir = temp_dir.path().join(".github");
338        fs::create_dir_all(&github_dir).unwrap();
339        fs::write(
340            github_dir.join("pull_request_template.md"),
341            "## Description\n\nPlease describe your changes.",
342        )
343        .unwrap();
344
345        let result = detect_pr_template(temp_dir.path());
346        assert!(result.is_some());
347        assert!(result.unwrap().contains("Description"));
348    }
349
350    // ========================================================================
351    // Direct function tests (internal fallback functions)
352    // ========================================================================
353
354    #[test]
355    fn test_create_pull_request_direct_builds_correct_command_args() {
356        // This test verifies the direct function exists and can format title/body
357        // We can't actually run the gh command in unit tests, but we verify
358        // the function compiles and the format functions work correctly
359        let spec = make_test_spec();
360        let title = format_pr_title(&spec);
361        let body = format_pr_description(&spec);
362
363        assert!(title.contains("TestProject"));
364        assert!(body.contains("Summary"));
365        assert!(body.contains("Test feature description"));
366    }
367
368    #[test]
369    fn test_update_pr_description_direct_builds_correct_command_args() {
370        // Verify the format functions work correctly for updates
371        let spec = make_test_spec();
372        let body = format_pr_description(&spec);
373
374        assert!(body.contains("Summary"));
375        assert!(body.contains("US-001"));
376        assert!(body.contains("Test Story"));
377    }
378
379    // ========================================================================
380    // Draft flag tests
381    // ========================================================================
382
383    #[test]
384    fn test_build_pr_create_args_without_draft() {
385        let title = "Test PR Title";
386        let body = "Test PR body content";
387        let args = build_pr_create_args(title, body, false);
388
389        assert_eq!(args, vec!["pr", "create", "--title", title, "--body", body]);
390        assert!(!args.contains(&"--draft"));
391    }
392
393    #[test]
394    fn test_build_pr_create_args_with_draft() {
395        let title = "Test PR Title";
396        let body = "Test PR body content";
397        let args = build_pr_create_args(title, body, true);
398
399        assert_eq!(
400            args,
401            vec!["pr", "create", "--title", title, "--body", body, "--draft"]
402        );
403        assert!(args.contains(&"--draft"));
404    }
405
406    // ========================================================================
407    // gh CLI detection tests
408    // ========================================================================
409
410    #[test]
411    fn test_is_gh_installed_returns_bool() {
412        // This test just verifies the function doesn't panic
413        // Result depends on whether gh is installed in the test environment
414        let _ = is_gh_installed();
415    }
416
417    #[test]
418    fn test_is_gh_authenticated_returns_bool() {
419        // This test just verifies the function doesn't panic
420        // Result depends on whether gh is authenticated in the test environment
421        let _ = is_gh_authenticated();
422    }
423}