tinytown 0.10.0

A simple, fast multi-agent orchestration system using Redis for message passing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
/*
 * Copyright (c) 2024-Present, Jeremy Plichta
 * Licensed under the MIT License
 */

//! Work Graph Compiler: transforms issues/docs into a dependency-aware DAG.
//!
//! This module parses GitHub issue bodies and documents to extract work items
//! and their dependencies, building a directed acyclic graph for execution.
//!
//! # Dependency Detection
//!
//! The compiler recognizes these dependency patterns in issue bodies:
//! - `depends on #123` / `depends on #123, #456`
//! - `after #123`
//! - `blocked by #123`
//! - `requires #123`
//!
//! # Manual Override
//!
//! A mission manifest file can override or supplement detected dependencies:
//! ```toml
//! [[work_items]]
//! issue = 23
//! kind = "implement"
//! depends_on = [22, 21]
//! owner_role = "backend"
//! ```

use std::collections::{HashMap, HashSet, VecDeque};

use regex::Regex;
use serde::Deserialize;
use tracing::{debug, instrument, warn};

use crate::error::{Error, Result};
use crate::mission::types::{MissionId, ObjectiveRef, WorkItem, WorkItemId, WorkKind};

// ==================== Compiled Work Graph ====================

/// Result of compiling objectives into a work graph.
#[derive(Debug, Clone)]
pub struct WorkGraph {
    /// Work items in topological order (respecting dependencies).
    pub items: Vec<WorkItem>,
    /// Map from issue number to work item ID for cross-referencing.
    pub issue_map: HashMap<u64, WorkItemId>,
}

impl WorkGraph {
    /// Get items that are ready to execute (no dependencies).
    #[must_use]
    pub fn ready_items(&self) -> Vec<&WorkItem> {
        self.items
            .iter()
            .filter(|item| item.depends_on.is_empty())
            .collect()
    }

    /// Check if the graph is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// Number of work items.
    #[must_use]
    pub fn len(&self) -> usize {
        self.items.len()
    }
}

// ==================== Parsed Issue ====================

/// Parsed information from a GitHub issue.
#[derive(Debug, Clone)]
pub struct ParsedIssue {
    /// Issue number
    pub number: u64,
    /// Issue title
    pub title: String,
    /// Issue body
    pub body: String,
    /// Repository owner
    pub owner: String,
    /// Repository name
    pub repo: String,
    /// Detected dependency issue numbers
    pub depends_on: Vec<u64>,
    /// Detected work kind
    pub kind: WorkKind,
    /// Suggested owner role
    pub owner_role: Option<String>,
}

// ==================== Manifest ====================

/// Manual mission manifest for dependency overrides.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct MissionManifest {
    /// Work item overrides
    #[serde(default)]
    pub work_items: Vec<ManifestWorkItem>,
}

/// Work item definition in manifest.
#[derive(Debug, Clone, Deserialize)]
pub struct ManifestWorkItem {
    /// Issue number this applies to
    pub issue: u64,
    /// Override work kind
    pub kind: Option<String>,
    /// Override dependencies (issue numbers)
    #[serde(default)]
    pub depends_on: Vec<u64>,
    /// Override owner role
    pub owner_role: Option<String>,
    /// Skip this issue
    #[serde(default)]
    pub skip: bool,
}

// ==================== Compiler ====================

/// Work Graph Compiler transforms objectives into executable work items.
///
/// The compiler parses issue bodies to detect dependencies, applies manifest
/// overrides, and produces a topologically sorted work graph.
pub struct WorkGraphCompiler {
    /// Regex patterns for dependency detection
    dependency_patterns: Vec<Regex>,
}

impl Default for WorkGraphCompiler {
    fn default() -> Self {
        Self::new()
    }
}

