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 Pr(super::import_pr::ImportPrArgs),
19
20 Gitlab(GitlabImportArgs),
22
23 Bitbucket(BitbucketImportArgs),
25
26 Jira(JiraImportArgs),
28}
29
30#[derive(Args, Debug, Clone)]
31pub struct GitlabImportArgs {
32 #[arg(short, long)]
34 pub mr: u64,
35
36 #[arg(short, long)]
38 pub project_id: Option<String>,
39
40 #[arg(short, long)]
42 pub token: Option<String>,
43
44 #[arg(long, default_value = "gitlab.com")]
46 pub host: Option<String>,
47
48 #[arg(short, long, default_value = "review")]
50 pub namespace: Option<String>,
51}
52
53#[derive(Args, Debug, Clone)]
54pub struct BitbucketImportArgs {
55 #[arg(short, long)]
57 pub pr: u64,
58
59 #[arg(short, long)]
61 pub repo: Option<String>,
62
63 #[arg(short, long)]
65 pub workspace: Option<String>,
66
67 #[arg(short, long)]
69 pub token: Option<String>,
70
71 #[arg(short, long, default_value = "review")]
73 pub namespace: Option<String>,
74}
75
76#[derive(Args, Debug, Clone)]
77pub struct JiraImportArgs {
78 #[arg(short, long)]
80 pub issue: String,
81
82 #[arg(long)]
84 pub host: String,
85
86 #[arg(short, long)]
88 pub token: Option<String>,
89
90 #[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
104pub 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 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 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(¬e).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
242pub 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 use base64_helper::base64_encode;
276 format!("Basic {}", base64_encode(token.as_bytes()))
277 } else {
278 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(¬e).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
368pub 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(¬e).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
479fn 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 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}