packages feed

pr-tools-0.2.0.0: src/PRTools/PRState.hs

module PRTools.PRState where

import Data.Aeson (ToJSON(..), object, (.=))
import Data.Aeson.Key (fromString)
import Data.Aeson.Types ((.!=))
import qualified Data.Map.Strict as Map
import Data.Yaml (FromJSON(..), decodeFileEither, encodeFile, parseJSON, withObject, (.:), (.:?))
import System.Directory (doesFileExist)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)
import Data.Time (UTCTime, NominalDiffTime, diffUTCTime, formatTime, getCurrentTime)
import Data.Time.Format (defaultTimeLocale, parseTimeM)
import System.Process (readProcess, readProcessWithExitCode)
import PRTools.Config (getBaseBranch, trimTrailing, getStaleDays)
import PRTools.ContentHash (generatePatchHash)
import System.Exit (ExitCode(..))
import Data.List (nub)

data CommitInfo = CommitInfo { ciHash :: String, ciMessage :: String } deriving (Eq, Show)

instance FromJSON CommitInfo where
  parseJSON = withObject "CommitInfo" $ \v -> CommitInfo
    <$> v .: fromString "hash"
    <*> v .: fromString "message"

instance ToJSON CommitInfo where
  toJSON ci = object
    [ fromString "hash" .= ciHash ci
    , fromString "message" .= ciMessage ci
    ]

data PRSnapshot = PRSnapshot { psTimestamp :: String, psCommits :: [CommitInfo] } deriving (Eq, Show)

instance FromJSON PRSnapshot where
  parseJSON = withObject "PRSnapshot" $ \v -> PRSnapshot
    <$> v .: fromString "timestamp"
    <*> v .: fromString "commits"

instance ToJSON PRSnapshot where
  toJSON ps = object
    [ fromString "timestamp" .= psTimestamp ps
    , fromString "commits" .= psCommits ps
    ]

data ReviewEvent = ReviewEvent
  { reReviewer :: String
  , reAction :: String -- "start", "end", "import-answers"
  , reTimestamp :: String
  , reContentHash :: Maybe String -- Content hash for "end" actions (legacy)
  , reReviewedCommits :: Maybe (Map.Map String String) -- commit hash -> content hash for that commit
  } deriving (Eq, Show)

instance FromJSON ReviewEvent where
  parseJSON = withObject "ReviewEvent" $ \v -> ReviewEvent
    <$> v .: fromString "reviewer"
    <*> v .: fromString "action"
    <*> v .: fromString "timestamp"
    <*> v .:? fromString "content_hash"
    <*> v .:? fromString "reviewed_commits"

instance ToJSON ReviewEvent where
  toJSON re = object
    [ fromString "reviewer" .= reReviewer re
    , fromString "action" .= reAction re
    , fromString "timestamp" .= reTimestamp re
    , fromString "content_hash" .= reContentHash re
    , fromString "reviewed_commits" .= reReviewedCommits re
    ]

data FixEvent = FixEvent
  { feFixer :: String
  , feAction :: String -- "start", "end"
  , feTimestamp :: String
  } deriving (Eq, Show)

instance FromJSON FixEvent where
  parseJSON = withObject "FixEvent" $ \v -> FixEvent
    <$> v .: fromString "fixer"
    <*> v .: fromString "action"
    <*> v .: fromString "timestamp"

instance ToJSON FixEvent where
  toJSON fe = object
    [ fromString "fixer" .= feFixer fe
    , fromString "action" .= feAction fe
    , fromString "timestamp" .= feTimestamp fe
    ]

data Approval = Approval
  { apApprover :: String
  , apTimestamp :: String
  , apCommits :: [CommitInfo]
  , apContentHash :: Maybe String  -- New field for content-based approval
  } deriving (Eq, Show)

instance FromJSON Approval where
    parseJSON = withObject "Approval" $ \v -> Approval
        <$> v .: fromString "approver"
        <*> v .: fromString "timestamp"
        <*> v .:? fromString "commits" .!= []
        <*> v .:? fromString "content_hash"

instance ToJSON Approval where
    toJSON ap = object
        [ fromString "approver" .= apApprover ap
        , fromString "timestamp" .= apTimestamp ap
        , fromString "commits" .= apCommits ap
        , fromString "content_hash" .= apContentHash ap
        ]

data MergeInfo = MergeInfo
  { miMerger :: String
  , miTimestamp :: String
  , miCommits :: [CommitInfo]
  } deriving (Eq, Show)

