use chrono::Utc;
use std::path::{Path, PathBuf};
use crate::config_loader::load_project_configuration;
use crate::error::KanbusError;
use crate::event_history::{
events_dir_for_local, events_dir_for_project, issue_created_payload, now_timestamp,
write_events_batch, EventRecord, EventType,
};
use crate::hierarchy::validate_parent_child_relationship;
use crate::ids::{generate_issue_identifier, issue_identifier_matches, IssueIdentifierRequest};
use crate::issue_files::{
issue_path_for_identifier, list_issue_identifiers, read_issue_from_file, write_issue_to_file,
};
use crate::models::{IssueData, ProjectConfiguration};
use crate::users::get_current_user;
use crate::workflows::validate_status_value;
use crate::{
file_io::{
ensure_project_local_directory, find_project_local_directory, get_configuration_path,
load_project_directory,
},
models::DependencyLink,
};
#[derive(Debug, Clone)]
pub struct IssueCreationRequest {
pub root: PathBuf,
pub title: String,
pub issue_type: Option<String>,
pub priority: Option<u8>,
pub assignee: Option<String>,
pub parent: Option<String>,
pub labels: Vec<String>,
pub description: Option<String>,
pub local: bool,
pub validate: bool,
}
#[derive(Debug, Clone)]
pub struct IssueCreationResult {
pub issue: IssueData,
pub configuration: ProjectConfiguration,
}
pub fn create_issue(request: &IssueCreationRequest) -> Result<IssueCreationResult, KanbusError> {
let project_dir = load_project_directory(request.root.as_path())?;
let mut issues_dir = project_dir.join("issues");
let mut local_dir = find_project_local_directory(&project_dir);
if request.local {
local_dir = Some(ensure_project_local_directory(&project_dir)?);
issues_dir = local_dir.as_ref().expect("local dir").join("issues");
}
let config_path = get_configuration_path(request.root.as_path())?;
let configuration = load_project_configuration(&config_path)?;
let resolved_type = request.issue_type.as_deref().unwrap_or("task");
let resolved_priority = request.priority.unwrap_or(configuration.default_priority);
let mut resolved_parent = request.parent.clone();
if let Some(parent_identifier) = resolved_parent.clone() {
let full_id =
resolve_issue_identifier(&issues_dir, &configuration.project_key, &parent_identifier)?;
resolved_parent = Some(full_id);
}
if request.validate {
validate_issue_type(&configuration, resolved_type)?;
if !configuration.priorities.contains_key(&resolved_priority) {
return Err(KanbusError::IssueOperation("invalid priority".to_string()));
}
if let Some(parent_identifier) = resolved_parent.as_deref() {
let parent_path = issue_path_for_identifier(&issues_dir, parent_identifier);
if !parent_path.exists() {
return Err(KanbusError::IssueOperation("not found".to_string()));
}
let parent_issue = read_issue_from_file(&parent_path)?;
validate_parent_child_relationship(
&configuration,
&parent_issue.issue_type,
resolved_type,
)?;
}
if let Some(duplicate_identifier) = find_duplicate_title(&issues_dir, &request.title)? {
return Err(KanbusError::IssueOperation(format!(
"duplicate title: \"{}\" already exists as {}",
request.title, duplicate_identifier
)));
}
validate_status_value(&configuration, resolved_type, &configuration.initial_status)?;
}
let mut existing_ids = list_issue_identifiers(&project_dir.join("issues"))?;
if let Some(local_dir) = local_dir {
let local_issues = local_dir.join("issues");
if local_issues.exists() {
existing_ids.extend(list_issue_identifiers(&local_issues)?);
}
}
let created_at = Utc::now();
let identifier_request = IssueIdentifierRequest {
title: request.title.clone(),
existing_ids,
prefix: configuration.project_key.clone(),
};
let identifier = generate_issue_identifier(&identifier_request)?.identifier;
let updated_at = created_at;
let resolved_assignee = request
.assignee
.clone()
.or_else(|| configuration.assignee.clone());
let issue = IssueData {
identifier,
title: request.title.clone(),
description: request.description.clone().unwrap_or_default(),
issue_type: resolved_type.to_string(),
status: configuration.initial_status.clone(),
priority: resolved_priority as i32,
assignee: resolved_assignee,
creator: None,
parent: resolved_parent.clone(),
labels: request.labels.clone(),
dependencies: Vec::<DependencyLink>::new(),
comments: Vec::new(),
created_at,
updated_at,
closed_at: None,
custom: std::collections::BTreeMap::new(),
};
let policies_dir = project_dir.join("policies");
if policies_dir.is_dir() {
let policy_documents = crate::policy_loader::load_policies(&policies_dir)?;
if !policy_documents.is_empty() {
let all_issues = crate::issue_listing::load_issues_from_directory(&issues_dir)?;
let context = crate::policy_context::PolicyContext {
current_issue: None,
proposed_issue: issue.clone(),
transition: None,
operation: crate::policy_context::PolicyOperation::Create,
project_configuration: configuration.clone(),
all_issues,
};
crate::policy_evaluator::evaluate_policies(&context, &policy_documents)?;
}
}
let issue_path = issue_path_for_identifier(&issues_dir, &issue.identifier);
write_issue_to_file(&issue, &issue_path)?;
let occurred_at = now_timestamp();
let actor_id = get_current_user();
let event = EventRecord::new(
issue.identifier.clone(),
EventType::IssueCreated,
actor_id,
issue_created_payload(&issue),
occurred_at,
);
let events_dir = if request.local {
match events_dir_for_local(&project_dir) {
Ok(path) => path,
Err(error) => {
std::fs::remove_file(&issue_path)
.map_err(|io_error| KanbusError::Io(io_error.to_string()))?;
return Err(error);
}
}
} else {
events_dir_for_project(&project_dir)
};
match write_events_batch(&events_dir, &[event]) {
Ok(_paths) => {}
Err(error) => {
std::fs::remove_file(&issue_path)
.map_err(|io_error| KanbusError::Io(io_error.to_string()))?;
return Err(error);
}
}
use crate::notification_events::NotificationEvent;
use crate::notification_publisher::publish_notification;
let _ = publish_notification(
request.root.as_path(),
NotificationEvent::IssueCreated {
issue_id: issue.identifier.clone(),
issue_data: issue.clone(),
},
);
Ok(IssueCreationResult {
issue,
configuration,
})
}
fn validate_issue_type(
configuration: &ProjectConfiguration,
issue_type: &str,
) -> Result<(), KanbusError> {
let is_known = configuration
.hierarchy
.iter()
.chain(configuration.types.iter())
.any(|entry| entry == issue_type);
if !is_known {
return Err(KanbusError::IssueOperation(
"unknown issue type".to_string(),
));
}
Ok(())
}
fn find_duplicate_title(issues_dir: &Path, title: &str) -> Result<Option<String>, KanbusError> {
let normalized_title = title.trim().to_lowercase();
for entry in
std::fs::read_dir(issues_dir).map_err(|error| KanbusError::Io(error.to_string()))?
{
let entry = entry.map_err(|error| KanbusError::Io(error.to_string()))?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let issue = read_issue_from_file(&path)?;
if issue.title.trim().to_lowercase() == normalized_title {
return Ok(Some(issue.identifier));
}
}
Ok(None)
}
pub fn resolve_issue_identifier(
issues_dir: &Path,
_project_key: &str,
candidate: &str,
) -> Result<String, KanbusError> {
let exact_path = issue_path_for_identifier(issues_dir, candidate);
if exact_path.exists() {
return Ok(candidate.to_string());
}
let identifiers = list_issue_identifiers(issues_dir)?;
let mut matches: Vec<String> = identifiers
.into_iter()
.filter(|full_id| issue_identifier_matches(candidate, full_id))
.collect();
match matches.len() {
1 => Ok(matches.pop().expect("single match")),
0 => Err(KanbusError::IssueOperation("not found".to_string())),
_ => Err(KanbusError::IssueOperation(
"ambiguous short id".to_string(),
)),
}
}