packages feed

github-actions-0.1.0.0: src/Language/Github/Actions/Workflow.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- |
-- Module      : Language.Github.Actions.Workflow
-- Description : GitHub Actions workflow definition and serialization
-- Copyright   : (c) 2025 Bellroy Pty Ltd
-- License     : BSD-3-Clause
-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
--
-- This module provides the core 'Workflow' type for representing GitHub Actions workflows,
-- along with JSON serialization/deserialization and generation utilities.
--
-- GitHub Actions workflows are defined in YAML files that specify automated processes
-- triggered by events in a GitHub repository. This module provides a type-safe way to
-- construct and serialize these workflows.
--
-- For more information about GitHub Actions workflow syntax, see:
-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions>
module Language.Github.Actions.Workflow
  ( Workflow (..),
    gen,
    new,
  )
where

import Data.Aeson (FromJSON, ToJSON (..), (.!=), (.:), (.:?), (.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as AesonKeyMap
import Data.Map (Map)
import Data.Maybe (catMaybes)
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import GHC.Generics (Generic)
import Hedgehog (MonadGen)
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Language.Github.Actions.Concurrency (Concurrency)
import qualified Language.Github.Actions.Concurrency as Concurrency
import Language.Github.Actions.Defaults (Defaults)
import qualified Language.Github.Actions.Defaults as Defaults
import Language.Github.Actions.Job (Job)
import qualified Language.Github.Actions.Job as Job
import Language.Github.Actions.Job.Id (JobId)
import qualified Language.Github.Actions.Job.Id as JobId
import Language.Github.Actions.Permissions (Permissions)
import qualified Language.Github.Actions.Permissions as Permissions
import Language.Github.Actions.Workflow.Trigger (WorkflowTrigger)
import qualified Language.Github.Actions.Workflow.Trigger as WorkflowTrigger

-- | Internal type for workflow job steps (used in JSON parsing)
data WorkflowJobStep = WorkflowJobStep
  { -- | Optional step name
    name :: Maybe Text,
    -- | Optional run command
    run :: Maybe Text
  }
  deriving stock (Eq, Generic, Ord, Show)

-- | A GitHub Actions workflow definition.
--
-- A workflow defines automated processes that run in response to events in your repository.
-- Workflows are defined in YAML files stored in the @.github\/workflows@ directory.
--
-- Example usage:
--
-- @
-- import Language.Github.Actions.Workflow
-- import qualified Language.Github.Actions.Job as Job
-- import qualified Language.Github.Actions.Job.Id as JobId
--
-- myWorkflow :: Workflow
-- myWorkflow = new
--  { workflowName = Just "CI"
--  , jobs = Map.singleton (JobId "build") Job.new
--  , on = Set.singleton pushTrigger
--  }
-- @
--
-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions>
data Workflow = Workflow
  { -- | Concurrency settings to limit workflow runs
    concurrency :: Maybe Concurrency,
    -- | Default settings for all jobs in the workflow
    defaults :: Maybe Defaults,
    -- | Environment variables available to all jobs
    env :: Map Text Text,
    -- | The jobs that make up the workflow
    jobs :: Map JobId Job,
    -- | Events that trigger the workflow
    on :: Set WorkflowTrigger,
    -- | Permissions granted to the workflow
    permissions :: Maybe Permissions,
    -- | Name for workflow runs
    runName :: Maybe Text,
    -- | Name of the workflow
    workflowName :: Maybe Text
  }
  deriving stock (Eq, Generic, Ord, Show)

instance FromJSON Workflow where
  parseJSON = Aeson.withObject "Workflow" $ \o -> do
    concurrency <- o .:? "concurrency"
    defaults <- o .:? "defaults"
    env <- o .:? "env" .!= mempty
    jobs <- o .: "jobs"
    Aeson.Object onObject <- o .: "on"
    triggers <-
      traverse
        ( Aeson.parseJSON
            . Aeson.Object
            . uncurry AesonKeyMap.singleton
        )
        $ AesonKeyMap.toList onObject
    let on = Set.fromList triggers
    permissions <- o .:? "permissions"
    runName <- o .:? "run-name"
    workflowName <- o .:? "name"
    pure Workflow {..}

instance ToJSON Workflow where
  toJSON Workflow {..} =
    Aeson.object $
      catMaybes
        [ ("concurrency" .=) <$> concurrency,
          ("defaults" .=) <$> defaults,
          ("env" .=) <$> monoidToMaybe env,
          Just $ "jobs" .= jobs,
          ("name" .=) <$> workflowName,
          Just $ "on" .= Aeson.Object (mconcat (forceToJSONObject . Aeson.toJSON <$> Set.toList on)),
          ("permissions" .=) <$> permissions,
          ("run-name" .=) <$> runName
        ]
    where
      forceToJSONObject :: Aeson.Value -> Aeson.Object
      forceToJSONObject (Aeson.Object o) = o
      forceToJSONObject _ = error "Expected Aeson.Object"

      monoidToMaybe :: (Eq a, Monoid a) => a -> Maybe a
      monoidToMaybe a = if a == mempty then Nothing else Just a

gen :: (MonadGen m) => m Workflow
gen = do
  concurrency <- Gen.maybe Concurrency.gen
  defaults <- Gen.maybe Defaults.gen
  env <- genTextMap
  jobs <-
    Gen.map (Range.linear 1 5) (liftA2 (,) JobId.gen Job.gen)
  on <-
    Set.fromList
      . snd
      . foldr onlyAddIfJsonKeyNotAlreadyPresent ([], [])
      <$> Gen.list (Range.linear 1 5) WorkflowTrigger.gen

  permissions <- Gen.maybe Permissions.gen
  runName <- Gen.maybe genText
  workflowName <- Gen.maybe genText
  pure Workflow {..}
  where
    onlyAddIfJsonKeyNotAlreadyPresent ::
      WorkflowTrigger ->
      ([AesonKeyMap.Key], [WorkflowTrigger]) ->
      ([AesonKeyMap.Key], [WorkflowTrigger])
    onlyAddIfJsonKeyNotAlreadyPresent trigger (keys, triggers) =
      let key = case Aeson.toJSONKey of
            Aeson.ToJSONKeyText f _ -> f trigger
            _ -> error "Expected Aeson.ToJSONKeyText"
       in if key `elem` keys
            then (keys, triggers)
            else (key : keys, trigger : triggers)

    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText

-- | Create a new empty 'Workflow' with default values.
--
-- This provides a minimal workflow that can be extended with specific jobs,
-- triggers, and other configuration.
--
-- Example:
--
-- @
-- myWorkflow = new
--   { workflowName = Just "My Workflow"
--   , jobs = Map.singleton (JobId "test") Job.new
--   , on = Set.singleton (PushTrigger pushTriggerDefaults)
--   }
-- @
new :: Workflow
new =
  Workflow
    { concurrency = Nothing,
      defaults = Nothing,
      env = mempty,
      jobs = mempty,
      on = mempty,
      permissions = Nothing,
      runName = Nothing,
      workflowName = Nothing
    }