diff options
Diffstat (limited to 'Omni/Jr.hs')
| -rwxr-xr-x | Omni/Jr.hs | 762 |
1 files changed, 762 insertions, 0 deletions
diff --git a/Omni/Jr.hs b/Omni/Jr.hs new file mode 100755 index 0000000..0690970 --- /dev/null +++ b/Omni/Jr.hs @@ -0,0 +1,762 @@ +#!/usr/bin/env run.sh +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- : out jr +-- : dep sqlite-simple +-- : dep warp +-- : dep servant-server +-- : dep lucid +-- : dep servant-lucid +module Omni.Jr where + +import Alpha +import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy.Char8 as BLC +import qualified Data.List as List +import qualified Data.Text as Text +import qualified Omni.Agent.Core as AgentCore +import qualified Omni.Agent.Worker as AgentWorker +import qualified Omni.Cli as Cli +import qualified Omni.Fact as Fact +import qualified Omni.Jr.Web as Web +import qualified Omni.Task as Task +import qualified Omni.Task.Core as TaskCore +import qualified Omni.Test as Test +import qualified System.Console.Docopt as Docopt +import qualified System.Directory as Directory +import System.Environment (withArgs) +import qualified System.Exit as Exit +import System.FilePath (takeFileName) +import qualified System.IO as IO +import qualified System.Process as Process + +main :: IO () +main = Cli.main plan + +plan :: Cli.Plan () +plan = + Cli.Plan + { Cli.help = help, + Cli.move = move, + Cli.test = test, + Cli.tidy = \_ -> pure () + } + +help :: Cli.Docopt +help = + [Cli.docopt| +jr + +Usage: + jr task [<args>...] + jr work [<task-id>] + jr web [--port=PORT] + jr review [<task-id>] [--auto] + jr loop [--delay=SECONDS] + jr facts list [--project=PROJECT] [--json] + jr facts show <fact-id> [--json] + jr facts add <project> <content> [--files=FILES] [--task=TASK] [--confidence=CONF] [--json] + jr facts delete <fact-id> [--json] + jr test + jr (-h | --help) + +Commands: + task Manage tasks + work Start a worker agent on a task + web Start the web UI server + review Review a completed task (show diff, accept/reject) + loop Run autonomous work+review loop + facts Manage knowledge base facts + +Options: + -h --help Show this help + --port=PORT Port for web server [default: 8080] + --auto Auto-review: accept if tests pass, reject if they fail + --delay=SECONDS Delay between loop iterations [default: 5] + --project=PROJECT Filter facts by project + --files=FILES Comma-separated list of related files + --task=TASK Source task ID + --confidence=CONF Confidence level 0.0-1.0 [default: 0.8] + --json Output in JSON format +|] + +move :: Cli.Arguments -> IO () +move args + | args `Cli.has` Cli.command "task" = do + let extraArgs = Cli.getAllArgs args (Cli.argument "args") + withArgs extraArgs Task.main + | args `Cli.has` Cli.command "web" = do + let port = case Cli.getArg args (Cli.longOption "port") of + Just p -> fromMaybe Web.defaultPort (readMaybe p) + Nothing -> Web.defaultPort + Web.run port + | args `Cli.has` Cli.command "work" = do + -- Always run in current directory + let path = "." + + -- Infer name from current directory + absPath <- Directory.getCurrentDirectory + let name = Text.pack (takeFileName absPath) + + let worker = + AgentCore.Worker + { AgentCore.workerName = name, + AgentCore.workerPid = Nothing, + AgentCore.workerStatus = AgentCore.Idle, + AgentCore.workerPath = path, + AgentCore.workerQuiet = False -- Show ANSI status bar for manual work + } + + let taskId = fmap Text.pack (Cli.getArg args (Cli.argument "task-id")) + + AgentWorker.start worker taskId + | args `Cli.has` Cli.command "review" = do + let autoMode = args `Cli.has` Cli.longOption "auto" + case Cli.getArg args (Cli.argument "task-id") of + Just tidStr -> reviewTask (Text.pack tidStr) autoMode + Nothing -> do + -- Find tasks in Review status + tasks <- TaskCore.loadTasks + let reviewTasks = filter (\t -> TaskCore.taskStatus t == TaskCore.Review) tasks + case reviewTasks of + [] -> putText "No tasks in Review status." + (t : _) -> reviewTask (TaskCore.taskId t) autoMode + | args `Cli.has` Cli.command "loop" = do + let delay = case Cli.getArg args (Cli.longOption "delay") of + Just d -> fromMaybe 5 (readMaybe d) + Nothing -> 5 + runLoop delay + | args `Cli.has` Cli.command "facts" = handleFacts args + | otherwise = putText (str <| Docopt.usage help) + +-- | Run the autonomous loop: work -> review -> repeat +runLoop :: Int -> IO () +runLoop delaySec = do + putText "[loop] Starting autonomous jr loop..." + putText ("[loop] Delay between iterations: " <> tshow delaySec <> "s") + go + where + go = do + -- First check for tasks to review (prioritize finishing work) + reviewResult <- reviewPending + if reviewResult + then do + -- Reviewed something, continue loop immediately + threadDelay (delaySec * 1000000) + go + else do + -- No reviews, check for ready work + readyTasks <- TaskCore.getReadyTasks + case readyTasks of + [] -> do + putText "[loop] No ready tasks, no pending reviews." + (task : _) -> do + putText "" + putText ("[loop] === Working on: " <> TaskCore.taskId task <> " ===") + -- Run worker (this blocks until the engine completes) + absPath <- Directory.getCurrentDirectory + let name = Text.pack (takeFileName absPath) + let worker = + AgentCore.Worker + { AgentCore.workerName = name, + AgentCore.workerPid = Nothing, + AgentCore.workerStatus = AgentCore.Idle, + AgentCore.workerPath = ".", + AgentCore.workerQuiet = True -- No ANSI status bar in loop mode + } + putText "[loop] Starting worker..." + AgentWorker.start worker (Just (TaskCore.taskId task)) + putText "[loop] Worker finished." + + -- Delay and loop + putText ("[loop] Sleeping " <> tshow delaySec <> "s...") + threadDelay (delaySec * 1000000) + go + + -- Returns True if a task was reviewed, False otherwise + reviewPending :: IO Bool + reviewPending = do + tasks <- TaskCore.loadTasks + let reviewTasks = filter (\t -> TaskCore.taskStatus t == TaskCore.Review) tasks + case reviewTasks of + [] -> pure False + (t : _) -> do + putText "" + putText ("[loop] === Reviewing: " <> TaskCore.taskId t <> " ===") + tryAutoReview (TaskCore.taskId t) + pure True + + -- Auto-review that doesn't exit on missing commit + tryAutoReview :: Text -> IO () + tryAutoReview tid = do + tasks <- TaskCore.loadTasks + case TaskCore.findTask tid tasks of + Nothing -> do + putText ("[review] Task " <> tid <> " not found.") + Just task -> do + let grepArg = "--grep=" <> Text.unpack tid + (code, shaOut, _) <- + Process.readProcessWithExitCode + "git" + ["log", "--pretty=format:%H", "-n", "1", grepArg] + "" + + if code /= Exit.ExitSuccess || null shaOut + then do + putText "[review] No commit found for this task." + putText "[review] Resetting to Open for retry." + TaskCore.updateTaskStatus tid TaskCore.Open [] + else do + let commitSha = case List.lines shaOut of + (x : _) -> x + [] -> "" + + -- Check for merge conflicts + conflictResult <- checkMergeConflict commitSha + case conflictResult of + Just conflictFiles -> do + putText "[review] MERGE CONFLICT DETECTED" + traverse_ (\f -> putText (" - " <> f)) conflictFiles + handleConflict tid conflictFiles commitSha + Nothing -> do + autoReview tid task commitSha + +-- | Handle merge conflict during review (Gerrit-style: provide rich context) +handleConflict :: Text -> [Text] -> String -> IO () +handleConflict tid conflictFiles commitSha = do + maybeCtx <- TaskCore.getRetryContext tid + let attempt = maybe 1 (\c -> TaskCore.retryAttempt c + 1) maybeCtx + + let conflictComment = buildConflictComment commitSha conflictFiles attempt + _ <- TaskCore.addComment tid conflictComment + + if attempt > 3 + then do + putText "[review] Task has failed 3 times. Needs human intervention." + TaskCore.updateTaskStatus tid TaskCore.Open [] + else do + conflictDetails <- gatherConflictContext commitSha conflictFiles + maybeExistingCtx <- TaskCore.getRetryContext tid + let currentReason = "attempt " <> tshow attempt <> ":\n" <> conflictDetails + let accumulatedReason = case maybeExistingCtx of + Nothing -> currentReason + Just ctx -> TaskCore.retryReason ctx <> "\n\n" <> currentReason + TaskCore.setRetryContext + TaskCore.RetryContext + { TaskCore.retryTaskId = tid, + TaskCore.retryOriginalCommit = Text.pack commitSha, + TaskCore.retryConflictFiles = conflictFiles, + TaskCore.retryAttempt = attempt, + TaskCore.retryReason = accumulatedReason, + TaskCore.retryNotes = maybeExistingCtx +> TaskCore.retryNotes + } + TaskCore.updateTaskStatus tid TaskCore.Open [] + putText ("[review] Task " <> tid <> " returned to queue (attempt " <> tshow attempt <> "/3).") + +-- | Build a review comment for merge conflicts +buildConflictComment :: String -> [Text] -> Int -> Text +buildConflictComment commitSha conflictFiles attempt = + Text.unlines + [ "## Auto-Review: Merge Conflict", + "", + "**Commit:** " <> Text.pack (take 8 commitSha), + "**Result:** ✗ MERGE CONFLICT", + "**Attempt:** " <> tshow attempt <> "/3", + "", + "### Conflicting Files", + Text.unlines (map ("- " <>) conflictFiles), + "Task returned to queue for conflict resolution." + ] + +-- | Gather Gerrit-style conflict context for the coder +gatherConflictContext :: String -> [Text] -> IO Text +gatherConflictContext commitSha conflictFiles = do + commitInfo <- getCommitInfo commitSha + currentHeadInfo <- getCurrentHeadInfo + fileDiffs <- traverse (getFileConflictInfo commitSha <. Text.unpack) conflictFiles + + pure + <| Text.unlines + [ "MERGE CONFLICT - Your changes could not be cleanly applied", + "", + "== Your Commit ==", + commitInfo, + "", + "== Current HEAD ==", + currentHeadInfo, + "", + "== Conflicting Files ==", + Text.unlines fileDiffs, + "", + "== Resolution Instructions ==", + "1. The codebase has been updated since your work", + "2. Review the current state of conflicting files", + "3. Re-implement your changes on top of the current code", + "4. Ensure your changes still make sense given the updates" + ] + +-- | Get info about the commit that caused the conflict +getCommitInfo :: String -> IO Text +getCommitInfo sha = do + (_, out, _) <- + Process.readProcessWithExitCode + "git" + ["log", "-1", "--format=%h %s%n%b", sha] + "" + pure <| Text.pack out + +-- | Get info about current HEAD +getCurrentHeadInfo :: IO Text +getCurrentHeadInfo = do + (_, out, _) <- + Process.readProcessWithExitCode + "git" + ["log", "-1", "--format=%h %s (%cr)"] + "" + pure <| Text.pack out + +-- | Get file-level conflict context showing what changed in both branches +getFileConflictInfo :: String -> FilePath -> IO Text +getFileConflictInfo commitSha filePath = do + yourChanges <- getYourChangesToFile commitSha filePath + recentChanges <- getRecentChangesToFile filePath + pure + <| Text.unlines + [ "--- " <> Text.pack filePath <> " ---", + "", + "Your changes to this file:", + yourChanges, + "", + "Recent changes by others:", + recentChanges + ] + +-- | Get a summary of changes in a specific commit to a file +getYourChangesToFile :: String -> FilePath -> IO Text +getYourChangesToFile commitSha filePath = do + (code, out, _) <- + Process.readProcessWithExitCode + "git" + ["show", "--stat", commitSha, "--", filePath] + "" + case code of + Exit.ExitSuccess -> pure <| Text.pack (take 500 out) + Exit.ExitFailure _ -> pure "(unable to get diff)" + +-- | Get recent changes to a file (last few commits) +getRecentChangesToFile :: FilePath -> IO Text +getRecentChangesToFile filePath = do + (code, out, _) <- + Process.readProcessWithExitCode + "git" + ["log", "-3", "--oneline", "--", filePath] + "" + case code of + Exit.ExitSuccess -> pure <| Text.pack out + Exit.ExitFailure _ -> pure "(unable to get history)" + +-- | Interactive review command (jr review <task-id>) +reviewTask :: Text -> Bool -> IO () +reviewTask tid autoMode = do + tasks <- TaskCore.loadTasks + case TaskCore.findTask tid tasks of + Nothing -> do + putText ("Task " <> tid <> " not found.") + Exit.exitFailure + Just task -> do + unless autoMode <| TaskCore.showTaskDetailed task + + let grepArg = "--grep=" <> Text.unpack tid + (code, shaOut, _) <- + Process.readProcessWithExitCode + "git" + ["log", "--pretty=format:%H", "-n", "1", grepArg] + "" + + when (code /= Exit.ExitSuccess || null shaOut) <| do + putText "\nNo commit found for this task." + putText "The worker may not have completed yet, or the commit message doesn't include the task ID." + Exit.exitFailure + + let commitSha = case List.lines shaOut of + (x : _) -> x + [] -> "" + + -- Check for merge conflicts before showing diff + conflictResult <- checkMergeConflict commitSha + case conflictResult of + Just conflictFiles -> do + putText "\n=== MERGE CONFLICT DETECTED ===" + traverse_ (\f -> putText (" - " <> f)) conflictFiles + handleConflict tid conflictFiles commitSha + Nothing -> do + if autoMode + then autoReview tid task commitSha + else interactiveReview tid task commitSha + +-- | Auto-review: run tests on namespace, accept if pass, reject if fail +autoReview :: Text -> TaskCore.Task -> String -> IO () +autoReview tid task commitSha = do + putText "[review] Running automated review..." + putText ("[review] Commit: " <> Text.pack (take 8 commitSha)) + + let namespace = fromMaybe "." (TaskCore.taskNamespace task) + let testTarget = Text.unpack namespace + + putText ("[review] Testing: " <> Text.pack testTarget) + + (testCode, testOut, testErr) <- + Process.readProcessWithExitCode + "bild" + ["--test", testTarget] + "" + + case testCode of + Exit.ExitSuccess -> do + putText "[review] ✓ Tests passed." + let reviewComment = buildReviewComment commitSha testTarget True testOut testErr + _ <- TaskCore.addComment tid reviewComment + TaskCore.clearRetryContext tid + TaskCore.updateTaskStatus tid TaskCore.Done [] + putText ("[review] Task " <> tid <> " -> Done") + checkEpicCompletion task + Exit.ExitFailure code -> do + putText ("[review] ✗ Tests failed (exit " <> tshow code <> ")") + let reason = "Test failure:\n" <> Text.pack testOut <> Text.pack testErr + + maybeCtx <- TaskCore.getRetryContext tid + let attempt = maybe 1 (\ctx -> TaskCore.retryAttempt ctx + 1) maybeCtx + + let reviewComment = buildReviewComment commitSha testTarget False testOut testErr + _ <- TaskCore.addComment tid reviewComment + + if attempt > 3 + then do + putText "[review] Task has failed 3 times. Needs human intervention." + TaskCore.updateTaskStatus tid TaskCore.Open [] + else do + let currentReason = "attempt " <> tshow attempt <> ": " <> reason + let accumulatedReason = case maybeCtx of + Nothing -> currentReason + Just ctx -> TaskCore.retryReason ctx <> "\n" <> currentReason + TaskCore.setRetryContext + TaskCore.RetryContext + { TaskCore.retryTaskId = tid, + TaskCore.retryOriginalCommit = Text.pack commitSha, + TaskCore.retryConflictFiles = [], + TaskCore.retryAttempt = attempt, + TaskCore.retryReason = accumulatedReason, + TaskCore.retryNotes = maybeCtx +> TaskCore.retryNotes + } + TaskCore.updateTaskStatus tid TaskCore.Open [] + putText ("[review] Task " <> tid <> " reopened (attempt " <> tshow attempt <> "/3).") + +-- | Build a review comment summarizing what was tested and the result +buildReviewComment :: String -> String -> Bool -> String -> String -> Text +buildReviewComment commitSha testTarget passed testOut testErr = + Text.unlines + [ "## Auto-Review", + "", + "**Commit:** " <> Text.pack (take 8 commitSha), + "**Test target:** " <> Text.pack testTarget, + "**Result:** " <> if passed then "✓ PASSED" else "✗ FAILED", + "", + if passed + then "All tests passed. Task accepted." + else + Text.unlines + [ "### Test Output", + "```", + Text.pack (truncateOutput 1000 (testOut ++ testErr)), + "```", + "", + "Task rejected and returned to queue for retry." + ] + ] + +-- | Truncate output to a maximum number of characters +truncateOutput :: Int -> String -> String +truncateOutput maxLen s + | length s <= maxLen = s + | otherwise = take maxLen s ++ "\n... (truncated)" + +-- | Interactive review with user prompts +interactiveReview :: Text -> TaskCore.Task -> String -> IO () +interactiveReview tid task commitSha = do + putText "\n=== Diff for this task ===\n" + _ <- Process.rawSystem "git" ["show", commitSha] + + putText "\n[a]ccept / [r]eject / [s]kip? " + IO.hFlush IO.stdout + choice <- getLine + + case Text.toLower choice of + c + | "a" `Text.isPrefixOf` c -> do + let acceptComment = buildHumanReviewComment commitSha True Nothing + _ <- TaskCore.addComment tid acceptComment + TaskCore.clearRetryContext tid + TaskCore.updateTaskStatus tid TaskCore.Done [] + putText ("Task " <> tid <> " marked as Done.") + checkEpicCompletion task + | "r" `Text.isPrefixOf` c -> do + putText "Enter rejection reason: " + IO.hFlush IO.stdout + reason <- getLine + let rejectComment = buildHumanReviewComment commitSha False (Just reason) + _ <- TaskCore.addComment tid rejectComment + maybeCtx <- TaskCore.getRetryContext tid + let attempt = maybe 1 (\ctx -> TaskCore.retryAttempt ctx + 1) maybeCtx + let currentReason = "attempt " <> tshow attempt <> ": rejected: " <> reason + let accumulatedReason = case maybeCtx of + Nothing -> currentReason + Just ctx -> TaskCore.retryReason ctx <> "\n" <> currentReason + TaskCore.setRetryContext + TaskCore.RetryContext + { TaskCore.retryTaskId = tid, + TaskCore.retryOriginalCommit = Text.pack commitSha, + TaskCore.retryConflictFiles = [], + TaskCore.retryAttempt = attempt, + TaskCore.retryReason = accumulatedReason, + TaskCore.retryNotes = maybeCtx +> TaskCore.retryNotes + } + TaskCore.updateTaskStatus tid TaskCore.Open [] + putText ("Task " <> tid <> " reopened (attempt " <> tshow attempt <> "/3).") + | otherwise -> putText "Skipped; no status change." + +-- | Build a human review comment +buildHumanReviewComment :: String -> Bool -> Maybe Text -> Text +buildHumanReviewComment commitSha accepted maybeReason = + Text.unlines + [ "## Human Review", + "", + "**Commit:** " <> Text.pack (take 8 commitSha), + "**Result:** " <> if accepted then "✓ ACCEPTED" else "✗ REJECTED", + case maybeReason of + Just reason -> "**Reason:** " <> reason + Nothing -> "" + ] + +-- | Check if a commit can be cleanly cherry-picked onto live +-- Returns Nothing if clean, Just [conflicting files] if conflict +checkMergeConflict :: String -> IO (Maybe [Text]) +checkMergeConflict commitSha = do + -- Save current state + (_, _, _) <- Process.readProcessWithExitCode "git" ["branch", "--show-current"] "" + (_, origHead, _) <- Process.readProcessWithExitCode "git" ["rev-parse", "HEAD"] "" + + -- Try cherry-pick + (cpCode, _, cpErr) <- + Process.readProcessWithExitCode + "git" + ["cherry-pick", "--no-commit", commitSha] + "" + + -- Always abort/reset regardless of result + _ <- Process.readProcessWithExitCode "git" ["cherry-pick", "--abort"] "" + _ <- Process.readProcessWithExitCode "git" ["reset", "--hard", List.head (List.lines origHead)] "" + + case cpCode of + Exit.ExitSuccess -> pure Nothing + Exit.ExitFailure _ -> do + -- Parse conflict files from error message + let errLines = Text.lines (Text.pack cpErr) + conflictLines = filter (Text.isPrefixOf "CONFLICT") errLines + -- Extract file names (rough parsing) + files = mapMaybe extractConflictFile conflictLines + pure (Just (if null files then ["(unknown files)"] else files)) + +extractConflictFile :: Text -> Maybe Text +extractConflictFile line = + -- CONFLICT (content): Merge conflict in path/to/file.hs + case Text.breakOn "Merge conflict in " line of + (_, rest) + | not (Text.null rest) -> Just (Text.strip (Text.drop 18 rest)) + _ -> case Text.breakOn "in " line of + (_, rest) + | not (Text.null rest) -> Just (Text.strip (Text.drop 3 rest)) + _ -> Nothing + +-- | Check if all children of an epic are Done, and if so, transition epic to Review +checkEpicCompletion :: TaskCore.Task -> IO () +checkEpicCompletion task = + case TaskCore.taskParent task of + Nothing -> pure () + Just parentId -> do + tasks <- TaskCore.loadTasks + case TaskCore.findTask parentId tasks of + Nothing -> pure () + Just parentTask -> + when (TaskCore.taskType parentTask == TaskCore.Epic) <| do + let children = filter (hasParent parentId) tasks + allDone = all (\t -> TaskCore.taskStatus t == TaskCore.Done) children + when (allDone && not (null children)) <| do + putText ("[review] All children of epic " <> parentId <> " are Done.") + TaskCore.updateTaskStatus parentId TaskCore.Review [] + putText ("[review] Epic " <> parentId <> " -> Review") + where + hasParent pid t = maybe False (TaskCore.matchesId pid) (TaskCore.taskParent t) + +-- | Handle facts subcommands +handleFacts :: Cli.Arguments -> IO () +handleFacts args + | args `Cli.has` Cli.command "list" = do + let maybeProject = Text.pack </ Cli.getArg args (Cli.longOption "project") + jsonMode = args `Cli.has` Cli.longOption "json" + facts <- maybe Fact.getAllFacts Fact.getFactsByProject maybeProject + if jsonMode + then BLC.putStrLn (Aeson.encode facts) + else traverse_ printFact facts + | args `Cli.has` Cli.command "show" = do + let jsonMode = args `Cli.has` Cli.longOption "json" + case Cli.getArg args (Cli.argument "fact-id") of + Nothing -> putText "fact-id required" + Just fidStr -> case readMaybe fidStr of + Nothing -> putText "Invalid fact ID (must be integer)" + Just fid -> do + maybeFact <- Fact.getFact fid + case maybeFact of + Nothing -> putText "Fact not found" + Just fact -> + if jsonMode + then BLC.putStrLn (Aeson.encode fact) + else printFactDetailed fact + | args `Cli.has` Cli.command "add" = do + let jsonMode = args `Cli.has` Cli.longOption "json" + case (Cli.getArg args (Cli.argument "project"), Cli.getArg args (Cli.argument "content")) of + (Just proj, Just content) -> do + let files = case Cli.getArg args (Cli.longOption "files") of + Just f -> Text.splitOn "," (Text.pack f) + Nothing -> [] + sourceTask = Text.pack </ Cli.getArg args (Cli.longOption "task") + confidence = case Cli.getArg args (Cli.longOption "confidence") of + Just c -> fromMaybe 0.8 (readMaybe c) + Nothing -> 0.8 + factId <- Fact.createFact (Text.pack proj) (Text.pack content) files sourceTask confidence + if jsonMode + then BLC.putStrLn (Aeson.encode (Aeson.object ["id" Aeson..= factId, "success" Aeson..= True])) + else putText ("Created fact: " <> tshow factId) + _ -> putText "project and content required" + | args `Cli.has` Cli.command "delete" = do + let jsonMode = args `Cli.has` Cli.longOption "json" + case Cli.getArg args (Cli.argument "fact-id") of + Nothing -> putText "fact-id required" + Just fidStr -> case readMaybe fidStr of + Nothing -> putText "Invalid fact ID (must be integer)" + Just fid -> do + Fact.deleteFact fid + if jsonMode + then BLC.putStrLn (Aeson.encode (Aeson.object ["success" Aeson..= True, "message" Aeson..= ("Deleted fact " <> tshow fid)])) + else putText ("Deleted fact: " <> tshow fid) + | otherwise = putText "Unknown facts subcommand. Use: list, show, add, or delete" + +-- | Print a fact in a compact format +printFact :: TaskCore.Fact -> IO () +printFact fact = do + let fid = maybe "?" tshow (TaskCore.factId fact) + proj = TaskCore.factProject fact + content = Text.take 60 (TaskCore.factContent fact) + suffix = if Text.length (TaskCore.factContent fact) > 60 then "..." else "" + putText (fid <> "\t" <> proj <> "\t" <> content <> suffix) + +-- | Print a fact in detailed format +printFactDetailed :: TaskCore.Fact -> IO () +printFactDetailed fact = do + putText ("ID: " <> maybe "?" tshow (TaskCore.factId fact)) + putText ("Project: " <> TaskCore.factProject fact) + putText ("Content: " <> TaskCore.factContent fact) + putText ("Files: " <> Text.intercalate ", " (TaskCore.factRelatedFiles fact)) + putText ("Source: " <> fromMaybe "-" (TaskCore.factSourceTask fact)) + putText ("Confidence: " <> tshow (TaskCore.factConfidence fact)) + putText ("Created: " <> tshow (TaskCore.factCreatedAt fact)) + +test :: Test.Tree +test = + Test.group + "Omni.Jr" + [ Test.unit "can run tests" <| True Test.@?= True, + Test.unit "can parse task command" <| do + let result = Docopt.parseArgs help ["task"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'task': " <> show err + Right args -> args `Cli.has` Cli.command "task" Test.@?= True, + Test.unit "can parse task command with args" <| do + let result = Docopt.parseArgs help ["task", "list", "--json"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'task list --json': " <> show err + Right args -> do + args `Cli.has` Cli.command "task" Test.@?= True + Cli.getAllArgs args (Cli.argument "args") Test.@?= ["list", "--json"], + Test.unit "can parse work command" <| do + let result = Docopt.parseArgs help ["work"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'work': " <> show err + Right args -> args `Cli.has` Cli.command "work" Test.@?= True, + Test.unit "can parse work command with task id" <| do + let result = Docopt.parseArgs help ["work", "t-123"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'work t-123': " <> show err + Right args -> do + args `Cli.has` Cli.command "work" Test.@?= True + Cli.getArg args (Cli.argument "task-id") Test.@?= Just "t-123", + Test.unit "can parse facts list command" <| do + let result = Docopt.parseArgs help ["facts", "list"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'facts list': " <> show err + Right args -> do + args `Cli.has` Cli.command "facts" Test.@?= True + args `Cli.has` Cli.command "list" Test.@?= True, + Test.unit "can parse facts list with --project" <| do + let result = Docopt.parseArgs help ["facts", "list", "--project=myproj"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'facts list --project': " <> show err + Right args -> do + args `Cli.has` Cli.command "facts" Test.@?= True + args `Cli.has` Cli.command "list" Test.@?= True + Cli.getArg args (Cli.longOption "project") Test.@?= Just "myproj", + Test.unit "can parse facts list with --json" <| do + let result = Docopt.parseArgs help ["facts", "list", "--json"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'facts list --json': " <> show err + Right args -> do + args `Cli.has` Cli.command "facts" Test.@?= True + args `Cli.has` Cli.command "list" Test.@?= True + args `Cli.has` Cli.longOption "json" Test.@?= True, + Test.unit "can parse facts show command" <| do + let result = Docopt.parseArgs help ["facts", "show", "42"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'facts show 42': " <> show err + Right args -> do + args `Cli.has` Cli.command "facts" Test.@?= True + args `Cli.has` Cli.command "show" Test.@?= True + Cli.getArg args (Cli.argument "fact-id") Test.@?= Just "42", + Test.unit "can parse facts add command" <| do + let result = Docopt.parseArgs help ["facts", "add", "myproj", "This is a fact"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'facts add': " <> show err + Right args -> do + args `Cli.has` Cli.command "facts" Test.@?= True + args `Cli.has` Cli.command "add" Test.@?= True + Cli.getArg args (Cli.argument "project") Test.@?= Just "myproj" + Cli.getArg args (Cli.argument "content") Test.@?= Just "This is a fact", + Test.unit "can parse facts add with options" <| do + let result = Docopt.parseArgs help ["facts", "add", "myproj", "fact", "--files=a.hs,b.hs", "--task=t-123", "--confidence=0.9"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'facts add' with options: " <> show err + Right args -> do + args `Cli.has` Cli.command "facts" Test.@?= True + args `Cli.has` Cli.command "add" Test.@?= True + Cli.getArg args (Cli.longOption "files") Test.@?= Just "a.hs,b.hs" + Cli.getArg args (Cli.longOption "task") Test.@?= Just "t-123" + Cli.getArg args (Cli.longOption "confidence") Test.@?= Just "0.9", + Test.unit "can parse facts delete command" <| do + let result = Docopt.parseArgs help ["facts", "delete", "42"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'facts delete 42': " <> show err + Right args -> do + args `Cli.has` Cli.command "facts" Test.@?= True + args `Cli.has` Cli.command "delete" Test.@?= True + Cli.getArg args (Cli.argument "fact-id") Test.@?= Just "42" + ] |
