packages feed

hix-0.9.0: test/Hix/Test/Managed/Maint/Handlers.hs

module Hix.Test.Managed.Maint.Handlers where

import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Time (TimeOfDay (TimeOfDay), UTCTime (UTCTime), fromGregorian, timeOfDayToTime)
import Data.Tuple.Extra (fst3)
import Data.Typeable (cast)
import Distribution.Compat.CharParsing (string)
import Distribution.Parsec (CabalParsing, Parsec (parsec), explicitEitherParsec)
import Distribution.Version (version0)
import Exon (exon)
import Path (parseRelDir, parseRelFile)
import Type.Reflection (typeRep)

import Hix.Class.Map (nAdjust, nElems, nKeys, nKeysSet, nMap, nMapWithKey, nOver, nTransform, nViaA, (!?))
import Hix.Data.Dep (Dep (..))
import Hix.Data.EnvName (EnvName)
import Hix.Data.MDep (MDep (..))
import qualified Hix.Data.Monad
import Hix.Data.Monad (M, appRes)
import Hix.Data.PackageId (PackageId (..))
import Hix.Data.PackageName (LocalPackage (..), PackageName)
import Hix.Managed.Cabal.Data.Revision (Revision (..))
import Hix.Managed.Data.BuildOutput (DepChanges (..), ModifiedId (..))
import Hix.Managed.Data.Envs (Envs)
import Hix.Managed.Data.MaintContext (MaintContext (..), MaintPackage (..))
import Hix.Managed.Data.ManagedPackage (ManagedPackage (..))
import Hix.Managed.Data.Mutable (depName)
import Hix.Managed.Data.Packages (Packages)
import Hix.Managed.Git (BranchName (..), GitApi (..), MaintBranch (..), Tag (..))
import qualified Hix.Managed.Handlers.Context as ContextHandlers
import Hix.Managed.Handlers.Context (ContextKey (ContextMaint), ContextQuery (ContextQuery))
import Hix.Managed.Handlers.HackageClient (
  HackageClient (..),
  HackageError (..),
  HackageRequest (..),
  HackageResponse (..),
  )
import Hix.Managed.Handlers.Maint (MaintHandlers (..))
import Hix.Managed.Maint.Git (GitMaint (..), formatReleaseBranch, releaseBranchName)
import Hix.Monad (noteFatal)
import Hix.Pretty (HPretty (..), field, prettyMap, showP, showPL)
import Hix.Test.Managed.ReleaseMaintenance.Case (
  EnvStyle (..),
  MaintTestCase (..),
  PackageConf (..),
  PackageMeta (..),
  ProjectHistoryEvent (..),
  TestDep (ModifiedDep),
  partitionTestDeps,
  testDepName,
  )
import Hix.Test.Managed.ReleaseMaintenance.Gen (oldDepBounds, oldDepRange)
import Hix.Test.Utils (addFile)

dummyTime :: UTCTime
dummyTime = UTCTime (fromGregorian 2024 1 16) (timeOfDayToTime (TimeOfDay 0 0 0))

dummyRevision :: Word -> Revision
dummyRevision number = Revision {user = "user-rev", time = dummyTime, sha256 = "deadbeef", number}

data MaintEvent =
  EventFetchRevisions
  |
  EventPublishRevision { packageId :: PackageId, form :: (Text, Text) }
  |
  GitCommitted {
    package :: LocalPackage,
    branch :: MaintBranch,
    modified :: NonEmpty ModifiedId
  }
  deriving stock (Eq, Show)

data GitState =
  GitState {
    branches :: [(MaintBranch, BranchName, Tag)],
    current :: Maybe MaintBranch
  }
  deriving stock (Eq, Show)

data MaintState =
  MaintState {
    events :: [MaintEvent],
    published :: Set PackageName,
    git :: GitState
  }
  deriving stock (Eq, Show, Generic)

pathParserRevisions :: CabalParsing m => m PackageId
pathParserRevisions = do
  string "package/"
  pid <- parsec
  string "/revisions/"
  pure pid

