packages feed

minici-0.1.9: src/Eval.hs

module Eval (
    EvalInput(..),
    EvalError(..), textEvalError,
    Eval, runEval,

    evalJobSet,
    evalJobSetSelected,
    evalJobReference,
    evalJobReferenceToSet,

    loadJobSetById,
) where

import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader

import Data.List
import Data.Maybe
import Data.Text (Text)
import Data.Text qualified as T

import System.FilePath

import Config
import Destination
import Job.Types
import Repo

data EvalInput = EvalInput
    { eiJobRoot :: JobRoot
    , eiRootPath :: FilePath
    , eiCurrentIdRev :: [ JobIdPart ]
    , eiContainingRepo :: Maybe Repo
    , eiOtherRepos :: [ ( RepoName, Repo ) ]
    , eiDestinations :: [ ( DestinationName, Destination ) ]
    }

data EvalError
    = OtherEvalError Text

textEvalError :: EvalError -> Text
textEvalError (OtherEvalError text) = text


type Eval a = ReaderT EvalInput (ExceptT EvalError IO) a

runEval :: Eval a -> EvalInput -> IO (Either EvalError a)
runEval action einput = runExceptT $ flip runReaderT einput action


commonPrefix :: Eq a => [ a ] -> [ a ] -> [ a ]
commonPrefix (x : xs) (y : ys) | x == y = x : commonPrefix xs ys
commonPrefix _        _                 = []

checkIfAlreadyHasDefaultRepoId :: Eval Bool
checkIfAlreadyHasDefaultRepoId = do
    asks (any isDefaultRepoId . eiCurrentIdRev)
  where
    isDefaultRepoId (JobIdName _) = False
    isDefaultRepoId (JobIdCommit rname _) = isNothing rname
    isDefaultRepoId (JobIdTree rname _ _) = isNothing rname

collectJobSetRepos :: [ ( Maybe RepoName, Tree ) ] -> DeclaredJobSet -> Eval [ ( Maybe RepoName, Tree ) ]
collectJobSetRepos revisionOverrides dset = do
    jobs <- either (throwError . OtherEvalError . T.pack) return $ jobsetJobsEither dset
    let someJobUsesDefaultRepo = any (any (isNothing . jcRepo) . jobCheckout) jobs
        repos =
            (if someJobUsesDefaultRepo then (Nothing :) else id) $
                map (Just . repoName) $ maybe [] configRepos $ jobsetConfig dset
    forM repos $ \rname -> do
        case lookup rname revisionOverrides of
            Just tree -> return ( rname, tree )
            Nothing -> do
                repo <- evalRepo rname
                tree <- getCommitTree =<< readCommit repo "HEAD"
                return ( rname, tree )

collectOtherRepos :: DeclaredJobSet -> DeclaredJob -> Eval [ ( Maybe ( RepoName, Maybe Text ), FilePath ) ]
collectOtherRepos dset decl = do
    jobs <- either (throwError . OtherEvalError . T.pack) return $ jobsetJobsEither dset
    let gatherDependencies seen (d : ds)
            | d `elem` seen = gatherDependencies seen ds
            | Just job <- find ((d ==) . jobName) jobs
                            = gatherDependencies (d : seen) (map fst (jobRequiredArtifacts job) ++ ds)
            | otherwise     = gatherDependencies (d : seen) ds
        gatherDependencies seen [] = seen

    let dependencies = gatherDependencies [] [ jobName decl ]
    dependencyRepos <- forM dependencies $ \name -> do
        job <- maybe (throwError $ OtherEvalError $ "job ‘" <> textJobName name <> "’ not found") return . find ((name ==) . jobName) $ jobs
        return $ jobCheckout job

    alreadyHasDefaultRepoId <- checkIfAlreadyHasDefaultRepoId
    let checkouts =
            (if alreadyHasDefaultRepoId then filter (isJust . jcRepo) else id) $
                concat dependencyRepos

    let commonSubdir reporev = joinPath $ foldr1 commonPrefix $
            map (maybe [] splitDirectories . jcSubtree) . filter ((reporev ==) . jcRepo) $ checkouts
    let canonicalRepoOrder = Nothing : maybe [] (map (Just . repoName) . configRepos) (jobsetConfig dset)
        getCheckoutsForName rname = map (\r -> ( r, commonSubdir r )) $ nub $ filter ((rname ==) . fmap fst) $ map jcRepo checkouts
    return $ concatMap getCheckoutsForName canonicalRepoOrder


