gn_cli/commands/
import_pr.rs1use 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 #[arg(short, long)]
13 pub pr: u64,
14
15 #[arg(short, long)]
17 pub token: Option<String>,
18
19 #[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 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(¬e) {
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}