packages feed

checkmate-0.3.0: app/Main.hs

{-# LANGUAGE OverloadedStrings #-}
import Control.Monad
import Data.List (elemIndices)
import Data.List.NonEmpty (nonEmpty, last)
import Data.Maybe
import Data.Semigroup ((<>))
import Prelude hiding (error)
import System.Environment
import System.Exit
import System.IO

import Data.Text
import Data.Text.Encoding
import Data.Text.IO as TIO
import Options.Applicative
import System.Directory
import System.Process hiding (cwd)

import Checkmate.Check
import Checkmate.Discover
import Checkmate.Publisher.GitHub
import Checkmate.Renderer

type InputReader = App -> IO Text
type CommandRunner = App -> Checklist -> IO ()

data Command = Command
    { inputReader :: InputReader
    , commandRunner :: CommandRunner
    }

data App = App
    { inputFilePath :: Maybe FilePath
    , appCommand :: Command
    }

withInputFile :: App -> (Handle -> IO r) -> IO r
withInputFile App { inputFilePath = Nothing } action' = do
    let i = stdin
    r <- action' i
    hClose i
    return r
withInputFile app@App { inputFilePath = Just "-" } action' =
    withInputFile (app { inputFilePath = Nothing }) action'
withInputFile App { inputFilePath = Just i } action' =
    withFile i ReadMode action'

readInputFile :: App -> IO Text
readInputFile = (`withInputFile` TIO.hGetContents)

readDiff :: App -> IO (Either String FileDeltas)
readDiff app@App{ appCommand = Command { inputReader = readInput } } =
    parseDiff <$> readInput app

listChecks :: App -> IO Checklist
listChecks app = do
    d <- readDiff app
    cwd <- getCurrentDirectory
    case d of
        Left msg -> die msg
        Right deltas -> discover cwd deltas

appP :: Parser App
appP = App
    <$> option (Just <$> str)
        (  long "input-file"
        <> short 'i'
        <> metavar "FILE"
        <> value Nothing
        <> help "A diff text to extract a checklist from"
        )
    <*> subparser (  command "commonmark" commonmarkPI
                  <> command "gfm" gfmPI
                  <> command "github" githubPI
                  <> command "github-circle" githubCirclePI
                  <> command "github-travis" githubTravisPI
                  )

commonmarkPI :: ParserInfo Command
commonmarkPI = info (pure $ Command readInputFile cmd) $
    progDesc "Print a checklist as CommonMark (i.e. Markdown) format."
  where
    cmd :: CommandRunner
    cmd _ checklist = do
        cwd <- getCurrentDirectory
        TIO.putStr $ toCommonMark cwd 1 checklist

gfmPI :: ParserInfo Command
gfmPI = info (pure $ Command readInputFile cmd) $
    progDesc "Print a checklist as GitHub Flavored Markdown format."
  where
    cmd :: CommandRunner
    cmd _ checklist = do
        cwd <- getCurrentDirectory
        TIO.putStr $ toGFMarkdown cwd 1 checklist

githubTokenOption :: Parser Token
githubTokenOption = option (encodeUtf8 . pack <$> str)
    (  long "access-token"
    <> long "token"
    <> short 't'
    <> metavar "TOKEN"
    <> help "GitHub OAuth access token"
    )

leaveGithubComment :: Maybe OwnerName
                   -> RepoName
                   -> PullRequestId
                   -> Token
                   -> Maybe Text
                   -> CommandRunner
leaveGithubComment owner' repo pr accessToken endpoint _ checklist = do
    cwd <- getCurrentDirectory
    r <- leaveComment owner' repo pr accessToken endpoint cwd checklist
    case r of
        Right Nothing -> return ()
        Right (Just (URL url)) -> TIO.putStrLn url
        Left e -> handleGithubError e

githubInputReader :: IO (Maybe OwnerName, RepoName, PullRequestId)
                  -> String
                  -> Token -> InputReader
githubInputReader identifier headEnvironKey accessToken (App Nothing _) = do
    (owner, repo, prId) <- identifier
    result <- pullRequestBaseSha owner repo prId accessToken Nothing
    base <- case result of
        Left e -> handleGithubError e
        Right sha -> return sha
    head' <- environ headEnvironKey
    let range = unpack base ++ ".." ++ head'
    diff <- readProcess "git" ["diff", range] ""
    return $ pack diff
githubInputReader _ _ _ app = readInputFile app

githubCommandRunner :: IO (Maybe OwnerName, RepoName, PullRequestId)
                    -> Token -> CommandRunner
githubCommandRunner identifier accessToken app checklist = do
    (owner, repo, prId) <- identifier
    leaveGithubComment owner repo prId accessToken Nothing app checklist

handleGithubError :: Checkmate.Publisher.GitHub.Error -> IO a
handleGithubError (HTTPError httpError) = printError $ pack $ show httpError
handleGithubError (ParseError message) = printError message
handleGithubError (JsonError message) = printError message
handleGithubError (UserError message) = printError message

githubPI :: ParserInfo Command
githubPI = info (parser <**> helper) $
    progDesc $ "Create a checklist comment on the corresponding pull " ++
               "reuqest on GitHub."
  where
    parser :: Parser Command
    parser = cmd
        <$> option (Just . mkOwnerName . pack <$> str)
            (  long "owner"
            <> long "login"
            <> short 'l'
            <> metavar "LOGIN"
            <> value Nothing
            <> help ("Owner of GitHub repository of a pull request to " ++
                     "create a checklist comment.  \"foo\" of " ++
                     "\"github.com/foo/bar\".  The currently authenticated " ++
                     "user (through -t/--access-token/--token) by default")
            )
        <*> option (mkRepoName . pack <$> str)
            (  long "repository"
            <> long "repo"
            <> short 'r'
            <> metavar "REPO"
            <> help ("Name of GitHub repository of a pull request to create " ++
                     "a checklist comment.  \"bar\" of \"github.com/foo/bar\"")
            )
        <*> option (mkPullRequestId <$> (auto :: ReadM Int))
            (  long "pull-request"
            <> long "pr"
            <> short 'p'
            <> metavar "NUM"
            <> help "No. of pull request to create a checklist comment"
            )
        <*> githubTokenOption
        <*> option (Just . dropWhileEnd (== '/') . pack <$> str)
            (  long "enterprise-endpoint"
            <> short 'e'
            <> metavar "URL"
            <> value Nothing
            <> help "API endpoint URL for GitHub Enterprise (if applicable)"
            )
    cmd :: Maybe OwnerName -> RepoName -> PullRequestId -> Token -> Maybe Text
        -> Command
    cmd owner repo prId token endpoint =
        Command readInputFile
                (leaveGithubComment owner repo prId token endpoint)

githubCirclePI :: ParserInfo Command
githubCirclePI = info (parser <**> helper) $
    progDesc $ "Create a checklist comment on the corresponding pull " ++
               "reuqest on GitHub from Circle CI. It depends on the " ++
               "following environment variables: CI_PULL_REQUEST, " ++
               "CIRCLE_PROJECT_REPONAME, CIRCLE_PROJECT_USERNAME, CIRCLE_SHA1"
  where
    parser :: Parser Command
    parser = fmap cmd githubTokenOption
    cmd :: Token -> Command
    cmd token = Command (githubInputReader identifier "CIRCLE_SHA1" token)
                        (githubCommandRunner identifier token)
    identifier :: IO (Maybe OwnerName, RepoName, PullRequestId)
    identifier = do
        pr <- environ "CI_PULL_REQUEST"
        case nonEmpty $ elemIndices '/' pr of
            Nothing -> do
                System.IO.hPutStrLn stderr "This is not a PR build; skipped..."
                exitSuccess  -- It shouldn't be marked as failure on CI builds
            Just slashes -> do
                let lastPart = Prelude.drop
                        (succ $ Data.List.NonEmpty.last slashes)
                        pr
                    prId = mkPullRequestId $ read lastPart
                owner <- environ "CIRCLE_PROJECT_USERNAME"
                repo <- environ "CIRCLE_PROJECT_REPONAME"
                return ( Just $ mkOwnerName $ pack owner
                       , mkRepoName $ pack repo
                       , prId
                       )

githubTravisPI :: ParserInfo Command
githubTravisPI = info (parser <**> helper) $
    progDesc $ "Create a checklist comment on the corresponding pull " ++
               "reuqest on GitHub from Travis CI. It depends on the " ++
               "following environment variables: TRAVIS_PULL_REQUEST, " ++
               "TRAVIS_PULL_REQUEST_SHA, TRAVIS_REPO_SLUG."
  where
    parser :: Parser Command
    parser = cmd <$> githubTokenOption
    cmd :: Token -> Command
    cmd token = Command
        (githubInputReader identifier "TRAVIS_PULL_REQUEST_SHA" token)
        (githubCommandRunner identifier token)
    identifier :: IO (Maybe OwnerName, RepoName, PullRequestId)
    identifier = do
        pr <- environ "TRAVIS_PULL_REQUEST"
        case pr of
            "false" -> do
                System.IO.hPutStrLn stderr "This is not a PR build; skipped..."
                exitSuccess  -- It shouldn't be marked as failure on CI builds
            _ -> do
                let prId = mkPullRequestId $ read pr
                slug <- pack <$> environ "TRAVIS_REPO_SLUG"
                let (o, r) = Data.Text.break (== '/') slug
                    owner = mkOwnerName o
                    repo = mkRepoName $ Data.Text.drop 1 r
                return (Just owner, repo, prId)

appPI :: ParserInfo App
appPI = info (appP <**> helper)
    (  fullDesc
    <> progDesc "Generate checklists relevant to a given patch."
    )

environ :: String -> IO String
environ name = do
    r <- lookupEnv name
    case r of
        Nothing -> printError $ pack name `append` " is not defined."
        Just v -> return v

printError :: Text -> IO a
printError message = do
    prog <- getProgName
    die $ prog ++ ": error: " ++ unpack message

main :: IO ()
main = do
    app@App { appCommand = Command { commandRunner = cmd } } <- execParser appPI
    checklist <- listChecks app
    cmd app checklist