packages feed

fbrnch-0.9.1: src/Bugzilla.hs

{-# LANGUAGE OverloadedStrings #-}

module Bugzilla (
  Bug(..),
  User(..),
  BugId,
  -- session
  BugzillaSession,
  bugIdsSession,
  bugsSession,
  bzLoginSession,
  bzReviewSession,
  reviewBugIdSession,
  approvedReviewBugIdSession,
  approvedReviewBugSession,
  pkgBugs,
  pkgReviews,
  listBzUsers,
  emailIsValid,
--  testBZlogin,
  -- search
  searchBugs,
  (.&&.),
  not',
  packageReview,
  assigneeIs,
  bugIdIs,
  reporterIs,
  reviewApproved,
  statusNewAssigned,
  statusNewPost,
  statusNewModified,
  statusOpen,
  summaryContains,
  -- comments
  Comment,
  checkForComment,
  checkRepoCreatedComment,
  createBug,
  commentBug,
  updateBug,
  putBugBuild,
  showComment,
  commentText,
  getComments,
  --
  reviewBugToPackage,
  sortBugsByID,
  sortBugsByProduct,
  sortBugsByStatus,
  -- output
  putBug,
  putBugVer,
  putReviewBug,
  putBugId,
  -- request
  newBzRequest,
  intAsText,
  makeTextItem
  ) where

import Common
import Common.System
import qualified Common.Text as T
import Prompt

import Control.Exception (finally)
import Data.Aeson.Types (Array, Object)
import qualified Data.ByteString.Char8 as B
import Data.ByteString.UTF8
import Data.Ini.Config
import Network.HTTP.Conduit
import Network.HTTP.Query
import Network.HTTP.Simple
import System.Environment
import System.Environment.XDG.BaseDir
import System.IO (hSetEcho, stdin)
import qualified Text.Email.Validate as Email
import Web.Bugzilla.RedHat
import Web.Bugzilla.RedHat.Search

createBug :: BugzillaSession -> [(String,String)] -> IO Int
createBug session params = do
  let req = -- posting url encoded utf8 to bugzilla only seems to work in body
            urlEncodedBody (encodeParams params) $
            setRequestCheckStatus $
            newBzRequest session ["bug"] []
  lookupKey' "id" . getResponseBody <$> httpJSON req

commentBug :: BugzillaSession -> BugId -> String -> IO ()
commentBug session bid comment = do
  let req = setRequestMethod "POST" $
            -- earlier posting url encoded utf8 only seemed to work in body
            urlEncodedBody (encodeParams [("comment", comment)]) $
            setRequestCheckStatus $
            newBzRequest session (map T.pack ["bug",show bid,"comment"]) []
  res <- getResponseBody <$> httpJSON req
  -- [("id",Number 1.4682731e7)]
  when (isNothing (lookupKey "id" res :: Maybe Int)) $ do
    -- eg [("error",Bool True),("documentation",String "https://bugzilla.redhat.com/docs/en/html/api/index.html"),("code",Number 32614.0),("message",String "A REST API resource was not found for 'POST /bug/1880903'.")]
    case lookupKey "message" res of
      Nothing -> print res
      Just msg -> T.putStrLn msg
    error' $ "failed to update bug " ++ show bid
  putStrLn "Comment added"

updateBug :: BugzillaSession -> BugId -> [(String,String)] -> IO ()
updateBug session bid params = do
  let req = setRequestMethod "PUT" $
            -- earlier posting url encoded utf8 only seemed to work in body
            urlEncodedBody (encodeParams params) $
            setRequestCheckStatus $
            newBzRequest session (map T.pack ["bug",show bid]) []
  res <- getResponseBody <$> httpJSON req
  -- [("bugs",Array [Object (fromList [("changes",Object (fromList [])),("alias",Array []),("id",Number 1897441.0),("last_change_time",String "2021-01-24T08:20:18Z")])])]
  when (isNothing (lookupKey "bugs" res :: Maybe Array)) $ do
    -- eg [("error",Bool True),("documentation",String "https://bugzilla.redhat.com/docs/en/html/api/index.html"),("code",Number 32614.0),("message",String "A REST API resource was not found for 'POST /bug/1880903'.")]
    case lookupKey "message" res of
      Nothing -> print res
      Just msg -> T.putStrLn msg
    error' $ "failed to update bug " ++ show bid

encodeParams :: [(String, String)] -> [(ByteString, ByteString)]
encodeParams [] = []
encodeParams ((k,v):ps) =
  (B.pack k, fromString v) : encodeParams ps

putBugBuild :: Bool -> BugzillaSession -> BugId -> String -> IO ()
putBugBuild dryrun session bid nvr = do
  unless dryrun $
    void $ updateBug session bid
    [("cf_fixed_in", nvr), ("status", "MODIFIED")]
  putStrLn $ "build posted to review bug " ++ show bid

brc :: T.Text
brc = "bugzilla.redhat.com"

bzReviewSession :: IO (Maybe BugId,BugzillaSession)
bzReviewSession = do
  pkg <- getDirectoryName
  (bids,session) <- bugIdsSession $
                    pkgReviews pkg .&&. statusOpen .&&. reviewApproved
  case bids of
    [bid] -> return (Just bid, session)
    _ -> return (Nothing, session)

newtype BzUserRC = BzUserRC {rcUserEmail :: UserEmail}
  deriving (Eq, Show)

emailIsValid :: String -> Bool
emailIsValid = Email.isValid . B.pack

-- FIXME support bugzilla API key
bzLoginSession :: IO (BugzillaSession, UserEmail)
bzLoginSession = do
  user <- getBzUser
  ctx <- newBugzillaContext brc
  session <- getBzLoginSession ctx user
  return (session,user)
  where
    getBzUser :: IO UserEmail
    getBzUser = do
      home <- getEnv "HOME"
      let rc = home </> ".bugzillarc"
      -- FIXME assumption if file exists then it has b.r.c user
      ifM (doesFileExist rc)
        (readIniConfig rc rcParser rcUserEmail) $
        do
        -- FIXME: option to override email
        email <- prompt "Bugzilla Username"
        when (emailIsValid email) $ do
          T.writeFile rc $ "[" <> brc <> "]\nuser = " <> T.pack email <> "\n"
          putStrLn $ "Saved in " ++ rc
        getBzUser
      where
        rcParser :: IniParser BzUserRC
        rcParser =
          section brc $
          BzUserRC <$> fieldOf "user" string

    getBzLoginSession :: BugzillaContext -> UserEmail -> IO BugzillaSession
    getBzLoginSession ctx user = do
      cache <- getUserCacheFile "python-bugzilla" "bugzillatoken"
      let cacheDir = takeDirectory cache
      cacheDirExists <- doesDirectoryExist cacheDir
      unless cacheDirExists $ createDirectory cacheDir
      tokenstatus <- ifM (notM (doesFileExist cache)) (return NoToken) $
        do
        token <- readIniConfig cache rcParser bzToken
        let session = LoginSession ctx $ BugzillaToken token
        let validreq = setRequestCheckStatus $
                       newBzRequest session ["valid_login"] [("login",Just user)]
        valid <- lookupKey' "result" . getResponseBody <$> httpJSON validreq
        return $ if valid then ValidToken session else InvalidToken token
      case tokenstatus of
        ValidToken session -> return session
        InvalidToken oldtoken -> do
          token <- bzLogin
          cmd_ "sed" ["-i", "s/" ++ T.unpack oldtoken ++ "/" ++ T.unpack token ++ "/", cache]
          return $ LoginSession ctx $ BugzillaToken token
        NoToken -> do
          token <- bzLogin
          T.writeFile cache $ "[" <> brc <> "]\ntoken = " <> token <> "\n"
          putStrLn $ "Saved in " ++ cache
          return $ LoginSession ctx $ BugzillaToken token
      where
        rcParser :: IniParser BzTokenConf
        rcParser =
          section brc $
          BzTokenConf <$> fieldOf "token" string

        bzLogin :: IO T.Text
        bzLogin = do
          putStrLn "No valid bugzilla login token, please login:"
          passwd <- withoutEcho $ prompt "Bugzilla Password"
          if null passwd then bzLogin
            else do
            let anonsession = AnonymousSession ctx
                tokenReq = setRequestCheckStatus $
                           newBzRequest anonsession ["login"]
                           [("login", Just user),
                            makeTextItem "password" passwd]
            res <- getResponseBody <$> httpJSON tokenReq
            case lookupKey "token" res of
              Nothing -> T.putStrLn (lookupKey' "message" res) >> bzLogin
              Just token -> return token

        withoutEcho :: IO a -> IO a
        withoutEcho action =
          finally (hSetEcho stdin False >> action) (hSetEcho stdin True)

newtype BzTokenConf = BzTokenConf {bzToken :: T.Text}
  deriving (Eq, Show)

data BzTokenStatus = ValidToken BugzillaSession | InvalidToken T.Text | NoToken

bugIdIs :: BugId -> SearchExpression
bugIdIs bid = BugIdField .==. bid

reporterIs :: T.Text -> SearchExpression
reporterIs = (ReporterField .==.)

assigneeIs :: T.Text -> SearchExpression
assigneeIs = (AssignedToField .==.)

packageReview :: SearchExpression
packageReview =
  ComponentField .==. ["Package Review"]

statusOpen :: SearchExpression
statusOpen =
  StatusField ./=. "CLOSED"

statusNewAssigned :: SearchExpression
statusNewAssigned =
  StatusField `equalsAny` ["NEW", "ASSIGNED"]

statusNewPost :: SearchExpression
statusNewPost =
  StatusField `equalsAny` ["NEW", "ASSIGNED", "POST"]

statusNewModified :: SearchExpression
statusNewModified =
  StatusField `equalsAny` ["NEW", "ASSIGNED", "POST", "MODIFIED"]

reviewApproved :: SearchExpression
reviewApproved =
  FlagsField `contains` "fedora-review+"

pkgReviews :: String -> SearchExpression
pkgReviews pkg =
  SummaryField `contains` T.pack ("Review Request: " ++ pkg ++ " - ") .&&.
  packageReview

pkgBugs :: String -> SearchExpression
pkgBugs pkg =
  ComponentField .==. [T.pack pkg]

summaryContains :: String -> SearchExpression
summaryContains keywrd =
  SummaryField `contains` T.pack keywrd

bugIdsSession :: SearchExpression -> IO ([BugId],BugzillaSession)
bugIdsSession query = do
  (session,_) <- bzLoginSession
  bugs <- searchBugs' session query
  return (bugs, session)

bugsSession :: SearchExpression -> IO ([Bug],BugzillaSession)
bugsSession query = do
  (session,_) <- bzLoginSession
  bugs <- searchBugs session query
  return (bugs, session)

reviewBugIdSession :: String -> IO (BugId,BugzillaSession)
reviewBugIdSession pkg = do
  (bugs,session) <- bugIdsSession $ pkgReviews pkg .&&. statusOpen
  case bugs of
    [] -> error $ "No review bug found for " ++ pkg
    [bug] -> return (bug, session)
    _ -> error' "more than one review bug found!"

approvedReviewBugIdSession :: String -> IO (BugId,BugzillaSession)
approvedReviewBugIdSession pkg = do
  (bugs,session) <- bugIdsSession $
                    pkgReviews pkg .&&. statusOpen .&&. reviewApproved
  case bugs of
    [] -> error $ "No review bug found for " ++ pkg
    [bug] -> return (bug, session)
    _ -> error' "more than one review bug found!"

approvedReviewBugSession :: String -> IO (Bug,BugzillaSession)
approvedReviewBugSession pkg = do
  (bugs,session) <- bugsSession $
                    pkgReviews pkg .&&. statusOpen .&&. reviewApproved
  case bugs of
    [] -> error $ "No review bug found for " ++ pkg
    [bug] -> return (bug, session)
    _ -> error' "more than one review bug found!"

reviewBugToPackage :: Bug -> String
reviewBugToPackage =
  head . dropOne ["Request:"] . dropOne ["Review", "Re-Review", "Rename"]  . words . T.unpack . bugSummary
  where
    dropOne _ [] = []
    dropOne ks as@(w:ws) = if w `elem` ks then ws else as

readIniConfig :: FilePath -> IniParser a -> (a -> b) -> IO b
readIniConfig inifile iniparser fn = do
  ini <- T.readFile inifile
  return $ either error fn $ parseIniFile ini iniparser

sortBugsByID :: [Bug] -> [Bug]
sortBugsByID = sortOn bugId

sortBugsByStatus :: [Bug] -> [Bug]
sortBugsByStatus = sortOn (bugStatusEnum . bugStatus)

sortBugsByProduct :: [Bug] -> [Bug]
sortBugsByProduct = sortOn bugProduct

-- FIXME make datatype
bugStatusEnum :: T.Text -> Int
bugStatusEnum st =
  case st of
    "NEW" -> 0
    "ASSIGNED" -> 1
    "POST" -> 2
    "MODIFIED" -> 3
    "ON_QA" -> 4
    "VERIFIED" -> 5
    "RELEASE_PENDING" -> 6
    "CLOSED" -> 7
    _ -> -1

showComment :: Comment -> IO ()
showComment cmt = do
  -- comment0 from fedora-create-review has leading newline
  T.putStrLn $ "(Comment " <> intAsText (commentCount cmt) <> ") <" <> commentCreator cmt <> "> " <> (T.pack . show) (commentCreationTime cmt) <> "\n"
  mapM_ (T.putStrLn . ("  " <>)) $ dropDuplicates . removeLeadingNewline . T.lines $ commentText cmt
  putStrLn ""

checkRepoCreatedComment :: BugzillaSession -> BugId -> IO Bool
checkRepoCreatedComment session bid =
    checkForComment session bid
      "(fedscm-admin):  The Pagure repository was created at"

checkForComment :: BugzillaSession -> BugId -> T.Text -> IO Bool
checkForComment session bid text = do
    comments <- map commentText <$> getComments session bid
    return $ any (text `T.isInfixOf`) $ reverse comments

putReviewBug :: Bool -> Bug -> IO ()
putReviewBug short bug = do
  if short then putStr $ reviewBugToPackage bug ++ " "
    else do
    putStrLn $ reviewBugToPackage bug ++ " (" ++ T.unpack (bugStatus bug) ++ ")"
    putBugId $ bugId bug
    putStrLn ""

putBug :: Bug -> IO ()
putBug bug = do
  T.putStrLn $ bugSummary bug <> " (" <> bugStatus bug <> ")"
  putBugId $ bugId bug
  putStrLn ""

putBugVer :: Bug -> IO ()
putBugVer bug = do
  T.putStr $ "[" <> prodVersion <> "] "
  putBug bug
  where
    prodVersion = T.unwords (bugVersion bug)

putBugId :: BugId -> IO ()
putBugId =
  T.putStrLn . (("https://" <> brc <> "/show_bug.cgi?id=") <>) . intAsText

-- uniq for lists
dropDuplicates :: Eq a => [a] -> [a]
dropDuplicates (x:xs) =
  let ys = dropDuplicates xs in
    case ys of
      (y:_) | x == y -> ys
      _ -> x:ys
dropDuplicates _ = []

removeLeadingNewline :: [T.Text] -> [T.Text]
removeLeadingNewline ("":ts) = ts
removeLeadingNewline ts = ts

-- testBZlogin :: IO ()
-- testBZlogin =
--   void bzLoginSession

-- | make a key-value
makeTextItem :: String -> String -> (T.Text, Maybe T.Text)
makeTextItem k val = (T.pack k, Just (T.pack val))

listBzUsers :: BugzillaSession -> String -> IO [Object]
listBzUsers session user = do
  let req = setRequestCheckStatus $
            newBzRequest session ["user"] [makeTextItem "match" user]
  lookupKey' "users" . getResponseBody <$> httpJSON req