instance FromJSON MergeInfo where
  parseJSON = withObject "MergeInfo" $ \v -> MergeInfo
    <$> v .: fromString "merger"
    <*> v .: fromString "timestamp"
    <*> v .: fromString "commits"

instance ToJSON MergeInfo where
  toJSON mi = object
    [ fromString "merger" .= miMerger mi
    , fromString "timestamp" .= miTimestamp mi
    , fromString "commits" .= miCommits mi
    ]

data PRState = PRState
  { prStatus :: String
  , prSnapshots :: [PRSnapshot]
  , approvalHistory :: [Approval]
  , prReviews :: [ReviewEvent]
  , prFixes :: [FixEvent]
  , prMergeInfo :: Maybe MergeInfo
  } deriving (Eq, Show)

instance FromJSON PRState where
  parseJSON = withObject "PRState" $ \v -> do
    maybeNewApprovals <- v .:? fromString "approval_history"
    maybeIntermediateApprovals <- v .:? fromString "approvals2"
    maybeOldApprovals <- v .:? fromString "approvals" .!= []

    let finalApprovals = case maybeNewApprovals of
                           Just new -> new
                           Nothing -> case maybeIntermediateApprovals of
                                        Just intermediate -> intermediate
                                        Nothing -> map (\name -> Approval name "migrated-approval" [] Nothing) maybeOldApprovals

    PRState
      <$> v .: fromString "status"
      <*> v .:? fromString "snapshots" .!= []
      <*> pure finalApprovals
      <*> v .:? fromString "reviews" .!= []
      <*> v .:? fromString "fixes" .!= []
      <*> v .:? fromString "merge_info"


instance ToJSON PRState where
  toJSON p = object
    [ fromString "status" .= prStatus p
    , fromString "snapshots" .= prSnapshots p
    , fromString "approval_history" .= approvalHistory p
    , fromString "reviews" .= prReviews p
    , fromString "fixes" .= prFixes p
    , fromString "merge_info" .= prMergeInfo p
    ]

statePath :: FilePath
statePath = ".pr-state.yaml"

loadState :: IO (Map.Map String PRState)
loadState = do
  exists <- doesFileExist statePath
  if exists
    then do
      res <- decodeFileEither statePath
      case res of
        Left err -> do
          hPutStrLn stderr (show err)
          exitFailure
        Right val -> return val
    else return Map.empty

saveState :: Map.Map String PRState -> IO ()
saveState = encodeFile statePath

isAncestor :: String -> String -> IO Bool
isAncestor commit branch = do
  (code, _, _) <- readProcessWithExitCode "git" ["merge-base", "--is-ancestor", commit, branch] ""
  return (code == ExitSuccess)