evalJobs
    :: [ DeclaredJob ] -> [ Either JobName Job ]
    -> [ ( Maybe RepoName, Tree ) ] -> DeclaredJobSet -> [ JobName ] -> Eval [ Job ]
evalJobs _ _ _ JobSet { jobsetJobsEither = Left err } _ = throwError $ OtherEvalError $ T.pack err

evalJobs [] evaluated repos dset@JobSet { jobsetJobsEither = Right decl } (req : reqs)
    | any ((req ==) . either id jobName) evaluated
    = evalJobs [] evaluated repos dset reqs
    | Just d <- find ((req ==) . jobName) decl
    = evalJobs [ d ] evaluated repos dset reqs
    | otherwise
    = throwError $ OtherEvalError $ "job ‘" <> textJobName req <> "’ not found in jobset"
evalJobs [] evaluated _ _ [] = return $ mapMaybe (either (const Nothing) Just) evaluated

evalJobs (current : evaluating) evaluated repos dset reqs
    | any ((jobName current ==) . jobName) evaluating = throwError $ OtherEvalError $ "cyclic dependency when evaluating job ‘" <> textJobName (jobName current) <> "’"
    | any ((jobName current ==) . either id jobName) evaluated = evalJobs evaluating evaluated repos dset reqs

evalJobs (current : evaluating) evaluated repos dset reqs
    | Just missing <- find (`notElem` (jobName current : map (either id jobName) evaluated)) $ map fst $ jobRequiredArtifacts current
    , d <- either (const Nothing) (find ((missing ==) . jobName)) (jobsetJobsEither dset)
    = evalJobs (fromJust d : current : evaluating) evaluated repos dset reqs

evalJobs (current : evaluating) evaluated repos dset reqs = do
    EvalInput {..} <- ask
    otherRepos <- collectOtherRepos dset current
    otherRepoTreesMb <- forM otherRepos $ \( mbrepo, commonPath ) -> do
        Just tree <- return $ lookup (fst <$> mbrepo) repos
        mbSubtree <- case snd =<< mbrepo of
            Just revisionOverride -> return . Just =<< getCommitTree =<< readCommit (treeRepo tree) revisionOverride
            Nothing
                | treeSubdir tree == commonPath -> do
                    return $ Just tree
                | splitDirectories (treeSubdir tree) `isPrefixOf` splitDirectories commonPath -> do
                    Just <$> getSubtree Nothing (makeRelative (treeSubdir tree) commonPath) tree
                | otherwise -> do
                    return Nothing
        return $ fmap (\subtree -> ( mbrepo, ( commonPath, subtree ) )) mbSubtree
    let otherRepoTrees = catMaybes otherRepoTreesMb
    if all isJust otherRepoTreesMb
      then do
        let otherRepoIds = flip mapMaybe otherRepoTrees $ \case
                ( repo, ( subtree, tree )) -> do
                    guard $ maybe True (isNothing . snd) repo -- use only checkouts without explicit revision in job id
                    Just $ JobIdTree (fst <$> repo) subtree (treeId tree)
        let currentJobId = JobId $ reverse $ reverse otherRepoIds ++ JobIdName (jobId current) : eiCurrentIdRev

        checkouts <- forM (jobCheckout current) $ \dcheckout -> do
            return dcheckout
                { jcRepo =
                    fromMaybe (error $ "expecting repo in either otherRepoTrees or repos: " <> show (textRepoName . fst <$> jcRepo dcheckout)) $
                    msum
                        [ snd <$> lookup (jcRepo dcheckout) otherRepoTrees
                        , lookup (fst <$> jcRepo dcheckout) repos -- for containing repo if filtered from otherRepos
                        ]
                }

        uses <- forM (jobUses current) $ \( jname, aname ) -> do
            Just (Right job) <- return $ find ((jname ==) . either id jobName) evaluated
            return ( jobId job, aname )

        destinations <- forM (jobPublish current) $ \dpublish -> do
            let ( jname, _ ) = jpArtifact dpublish
            jid <- if
                | jname == jobName current -> return currentJobId
                | otherwise -> do
                    Just (Right job) <- return $ find ((jname ==) . either id jobName) evaluated
                    return $ jobId job

            case lookup (jpDestination dpublish) eiDestinations of
                Just dest -> return dpublish
                    { jpArtifact = ( jid, snd (jpArtifact dpublish) )
                    , jpDestination = dest
                    }
                Nothing -> throwError $ OtherEvalError $ "no url defined for destination ‘" <> textDestinationName (jpDestination dpublish) <> "’"

        let job = Job
                { jobId = currentJobId
                , jobName = jobName current
                , jobCheckout = checkouts
                , jobRecipe = jobRecipe current
                , jobArtifacts = jobArtifacts current
                , jobUses = uses
                , jobPublish = destinations
                }
        evalJobs evaluating (Right job : evaluated) repos dset reqs
      else do
        evalJobs evaluating (Left (jobName current) : evaluated) repos dset reqs

