feat: add ticket record language config

This commit is contained in:
2026-06-08 17:02:16 +09:00
parent 17ac5b04f0
commit fb261bb4f6
9 changed files with 310 additions and 28 deletions
+94
View File
@@ -34,6 +34,9 @@ pub fn ticket_config_scaffold() -> String {
"root = \"{}\"\n",
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 {
out.push_str(&format!(
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
@@ -65,6 +68,7 @@ pub enum TicketConfigError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketConfig {
pub backend: TicketBackendConfig,
pub ticket: TicketRecordConfig,
pub roles: TicketRoleProfiles,
}
@@ -73,6 +77,7 @@ impl TicketConfig {
let workspace_root = workspace_root.as_ref();
Self {
backend: TicketBackendConfig::default_for_workspace(workspace_root),
ticket: TicketRecordConfig::default(),
roles: TicketRoleProfiles::default(),
}
}
@@ -109,6 +114,13 @@ impl TicketConfig {
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 {
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)]
pub struct TicketRoleProfiles {
inner: BTreeMap<TicketRole, TicketRoleConfig>,
@@ -473,9 +520,26 @@ struct RawTicketConfig {
#[serde(default)]
backend: RawBackendConfig,
#[serde(default)]
ticket: RawTicketRecordConfig,
#[serde(default)]
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 {
fn resolve(
self,
@@ -502,6 +566,7 @@ impl RawTicketConfig {
message,
}
})?,
ticket: self.ticket.resolve(),
roles,
})
}
@@ -605,6 +670,7 @@ mod tests {
config.backend.root,
temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)
);
assert_eq!(config.ticket_record_language(), None);
for role in TicketRole::ALL {
let role_config = config.role(role);
assert_eq!(role_config.profile.as_str(), "inherit");
@@ -623,6 +689,9 @@ mod tests {
provider = "builtin:yoi_local"
root = "custom-tickets"
[ticket]
language = "Japanese"
[roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
@@ -656,6 +725,7 @@ workflow = "ticket-orchestrator-routing"
TicketBackendProvider::BuiltinYoiLocal
);
assert_eq!(config.backend.root, temp.path().join("custom-tickets"));
assert_eq!(config.ticket_record_language(), Some("Japanese"));
assert_eq!(
config.profile_for(TicketRole::Intake).as_str(),
"project:intake"
@@ -681,6 +751,7 @@ workflow = "ticket-orchestrator-routing"
assert!(scaffold.contains("[backend]\n"));
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
assert!(scaffold.contains("root = \".yoi/tickets\""));
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\""));
for role in TicketRole::ALL {
assert!(scaffold.contains(&format!("[roles.{role}]")));
assert!(scaffold.contains(&format!(
@@ -868,6 +939,29 @@ root = "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]
fn unsupported_backend_provider_is_rejected() {
let temp = TempDir::new().unwrap();
+125 -11
View File
@@ -38,6 +38,26 @@ const REQUIRED_FIELDS: [&str; 11] = [
];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 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>;
@@ -492,9 +512,7 @@ impl NewTicket {
kind: "task".to_string(),
priority: "P2".to_string(),
labels: Vec::new(),
body: MarkdownText::new(
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n",
),
body: MarkdownText::new(DEFAULT_TICKET_BODY),
author: None,
assignee: None,
legacy_ticket: None,
@@ -693,17 +711,86 @@ pub trait TicketBackend {
#[derive(Debug, Clone)]
pub struct LocalTicketBackend {
root: PathBuf,
record_language: Option<String>,
}
impl LocalTicketBackend {
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 {
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<()> {
for status in STATUSES {
let dir = self.status_dir(status);
@@ -1099,10 +1186,17 @@ impl TicketBackend for LocalTicketBackend {
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())?;
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())?;
Ok(TicketRef {
@@ -1243,7 +1337,7 @@ impl TicketBackend for LocalTicketBackend {
TicketWorkflowState::Ready.as_str(),
TicketWorkflowState::Queued.as_str(),
"queued",
"Ticket queued for Orchestrator routing.\n",
self.queued_ready_body(queued_by),
);
change.author = Some(queued_by.to_string());
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())])?;
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(
&new_dir,
"status_changed",
"Status changed",
self.generated_heading("Status changed", "ステータス変更"),
&author,
Some(status.as_str()),
&[],
@@ -1336,7 +1430,7 @@ impl TicketBackend for LocalTicketBackend {
current_workflow_state.as_str(),
TicketWorkflowState::Done.as_str(),
"closed",
"Ticket closed; workflow_state set to done.\n",
self.closed_workflow_state_body(),
);
change.author = Some(default_author());
self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?;
@@ -1357,7 +1451,7 @@ impl TicketBackend for LocalTicketBackend {
self.append_thread_event(
&closed_dir,
"close",
"Closed",
self.generated_heading("Closed", "完了"),
&author,
Some("closed"),
&[],
@@ -2493,6 +2587,26 @@ workflow_state: intake
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]
fn create_round_trips_numeric_looking_string_frontmatter_values() {
let tmp = TempDir::new().unwrap();
+2 -4
View File
@@ -583,10 +583,8 @@ impl Tool for TicketIntakeReadyTool {
let from = TicketWorkflowState::Intake;
let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string());
let body = params.state_change_body.unwrap_or_else(|| {
format!(
"Ticket intake complete; workflow_state {} -> ready.\n",
from.as_str()
)
self.backend
.default_intake_ready_state_change_body(from.as_str())
});
let mut summary = TicketIntakeSummary::new(params.intake_summary);
summary.author = params.author.clone();