1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::Path;
6
7use serde_json::{Value, json};
8
9use crate::config::{AgentConfig, expand_agent_cmd};
10use crate::flow::Flow;
11use crate::frontmatter;
12use crate::ids::next_id;
13use crate::sources::TicketSource;
14use crate::store::{ReindexTicket, Store};
15
16#[allow(clippy::too_many_arguments)]
17pub fn run(
18 root: &Path,
19 ticket_source: &dyn TicketSource,
20 worktree_dir: &Path,
21 store: &Store,
22 now_ms: i64,
23 ticket_prefix: &str,
24 project_ids: &[String],
25 agent: Option<&AgentConfig>,
26 flows: &BTreeMap<String, Flow>,
27 default_flow: &str,
28) -> Result<Value, ReindexError> {
29 let authored = ticket_source
30 .pull()
31 .map_err(|error| ReindexError(error.to_string()))?;
32 let mut known_ids: Vec<String> = authored
33 .iter()
34 .filter_map(|ticket| ticket.frontmatter.id.clone())
35 .collect();
36 let mut unique_ids = BTreeSet::new();
37 for id in &known_ids {
38 if !unique_ids.insert(id.clone()) {
39 return Err(ReindexError(format!(
40 "duplicate ticket ID `{id}` in the configured ticket directory"
41 )));
42 }
43 }
44 let mut unique_refs = BTreeSet::new();
45 for ticket in &authored {
46 if !unique_refs.insert((&ticket.source, &ticket.source_ref)) {
47 return Err(ReindexError(format!(
48 "duplicate source reference `{}` from `{}`",
49 ticket.source_ref, ticket.source
50 )));
51 }
52 }
53 let known_projects: BTreeSet<&str> = project_ids.iter().map(String::as_str).collect();
54 let fallback_project = if known_projects.contains("default") {
55 "default".to_owned()
56 } else {
57 project_ids
58 .first()
59 .cloned()
60 .ok_or_else(|| ReindexError("cannot index tickets without an indexed project".into()))?
61 };
62
63 let mut tickets = Vec::with_capacity(authored.len());
64 let mut assigned_ids = BTreeSet::new();
65 for authored_ticket in authored {
66 let prior_id = store
67 .ticket_by_source_ref(&authored_ticket.source, &authored_ticket.source_ref)
68 .map_err(|error| ReindexError(error.to_string()))?
69 .map(|ticket| ticket.id);
70 let id = match authored_ticket.frontmatter.id.clone().or(prior_id) {
71 Some(id) => id,
72 None => {
73 let id = next_id(ticket_prefix, known_ids.iter().map(String::as_str))
74 .map_err(|error| ReindexError(error.to_string()))?;
75 known_ids.push(id.clone());
76 id
77 }
78 };
79 if !assigned_ids.insert(id.clone()) {
80 return Err(ReindexError(format!(
81 "duplicate ticket ID `{id}` in the pulled ticket source"
82 )));
83 }
84 if !known_ids.contains(&id) {
85 known_ids.push(id.clone());
86 }
87 let authored_project = authored_ticket
88 .frontmatter
89 .project
90 .clone()
91 .unwrap_or_else(|| "default".to_owned());
92 let mut held_reason = authored_ticket.validation_error.clone();
93 if authored_ticket.frontmatter.name.trim().is_empty() {
94 held_reason.get_or_insert_with(|| {
95 format!(
96 "{}: frontmatter field `name` is required and must be non-empty",
97 authored_ticket.source_ref
98 )
99 });
100 }
101 if authored_ticket.body.trim().is_empty() {
102 held_reason.get_or_insert_with(|| {
103 format!(
104 "{}: ticket body must be non-empty",
105 authored_ticket.source_ref
106 )
107 });
108 }
109 let project = if known_projects.contains(authored_project.as_str()) {
110 authored_project.clone()
111 } else {
112 held_reason.get_or_insert_with(|| {
113 format!(
114 "{}: project `{authored_project}` is not indexed",
115 authored_ticket.source_ref
116 )
117 });
118 fallback_project.clone()
119 };
120 let flow = authored_ticket
121 .frontmatter
122 .flow
123 .clone()
124 .unwrap_or_else(|| default_flow.to_owned());
125 if !flows.contains_key(&flow) {
126 held_reason.get_or_insert_with(|| {
127 format!(
128 "{}: flow `{flow}` is not defined",
129 authored_ticket.source_ref
130 )
131 });
132 }
133 let target = match authored_ticket.frontmatter.target.as_deref() {
134 Some(target) if agent.is_some_and(|agent| agent.targets.contains_key(target)) => {
135 Some(target.to_owned())
136 }
137 Some(target) => {
138 held_reason.get_or_insert_with(|| {
139 format!(
140 "{}: agent target `{target}` is not configured",
141 authored_ticket.source_ref
142 )
143 });
144 Some(target.to_owned())
145 }
146 None => agent.map(|agent| agent.default_target.clone()),
147 };
148 if let (Some(agent), Some(target)) = (agent, target.as_deref()) {
149 if let Some(command) = agent.targets.get(target)
150 && let Err(message) = expand_agent_cmd(
151 command,
152 authored_ticket.frontmatter.model.as_deref(),
153 authored_ticket.frontmatter.effort.as_deref(),
154 "",
155 )
156 {
157 held_reason.get_or_insert_with(|| {
158 format!(
159 "{}: ticket using agent target `{target}` {message}",
160 authored_ticket.source_ref
161 )
162 });
163 }
164 }
165 let worktree = match authored_ticket.frontmatter.worktree.clone() {
166 Some(worktree) => worktree,
167 None => {
168 let stem = authored_ticket
169 .file_path
170 .as_deref()
171 .and_then(Path::file_stem)
172 .and_then(|stem| stem.to_str());
173 match crate::ids::default_worktree(stem, &id) {
174 Ok(branch) => branch,
175 Err(reason) => {
176 held_reason.get_or_insert_with(|| {
177 format!("{}: {reason}", authored_ticket.source_ref)
178 });
179 format!("sloop/{id}")
180 }
181 }
182 }
183 };
184 if held_reason.is_none()
185 && let (Some(path), Some(content)) = (
186 authored_ticket.file_path.as_ref(),
187 authored_ticket.original_content.as_ref(),
188 )
189 && let Some(updated) = frontmatter::stamp(content, &id, &project, &worktree, &flow)
190 .map_err(|error| ReindexError(format!("{}: {error}", authored_ticket.source_ref)))?
191 {
192 let absolute = root.join(path);
193 fs::write(&absolute, updated).map_err(|source| ReindexError::io(&absolute, source))?;
194 }
195
196 tickets.push(ReindexTicket {
197 id,
198 project_id: project,
199 source: authored_ticket.source,
200 source_ref: authored_ticket.source_ref,
201 file_path: authored_ticket
202 .file_path
203 .map(|path| path.to_string_lossy().into_owned()),
204 name: authored_ticket.frontmatter.name,
205 blocked_by: authored_ticket.frontmatter.blocked_by,
206 worktree,
207 target,
208 model: authored_ticket.frontmatter.model,
209 effort: authored_ticket.frontmatter.effort,
210 flow,
211 body: authored_ticket.body,
212 held_reason,
213 derived_state: None,
214 });
215 }
216
217 let ticket_ids: BTreeSet<String> = tickets.iter().map(|ticket| ticket.id.clone()).collect();
218 let mut dependencies = BTreeMap::new();
219 for ticket in &mut tickets {
220 let unknown_blocker = ticket
221 .blocked_by
222 .iter()
223 .find(|blocker| !ticket_ids.contains(*blocker))
224 .cloned();
225 if let Some(blocker) = unknown_blocker {
226 ticket.held_reason.get_or_insert_with(|| {
227 format!(
228 "ticket `{}` field `blocked_by` references unknown ticket `{blocker}`; edit `{}` to drop or correct the reference",
229 ticket.id, ticket.source_ref
230 )
231 });
232 ticket.blocked_by.clear();
233 } else {
234 dependencies.insert(ticket.id.clone(), ticket.blocked_by.clone());
235 }
236 }
237 if let Some(chain) = crate::domain::graph::find_cycle(&dependencies) {
238 return Err(ReindexError(format!(
239 "field `blocked_by` creates a dependency cycle: {}",
240 chain.join(" -> ")
241 )));
242 }
243
244 crate::reindex_evidence::derive_states(root, worktree_dir, &mut tickets)?;
245 let result = store
246 .apply_reindex(project_ids, &tickets, now_ms)
247 .map_err(|error| ReindexError(error.to_string()))?;
248 let state_changes: Vec<Value> = result
249 .state_changes
250 .into_iter()
251 .map(|change| {
252 json!({
253 "ticket": change.ticket_id,
254 "previous_state": change.previous_state,
255 "state": change.state,
256 })
257 })
258 .collect();
259 Ok(json!({
260 "projects_indexed": project_ids.len(),
261 "tickets_indexed": tickets.len(),
262 "tickets_state_changed": state_changes.len(),
263 "state_changes": state_changes,
264 "rows_dropped": result.rows_dropped,
265 }))
266}
267
268#[derive(Debug)]
269pub struct ReindexError(pub(crate) String);
270
271impl ReindexError {
272 pub(crate) fn io(path: &Path, source: io::Error) -> Self {
273 Self(format!("{}: {source}", path.display()))
274 }
275}
276
277impl fmt::Display for ReindexError {
278 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
279 formatter.write_str(&self.0)
280 }
281}
282
283impl std::error::Error for ReindexError {}