codeberg_cli/actions/issue/
edit.rs

1use crate::actions::GeneralArgs;
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::context::BergContext;
8use crate::types::git::OwnerRepo;
9use crate::types::output::OutputMode;
10use anyhow::Context;
11use forgejo_api::structs::{
12    EditIssueOption, Issue, IssueLabelsOption, IssueListIssuesQuery, IssueListLabelsQuery,
13    StateType,
14};
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
25#[derive(Display, PartialEq, Eq, VariantArray)]
26enum EditableFields {
27    Assignees,
28    Description,
29    State,
30    Title,
31    Labels,
32}
33
34impl EditIssueArgs {
35    pub async fn run(self, general_args: GeneralArgs) -> anyhow::Result<()> {
36        let ctx = BergContext::new(self, general_args).await?;
37        let OwnerRepo { repo, owner } = ctx.owner_repo()?;
38        let issue = select_issue(&ctx).await?;
39        let issue_id = issue.number.context("Selected issue doesn't have an ID")?;
40
41        let (options, label_options) = create_options(&ctx, &issue).await?;
42
43        let updated_issue = ctx
44            .client
45            .issue_edit_issue(owner.as_str(), repo.as_str(), issue_id as u64, options)
46            .await?;
47
48        tracing::debug!("{updated_issue:?}");
49
50        let (_, updated_labels) = ctx
51            .client
52            .issue_replace_labels(
53                owner.as_str(),
54                repo.as_str(),
55                issue_id as u64,
56                label_options,
57            )
58            .await?;
59
60        match general_args.output_mode {
61            OutputMode::Pretty => {
62                tracing::debug!("{updated_labels:?}");
63            }
64            OutputMode::Json => updated_labels.print_json()?,
65        }
66
67        Ok(())
68    }
69}
70
71async fn select_issue(ctx: &BergContext<EditIssueArgs>) -> anyhow::Result<Issue> {
72    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
73    let (_, issues_list) = spin_until_ready(ctx.client.issue_list_issues(
74        owner.as_str(),
75        repo.as_str(),
76        IssueListIssuesQuery::default(),
77    ))
78    .await?;
79
80    if issues_list.is_empty() {
81        anyhow::bail!("No issues found in this repository");
82    }
83
84    fuzzy_select_with_key(&issues_list, select_prompt_for("issue"), display_issue).cloned()
85}
86
87async fn create_options(
88    ctx: &BergContext<EditIssueArgs>,
89    issue: &Issue,
90) -> anyhow::Result<(EditIssueOption, IssueLabelsOption)> {
91    let selected_update_fields = multi_fuzzy_select_with_key(
92        EditableFields::VARIANTS,
93        select_prompt_for("options"),
94        |_| false,
95        |f| f.to_string(),
96    )?;
97
98    let mut options = EditIssueOption {
99        assignee: None,
100        assignees: None,
101        body: None,
102        due_date: None,
103        milestone: None,
104        r#ref: None,
105        state: None,
106        title: None,
107        unset_due_date: None,
108        updated_at: None,
109    };
110
111    let mut label_options = IssueLabelsOption {
112        labels: None,
113        updated_at: None,
114    };
115
116    if selected_update_fields.contains(&&EditableFields::Assignees) {
117        let current_assignees = issue
118            .assignees
119            .as_ref()
120            .map(|users| users.iter().filter_map(|user| user.id).collect::<Vec<_>>());
121        options
122            .assignees
123            .replace(issue_assignees(ctx, current_assignees).await?);
124    }
125    if selected_update_fields.contains(&&EditableFields::Description) {
126        options
127            .body
128            .replace(issue_body(ctx, issue.body.clone()).await?);
129    }
130    if selected_update_fields.contains(&&EditableFields::State) {
131        options.state.replace(issue_state(ctx, issue.state).await?);
132    }
133    if selected_update_fields.contains(&&EditableFields::Title) {
134        options
135            .title
136            .replace(issue_title(ctx, issue.title.clone()).await?);
137    }
138    if selected_update_fields.contains(&&EditableFields::Labels) {
139        let current_labels = issue.labels.as_ref().map(|labels| {
140            labels
141                .iter()
142                .filter_map(|label| label.id)
143                .collect::<Vec<_>>()
144        });
145        label_options
146            .labels
147            .replace(issue_labels(ctx, current_labels).await?);
148    }
149
150    Ok((options, label_options))
151}
152
153async fn issue_assignees(
154    ctx: &BergContext<EditIssueArgs>,
155    current_assignees: Option<Vec<i64>>,
156) -> anyhow::Result<Vec<String>> {
157    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
158    let current_assignees = current_assignees.unwrap_or_default();
159    let (_, all_assignees) = ctx
160        .client
161        .repo_get_assignees(owner.as_str(), repo.as_str())
162        .await?;
163    let selected_assignees = multi_fuzzy_select_with_key(
164        &all_assignees,
165        select_prompt_for("assignees"),
166        |u| u.id.is_some_and(|id| current_assignees.contains(&id)),
167        |u| option_display(&u.login),
168    )?;
169
170    Ok(selected_assignees
171        .into_iter()
172        .filter_map(|u| u.login.as_ref().cloned())
173        .collect::<Vec<_>>())
174}
175
176async fn issue_body(
177    ctx: &BergContext<EditIssueArgs>,
178    current_body: Option<String>,
179) -> anyhow::Result<String> {
180    ctx.editor_for(
181        "a description",
182        current_body
183            .as_deref()
184            .unwrap_or("Enter an issue description"),
185    )
186}
187
188async fn issue_state(
189    _ctx: &BergContext<EditIssueArgs>,
190    _current_state: Option<StateType>,
191) -> anyhow::Result<String> {
192    let selected_state = fuzzy_select_with_key(
193        &[StateType::Open, StateType::Closed],
194        select_prompt_for("states"),
195        |f| format!("{f:?}"),
196    )?;
197    Ok(format!("{selected_state:?}"))
198}
199
200async fn issue_title(
201    _ctx: &BergContext<EditIssueArgs>,
202    current_title: Option<String>,
203) -> anyhow::Result<String> {
204    inquire::Text::new(input_prompt_for("Choose a new issue title").as_str())
205        .with_default(current_title.as_deref().unwrap_or("Enter an issue title"))
206        .prompt()
207        .map_err(anyhow::Error::from)
208}
209
210async fn issue_labels(
211    ctx: &BergContext<EditIssueArgs>,
212    current_labels: Option<Vec<i64>>,
213) -> anyhow::Result<Vec<serde_json::Value>> {
214    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
215    let current_labels = current_labels.unwrap_or_default();
216    let (_, all_labels) = ctx
217        .client
218        .issue_list_labels(
219            owner.as_str(),
220            repo.as_str(),
221            IssueListLabelsQuery::default(),
222        )
223        .await?;
224
225    let selected_labels = multi_fuzzy_select_with_key(
226        &all_labels,
227        select_prompt_for("labels"),
228        |l| l.id.is_some_and(|id| current_labels.contains(&id)),
229        |l| option_display(&l.name),
230    )?;
231
232    let label_ids = selected_labels
233        .iter()
234        .filter_map(|l| l.id)
235        .map(|x| serde_json::json!(x))
236        .collect::<Vec<_>>();
237
238    Ok(label_ids)
239}