recordPR :: String -> IO String
recordPR branch = do
  state <- loadState
  let existing = Map.lookup branch state
  base <- getBaseBranch
  (branchExists, commits) <- do
    (code, out, _) <- readProcessWithExitCode "git" ["rev-parse", "--verify", branch] ""
    if code == ExitSuccess
      then do
        logOut <- readProcess "git" ["log", "--format=%H %s", base ++ ".." ++ branch, "--"] ""
        let commitLines = lines logOut
        let cs = map (\ln -> let h = take 40 ln
                                 m = drop 41 ln
                             in CommitInfo h m) (filter (not . null) commitLines)
        return (True, cs)
      else return (False, [])

  case existing of
    Nothing -> do
      if not branchExists
        then do
          hPutStrLn stderr $ "Branch " ++ branch ++ " does not exist and is not tracked."
          exitFailure
        else do
          let ex = PRState "open" [] [] [] [] Nothing
          currentTime <- getCurrentTime
          let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" currentTime
          let newSnapshot = PRSnapshot timeStr commits
          let updatedSnapshots = prSnapshots ex ++ [newSnapshot]

          staleDays <- getStaleDays
          let snapsForStale = if null updatedSnapshots then prSnapshots ex else updatedSnapshots
          let isStale = case snapsForStale of
                          [] -> False
                          snaps -> let lastSnap = last snaps
                                       lastTimeM = parseTimeM True defaultTimeLocale "%Y-%m-%d %H:%M:%S" (psTimestamp lastSnap)
                                   in case lastTimeM of
                                        Just lastTime -> diffUTCTime currentTime lastTime > fromIntegral (staleDays * 86400) && null commits
                                        Nothing -> False
          let statusAfterStale = if isStale && prStatus ex /= "stale" && prStatus ex /= "merged" then "stale" else prStatus ex

          let checkCommits = commits
          allMerged <- if null checkCommits then return False else and <$> mapM (\ci -> isAncestor (ciHash ci) base) checkCommits
          let finalStatus = if allMerged && statusAfterStale /= "merged" then "merged" else statusAfterStale

          let updated = ex { prSnapshots = updatedSnapshots, prStatus = finalStatus }
          let newState = Map.insert branch updated state
          saveState newState
          let msg = if finalStatus /= prStatus ex then "Status updated to " ++ finalStatus else ""
          return $ msg ++ " (new tracking started)"
    Just ex -> do
      currentTime <- getCurrentTime
      let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" currentTime
      let updatedSnapshots = if null commits
                             then prSnapshots ex
                             else prSnapshots ex ++ [PRSnapshot timeStr commits]

      staleDays <- getStaleDays
      let snapsForStale = if null updatedSnapshots then prSnapshots ex else updatedSnapshots
      let isStale = case snapsForStale of
                      [] -> False
                      snaps -> let lastSnap = last snaps
                                   lastTimeM = parseTimeM True defaultTimeLocale "%Y-%m-%d %H:%M:%S" (psTimestamp lastSnap)
                               in case lastTimeM of
                                    Just lastTime -> diffUTCTime currentTime lastTime > fromIntegral (staleDays * 86400) && null commits
                                    Nothing -> False
      let statusAfterStale = if isStale && prStatus ex /= "stale" && prStatus ex /= "merged" then "stale" else prStatus ex

      let checkCommits = if not (null commits) then commits else if null (prSnapshots ex) then [] else psCommits (last (prSnapshots ex))
      allMerged <- if null checkCommits then return False else and <$> mapM (\ci -> isAncestor (ciHash ci) base) checkCommits
      
      -- Check if all commits are removed (not reachable from branch or base)
      allRemoved <- if null checkCommits then return False else do
        -- Check if branch exists
        (branchCode, _, _) <- readProcessWithExitCode "git" ["rev-parse", "--verify", branch] ""
        if branchCode == ExitSuccess then do
          -- Branch exists, check if commits are reachable from it
          branchReachable <- and <$> mapM (\ci -> isAncestor (ciHash ci) branch) checkCommits
          return (not branchReachable)
        else do
          -- Branch doesn't exist, commits are removed if not in base
          baseReachable <- and <$> mapM (\ci -> isAncestor (ciHash ci) base) checkCommits
          return (not baseReachable)
      
      let finalStatus = if allMerged then "merged" 
                       else if allRemoved then "removed"
                       else statusAfterStale

      let updated = ex { prSnapshots = updatedSnapshots, prStatus = finalStatus }
      let newState = Map.insert branch updated state
      saveState newState
      let msg = if finalStatus /= prStatus ex
                then "Status updated to " ++ finalStatus
                else if null commits
                     then "No update performed"
                     else ""
      let extraMsg = if not branchExists then " (branch does not exist; using latest snapshot for checks)" else ""
      return $ msg ++ extraMsg

recordReviewEvent :: String -> String -> String -> IO ()
recordReviewEvent branch reviewer action = do
    state <- loadState
    currentTime <- getCurrentTime
    let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" currentTime
    let newEvent = ReviewEvent reviewer action timeStr Nothing Nothing
    let pr = Map.findWithDefault (PRState "open" [] [] [] [] Nothing) branch state
    let updatedPr = pr { prReviews = prReviews pr ++ [newEvent] }
    let newState = Map.insert branch updatedPr state
    saveState newState

-- Record review event with content hash (for "end" actions)
recordReviewEventWithHash :: String -> String -> String -> Maybe String -> IO ()
recordReviewEventWithHash branch reviewer action mbContentHash = do
    state <- loadState
    currentTime <- getCurrentTime
    let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" currentTime
    let newEvent = ReviewEvent reviewer action timeStr mbContentHash Nothing
    let pr = Map.findWithDefault (PRState "open" [] [] [] [] Nothing) branch state
    let updatedPr = pr { prReviews = prReviews pr ++ [newEvent] }
    let newState = Map.insert branch updatedPr state
    saveState newState

