huskies: merge 512_story_migrate_chat_commands_from_filesystem_lookup_to_crdt_db
This commit is contained in:
@@ -9,16 +9,6 @@
|
||||
use super::CommandContext;
|
||||
use crate::io::story_metadata::{parse_front_matter, write_depends_on};
|
||||
|
||||
/// All pipeline stage directories searched when finding a work item by number.
|
||||
const SEARCH_DIRS: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
/// Handle the `depends` command.
|
||||
///
|
||||
/// Syntax: `depends <number> [dep1 dep2 ...]`
|
||||
@@ -60,60 +50,10 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Find the story file across all pipeline stages by numeric prefix.
|
||||
// Try the content store / CRDT state first, then fall back to filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None;
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == num_str && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) {
|
||||
let path = ctx
|
||||
.project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(item.stage.dir_name())
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
if found.is_none() {
|
||||
'outer: for stage_dir in SEARCH_DIRS {
|
||||
let dir = ctx
|
||||
.project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage_dir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
found = Some((path.to_path_buf(), stem.to_string()));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, story_id) = match found {
|
||||
Some(f) => f,
|
||||
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
||||
let (story_id, _stage_dir, path, content) =
|
||||
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No story, bug, or spike with number **{num_str}** found."
|
||||
@@ -121,8 +61,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
|
||||
}
|
||||
};
|
||||
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
let story_name = content
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|c| parse_front_matter(&c).ok())
|
||||
.and_then(|m| m.name)
|
||||
|
||||
@@ -10,16 +10,6 @@ use crate::agents::move_story_to_stage;
|
||||
/// Valid stage names accepted by the move command.
|
||||
const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"];
|
||||
|
||||
/// All pipeline stage directories to search when finding a work item by number.
|
||||
const SEARCH_DIRS: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
/// Handle the `move` command.
|
||||
///
|
||||
/// Parses `<number> <stage>` from `ctx.args`, locates the work item by its
|
||||
@@ -55,49 +45,10 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
|
||||
));
|
||||
}
|
||||
|
||||
// Find the story file across all pipeline stages by numeric prefix.
|
||||
let mut found_story_id: Option<String> = None;
|
||||
let mut found_name: Option<String> = None;
|
||||
|
||||
'outer: for stage_dir in SEARCH_DIRS {
|
||||
let dir = ctx
|
||||
.project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage_dir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
found_story_id = Some(stem.to_string());
|
||||
found_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
});
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let story_id = match found_story_id {
|
||||
Some(id) => id,
|
||||
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
||||
let (story_id, _stage_dir, _path, content) =
|
||||
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No story, bug, or spike with number **{num_str}** found."
|
||||
@@ -105,6 +56,10 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
|
||||
}
|
||||
};
|
||||
|
||||
let found_name = content
|
||||
.and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok())
|
||||
.and_then(|m| m.name);
|
||||
|
||||
let display_name = found_name.as_deref().unwrap_or(&story_id);
|
||||
|
||||
match move_story_to_stage(ctx.project_root, &story_id, &target_stage) {
|
||||
|
||||
@@ -4,9 +4,8 @@ use super::CommandContext;
|
||||
|
||||
/// Display the full markdown text of a work item identified by its numeric ID.
|
||||
///
|
||||
/// Searches all pipeline stages in order and returns the raw file contents of
|
||||
/// the first matching story, bug, or spike. Returns a friendly message when
|
||||
/// no match is found.
|
||||
/// Lookup priority: CRDT → content store → filesystem (Story 512).
|
||||
/// Returns a friendly message when no match is found.
|
||||
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
@@ -22,50 +21,27 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||
));
|
||||
}
|
||||
|
||||
let stages = [
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
for stage in &stages {
|
||||
let dir = ctx
|
||||
.project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
return match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => Some(contents),
|
||||
Err(e) => Some(format!("Failed to read story {num_str}: {e}")),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
||||
let (story_id, _stage_dir, path, content) =
|
||||
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No story, bug, or spike with number **{num_str}** found."
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// `content` is populated from the content store (CRDT/DB path) or read
|
||||
// from disk during the filesystem fallback. If it is None (story found in
|
||||
// CRDT but no content-store entry yet), attempt a direct disk read.
|
||||
Some(
|
||||
content
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.unwrap_or_else(|| {
|
||||
format!("Story {story_id} found in pipeline but its content is unavailable.")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,16 +8,6 @@ use super::CommandContext;
|
||||
use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
|
||||
use std::path::Path;
|
||||
|
||||
/// All pipeline stage directories to search when finding a work item by number.
|
||||
const SEARCH_DIRS: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
/// Handle the `unblock` command.
|
||||
///
|
||||
/// Parses `<number>` from `ctx.args`, locates the work item, checks that it is
|
||||
@@ -40,62 +30,29 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
|
||||
///
|
||||
/// Returns a Markdown-formatted response string suitable for all transports.
|
||||
/// Also used by the MCP `unblock` tool.
|
||||
///
|
||||
/// Lookup priority: CRDT → content store → filesystem (Story 512).
|
||||
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
|
||||
// Try content store / CRDT first to find story by numeric prefix.
|
||||
let mut found_id: Option<String> = None;
|
||||
|
||||
// Check content store IDs.
|
||||
for id in crate::db::all_content_ids() {
|
||||
let num = id.split('_').next().unwrap_or("");
|
||||
if num == story_number {
|
||||
found_id = Some(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If found in content store, use DB-backed unblock.
|
||||
if let Some(story_id) = found_id {
|
||||
return unblock_by_story_id(&story_id);
|
||||
}
|
||||
|
||||
// Fallback: find the story file across all pipeline stages on filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None;
|
||||
|
||||
'outer: for stage_dir in SEARCH_DIRS {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage_dir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == story_number {
|
||||
found = Some((path.to_path_buf(), stem.to_string()));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, story_id) = match found {
|
||||
Some(f) => f,
|
||||
let (story_id, _stage_dir, path, _content) =
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Prefer DB-backed unblock when the story is in the content store.
|
||||
// Note: `content` may have come from the filesystem fallback in
|
||||
// `find_story_by_number`, so we must re-check the DB rather than
|
||||
// relying on `content.is_some()` alone.
|
||||
if crate::db::read_content(&story_id).is_some() {
|
||||
unblock_by_story_id(&story_id)
|
||||
} else {
|
||||
unblock_by_path(&path, &story_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unblock a story using the content store (DB-backed).
|
||||
fn unblock_by_story_id(story_id: &str) -> String {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Shared story-lookup helper for chat commands.
|
||||
//!
|
||||
//! All chat commands that need to find a work item by its numeric prefix
|
||||
//! use [`find_story_by_number`]. The lookup priority matches the MCP
|
||||
//! `move_story` tool (which already worked correctly post-491/492 migration):
|
||||
//!
|
||||
//! 1. **CRDT** — authoritative in-memory state, works even when the
|
||||
//! filesystem shadow does not exist.
|
||||
//! 2. **Content store / pipeline_items** — in-memory mirror of the
|
||||
//! `pipeline_items` table; catches items that are in the DB but whose
|
||||
//! CRDT entry hasn't been synced yet.
|
||||
//! 3. **Filesystem** — backward-compatible fallback for stories that have
|
||||
//! not yet been imported into the DB (pre-migration window).
|
||||
//!
|
||||
//! **Why this module exists (Story 512):** before this change, `move` and
|
||||
//! `show` used pure filesystem lookups, causing them to silently fail with
|
||||
//! "No story found" for any story whose filesystem shadow didn't exist — even
|
||||
//! when the story was fully present in CRDT and `pipeline_items`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pipeline stage directories searched by the filesystem fallback, in order.
|
||||
pub(crate) const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
/// Locate a work item by its numeric ID prefix.
|
||||
///
|
||||
/// Returns `(story_id, stage_dir, path, content)` where:
|
||||
/// - `story_id` — full stem, e.g. `"503_story_some_feature"`
|
||||
/// - `stage_dir` — pipeline directory name, e.g. `"4_merge"`
|
||||
/// - `path` — canonical path under `.huskies/work/`; may not exist on disk
|
||||
/// for CRDT-only stories
|
||||
/// - `content` — markdown body from the content store when available;
|
||||
/// for the filesystem fallback this is the file contents read from disk;
|
||||
/// `None` only when the story was found in CRDT but has no content entry
|
||||
/// (callers may fall back to `fs::read_to_string(&path)` in that case)
|
||||
///
|
||||
/// Returns `None` when no matching work item is found by any mechanism.
|
||||
pub(crate) fn find_story_by_number(
|
||||
project_root: &Path,
|
||||
number: &str,
|
||||
) -> Option<(String, String, PathBuf, Option<String>)> {
|
||||
// ── 1. CRDT (authoritative) ──────────────────────────────────────────
|
||||
// `read_all_items` returns None only when the CRDT layer is not yet
|
||||
// initialised (e.g. in unit tests or very early startup).
|
||||
if let Some(items) = crate::crdt_state::read_all_items() {
|
||||
for item in items {
|
||||
if item.story_id.split('_').next().unwrap_or("") == number {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(&item.stage)
|
||||
.join(format!("{}.md", item.story_id));
|
||||
let content = crate::db::read_content(&item.story_id);
|
||||
return Some((item.story_id, item.stage, path, content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Content store + CRDT stage lookup ────────────────────────────
|
||||
// Handles the edge case where an item is in the content store but was
|
||||
// somehow missing from the CRDT iteration above (e.g. concurrent write).
|
||||
for id in crate::db::all_content_ids() {
|
||||
if id.split('_').next().unwrap_or("") == number
|
||||
&& let Some(view) = crate::crdt_state::read_item(&id)
|
||||
{
|
||||
let stage_dir = view.stage;
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(&stage_dir)
|
||||
.join(format!("{id}.md"));
|
||||
let content = crate::db::read_content(&id);
|
||||
return Some((id, stage_dir, path, content));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Filesystem (backward-compat for pre-migration stories) ────────
|
||||
for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == number {
|
||||
let content = std::fs::read_to_string(&path).ok();
|
||||
return Some((
|
||||
stem.to_string(),
|
||||
stage.to_string(),
|
||||
path,
|
||||
content,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat::test_helpers::write_story_file;
|
||||
|
||||
#[test]
|
||||
fn not_found_returns_none() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Create the pipeline directories so the search runs.
|
||||
for stage in STAGES {
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies/work").join(stage)).unwrap();
|
||||
}
|
||||
let result = find_story_by_number(tmp.path(), "999");
|
||||
assert!(result.is_none(), "should return None when story is not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_story_in_backlog_via_filesystem() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"42_story_some_feature.md",
|
||||
"---\nname: Some Feature\n---\n\n# Story 42\n",
|
||||
);
|
||||
let (story_id, stage_dir, path, content) =
|
||||
find_story_by_number(tmp.path(), "42").expect("should find story 42");
|
||||
assert_eq!(story_id, "42_story_some_feature");
|
||||
assert_eq!(stage_dir, "1_backlog");
|
||||
assert!(
|
||||
path.ends_with("1_backlog/42_story_some_feature.md"),
|
||||
"unexpected path: {path:?}"
|
||||
);
|
||||
assert!(
|
||||
content.as_deref().unwrap_or("").contains("Some Feature"),
|
||||
"content should include story text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_story_in_any_stage_via_filesystem() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"3_qa",
|
||||
"55_story_inqa.md",
|
||||
"---\nname: In QA\n---\n",
|
||||
);
|
||||
let (story_id, stage_dir, _, _) =
|
||||
find_story_by_number(tmp.path(), "55").expect("should find story 55");
|
||||
assert_eq!(story_id, "55_story_inqa");
|
||||
assert_eq!(stage_dir, "3_qa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_bug_by_number_via_filesystem() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"7_bug_crash_on_login.md",
|
||||
"---\nname: Crash on login\n---\n",
|
||||
);
|
||||
let (story_id, stage_dir, _, _) =
|
||||
find_story_by_number(tmp.path(), "7").expect("should find bug 7");
|
||||
assert_eq!(story_id, "7_bug_crash_on_login");
|
||||
assert_eq!(stage_dir, "2_current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_prefix_must_match_exactly() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Story 1 exists; searching for "10" must not match "1_story_foo".
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"1_story_foo.md",
|
||||
"---\nname: Foo\n---\n",
|
||||
);
|
||||
let result = find_story_by_number(tmp.path(), "10");
|
||||
assert!(result.is_none(), "number 10 should not match story 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returned_path_points_into_project_root() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"4_merge",
|
||||
"503_story_migration.md",
|
||||
"---\nname: Migration\n---\n",
|
||||
);
|
||||
let (_, _, path, _) =
|
||||
find_story_by_number(tmp.path(), "503").expect("should find story 503");
|
||||
assert!(
|
||||
path.starts_with(tmp.path()),
|
||||
"path should be under the project root"
|
||||
);
|
||||
assert!(path.exists(), "filesystem-fallback path should exist on disk");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! notifications) to work against any chat platform — Matrix, WhatsApp, etc.
|
||||
|
||||
pub mod commands;
|
||||
pub(crate) mod lookup;
|
||||
pub mod timer;
|
||||
pub mod transport;
|
||||
pub mod util;
|
||||
|
||||
@@ -13,16 +13,6 @@ use crate::chat::util::strip_bot_mention;
|
||||
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
|
||||
use std::path::Path;
|
||||
|
||||
/// All pipeline stage directories to search when finding a work item by number.
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
/// A parsed assign command from a Matrix message body.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum AssignCommand {
|
||||
@@ -101,60 +91,10 @@ pub async fn handle_assign(
|
||||
project_root: &Path,
|
||||
agents: &AgentPool,
|
||||
) -> String {
|
||||
// Find the story file across all pipeline stages.
|
||||
// Try the content store / CRDT state first, then fall back to filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None;
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == story_number && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(item.stage.dir_name())
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
if found.is_none() {
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, story_id) = match found {
|
||||
Some(f) => f,
|
||||
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
||||
let (story_id, _stage_dir, path, content) =
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
@@ -162,15 +102,9 @@ pub async fn handle_assign(
|
||||
}
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the response.
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
let story_name = content
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|contents| {
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
})
|
||||
.and_then(|contents| parse_front_matter(&contents).ok().and_then(|m| m.name))
|
||||
.unwrap_or_else(|| story_id.clone());
|
||||
|
||||
let agent_name = resolve_agent_name(model_str);
|
||||
@@ -370,6 +304,7 @@ mod tests {
|
||||
|
||||
// -- handle_assign (no running coder) ------------------------------------
|
||||
|
||||
use crate::chat::lookup::STAGES;
|
||||
use crate::chat::test_helpers::write_story_file;
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -60,77 +60,18 @@ pub async fn handle_delete(
|
||||
project_root: &Path,
|
||||
agents: &AgentPool,
|
||||
) -> String {
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
// Find the story file across all pipeline stages.
|
||||
// Try the content store / CRDT state first, then fall back to filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String, String)> = None; // (path, stage, story_id)
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == story_number && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(item.stage.dir_name())
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, item.stage.dir_name().to_string(), id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
if found.is_none() {
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stage.to_string(), stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, stage, story_id) = match found {
|
||||
Some(f) => f,
|
||||
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
||||
let (story_id, stage, path, content) =
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the confirmation message.
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
let story_name = content
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
|
||||
@@ -79,69 +79,10 @@ pub async fn handle_start(
|
||||
project_root: &Path,
|
||||
agents: &AgentPool,
|
||||
) -> String {
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
// Find the story file across all pipeline stages.
|
||||
// Try the content store / CRDT state first, then fall back to filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None; // (path, story_id)
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == story_number && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(item.stage.dir_name())
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
if found.is_none() {
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, story_id) = match found {
|
||||
Some(f) => f,
|
||||
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
||||
let (story_id, _stage_dir, path, content) =
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
@@ -149,9 +90,7 @@ pub async fn handle_start(
|
||||
}
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the response.
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
let story_name = content
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
|
||||
Reference in New Issue
Block a user