impl WorkGraphCompiler {
    /// Create a new compiler with default dependency patterns.
    #[must_use]
    pub fn new() -> Self {
        // Compile regex patterns for dependency detection
        // These patterns match the keyword followed by issue references
        // We'll extract all #N numbers from the matched portion
        //
        // Pattern structure: keyword + issue list
        // Issue list: #N optionally followed by more #N with separators (comma, "and", space)
        // Using non-greedy matching and explicit delimiters to avoid false matches
        let patterns = vec![
            // "depends on #123" or "depends on #123, #456" or "depends on #123 and #456"
            r"(?i)depends?\s+on\s+(#\d+(?:\s*[,&]\s*#\d+|\s+and\s+#\d+)*)",
            // "after #123" or "after #123, #456"
            r"(?i)after\s+(#\d+(?:\s*[,&]\s*#\d+|\s+and\s+#\d+)*)",
            // "blocked by #123"
            r"(?i)blocked\s+by\s+(#\d+(?:\s*[,&]\s*#\d+|\s+and\s+#\d+)*)",
            // "requires #123"
            r"(?i)requires?\s+(#\d+(?:\s*[,&]\s*#\d+|\s+and\s+#\d+)*)",
        ];

        let dependency_patterns = patterns
            .into_iter()
            .map(|p| Regex::new(p).expect("Invalid regex pattern"))
            .collect();

        Self {
            dependency_patterns,
        }
    }

    /// Parse dependencies from issue body text.
    #[instrument(skip(self, body), fields(body_len = body.len()))]
    pub fn parse_dependencies(&self, body: &str) -> Vec<u64> {
        let mut deps = HashSet::new();

        // Simple pattern: find all #N references after dependency keywords
        let number_pattern = Regex::new(r"#(\d+)").expect("Invalid regex");

        for pattern in &self.dependency_patterns {
            for cap in pattern.captures_iter(body) {
                // Extract the matched portion and find all issue numbers
                if let Some(m) = cap.get(0) {
                    for num_cap in number_pattern.captures_iter(m.as_str()) {
                        if let Some(num_match) = num_cap.get(1)
                            && let Ok(num) = num_match.as_str().parse::<u64>()
                        {
                            deps.insert(num);
                        }
                    }
                }
            }
        }

        let result: Vec<u64> = deps.into_iter().collect();
        debug!("Found {} dependencies", result.len());
        result
    }

    /// Infer work kind from issue title and body.
    #[must_use]
    pub fn infer_work_kind(&self, title: &str, body: &str) -> WorkKind {
        let text = format!("{} {}", title, body).to_lowercase();

        if text.contains("design") || text.contains("rfc") || text.contains("proposal") {
            WorkKind::Design
        } else if text.contains("test") || text.contains("spec") {
            WorkKind::Test
        } else if text.contains("review") {
            WorkKind::Review
        } else if text.contains("merge") {
            WorkKind::MergeGate
        } else if text.contains("followup") || text.contains("follow-up") || text.contains("fix") {
            WorkKind::Followup
        } else {
            WorkKind::Implement
        }
    }

    /// Infer owner role from issue labels or content.
    #[must_use]
    pub fn infer_owner_role(&self, title: &str, body: &str) -> Option<String> {
        let text = format!("{} {}", title, body).to_lowercase();

        if text.contains("backend") || text.contains("api") || text.contains("server") {
            Some("backend".to_string())
        } else if text.contains("frontend") || text.contains("ui") || text.contains("web") {
            Some("frontend".to_string())
        } else if text.contains("test") || text.contains("qa") {
            Some("tester".to_string())
        } else if text.contains("review") {
            Some("reviewer".to_string())
        } else if text.contains("devops")
            || text.contains("infrastructure")
            || text.contains("deploy")
        {
            Some("devops".to_string())
        } else {
            None
        }
    }

    /// Parse a GitHub issue into a ParsedIssue structure.
    #[must_use]
    pub fn parse_issue(
        &self,
        number: u64,
        title: String,
        body: String,
        owner: String,
        repo: String,
    ) -> ParsedIssue {
        let depends_on = self.parse_dependencies(&body);
        let kind = self.infer_work_kind(&title, &body);
        let owner_role = self.infer_owner_role(&title, &body);

        ParsedIssue {
            number,
            title,
            body,
            owner,
            repo,
            depends_on,
            kind,
            owner_role,
        }
    }

    /// Apply manifest overrides to a parsed issue.
    fn apply_manifest_overrides(
        &self,
        issue: &mut ParsedIssue,
        manifest: &MissionManifest,
    ) -> bool {
        if let Some(override_item) = manifest.work_items.iter().find(|w| w.issue == issue.number) {
            if override_item.skip {
                debug!("Skipping issue #{} per manifest", issue.number);
                return false;
            }

            if let Some(ref kind_str) = override_item.kind {
                issue.kind = match kind_str.to_lowercase().as_str() {
                    "design" => WorkKind::Design,
                    "implement" => WorkKind::Implement,
                    "test" => WorkKind::Test,
                    "review" => WorkKind::Review,
                    "merge_gate" => WorkKind::MergeGate,
                    "followup" => WorkKind::Followup,
                    _ => issue.kind,
                };
            }

            if !override_item.depends_on.is_empty() {
                issue.depends_on = override_item.depends_on.clone();
            }

            if override_item.owner_role.is_some() {
                issue.owner_role = override_item.owner_role.clone();
            }
        }
        true
    }

