Skip to main content

gn_cli/commands/
import_pr.rs

1use anyhow::{Context, Result};
2use clap::Args;
3use gn_core::namespace::Namespace;
4use gn_core::note::{Note, NoteStatus};
5use gn_core::NotesEngine;
6use std::env;
7use std::process::Command;
8
9#[derive(Args, Debug)]
10pub struct ImportPrArgs {
11    /// Pull Request number
12    #[arg(short, long)]
13    pub pr: u64,
14
15    /// GitHub Personal Access Token (defaults to GITHUB_TOKEN env var)
16    #[arg(short, long)]
17    pub token: Option<String>,
18
19    /// Namespace to store imported comments (default: review)
20    #[arg(short, long, default_value = "review")]
21    pub namespace: String,
22}
23
24pub fn run(args: &ImportPrArgs) -> Result<()> {
25    let token = args
26        .token
27        .clone()
28        .or_else(|| env::var("GITHUB_TOKEN").ok())
29        .context("GitHub token required. Provide via --token or GITHUB_TOKEN env var.")?;
30
31    // Detect owner/repo from git remote
32    let remote_out = Command::new("git")
33        .args(["remote", "get-url", "origin"])
34        .output()
35        .context("Failed to get origin remote URL")?;
36
37    let remote_str = String::from_utf8_lossy(&remote_out.stdout).trim().to_string();
38    let (owner, repo) = parse_github_owner_repo(&remote_str)
39        .context("Could not determine GitHub owner/repo from remote URL")?;
40
41    println!("Importing review comments from {}/{} PR #{}...", owner, repo, args.pr);
42
43    let url = format!(
44        "https://api.github.com/repos/{}/{}/pulls/{}/comments",
45        owner, repo, args.pr
46    );
47
48    let curl_output = Command::new("curl")
49        .args([
50            "-fsSL",
51            "-H",
52            &format!("Authorization: token {}", token),
53            "-H",
54            "Accept: application/vnd.github+json",
55            "-H",
56            "User-Agent: git-notes-cli",
57            &url,
58        ])
59        .output()
60        .context("Failed to run curl to fetch PR comments")?;
61
62    if !curl_output.status.success() {
63        let err = String::from_utf8_lossy(&curl_output.stderr);
64        anyhow::bail!("GitHub API request failed: {}", err);
65    }
66
67    let comments: Vec<serde_json::Value> = serde_json::from_slice(&curl_output.stdout)
68        .context("Failed to parse GitHub comments JSON")?;
69
70    let engine = NotesEngine::new(".");
71    let target_commit = match Command::new("git").args(["rev-parse", "HEAD"]).output() {
72        Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(),
73        Err(_) => "HEAD".to_string(),
74    };
75
76    let mut imported = 0;
77    let ns = Namespace::from_str(&args.namespace);
78
79    for c in &comments {
80        let body = match c.get("body").and_then(|b| b.as_str()) {
81            Some(b) => b.to_string(),
82            None => continue,
83        };
84
85        let file = c.get("path").and_then(|p| p.as_str()).map(|s| s.to_string());
86        let line = c.get("line").and_then(|l| l.as_u64()).map(|l| l as u32);
87        let author = c
88            .get("user")
89            .and_then(|u| u.get("login"))
90            .and_then(|l| l.as_str())
91            .unwrap_or("unknown")
92            .to_string();
93
94        let mut note = Note::new(
95            target_commit.clone(),
96            file,
97            line,
98            line,
99            body,
100            author,
101            ns.clone(),
102        );
103        note.status = NoteStatus::Open;
104
105        if let Ok(_) = engine.write_note(&note) {
106            imported += 1;
107        }
108    }
109
110    println!(
111        "\x1b[32m✔\x1b[0m Successfully imported {} review comment(s) into refs/notes/{}",
112        imported, args.namespace
113    );
114
115    Ok(())
116}
117
118fn parse_github_owner_repo(remote: &str) -> Option<(String, String)> {
119    let clean = remote
120        .trim_end_matches(".git")
121        .replace("git@github.com:", "")
122        .replace("https://github.com/", "")
123        .replace("http://github.com/", "");
124
125    let parts: Vec<&str> = clean.split('/').collect();
126    if parts.len() >= 2 {
127        Some((parts[0].to_string(), parts[1].to_string()))
128    } else {
129        None
130    }
131}