Skip to main content

sloop/
reindex.rs

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 {}