-- Record review event with individual commit hashes (for "end" actions)
recordReviewEventWithCommitHashes :: String -> String -> String -> Map.Map String String -> IO ()
recordReviewEventWithCommitHashes branch reviewer action commitHashes = do
    state <- loadState
    currentTime <- getCurrentTime
    let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" currentTime
    let newEvent = ReviewEvent reviewer action timeStr Nothing (Just commitHashes)
    let pr = Map.findWithDefault (PRState "open" [] [] [] [] Nothing) branch state
    let updatedPr = pr { prReviews = prReviews pr ++ [newEvent] }
    let newState = Map.insert branch updatedPr state
    saveState newState

recordFixEvent :: String -> String -> String -> IO ()
recordFixEvent branch fixer action = do
    state <- loadState
    currentTime <- getCurrentTime
    let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" currentTime
    let newEvent = FixEvent fixer action timeStr
    let pr = Map.findWithDefault (PRState "open" [] [] [] [] Nothing) branch state
    let updatedPr = pr { prFixes = prFixes pr ++ [newEvent] }
    let newState = Map.insert branch updatedPr state
    saveState newState

-- Check if approvals are still valid for the current branch state
checkValidApprovals :: String -> IO [Approval]
checkValidApprovals branch = do
  state <- loadState
  case Map.lookup branch state of
    Nothing -> return []
    Just pr -> do
      base <- getBaseBranch
      (code, out, _) <- readProcessWithExitCode "git" ["rev-parse", branch] ""
      if code /= ExitSuccess
        then return [] -- Branch doesn't exist, no valid approvals
        else do
          let currentCommit = trimTrailing out
          currentContentHash <- generatePatchHash base currentCommit
          
          let approvals = approvalHistory pr
          validApprovals <- filterM (\approval -> do
            -- Check if approval is valid by commit ID or content hash
            let commitValid = any (\ci -> ciHash ci == currentCommit) (apCommits approval)
            let contentValid = case apContentHash approval of
                  Just hash -> hash == currentContentHash
                  Nothing -> False
            -- For debugging, let's see what's happening
            -- putStrLn $ "Checking approval by " ++ apApprover approval ++ ": commitValid=" ++ show commitValid ++ ", contentValid=" ++ show contentValid
            return (commitValid || contentValid)
            ) approvals
          
          return validApprovals
  where
    filterM :: Monad m => (a -> m Bool) -> [a] -> m [a]
    filterM _ [] = return []
    filterM p (x:xs) = do
      flg <- p x
      ys <- filterM p xs
      return (if flg then x:ys else ys)

-- Transfer approvals after a rebase by updating commit hashes while preserving content hashes
transferApprovalsAfterRebase :: String -> String -> String -> IO ()
transferApprovalsAfterRebase branch oldCommit newCommit = do
  state <- loadState
  case Map.lookup branch state of
    Nothing -> putStrLn "No PR state found for branch"
    Just pr -> do
      base <- getBaseBranch
      oldContentHash <- generatePatchHash base oldCommit
      newContentHash <- generatePatchHash base newCommit
      
      putStrLn $ "Old content hash: " ++ oldContentHash
      putStrLn $ "New content hash: " ++ newContentHash
      
      -- Only transfer if content is the same
      if oldContentHash == newContentHash then do
        -- Get new commit list
        logOut <- readProcess "git" ["log", "--format=%H %s", base ++ ".." ++ newCommit, "--"] ""
        let commitLines = lines logOut
        let newCommits = map (\ln -> let h = take 40 ln
                                         m = drop 41 ln
                                     in CommitInfo h m) (filter (not . null) commitLines)
        
        putStrLn $ "Found " ++ show (length (approvalHistory pr)) ++ " approvals to check"
        
        -- Update approvals with new commit hashes but keep content hash
        let updatedApprovals = map (\approval -> 
              case apContentHash approval of
                Just hash | hash == oldContentHash -> do
                  let updated = approval { apCommits = newCommits }
                  updated
                _ -> approval
              ) (approvalHistory pr)
        
        let updatedPr = pr { approvalHistory = updatedApprovals }
        let newState = Map.insert branch updatedPr state
        saveState newState
        putStrLn $ "Approvals transferred after rebase. Updated " ++ show (length updatedApprovals) ++ " approvals."
      else
        putStrLn "Content has changed - approvals cannot be transferred automatically"