evalJobSet :: [ ( Maybe RepoName, Tree ) ] -> DeclaredJobSet -> Eval JobSet
evalJobSet revisionOverrides decl = evalJobSetSelected (either (const []) (map jobName) (jobsetJobsEither decl)) revisionOverrides decl

evalJobSetSelected :: [ JobName ] -> [ ( Maybe RepoName, Tree ) ] -> DeclaredJobSet -> Eval JobSet
evalJobSetSelected selected revisionOverrides decl = do
    EvalInput {..} <- ask
    repos <- collectJobSetRepos revisionOverrides decl
    alreadyHasDefaultRepoId <- checkIfAlreadyHasDefaultRepoId
    let addedRepoIds =
            map (\( mbname, tree ) -> JobIdTree mbname (treeSubdir tree) (treeId tree)) $
            (if alreadyHasDefaultRepoId then filter (isJust . fst) else id) $
            repos

    evaluated <- handleToEither $ evalJobs [] [] repos decl selected
    let jobs = case liftM2 (,) evaluated (jobsetJobsEither decl) of
            Left err -> Left err
            Right ( ejobs, djobs ) -> Right $ mapMaybe (\dj -> find ((jobName dj ==) . jobName) ejobs) djobs

    let explicit = mapMaybe (\name -> jobId <$> find ((name ==) . jobName) (either (const []) id jobs)) $ jobsetExplicitlyRequested decl
    return JobSet
        { jobsetId = JobSetId $ reverse $ reverse addedRepoIds ++ eiCurrentIdRev
        , jobsetConfig = jobsetConfig decl
        , jobsetCommit = jobsetCommit decl
        , jobsetExplicitlyRequested = explicit
        , jobsetJobsEither = jobs
        }
  where
    handleToEither = flip catchError (return . Left . T.unpack . textEvalError) . fmap Right

evalRepo :: Maybe RepoName -> Eval Repo
evalRepo Nothing = asks eiContainingRepo >>= \case
    Just repo -> return repo
    Nothing -> throwError $ OtherEvalError $ "no default repo"
evalRepo (Just name) = asks (lookup name . eiOtherRepos) >>= \case
    Just repo -> return repo
    Nothing -> throwError $ OtherEvalError $ "repo ‘" <> textRepoName name <> "’ not defined"


