codeberg_cli/actions/issue/
edit.rs

1use crate::actions::GlobalArgs;
2use crate::actions::issue::display_issue;
3use crate::render::json::JsonToStdout;
4use crate::render::option::option_display;
5use crate::render::spinner::spin_until_ready;
6use crate::render::ui::{fuzzy_select_with_key, multi_fuzzy_select_with_key};
7use crate::types::api::state_type::ExclusiveStateType;
8use crate::types::context::BergContext;
9use crate::types::git::OwnerRepo;
10use crate::types::output::OutputMode;
11use anyhow::Context;
12use forgejo_api::structs::{
13    EditIssueOption, Issue, IssueLabelsOption, IssueListIssuesQuery, IssueListLabelsQuery,
14    StateType,
15};
16use strum::{Display, VariantArray};
17
18use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
19
20use clap::Parser;
21
22/// Edit selected issue
23#[derive(Parser, Debug)]
24pub struct EditIssueArgs {
25    /// The id of the issue that's going to be edited
26    pub id: Option<i64>,
27
28    /// Change title or summary
29    #[arg(short, long)]
30    pub title: Option<String>,
31
32    /// Change comma-delimited list of label ids (this overwrites existing labels)
33    #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
34    pub labels: Vec<String>,
35
36    /// Changes main description of issue
37    #[arg(id = "description", short, long)]
38    pub body: Option<String>,
39
40    /// Changes comma-delimited list of assignee names (this overwrites existing assignees)
41    #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
42    pub assignees: Vec<String>,
43
44    /// Changes the milestone the issue is related to
45    #[arg(short, long)]
46    pub milestone: Option<String>,
47
48    /// Changes the state of the issue
49    #[arg(short, long, value_enum)]
50    pub state: Option<ExclusiveStateType>,
51}
52
53#[derive(Display, PartialEq, Eq, VariantArray)]
54enum EditableFields {
55    Assignees,
56    Description,
57    State,
58    Title,
59    Labels,
60}
61
62impl EditIssueArgs {
63    pub async fn run(self, global_args: GlobalArgs) -> anyhow::Result<()> {
64        let ctx = BergContext::new(self, global_args).await?;
65        let OwnerRepo { repo, owner } = ctx.owner_repo()?;
66        let issue = select_issue(&ctx).await?;
67        let issue_id = issue.number.context("Selected issue doesn't have an ID")?;
68
69        let (options, label_options) = create_options(&ctx, &issue).await?;
70
71        let (_, updated_labels) = ctx
72            .client
73            .issue_replace_labels(owner.as_str(), repo.as_str(), issue_id, label_options)
74            .await?;
75
76        tracing::debug!("{updated_labels:?}");
77
78        let updated_issue = ctx
79            .client
80            .issue_edit_issue(owner.as_str(), repo.as_str(), issue_id, options)
81            .await?;
82
83        match ctx.global_args.output_mode {
84            OutputMode::Pretty => {
85                tracing::debug!("{updated_issue:?}");
86            }
87            OutputMode::Json => updated_issue.print_json()?,
88        }
89
90        Ok(())
91    }
92}
93
94async fn select_issue(ctx: &BergContext<EditIssueArgs>) -> anyhow::Result<Issue> {
95    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
96    if let Some(index) = ctx.args.id {
97        ctx.client
98            .issue_get_issue(owner.as_str(), repo.as_str(), index)
99            .await
100            .with_context(|| format!("Couldn't find selected issue #{index} in repo"))
101    } else {
102        if ctx.global_args.non_interactive {
103            anyhow::bail!("non-interactive mode enabled. You have to specify an issue ID");
104        };
105        let (_, issues_list) = spin_until_ready(
106            ctx.client
107                .issue_list_issues(
108                    owner.as_str(),
109                    repo.as_str(),
110                    IssueListIssuesQuery::default(),
111                )
112                .send(),
113        )
114        .await?;
115
116        fuzzy_select_with_key(&issues_list, select_prompt_for("issue"), display_issue).cloned()
117    }
118}
119
120async fn create_options(
121    ctx: &BergContext<EditIssueArgs>,
122    issue: &Issue,
123) -> anyhow::Result<(EditIssueOption, IssueLabelsOption)> {
124    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
125
126    // we have the milestone name but we need it's id ... do some extra steps to retrieve it
127    let milestone = if let Some(milestone_title) = ctx.args.milestone.as_ref() {
128        let (_, milestones) = ctx
129            .client
130            .issue_get_milestones_list(owner.as_str(), repo.as_str(), Default::default())
131            .await?;
132        let selected_milestone = milestones
133            .into_iter()
134            .find(|milestone| {
135                milestone
136                    .title
137                    .as_ref()
138                    .is_some_and(|title| title == milestone_title)
139            })
140            .with_context(|| format!("Couldn't find milestone with title: {milestone_title}"))?;
141        Some(
142            selected_milestone
143                .id
144                .context("Milestone is expected to have a milestone ID!")?,
145        )
146    } else {
147        None
148    };
149
150    let state = ctx.args.state.as_ref().map(ToString::to_string);
151
152    let labels = ctx
153        .args
154        .labels
155        .iter()
156        .cloned()
157        .map(serde_json::Value::String)
158        .collect::<Vec<_>>();
159    let labels = (!labels.is_empty())
160        .then_some(labels)
161        .or(issue.labels.as_ref().and_then(|labels| {
162            let labels = labels
163                .iter()
164                .filter_map(|label| label.id)
165                .map(serde_json::Number::from)
166                .map(serde_json::Value::Number)
167                .collect::<Vec<_>>();
168            (!labels.is_empty()).then_some(labels)
169        }));
170
171    let assignees = ctx.args.assignees.clone();
172    let assignees = (!assignees.is_empty()).then_some(assignees).or(issue
173        .assignees
174        .as_ref()
175        .and_then(|users| {
176            let users = users
177                .iter()
178                .filter_map(|user| user.login_name.clone())
179                // we need to be careful, without any assignees issue.assignees returns [""] which is not a valid
180                // login of any user
181                .filter(|name| !name.is_empty())
182                .collect::<Vec<_>>();
183            (!users.is_empty()).then_some(users)
184        }));
185
186    let mut options = EditIssueOption {
187        // deprecated
188        assignee: None,
189        assignees,
190        body: ctx.args.body.clone().or(issue.body.clone()),
191        due_date: issue.due_date,
192        milestone: milestone.or(issue.milestone.as_ref().and_then(|milestone| milestone.id)),
193        r#ref: issue.r#ref.clone(),
194        state: state.or(issue
195            .state
196            .map(ExclusiveStateType::from)
197            .as_ref()
198            .map(ToString::to_string)),
199        title: ctx.args.title.clone().or(issue.title.clone()),
200        unset_due_date: None,
201        updated_at: None,
202    };
203
204    let mut label_options = IssueLabelsOption {
205        labels,
206        updated_at: None,
207    };
208
209    // interactive part
210    if !ctx.global_args.non_interactive {
211        let selected_update_fields = multi_fuzzy_select_with_key(
212            EditableFields::VARIANTS,
213            select_prompt_for("options"),
214            |_| false,
215            |f| f.to_string(),
216        )?;
217
218        if selected_update_fields.contains(&&EditableFields::Assignees) {
219            let current_assignees = issue
220                .assignees
221                .as_ref()
222                .map(|users| users.iter().filter_map(|user| user.id).collect::<Vec<_>>());
223            options
224                .assignees
225                .replace(issue_assignees(ctx, current_assignees).await?);
226        }
227        if selected_update_fields.contains(&&EditableFields::Description) {
228            options
229                .body
230                .replace(issue_body(ctx, issue.body.clone()).await?);
231        }
232        if selected_update_fields.contains(&&EditableFields::State) {
233            options.state.replace(issue_state(ctx, issue.state).await?);
234        }
235        if selected_update_fields.contains(&&EditableFields::Title) {
236            options
237                .title
238                .replace(issue_title(ctx, issue.title.clone()).await?);
239        }
240        if selected_update_fields.contains(&&EditableFields::Labels) {
241            let current_labels = issue.labels.as_ref().map(|labels| {
242                labels
243                    .iter()
244                    .filter_map(|label| label.id)
245                    .collect::<Vec<_>>()
246            });
247            label_options
248                .labels
249                .replace(issue_labels(ctx, current_labels).await?);
250        }
251    }
252
253    // update time last so it's fresh
254    let now = time::OffsetDateTime::now_utc();
255    options.updated_at.replace(now);
256    label_options.updated_at.replace(now);
257
258    Ok((options, label_options))
259}
260
261async fn issue_assignees(
262    ctx: &BergContext<EditIssueArgs>,
263    current_assignees: Option<Vec<i64>>,
264) -> anyhow::Result<Vec<String>> {
265    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
266    let current_assignees = current_assignees.unwrap_or_default();
267    let (_, all_assignees) = ctx
268        .client
269        .repo_get_assignees(owner.as_str(), repo.as_str())
270        .await?;
271    let selected_assignees = multi_fuzzy_select_with_key(
272        &all_assignees,
273        select_prompt_for("assignees"),
274        |u| u.id.is_some_and(|id| current_assignees.contains(&id)),
275        |u| option_display(&u.login),
276    )?;
277
278    Ok(selected_assignees
279        .into_iter()
280        .filter_map(|u| u.login.as_ref().cloned())
281        .collect::<Vec<_>>())
282}
283
284async fn issue_body(
285    ctx: &BergContext<EditIssueArgs>,
286    current_body: Option<String>,
287) -> anyhow::Result<String> {
288    ctx.editor_for(
289        "a description",
290        current_body
291            .as_deref()
292            .unwrap_or("Enter an issue description"),
293    )
294}
295
296async fn issue_state(
297    _ctx: &BergContext<EditIssueArgs>,
298    _current_state: Option<StateType>,
299) -> anyhow::Result<String> {
300    let selected_state = fuzzy_select_with_key(
301        &[StateType::Open, StateType::Closed],
302        select_prompt_for("states"),
303        |f| format!("{f:?}"),
304    )?;
305    Ok(format!("{selected_state:?}"))
306}
307
308async fn issue_title(
309    _ctx: &BergContext<EditIssueArgs>,
310    current_title: Option<String>,
311) -> anyhow::Result<String> {
312    inquire::Text::new(input_prompt_for("Choose a new issue title").as_str())
313        .with_default(current_title.as_deref().unwrap_or("Enter an issue title"))
314        .prompt()
315        .map_err(anyhow::Error::from)
316}
317
318async fn issue_labels(
319    ctx: &BergContext<EditIssueArgs>,
320    current_labels: Option<Vec<i64>>,
321) -> anyhow::Result<Vec<serde_json::Value>> {
322    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
323
324    let current_labels = current_labels.unwrap_or_default();
325    let (_, all_labels) = ctx
326        .client
327        .issue_list_labels(
328            owner.as_str(),
329            repo.as_str(),
330            IssueListLabelsQuery::default(),
331        )
332        .await?;
333
334    let selected_labels = multi_fuzzy_select_with_key(
335        &all_labels,
336        select_prompt_for("labels"),
337        |l| l.id.is_some_and(|id| current_labels.contains(&id)),
338        |l| option_display(&l.name),
339    )?;
340
341    let label_ids = selected_labels
342        .iter()
343        .filter_map(|l| l.id)
344        .map(|x| serde_json::json!(x))
345        .collect::<Vec<_>>();
346
347    Ok(label_ids)
348}