packages feed

minici-0.1.8: src/Eval.hs

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

    evalJob,
    evalJobSet,
    evalJobReference,

    loadJobSetById,
    fillInDependencies,
) where

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

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

import System.FilePath

import Config
import Job.Types
import Repo

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

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 _        _                 = []

isDefaultRepoMissingInId :: DeclaredJob -> Eval Bool
isDefaultRepoMissingInId djob
    | all (isJust . jcRepo) (jobCheckout djob) = return False
    | otherwise = asks (not . any matches . eiCurrentIdRev)
  where
    matches (JobIdName _) = False
    matches (JobIdCommit rname _) = isNothing rname
    matches (JobIdTree rname _ _) = isNothing rname

collectOtherRepos :: DeclaredJobSet -> DeclaredJob -> Eval [ ( Maybe ( RepoName, Maybe Text ), FilePath ) ]
collectOtherRepos dset decl = do
    let dependencies = map fst $ jobUses decl
    dependencyRepos <- forM dependencies $ \name -> do
        jobs <- either (throwError . OtherEvalError . T.pack) return $ jobsetJobsEither dset
        job <- maybe (throwError $ OtherEvalError $ "job ‘" <> textJobName name <> "’ not found") return . find ((name ==) . jobName) $ jobs
        return $ jobCheckout job

    missingDefault <- isDefaultRepoMissingInId decl

    let checkouts =
            (if missingDefault then id else (filter (isJust . jcRepo))) $
            concat
                [ jobCheckout decl
                , concat dependencyRepos
                ]
    let commonSubdir reporev = joinPath $ foldr1 commonPrefix $
            map (maybe [] splitDirectories . jcSubtree) . filter ((reporev ==) . jcRepo) $ checkouts
    return $ map (\r -> ( r, commonSubdir r )) . nub . map jcRepo $ checkouts


evalJob :: [ ( Maybe RepoName, Tree ) ] -> DeclaredJobSet -> DeclaredJob -> Eval ( Job, JobSetId )
evalJob revisionOverrides dset decl = do
    EvalInput {..} <- ask
    otherRepos <- collectOtherRepos dset decl
    otherRepoTrees <- forM otherRepos $ \( mbrepo, commonPath ) -> do
        ( mbrepo, ) . ( commonPath, ) <$> do
            case lookup (fst <$> mbrepo) revisionOverrides of
                Just tree -> return tree
                Nothing -> do
                    repo <- evalRepo (fst <$> mbrepo)
                    commit <- readCommit repo (fromMaybe "HEAD" $ join $ snd <$> mbrepo)
                    getSubtree (Just commit) commonPath =<< getCommitTree commit

    checkouts <- forM (jobCheckout decl) $ \dcheckout -> do
        return dcheckout
            { jcRepo =
                fromMaybe (error $ "expecting repo in either otherRepoTrees or revisionOverrides: " <> show (textRepoName . fst <$> jcRepo dcheckout)) $
                msum
                    [ snd <$> lookup (jcRepo dcheckout) otherRepoTrees
                    , lookup (fst <$> jcRepo dcheckout) revisionOverrides
                    ]
            }

    let otherRepoIds = map (\( repo, ( subtree, tree )) -> JobIdTree (fst <$> repo) subtree (treeId tree)) otherRepoTrees
    return
        ( Job
            { jobId = JobId $ reverse $ reverse otherRepoIds ++ JobIdName (jobId decl) : eiCurrentIdRev
            , jobName = jobName decl
            , jobCheckout = checkouts
            , jobRecipe = jobRecipe decl
            , jobArtifacts = jobArtifacts decl
            , jobUses = jobUses decl
            }
        , JobSetId $ reverse $ reverse otherRepoIds ++ eiCurrentIdRev
        )

evalJobSet :: [ ( Maybe RepoName, Tree ) ] -> DeclaredJobSet -> Eval JobSet
evalJobSet revisionOverrides decl = do
    EvalInput {..} <- ask
    jobs <- fmap (fmap (map fst))
        $ either (return . Left) (handleToEither . mapM (evalJob revisionOverrides decl))
        $ jobsetJobsEither decl
    return JobSet
        { jobsetId = JobSetId $ reverse $ eiCurrentIdRev
        , jobsetCommit = jobsetCommit decl
        , 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 ( Job, JobSetId )
canonicalJobName (r : rs) config mbDefaultRepo = do
    let name = JobName r
        dset = JobSet () Nothing $ 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 ) -> 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 ()
            evalJob (maybe id ((:) . ( Nothing, )) mbDefaultRepo $ overrides) dset djob
        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 ( Job, JobSetId )
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)

evalJobReference :: JobRef -> Eval ( Job, JobSetId )
evalJobReference (JobRef rs) =
    asks eiJobRoot >>= \case
        JobRootRepo defRepo -> do
            canonicalCommitConfig rs defRepo
        JobRootConfig config -> do
            canonicalJobName rs config Nothing


jobsetFromConfig :: [ JobIdPart ] -> Config -> Maybe Tree -> Eval ( DeclaredJobSet, [ JobIdPart ], [ ( Maybe RepoName, Tree ) ] )
jobsetFromConfig sid config _ = do
    EvalInput {..} <- ask
    let dset = JobSet () 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 "" (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 "" (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

fillInDependencies :: JobSet -> Eval JobSet
fillInDependencies jset = do
    ( dset, idRev, otherRepos ) <- local (\ei -> ei { eiCurrentIdRev = [] }) $ do
        loadJobSetById (jobsetId jset)
    origJobs <- either (throwError . OtherEvalError . T.pack) return $ jobsetJobsEither jset
    declJobs <- either (throwError . OtherEvalError . T.pack) return $ jobsetJobsEither dset
    deps <- gather declJobs S.empty (map jobName origJobs)

    jobs <- local (\ei -> ei { eiCurrentIdRev = idRev }) $ do
        fmap catMaybes $ forM declJobs $ \djob -> if
            | Just job <- find ((jobName djob ==) . jobName) origJobs
            -> return (Just job)

            | jobName djob `S.member` deps
            -> Just . fst <$> evalJob otherRepos dset djob

            | otherwise
            -> return Nothing

    return $ jset { jobsetJobsEither = Right jobs }
  where
    gather djobs cur ( name : rest )
        | name `S.member` cur
        = gather djobs cur rest

        | Just djob <- find ((name ==) . jobName) djobs
        = gather djobs (S.insert name cur) $ map fst (jobUses djob) ++ rest

        | otherwise
        = throwError $ OtherEvalError $ "dependency ‘" <> textJobName name <> "’ not found"

    gather _ cur [] = return cur