packages feed

pr-tools-0.1.0.0: app/pr-view.hs

{-# LANGUAGE ScopedTypeVariables #-}

import Control.Exception (catch, IOException)
import Control.Monad (foldM)
import Data.List (drop, dropWhile, foldl', head, length, lines, null, take, takeWhile, words, zipWith)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Yaml (decodeFileEither)
import Options.Applicative
import System.Environment (getArgs)
import System.FilePath ( (</>) )
import System.FilePath.Glob (glob)
import System.Process (readProcess)
import PRTools.Config (getBaseBranch, reviewDir, trimTrailing, sanitizeBranch)
import PRTools.ReviewState (ReviewState(..), Cmt(..))

data Comment = Comment { cReviewer :: String, cText :: String, cResolved :: Bool, cId :: String } deriving Show

type Comments = Map.Map String (Map.Map Int [Comment])

collectComments :: String -> IO Comments
collectComments branch = do
  let safeBranch = sanitizeBranch branch
  rfs <- glob (reviewDir </> (safeBranch ++ "-*.yaml"))
  foldM (\acc rf -> do
    mState <- decodeFileEither rf
    case mState of
      Left _ -> return acc
      Right state -> return $ foldl' (\accC c ->
        let file = cmFile c
            line = cmLine c
            newC = Comment (rsReviewer state) (cmText c) (cmResolved c) (cmId c)
        in Map.alter (\maybeInner ->
             let inner = fromMaybe Map.empty maybeInner
                 newInner = Map.alter (\maybeList -> Just (newC : fromMaybe [] maybeList)) line inner
             in Just newInner
           ) file accC
        ) acc (rsComments state)
    ) Map.empty rfs

data Command =
    Diff { dBranch :: Maybe String, dBase :: Maybe String, dFull :: Bool }
  | Comments { cBranch :: Maybe String }

commandParser :: Parser Command
commandParser = subparser
  ( command "diff" (info diffParser (progDesc "View annotated diff (default)"))
 <> command "comments" (info commentsParser (progDesc "List all comments with context"))
  ) <|> (Diff
    <$> optional (strArgument (metavar "BRANCH" <> help "The feature branch (default: current)"))
    <*> optional (strOption (long "base-branch" <> metavar "BASE" <> help "Override the base branch"))
    <*> switch (long "full" <> help "Show full file contents with comments (including unchanged areas)")
  )
  where
    diffParser = Diff
      <$> optional (strArgument (metavar "BRANCH" <> help "The feature branch (default: current)"))
      <*> optional (strOption (long "base-branch" <> metavar "BASE" <> help "Override the base branch"))
      <*> switch (long "full" <> help "Show full file contents with comments (including unchanged areas)")
    commentsParser = Comments
      <$> optional (strArgument (metavar "BRANCH" <> help "The feature branch (default: current)"))

main :: IO ()
main = do
  cmd <- execParser $ info (commandParser <**> helper) idm
  case cmd of
    Diff mbBranch mbBase full -> do
      baseB <- case mbBase of
        Just b -> return b
        Nothing -> getBaseBranch
      branch <- case mbBranch of
        Just b -> return b
        Nothing -> fmap trimTrailing (readProcess "git" ["rev-parse", "--abbrev-ref", "HEAD"] "")
      mergeBase <- trimTrailing <$> readProcess "git" ["merge-base", baseB, branch] ""
      comms <- collectComments branch
      if full then do
        let allFiles = Map.keys comms  -- Files with comments
        outputs <- mapM (renderFullFile branch mergeBase comms) allFiles
        putStr (unlines $ ("# Full Annotated Files for " ++ branch) : concat outputs)
      else do
        diffText <- readProcess "git" ["diff", mergeBase, branch, "--"] ""
        let diffLines = lines diffText
            go [] _ _ _ = ["```"]
            go (l:ls) cf cl comms = l : (case cf of
              Just f -> fromMaybe [] (do
                inner <- Map.lookup f comms
                cs <- Map.lookup cl inner
                return $ map (\c -> let status = if cResolved c then "RESOLVED" else "COMMENT"
                                        txt = map (\ch -> if ch == '\n' then ' ' else ch) (cText c)
                                    in " # " ++ status ++ " (" ++ cReviewer c ++ ") [id:" ++ cId c ++ "]: " ++ txt) cs
                ) 
              Nothing -> []) ++ if startsWith "diff --git " l then
                let parts = words l
                    newFile = drop 2 (parts !! 3)
                in go ls (Just newFile) 0 comms
              else if startsWith "@@ " l then
                let hunk = words l !! 2
                    start = drop 1 hunk
                    newCl = read (takeWhile (/= ',') start) :: Int
                in go ls cf newCl comms
              else if not (null l) && (l !! 0 == '+' || l !! 0 == ' ') then go ls cf (cl + 1) comms
              else go ls cf cl comms

            startsWith :: String -> String -> Bool
            startsWith pre str = take (length pre) str == pre
            output = ("# Annotated Diff for " ++ branch) : "" : "```diff" : go diffLines Nothing 0 comms
        putStr (unlines output)
    Comments mbBranch -> do
      branch <- case mbBranch of
        Just b -> return b
        Nothing -> fmap trimTrailing (readProcess "git" ["rev-parse", "--abbrev-ref", "HEAD"] "")
      comms <- collectComments branch
      let allComments = Map.foldrWithKey
            (\file lineMap acc ->
              Map.foldrWithKey
                (\line cs acc' ->
                  acc' ++ map (\c -> (file, line, c)) cs
                )
                acc
                lineMap
            )
            []
            comms
      mapM_ (\(file, line, c) -> do
        content <- readProcess "git" ["show", branch ++ ":" ++ file] "" `catch` (\(_ :: IOException) -> return "")
        let fileLines = lines content
        let start = max 0 (line - 4)
        let context = take 7 (drop start fileLines)
        let numberedContext = zipWith (\i ln -> "  " ++ show (start + 1 + i) ++ ": " ++ ln) [0..] context
        putStrLn $ "File: " ++ file ++ "\nLine: " ++ show line ++ "\nID: " ++ cId c ++ "\nStatus: " ++ (if cResolved c then "resolved" else "unresolved") ++ "\nComment: " ++ cText c ++ "\nContext:\n" ++ unlines numberedContext ++ "\n---"
        ) allComments

renderFullFile :: String -> String -> Comments -> String -> IO [String]
renderFullFile branch mergeBase comms file = do
  content <- readProcess "git" ["show", branch ++ ":" ++ file] "" `catch` (\(_ :: IOException) -> return "")
  let fileLines = lines content
  baseContent <- readProcess "git" ["show", mergeBase ++ ":" ++ file] "" `catch` (\(_ :: IOException) -> return "")
  let baseLines = lines baseContent
  let lineComments = fromMaybe Map.empty (Map.lookup file comms)
  let annotated = foldl' (\acc (i, line) ->
                                let cs = fromMaybe [] (Map.lookup (i+1) lineComments)  -- 1-based
                                    cLines = map (\c -> " # " ++ (if cResolved c then "RESOLVED" else "COMMENT") ++ " (" ++ cReviewer c ++ ") [id:" ++ cId c ++ "]: " ++ trimTrailingNewlines (cText c)) cs
                                    prefix = if i < length baseLines && baseLines !! i /= line then "+ " else "  "
                                in acc ++ [prefix ++ line] ++ cLines
                              ) [] (zip [0..] fileLines)
  return $ "" : ("## " ++ file) : "```" : annotated ++ ["```"]
  where
    trimTrailingNewlines s = reverse $ dropWhile (== '\n') $ reverse s