    /// Compile parsed issues into a work graph.
    ///
    /// This is the main compilation entry point. It:
    /// 1. Applies manifest overrides
    /// 2. Creates work items with proper dependencies
    /// 3. Performs topological sort
    /// 4. Returns error if cycle detected
    #[instrument(skip(self, issues, manifest))]
    pub fn compile(
        &self,
        mission_id: MissionId,
        mut issues: Vec<ParsedIssue>,
        manifest: Option<&MissionManifest>,
    ) -> Result<WorkGraph> {
        let manifest = manifest.cloned().unwrap_or_default();

        // Apply manifest overrides and filter skipped issues
        issues.retain_mut(|issue| self.apply_manifest_overrides(issue, &manifest));

        // Create work items and build issue -> WorkItemId map
        let mut issue_map: HashMap<u64, WorkItemId> = HashMap::new();
        let mut work_items: HashMap<WorkItemId, WorkItem> = HashMap::new();

        for issue in &issues {
            let mut item = WorkItem::new(mission_id, issue.title.clone(), issue.kind);
            item = item.with_source_ref(format!(
                "{}/#{}",
                ObjectiveRef::Issue {
                    owner: issue.owner.clone(),
                    repo: issue.repo.clone(),
                    number: issue.number,
                },
                issue.number
            ));

            if let Some(ref role) = issue.owner_role {
                item = item.with_owner_role(role.clone());
            }

            issue_map.insert(issue.number, item.id);
            work_items.insert(item.id, item);
        }

        // Resolve dependencies from issue numbers to WorkItemIds
        for issue in &issues {
            if let Some(&work_id) = issue_map.get(&issue.number) {
                let dep_ids: Vec<WorkItemId> = issue
                    .depends_on
                    .iter()
                    .filter_map(|dep_num| issue_map.get(dep_num).copied())
                    .collect();

                if !dep_ids.is_empty()
                    && let Some(item) = work_items.get_mut(&work_id)
                {
                    item.depends_on = dep_ids;
                }
            }
        }

        // Topological sort
        let sorted_items = self.topological_sort(work_items)?;

        debug!("Compiled {} work items", sorted_items.len());

        Ok(WorkGraph {
            items: sorted_items,
            issue_map,
        })
    }

    /// Perform Kahn's algorithm for topological sorting.
    /// Returns error if a cycle is detected.
    fn topological_sort(&self, items: HashMap<WorkItemId, WorkItem>) -> Result<Vec<WorkItem>> {
        let mut in_degree: HashMap<WorkItemId, usize> = HashMap::new();
        let mut dependents: HashMap<WorkItemId, Vec<WorkItemId>> = HashMap::new();

        // Initialize in-degrees
        for (id, item) in &items {
            in_degree.entry(*id).or_insert(0);
            for dep in &item.depends_on {
                *in_degree.entry(*id).or_insert(0) += 1;
                dependents.entry(*dep).or_default().push(*id);
            }
        }

        // Start with items that have no dependencies
        let mut queue: VecDeque<WorkItemId> = in_degree
            .iter()
            .filter(|&(_, deg)| *deg == 0)
            .map(|(&id, _)| id)
            .collect();

        let mut sorted = Vec::new();

        while let Some(id) = queue.pop_front() {
            if let Some(item) = items.get(&id).cloned() {
                sorted.push(item);

                // Reduce in-degree for dependents
                if let Some(deps) = dependents.get(&id) {
                    for dep_id in deps {
                        if let Some(deg) = in_degree.get_mut(dep_id) {
                            *deg -= 1;
                            if *deg == 0 {
                                queue.push_back(*dep_id);
                            }
                        }
                    }
                }
            }
        }

        // Check for cycles
        if sorted.len() != items.len() {
            let cycle_items: Vec<String> = items
                .iter()
                .filter(|(id, _)| in_degree.get(id).is_some_and(|&d| d > 0))
                .map(|(_, item)| item.title.clone())
                .collect();
            warn!("Cycle detected in work graph: {:?}", cycle_items);
            return Err(Error::Config(format!(
                "Cycle detected in work graph involving: {}",
                cycle_items.join(", ")
            )));
        }

        Ok(sorted)
    }

