feat: add ticket record language config
This commit is contained in:
parent
17ac5b04f0
commit
fb261bb4f6
|
|
@ -2,6 +2,9 @@
|
||||||
provider = "builtin:yoi_local"
|
provider = "builtin:yoi_local"
|
||||||
root = ".yoi/tickets"
|
root = ".yoi/tickets"
|
||||||
|
|
||||||
|
[ticket]
|
||||||
|
language = "Japanese"
|
||||||
|
|
||||||
[roles.intake]
|
[roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
workflow = "ticket-intake-workflow"
|
workflow = "ticket-intake-workflow"
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ pub struct TicketRoleLaunchContext {
|
||||||
pub ticket: Option<TicketRef>,
|
pub ticket: Option<TicketRef>,
|
||||||
pub user_instruction: Option<String>,
|
pub user_instruction: Option<String>,
|
||||||
pub intake_handoff: Option<TicketIntakeHandoff>,
|
pub intake_handoff: Option<TicketIntakeHandoff>,
|
||||||
|
pub ticket_record_language: Option<String>,
|
||||||
pub intent_packet: Option<String>,
|
pub intent_packet: Option<String>,
|
||||||
pub worktree_path: Option<PathBuf>,
|
pub worktree_path: Option<PathBuf>,
|
||||||
pub branch: Option<String>,
|
pub branch: Option<String>,
|
||||||
|
|
@ -123,6 +124,7 @@ impl TicketRoleLaunchContext {
|
||||||
ticket: None,
|
ticket: None,
|
||||||
user_instruction: None,
|
user_instruction: None,
|
||||||
intake_handoff: None,
|
intake_handoff: None,
|
||||||
|
ticket_record_language: None,
|
||||||
intent_packet: None,
|
intent_packet: None,
|
||||||
worktree_path: None,
|
worktree_path: None,
|
||||||
branch: None,
|
branch: None,
|
||||||
|
|
@ -246,9 +248,12 @@ pub fn plan_ticket_role_launch(
|
||||||
|
|
||||||
/// Construct a launch plan from an already-loaded Ticket config.
|
/// Construct a launch plan from an already-loaded Ticket config.
|
||||||
pub fn plan_ticket_role_launch_with_config(
|
pub fn plan_ticket_role_launch_with_config(
|
||||||
context: TicketRoleLaunchContext,
|
mut context: TicketRoleLaunchContext,
|
||||||
config: &TicketConfig,
|
config: &TicketConfig,
|
||||||
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
|
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
|
||||||
|
if context.ticket_record_language.is_none() {
|
||||||
|
context.ticket_record_language = config.ticket_record_language().map(str::to_string);
|
||||||
|
}
|
||||||
let role_config = config.role_launch_config(context.role)?;
|
let role_config = config.role_launch_config(context.role)?;
|
||||||
let profile = role_config.profile.as_str().to_string();
|
let profile = role_config.profile.as_str().to_string();
|
||||||
let workflow = role_config.workflow.as_str().to_string();
|
let workflow = role_config.workflow.as_str().to_string();
|
||||||
|
|
@ -488,6 +493,14 @@ fn build_launch_prompt(
|
||||||
None => out.push_str("Configured launch_prompt ref: none\n"),
|
None => out.push_str("Configured launch_prompt ref: none\n"),
|
||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
|
match non_empty(context.ticket_record_language.as_deref()) {
|
||||||
|
Some(language) => {
|
||||||
|
push_bounded_field(&mut out, "Ticket record language", language);
|
||||||
|
out.push_str("Ticket record language guidance: write durable Ticket item/thread/resolution text and Ticket tool bodies in this language. This does not change normal worker response language or memory/Knowledge generation language. Do not translate protocol literals, file paths, commands, logs, identifiers, or quoted external text solely because this language is configured.\n");
|
||||||
|
}
|
||||||
|
None => out.push_str("Ticket record language: not configured; preserve existing/default Ticket record language behavior.\n"),
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
if let Some(ticket) = &context.ticket {
|
if let Some(ticket) = &context.ticket {
|
||||||
ticket.append_prompt_lines(&mut out);
|
ticket.append_prompt_lines(&mut out);
|
||||||
|
|
@ -935,6 +948,31 @@ profile = "project:no-such-ticket-role-profile"
|
||||||
assert_eq!(plan.profile, "builtin:default");
|
assert_eq!(plan.profile, "builtin:default");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_ticket_record_language_is_included_in_role_prompt() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
write_config(
|
||||||
|
temp.path(),
|
||||||
|
r#"
|
||||||
|
[ticket]
|
||||||
|
language = "Japanese"
|
||||||
|
|
||||||
|
[roles.intake]
|
||||||
|
profile = "builtin:default"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
||||||
|
|
||||||
|
let plan = plan_ticket_role_launch(context).unwrap();
|
||||||
|
let text = text_segment(&plan);
|
||||||
|
|
||||||
|
assert!(text.contains("Ticket record language: Japanese"));
|
||||||
|
assert!(text.contains("write durable Ticket item/thread/resolution text"));
|
||||||
|
assert!(text.contains("does not change normal worker response language"));
|
||||||
|
assert!(text.contains("memory/Knowledge generation language"));
|
||||||
|
assert!(text.contains("Do not translate protocol literals"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scaffold_config_allows_intake_and_orchestrator_launch_planning() {
|
fn scaffold_config_allows_intake_and_orchestrator_launch_planning() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ impl TicketFeatureAccess {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct TicketFeature {
|
pub struct TicketFeature {
|
||||||
backend_root: PathBuf,
|
backend_root: PathBuf,
|
||||||
|
record_language: Option<String>,
|
||||||
config_error: Option<String>,
|
config_error: Option<String>,
|
||||||
access: TicketFeatureAccess,
|
access: TicketFeatureAccess,
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +56,7 @@ impl TicketFeature {
|
||||||
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
|
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
|
||||||
Self {
|
Self {
|
||||||
backend_root: backend_root.into(),
|
backend_root: backend_root.into(),
|
||||||
|
record_language: None,
|
||||||
config_error: None,
|
config_error: None,
|
||||||
access,
|
access,
|
||||||
}
|
}
|
||||||
|
|
@ -70,9 +72,16 @@ impl TicketFeature {
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let workspace = workspace.as_ref();
|
let workspace = workspace.as_ref();
|
||||||
match TicketConfig::load_workspace(workspace) {
|
match TicketConfig::load_workspace(workspace) {
|
||||||
Ok(config) => Self::new_with_access(config.backend_root().to_path_buf(), access),
|
Ok(config) => {
|
||||||
|
let backend_root = config.backend_root().to_path_buf();
|
||||||
|
let record_language = config.ticket_record_language().map(str::to_string);
|
||||||
|
let mut feature = Self::new_with_access(backend_root, access);
|
||||||
|
feature.record_language = record_language;
|
||||||
|
feature
|
||||||
|
}
|
||||||
Err(error) => Self {
|
Err(error) => Self {
|
||||||
backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
||||||
|
record_language: None,
|
||||||
config_error: Some(error.to_string()),
|
config_error: Some(error.to_string()),
|
||||||
access,
|
access,
|
||||||
},
|
},
|
||||||
|
|
@ -149,7 +158,8 @@ impl FeatureModule for TicketFeature {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let authority = self.authority();
|
let authority = self.authority();
|
||||||
let backend = LocalTicketBackend::new(usable_root);
|
let backend = LocalTicketBackend::new(usable_root)
|
||||||
|
.with_record_language(self.record_language.as_deref());
|
||||||
let allowed_tool_names = self.access.tool_names();
|
let allowed_tool_names = self.access.tool_names();
|
||||||
let mut tools = context.tools();
|
let mut tools = context.tools();
|
||||||
for definition in ticket_tools(backend) {
|
for definition in ticket_tools(backend) {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ pub fn ticket_config_scaffold() -> String {
|
||||||
"root = \"{}\"\n",
|
"root = \"{}\"\n",
|
||||||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||||
));
|
));
|
||||||
|
out.push_str(
|
||||||
|
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n",
|
||||||
|
);
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
||||||
|
|
@ -65,6 +68,7 @@ pub enum TicketConfigError {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketConfig {
|
pub struct TicketConfig {
|
||||||
pub backend: TicketBackendConfig,
|
pub backend: TicketBackendConfig,
|
||||||
|
pub ticket: TicketRecordConfig,
|
||||||
pub roles: TicketRoleProfiles,
|
pub roles: TicketRoleProfiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +77,7 @@ impl TicketConfig {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
Self {
|
Self {
|
||||||
backend: TicketBackendConfig::default_for_workspace(workspace_root),
|
backend: TicketBackendConfig::default_for_workspace(workspace_root),
|
||||||
|
ticket: TicketRecordConfig::default(),
|
||||||
roles: TicketRoleProfiles::default(),
|
roles: TicketRoleProfiles::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +114,13 @@ impl TicketConfig {
|
||||||
self.backend.root.as_path()
|
self.backend.root.as_path()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ticket_record_language(&self) -> Option<&str> {
|
||||||
|
self.ticket
|
||||||
|
.language
|
||||||
|
.as_ref()
|
||||||
|
.map(TicketRecordLanguage::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn role(&self, role: TicketRole) -> &TicketRoleConfig {
|
pub fn role(&self, role: TicketRole) -> &TicketRoleConfig {
|
||||||
self.roles.get(role)
|
self.roles.get(role)
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +243,41 @@ impl fmt::Display for TicketRole {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TicketRecordConfig {
|
||||||
|
pub language: Option<TicketRecordLanguage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TicketRecordConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { language: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TicketRecordLanguage(String);
|
||||||
|
|
||||||
|
impl TicketRecordLanguage {
|
||||||
|
pub fn new(language: impl Into<String>) -> Result<Self, String> {
|
||||||
|
let language = normalized_non_empty(language, "ticket record language")?;
|
||||||
|
Ok(Self(language))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for TicketRecordLanguage {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
TicketRecordLanguage::new(value).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketRoleProfiles {
|
pub struct TicketRoleProfiles {
|
||||||
inner: BTreeMap<TicketRole, TicketRoleConfig>,
|
inner: BTreeMap<TicketRole, TicketRoleConfig>,
|
||||||
|
|
@ -473,9 +520,26 @@ struct RawTicketConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
backend: RawBackendConfig,
|
backend: RawBackendConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
ticket: RawTicketRecordConfig,
|
||||||
|
#[serde(default)]
|
||||||
roles: BTreeMap<String, RawTicketRoleConfig>,
|
roles: BTreeMap<String, RawTicketRoleConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct RawTicketRecordConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
language: Option<TicketRecordLanguage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTicketRecordConfig {
|
||||||
|
fn resolve(self) -> TicketRecordConfig {
|
||||||
|
TicketRecordConfig {
|
||||||
|
language: self.language,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RawTicketConfig {
|
impl RawTicketConfig {
|
||||||
fn resolve(
|
fn resolve(
|
||||||
self,
|
self,
|
||||||
|
|
@ -502,6 +566,7 @@ impl RawTicketConfig {
|
||||||
message,
|
message,
|
||||||
}
|
}
|
||||||
})?,
|
})?,
|
||||||
|
ticket: self.ticket.resolve(),
|
||||||
roles,
|
roles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -605,6 +670,7 @@ mod tests {
|
||||||
config.backend.root,
|
config.backend.root,
|
||||||
temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)
|
temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)
|
||||||
);
|
);
|
||||||
|
assert_eq!(config.ticket_record_language(), None);
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
let role_config = config.role(role);
|
let role_config = config.role(role);
|
||||||
assert_eq!(role_config.profile.as_str(), "inherit");
|
assert_eq!(role_config.profile.as_str(), "inherit");
|
||||||
|
|
@ -623,6 +689,9 @@ mod tests {
|
||||||
provider = "builtin:yoi_local"
|
provider = "builtin:yoi_local"
|
||||||
root = "custom-tickets"
|
root = "custom-tickets"
|
||||||
|
|
||||||
|
[ticket]
|
||||||
|
language = "Japanese"
|
||||||
|
|
||||||
[roles.intake]
|
[roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "$workspace/ticket/intake/launch"
|
||||||
|
|
@ -656,6 +725,7 @@ workflow = "ticket-orchestrator-routing"
|
||||||
TicketBackendProvider::BuiltinYoiLocal
|
TicketBackendProvider::BuiltinYoiLocal
|
||||||
);
|
);
|
||||||
assert_eq!(config.backend.root, temp.path().join("custom-tickets"));
|
assert_eq!(config.backend.root, temp.path().join("custom-tickets"));
|
||||||
|
assert_eq!(config.ticket_record_language(), Some("Japanese"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.profile_for(TicketRole::Intake).as_str(),
|
config.profile_for(TicketRole::Intake).as_str(),
|
||||||
"project:intake"
|
"project:intake"
|
||||||
|
|
@ -681,6 +751,7 @@ workflow = "ticket-orchestrator-routing"
|
||||||
assert!(scaffold.contains("[backend]\n"));
|
assert!(scaffold.contains("[backend]\n"));
|
||||||
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
|
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
|
||||||
assert!(scaffold.contains("root = \".yoi/tickets\""));
|
assert!(scaffold.contains("root = \".yoi/tickets\""));
|
||||||
|
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\""));
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
assert!(scaffold.contains(&format!("[roles.{role}]")));
|
assert!(scaffold.contains(&format!("[roles.{role}]")));
|
||||||
assert!(scaffold.contains(&format!(
|
assert!(scaffold.contains(&format!(
|
||||||
|
|
@ -868,6 +939,29 @@ root = "legacy-tickets"
|
||||||
assert_eq!(config.backend_root(), temp.path().join("legacy-tickets"));
|
assert_eq!(config.backend_root(), temp.path().join("legacy-tickets"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_ticket_record_language() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
write_config(
|
||||||
|
temp.path(),
|
||||||
|
r#"
|
||||||
|
[backend]
|
||||||
|
provider = "builtin:yoi_local"
|
||||||
|
root = ".yoi/tickets"
|
||||||
|
|
||||||
|
[ticket]
|
||||||
|
language = " "
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("ticket record language must not be empty")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unsupported_backend_provider_is_rejected() {
|
fn unsupported_backend_provider_is_rejected() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,26 @@ const REQUIRED_FIELDS: [&str; 11] = [
|
||||||
];
|
];
|
||||||
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
||||||
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
|
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
|
||||||
|
const DEFAULT_TICKET_BODY: &str =
|
||||||
|
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n";
|
||||||
|
const JAPANESE_TICKET_BODY: &str =
|
||||||
|
"## 背景\n\nLocalTicketBackend によって作成されました。\n\n## 受け入れ条件\n\n- 未定\n";
|
||||||
|
|
||||||
|
fn normalized_record_language(language: &str) -> Option<String> {
|
||||||
|
let language = language.trim();
|
||||||
|
(!language.is_empty()).then(|| language.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_japanese_record_language(language: Option<&str>) -> bool {
|
||||||
|
let Some(language) = language else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let language = language.trim();
|
||||||
|
language.eq_ignore_ascii_case("japanese")
|
||||||
|
|| language.eq_ignore_ascii_case("ja")
|
||||||
|
|| language.eq_ignore_ascii_case("ja-JP")
|
||||||
|
|| language.contains("日本語")
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, TicketError>;
|
pub type Result<T> = std::result::Result<T, TicketError>;
|
||||||
|
|
||||||
|
|
@ -492,9 +512,7 @@ impl NewTicket {
|
||||||
kind: "task".to_string(),
|
kind: "task".to_string(),
|
||||||
priority: "P2".to_string(),
|
priority: "P2".to_string(),
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
body: MarkdownText::new(
|
body: MarkdownText::new(DEFAULT_TICKET_BODY),
|
||||||
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n",
|
|
||||||
),
|
|
||||||
author: None,
|
author: None,
|
||||||
assignee: None,
|
assignee: None,
|
||||||
legacy_ticket: None,
|
legacy_ticket: None,
|
||||||
|
|
@ -693,17 +711,86 @@ pub trait TicketBackend {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalTicketBackend {
|
pub struct LocalTicketBackend {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
record_language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalTicketBackend {
|
impl LocalTicketBackend {
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
Self { root: root.into() }
|
Self {
|
||||||
|
root: root.into(),
|
||||||
|
record_language: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_record_language(mut self, language: Option<&str>) -> Self {
|
||||||
|
self.record_language = language.and_then(normalized_record_language);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_language(&self) -> Option<&str> {
|
||||||
|
self.record_language.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
self.root.as_path()
|
self.root.as_path()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||||
|
if is_japanese_record_language(self.record_language()) {
|
||||||
|
format!("Ticket intake が完了しました。workflow_state {from} -> ready。\n")
|
||||||
|
} else {
|
||||||
|
format!("Ticket intake complete; workflow_state {from} -> ready.\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str {
|
||||||
|
if is_japanese_record_language(self.record_language()) {
|
||||||
|
japanese
|
||||||
|
} else {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_default_body(&self) -> &'static str {
|
||||||
|
if is_japanese_record_language(self.record_language()) {
|
||||||
|
JAPANESE_TICKET_BODY
|
||||||
|
} else {
|
||||||
|
DEFAULT_TICKET_BODY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn created_event_body(&self) -> &'static str {
|
||||||
|
if is_japanese_record_language(self.record_language()) {
|
||||||
|
"LocalTicketBackend によって作成されました。"
|
||||||
|
} else {
|
||||||
|
"Created by LocalTicketBackend create."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queued_ready_body(&self, queued_by: &str) -> String {
|
||||||
|
if is_japanese_record_language(self.record_language()) {
|
||||||
|
format!("Ticket を `{queued_by}` が queued にしました。\n")
|
||||||
|
} else {
|
||||||
|
"Ticket queued for Orchestrator routing.\n".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_changed_body(&self, status: TicketStatus) -> String {
|
||||||
|
if is_japanese_record_language(self.record_language()) {
|
||||||
|
format!("Ticket status を `{}` に変更しました。\n", status.as_str())
|
||||||
|
} else {
|
||||||
|
format!("Status changed to `{}`.\n", status.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn closed_workflow_state_body(&self) -> &'static str {
|
||||||
|
if is_japanese_record_language(self.record_language()) {
|
||||||
|
"Ticket closed; workflow_state を done に設定しました。\n"
|
||||||
|
} else {
|
||||||
|
"Ticket closed; workflow_state set to done.\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_backend_dirs(&self) -> Result<()> {
|
fn ensure_backend_dirs(&self) -> Result<()> {
|
||||||
for status in STATUSES {
|
for status in STATUSES {
|
||||||
let dir = self.status_dir(status);
|
let dir = self.status_dir(status);
|
||||||
|
|
@ -1099,10 +1186,17 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
format_yaml_string_scalar(queued_at.as_str()),
|
format_yaml_string_scalar(queued_at.as_str()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let item = serialize_item(&fields, input.body.as_str());
|
let item_body = if input.body.as_str() == DEFAULT_TICKET_BODY {
|
||||||
|
self.generated_default_body()
|
||||||
|
} else {
|
||||||
|
input.body.as_str()
|
||||||
|
};
|
||||||
|
let item = serialize_item(&fields, item_body);
|
||||||
atomic_write(&dir.join("item.md"), item.as_bytes())?;
|
atomic_write(&dir.join("item.md"), item.as_bytes())?;
|
||||||
let thread = format!(
|
let thread = format!(
|
||||||
"{create_comment}\n\n## Created\n\nCreated by LocalTicketBackend create.\n\n---\n"
|
"{create_comment}\n\n## {}\n\n{}\n\n---\n",
|
||||||
|
self.generated_heading("Created", "作成"),
|
||||||
|
self.created_event_body()
|
||||||
);
|
);
|
||||||
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
|
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
|
||||||
Ok(TicketRef {
|
Ok(TicketRef {
|
||||||
|
|
@ -1243,7 +1337,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
TicketWorkflowState::Ready.as_str(),
|
TicketWorkflowState::Ready.as_str(),
|
||||||
TicketWorkflowState::Queued.as_str(),
|
TicketWorkflowState::Queued.as_str(),
|
||||||
"queued",
|
"queued",
|
||||||
"Ticket queued for Orchestrator routing.\n",
|
self.queued_ready_body(queued_by),
|
||||||
);
|
);
|
||||||
change.author = Some(queued_by.to_string());
|
change.author = Some(queued_by.to_string());
|
||||||
self.apply_workflow_state_change(
|
self.apply_workflow_state_change(
|
||||||
|
|
@ -1294,11 +1388,11 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
}
|
}
|
||||||
self.set_frontmatter_fields(&new_dir.join("item.md"), &[("status", status.as_str())])?;
|
self.set_frontmatter_fields(&new_dir.join("item.md"), &[("status", status.as_str())])?;
|
||||||
let author = default_author();
|
let author = default_author();
|
||||||
let body = MarkdownText::new(format!("Status changed to `{}`.\n", status.as_str()));
|
let body = MarkdownText::new(self.status_changed_body(status));
|
||||||
self.append_thread_event(
|
self.append_thread_event(
|
||||||
&new_dir,
|
&new_dir,
|
||||||
"status_changed",
|
"status_changed",
|
||||||
"Status changed",
|
self.generated_heading("Status changed", "ステータス変更"),
|
||||||
&author,
|
&author,
|
||||||
Some(status.as_str()),
|
Some(status.as_str()),
|
||||||
&[],
|
&[],
|
||||||
|
|
@ -1336,7 +1430,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
current_workflow_state.as_str(),
|
current_workflow_state.as_str(),
|
||||||
TicketWorkflowState::Done.as_str(),
|
TicketWorkflowState::Done.as_str(),
|
||||||
"closed",
|
"closed",
|
||||||
"Ticket closed; workflow_state set to done.\n",
|
self.closed_workflow_state_body(),
|
||||||
);
|
);
|
||||||
change.author = Some(default_author());
|
change.author = Some(default_author());
|
||||||
self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?;
|
self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?;
|
||||||
|
|
@ -1357,7 +1451,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
self.append_thread_event(
|
self.append_thread_event(
|
||||||
&closed_dir,
|
&closed_dir,
|
||||||
"close",
|
"close",
|
||||||
"Closed",
|
self.generated_heading("Closed", "完了"),
|
||||||
&author,
|
&author,
|
||||||
Some("closed"),
|
Some("closed"),
|
||||||
&[],
|
&[],
|
||||||
|
|
@ -2493,6 +2587,26 @@ workflow_state: intake
|
||||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_uses_configured_japanese_record_language_for_generated_defaults() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let backend = LocalTicketBackend::new(tmp.path().join("tickets"))
|
||||||
|
.with_record_language(Some("Japanese"));
|
||||||
|
|
||||||
|
let created = backend.create(NewTicket::new("日本語レコード")).unwrap();
|
||||||
|
let dir = backend
|
||||||
|
.root()
|
||||||
|
.join(TicketStatus::Open.as_str())
|
||||||
|
.join(created.id.as_str());
|
||||||
|
let item = fs::read_to_string(dir.join("item.md")).unwrap();
|
||||||
|
let thread = fs::read_to_string(dir.join("thread.md")).unwrap();
|
||||||
|
|
||||||
|
assert!(item.contains("## 背景"));
|
||||||
|
assert!(item.contains("LocalTicketBackend によって作成されました。"));
|
||||||
|
assert!(thread.contains("## 作成"));
|
||||||
|
assert!(thread.contains("LocalTicketBackend によって作成されました。"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_round_trips_numeric_looking_string_frontmatter_values() {
|
fn create_round_trips_numeric_looking_string_frontmatter_values() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -583,10 +583,8 @@ impl Tool for TicketIntakeReadyTool {
|
||||||
let from = TicketWorkflowState::Intake;
|
let from = TicketWorkflowState::Intake;
|
||||||
let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string());
|
let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string());
|
||||||
let body = params.state_change_body.unwrap_or_else(|| {
|
let body = params.state_change_body.unwrap_or_else(|| {
|
||||||
format!(
|
self.backend
|
||||||
"Ticket intake complete; workflow_state {} -> ready.\n",
|
.default_intake_ready_state_change_body(from.as_str())
|
||||||
from.as_str()
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
let mut summary = TicketIntakeSummary::new(params.intake_summary);
|
let mut summary = TicketIntakeSummary::new(params.intake_summary);
|
||||||
summary.author = params.author.clone();
|
summary.author = params.author.clone();
|
||||||
|
|
|
||||||
|
|
@ -1955,7 +1955,8 @@ async fn dispatch_ticket_action(
|
||||||
}
|
}
|
||||||
let config = TicketConfig::load_workspace(&request.workspace_root)
|
let config = TicketConfig::load_workspace(&request.workspace_root)
|
||||||
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
|
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
|
||||||
let backend = LocalTicketBackend::new(config.backend_root());
|
let backend = LocalTicketBackend::new(config.backend_root())
|
||||||
|
.with_record_language(config.ticket_record_language());
|
||||||
if request.action == NextUserAction::Close {
|
if request.action == NextUserAction::Close {
|
||||||
return dispatch_panel_close(&backend, &request.ticket_id);
|
return dispatch_panel_close(&backend, &request.ticket_id);
|
||||||
}
|
}
|
||||||
|
|
@ -2064,7 +2065,7 @@ fn dispatch_panel_close(
|
||||||
return Err(TicketActionError::Stale(blocker));
|
return Err(TicketActionError::Stale(blocker));
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolution = panel_close_resolution(&ticket);
|
let resolution = panel_close_resolution(&ticket, backend.record_language());
|
||||||
backend
|
backend
|
||||||
.close(TicketIdOrSlug::Id(ticket_id.to_owned()), resolution)
|
.close(TicketIdOrSlug::Id(ticket_id.to_owned()), resolution)
|
||||||
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||||
|
|
@ -2115,11 +2116,32 @@ fn non_empty_ticket_field(value: Option<&str>) -> Option<&str> {
|
||||||
value.map(str::trim).filter(|value| !value.is_empty())
|
value.map(str::trim).filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_close_resolution(ticket: &ticket::Ticket) -> ticket::MarkdownText {
|
fn panel_close_resolution(
|
||||||
ticket::MarkdownText::new(format!(
|
ticket: &ticket::Ticket,
|
||||||
"Closed from the workspace Panel because Ticket `{}` (`{}`) had already reached `workflow_state: done`.\n\nNo implementation work, workflow-state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n",
|
record_language: Option<&str>,
|
||||||
ticket.meta.slug, ticket.meta.id
|
) -> ticket::MarkdownText {
|
||||||
))
|
if is_japanese_ticket_record_language(record_language) {
|
||||||
|
ticket::MarkdownText::new(format!(
|
||||||
|
"Ticket `{}` (`{}`) はすでに `workflow_state: done` に到達していたため、workspace Panel から close しました。\n\nこの Close action によって、実装作業、workflow-state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。\n",
|
||||||
|
ticket.meta.slug, ticket.meta.id
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
ticket::MarkdownText::new(format!(
|
||||||
|
"Closed from the workspace Panel because Ticket `{}` (`{}`) had already reached `workflow_state: done`.\n\nNo implementation work, workflow-state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n",
|
||||||
|
ticket.meta.slug, ticket.meta.id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_japanese_ticket_record_language(language: Option<&str>) -> bool {
|
||||||
|
let Some(language) = language else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let language = language.trim();
|
||||||
|
language.eq_ignore_ascii_case("japanese")
|
||||||
|
|| language.eq_ignore_ascii_case("ja")
|
||||||
|
|| language.eq_ignore_ascii_case("ja-JP")
|
||||||
|
|| language.contains("日本語")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_panel_decision(
|
fn append_panel_decision(
|
||||||
|
|
|
||||||
|
|
@ -552,7 +552,8 @@ fn build_workspace_panel_with_registry_model(
|
||||||
match TicketConfig::load_workspace(workspace_root) {
|
match TicketConfig::load_workspace(workspace_root) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
model.header.ticket_root = config.backend_root().to_path_buf();
|
model.header.ticket_root = config.backend_root().to_path_buf();
|
||||||
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf());
|
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||||
|
.with_record_language(config.ticket_record_language());
|
||||||
match build_ticket_rows(&backend, pods, registry) {
|
match build_ticket_rows(&backend, pods, registry) {
|
||||||
Ok(rows) => model.rows.extend(rows),
|
Ok(rows) => model.rows.extend(rows),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,8 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||||
|
|
||||||
fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
|
fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
|
||||||
let config = TicketConfig::load_workspace(workspace)?;
|
let config = TicketConfig::load_workspace(workspace)?;
|
||||||
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()))
|
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||||
|
.with_record_language(config.ticket_record_language()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create(
|
fn create(
|
||||||
|
|
@ -804,7 +805,7 @@ fn default_author() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn help_text() -> &'static str {
|
fn help_text() -> &'static str {
|
||||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title> [--slug <slug>] [--kind <kind>] [--priority P2] [--label a,b]\n yoi ticket list [--status open|pending|closed|all]\n yoi ticket show <id-or-slug>\n yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id-or-slug> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket status <id-or-slug> <open|pending|closed>\n yoi ticket close <id-or-slug> (--resolution <text>|--file <path>)\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n"
|
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title> [--slug <slug>] [--kind <kind>] [--priority P2] [--label a,b]\n yoi ticket list [--status open|pending|closed|all]\n yoi ticket show <id-or-slug>\n yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id-or-slug> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket status <id-or-slug> <open|pending|closed>\n yoi ticket close <id-or-slug> (--resolution <text>|--file <path>)\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -841,6 +842,7 @@ mod tests {
|
||||||
assert!(config.contains("[backend]\n"));
|
assert!(config.contains("[backend]\n"));
|
||||||
assert!(config.contains("provider = \"builtin:yoi_local\""));
|
assert!(config.contains("provider = \"builtin:yoi_local\""));
|
||||||
assert!(config.contains("root = \".yoi/tickets\""));
|
assert!(config.contains("root = \".yoi/tickets\""));
|
||||||
|
assert!(config.contains("# [ticket]\n# language = \"Japanese\""));
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
assert!(config.contains(&format!(
|
assert!(config.contains(&format!(
|
||||||
"[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"",
|
"[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user