codeberg_cli/actions/issue/
edit.rs

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