    /// Compile from objective references (convenience method).
    ///
    /// This method is intended for use when you have ObjectiveRefs and
    /// need to fetch issue data externally. It returns the parsed issues
    /// so the caller can populate them with actual issue data.
    #[must_use]
    pub fn extract_issue_refs(objectives: &[ObjectiveRef]) -> Vec<(String, String, u64)> {
        objectives
            .iter()
            .filter_map(|obj| match obj {
                ObjectiveRef::Issue {
                    owner,
                    repo,
                    number,
                } => Some((owner.clone(), repo.clone(), *number)),
                ObjectiveRef::Doc { .. } => None,
            })
            .collect()
    }
}

// ==================== Tests ====================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_dependencies_depends_on() {
        let compiler = WorkGraphCompiler::new();
        let body = "This issue depends on #123 and #456";
        let deps = compiler.parse_dependencies(body);
        assert!(deps.contains(&123));
        assert!(deps.contains(&456));
    }

    #[test]
    fn test_parse_dependencies_after() {
        let compiler = WorkGraphCompiler::new();
        let body = "This should be done after #42";
        let deps = compiler.parse_dependencies(body);
        assert!(deps.contains(&42));
    }

    #[test]
    fn test_parse_dependencies_blocked_by() {
        let compiler = WorkGraphCompiler::new();
        let body = "Blocked by #99";
        let deps = compiler.parse_dependencies(body);
        assert!(deps.contains(&99));
    }

    #[test]
    fn test_parse_dependencies_requires() {
        let compiler = WorkGraphCompiler::new();
        let body = "Requires #10 to be completed first";
        let deps = compiler.parse_dependencies(body);
        assert!(deps.contains(&10));
    }

    #[test]
    fn test_parse_dependencies_case_insensitive() {
        let compiler = WorkGraphCompiler::new();
        let body = "DEPENDS ON #100";
        let deps = compiler.parse_dependencies(body);
        assert!(deps.contains(&100));
    }

    #[test]
    fn test_infer_work_kind() {
        let compiler = WorkGraphCompiler::new();
        assert_eq!(
            compiler.infer_work_kind("Design auth system", ""),
            WorkKind::Design
        );
        assert_eq!(
            compiler.infer_work_kind("Add unit tests", ""),
            WorkKind::Test
        );
        assert_eq!(
            compiler.infer_work_kind("Implement feature", ""),
            WorkKind::Implement
        );
    }

    #[test]
    fn test_compile_simple() {
        let compiler = WorkGraphCompiler::new();
        let mission_id = MissionId::new();

        let issues = vec![
            ParsedIssue {
                number: 1,
                title: "First issue".to_string(),
                body: "".to_string(),
                owner: "owner".to_string(),
                repo: "repo".to_string(),
                depends_on: vec![],
                kind: WorkKind::Implement,
                owner_role: None,
            },
            ParsedIssue {
                number: 2,
                title: "Second issue".to_string(),
                body: "".to_string(),
                owner: "owner".to_string(),
                repo: "repo".to_string(),
                depends_on: vec![1],
                kind: WorkKind::Implement,
                owner_role: None,
            },
        ];

        let graph = compiler.compile(mission_id, issues, None).unwrap();
        assert_eq!(graph.len(), 2);

        // First item should have no dependencies
        assert!(graph.items[0].depends_on.is_empty());
        // Second item should depend on first
        assert_eq!(graph.items[1].depends_on.len(), 1);
    }

    #[test]
    fn test_compile_detects_cycle() {
        let compiler = WorkGraphCompiler::new();
        let mission_id = MissionId::new();

        let issues = vec![
            ParsedIssue {
                number: 1,
                title: "First".to_string(),
                body: "".to_string(),
                owner: "owner".to_string(),
                repo: "repo".to_string(),
                depends_on: vec![2],
                kind: WorkKind::Implement,
                owner_role: None,
            },
            ParsedIssue {
                number: 2,
                title: "Second".to_string(),
                body: "".to_string(),
                owner: "owner".to_string(),
                repo: "repo".to_string(),
                depends_on: vec![1],
                kind: WorkKind::Implement,
                owner_role: None,
            },
        ];

        let result = compiler.compile(mission_id, issues, None);
        assert!(result.is_err());
    }
}