Skip to main content

gn_cli/commands/
import.rs

1use anyhow::{Context, Result};
2use clap::{Args, Subcommand};
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 ImportArgs {
11    #[command(subcommand)]
12    pub command: ImportCommands,
13}
14
15#[derive(Subcommand, Debug)]
16pub enum ImportCommands {
17    /// Import review comments from a GitHub Pull Request [wrap/delegate]
18    Pr(super::import_pr::ImportPrArgs),
19
20    /// Import discussion notes and comments from a GitLab Merge Request
21    Gitlab(GitlabImportArgs),
22
23    /// Import comments and discussions from a Bitbucket Cloud Pull Request
24    Bitbucket(BitbucketImportArgs),
25
26    /// Import issue comments from a Jira instance
27    Jira(JiraImportArgs),
28}
29
30#[derive(Args, Debug, Clone)]
31pub struct GitlabImportArgs {
32    /// Merge Request IID
33    #[arg(short, long)]
34    pub mr: u64,
35
36    /// GitLab Project ID or URL-encoded path (e.g. group/repo or numeric ID)
37    #[arg(short, long)]
38    pub project_id: Option<String>,
39
40    /// GitLab Personal Access Token / Bearer token (defaults to GITLAB_TOKEN env var)
41    #[arg(short, long)]
42    pub token: Option<String>,
43
44    /// GitLab instance host (default: gitlab.com)
45    #[arg(long, default_value = "gitlab.com")]
46    pub host: Option<String>,
47
48    /// Namespace to store imported comments (default: review)
49    #[arg(short, long, default_value = "review")]
50    pub namespace: Option<String>,
51}
52
53#[derive(Args, Debug, Clone)]
54pub struct BitbucketImportArgs {
55    /// Bitbucket Pull Request ID
56    #[arg(short, long)]
57    pub pr: u64,
58
59    /// Repository slug
60    #[arg(short, long)]
61    pub repo: Option<String>,
62
63    /// Bitbucket workspace
64    #[arg(short, long)]
65    pub workspace: Option<String>,
66
67    /// Bitbucket App Password / Access Token (defaults to BITBUCKET_TOKEN env var)
68    #[arg(short, long)]
69    pub token: Option<String>,
70
71    /// Namespace to store imported comments (default: review)
72    #[arg(short, long, default_value = "review")]
73    pub namespace: Option<String>,
74}
75
76#[derive(Args, Debug, Clone)]
77pub struct JiraImportArgs {
78    /// Jira Issue ID or Key (e.g. PROJ-123)
79    #[arg(short, long)]
80    pub issue: String,
81
82    /// Jira host URL (e.g. https://your-domain.atlassian.net)
83    #[arg(long)]
84    pub host: String,
85
86    /// Jira API token (defaults to JIRA_TOKEN or JIRA_API_TOKEN env var)
87    #[arg(short, long)]
88    pub token: Option<String>,
89
90    /// Namespace to store imported comments (default: review)
91    #[arg(short, long, default_value = "review")]
92    pub namespace: Option<String>,
93}
94
95pub fn run(args: &ImportArgs) -> Result<()> {
96    match &args.command {
97        ImportCommands::Pr(pr_args) => super::import_pr::run(pr_args),
98        ImportCommands::Gitlab(gl_args) => run_gitlab(gl_args),
99        ImportCommands::Bitbucket(bb_args) => run_bitbucket(bb_args),
100        ImportCommands::Jira(jira_args) => run_jira(jira_args),
101    }
102}
103
104// -----------------------------------------------------------------------------
105// GitLab Import
106// -----------------------------------------------------------------------------
107
108pub fn run_gitlab(args: &GitlabImportArgs) -> Result<()> {
109    let token = args
110        .token
111        .clone()
112        .or_else(|| env::var("GITLAB_TOKEN").ok())
113        .context("GitLab token required. Provide via --token or GITLAB_TOKEN env var.")?;
114
115    let host = args.host.as_deref().unwrap_or("gitlab.com");
116    let host = host.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');
117
118    let project_id = match &args.project_id {
119        Some(pid) => pid.clone(),
120        None => {
121            // Detect from git remote origin
122            let remote_url = get_git_remote_url("origin")?;
123            parse_gitlab_project(&remote_url, host)
124                .context("Could not determine GitLab project from git remote. Please provide --project-id.")?
125        }
126    };
127
128    let encoded_project_id = urlencoding(&project_id);
129    let url = format!(
130        "https://{}/api/v4/projects/{}/merge_requests/{}/discussions",
131        host, encoded_project_id, args.mr
132    );
133
134    println!(
135        "Importing review discussions from GitLab [{}] MR !{}...",
136        project_id, args.mr
137    );
138
139    let curl_args = vec![
140        "-fsSL".to_string(),
141        "-H".to_string(),
142        format!("PRIVATE-TOKEN: {}", token),
143        "-H".to_string(),
144        "Accept: application/json".to_string(),
145        "-H".to_string(),
146        "User-Agent: git-notes-cli".to_string(),
147        url,
148    ];
149
150    let output = Command::new("curl")
151        .args(&curl_args)
152        .output()
153        .context("Failed to execute curl command")?;
154
155    if !output.status.success() {
156        let err = String::from_utf8_lossy(&output.stderr);
157        anyhow::bail!("GitLab API request failed: {}", err);
158    }
159
160    let discussions: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)
161        .context("Failed to parse GitLab discussions JSON")?;
162
163    let engine = NotesEngine::new(".");
164    let target_commit = get_head_commit();
165    let namespace_name = args.namespace.as_deref().unwrap_or("review");
166    let ns = Namespace::from_str(namespace_name);
167
168    let mut imported = 0;
169
170    for discussion in &discussions {
171        let notes_array = match discussion.get("notes").and_then(|n| n.as_array()) {
172            Some(arr) => arr,
173            None => continue,
174        };
175
176        for gl_note in notes_array {
177            // Check if note is system note (e.g. status changes), skip if system
178            if gl_note.get("system").and_then(|s| s.as_bool()).unwrap_or(false) {
179                continue;
180            }
181
182            let body = match gl_note.get("body").and_then(|b| b.as_str()) {
183                Some(b) => b.to_string(),
184                None => continue,
185            };
186
187            let position = gl_note.get("position");
188            let file = position
189                .and_then(|p| p.get("new_path").or_else(|| p.get("old_path")))
190                .and_then(|p| p.as_str())
191                .map(|s| s.to_string());
192
193            let line = position
194                .and_then(|p| p.get("new_line").or_else(|| p.get("old_line")))
195                .and_then(|l| l.as_u64())
196                .map(|l| l as u32);
197
198            let commit_sha = position
199                .and_then(|p| p.get("head_sha"))
200                .and_then(|s| s.as_str())
201                .map(|s| s.to_string())
202                .unwrap_or_else(|| target_commit.clone());
203
204            let author_name = gl_note
205                .get("author")
206                .and_then(|a| a.get("name").or_else(|| a.get("username")))
207                .and_then(|n| n.as_str())
208                .unwrap_or("unknown")
209                .to_string();
210
211            let mut note = Note::new(
212                commit_sha,
213                file,
214                line,
215                line,
216                body,
217                author_name,
218                ns.clone(),
219            );
220
221            let resolved = gl_note.get("resolved").and_then(|r| r.as_bool()).unwrap_or(false);
222            note.status = if resolved {
223                NoteStatus::Resolved
224            } else {
225                NoteStatus::Open
226            };
227
228            if engine.write_note(&note).is_ok() {
229                imported += 1;
230            }
231        }
232    }
233
234    println!(
235        "\x1b[32m✔\x1b[0m Successfully imported {} GitLab discussion comment(s) into refs/notes/{}",
236        imported, namespace_name
237    );
238
239    Ok(())
240}
241
242// -----------------------------------------------------------------------------
243// Bitbucket Import
244// -----------------------------------------------------------------------------
245
246pub fn run_bitbucket(args: &BitbucketImportArgs) -> Result<()> {
247    let token = args
248        .token
249        .clone()
250        .or_else(|| env::var("BITBUCKET_TOKEN").ok())
251        .context("Bitbucket token required. Provide via --token or BITBUCKET_TOKEN env var.")?;
252
253    let (workspace, repo) = match (&args.workspace, &args.repo) {
254        (Some(w), Some(r)) => (w.clone(), r.clone()),
255        _ => {
256            let remote_url = get_git_remote_url("origin")?;
257            let (w, r) = parse_bitbucket_workspace_repo(&remote_url)
258                .context("Could not determine Bitbucket workspace/repo from git remote. Please provide --workspace and --repo.")?;
259            (args.workspace.clone().unwrap_or(w), args.repo.clone().unwrap_or(r))
260        }
261    };
262
263    println!(
264        "Importing review comments from Bitbucket {}/{} PR #{}...",
265        workspace, repo, args.pr
266    );
267
268    let url = format!(
269        "https://api.bitbucket.org/2.0/repositories/{}/{}/pullrequests/{}/comments",
270        workspace, repo, args.pr
271    );
272
273    let auth_header = if token.contains(':') {
274        // Basic auth user:app_password
275        use base64_helper::base64_encode;
276        format!("Basic {}", base64_encode(token.as_bytes()))
277    } else {
278        // Bearer token
279        format!("Bearer {}", token)
280    };
281
282    let output = Command::new("curl")
283        .args([
284            "-fsSL",
285            "-H",
286            &format!("Authorization: {}", auth_header),
287            "-H",
288            "Accept: application/json",
289            "-H",
290            "User-Agent: git-notes-cli",
291            &url,
292        ])
293        .output()
294        .context("Failed to execute curl command")?;
295
296    if !output.status.success() {
297        let err = String::from_utf8_lossy(&output.stderr);
298        anyhow::bail!("Bitbucket API request failed: {}", err);
299    }
300
301    let json_resp: serde_json::Value = serde_json::from_slice(&output.stdout)
302        .context("Failed to parse Bitbucket comments JSON")?;
303
304    let comments = json_resp
305        .get("values")
306        .and_then(|v| v.as_array())
307        .context("Bitbucket response missing 'values' array")?;
308
309    let engine = NotesEngine::new(".");
310    let target_commit = get_head_commit();
311    let namespace_name = args.namespace.as_deref().unwrap_or("review");
312    let ns = Namespace::from_str(namespace_name);
313
314    let mut imported = 0;
315
316    for c in comments {
317        if c.get("deleted").and_then(|d| d.as_bool()).unwrap_or(false) {
318            continue;
319        }
320
321        let body = match c.get("content").and_then(|cnt| cnt.get("raw")).and_then(|r| r.as_str()) {
322            Some(b) => b.to_string(),
323            None => continue,
324        };
325
326        let inline = c.get("inline");
327        let file = inline
328            .and_then(|i| i.get("path"))
329            .and_then(|p| p.as_str())
330            .map(|s| s.to_string());
331
332        let line = inline
333            .and_then(|i| i.get("to").or_else(|| i.get("from")))
334            .and_then(|l| l.as_u64())
335            .map(|l| l as u32);
336
337        let author_name = c
338            .get("user")
339            .and_then(|u| u.get("display_name").or_else(|| u.get("nickname")).or_else(|| u.get("account_id")))
340            .and_then(|n| n.as_str())
341            .unwrap_or("unknown")
342            .to_string();
343
344        let mut note = Note::new(
345            target_commit.clone(),
346            file,
347            line,
348            line,
349            body,
350            author_name,
351            ns.clone(),
352        );
353        note.status = NoteStatus::Open;
354
355        if engine.write_note(&note).is_ok() {
356            imported += 1;
357        }
358    }
359
360    println!(
361        "\x1b[32m✔\x1b[0m Successfully imported {} Bitbucket comment(s) into refs/notes/{}",
362        imported, namespace_name
363    );
364
365    Ok(())
366}
367
368// -----------------------------------------------------------------------------
369// Jira Import
370// -----------------------------------------------------------------------------
371
372pub fn run_jira(args: &JiraImportArgs) -> Result<()> {
373    let token = args
374        .token
375        .clone()
376        .or_else(|| env::var("JIRA_TOKEN").ok())
377        .or_else(|| env::var("JIRA_API_TOKEN").ok())
378        .context("Jira API token required. Provide via --token or JIRA_TOKEN / JIRA_API_TOKEN env var.")?;
379
380    let host = args.host.trim_end_matches('/');
381    let host_url = if !host.starts_with("http://") && !host.starts_with("https://") {
382        format!("https://{}", host)
383    } else {
384        host.to_string()
385    };
386
387    println!(
388        "Importing comments from Jira issue [{}] at {}...",
389        args.issue, host_url
390    );
391
392    let url = format!("{}/rest/api/3/issue/{}/comment", host_url, args.issue);
393
394    let auth_header = if token.contains(':') {
395        use base64_helper::base64_encode;
396        format!("Basic {}", base64_encode(token.as_bytes()))
397    } else if let Ok(email) = env::var("JIRA_EMAIL") {
398        use base64_helper::base64_encode;
399        format!("Basic {}", base64_encode(format!("{}:{}", email, token).as_bytes()))
400    } else {
401        format!("Bearer {}", token)
402    };
403
404    let output = Command::new("curl")
405        .args([
406            "-fsSL",
407            "-H",
408            &format!("Authorization: {}", auth_header),
409            "-H",
410            "Accept: application/json",
411            "-H",
412            "User-Agent: git-notes-cli",
413            &url,
414        ])
415        .output()
416        .context("Failed to execute curl command")?;
417
418    if !output.status.success() {
419        let err = String::from_utf8_lossy(&output.stderr);
420        anyhow::bail!("Jira API request failed: {}", err);
421    }
422
423    let json_resp: serde_json::Value = serde_json::from_slice(&output.stdout)
424        .context("Failed to parse Jira comments JSON")?;
425
426    let comments = json_resp
427        .get("comments")
428        .and_then(|v| v.as_array())
429        .context("Jira response missing 'comments' array")?;
430
431    let engine = NotesEngine::new(".");
432    let target_commit = get_head_commit();
433    let namespace_name = args.namespace.as_deref().unwrap_or("review");
434    let ns = Namespace::from_str(namespace_name);
435
436    let mut imported = 0;
437
438    for c in comments {
439        let body_val = c.get("body");
440        let body_str = extract_jira_comment_body(body_val);
441
442        if body_str.trim().is_empty() {
443            continue;
444        }
445
446        let body_with_prefix = format!("[Jira {}] {}", args.issue, body_str);
447
448        let author_name = c
449            .get("author")
450            .and_then(|a| a.get("displayName").or_else(|| a.get("emailAddress")).or_else(|| a.get("accountId")))
451            .and_then(|n| n.as_str())
452            .unwrap_or("unknown")
453            .to_string();
454
455        let mut note = Note::new(
456            target_commit.clone(),
457            None,
458            None,
459            None,
460            body_with_prefix,
461            author_name,
462            ns.clone(),
463        );
464        note.status = NoteStatus::Open;
465
466        if engine.write_note(&note).is_ok() {
467            imported += 1;
468        }
469    }
470
471    println!(
472        "\x1b[32m✔\x1b[0m Successfully imported {} Jira comment(s) into refs/notes/{}",
473        imported, namespace_name
474    );
475
476    Ok(())
477}
478
479// -----------------------------------------------------------------------------
480// Helpers & Parsers
481// -----------------------------------------------------------------------------
482
483fn get_head_commit() -> String {
484    match Command::new("git").args(["rev-parse", "HEAD"]).output() {
485        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
486        _ => "HEAD".to_string(),
487    }
488}
489
490fn get_git_remote_url(remote: &str) -> Result<String> {
491    let out = Command::new("git")
492        .args(["remote", "get-url", remote])
493        .output()
494        .context("Failed to get remote URL")?;
495    if !out.status.success() {
496        anyhow::bail!("git remote get-url {} exited with non-zero status", remote);
497    }
498    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
499}
500
501pub fn parse_gitlab_project(remote: &str, host: &str) -> Option<String> {
502    let clean = remote
503        .trim_end_matches(".git")
504        .replace(&format!("git@{}:", host), "")
505        .replace(&format!("https://{}/", host), "")
506        .replace(&format!("http://{}/", host), "");
507    let clean = clean.trim_start_matches('/');
508    if clean.is_empty() {
509        None
510    } else {
511        Some(clean.to_string())
512    }
513}
514
515pub fn parse_bitbucket_workspace_repo(remote: &str) -> Option<(String, String)> {
516    let clean = remote
517        .trim_end_matches(".git")
518        .replace("git@bitbucket.org:", "")
519        .replace("https://bitbucket.org/", "")
520        .replace("http://bitbucket.org/", "");
521    let clean = clean.trim_start_matches('/');
522    let parts: Vec<&str> = clean.split('/').collect();
523    if parts.len() >= 2 {
524        Some((parts[0].to_string(), parts[1].to_string()))
525    } else {
526        None
527    }
528}
529
530fn urlencoding(s: &str) -> String {
531    let mut encoded = String::new();
532    for b in s.bytes() {
533        match b {
534            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
535                encoded.push(b as char);
536            }
537            _ => {
538                encoded.push_str(&format!("%{:02X}", b));
539            }
540        }
541    }
542    encoded
543}
544
545fn extract_jira_comment_body(val: Option<&serde_json::Value>) -> String {
546    let val = match val {
547        Some(v) => v,
548        None => return String::new(),
549    };
550
551    if let Some(s) = val.as_str() {
552        return s.to_string();
553    }
554
555    // Atlassian Document Format (ADF) handling:
556    // { "type": "doc", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "..." } ] } ] }
557    let mut text_acc = String::new();
558    extract_adf_text(val, &mut text_acc);
559    text_acc
560}
561
562fn extract_adf_text(val: &serde_json::Value, acc: &mut String) {
563    if let Some(t) = val.get("text").and_then(|t| t.as_str()) {
564        acc.push_str(t);
565    }
566    if let Some(content) = val.get("content").and_then(|c| c.as_array()) {
567        for child in content {
568            extract_adf_text(child, acc);
569            if child.get("type").and_then(|t| t.as_str()) == Some("paragraph") {
570                acc.push('\n');
571            }
572        }
573    }
574}
575
576mod base64_helper {
577    const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
578
579    pub fn base64_encode(input: &[u8]) -> String {
580        let mut result = String::new();
581        let len = input.len();
582        let mut i = 0;
583
584        while i < len {
585            let b0 = input[i];
586            let b1 = if i + 1 < len { input[i + 1] } else { 0 };
587            let b2 = if i + 2 < len { input[i + 2] } else { 0 };
588
589            result.push(B64_CHARS[((b0 >> 2) & 0x3F) as usize] as char);
590            result.push(B64_CHARS[(((b0 & 0x03) << 4) | ((b1 >> 4) & 0x0F)) as usize] as char);
591
592            if i + 1 < len {
593                result.push(B64_CHARS[(((b1 & 0x0F) << 2) | ((b2 >> 6) & 0x03)) as usize] as char);
594            } else {
595                result.push('=');
596            }
597
598            if i + 2 < len {
599                result.push(B64_CHARS[(b2 & 0x3F) as usize] as char);
600            } else {
601                result.push('=');
602            }
603
604            i += 3;
605        }
606
607        result
608    }
609
610    #[test]
611    fn test_base64_encode() {
612        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
613        assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn test_parse_gitlab_project() {
623        assert_eq!(
624            parse_gitlab_project("git@gitlab.com:group/subgroup/repo.git", "gitlab.com"),
625            Some("group/subgroup/repo".to_string())
626        );
627        assert_eq!(
628            parse_gitlab_project("https://gitlab.com/owner/project.git", "gitlab.com"),
629            Some("owner/project".to_string())
630        );
631        assert_eq!(
632            parse_gitlab_project("https://gitlab.example.org/myorg/myproject", "gitlab.example.org"),
633            Some("myorg/myproject".to_string())
634        );
635    }
636
637    #[test]
638    fn test_parse_bitbucket_workspace_repo() {
639        assert_eq!(
640            parse_bitbucket_workspace_repo("git@bitbucket.org:myws/myrepo.git"),
641            Some(("myws".to_string(), "myrepo".to_string()))
642        );
643        assert_eq!(
644            parse_bitbucket_workspace_repo("https://bitbucket.org/team/project"),
645            Some(("team".to_string(), "project".to_string()))
646        );
647    }
648
649    #[test]
650    fn test_urlencoding() {
651        assert_eq!(urlencoding("group/subgroup/repo"), "group%2Fsubgroup%2Frepo");
652        assert_eq!(urlencoding("12345"), "12345");
653    }
654
655    #[test]
656    fn test_extract_jira_adf() {
657        let adf = serde_json::json!({
658            "type": "doc",
659            "content": [
660                {
661                    "type": "paragraph",
662                    "content": [
663                        { "type": "text", "text": "This is a Jira " },
664                        { "type": "text", "text": "comment." }
665                    ]
666                }
667            ]
668        });
669        let body = extract_jira_comment_body(Some(&adf));
670        assert!(body.contains("This is a Jira comment."));
671    }
672}