use std::path::PathBuf;
use indexmap::IndexMap;
use serde::de;
use serde::{Deserialize, Deserializer, Serialize};
use super::WorkflowSchema;
use super::diagnose::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueIntakeSchema {
pub pull: IssuePullSchema,
#[serde(flatten)]
unknown_fields: serde_yaml::Mapping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssuePullSchema {
pub command: String,
#[serde(default = "default_idle_sec")]
pub idle_sec: u64,
#[serde(flatten)]
unknown_fields: serde_yaml::Mapping,
}
fn default_idle_sec() -> u64 {
5
}
impl Default for IssuePullSchema {
fn default() -> Self {
Self {
command: String::new(),
idle_sec: default_idle_sec(),
unknown_fields: serde_yaml::Mapping::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueHandlingSchema {
#[serde(default)]
pub hooks: IssueHooks,
#[serde(deserialize_with = "deserialize_stages")]
pub stages: IndexMap<String, IssueStageSchema>,
#[serde(flatten)]
unknown_fields: serde_yaml::Mapping,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueHooks {
pub after_create: Option<String>,
#[serde(flatten)]
unknown_fields: serde_yaml::Mapping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueStageSchema {
#[serde(skip)]
pub name: String,
pub when: IssueStageMatch,
pub agent: String,
#[serde(flatten, deserialize_with = "deserialize_prompt_source")]
pub prompt_source: IssueStagePromptSource,
#[serde(default)]
pub hooks: IssueStageHooks,
#[serde(flatten)]
unknown_fields: serde_yaml::Mapping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueStagePromptSource {
#[serde(rename = "prompt_file")]
File(PathBuf),
#[serde(rename = "prompt")]
Inline(String),
}
#[derive(Deserialize)]
struct IssueStagePromptSourceInput {
#[serde(default)]
prompt_file: Option<PathBuf>,
#[serde(default)]
prompt: Option<String>,
}
fn deserialize_prompt_source<'de, D>(deserializer: D) -> Result<IssueStagePromptSource, D::Error>
where
D: Deserializer<'de>,
{
let input = IssueStagePromptSourceInput::deserialize(deserializer)?;
match (input.prompt_file, input.prompt) {
(Some(prompt_file), None) => Ok(IssueStagePromptSource::File(prompt_file)),
(None, Some(prompt)) => Ok(IssueStagePromptSource::Inline(prompt)),
(Some(_), Some(_)) | (None, None) => Err(de::Error::custom(
"issue stage must define exactly one of `prompt_file` or `prompt`",
)),
}
}
impl Default for IssueStagePromptSource {
fn default() -> Self {
Self::File(PathBuf::new())
}
}
#[cfg(test)]
impl IssueStageSchema {
pub fn new(when: impl Into<String>) -> Self {
Self {
name: String::new(),
when: IssueStageMatch {
state: when.into(),
unknown_fields: Default::default(),
},
agent: String::new(),
prompt_source: IssueStagePromptSource::default(),
hooks: IssueStageHooks::default(),
unknown_fields: Default::default(),
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
pub fn with_prompt_file(mut self, prompt_file: impl Into<PathBuf>) -> Self {
self.prompt_source = IssueStagePromptSource::File(prompt_file.into());
self
}
pub fn with_inline_prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt_source = IssueStagePromptSource::Inline(prompt.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueStageMatch {
pub state: String,
#[serde(flatten)]
unknown_fields: serde_yaml::Mapping,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueStageHooks {
pub before_run: Option<String>,
pub after_run: Option<String>,
#[serde(flatten)]
unknown_fields: serde_yaml::Mapping,
}
impl Diagnose for IssueIntakeSchema {
fn diagnose(&self, schema: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
diagnose_fields!(diagnostics, self, schema, "pull" => pull);
diagnostics.warn_unknown_fields(&self.unknown_fields);
diagnostics
}
}
impl Diagnose for IssuePullSchema {
fn diagnose(&self, _: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
diagnostics.error_if_empty_str("command", &self.command);
diagnostics.error_if_non_positive("idle_sec", self.idle_sec as usize);
diagnostics.warn_unknown_fields(&self.unknown_fields);
diagnostics
}
}
impl Diagnose for IssueHandlingSchema {
fn diagnose(&self, schema: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
diagnostics.error_if_empty_map("stages", self.stages.is_empty());
if !self.stages.is_empty() {
self.stages.iter().for_each(|(name, stage)| {
diagnostics.error_if_empty_str("stages", name);
diagnostics.extends_with_pointer(&stage_pointer(name), stage.diagnose(schema));
});
}
diagnose_fields!(diagnostics, self, schema, "hooks" => hooks);
diagnostics.warn_unknown_fields(&self.unknown_fields);
diagnostics
}
}
fn deserialize_stages<'de, D>(deserializer: D) -> Result<IndexMap<String, IssueStageSchema>, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut stages = IndexMap::<String, IssueStageSchema>::deserialize(deserializer)?;
stages.iter_mut().for_each(|(name, stage)| {
stage.name = name.clone();
});
Ok(stages)
}
fn stage_pointer(stage_name: &str) -> String {
if stage_name.trim().is_empty() {
"stages".to_string()
} else {
format!("stages.{stage_name}")
}
}
impl Diagnose for IssueStageSchema {
fn diagnose(&self, schema: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
diagnose_fields!(
diagnostics,
self,
schema,
"when" => when,
"hooks" => hooks,
);
diagnostics.error_if_empty_str("agent", &self.agent);
if !self.agent.trim().is_empty() && !schema.agents.contains_key(&self.agent) {
diagnostics.push(Diagnostic::error(
"agent",
DiagnosticCode::UnknownAgent(self.agent.clone()),
));
}
diagnostics.extends_with_pointer("", self.prompt_source.diagnose(schema));
diagnostics.warn_unknown_fields(&self.unknown_fields);
diagnostics
}
}
impl Diagnose for IssueStagePromptSource {
fn diagnose(&self, _: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
match self {
IssueStagePromptSource::File(prompt_file) => diagnostics.error_if_empty_path("prompt_file", prompt_file),
IssueStagePromptSource::Inline(prompt) => diagnostics.error_if_empty_str("prompt", prompt),
}
diagnostics
}
}
impl Diagnose for IssueHooks {
fn diagnose(&self, _: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
diagnostics.warn_unknown_fields(&self.unknown_fields);
diagnostics
}
}
impl Diagnose for IssueStageMatch {
fn diagnose(&self, _: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
diagnostics.error_if_empty_str("state", &self.state);
diagnostics.warn_unknown_fields(&self.unknown_fields);
diagnostics
}
}
impl Diagnose for IssueStageHooks {
fn diagnose(&self, _: &WorkflowSchema) -> Diagnostics {
let mut diagnostics = Diagnostics::new();
diagnostics.warn_unknown_fields(&self.unknown_fields);
diagnostics
}
}
#[cfg(test)]
mod tests {
use crate::config::AgentProfileSchema;
use crate::config::AgentRuntime;
use crate::config::WorkflowSchema;
use crate::config::diagnose::Diagnose;
use crate::config::diagnose::DiagnosticCode;
use super::*;
#[test]
fn issue_pull_defaults_idle_sec_when_omitted() {
let pull: IssuePullSchema = serde_yaml::from_str(
r#"
command: ./scripts/issues-json
"#,
)
.expect("pull schema parses");
let diagnostics = pull.diagnose(&WorkflowSchema::default());
assert_eq!(pull.command, "./scripts/issues-json");
assert_eq!(pull.idle_sec, 5);
assert!(!diagnostics.has_errors());
}
#[test]
fn issue_stage_accepts_known_agent_and_reports_empty_prompt_file() {
let mut workflow = WorkflowSchema::default();
workflow.agents.insert(
"codex".to_string(),
AgentProfileSchema::new(AgentRuntime::Codex, "gpt-5.5".to_string()),
);
let stage: IssueStageSchema = serde_yaml::from_str(
r#"
when:
state: Todo
agent: codex
prompt_file: ''
"#,
)
.expect("stage schema parses");
let diagnostics = stage.diagnose(&workflow);
assert!(
diagnostics
.errors
.iter()
.any(|diag| { diag.pointer == "prompt_file" && matches!(diag.code, DiagnosticCode::EmptyStr) })
);
assert!(
!diagnostics
.errors
.iter()
.any(|diag| matches!(diag.code, DiagnosticCode::UnknownAgent(_)))
);
}
#[test]
fn issue_stages_deserialize_map_into_named_indexmap_entries() {
let issue: IssueHandlingSchema = serde_yaml::from_str(
r#"
stages:
plan:
when:
state: todo
agent: codex
prompt_file: ./plan.md
implement:
when:
state: todo
agent: codex
prompt_file: ./implement.md
"#,
)
.expect("issue schema parses");
assert_eq!(
issue.stages.keys().map(String::as_str).collect::<Vec<_>>(),
["plan", "implement"]
);
assert_eq!(issue.stages.get("plan").expect("plan stage").name, "plan");
assert_eq!(
issue.stages.get("implement").expect("implement stage").name,
"implement"
);
}
#[test]
fn issue_stages_reject_array_shape() {
let err = serde_yaml::from_str::<IssueHandlingSchema>(
r#"
stages:
- name: plan
when:
state: todo
agent: codex
prompt_file: ./plan.md
"#,
)
.expect_err("array-shaped stages are unsupported");
assert!(err.to_string().contains("invalid type: sequence"));
}
#[test]
fn issue_stages_report_empty_map() {
let issue: IssueHandlingSchema = serde_yaml::from_str(
r#"
stages: {}
"#,
)
.expect("issue schema parses");
let diagnostics = issue.diagnose(&workflow_with_agent());
assert!(
diagnostics
.errors
.iter()
.any(|diag| { diag.pointer == "stages" && matches!(diag.code, DiagnosticCode::EmptyMap) })
);
}
#[test]
fn issue_stages_report_empty_stage_name() {
let issue: IssueHandlingSchema = serde_yaml::from_str(
r#"
stages:
"":
when:
state: todo
agent: codex
prompt_file: ./plan.md
"#,
)
.expect("issue schema parses");
let diagnostics = issue.diagnose(&workflow_with_agent());
assert!(
diagnostics
.errors
.iter()
.any(|diag| { diag.pointer == "stages" && matches!(diag.code, DiagnosticCode::EmptyStr) })
);
}
#[test]
fn issue_stages_derive_name_from_map_key_not_authored_field() {
let issue: IssueHandlingSchema = serde_yaml::from_str(
r#"
stages:
plan:
name: authored
when:
state: todo
agent: codex
prompt_file: ./plan.md
"#,
)
.expect("issue schema parses");
let diagnostics = issue.diagnose(&workflow_with_agent());
assert_eq!(issue.stages.get("plan").expect("plan stage").name, "plan");
assert!(
diagnostics
.warnings
.iter()
.any(|diag| { diag.pointer == "stages.plan.name" && matches!(diag.code, DiagnosticCode::UnknownField) })
);
}
#[test]
fn issue_stage_prompt_source_accepts_prompt_file() {
let stage: IssueStageSchema = serde_yaml::from_str(
r#"
when:
state: Todo
agent: codex
prompt_file: ./prompts/plan.md
"#,
)
.expect("stage schema parses");
let IssueStagePromptSource::File(path) = stage.prompt_source else {
panic!("expected file prompt source");
};
assert_eq!(path, PathBuf::from("./prompts/plan.md"));
}
#[test]
fn issue_stage_prompt_source_accepts_inline_prompt() {
let stage: IssueStageSchema = serde_yaml::from_str(
r#"
when:
state: Todo
agent: codex
prompt: |
plan on {{ issue.id }}
"#,
)
.expect("stage schema parses");
let diagnostics = stage.diagnose(&workflow_with_agent());
assert!(!diagnostics.has_errors(), "{diagnostics}");
assert!(!diagnostics.has_warnings(), "{diagnostics}");
}
#[test]
fn issue_stage_prompt_source_rejects_both_sources() {
let err = serde_yaml::from_str::<IssueStageSchema>(
r#"
when:
state: Todo
agent: codex
prompt_file: ./prompts/plan.md
prompt: inline
"#,
)
.expect_err("both prompt sources must fail");
assert!(err.to_string().contains("prompt_file"));
assert!(err.to_string().contains("prompt"));
}
#[test]
fn issue_stage_prompt_source_rejects_missing_source() {
let err = serde_yaml::from_str::<IssueStageSchema>(
r#"
when:
state: Todo
agent: codex
"#,
)
.expect_err("missing prompt source must fail");
assert!(err.to_string().contains("prompt_file"));
assert!(err.to_string().contains("prompt"));
}
#[test]
fn issue_stage_prompt_source_preserves_unknown_field_warning() {
let stage: IssueStageSchema = serde_yaml::from_str(
r#"
when:
state: Todo
agent: codex
prompt: inline
extra_stage_field: true
"#,
)
.expect("stage schema parses");
let diagnostics = stage.diagnose(&workflow_with_agent());
assert!(!diagnostics.has_errors(), "{diagnostics}");
assert!(
diagnostics
.warnings
.iter()
.any(|diag| { diag.pointer == "extra_stage_field" && matches!(diag.code, DiagnosticCode::UnknownField) })
);
}
fn workflow_with_agent() -> WorkflowSchema {
let mut workflow = WorkflowSchema::default();
workflow.agents.insert(
"codex".to_string(),
AgentProfileSchema::new(AgentRuntime::Codex, "gpt-5.5".to_string()),
);
workflow
}
}