bitbucket_cli/cli/
issue.rs

1use anyhow::Result;
2use clap::{Subcommand, ValueEnum};
3use colored::Colorize;
4use tabled::{Table, Tabled};
5
6use crate::api::BitbucketClient;
7use crate::models::{
8    CreateIssueRequest, IssueContentRequest, IssueKind, IssuePriority, IssueState,
9};
10
11#[derive(Subcommand)]
12pub enum IssueCommands {
13    /// List issues
14    List {
15        /// Repository in format workspace/repo-slug
16        repo: String,
17
18        /// Filter by state
19        #[arg(short, long, value_enum)]
20        state: Option<IssueStateArg>,
21
22        /// Number of results
23        #[arg(short, long, default_value = "25")]
24        limit: u32,
25    },
26
27    /// View issue details
28    View {
29        /// Repository in format workspace/repo-slug
30        repo: String,
31
32        /// Issue ID
33        id: u64,
34
35        /// Open in browser
36        #[arg(short, long)]
37        web: bool,
38    },
39
40    /// Create a new issue
41    Create {
42        /// Repository in format workspace/repo-slug
43        repo: String,
44
45        /// Issue title
46        #[arg(short, long)]
47        title: String,
48
49        /// Issue description
50        #[arg(short = 'b', long)]
51        body: Option<String>,
52
53        /// Issue type
54        #[arg(short, long, value_enum, default_value = "bug")]
55        kind: IssueKindArg,
56
57        /// Issue priority
58        #[arg(short, long, value_enum, default_value = "major")]
59        priority: IssuePriorityArg,
60    },
61
62    /// Add a comment to an issue
63    Comment {
64        /// Repository in format workspace/repo-slug
65        repo: String,
66
67        /// Issue ID
68        id: u64,
69
70        /// Comment text
71        #[arg(short, long)]
72        body: String,
73    },
74
75    /// Close an issue
76    Close {
77        /// Repository in format workspace/repo-slug
78        repo: String,
79
80        /// Issue ID
81        id: u64,
82    },
83
84    /// Reopen an issue
85    Reopen {
86        /// Repository in format workspace/repo-slug
87        repo: String,
88
89        /// Issue ID
90        id: u64,
91    },
92}
93
94#[derive(ValueEnum, Clone)]
95pub enum IssueStateArg {
96    New,
97    Open,
98    Resolved,
99    OnHold,
100    Invalid,
101    Duplicate,
102    Wontfix,
103    Closed,
104}
105
106impl From<IssueStateArg> for IssueState {
107    fn from(state: IssueStateArg) -> Self {
108        match state {
109            IssueStateArg::New => IssueState::New,
110            IssueStateArg::Open => IssueState::Open,
111            IssueStateArg::Resolved => IssueState::Resolved,
112            IssueStateArg::OnHold => IssueState::OnHold,
113            IssueStateArg::Invalid => IssueState::Invalid,
114            IssueStateArg::Duplicate => IssueState::Duplicate,
115            IssueStateArg::Wontfix => IssueState::Wontfix,
116            IssueStateArg::Closed => IssueState::Closed,
117        }
118    }
119}
120
121#[derive(ValueEnum, Clone)]
122pub enum IssueKindArg {
123    Bug,
124    Enhancement,
125    Proposal,
126    Task,
127}
128
129impl From<IssueKindArg> for IssueKind {
130    fn from(kind: IssueKindArg) -> Self {
131        match kind {
132            IssueKindArg::Bug => IssueKind::Bug,
133            IssueKindArg::Enhancement => IssueKind::Enhancement,
134            IssueKindArg::Proposal => IssueKind::Proposal,
135            IssueKindArg::Task => IssueKind::Task,
136        }
137    }
138}
139
140#[derive(ValueEnum, Clone)]
141pub enum IssuePriorityArg {
142    Trivial,
143    Minor,
144    Major,
145    Critical,
146    Blocker,
147}
148
149impl From<IssuePriorityArg> for IssuePriority {
150    fn from(priority: IssuePriorityArg) -> Self {
151        match priority {
152            IssuePriorityArg::Trivial => IssuePriority::Trivial,
153            IssuePriorityArg::Minor => IssuePriority::Minor,
154            IssuePriorityArg::Major => IssuePriority::Major,
155            IssuePriorityArg::Critical => IssuePriority::Critical,
156            IssuePriorityArg::Blocker => IssuePriority::Blocker,
157        }
158    }
159}
160
161#[derive(Tabled)]
162struct IssueRow {
163    #[tabled(rename = "ID")]
164    id: u64,
165    #[tabled(rename = "TITLE")]
166    title: String,
167    #[tabled(rename = "STATE")]
168    state: String,
169    #[tabled(rename = "KIND")]
170    kind: String,
171    #[tabled(rename = "PRIORITY")]
172    priority: String,
173}
174
175impl IssueCommands {
176    pub async fn run(self) -> Result<()> {
177        match self {
178            IssueCommands::List { repo, state, limit } => {
179                let (workspace, repo_slug) = parse_repo(&repo)?;
180                let client = BitbucketClient::from_stored()?;
181
182                let issues = client
183                    .list_issues(
184                        &workspace,
185                        &repo_slug,
186                        state.map(|s| s.into()),
187                        None,
188                        Some(limit),
189                    )
190                    .await?;
191
192                if issues.values.is_empty() {
193                    println!("No issues found");
194                    return Ok(());
195                }
196
197                let rows: Vec<IssueRow> = issues
198                    .values
199                    .iter()
200                    .map(|issue| IssueRow {
201                        id: issue.id,
202                        title: issue.title.chars().take(50).collect(),
203                        state: format_state(&issue.state),
204                        kind: format!("{}", issue.kind),
205                        priority: format_priority(&issue.priority),
206                    })
207                    .collect();
208
209                let table = Table::new(rows).to_string();
210                println!("{}", table);
211
212                Ok(())
213            }
214
215            IssueCommands::View { repo, id, web } => {
216                let (workspace, repo_slug) = parse_repo(&repo)?;
217                let client = BitbucketClient::from_stored()?;
218                let issue = client.get_issue(&workspace, &repo_slug, id).await?;
219
220                if web {
221                    if let Some(links) = &issue.links {
222                        if let Some(html) = &links.html {
223                            open::that(&html.href)?;
224                            println!("Opened {} in browser", html.href.cyan());
225                            return Ok(());
226                        }
227                    }
228                    anyhow::bail!("Could not find issue URL");
229                }
230
231                println!(
232                    "{} {} #{}",
233                    format_state(&issue.state),
234                    issue.title.bold(),
235                    issue.id
236                );
237                println!("{}", "─".repeat(60));
238
239                println!("{} {}", "Kind:".dimmed(), issue.kind);
240                println!(
241                    "{} {}",
242                    "Priority:".dimmed(),
243                    format_priority(&issue.priority)
244                );
245
246                if let Some(reporter) = &issue.reporter {
247                    println!("{} {}", "Reporter:".dimmed(), reporter.display_name);
248                }
249
250                if let Some(assignee) = &issue.assignee {
251                    println!("{} {}", "Assignee:".dimmed(), assignee.display_name);
252                }
253
254                println!(
255                    "{} {}",
256                    "Created:".dimmed(),
257                    issue.created_on.format("%Y-%m-%d %H:%M")
258                );
259
260                if let Some(updated) = issue.updated_on {
261                    println!(
262                        "{} {}",
263                        "Updated:".dimmed(),
264                        updated.format("%Y-%m-%d %H:%M")
265                    );
266                }
267
268                if let Some(votes) = issue.votes {
269                    if votes > 0 {
270                        println!("{} {}", "Votes:".dimmed(), votes);
271                    }
272                }
273
274                if let Some(content) = &issue.content {
275                    if let Some(raw) = &content.raw {
276                        if !raw.is_empty() {
277                            println!();
278                            println!("{}", raw);
279                        }
280                    }
281                }
282
283                if let Some(links) = &issue.links {
284                    if let Some(html) = &links.html {
285                        println!();
286                        println!("{} {}", "URL:".dimmed(), html.href.cyan());
287                    }
288                }
289
290                Ok(())
291            }
292
293            IssueCommands::Create {
294                repo,
295                title,
296                body,
297                kind,
298                priority,
299            } => {
300                let (workspace, repo_slug) = parse_repo(&repo)?;
301                let client = BitbucketClient::from_stored()?;
302
303                let request = CreateIssueRequest {
304                    title,
305                    content: body.map(|b| IssueContentRequest { raw: b }),
306                    kind: Some(kind.into()),
307                    priority: Some(priority.into()),
308                    assignee: None,
309                    component: None,
310                    milestone: None,
311                    version: None,
312                };
313
314                let issue = client
315                    .create_issue(&workspace, &repo_slug, &request)
316                    .await?;
317
318                println!("{} Created issue #{}", "✓".green(), issue.id);
319
320                if let Some(links) = &issue.links {
321                    if let Some(html) = &links.html {
322                        println!("{} {}", "URL:".dimmed(), html.href.cyan());
323                    }
324                }
325
326                Ok(())
327            }
328
329            IssueCommands::Comment { repo, id, body } => {
330                let (workspace, repo_slug) = parse_repo(&repo)?;
331                let client = BitbucketClient::from_stored()?;
332
333                client
334                    .add_issue_comment(&workspace, &repo_slug, id, &body)
335                    .await?;
336
337                println!("{} Added comment to issue #{}", "✓".green(), id);
338
339                Ok(())
340            }
341
342            IssueCommands::Close { repo, id } => {
343                let (workspace, repo_slug) = parse_repo(&repo)?;
344                let client = BitbucketClient::from_stored()?;
345
346                client
347                    .update_issue(
348                        &workspace,
349                        &repo_slug,
350                        id,
351                        None,
352                        None,
353                        Some(IssueState::Closed),
354                    )
355                    .await?;
356
357                println!("{} Closed issue #{}", "✓".green(), id);
358
359                Ok(())
360            }
361
362            IssueCommands::Reopen { repo, id } => {
363                let (workspace, repo_slug) = parse_repo(&repo)?;
364                let client = BitbucketClient::from_stored()?;
365
366                client
367                    .update_issue(
368                        &workspace,
369                        &repo_slug,
370                        id,
371                        None,
372                        None,
373                        Some(IssueState::Open),
374                    )
375                    .await?;
376
377                println!("{} Reopened issue #{}", "✓".green(), id);
378
379                Ok(())
380            }
381        }
382    }
383}
384
385fn parse_repo(repo: &str) -> Result<(String, String)> {
386    let parts: Vec<&str> = repo.split('/').collect();
387    if parts.len() != 2 {
388        anyhow::bail!(
389            "Invalid repository format. Expected 'workspace/repo-slug', got '{}'",
390            repo
391        );
392    }
393    Ok((parts[0].to_string(), parts[1].to_string()))
394}
395
396fn format_state(state: &IssueState) -> String {
397    match state {
398        IssueState::New => "NEW".cyan().to_string(),
399        IssueState::Open => "OPEN".green().to_string(),
400        IssueState::Resolved => "RESOLVED".blue().to_string(),
401        IssueState::OnHold => "ON HOLD".yellow().to_string(),
402        IssueState::Invalid => "INVALID".dimmed().to_string(),
403        IssueState::Duplicate => "DUPLICATE".dimmed().to_string(),
404        IssueState::Wontfix => "WONTFIX".dimmed().to_string(),
405        IssueState::Closed => "CLOSED".purple().to_string(),
406    }
407}
408
409fn format_priority(priority: &IssuePriority) -> String {
410    match priority {
411        IssuePriority::Trivial => "trivial".dimmed().to_string(),
412        IssuePriority::Minor => "minor".normal().to_string(),
413        IssuePriority::Major => "major".yellow().to_string(),
414        IssuePriority::Critical => "critical".red().to_string(),
415        IssuePriority::Blocker => "blocker".red().bold().to_string(),
416    }
417}