Skip to main content

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