1use std::io::Write;
2
3use super::{confirm, join_flags, resolve_milestone_opt, Ctx};
4use crate::api::issues::{CreateIssue, EditIssue, IssueFilter};
5use crate::cli::IssueCmd;
6use crate::error::{GiteeError, Result};
7use crate::models::IssueState;
8use crate::out;
9
10fn parse_issue_state(raw: &str) -> Result<IssueState> {
14 match raw {
15 "open" => Ok(IssueState::Open),
16 "progressing" => Ok(IssueState::Progressing),
17 "closed" => Ok(IssueState::Closed),
18 "rejected" => Err(GiteeError::Usage(
19 "issue state 'rejected' is not writable on Gitee v5 (API returns \
20 400). Use --state closed and a repo label such as wontfix for \
21 non-completion intent"
22 .into(),
23 )),
24 other => Err(GiteeError::Usage(format!(
25 "unsupported issue state '{other}' (want open|progressing|closed; \
26 Gitee v5 has no writable 'rejected')"
27 ))),
28 }
29}
30
31pub fn execute(ctx: &Ctx, cmd: IssueCmd) -> Result<()> {
32 match cmd {
33 IssueCmd::List { list, assignee } => {
34 let repo = ctx.repo()?;
35 let filter = IssueFilter {
36 state: list.state.as_deref(),
37 assignee: assignee.as_deref(),
38 limit: list.limit,
39 ..Default::default()
40 };
41 let items = ctx.client.issues(repo).list(&filter)?;
42 let mut out = std::io::stdout().lock();
43 ctx.out
44 .render(&mut out, &items, |w| out::issue_table(w, &items))?;
45 }
46 IssueCmd::Status { limit } => {
47 let repo = ctx.repo()?;
48 let me = ctx.me()?;
49 let login = me.login.as_str();
50 let open = Some("open");
51 let created = ctx.client.issues(repo).list(&IssueFilter {
52 state: open,
53 creator: Some(login),
54 limit: limit.limit,
55 ..Default::default()
56 })?;
57 let assigned = ctx.client.issues(repo).list(&IssueFilter {
58 state: open,
59 assignee: Some(login),
60 limit: limit.limit,
61 ..Default::default()
62 })?;
63 let status = out::IssueStatus { created, assigned };
64 let mut out = std::io::stdout().lock();
65 ctx.out
66 .render(&mut out, &status, |w| out::issue_status(w, &status))?;
67 }
68 IssueCmd::View { number, web } => {
69 let repo = ctx.repo()?;
70 if web {
71 let url = crate::web::issue_url(&ctx.host, repo, &number);
72 return crate::web::open_or_print(&url);
73 }
74 let issue = ctx.client.issues(repo).get(&number)?;
75 let mut out = std::io::stdout().lock();
76 ctx.out
77 .render(&mut out, &issue, |w| out::one_issue(w, &issue))?;
78 }
79 IssueCmd::Create {
80 title,
81 body,
82 assignee,
83 labels,
84 milestone,
85 security_hole,
86 } => {
87 if ctx.preview {
88 let repo = ctx.repo()?;
89 let t = title.clone().unwrap_or_default();
90 let b = body.clone().unwrap_or_default();
91 let a = assignee.clone().unwrap_or_default();
92 let l = labels.clone().unwrap_or_default();
93 let m = milestone.clone().unwrap_or_default();
94 let repo_str = format!("{}/{}", repo.owner, repo.name);
95 let sh = if security_hole { "true" } else { "false" };
96 let details: Vec<(&str, &str)> = vec![
97 ("repo", &repo_str),
98 ("title", &t),
99 ("body", &b),
100 ("assignee", &a),
101 ("labels", &l),
102 ("milestone", &m),
103 ("security_hole", sh),
104 ];
105 println!("{}", super::preview_line("create issue", &details));
106 return Ok(());
107 }
108 if super::interactive::should_run_interactive_create(title.as_deref(), false)
109 && !super::interactive::stdin_is_tty()
110 {
111 return Err(super::interactive::missing_title_usage(
112 "issue create",
113 false,
114 ));
115 }
116 let repo = ctx.repo()?;
117 let mut title = title;
118 let mut body = body;
119 if super::interactive::should_run_interactive_create(title.as_deref(), false) {
120 if !super::interactive::stdin_is_tty() {
121 return Err(super::interactive::missing_title_usage(
122 "issue create",
123 false,
124 ));
125 }
126 title = Some(super::interactive::prompt_title(None)?);
127 if body.is_none() {
128 let editor = super::interactive::resolve_editor_from_env_and_config()?;
129 body = super::interactive::edit_body_in_editor("", &editor)?;
130 }
131 }
132 let title = title.ok_or_else(|| {
133 super::interactive::missing_title_usage("issue create", false)
134 })?;
135 let milestone_number = resolve_milestone_opt(ctx, repo, milestone.as_deref())?;
136 let req = CreateIssue {
137 title: &title,
138 body: body.as_deref(),
139 assignee: assignee.as_deref(),
140 labels: labels.as_deref(),
141 milestone_number,
142 security_hole,
143 };
144 let issue = ctx.client.issues(repo).create(&req)?;
145 let mut out = std::io::stdout().lock();
146 ctx.out
147 .render(&mut out, &issue, |w| out::one_issue(w, &issue))?;
148 }
149 IssueCmd::Edit {
150 number,
151 title,
152 body,
153 assignee,
154 label,
155 milestone,
156 security_hole,
157 state,
158 } => {
159 let repo = ctx.repo()?;
160 let milestone_number = resolve_milestone_opt(ctx, repo, milestone.as_deref())?;
161 let labels = join_flags(&label);
162 let state = state
163 .as_deref()
164 .map(parse_issue_state)
165 .transpose()?;
166 let req = EditIssue {
167 title: title.as_deref(),
168 body: body.as_deref(),
169 assignee: assignee.as_deref(),
170 labels: labels.as_deref(),
171 milestone_number,
172 security_hole: security_hole.then_some(true),
173 state,
174 };
175 let issue = ctx.client.issues(repo).edit(&number, &req)?;
176 let mut out = std::io::stdout().lock();
177 ctx.out
178 .render(&mut out, &issue, |w| out::one_issue(w, &issue))?;
179 }
180 IssueCmd::Close { number } => {
181 let repo = ctx.repo()?;
182 if ctx.preview {
183 println!("{}", super::preview_line(
184 &format!("close issue {number}"),
185 &[("repo", &format!("{}/{}", repo.owner, repo.name))],
186 ));
187 return Ok(());
188 }
189 let change = ctx
190 .client
191 .issues(repo)
192 .set_state_idempotent(&number, IssueState::Closed)?;
193 render_idempotent_issue(ctx, change, "closed", &number)?;
194 }
195 IssueCmd::Reopen { number } => {
196 let repo = ctx.repo()?;
197 if ctx.preview {
198 println!("{}", super::preview_line(
199 &format!("reopen issue {number}"),
200 &[("repo", &format!("{}/{}", repo.owner, repo.name))],
201 ));
202 return Ok(());
203 }
204 let change = ctx
205 .client
206 .issues(repo)
207 .set_state_idempotent(&number, IssueState::Open)?;
208 render_idempotent_issue(ctx, change, "open", &number)?;
209 }
210 IssueCmd::Link { number, pr } => {
211 let repo = ctx.repo()?;
212 let tag = format!("!{pr}");
213 let linked = ctx.client.issues(repo).link(&number, &tag)?;
214 let mut out = std::io::stdout().lock();
215 if linked {
216 writeln!(out, "Linked pull request {tag} on issue #{number}")?;
217 } else {
218 writeln!(out, "Issue #{number} already references {tag}")?;
219 }
220 }
221 IssueCmd::Comment(crate::cli::IssueCommentCmd::Create { number, body }) => {
222 let repo = ctx.repo()?;
223 if ctx.preview {
224 println!(
225 "{}",
226 super::preview_line(
227 &format!("create comment on issue {number}"),
228 &[
229 ("repo", &format!("{}/{}", repo.owner, repo.name)),
230 ("body", &body.body),
231 ],
232 )
233 );
234 return Ok(());
235 }
236 let c = ctx.client.issues(repo).comment(&number, &body.body)?;
237 let mut out = std::io::stdout().lock();
238 ctx.out.render(&mut out, &c, |w| out::comment_line(w, &c))?;
239 }
240 IssueCmd::Comment(crate::cli::IssueCommentCmd::List { number, limit }) => {
241 let repo = ctx.repo()?;
242 let items = ctx.client.issues(repo).list_comments(&number, limit.limit)?;
243 let mut out = std::io::stdout().lock();
244 ctx.out
245 .render(&mut out, &items, |w| out::comment_table(w, &items))?;
246 }
247 IssueCmd::Comment(crate::cli::IssueCommentCmd::Edit {
248 target,
249 last,
250 body,
251 }) => {
252 let repo = ctx.repo()?;
253 if ctx.preview {
254 let action = if last {
255 format!("edit latest comment on issue {target}")
256 } else {
257 format!("edit comment {target}")
258 };
259 println!(
260 "{}",
261 super::preview_line(
262 &action,
263 &[("repo", &format!("{}/{}", repo.owner, repo.name))],
264 )
265 );
266 return Ok(());
267 }
268 let ops = ctx.client.issues(repo);
269 let c = if last {
270 let me = ctx.me()?;
271 match body {
272 Some(b) => ops.update_latest_comment(&target, &me.login, &b)?,
273 None => {
274 if !super::interactive::stdin_is_tty() {
276 return Err(GiteeError::Usage(
277 "issue comment edit needs --body/-m".into(),
278 ));
279 }
280 let existing = ops.latest_comment(&target, &me.login)?;
281 let body_text = super::interactive::resolve_optional_body(
282 None,
283 &existing.body,
284 "issue comment edit",
285 )?;
286 ops.update_comment(existing.id, &body_text)?
287 }
288 }
289 } else {
290 let id: i64 = target.parse().map_err(|_| {
291 GiteeError::Usage(format!(
292 "comment id must be an integer, got '{target}'"
293 ))
294 })?;
295 let body_text = super::interactive::resolve_optional_body(
296 body,
297 "",
298 "issue comment edit",
299 )?;
300 ops.update_comment(id, &body_text)?
301 };
302 let mut out = std::io::stdout().lock();
303 ctx.out.render(&mut out, &c, |w| out::comment_line(w, &c))?;
304 }
305 IssueCmd::Comment(crate::cli::IssueCommentCmd::Delete {
306 target,
307 last,
308 yes,
309 }) => {
310 let repo = ctx.repo()?;
311 if ctx.preview {
312 let action = if last {
313 format!("delete latest comment on issue {target}")
314 } else {
315 format!("delete comment {target}")
316 };
317 println!(
318 "{}",
319 super::preview_line(
320 &action,
321 &[("repo", &format!("{}/{}", repo.owner, repo.name))],
322 )
323 );
324 return Ok(());
325 }
326 let confirm_msg = if last {
327 format!("Delete latest comment on issue {target}")
328 } else {
329 format!("Delete comment {target}")
330 };
331 confirm(&confirm_msg, yes)?;
332 let ops = ctx.client.issues(repo);
333 let change = if last {
334 let me = ctx.me()?;
335 ops.delete_latest_comment(&target, &me.login)?
336 } else {
337 let id: i64 = target.parse().map_err(|_| {
338 GiteeError::Usage(format!(
339 "comment id must be an integer, got '{target}'"
340 ))
341 })?;
342 ops.delete_comment(id)?
343 };
344 if change.was_changed() {
346 let mut out = std::io::stdout().lock();
347 if last {
348 writeln!(out, "Deleted latest comment on issue {target}")?;
349 } else {
350 writeln!(out, "Deleted comment {target}")?;
351 }
352 }
353 }
354 IssueCmd::Label(crate::cli::IssueLabelCmd::List { number }) => {
355 let repo = ctx.repo()?;
356 let items = ctx.client.issues(repo).list_labels(&number)?;
357 let mut out = std::io::stdout().lock();
358 ctx.out
359 .render(&mut out, &items, |w| out::label_table(w, &items))?;
360 }
361 IssueCmd::Label(crate::cli::IssueLabelCmd::Add { number, labels }) => {
362 let repo = ctx.repo()?;
363 let joined = labels.join(", ");
364 if ctx.preview {
365 println!(
366 "{}",
367 super::preview_line(
368 &format!("add labels on issue {number}"),
369 &[
370 ("repo", &format!("{}/{}", repo.owner, repo.name)),
371 ("labels", &joined),
372 ],
373 )
374 );
375 return Ok(());
376 }
377 let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
378 let change = ctx
379 .client
380 .issues(repo)
381 .add_labels_idempotent(&number, &refs)?;
382 if let crate::api::StateChange::Changed(items) = change {
384 let mut out = std::io::stdout().lock();
385 ctx.out
386 .render(&mut out, &items, |w| out::label_table(w, &items))?;
387 }
388 }
389 IssueCmd::Label(crate::cli::IssueLabelCmd::Remove { number, labels }) => {
390 let repo = ctx.repo()?;
391 let joined = labels.join(", ");
392 if ctx.preview {
393 println!(
394 "{}",
395 super::preview_line(
396 &format!("remove labels on issue {number}"),
397 &[
398 ("repo", &format!("{}/{}", repo.owner, repo.name)),
399 ("labels", &joined),
400 ],
401 )
402 );
403 return Ok(());
404 }
405 let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
406 let change = ctx
407 .client
408 .issues(repo)
409 .remove_labels_idempotent(&number, &refs)?;
410 if change.was_changed() {
411 let mut out = std::io::stdout().lock();
412 writeln!(out, "Removed labels [{joined}] from issue {number}")?;
413 }
414 }
415 }
416 Ok(())
417}
418
419fn render_idempotent_issue(
423 ctx: &Ctx,
424 change: crate::api::StateChange<crate::models::Issue>,
425 state_word: &str,
426 number: &str,
427) -> Result<()> {
428 use crate::api::StateChange;
429 let mut out = std::io::stdout().lock();
430 match change {
431 StateChange::Changed(issue) => {
432 ctx.out
433 .render(&mut out, &issue, |w| out::one_issue(w, &issue))?;
434 }
435 StateChange::Already(issue) => {
436 let line = format_already_message(&issue, state_word, number, ctx.out.json.is_some());
437 writeln!(out, "{line}")?;
438 }
439 }
440 Ok(())
441}
442
443fn format_already_message(
446 issue: &crate::models::Issue,
447 state_word: &str,
448 number: &str,
449 json: bool,
450) -> String {
451 if json {
452 let envelope = serde_json::json!({
453 "number": issue.number,
454 "state": issue.state.as_str(),
455 "message": format!("already {state_word}"),
456 });
457 serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| envelope.to_string())
458 } else {
459 format!("issue {number} already {state_word}")
460 }
461}
462
463#[cfg(test)]
464mod idempotent_message_tests {
465 use super::format_already_message;
466 use crate::models::{Issue, IssueState};
467
468 fn closed_issue() -> Issue {
469 Issue {
470 number: "I88".into(),
471 title: "Bug".into(),
472 state: IssueState::Closed,
473 ..Default::default()
474 }
475 }
476
477 #[test]
478 fn human_message_names_issue_and_state() {
479 let line = format_already_message(&closed_issue(), "closed", "I88", false);
480 assert_eq!(line, "issue I88 already closed");
481 }
482
483 #[test]
484 fn json_message_emits_structured_envelope() {
485 let line = format_already_message(&closed_issue(), "closed", "I88", true);
486 let v: serde_json::Value = serde_json::from_str(&line).unwrap();
487 assert_eq!(v["number"], "I88");
488 assert_eq!(v["state"], "closed");
489 assert_eq!(v["message"], "already closed");
490 }
491}