pathParserPublishRevision :: CabalParsing m => m PackageId
pathParserPublishRevision = do
  string "package/"
  pid <- parsec
  string [exon|/#{toString pid.name}.cabal/edit|]
  pure pid

hackage :: ∀ a . Packages PackageMeta -> MVar MaintState -> HackageRequest a -> M (Either HackageError a)
hackage packages state = \case
  HackageRequest {path, accept = HackageResponseJson}
    | Right pid <- explicitEitherParsec pathParserRevisions (toString path)
    , Just package <- packages !? LocalPackage pid.name
    ->
      liftIO $ modifyMVar state \case
        MaintState {events, published, ..} -> do
          let inc = if Set.member pid.name published then 1 else 0
          res <- respond @[_] (dummyRevision <$> [0..package.revision + inc])
          pure (MaintState {events = EventFetchRevisions : events, published, ..}, res)
    where
      respond :: ∀ r . Typeable r => r -> IO (Either HackageError a)
      respond r | Just a <- cast r = pure (Right a)
                | otherwise = pure (Left (HackageFatal [exon|Unexpected json type: #{show (typeRep @a)}|]))

  HackageRequest {path, accept = HackageResponseHtml, body = Just (Left formData)}
    | Right pid <- explicitEitherParsec pathParserPublishRevision (toString path)
    ->
      withState \ MaintState {..} -> do
        let new = MaintState {published = Set.insert pid.name published, ..}
        pure (EventPublishRevision pid (NonEmpty.head formData), new, Right "all good")

  HackageRequest {path, accept} ->
    pure (Left (HackageFatal [exon|Unexpected Hackage request for path '#{path}' with type '#{show accept}'|]))
  where
    withState :: ∀ x . (MaintState -> IO (MaintEvent, MaintState, x)) -> M x
    withState f =
      liftIO $ modifyMVar state \ oldState -> do
        (newEvent, MaintState {..}, a) <- f oldState
        pure (MaintState {events = newEvent : events, ..}, a)

data MaintPremise =
  MaintPremise {
    packages :: Packages PackageMeta,
    tags :: [Tag],
    branches :: [(MaintBranch, BranchName, Tag)]
  }
  deriving stock (Eq, Show)

package0 ::
  EnvStyle ->
  Bool ->
  LocalPackage ->
  PackageConf ->
  PackageMeta
package0 envStyle anyDepModified package PackageConf {..} =
  PackageMeta {
    package,
    deps,
    version = version0,
    modified,
    bumped = any isRangeBump (nElems deps),
    envModified,
    released = False,
    revision = 0,
    shared = False
  }
  where
    envModified = case envStyle of
      EnvAll -> anyDepModified
      EnvEach -> modified

    modified = any isBump (nElems deps)

    isBump = \case
      ModifiedDep _ -> True
      _ -> False

    isRangeBump = \case
      ModifiedDep ModifiedId {range = Just _} -> True
      _ -> False

evolveHistory :: MaintTestCase -> MaintPremise
evolveHistory testCase =
  foldl' step MaintPremise {
    packages = nMapWithKey (package0 testCase.envStyle testCase.anyDepModified) testCase.packages,
    tags = [],
    branches = []
  } testCase.history
  where

    step MaintPremise {..} = \case
      Release {..} ->
        let
          version = bump major ((.version) <$> packages !? package)
          newPackages = nAdjust package packages (release version revision False)
        in withBranches newPackages (Just package) [package] version revision
      ReleaseShared {..} ->
        let
          version = bump major (maximum ((.version) <$> nElems packages))
          newPackages = nMap (release version revision True) packages
        in withBranches newPackages Nothing (nKeys packages) version revision
      where
        withBranches newPackages tagPackage names version revision =
          let
            tag = Tag {package = tagPackage, version}
            newBranch | 0 <- revision = []
                      | otherwise = [(branch tag name version) | name <- names]
          in MaintPremise {packages = newPackages, tags = tag : tags, branches = newBranch ++ branches}

    branch tag name version =
      let mb = MaintBranch {package = name, version}
      in (mb, releaseBranchName mb, tag)

    release version revision shared PackageMeta {package, deps, modified, bumped, envModified} = do
      PackageMeta {version, released = True, ..}

    bump major = \case
      Just [s, ma, mi] -> if major then [s, ma + 1, 0] else [s, ma, mi + 1]
      _ -> if major then [0, 1, 0] else [0, 0, 1]

depChanges :: [PackageMeta] -> DepChanges
depChanges packages =
  DepChanges {
    modified,
    unmodified,
    failed
  }
  where
    (unmodified, modified, failed) = partitionTestDeps allDeps

    allDeps = nElems (mconcat ((.deps) <$> packages))

data EnvOutput =
  EnvOutput {
    targets :: [PackageMeta],
    changes :: DepChanges
  }
  deriving stock (Eq, Show)

instance HPretty EnvOutput where
  hpretty EnvOutput {..} =
    prettyMap "env" [
      field "targets" targets
    ]

buildOutputs :: Packages PackageMeta -> EnvStyle -> Envs EnvOutput
buildOutputs packages = \case
  EnvAll ->
    [("latest", output (nElems packages))]
  EnvEach ->
    flip nTransform packages \ name pkg ->
      ([exon|latest-##{name}|], output [pkg])
  where
    output targets =
      EnvOutput {
        targets,
        changes = depChanges targets
      }

gitMaint ::
  MVar MaintState ->
  MaintPremise ->
  GitMaint
gitMaint state premise =
  GitMaint {..}
  where
    bracket ma = ma

    readTags = pure premise.tags

    listTargetBranches target = do
      branches <- allBranches
      pure (filter ((target ==) . (.package)) branches)

    branchOffTag branch tag =
      update \ GitState {..} ->
        GitState {
          current = Just branch,
          branches = (branch, BranchName (formatReleaseBranch branch), tag) : branches,
          ..
        }

    switchBranch branch = do
      update \ GitState {..} -> GitState {current = Just branch, ..}
      pure (releaseBranchName branch)

    commitBump branch package modified = do
      updateState \ MaintState {..} -> MaintState {events = GitCommitted {package, branch, modified} : events, ..}
      pure (releaseBranchName branch)

    allBranches = liftIO $ readMVar state <&> \ MaintState {git = GitState {branches}} ->
      fst3 <$> branches ++ premise.branches

    update f = updateState \ MaintState {git, ..} -> MaintState {git = f git, ..}

    updateState f = liftIO $ modifyMVar_ state (pure . f)

buildCabalConfig :: PackageMeta -> DepChanges -> M Text
buildCabalConfig PackageMeta {package, deps} DepChanges {..} = do
  pure [exon|cabal-version: 2.2
name: ##{package}
version: 0.2.0
library ##{package}
  build-depends:
    #{showPL bdeps}
|]
  where
    bdeps = sortOn (.package) (mapMaybe mdep modified ++ mapMaybe udep unmodified)

    mdep ModifiedId {package = dpackage, range} =
      let name = depName dpackage
      in if isDep name
      then Just MDep {package = name, bounds = fromMaybe oldDepBounds range}
      else Nothing

    udep d =
      let name = depName d
      in if isDep name
      then Just MDep {package = name, bounds = oldDepBounds}
      else Nothing

    isDep = flip Set.member depNames

    depNames = nKeysSet deps

-- TODO we still need to keep adding the cabal file since publishRevision reads it.
-- It should probably generate it using the flake, on the fly (and this could then be mocked here).
runBump ::
  Envs EnvOutput ->
  EnvName ->
  M DepChanges
runBump outputs env = do
  root <- appRes.root
  EnvOutput {targets, changes} <- noteFatal [exon|No build output in test data for '#{showP env}'|] (outputs !? env)
  for_ targets \ meta@PackageMeta {package} -> do
    cabalPath <- parseRelFile [exon|packages/##{package}/##{package}.cabal|]
    cabalConfig <- buildCabalConfig meta changes
    addFile root cabalPath cabalConfig
  pure changes

testContext :: Envs EnvOutput -> Packages PackageMeta -> M MaintContext
testContext outputs packages = do
  maintPackages <- nViaA (Map.traverseWithKey maintPackage) packages
  pure MaintContext {packages = maintPackages, hackage = [], envs}
  where
    maintPackage package PackageMeta {version, deps} = do
      path <- pathError package $ parseRelDir [exon|packages/##{package}|]
      pure MaintPackage {
        package = ManagedPackage {
          name = package,
          version,
          deps = [Dep {package = testDepName dep, version = oldDepRange} | dep <- nElems deps]
        },
        path
      }

    envs = nOver outputs \ env -> [package | PackageMeta {package} <- env.targets]

    pathError package = noteFatal [exon|testContext: Invalid package name for path: #{showP package}|]

configQuery :: MaintContext -> ContextQuery a -> Maybe a
configQuery conf = \case
  ContextQuery ContextMaint -> Just conf
  _ -> Nothing

maintTestHandlers ::
  Envs EnvOutput ->
  MaintPremise ->
  M (MaintHandlers, MaintContext, MVar MaintState)
maintTestHandlers outputs premise = do
  state <- liftIO $ newMVar initialState
  context <- testContext outputs premise.packages
  let handlers = MaintHandlers {
    runBump = runBump outputs,
    git = GitApi \ _ f -> f (gitMaint state premise),
    context = ContextHandlers.handlersTest (const (pure Nothing)),
    publishHackages = [HackageClient "mock hackage" (hackage premise.packages state)]
  }
  pure (handlers, context, state)
  where
    initialState =
      MaintState {events = [], published = [], git = GitState {branches = [], current = Nothing}}