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 {
15 repo: String,
17
18 #[arg(short, long, value_enum)]
20 state: Option<IssueStateArg>,
21
22 #[arg(short, long, default_value = "25")]
24 limit: u32,
25 },
26
27 View {
29 repo: String,
31
32 id: u64,
34
35 #[arg(short, long)]
37 web: bool,
38 },
39
40 Create {
42 repo: String,
44
45 #[arg(short, long)]
47 title: String,
48
49 #[arg(short = 'b', long)]
51 body: Option<String>,
52
53 #[arg(short, long, value_enum, default_value = "bug")]
55 kind: IssueKindArg,
56
57 #[arg(short, long, value_enum, default_value = "major")]
59 priority: IssuePriorityArg,
60 },
61
62 Comment {
64 repo: String,
66
67 id: u64,
69
70 #[arg(short, long)]
72 body: String,
73 },
74
75 Close {
77 repo: String,
79
80 id: u64,
82 },
83
84 Reopen {
86 repo: String,
88
89 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}