use crate::actions::GlobalArgs;
use crate::actions::issue::display_issue;
use crate::render::json::JsonToStdout;
use crate::render::option::option_display;
use crate::render::spinner::spin_until_ready;
use crate::render::ui::{fuzzy_select_with_key, multi_fuzzy_select_with_key, select_state};
use crate::types::api::state_type::ExclusiveStateType;
use crate::types::context::BergContext;
use crate::types::git::OwnerRepo;
use crate::types::output::OutputMode;
use forgejo_api::structs::{
EditIssueOption, Issue, IssueLabelsOption, IssueListIssuesQuery, IssueListLabelsQuery,
};
use miette::{Context, IntoDiagnostic};
use strum::{Display, VariantArray};
use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
use clap::Parser;
#[derive(Parser, Debug)]
pub struct EditIssueArgs {
pub id: Option<i64>,
#[arg(short, long)]
pub title: Option<String>,
#[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
pub labels: Vec<String>,
#[arg(id = "description", short, long)]
pub body: Option<String>,
#[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
pub assignees: Vec<String>,
#[arg(short, long)]
pub milestone: Option<String>,
#[arg(short, long, value_enum)]
pub state: Option<ExclusiveStateType>,
}
#[derive(Display, PartialEq, Eq, VariantArray)]
enum EditableFields {
Assignees,
Description,
State,
Title,
Labels,
}
impl EditIssueArgs {
pub async fn run(self, global_args: GlobalArgs) -> miette::Result<()> {
let ctx = BergContext::new(self, global_args).await?;
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let issue = select_issue(&ctx).await?;
let issue_id = issue.number.context("Selected issue doesn't have an ID")?;
let (options, label_options) = create_options(&ctx, &issue).await?;
let updated_labels = ctx
.client
.issue_replace_labels(owner.as_str(), repo.as_str(), issue_id, label_options)
.await
.into_diagnostic()?;
tracing::debug!("{updated_labels:?}");
let updated_issue = ctx
.client
.issue_edit_issue(owner.as_str(), repo.as_str(), issue_id, options)
.await
.into_diagnostic()?;
match ctx.global_args.output_mode {
OutputMode::Pretty => {
tracing::debug!("{updated_issue:?}");
}
OutputMode::Json => updated_issue.print_json()?,
}
Ok(())
}
}
async fn select_issue(ctx: &BergContext<EditIssueArgs>) -> miette::Result<Issue> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
if let Some(index) = ctx.args.id {
ctx.client
.issue_get_issue(owner.as_str(), repo.as_str(), index)
.await
.into_diagnostic()
.with_context(|| format!("Couldn't find selected issue #{index} in repo"))
} else {
if ctx.global_args.non_interactive {
miette::bail!("non-interactive mode enabled. You have to specify an issue ID");
};
let (_, issues_list) = spin_until_ready(
ctx.client
.issue_list_issues(
owner.as_str(),
repo.as_str(),
IssueListIssuesQuery::default(),
)
.send(),
)
.await
.into_diagnostic()?;
fuzzy_select_with_key(&issues_list, select_prompt_for("issue"), display_issue).cloned()
}
}
async fn create_options(
ctx: &BergContext<EditIssueArgs>,
issue: &Issue,
) -> miette::Result<(EditIssueOption, IssueLabelsOption)> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let milestone = if let Some(milestone_title) = ctx.args.milestone.as_ref() {
let (_, milestones) = ctx
.client
.issue_get_milestones_list(owner.as_str(), repo.as_str(), Default::default())
.await
.into_diagnostic()?;
let selected_milestone = milestones
.into_iter()
.find(|milestone| {
milestone
.title
.as_ref()
.is_some_and(|title| title == milestone_title)
})
.with_context(|| format!("Couldn't find milestone with title: {milestone_title}"))?;
Some(
selected_milestone
.id
.context("Milestone is expected to have a milestone ID!")?,
)
} else {
None
};
let state = ctx.args.state.as_ref().map(ToString::to_string);
let labels = ctx
.args
.labels
.iter()
.cloned()
.map(serde_json::Value::String)
.collect::<Vec<_>>();
let labels = (!labels.is_empty())
.then_some(labels)
.or(issue.labels.as_ref().and_then(|labels| {
let labels = labels
.iter()
.filter_map(|label| label.id)
.map(serde_json::Number::from)
.map(serde_json::Value::Number)
.collect::<Vec<_>>();
(!labels.is_empty()).then_some(labels)
}));
let assignees = ctx.args.assignees.clone();
let assignees = (!assignees.is_empty()).then_some(assignees).or(issue
.assignees
.as_ref()
.and_then(|users| {
let users = users
.iter()
.filter_map(|user| user.login_name.clone())
.filter(|name| !name.is_empty())
.collect::<Vec<_>>();
(!users.is_empty()).then_some(users)
}));
let mut options = EditIssueOption {
assignee: None,
assignees,
body: ctx.args.body.clone().or(issue.body.clone()),
due_date: issue.due_date,
milestone: milestone.or(issue.milestone.as_ref().and_then(|milestone| milestone.id)),
r#ref: issue.r#ref.clone(),
state: state.or(issue
.state
.map(ExclusiveStateType::from)
.as_ref()
.map(ToString::to_string)),
title: ctx.args.title.clone().or(issue.title.clone()),
unset_due_date: None,
updated_at: None,
};
let mut label_options = IssueLabelsOption {
labels,
updated_at: None,
};
if !ctx.global_args.non_interactive {
let selected_update_fields = multi_fuzzy_select_with_key(
EditableFields::VARIANTS,
select_prompt_for("options"),
|_| false,
|f| f.to_string(),
)?;
if selected_update_fields.contains(&&EditableFields::Assignees) {
let current_assignees = issue
.assignees
.as_ref()
.map(|users| users.iter().filter_map(|user| user.id).collect::<Vec<_>>());
options
.assignees
.replace(issue_assignees(ctx, current_assignees).await?);
}
if selected_update_fields.contains(&&EditableFields::Description) {
options
.body
.replace(issue_body(ctx, issue.body.clone()).await?);
}
if selected_update_fields.contains(&&EditableFields::State) {
options.state.replace(select_state(issue.state)?);
}
if selected_update_fields.contains(&&EditableFields::Title) {
options
.title
.replace(issue_title(ctx, issue.title.clone()).await?);
}
if selected_update_fields.contains(&&EditableFields::Labels) {
let current_labels = issue.labels.as_ref().map(|labels| {
labels
.iter()
.filter_map(|label| label.id)
.collect::<Vec<_>>()
});
label_options
.labels
.replace(issue_labels(ctx, current_labels).await?);
}
}
let now = time::OffsetDateTime::now_utc();
options.updated_at.replace(now);
label_options.updated_at.replace(now);
Ok((options, label_options))
}
async fn issue_assignees(
ctx: &BergContext<EditIssueArgs>,
current_assignees: Option<Vec<i64>>,
) -> miette::Result<Vec<String>> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let current_assignees = current_assignees.unwrap_or_default();
let (_, all_assignees) = ctx
.client
.repo_get_assignees(owner.as_str(), repo.as_str())
.await
.into_diagnostic()?;
let selected_assignees = multi_fuzzy_select_with_key(
&all_assignees,
select_prompt_for("assignees"),
|u| u.id.is_some_and(|id| current_assignees.contains(&id)),
|u| option_display(&u.login),
)?;
Ok(selected_assignees
.into_iter()
.filter_map(|u| u.login.as_ref().cloned())
.collect::<Vec<_>>())
}
async fn issue_body(
ctx: &BergContext<EditIssueArgs>,
current_body: Option<String>,
) -> miette::Result<String> {
ctx.editor_for(
"a description",
current_body
.as_deref()
.unwrap_or("Enter an issue description"),
)
}
async fn issue_title(
_ctx: &BergContext<EditIssueArgs>,
current_title: Option<String>,
) -> miette::Result<String> {
inquire::Text::new(input_prompt_for("Choose a new issue title").as_str())
.with_default(current_title.as_deref().unwrap_or("Enter an issue title"))
.prompt()
.into_diagnostic()
}
async fn issue_labels(
ctx: &BergContext<EditIssueArgs>,
current_labels: Option<Vec<i64>>,
) -> miette::Result<Vec<serde_json::Value>> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let current_labels = current_labels.unwrap_or_default();
let (_, all_labels) = ctx
.client
.issue_list_labels(
owner.as_str(),
repo.as_str(),
IssueListLabelsQuery::default(),
)
.await
.into_diagnostic()?;
let selected_labels = multi_fuzzy_select_with_key(
&all_labels,
select_prompt_for("labels"),
|l| l.id.is_some_and(|id| current_labels.contains(&id)),
|l| option_display(&l.name),
)?;
let label_ids = selected_labels
.iter()
.filter_map(|l| l.id)
.map(|x| serde_json::json!(x))
.collect::<Vec<_>>();
Ok(label_ids)
}