Skip to main content

gitee_cli_rs/cmd/
issue.rs

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
10/// Clap already restricts `--state` to API-writable values; map them onto
11/// `IssueState`. `rejected` is intentionally not accepted for writes — Gitee
12/// v5 returns 400 for it (see `issue edit` command docs).
13fn 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                        // Non-TTY without -m: usage error before listing.
275                        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            // 404 / already-gone is silent (idempotent); only announce real deletes.
345            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            // Already-present is silent (idempotent); render labels after a real add.
383            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
419/// Render an idempotent close/reopen result. On the `Already` branch print a
420/// human "already <state>" message (or a structured `--json` object); on the
421/// `Changed` branch render the updated issue through the normal renderer.
422fn 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
443/// Build the "already <state>" line. JSON mode emits a structured envelope
444/// `{"number","state","message"}`; human mode emits `issue <n> already <state>`.
445fn 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}