Current version of JIRA inside Itransition does not allow to validate “whether a person belongs to a certain group” for multi-people fields, only for single-user fields. Coorish – is a small utility to determine ineligible people being specified in JIRA tickets.
History
Project card – is a custom JIRA ticket, containing a bunch of fields about the project (I do work in an outsource company, so…) – technologies used, people involved, plans and troubles, etc. Since migration plans are far away, I decided to write a small utility to ensure that there are no “misuses” – only people from Project.Management.All
AD group are specified in “Project Manager” fields in project cards.
Internals
So it is a small terminal application, which “talks” to JIRA asking about project cards, “talks” to AD via LDAP to get members of the groups and spills the results to the terminal.
main :: IO ()
= do
main Config {..} <- readConfig @Config
<- readConfig @Ldap.LdapConfig
ldapConfig <- readConfig @Jira.JiraConfig
jiraConfig
<- Ldap.groupMembers ldapGroups ldapConfig
activeDirectoryPeople <- Jira.projectCards jiraField jiraConfig
projectCards
$ \card -> do
forM_ projectCards let people = Jira.people card
null people) $ pure ()
when (
let (validPeople, invalidPeople) = partition (\person -> Jira.displayName person `elem` activeDirectoryPeople) people
null invalidPeople) $ do
unless ($
putTextLn "Card '" <> Jira.projectName card <> "' (" <> Jira.key card <> ") "
<> "has some people in '"
<> jiraField
<> "' field not from '"
<> mconcat (intersperse "; " ldapGroups)
<> "' AD group: '"
<> mconcat (intersperse "; " (map Jira.displayName invalidPeople))
<> "'"
I used the same ldap-client library for LDAP communication, but this time utilized text-ldap for parsing DNs from ldap into proper data structures instead of treating results like strings. Envy library with some sprinkles of template-haskell magic allowed me to read configuration properties from environment variables. Servant-client again proved to be very handy to “talk” to the external HTTP API. Template haskell was used for one more thing – embedding configuration values into FromJSON
instances – I didn’t know how to parametrize them in an elegant way.
data Config = Config
jiraField :: Text,
{ ldapGroups :: [Text]
}deriving (Generic, Show)
configValue :: Lift t => (Config -> t) -> Q Exp
= do
configValue f <- runIO (f <$> readConfig @Config)
groups
[e|groups|]
instance FromJSON ProjectCard where
= withObject "card" $ \card -> do
parseJSON <- card .: "key"
key <- card .: "fields"
fields <- fields .: "summary"
projectName <- fields .:? $(configValue jiraField) <|> fmap (replicate 1) <$> fields .:? $(configValue jiraField)
peopleMaybe pure $ ProjectCard key projectName $ fromMaybe [] peopleMaybe
Well, now I know, the trick is to make FromJSON
instance for a function).
instance FromJSON (Text -> ProjectCard) where
= withObject "card" $ \card -> do
parseJSON <- card .: "key"
key <- card .: "fields"
fields <- fields .: "summary"
projectName <- M.fromList <$> mapM parser (HM.toList fields)
allPossiblePeople pure $ \feild -> ProjectCard key projectName $ fromMaybe [] $ join $ lookup feild allPossiblePeople
parser :: FromJSON a => (Text, Value) -> Parser (Text, Maybe [a])
= (x,) <$> (parseJSON field <|> fmap (replicate 1) <$> parseJSON field) <|> pure (x, Nothing) parser (x, field)
UPD. KnownSymbol
to the rescue.
Friend of mine suggested a nice idea of using KnownSymbol
constraints for the Aeson
instances.
instance KnownSymbol key => FromJSON (ProjectCard key) where
= withObject "card" $ \card -> do
parseJSON let name = symbolVal (Proxy @key)
<- card .: "key"
key <- card .: "fields"
fields <- fields .: "summary"
projectName <- fields .:? fromString name <|> fmap (replicate 1) <$> fields .:? fromString name
peopleMaybe pure $ ProjectCard key projectName $ fromMaybe [] peopleMaybe
Turns out, you can build a Symbol
by a runtime value.
<- Jira.obtainFieldId jiraConfig jiraField
fieldId SomeSymbol (Proxy :: Proxy key) <- pure $ someSymbolVal $ toString fieldId
<- Jira.projectCards (Jira.Field @key jiraField fieldId) jiraConfig projectCards
Unfortunately, JIRA API has to split because additional type parameter prevents servant
’s client
function to have same type in both handlers.
type JiraInternalAPI = "rest" :> "api" :> "latest" :> "field" :> Verb 'GET 200 '[JSON] [JiraField]
type JiraAPI (key :: Symbol) =
"rest" :> "api" :> "latest" :> "search" :> RequiredParam "jql" Text :> RequiredParam "fields" Text :> RequiredParam "maxResults" Int :> Verb 'GET 200 '[JSON] (SearchResult key)
UPD 2. The more you know…
reflection package provides a handy Given
constraint, which allows to (with help of FlexibleContexts
and UndecidableInstances
) not worry about additional type parameters in your data types:
instance Given String => FromJSON ProjectCard where
= withObject "card" $ \card -> do
parseJSON <- card .: "key"
key <- card .: "fields"
fields <- fields .: "summary"
projectName <- fields .:? fromString given <|> fmap (replicate 1) <$> fields .:? fromString given
peopleMaybe pure $ ProjectCard key projectName $ fromMaybe [] peopleMaybe
Upon usage, you just provide what was claimed as Given
, which makes GHC happy.
projectCards :: Text -> JiraConfig -> IO [ProjectCard]
@JiraConfig {..} = do
projectCards fieldName config<- obtainFieldId config fieldName
fieldId $ cards <$> give (toString fieldId) searchForIssuesUsingJql (replace "{fieldName}" fieldName jql) (fieldId <> ",summary") 1000 runClient config
That (and NIX flakes of course) allowed me to create several binaries for each JIRA field to test against (instead of configuring it with terminal flags or environment variables). Being tired of typing T.pack
and T.unpack
, I decided to give a relude a try – a custom prelude, which is quite nice to use (but I haven’t yet tried rio or universum).
{
configs = "technical-cordinator" = p:
"Technical Coordinator" "Tech Coordinators";
p "cto-office-representative" = p:
"CTO Office Representative" "CTO Office";
p "project-manager" = p: p "Project manager" "Managers All";
...};
"coorish" ./. { };
basePackage = haskellPackages.callCabal2nix
(name: field: groups:
package = (drv: {
basePackage.overrideDerivation pname = "coorish-${name}";
buildInputs = drv.buildInputs or [ ] ++ [ pkgs.makeWrapper ];
postInstall = ''
mv $out/bin/coorish-console $out/bin/coorish-${name}
rm $out/bin/coorish-server
wrapProgram $out/bin/coorish-${name} --set COORISH_JIRA_FIELD "${field}" --set COORISH_LDAP_GROUPS "${groups}"
'';
}));
Server
But having only console utilities are not useful for other people. Sometimes non-technical personnel wants to know “what is wrong” with project cards. So I decided to split the code into three parts:
- Library code, which does all the heavy lifting, but free of any presentation logic
- Console application to display results in terminal (nix will build multiple binaries per config value)
- Web server which executes all queries to JIRA and AD in concurrently and serves result on a single page
Since web server needs all configs at once, I am concatenating everything together into flatConfig
variable to pass it into coorish-server
wrapper.
"coorish" ./coorish { };
basePackage = haskellPackages.callCabal2nix "console" ./console { coorish = basePackage; };
basePackageConsole = haskellPackages.callCabal2nix "server" ./server { coorish = basePackage; };
basePackageServer = haskellPackages.callCabal2nix
(builtins.concatStringsSep ";"
flatConfig = (map (f: f (a: b: "${a}=${b}")) (lib.attrValues configs)));
(drv: {
server = basePackageServer.overrideDerivation pname = "coorish-server";
buildInputs = drv.buildInputs or [ ] ++ [ pkgs.makeWrapper ];
postInstall = ''
wrapProgram $out/bin/coorish-server --set COORISH_SERVER_CONFIG "${flatConfig}"
'';
});
Plans
I am also experimenting with generating a haskell data structure (with template-haskell) with fields, which would correspond to a JIRA project card on compile time.
createConstant :: Q [Dec]
= do
createConstant <- newName "ProjectCard"
cardTypeName <- newName "ProjectCard"
cardConsName =<< mapM process =<< runIO fields
declare cardTypeName cardConsName where
process :: JiraField -> Q VarBangType
= do
process jf <- newName $ T.unpack $ T.replace " " "" $ T.toLower $ jiraFieldName jf
jName <- fromJust <$> lookupTypeName (T.unpack $ T.replace "Value" "" $ T.replace "Multiple " "" $ T.replace "Single " "" $ T.pack $ show $ jiraFieldType jf)
jType pure (jName, Bang NoSourceUnpackedness NoSourceStrictness, AppT ListT (ConT jType))
declare :: Name -> Name -> [VarBangType] -> Q [Dec]
= do
declare cardTypeName cardConsName z pure [DataD [] cardTypeName [] Nothing [RecC cardConsName z] [DerivClause Nothing [ConT ''Show, ConT ''Generic, ConT ''FromJSON]]]
data FieldTypePlurality = IssueKey
| Single FieldTypeKind
| Multiple FieldTypeKind
| UnknownField deriving
Generic, FromJSON, Show, Eq)
(
data FieldTypeKind = UserValue
| GroupValue
| StringValue
| DateValue
| DateTimeValue
| OptionValue
| NumberValue
| AutocompleteValue
deriving (Generic, FromJSON, Show, Eq)
data JiraField = JiraField
jiraFieldId :: Text
{ jiraFieldName :: Text
, jiraFieldType :: FieldTypePlurality
,
}deriving (Generic, Show)
instance FromJSON JiraField where
= withObject "field" $ \field -> do
parseJSON id <- field .: "id"
<- field .: "name"
name <-
config if id == "issuekey"
then pure IssueKey
else parseSchema =<< field .:? "schema"
pure $ JiraField id name config
where
parseSchema :: Maybe Object -> Parser FieldTypePlurality
Nothing = pure UnknownField
parseSchema Just schema) = parseType schema =<< schema .: "type"
parseSchema (
parseType :: Object -> Text -> Parser FieldTypePlurality
"user" = pure $ Single UserValue
parseType _ "number" = pure $ Single NumberValue
parseType _ "date" = pure $ Single DateValue
parseType _ "datetime" = pure $ Single DateTimeValue
parseType _ "option" = pure $ Single OptionValue
parseType _ "string" = pure $ Single StringValue
parseType _ "array" = parseArray <$> schema .: "items"
parseType schema "any" = parseCustom <$> schema .: "custom"
parseType schema = pure UnknownField
parseType _ _
parseArray :: Text -> FieldTypePlurality
"user" = Multiple UserValue
parseArray "group" = Multiple GroupValue
parseArray "option" = Multiple OptionValue
parseArray "string" = Multiple StringValue
parseArray = UnknownField
parseArray _
parseCustom :: Text -> FieldTypePlurality
"com.itransition.jira.plugin.customfields.jira-custom-fields:singlecomplete" = Single AutocompleteValue
parseCustom "com.itransition.jira.plugin.customfields.jira-custom-fields:typeaheadfield" = Multiple AutocompleteValue
parseCustom = UnknownField parseCustom _
That would allow to express programs “around” project cards in “their” language and not hardcode field names or IDs into NIX build configs. But the experiment is far from end…