recordApproval :: String -> String -> String -> IO ()
recordApproval branch approver commitHash = do
  state <- loadState
  let pr = Map.findWithDefault (PRState "open" [] [] [] [] Nothing) branch state
  
  base <- getBaseBranch
  logOut <- readProcess "git" ["log", "--format=%H %s", base ++ ".." ++ commitHash, "--"] ""
  let commitLines = lines logOut
  let commits = map (\ln -> let h = take 40 ln
                                m = drop 41 ln
                            in CommitInfo h m) (filter (not . null) commitLines)

  -- Generate content hash for the patch
  contentHash <- generatePatchHash base commitHash

  currentTime <- getCurrentTime
  let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" currentTime
  let newApproval = Approval approver timeStr commits (Just contentHash)
  let newApprovalHistory = approvalHistory pr ++ [newApproval]
  
  let newPr = pr { approvalHistory = newApprovalHistory }
  let newState = Map.insert branch newPr state
  saveState newState

-- Check if current content has been reviewed
checkReviewStatus :: String -> IO (Bool, [String])
checkReviewStatus branch = do
  state <- loadState
  case Map.lookup branch state of
    Nothing -> return (False, [])
    Just pr -> do
      base <- getBaseBranch
      (code, out, _) <- readProcessWithExitCode "git" ["rev-parse", branch] ""
      if code /= ExitSuccess
        then do
          -- Branch doesn't exist, check if all non-removed commits from latest snapshot are reviewed
          if null (prSnapshots pr)
            then return (False, [])
            else do
              let latestSnapshot = last (prSnapshots pr)
              let commits = psCommits latestSnapshot
              if null commits
                then return (False, [])
                else do
                  -- Filter out removed commits (commits that no longer exist)
                  existingCommits <- filterM (\ci -> do
                    (commitCode, _, _) <- readProcessWithExitCode "git" ["rev-parse", "--verify", ciHash ci] ""
                    return (commitCode == ExitSuccess)
                    ) commits
                  
                  if null existingCommits
                    then return (True, []) -- All commits are removed, consider reviewed
                    else do
                      -- Check if all existing commits are reviewed
                      reviewStatuses <- mapM (checkCommitReviewStatus branch . ciHash) existingCommits
                      let allReviewed = and reviewStatuses
                      if allReviewed
                        then do
                          -- Get reviewers from review events
                          let reviewEvents = prReviews pr
                          let endEvents = filter (\re -> reAction re == "end") reviewEvents
                          let reviewers = nub [reReviewer re | re <- endEvents]
                          return (True, reviewers)
                        else return (False, [])
        else do
          -- Branch exists, get current commits and check if all are reviewed
          let currentCommit = trimTrailing out
          logOut <- readProcess "git" ["log", "--format=%H", base ++ ".." ++ currentCommit] ""
          let commitHashes = lines logOut
          if null commitHashes
            then return (True, []) -- No commits to review
            else do
              -- Check if all current commits are reviewed
              reviewStatuses <- mapM (checkCommitReviewStatus branch) commitHashes
              let allReviewed = and reviewStatuses
              if allReviewed
                then do
                  -- Get reviewers from review events
                  let reviewEvents = prReviews pr
                  let endEvents = filter (\re -> reAction re == "end") reviewEvents
                  let reviewers = nub [reReviewer re | re <- endEvents]
                  return (True, reviewers)
                else return (False, [])
  where
    filterM :: Monad m => (a -> m Bool) -> [a] -> m [a]
    filterM _ [] = return []
    filterM p (x:xs) = do
      flg <- p x
      ys <- filterM p xs
      return (if flg then x:ys else ys)

-- Check if a specific commit has been reviewed
checkCommitReviewStatus :: String -> String -> IO Bool
checkCommitReviewStatus branch commitHash = do
  state <- loadState
  case Map.lookup branch state of
    Nothing -> return False
    Just pr -> do
      let reviewEvents = prReviews pr
      let endEvents = filter (\re -> reAction re == "end") reviewEvents
      
      -- Check new format first (individual commit hashes)
      let reviewedCommitMaps = [commitMap | re <- endEvents, Just commitMap <- [reReviewedCommits re]]
      let directlyReviewed = any (Map.member commitHash) reviewedCommitMaps
      
      if directlyReviewed
        then return True
        else do
          -- Fall back to legacy format for backward compatibility
          base <- getBaseBranch
          (code, _, _) <- readProcessWithExitCode "git" ["rev-parse", "--verify", commitHash] ""
          if code /= ExitSuccess
            then return False -- Commit doesn't exist
            else do
              let legacyHashes = [hash | re <- endEvents, Just hash <- [reContentHash re], reReviewedCommits re == Nothing]
              if null legacyHashes
                then return False
                else do
                  -- For legacy events, check if this commit's content matches any reviewed content
                  commitContentHash <- generatePatchHash base commitHash
                  return (commitContentHash `elem` legacyHashes)