canonicalJobName :: [ Text ] -> Config -> Maybe Tree -> Eval JobSet
canonicalJobName (r : rs) config mbDefaultRepo = do
    let name = JobName r
        dset = JobSet
            { jobsetId = ()
            , jobsetConfig = Just config
            , jobsetCommit = Nothing
            , jobsetExplicitlyRequested = [ name ]
            , jobsetJobsEither = Right $ configJobs config
            }
    case find ((name ==) . jobName) (configJobs config) of
        Just djob -> do
            otherRepos <- collectOtherRepos dset djob
            ( overrides, rs' ) <- (\f -> foldM f ( [], rs ) otherRepos) $
                \( overrides, crs ) ( mbrepo, path ) -> if
                    | Just ( _, Just _ ) <- mbrepo -> do
                        -- use only checkouts without explicit revision in job id
                        return ( overrides, crs )
                    | otherwise -> do
                        ( tree, crs' ) <- readTreeFromIdRef crs path =<< evalRepo (fst <$> mbrepo)
                        return ( ( fst <$> mbrepo, tree ) : overrides, crs' )
            case rs' of
                (r' : _) -> throwError $ OtherEvalError $ "unexpected job ref part ‘" <> r' <> "’"
                _ -> return ()
            evalJobSetSelected (jobsetExplicitlyRequested dset) (maybe id ((:) . ( Nothing, )) mbDefaultRepo $ overrides) dset
        Nothing -> throwError $ OtherEvalError $ "job ‘" <> r <> "’ not found"
canonicalJobName [] _ _ = throwError $ OtherEvalError "expected job name"

readTreeFromIdRef :: [ Text ] -> FilePath -> Repo -> Eval ( Tree, [ Text ] )
readTreeFromIdRef (r : rs) subdir repo = do
    tryReadCommit repo r >>= \case
        Just commit -> return . (, rs) =<< getSubtree (Just commit) subdir =<< getCommitTree commit
        Nothing -> tryReadTree repo subdir r >>= \case
            Just tree -> return ( tree, rs )
            Nothing -> throwError $ OtherEvalError $ "failed to resolve ‘" <> r <> "’ to a commit or tree in " <> T.pack (show repo)
readTreeFromIdRef [] _ _ = throwError $ OtherEvalError $ "expected commit or tree reference"

canonicalCommitConfig :: [ Text ] -> Repo -> Eval JobSet
canonicalCommitConfig rs repo = do
    ( tree, rs' ) <- readTreeFromIdRef rs "" repo
    config <- either fail return =<< loadConfigForCommit tree
    local (\ei -> ei { eiCurrentIdRev = JobIdTree Nothing "" (treeId tree) : eiCurrentIdRev ei }) $
        canonicalJobName rs' config (Just tree)

evalJobReferenceToSet :: JobRef -> Eval JobSet
evalJobReferenceToSet (JobRef rs) =
    asks eiJobRoot >>= \case
        JobRootRepo defRepo -> do
            canonicalCommitConfig rs defRepo
        JobRootConfig config -> do
            canonicalJobName rs config Nothing

evalJobReference :: JobRef -> Eval Job
evalJobReference ref = do
    jset <- evalJobReferenceToSet ref
    jobs <- either (throwError . OtherEvalError . T.pack) return $ jobsetJobsEither jset
    [ name ] <- return $ jobsetExplicitlyRequested jset
    maybe (error "missing job in evalJobReferenceToSet result") return $ find ((name ==) . jobId) jobs


jobsetFromConfig :: [ JobIdPart ] -> Config -> Maybe Tree -> Eval ( DeclaredJobSet, [ JobIdPart ], [ ( Maybe RepoName, Tree ) ] )
jobsetFromConfig sid config _ = do
    EvalInput {..} <- ask
    let dset = JobSet () (Just config) Nothing [] $ Right $ configJobs config
    otherRepos <- forM sid $ \case
        JobIdName name -> do
            throwError $ OtherEvalError $ "expected tree id, not a job name ‘" <> textJobName name <> "’"
        JobIdCommit name cid -> do
            repo <- evalRepo name
            tree <- getCommitTree =<< readCommitId repo cid
            return ( name, tree )
        JobIdTree name path tid -> do
            repo <- evalRepo name
            tree <- readTreeId repo path tid
            return ( name, tree )
    return ( dset, eiCurrentIdRev, otherRepos )

jobsetFromCommitConfig :: [ JobIdPart ] -> Repo -> Eval ( DeclaredJobSet, [ JobIdPart ], [ ( Maybe RepoName, Tree ) ] )
jobsetFromCommitConfig (JobIdTree name path tid : sid) repo = do
    when (isJust name) $ do
        throwError $ OtherEvalError $ "expected default repo commit or tree id"
    when (not (null path)) $ do
        throwError $ OtherEvalError $ "expected root commit or tree id"
    tree <- readTreeId repo path tid
    config <- either fail return =<< loadConfigForCommit tree
    local (\ei -> ei { eiCurrentIdRev = JobIdTree Nothing (treeSubdir tree) (treeId tree) : eiCurrentIdRev ei }) $ do
        ( dset, idRev, otherRepos ) <- jobsetFromConfig sid config (Just tree)
        return ( dset, idRev, ( Nothing, tree ) : otherRepos )

jobsetFromCommitConfig (JobIdCommit name cid : sid) repo = do
    when (isJust name) $ do
        throwError $ OtherEvalError $ "expected default repo commit or tree id"
    tree <- getCommitTree =<< readCommitId repo cid
    jobsetFromCommitConfig (JobIdTree name (treeSubdir tree) (treeId tree) : sid) repo

jobsetFromCommitConfig (JobIdName name : _) _ = do
    throwError $ OtherEvalError $ "expected commit or tree id, not a job name ‘" <> textJobName name <> "’"

jobsetFromCommitConfig [] _ = do
    throwError $ OtherEvalError $ "expected commit or tree id"

loadJobSetById :: JobSetId -> Eval ( DeclaredJobSet, [ JobIdPart ], [ ( Maybe RepoName, Tree ) ] )
loadJobSetById (JobSetId sid) = do
    asks eiJobRoot >>= \case
        JobRootRepo defRepo -> do
            jobsetFromCommitConfig sid defRepo
        JobRootConfig config -> do
            jobsetFromConfig sid config Nothing