fix(ticket-assistant): édition de ticket via les tools MCP idea_ticket_* (#27)
L'assistant IA d'édition de ticket éditait les fichiers du ticket en direct, hors de tout contrôle. Il passe désormais par les tools MCP idea_ticket_* : préparation d'un environnement structuré dédié et policy d'enforcement scopée au ticket courant, de sorte que l'assistant ne peut agir que sur son ticket via la surface MCP plutôt que sur le système de fichiers. Couvert par de nouveaux tests QA (mcp_server, assistant_context_store). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2,16 +2,26 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::McpRuntime;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::SessionPlan;
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
use domain::{
|
||||
AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc,
|
||||
PreparedContext, Project, RemotePath,
|
||||
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
||||
ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring,
|
||||
PreparedContext, Project, ProjectPath, RemotePath, RuntimeError, StructuredSessionEnvironment,
|
||||
StructuredSessionEnvironmentPreparer,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
const ASSISTANT_DIR: &str = "assistant";
|
||||
const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md";
|
||||
const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md");
|
||||
|
||||
/// Resolves the live MCP runtime facts for a ticket assistant requester.
|
||||
pub type TicketAssistantMcpRuntimeResolver =
|
||||
dyn Fn(&Project, &str) -> Option<McpRuntime> + Send + Sync;
|
||||
|
||||
/// File-backed provider for the IdeA-owned ticket assistant context.
|
||||
#[derive(Clone)]
|
||||
pub struct FsAssistantContextStore {
|
||||
@ -74,3 +84,400 @@ impl AssistantContextProvider for FsAssistantContextStore {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// File-backed environment preparer for structured ticket assistant sessions.
|
||||
#[derive(Clone)]
|
||||
pub struct TicketAssistantEnvironmentPreparer {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
|
||||
}
|
||||
|
||||
impl TicketAssistantEnvironmentPreparer {
|
||||
/// Builds the preparer from filesystem, app-data dir, runtime and MCP resolver.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: impl Into<String>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
runtime,
|
||||
mcp_runtime,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_dir(&self, project: &Project, issue_ref: IssueRef) -> Result<ProjectPath, RuntimeError> {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
let project_id = project.id.as_uuid().simple();
|
||||
let issue_number = issue_ref.number().get();
|
||||
ProjectPath::new(format!(
|
||||
"{base}/{ASSISTANT_DIR}/tickets/{project_id}/{issue_number}"
|
||||
))
|
||||
.map_err(|e| RuntimeError::Invocation(e.to_string()))
|
||||
}
|
||||
|
||||
async fn create_dir(&self, path: &str) -> Result<(), RuntimeError> {
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(path.to_owned()))
|
||||
.await
|
||||
.map_err(|e| RuntimeError::Invocation(e.to_string()))
|
||||
}
|
||||
|
||||
async fn write_file(&self, path: &str, bytes: &[u8]) -> Result<(), RuntimeError> {
|
||||
if let Some(parent) = parent_abs(path) {
|
||||
self.create_dir(parent).await?;
|
||||
}
|
||||
self.fs
|
||||
.write(&RemotePath::new(path.to_owned()), bytes)
|
||||
.await
|
||||
.map_err(|e| RuntimeError::Invocation(e.to_string()))
|
||||
}
|
||||
|
||||
async fn read_optional(&self, path: &str) -> Result<Option<String>, RuntimeError> {
|
||||
match self.fs.read(&RemotePath::new(path.to_owned())).await {
|
||||
Ok(bytes) => String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| RuntimeError::Invocation(e.to_string())),
|
||||
Err(FsError::NotFound(_)) => Ok(None),
|
||||
Err(e) => Err(RuntimeError::Invocation(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn materialise_context(
|
||||
&self,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
cwd: &ProjectPath,
|
||||
prepared: &PreparedContext,
|
||||
env: &mut Vec<(String, String)>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
match plan {
|
||||
Some(ContextInjectionPlan::File { target }) => {
|
||||
self.write_file(&join(cwd, &target), prepared.content.as_str().as_bytes())
|
||||
.await?;
|
||||
}
|
||||
Some(ContextInjectionPlan::Env { var }) => {
|
||||
let path = join(cwd, &prepared.relative_path);
|
||||
self.write_file(&path, prepared.content.as_str().as_bytes())
|
||||
.await?;
|
||||
env.push((var, path));
|
||||
}
|
||||
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {
|
||||
let path = join(cwd, &prepared.relative_path);
|
||||
self.write_file(&path, prepared.content.as_str().as_bytes())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn materialise_mcp(
|
||||
&self,
|
||||
project: &Project,
|
||||
profile: &AgentProfile,
|
||||
cwd: &ProjectPath,
|
||||
requester: &str,
|
||||
env: &mut Vec<(String, String)>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let Some(mcp) = &profile.mcp else {
|
||||
return Ok(());
|
||||
};
|
||||
let runtime = (self.mcp_runtime)(project, requester);
|
||||
match &mcp.config {
|
||||
McpConfigStrategy::ConfigFile { target } => {
|
||||
let declaration = mcp_server_wiring(mcp.transport, runtime.as_ref()).to_mcp_json();
|
||||
self.write_file(&join(cwd, target), declaration.as_bytes())
|
||||
.await?;
|
||||
}
|
||||
McpConfigStrategy::TomlConfigHome { target, home_env } => {
|
||||
let path = join(cwd, target);
|
||||
let declaration =
|
||||
mcp_server_wiring(mcp.transport, runtime.as_ref()).to_config_toml();
|
||||
let existing = self.read_optional(&path).await?;
|
||||
let rendered = codex_config_toml(
|
||||
existing.as_deref(),
|
||||
&declaration,
|
||||
cwd.as_str(),
|
||||
project.root.as_str(),
|
||||
);
|
||||
self.write_file(&path, rendered.as_bytes()).await?;
|
||||
env.push((home_env.clone(), parent_dir(cwd, target)));
|
||||
}
|
||||
McpConfigStrategy::OpenCodeConfig { target } => {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(opencode) = profile.opencode.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let config_path = join(cwd, target);
|
||||
let opencode_home = join(cwd, ".opencode");
|
||||
let xdg_config = format!("{opencode_home}/config");
|
||||
let xdg_data = format!("{opencode_home}/data");
|
||||
let xdg_cache = format!("{opencode_home}/cache");
|
||||
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
|
||||
self.create_dir(dir).await?;
|
||||
}
|
||||
let body = opencode_config_json(opencode, project.root.as_str(), runtime.as_ref())
|
||||
.to_string();
|
||||
self.write_file(&config_path, body.as_bytes()).await?;
|
||||
env.extend([
|
||||
("OPENCODE_CONFIG".to_owned(), config_path),
|
||||
("HOME".to_owned(), opencode_home),
|
||||
("XDG_CONFIG_HOME".to_owned(), xdg_config),
|
||||
("XDG_DATA_HOME".to_owned(), xdg_data),
|
||||
("XDG_CACHE_HOME".to_owned(), xdg_cache),
|
||||
("OPENCODE_DISABLE_AUTOUPDATE".to_owned(), "1".to_owned()),
|
||||
]);
|
||||
}
|
||||
McpConfigStrategy::Flag { flag } => {
|
||||
env.push((flag.clone(), cwd.as_str().to_owned()));
|
||||
}
|
||||
McpConfigStrategy::Env { var } => {
|
||||
env.push((var.clone(), cwd.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer {
|
||||
async fn prepare_ticket_assistant(
|
||||
&self,
|
||||
project: &Project,
|
||||
issue_ref: IssueRef,
|
||||
profile: &AgentProfile,
|
||||
prepared: &PreparedContext,
|
||||
requester: &str,
|
||||
) -> Result<StructuredSessionEnvironment, RuntimeError> {
|
||||
let run_dir = self.run_dir(project, issue_ref)?;
|
||||
self.create_dir(run_dir.as_str()).await?;
|
||||
let spec =
|
||||
self.runtime
|
||||
.prepare_invocation(profile, prepared, &run_dir, &SessionPlan::None)?;
|
||||
let mut env = spec.env;
|
||||
self.materialise_context(spec.context_plan, &spec.cwd, prepared, &mut env)
|
||||
.await?;
|
||||
self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env)
|
||||
.await?;
|
||||
Ok(StructuredSessionEnvironment { cwd: spec.cwd, env })
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_server_wiring(
|
||||
transport: domain::profile::McpTransport,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> McpServerWiring {
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
rt.endpoint.clone(),
|
||||
"--project".to_owned(),
|
||||
rt.project_id.clone(),
|
||||
"--requester".to_owned(),
|
||||
rt.requester.clone(),
|
||||
],
|
||||
),
|
||||
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||||
};
|
||||
McpServerWiring::new(command, args, transport)
|
||||
}
|
||||
|
||||
fn opencode_config_json(
|
||||
config: &domain::profile::OpenCodeConfig,
|
||||
project_root: &str,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> Value {
|
||||
let model = config.model.as_str();
|
||||
let opencode_model = format!("llamacpp/{model}");
|
||||
let mut root = Map::new();
|
||||
root.insert(
|
||||
"$schema".to_owned(),
|
||||
Value::String("https://opencode.ai/config.json".to_owned()),
|
||||
);
|
||||
root.insert("model".to_owned(), Value::String(opencode_model.clone()));
|
||||
|
||||
let mut options = Map::new();
|
||||
options.insert("baseURL".to_owned(), Value::String(config.base_url.clone()));
|
||||
if let Some(api_key) = config.api_key.as_ref() {
|
||||
options.insert("apiKey".to_owned(), Value::String(api_key.clone()));
|
||||
}
|
||||
|
||||
root.insert(
|
||||
"provider".to_owned(),
|
||||
json!({
|
||||
"llamacpp": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "llama.cpp",
|
||||
"options": options,
|
||||
"models": {
|
||||
model: {
|
||||
"name": opencode_model,
|
||||
"tool_call": true,
|
||||
"reasoning": config.reasoning_enabled(),
|
||||
"attachment": config.attachment_enabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
rt.endpoint.clone(),
|
||||
"--project".to_owned(),
|
||||
rt.project_id.clone(),
|
||||
"--requester".to_owned(),
|
||||
rt.requester.clone(),
|
||||
],
|
||||
),
|
||||
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||||
};
|
||||
let command_array = std::iter::once(command)
|
||||
.chain(args)
|
||||
.map(Value::String)
|
||||
.collect::<Vec<_>>();
|
||||
root.insert(
|
||||
"mcp".to_owned(),
|
||||
json!({
|
||||
"idea": {
|
||||
"type": "local",
|
||||
"command": command_array,
|
||||
"cwd": project_root,
|
||||
"enabled": true,
|
||||
"timeout": 15000
|
||||
}
|
||||
}),
|
||||
);
|
||||
root.insert(
|
||||
"permission".to_owned(),
|
||||
json!({
|
||||
"bash": "ask",
|
||||
"edit": "ask"
|
||||
}),
|
||||
);
|
||||
root.insert(
|
||||
"disabled_providers".to_owned(),
|
||||
json!(["anthropic", "openai", "gemini", "ollama"]),
|
||||
);
|
||||
Value::Object(root)
|
||||
}
|
||||
|
||||
fn codex_config_toml(
|
||||
existing: Option<&str>,
|
||||
mcp_declaration: &str,
|
||||
run_dir: &str,
|
||||
project_root: &str,
|
||||
) -> String {
|
||||
let mut text = replace_toml_table_block(
|
||||
existing.unwrap_or_default(),
|
||||
"mcp_servers.idea",
|
||||
mcp_declaration.trim_end(),
|
||||
);
|
||||
text = ensure_codex_project_trust(&text, run_dir);
|
||||
text = ensure_codex_project_trust(&text, project_root);
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
|
||||
let header = format!("[{table}]");
|
||||
let mut out = Vec::new();
|
||||
let mut skipping = false;
|
||||
let mut inserted = false;
|
||||
|
||||
for line in existing.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == header {
|
||||
if !inserted {
|
||||
out.extend(replacement.lines().map(ToOwned::to_owned));
|
||||
inserted = true;
|
||||
}
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
skipping = false;
|
||||
}
|
||||
if !skipping {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if !inserted {
|
||||
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
|
||||
out.push(String::new());
|
||||
}
|
||||
out.extend(replacement.lines().map(ToOwned::to_owned));
|
||||
}
|
||||
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
return existing.to_owned();
|
||||
}
|
||||
let header = format!(r#"[projects.{}]"#, toml_quoted(path));
|
||||
if existing.lines().any(|line| line.trim() == header) {
|
||||
return existing.to_owned();
|
||||
}
|
||||
let mut text = existing.trim_end().to_owned();
|
||||
if !text.is_empty() {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
text.push_str(&header);
|
||||
text.push_str("\ntrust_level = \"trusted\"\n");
|
||||
text
|
||||
}
|
||||
|
||||
fn toml_quoted(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
let base = base.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{rel}")
|
||||
}
|
||||
|
||||
fn parent_abs(path: &str) -> Option<&str> {
|
||||
path.rsplit_once(['/', '\\'])
|
||||
.map(|(parent, _)| parent)
|
||||
.filter(|parent| !parent.is_empty())
|
||||
}
|
||||
|
||||
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
|
||||
let full = join(base, rel);
|
||||
match full.rsplit_once(['/', '\\']) {
|
||||
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
|
||||
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ pub mod sprints;
|
||||
pub mod store;
|
||||
pub mod timeparse;
|
||||
|
||||
pub use assistant::FsAssistantContextStore;
|
||||
pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer};
|
||||
pub use background_task::{
|
||||
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
|
||||
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
|
||||
|
||||
@ -482,7 +482,15 @@ impl McpServer {
|
||||
),
|
||||
));
|
||||
}
|
||||
if matches!(name, "idea_ticket_update" | "idea_ticket_update_carnet") {
|
||||
if matches!(
|
||||
name,
|
||||
"idea_ticket_update"
|
||||
| "idea_ticket_update_status"
|
||||
| "idea_ticket_update_priority"
|
||||
| "idea_ticket_update_carnet"
|
||||
| "idea_ticket_link"
|
||||
| "idea_ticket_unlink"
|
||||
) {
|
||||
let raw_ref = arguments
|
||||
.get("ref")
|
||||
.and_then(Value::as_str)
|
||||
|
||||
Reference in New Issue
Block a user