codeberg_cli/actions/issue/
edit.rs1use 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#[derive(Parser, Debug)]
23pub struct EditIssueArgs {
24 pub id: Option<i64>,
26
27 #[arg(short, long)]
29 pub title: Option<String>,
30
31 #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
33 pub labels: Vec<String>,
34
35 #[arg(id = "description", short, long)]
37 pub body: Option<String>,
38
39 #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
41 pub assignees: Vec<String>,
42
43 #[arg(short, long)]
45 pub milestone: Option<String>,
46
47 #[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 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 .filter(|name| !name.is_empty())
186 .collect::<Vec<_>>();
187 (!users.is_empty()).then_some(users)
188 }));
189
190 let mut options = EditIssueOption {
191 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 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 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}