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};
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#[derive(Parser, Debug)]
24pub struct EditIssueArgs {
25 pub id: Option<i64>,
27
28 #[arg(short, long)]
30 pub title: Option<String>,
31
32 #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
34 pub labels: Vec<String>,
35
36 #[arg(id = "description", short, long)]
38 pub body: Option<String>,
39
40 #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
42 pub assignees: Vec<String>,
43
44 #[arg(short, long)]
46 pub milestone: Option<String>,
47
48 #[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 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 .filter(|name| !name.is_empty())
182 .collect::<Vec<_>>();
183 (!users.is_empty()).then_some(users)
184 }));
185
186 let mut options = EditIssueOption {
187 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 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 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}