// synops-feature-status — Sjekk feature-status. // // Samler informasjon om en feature fra fire kilder: // 1. Spec-fil på disk (docs/features/ eller docs/concepts/) // 2. Oppgavestatus fra tasks.md // 3. Nylige git-commits som berører featuren // 4. Ubesvart feedback fra databasen (kommunikasjonsnoder med discusses-edge) // // Output: markdown-formatert sammendrag til stdout. // // Miljøvariabler: // DATABASE_URL — PostgreSQL-tilkobling (valgfri — feedback hoppes over uten) // // Ref: docs/retninger/unix_filosofi.md, docs/features/feature_feedback.md use clap::Parser; use regex::Regex; use std::path::{Path, PathBuf}; use std::process::{self, Command}; /// Sjekk feature-status: spec, oppgaver, commits og feedback. #[derive(Parser)] #[command(name = "synops-feature-status", about = "Sjekk feature-status")] struct Cli { /// Feature-nøkkel (f.eks. "lydstudio", "chat", "publisering") feature_key: String, /// Antall commits å vise (default: 10) #[arg(long, default_value_t = 10)] commits: usize, /// Repo-rot (default: /home/vegard/synops) #[arg(long, default_value = "/home/vegard/synops")] repo: String, } fn main() { synops_common::logging::init("synops_feature_status"); let cli = Cli::parse(); let repo = PathBuf::from(&cli.repo); if !repo.join("tasks.md").exists() { eprintln!("Feil: Fant ikke tasks.md i {}", repo.display()); process::exit(1); } let key = &cli.feature_key; println!("# Feature-status: {key}\n"); // 1. Spec-sammendrag print_spec_summary(&repo, key); // 2. Oppgavestatus print_task_status(&repo, key); // 3. Nylige commits print_recent_commits(&repo, key, cli.commits); // 4. Ubesvart feedback (krever DATABASE_URL) run_feedback(key); } // --- Spec-sammendrag --- fn find_spec_file(repo: &Path, key: &str) -> Option { let key_lower = key.to_lowercase(); let underscore = key_lower.replace('-', "_"); let hyphen = key_lower.replace('_', "-"); let candidates = [ repo.join(format!("docs/features/{key_lower}.md")), repo.join(format!("docs/concepts/{key_lower}.md")), repo.join(format!("docs/features/{underscore}.md")), repo.join(format!("docs/concepts/{underscore}.md")), repo.join(format!("docs/features/{hyphen}.md")), repo.join(format!("docs/concepts/{hyphen}.md")), ]; for path in &candidates { if path.exists() { return Some(path.clone()); } } // Fuzzy: søk i begge mapper etter filer som inneholder nøkkelen for dir in &["docs/features", "docs/concepts"] { let dir_path = repo.join(dir); if let Ok(entries) = std::fs::read_dir(&dir_path) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_lowercase(); if name.ends_with(".md") && name.contains(&key_lower) { return Some(entry.path()); } } } } None } fn extract_summary(content: &str) -> String { let lines: Vec<&str> = content.lines().collect(); let mut summary_parts: Vec = Vec::new(); // Hent tittelen (første # heading) for line in &lines { if line.starts_with("# ") { summary_parts.push(line.to_string()); break; } } // Hent første avsnitt etter tittelen (eller etter frontmatter) let mut in_frontmatter = false; let mut past_title = false; let mut paragraph = Vec::new(); for line in &lines { if line.starts_with("---") { in_frontmatter = !in_frontmatter; continue; } if in_frontmatter { continue; } if line.starts_with("# ") { past_title = true; continue; } if !past_title { continue; } let trimmed = line.trim(); if trimmed.is_empty() { if !paragraph.is_empty() { break; } continue; } if trimmed.starts_with('#') { if !paragraph.is_empty() { break; } continue; } paragraph.push(trimmed.to_string()); } if !paragraph.is_empty() { summary_parts.push(paragraph.join(" ")); } // Hent ## headings som oversikt let headings: Vec<&str> = lines .iter() .filter(|l| l.starts_with("## ")) .copied() .collect(); if !headings.is_empty() { summary_parts.push(String::new()); summary_parts.push("**Seksjoner:**".to_string()); for h in &headings { summary_parts.push(format!("- {}", h.trim_start_matches("## "))); } } summary_parts.join("\n") } fn print_spec_summary(repo: &Path, key: &str) { println!("## Spec\n"); match find_spec_file(repo, key) { Some(path) => { let rel_path = path .strip_prefix(repo) .unwrap_or(&path) .display() .to_string(); match std::fs::read_to_string(&path) { Ok(content) => { println!("Kilde: `{rel_path}`\n"); let summary = extract_summary(&content); if summary.is_empty() { println!("_(Tom spec-fil)_\n"); } else { println!("{summary}\n"); } } Err(e) => println!("Kunne ikke lese {rel_path}: {e}\n"), } } None => { println!("Ingen spec-fil funnet for «{key}»."); println!("Søkte i: docs/features/, docs/concepts/\n"); } } } // --- Oppgavestatus --- struct TaskInfo { phase: u32, phase_title: String, id: String, title: String, status: String, details: Vec, } fn print_task_status(repo: &Path, key: &str) { println!("## Oppgaver\n"); let tasks_path = repo.join("tasks.md"); let content = match std::fs::read_to_string(&tasks_path) { Ok(c) => c, Err(e) => { println!("Kunne ikke lese tasks.md: {e}\n"); return; } }; let key_lower = key.to_lowercase(); let search_terms: Vec = vec![ key_lower.clone(), key_lower.replace('-', "_"), key_lower.replace('_', "-"), key_lower.replace('_', " "), key_lower.replace('-', " "), ]; let phase_re = Regex::new(r"^## Fase (\d+):\s*(.+)$").unwrap(); let task_re = Regex::new(r"^- \[(.)\] (\d+\.\d+)\s+(.+)$").unwrap(); let detail_re = Regex::new(r"^ > (.+)$").unwrap(); let mut all_tasks: Vec = Vec::new(); let mut current_phase: Option<(u32, String)> = None; for line in content.lines() { if let Some(caps) = phase_re.captures(line) { let num: u32 = caps[1].parse().unwrap_or(0); let title = caps[2].trim().to_string(); current_phase = Some((num, title)); continue; } if let Some(caps) = task_re.captures(line) { let status_char = &caps[1]; let id = caps[2].to_string(); let title = caps[3].to_string(); let status = match status_char { " " => "[ ]", "~" => "[~]", "x" => "[x]", "?" => "[?]", "!" => "[!]", _ => "[?]", } .to_string(); let (phase, phase_title) = current_phase .clone() .unwrap_or((0, "Ukjent".to_string())); all_tasks.push(TaskInfo { phase, phase_title, id, title, status, details: Vec::new(), }); continue; } if let Some(caps) = detail_re.captures(line) { if let Some(task) = all_tasks.last_mut() { task.details.push(caps[1].to_string()); } } } // Finn oppgaver som matcher feature-nøkkelen let mut matching: Vec<&TaskInfo> = all_tasks .iter() .filter(|t| { let title_lower = t.title.to_lowercase(); let phase_lower = t.phase_title.to_lowercase(); search_terms .iter() .any(|term| title_lower.contains(term) || phase_lower.contains(term)) }) .collect(); // Også prøv synops- hvis ingenting funnet if matching.is_empty() { let tool_name = format!("synops-{}", key_lower); matching = all_tasks .iter() .filter(|t| t.title.to_lowercase().contains(&tool_name)) .collect(); } if matching.is_empty() { println!("Ingen oppgaver funnet for «{key}».\n"); return; } let total = matching.len(); let done = matching.iter().filter(|t| t.status == "[x]").count(); println!("Funnet {total} relaterte oppgaver ({done}/{total} ferdig):\n"); let mut current_ph: Option = None; for task in &matching { if current_ph != Some(task.phase) { current_ph = Some(task.phase); println!("### Fase {}: {}\n", task.phase, task.phase_title); } println!("- {} {} {}", task.status, task.id, task.title); for detail in &task.details { println!(" > {detail}"); } } println!(); } // --- Git-commits --- fn print_recent_commits(repo: &Path, key: &str, max: usize) { println!("## Nylige commits\n"); let key_lower = key.to_lowercase(); let search_patterns: Vec = vec![ key_lower.clone(), key_lower.replace('-', "_"), key_lower.replace('_', "-"), format!("synops-{}", key_lower), ]; let file_patterns: Vec = vec![ format!("docs/features/{}*", key_lower), format!("docs/features/{}*", key_lower.replace('-', "_")), format!("docs/concepts/{}*", key_lower), format!("docs/concepts/{}*", key_lower.replace('-', "_")), format!("tools/synops-{}*", key_lower), ]; let mut commits: Vec<(String, String, String)> = Vec::new(); let mut seen_hashes: std::collections::HashSet = std::collections::HashSet::new(); // Commits fra filer for pattern in &file_patterns { if let Ok(output) = Command::new("git") .args([ "log", "--oneline", "--format=%h|%ai|%s", &format!("-{max}"), "--", pattern, ]) .current_dir(repo) .output() { let stdout = String::from_utf8_lossy(&output.stdout); parse_commit_lines(&stdout, &mut commits, &mut seen_hashes); } } // Commits fra meldinger (--grep) for term in &search_patterns { if let Ok(output) = Command::new("git") .args([ "log", "--oneline", "--format=%h|%ai|%s", &format!("-{max}"), "-i", "--grep", term, ]) .current_dir(repo) .output() { let stdout = String::from_utf8_lossy(&output.stdout); parse_commit_lines(&stdout, &mut commits, &mut seen_hashes); } } // Sorter etter dato (nyeste først) commits.sort_by(|a, b| b.1.cmp(&a.1)); commits.truncate(max); if commits.is_empty() { println!("Ingen commits funnet for «{key}».\n"); return; } println!("{} relevante commits:\n", commits.len()); for (hash, date, msg) in &commits { let short_date = if date.len() >= 10 { &date[..10] } else { date }; println!("- `{hash}` ({short_date}) {msg}"); } println!(); } fn parse_commit_lines( stdout: &str, commits: &mut Vec<(String, String, String)>, seen: &mut std::collections::HashSet, ) { for line in stdout.lines() { let parts: Vec<&str> = line.splitn(3, '|').collect(); if parts.len() == 3 && seen.insert(parts[0].to_string()) { commits.push(( parts[0].to_string(), parts[1].to_string(), parts[2].to_string(), )); } } } // --- Feedback fra database --- fn run_feedback(key: &str) { // Sikre at DATABASE_URL er satt — prøv maskinrommet.env som fallback if std::env::var("DATABASE_URL").is_err() { if let Ok(content) = std::fs::read_to_string("/tmp/maskinrommet.env") { for line in content.lines() { if let Some(val) = line.strip_prefix("DATABASE_URL=") { // SAFETY: kalles tidlig i main, før noen tråder er startet. unsafe { std::env::set_var("DATABASE_URL", val); } break; } } } } let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(print_feedback(key)); } async fn print_feedback(key: &str) { println!("## Feedback\n"); let db = match synops_common::db::connect().await { Ok(pool) => pool, Err(e) => { eprintln!("{e}"); println!("_(DATABASE_URL ikke satt eller databasefeil — feedback hoppes over)_\n"); return; } }; // Finn spec-noder med matching feature_key let spec_nodes: Vec<(uuid::Uuid, Option)> = sqlx::query_as( "SELECT id, title FROM nodes \ WHERE metadata->>'feature_spec' = 'true' \ AND LOWER(metadata->>'feature_key') = LOWER($1) \ LIMIT 5", ) .bind(key) .fetch_all(&db) .await .unwrap_or_default(); if spec_nodes.is_empty() { println!("Ingen spec-node funnet i databasen for «{key}»."); println!("Feature feedback-systemet er kanskje ikke satt opp for denne featuren.\n"); return; } for (node_id, title) in &spec_nodes { let t = title.as_deref().unwrap_or("Uten tittel"); println!("Spec-node: `{node_id}` — {t}"); print_feedback_for_spec(&db, *node_id).await; } println!(); } async fn print_feedback_for_spec(db: &sqlx::PgPool, spec_node_id: uuid::Uuid) { // Finn feedback-chatter (kommunikasjonsnoder med discusses-edge til spec) let feedback_chats: Vec<(uuid::Uuid, Option)> = sqlx::query_as( "SELECT n.id, n.title \ FROM nodes n \ JOIN edges e ON e.source_id = n.id \ WHERE e.target_id = $1 \ AND e.edge_type = 'discusses' \ AND n.node_kind = 'communication' \ LIMIT 10", ) .bind(spec_node_id) .fetch_all(db) .await .unwrap_or_default(); if feedback_chats.is_empty() { println!(" Ingen feedback-chatter koblet til denne spec-noden.\n"); return; } for (chat_id, chat_title) in &feedback_chats { let ct = chat_title.as_deref().unwrap_or("Feedback"); println!("\n Feedback-chat: `{chat_id}` — {ct}"); // Finn ubesvarte meldinger: meldinger nyere enn siste Claude-svar let unanswered: Vec<(uuid::Uuid, Option, chrono::DateTime)> = sqlx::query_as( "SELECT n.id, \ LEFT(n.content, 200) AS excerpt, \ n.created_at \ FROM nodes n \ JOIN edges e ON e.source_id = n.id \ WHERE e.target_id = $1 \ AND e.edge_type = 'belongs_to' \ AND n.created_at > COALESCE( \ (SELECT MAX(n2.created_at) \ FROM nodes n2 \ JOIN edges e2 ON e2.source_id = n2.id \ WHERE e2.target_id = $1 \ AND e2.edge_type = 'belongs_to' \ AND n2.created_by = 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44'::uuid), \ '1970-01-01'::timestamptz) \ AND n.created_by != 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44'::uuid \ ORDER BY n.created_at DESC \ LIMIT 20", ) .bind(chat_id) .fetch_all(db) .await .unwrap_or_default(); if unanswered.is_empty() { println!(" Ingen ubesvart feedback."); } else { println!(" **{} ubesvarte meldinger:**", unanswered.len()); for (msg_id, excerpt, created) in &unanswered { let date = created.format("%Y-%m-%d %H:%M"); let text = excerpt .as_deref() .unwrap_or("(tomt)") .replace('\n', " "); println!(" - `{msg_id}` ({date}): {text}"); } } } }