1use std::collections::BTreeMap;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::{Component, Path, PathBuf};
6
7use serde_json::{Value, json};
8
9use crate::config::{AgentConfig, expand_agent_cmd};
10use crate::domain::ticket::TicketState;
11use crate::flow::Flow;
12use crate::frontmatter::{self, FrontmatterError};
13use crate::ids::{IdError, next_id};
14use crate::protocol::{PostActivation, PostArgs};
15use crate::store::{ActivationKind, NewActivation, Store, StoreError};
16
17#[allow(clippy::too_many_arguments)]
24pub fn handle(
25 root: &Path,
26 ticket_dir: &Path,
27 store: &Store,
28 args: &PostArgs,
29 now_ms: i64,
30 at_eligible_ms: Option<i64>,
31 ticket_prefix: &str,
32 agent: Option<&AgentConfig>,
33 flows: &BTreeMap<String, Flow>,
34 default_flow: &str,
35) -> Result<Value, PostError> {
36 let initial_state = match args.activation {
37 PostActivation::Hold => TicketState::Held,
38 _ => TicketState::Ready,
39 };
40 let relative = repository_relative(root, ticket_dir, &args.file)?;
41 let relative_str = relative.to_string_lossy().into_owned();
42 let absolute = root.join(&relative);
43 let content = fs::read_to_string(&absolute).map_err(|source| {
44 if source.kind() == io::ErrorKind::NotFound {
45 PostError::TicketFileNotFound(relative_str.clone())
46 } else {
47 PostError::Io {
48 path: relative_str.clone(),
49 source,
50 }
51 }
52 })?;
53 let stamped = parse_ticket_frontmatter(&content, &relative_str)?;
54
55 let project = match (stamped.project.as_deref(), args.project.as_deref()) {
56 (Some(stamped), Some(requested)) if stamped != requested => {
57 return Err(PostError::ProjectConflict {
58 path: relative_str,
59 stamped: stamped.into(),
60 requested: requested.into(),
61 });
62 }
63 (Some(stamped), _) => stamped.to_owned(),
64 (None, Some(requested)) => requested.to_owned(),
65 (None, None) => "default".to_owned(),
66 };
67 if !store.project_exists(&project)? {
68 return Err(PostError::UnknownProject(project));
69 }
70
71 let flow_name = match (stamped.flow.as_deref(), args.flow.as_deref()) {
72 (Some(stamped), Some(requested)) if stamped != requested => {
73 return Err(PostError::FlowConflict {
74 path: relative_str,
75 stamped: stamped.into(),
76 requested: requested.into(),
77 });
78 }
79 (Some(stamped), _) => stamped.to_owned(),
80 (None, Some(requested)) => requested.to_owned(),
81 (None, None) => default_flow.to_owned(),
82 };
83 if !flows.contains_key(&flow_name) {
84 let mut known: Vec<&str> = flows.keys().map(String::as_str).collect();
85 known.sort_unstable();
86 return Err(PostError::UnknownFlow {
87 flow: flow_name,
88 known: known.into_iter().map(str::to_owned).collect(),
89 });
90 }
91
92 let target = match stamped.target.as_deref() {
93 Some(target) if agent.is_some_and(|agent| agent.targets.contains_key(target)) => {
94 Some(target.to_owned())
95 }
96 Some(target) => return Err(PostError::UnknownTarget(target.to_owned())),
97 None => agent.map(|agent| agent.default_target.clone()),
98 };
99 if let (Some(agent), Some(target)) = (agent, target.as_deref()) {
100 let command = agent
101 .targets
102 .get(target)
103 .expect("configured default target was validated");
104 expand_agent_cmd(
105 command,
106 stamped.model.as_deref(),
107 stamped.effort.as_deref(),
108 "",
109 )
110 .map_err(|message| PostError::MissingTargetValue {
111 target: target.to_owned(),
112 message,
113 })?;
114 }
115
116 let (ticket_id, existing) = match stamped.id.as_deref() {
117 Some(id) => {
118 if let Some(existing) = store.ticket(id)? {
119 if existing.file_path.as_deref() != Some(relative_str.as_str()) {
120 return Err(PostError::TicketIdTaken {
121 id: id.to_owned(),
122 file: existing.file_path.unwrap_or_default(),
123 });
124 }
125 if existing.project_id != project {
126 return Err(PostError::ProjectConflict {
127 path: relative_str,
128 stamped: project,
129 requested: existing.project_id,
130 });
131 }
132 (id.to_owned(), Some(existing))
133 } else {
134 (id.to_owned(), None)
135 }
136 }
137 None => (allocate_ticket_id(store, ticket_prefix)?, None),
138 };
139 for blocker in &stamped.blocked_by {
140 if blocker != &ticket_id && store.ticket(blocker)?.is_none() {
141 return Err(PostError::UnknownBlockedBy {
142 ticket: ticket_id.clone(),
143 blocker: blocker.clone(),
144 });
145 }
146 }
147 let mut dependencies = store.ticket_dependencies()?;
148 dependencies.insert(ticket_id.clone(), stamped.blocked_by.clone());
149 if let Some(chain) = crate::domain::graph::find_cycle(&dependencies) {
150 return Err(PostError::DependencyCycle(chain));
151 }
152
153 let worktree = match stamped.worktree.clone() {
154 Some(worktree) => worktree,
155 None => {
156 let stem = Path::new(&relative_str)
157 .file_stem()
158 .and_then(|stem| stem.to_str());
159 crate::ids::default_worktree(stem, &ticket_id).map_err(|reason| {
160 PostError::InvalidWorktreeStem {
161 path: relative_str.clone(),
162 reason,
163 }
164 })?
165 }
166 };
167 if existing.is_some() {
168 store.update_local_ticket(
169 &ticket_id,
170 &stamped.name,
171 &stamped.blocked_by,
172 &worktree,
173 target.as_deref(),
174 stamped.model.as_deref(),
175 stamped.effort.as_deref(),
176 &flow_name,
177 now_ms,
178 )?;
179 } else {
180 store.insert_local_ticket(
181 &ticket_id,
182 &project,
183 &relative_str,
184 &stamped.name,
185 &stamped.blocked_by,
186 &worktree,
187 target.as_deref(),
188 stamped.model.as_deref(),
189 stamped.effort.as_deref(),
190 &flow_name,
191 initial_state,
192 now_ms,
193 )?;
194 }
195 store.update_ticket_body(
196 &ticket_id,
197 frontmatter::body(&content).expect("validated frontmatter has a body"),
198 now_ms,
199 )?;
200 let ticket = store
201 .ticket(&ticket_id)?
202 .expect("registered ticket still exists");
203
204 if let Some(updated) = frontmatter::stamp(&content, &ticket.id, &project, &worktree, &flow_name)
205 .map_err(|error| PostError::InvalidTicket {
206 path: relative_str.clone(),
207 error,
208 })?
209 {
210 fs::write(&absolute, updated).map_err(|source| PostError::Io {
211 path: relative_str.clone(),
212 source,
213 })?;
214 }
215
216 let activation = match &args.activation {
217 PostActivation::Manual | PostActivation::Hold => Value::Null,
218 PostActivation::Auto => {
219 queue_activation(store, &ticket.id, ActivationKind::Auto, None, now_ms)?
220 }
221 PostActivation::At { .. } => {
222 let eligible_at_ms =
223 at_eligible_ms.expect("the dispatcher computes eligibility for at activations");
224 queue_activation(
225 store,
226 &ticket.id,
227 ActivationKind::At,
228 Some(eligible_at_ms),
229 now_ms,
230 )?
231 }
232 };
233
234 Ok(json!({
235 "ticket": {
236 "id": ticket.id,
237 "project": project,
238 "file": relative_str,
239 "state": ticket.state,
240 "name": ticket.name,
241 "blocked_by": ticket.blocked_by,
242 "worktree": ticket.worktree,
243 "target": ticket.target,
244 "model": ticket.model,
245 "effort": ticket.effort,
246 "flow": ticket.flow,
247 },
248 "activation": activation,
249 }))
250}
251
252pub(crate) fn parse_ticket_frontmatter(
253 content: &str,
254 path: &str,
255) -> Result<frontmatter::Frontmatter, PostError> {
256 let stamped = frontmatter::parse(content).map_err(|error| match error {
257 FrontmatterError::InvalidBlockedBy => PostError::InvalidBlockedBy {
258 path: path.to_owned(),
259 },
260 error => PostError::InvalidTicket {
261 path: path.to_owned(),
262 error,
263 },
264 })?;
265 if stamped.name.trim().is_empty() {
266 return Err(PostError::MissingName {
267 path: path.to_owned(),
268 });
269 }
270 if !stamped.has_blocked_by() {
271 return Err(PostError::MissingBlockedBy {
272 path: path.to_owned(),
273 });
274 }
275 if frontmatter::body(content)
276 .expect("frontmatter was already parsed")
277 .trim()
278 .is_empty()
279 {
280 return Err(PostError::EmptyBody {
281 path: path.to_owned(),
282 });
283 }
284 Ok(stamped)
285}
286
287fn queue_activation(
291 store: &Store,
292 ticket_id: &str,
293 kind: ActivationKind,
294 eligible_at_ms: Option<i64>,
295 now_ms: i64,
296) -> Result<Value, PostError> {
297 let id = match store.queued_ticket_activation(ticket_id, kind)? {
298 Some(id) => {
299 if let Some(eligible_at_ms) = eligible_at_ms {
300 store.reschedule_activation(&id, eligible_at_ms, now_ms)?;
301 }
302 id
303 }
304 None => {
305 let id = format!("A{}", store.next_activation_ordinal()?);
306 store.insert_activation(
307 &NewActivation {
308 id: &id,
309 kind,
310 ticket_id: Some(ticket_id),
311 project_id: None,
312 eligible_at_ms,
313 interval_ms: None,
314 },
315 now_ms,
316 )?;
317 id
318 }
319 };
320 let mut activation = json!({
321 "id": id,
322 "kind": kind.as_str(),
323 "state": "queued",
324 "ticket": ticket_id,
325 });
326 if let Some(eligible_at_ms) = eligible_at_ms {
327 activation["eligible_at_ms"] = json!(eligible_at_ms);
328 }
329 Ok(activation)
330}
331
332fn allocate_ticket_id(store: &Store, prefix: &str) -> Result<String, PostError> {
333 let ids = store.ticket_ids()?;
334 next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
335}
336
337fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
340 let path = Path::new(file);
341 let joined = if path.is_absolute() {
342 path.to_path_buf()
343 } else {
344 root.join(path)
345 };
346
347 let mut normalized = PathBuf::new();
348 for component in joined.components() {
349 match component {
350 Component::CurDir => {}
351 Component::ParentDir => {
352 if !normalized.pop() {
353 return Err(PostError::OutsideRepository(file.to_owned()));
354 }
355 }
356 component => normalized.push(component),
357 }
358 }
359 let relative = normalized
360 .strip_prefix(root)
361 .map(Path::to_path_buf)
362 .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
363 if !relative.starts_with(ticket_dir) {
364 return Err(PostError::OutsideTicketDirectory {
365 path: file.to_owned(),
366 directory: ticket_dir.to_path_buf(),
367 });
368 }
369 Ok(relative)
370}
371
372#[derive(Debug)]
373pub enum PostError {
374 TicketFileNotFound(String),
375 OutsideRepository(String),
376 OutsideTicketDirectory {
377 path: String,
378 directory: PathBuf,
379 },
380 InvalidTicket {
381 path: String,
382 error: FrontmatterError,
383 },
384 MissingName {
385 path: String,
386 },
387 InvalidWorktreeStem {
388 path: String,
389 reason: String,
390 },
391 MissingBlockedBy {
392 path: String,
393 },
394 InvalidBlockedBy {
395 path: String,
396 },
397 EmptyBody {
398 path: String,
399 },
400 UnknownBlockedBy {
401 ticket: String,
402 blocker: String,
403 },
404 DependencyCycle(Vec<String>),
405 UnknownProject(String),
406 UnknownTarget(String),
407 MissingTargetValue {
408 target: String,
409 message: String,
410 },
411 ProjectConflict {
412 path: String,
413 stamped: String,
414 requested: String,
415 },
416 FlowConflict {
417 path: String,
418 stamped: String,
419 requested: String,
420 },
421 UnknownFlow {
422 flow: String,
423 known: Vec<String>,
424 },
425 TicketIdTaken {
426 id: String,
427 file: String,
428 },
429 Io {
430 path: String,
431 source: io::Error,
432 },
433 Store(StoreError),
434 IdAllocation(IdError),
435}
436
437impl From<StoreError> for PostError {
438 fn from(error: StoreError) -> Self {
439 Self::Store(error)
440 }
441}
442
443impl fmt::Display for PostError {
444 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
445 match self {
446 Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
447 Self::OutsideRepository(path) => {
448 write!(formatter, "`{path}` is outside the repository")
449 }
450 Self::OutsideTicketDirectory { path, directory } => write!(
451 formatter,
452 "`{path}` is outside the {} directory",
453 directory.display()
454 ),
455 Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
456 Self::MissingName { path } => write!(
457 formatter,
458 "{path}: missing or empty `name`; add `name: Your ticket title`"
459 ),
460 Self::InvalidWorktreeStem { path, reason } => {
461 write!(formatter, "{path}: {reason}")
462 }
463 Self::MissingBlockedBy { path } => write!(
464 formatter,
465 "{path}: missing `blocked_by`; add `blocked_by: []` if there are no dependencies"
466 ),
467 Self::InvalidBlockedBy { path } => write!(
468 formatter,
469 "{path}: invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs"
470 ),
471 Self::EmptyBody { path } => write!(
472 formatter,
473 "{path}: empty `body`; add a ticket description after the frontmatter"
474 ),
475 Self::UnknownBlockedBy { ticket, blocker } => write!(
476 formatter,
477 "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
478 ),
479 Self::DependencyCycle(chain) => write!(
480 formatter,
481 "field `blocked_by` creates a dependency cycle: {}",
482 chain.join(" -> ")
483 ),
484 Self::UnknownProject(project) => {
485 write!(formatter, "project `{project}` is not indexed")
486 }
487 Self::UnknownTarget(target) => {
488 write!(formatter, "agent target `{target}` is not configured")
489 }
490 Self::MissingTargetValue { target, message } => {
491 write!(formatter, "ticket using agent target `{target}` {message}")
492 }
493 Self::ProjectConflict {
494 path,
495 stamped,
496 requested,
497 } => write!(
498 formatter,
499 "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
500 ),
501 Self::FlowConflict {
502 path,
503 stamped,
504 requested,
505 } => write!(
506 formatter,
507 "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
508 ),
509 Self::UnknownFlow { flow, known } => write!(
510 formatter,
511 "flow `{flow}` is not defined; known flows: {}",
512 known.join(", ")
513 ),
514 Self::TicketIdTaken { id, file } => write!(
515 formatter,
516 "ticket ID `{id}` is already registered by `{file}`"
517 ),
518 Self::Io { path, source } => write!(formatter, "{path}: {source}"),
519 Self::Store(error) => error.fmt(formatter),
520 Self::IdAllocation(error) => error.fmt(formatter),
521 }
522 }
523}
524
525impl std::error::Error for PostError {}
526
527#[cfg(test)]
528mod tests {
529 use std::collections::BTreeMap;
530
531 use tempfile::tempdir;
532
533 use super::{PostError, handle as handle_with_directory};
534 use crate::config::{AgentConfig, AgentTarget};
535 use crate::flow::{Flow, Stage, StageKind, VerdictPolicy};
536 use crate::protocol::{PostActivation, PostArgs};
537 use crate::store::Store;
538
539 fn world() -> (tempfile::TempDir, Store) {
540 let root = tempdir().unwrap();
541 std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
542 let store = Store::open(&root.path().join("sloop.db"), 1_000).unwrap();
543 store
544 .upsert_local_project(
545 "default",
546 ".agents/sloop/projects/default.md",
547 "Default",
548 1_000,
549 )
550 .unwrap();
551 (root, store)
552 }
553
554 #[allow(clippy::too_many_arguments)]
555 fn handle(
556 root: &std::path::Path,
557 store: &Store,
558 args: &PostArgs,
559 now_ms: i64,
560 ticket_prefix: &str,
561 agent: Option<&AgentConfig>,
562 flows: &BTreeMap<String, Flow>,
563 default_flow: &str,
564 ) -> Result<serde_json::Value, PostError> {
565 handle_with_directory(
566 root,
567 std::path::Path::new(".agents/sloop/tickets"),
568 store,
569 args,
570 now_ms,
571 None,
572 ticket_prefix,
573 agent,
574 flows,
575 default_flow,
576 )
577 }
578
579 fn handle_at(
580 root: &std::path::Path,
581 store: &Store,
582 args: &PostArgs,
583 now_ms: i64,
584 at_eligible_ms: i64,
585 ) -> Result<serde_json::Value, PostError> {
586 handle_with_directory(
587 root,
588 std::path::Path::new(".agents/sloop/tickets"),
589 store,
590 args,
591 now_ms,
592 Some(at_eligible_ms),
593 "TICK",
594 None,
595 &flows(),
596 "default",
597 )
598 }
599
600 fn post(file: &str, activation: PostActivation) -> PostArgs {
601 PostArgs {
602 file: file.into(),
603 project: None,
604 flow: None,
605 activation,
606 }
607 }
608
609 fn flows() -> BTreeMap<String, Flow> {
610 BTreeMap::from([
611 (
612 "default".to_owned(),
613 Flow {
614 name: "default".into(),
615 stages: vec![Stage {
616 name: "build".into(),
617 kind: StageKind::Agent,
618 verdict: VerdictPolicy::Commits,
619 on_fail: None,
620 }],
621 },
622 ),
623 (
624 "release".to_owned(),
625 Flow {
626 name: "release".into(),
627 stages: vec![Stage {
628 name: "build".into(),
629 kind: StageKind::Agent,
630 verdict: VerdictPolicy::Commits,
631 on_fail: None,
632 }],
633 },
634 ),
635 ])
636 }
637
638 fn ticket(frontmatter: &str, body: &str) -> String {
639 format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
640 }
641
642 fn agent() -> AgentConfig {
643 AgentConfig {
644 default_target: "claude".into(),
645 targets: BTreeMap::from([
646 (
647 "claude".into(),
648 AgentTarget {
649 cmd: vec!["claude".into(), "{prompt}".into()],
650 model: None,
651 effort: None,
652 },
653 ),
654 (
655 "codex".into(),
656 AgentTarget {
657 cmd: vec![
658 "codex".into(),
659 "{model}".into(),
660 "{effort}".into(),
661 "{prompt}".into(),
662 ],
663 model: None,
664 effort: None,
665 },
666 ),
667 ]),
668 }
669 }
670
671 #[test]
672 fn posting_twice_reuses_the_registration_and_activation() {
673 let (root, store) = world();
674 std::fs::write(
675 root.path().join(".agents/sloop/tickets/cooldown.md"),
676 ticket("", "# Cooldowns\n"),
677 )
678 .unwrap();
679 let args = post(".agents/sloop/tickets/cooldown.md", PostActivation::Auto);
680
681 let first = handle(
682 root.path(),
683 &store,
684 &args,
685 2_000,
686 "TICK",
687 None,
688 &flows(),
689 "default",
690 )
691 .unwrap();
692 let second = handle(
693 root.path(),
694 &store,
695 &args,
696 3_000,
697 "TICK",
698 None,
699 &flows(),
700 "default",
701 )
702 .unwrap();
703 assert_eq!(first["ticket"]["id"], second["ticket"]["id"]);
704 assert_eq!(first["activation"]["id"], second["activation"]["id"]);
705 }
706
707 #[test]
708 fn posting_at_queues_a_timed_activation_and_reposting_reschedules_it() {
709 let (root, store) = world();
710 std::fs::write(
711 root.path().join(".agents/sloop/tickets/timed.md"),
712 ticket("", "# Timed\n"),
713 )
714 .unwrap();
715 let args = post(
716 ".agents/sloop/tickets/timed.md",
717 PostActivation::At {
718 time: "03:00".into(),
719 },
720 );
721
722 let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
723 assert_eq!(first["ticket"]["state"], "ready");
724 assert_eq!(first["activation"]["kind"], "at");
725 assert_eq!(first["activation"]["eligible_at_ms"], 10_000);
726
727 let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
728 assert_eq!(second["activation"]["id"], first["activation"]["id"]);
729 assert_eq!(second["activation"]["eligible_at_ms"], 20_000);
730
731 let queued = store.queued_activations().unwrap();
732 assert_eq!(queued.len(), 1);
733 assert_eq!(queued[0].eligible_at_ms, Some(20_000));
734 }
735
736 #[test]
737 fn posting_snapshots_the_default_target_and_reposting_refreshes_execution_values() {
738 let (root, store) = world();
739 let path = root.path().join(".agents/sloop/tickets/work.md");
740 std::fs::write(&path, ticket("model: sonnet\neffort: medium\n", "# Work\n")).unwrap();
741 let args = post(".agents/sloop/tickets/work.md", PostActivation::Manual);
742 let agent = agent();
743
744 let first = handle(
745 root.path(),
746 &store,
747 &args,
748 2_000,
749 "TICK",
750 Some(&agent),
751 &flows(),
752 "default",
753 )
754 .unwrap();
755 assert_eq!(first["ticket"]["target"], "claude");
756
757 std::fs::write(
758 &path,
759 ticket(
760 "id: TICK-1\nproject: default\ntarget: codex\nmodel: o3\neffort: high\n",
761 "# Work\n",
762 ),
763 )
764 .unwrap();
765 let second = handle(
766 root.path(),
767 &store,
768 &args,
769 3_000,
770 "TICK",
771 Some(&agent),
772 &flows(),
773 "default",
774 )
775 .unwrap();
776 assert_eq!(second["ticket"]["id"], first["ticket"]["id"]);
777 assert_eq!(second["ticket"]["target"], "codex");
778 assert_eq!(second["ticket"]["model"], "o3");
779 assert_eq!(second["ticket"]["effort"], "high");
780 }
781
782 #[test]
783 fn unknown_targets_are_rejected_before_registration_or_activation() {
784 let (root, store) = world();
785 std::fs::write(
786 root.path().join(".agents/sloop/tickets/work.md"),
787 ticket("target: missing\n", "# Work\n"),
788 )
789 .unwrap();
790 let args = post(".agents/sloop/tickets/work.md", PostActivation::Auto);
791
792 assert!(matches!(
793 handle(root.path(), &store, &args, 2_000, "TICK", Some(&agent()), &flows(), "default"),
794 Err(PostError::UnknownTarget(target)) if target == "missing"
795 ));
796 assert!(store.ticket_ids().unwrap().is_empty());
797 assert!(store.queued_activations().unwrap().is_empty());
798 }
799
800 #[test]
801 fn selected_target_placeholders_require_ticket_values_before_registration() {
802 let (root, store) = world();
803 std::fs::write(
804 root.path().join(".agents/sloop/tickets/work.md"),
805 ticket("target: codex\neffort: high\n", "# Work\n"),
806 )
807 .unwrap();
808 let args = post(".agents/sloop/tickets/work.md", PostActivation::Manual);
809
810 let error = handle(
811 root.path(),
812 &store,
813 &args,
814 2_000,
815 "TICK",
816 Some(&agent()),
817 &flows(),
818 "default",
819 )
820 .unwrap_err()
821 .to_string();
822 assert!(error.contains("agent target `codex`"), "{error}");
823 assert!(error.contains("does not specify `model`"), "{error}");
824 assert!(store.ticket_ids().unwrap().is_empty());
825 }
826
827 #[test]
828 fn a_stamped_project_mismatching_the_request_is_a_conflict() {
829 let (root, store) = world();
830 std::fs::write(
831 root.path().join(".agents/sloop/tickets/t.md"),
832 ticket("id: T1\nproject: default\n", "# Work\n"),
833 )
834 .unwrap();
835 let args = PostArgs {
836 file: ".agents/sloop/tickets/t.md".into(),
837 project: Some("other".into()),
838 flow: None,
839 activation: PostActivation::Manual,
840 };
841
842 assert!(matches!(
843 handle(
844 root.path(),
845 &store,
846 &args,
847 2_000,
848 "TICK",
849 None,
850 &flows(),
851 "default"
852 ),
853 Err(PostError::ProjectConflict { .. })
854 ));
855 }
856
857 #[test]
858 fn an_unknown_project_is_rejected() {
859 let (root, store) = world();
860 std::fs::write(
861 root.path().join(".agents/sloop/tickets/t.md"),
862 ticket("", "# T\n"),
863 )
864 .unwrap();
865 let args = PostArgs {
866 file: ".agents/sloop/tickets/t.md".into(),
867 project: Some("missing".into()),
868 flow: None,
869 activation: PostActivation::Manual,
870 };
871
872 assert!(matches!(
873 handle(root.path(), &store, &args, 2_000, "TICK", None, &flows(), "default"),
874 Err(PostError::UnknownProject(project)) if project == "missing"
875 ));
876 }
877
878 #[test]
879 fn a_missing_flow_is_stamped_with_the_default() {
880 let (root, store) = world();
881 let path = root.path().join(".agents/sloop/tickets/t.md");
882 std::fs::write(&path, ticket("", "# T\n")).unwrap();
883 let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
884
885 let response = handle(
886 root.path(),
887 &store,
888 &args,
889 2_000,
890 "TICK",
891 None,
892 &flows(),
893 "default",
894 )
895 .unwrap();
896
897 assert_eq!(response["ticket"]["flow"], "default");
898 assert!(
899 std::fs::read_to_string(&path)
900 .unwrap()
901 .contains("flow: default")
902 );
903 }
904
905 #[test]
906 fn an_explicit_flow_is_honored() {
907 let (root, store) = world();
908 std::fs::write(
909 root.path().join(".agents/sloop/tickets/t.md"),
910 ticket("flow: release\n", "# T\n"),
911 )
912 .unwrap();
913 let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
914
915 let response = handle(
916 root.path(),
917 &store,
918 &args,
919 2_000,
920 "TICK",
921 None,
922 &flows(),
923 "default",
924 )
925 .unwrap();
926
927 assert_eq!(response["ticket"]["flow"], "release");
928 }
929
930 #[test]
931 fn a_stamped_flow_mismatching_the_request_is_a_conflict() {
932 let (root, store) = world();
933 std::fs::write(
934 root.path().join(".agents/sloop/tickets/t.md"),
935 ticket("flow: release\n", "# T\n"),
936 )
937 .unwrap();
938 let args = PostArgs {
939 file: ".agents/sloop/tickets/t.md".into(),
940 project: None,
941 flow: Some("default".into()),
942 activation: PostActivation::Manual,
943 };
944
945 assert!(matches!(
946 handle(
947 root.path(),
948 &store,
949 &args,
950 2_000,
951 "TICK",
952 None,
953 &flows(),
954 "default"
955 ),
956 Err(PostError::FlowConflict { .. })
957 ));
958 }
959
960 #[test]
961 fn an_unknown_flow_is_rejected_and_names_known_flows() {
962 let (root, store) = world();
963 std::fs::write(
964 root.path().join(".agents/sloop/tickets/t.md"),
965 ticket("flow: bogus\n", "# T\n"),
966 )
967 .unwrap();
968 let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
969
970 let error = handle(
971 root.path(),
972 &store,
973 &args,
974 2_000,
975 "TICK",
976 None,
977 &flows(),
978 "default",
979 )
980 .unwrap_err()
981 .to_string();
982 assert!(error.contains("bogus"), "{error}");
983 assert!(error.contains("default"), "{error}");
984 assert!(error.contains("release"), "{error}");
985 assert!(store.ticket_ids().unwrap().is_empty());
986 }
987
988 #[test]
989 fn reindex_recovers_the_flow_binding_from_frontmatter_into_a_fresh_store() {
990 let (root, store) = world();
991 std::fs::write(
992 root.path().join(".agents/sloop/tickets/t.md"),
993 ticket("", "# T\n"),
994 )
995 .unwrap();
996 let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
997 handle(
998 root.path(),
999 &store,
1000 &args,
1001 2_000,
1002 "TICK",
1003 None,
1004 &flows(),
1005 "default",
1006 )
1007 .unwrap();
1008 drop(store);
1009
1010 let fresh_store = Store::open(&root.path().join("fresh.db"), 3_000).unwrap();
1013 fresh_store
1014 .upsert_local_project(
1015 "default",
1016 ".agents/sloop/projects/default.md",
1017 "Default",
1018 3_000,
1019 )
1020 .unwrap();
1021 let response = handle(
1022 root.path(),
1023 &fresh_store,
1024 &args,
1025 3_000,
1026 "TICK",
1027 None,
1028 &flows(),
1029 "default",
1030 )
1031 .unwrap();
1032
1033 assert_eq!(response["ticket"]["id"], "TICK-1");
1034 assert_eq!(response["ticket"]["flow"], "default");
1035 }
1036
1037 #[test]
1038 fn idless_tickets_get_monotonic_generated_ids() {
1039 let (root, store) = world();
1040 std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1041 std::fs::write(
1042 root.path().join(".agents/sloop/tickets/fix.md"),
1043 ticket("", "# A\n"),
1044 )
1045 .unwrap();
1046 std::fs::write(
1047 root.path().join(".agents/sloop/tickets/nested/fix.md"),
1048 ticket("", "# B\n"),
1049 )
1050 .unwrap();
1051
1052 let first = handle(
1053 root.path(),
1054 &store,
1055 &post(".agents/sloop/tickets/fix.md", PostActivation::Manual),
1056 2_000,
1057 "TICK",
1058 None,
1059 &flows(),
1060 "default",
1061 )
1062 .unwrap();
1063 let second = handle(
1064 root.path(),
1065 &store,
1066 &post(
1067 ".agents/sloop/tickets/nested/fix.md",
1068 PostActivation::Manual,
1069 ),
1070 2_100,
1071 "TICK",
1072 None,
1073 &flows(),
1074 "default",
1075 )
1076 .unwrap();
1077 assert_eq!(first["ticket"]["id"], "TICK-1");
1078 assert_eq!(second["ticket"]["id"], "TICK-2");
1079 }
1080
1081 #[test]
1082 fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1083 let (root, store) = world();
1084 let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1085 let explicit_content = ticket(
1086 "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1087 "# Explicit\n",
1088 );
1089 std::fs::write(&explicit, &explicit_content).unwrap();
1090 handle(
1091 root.path(),
1092 &store,
1093 &post(".agents/sloop/tickets/explicit.md", PostActivation::Manual),
1094 2_000,
1095 "WORK",
1096 None,
1097 &flows(),
1098 "default",
1099 )
1100 .unwrap();
1101 assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1102
1103 std::fs::write(
1104 root.path().join(".agents/sloop/tickets/unrelated.md"),
1105 ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1106 )
1107 .unwrap();
1108 handle(
1109 root.path(),
1110 &store,
1111 &post(".agents/sloop/tickets/unrelated.md", PostActivation::Manual),
1112 2_100,
1113 "WORK",
1114 None,
1115 &flows(),
1116 "default",
1117 )
1118 .unwrap();
1119
1120 std::fs::write(
1121 root.path().join(".agents/sloop/tickets/generated.md"),
1122 ticket("", "# Generated\n"),
1123 )
1124 .unwrap();
1125 let generated = handle(
1126 root.path(),
1127 &store,
1128 &post(".agents/sloop/tickets/generated.md", PostActivation::Manual),
1129 2_200,
1130 "WORK",
1131 None,
1132 &flows(),
1133 "default",
1134 )
1135 .unwrap();
1136 assert_eq!(generated["ticket"]["id"], "WORK-10");
1137 }
1138
1139 #[test]
1140 fn paths_escaping_the_repository_are_rejected() {
1141 let (root, store) = world();
1142 let args = post("../outside.md", PostActivation::Manual);
1143
1144 assert!(matches!(
1145 handle(
1146 root.path(),
1147 &store,
1148 &args,
1149 2_000,
1150 "TICK",
1151 None,
1152 &flows(),
1153 "default"
1154 ),
1155 Err(PostError::OutsideRepository(_))
1156 ));
1157 }
1158
1159 #[test]
1160 fn paths_outside_the_ticket_directory_are_rejected() {
1161 let (root, store) = world();
1162 std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1163
1164 assert!(matches!(
1165 handle(
1166 root.path(),
1167 &store,
1168 &post("elsewhere.md", PostActivation::Manual),
1169 2_000,
1170 "TICK",
1171 None,
1172 &flows(),
1173 "default",
1174 ),
1175 Err(PostError::OutsideTicketDirectory { .. })
1176 ));
1177 }
1178}