From 26e0bbbeb61ee6313b2ed67a6e90d10f9c4990da Mon Sep 17 00:00:00 2001 From: Eduardo Trujillo <ed@chromabits.com> Date: Sat, 26 Dec 2020 22:43:20 -0800 Subject: [PATCH] feat(GitLab): Add support for looking up GitLab users --- package.yaml | 5 +- shift.cabal | 11 +++- src/GitLab/UserSearch.hs | 124 +++++++++++++++++++++++++++++++++++++++ src/Shift/CLI.hs | 37 +++++++++++- src/Shift/Git.hs | 31 ++++++++++ src/Shift/Types.hs | 65 +++++++++++++++++++- 6 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 src/GitLab/UserSearch.hs diff --git a/package.yaml b/package.yaml index 547285c..93c6627 100644 --- a/package.yaml +++ b/package.yaml @@ -42,6 +42,8 @@ dependencies: - semigroups - cmark-gfm - scotty + - gitlab-haskell + - http-types library: source-dirs: @@ -58,10 +60,11 @@ library: - Shift.Types - Shift.Utilities - GitHub.UserSearch + - GitLab.UserSearch other-modules: [] executables: - dab: + shift: source-dirs: app main: Main.hs dependencies: diff --git a/shift.cabal b/shift.cabal index 80a0383..c53d040 100644 --- a/shift.cabal +++ b/shift.cabal @@ -35,6 +35,7 @@ library Shift.Types Shift.Utilities GitHub.UserSearch + GitLab.UserSearch hs-source-dirs: src ghc-options: -Wall @@ -47,9 +48,11 @@ library , data-default , exceptions , github + , gitlab-haskell , hgit , http-client , http-client-tls + , http-types , io-memoize , lens , megaparsec @@ -66,7 +69,7 @@ library , versions default-language: Haskell2010 -executable dab +executable shift main-is: Main.hs other-modules: Paths_shift @@ -82,9 +85,11 @@ executable dab , data-default , exceptions , github + , gitlab-haskell , hgit , http-client , http-client-tls + , http-types , io-memoize , lens , megaparsec @@ -118,9 +123,11 @@ test-suite doctest , doctest , exceptions , github + , gitlab-haskell , hgit , http-client , http-client-tls + , http-types , io-memoize , lens , megaparsec @@ -156,12 +163,14 @@ test-suite spec , data-default , exceptions , github + , gitlab-haskell , hgit , hspec , hspec-discover , hspec-megaparsec , http-client , http-client-tls + , http-types , io-memoize , lens , megaparsec diff --git a/src/GitLab/UserSearch.hs b/src/GitLab/UserSearch.hs new file mode 100644 index 0000000..fd53da9 --- /dev/null +++ b/src/GitLab/UserSearch.hs @@ -0,0 +1,124 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} + +module GitLab.UserSearch where + +import qualified Control.Exception as Exception +import Control.Monad.IO.Class (MonadIO (liftIO)) +import Control.Monad.Reader (asks) +import Data.Aeson (FromJSON, eitherDecode) +import qualified Data.ByteString.Lazy as BSL +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as T +import GitLab.Types + ( GitLab, + GitLabServerConfig (retries, timeout, token, url), + GitLabState (httpManager, serverCfg), + User, + ) +import Network.HTTP.Client + ( HttpException, + Manager, + Request (requestHeaders, responseTimeout), + Response (responseBody, responseHeaders, responseStatus), + httpLbs, + parseRequest_, + responseTimeoutMicro, + ) +import Network.HTTP.Types (urlEncode) +import Network.HTTP.Types.Status (Status (Status)) +import Text.Read (readMaybe) + +newtype GitLabUserSearchException = GitLabUserSearchException String + deriving (Eq, Show) + +instance Exception.Exception GitLabUserSearchException + +-- | Searches for a user given a Username or Email. Returns @Just User@ if the +-- user is found, otherwise @Nothing@. +searchUsers :: + -- | User to search for (Username, Email, etc) + Text -> + GitLab [User] +searchUsers query = do + let path = "/search" + attrs = "&scope=users&search=" <> query + res <- gitlabReqJsonMany path attrs + case res of + Left _ -> return [] + Right users -> return users + +gitlabReqJsonMany :: (FromJSON a) => Text -> Text -> GitLab (Either Status [a]) +gitlabReqJsonMany urlPath attrs = + go 1 [] + where + go i accum = do + cfg <- asks serverCfg + manager <- asks httpManager + let url' = + url cfg + <> "/api/v4" + <> urlPath + <> "?per_page=100" + <> "&page=" + <> T.pack (show i) + <> T.decodeUtf8 (urlEncode False (T.encodeUtf8 attrs)) + let request' = parseRequest_ (T.unpack url') + request = + request' + { requestHeaders = + [("PRIVATE-TOKEN", T.encodeUtf8 (token cfg))], + responseTimeout = responseTimeoutMicro (timeout cfg) + } + -- here + resp <- liftIO $ tryGitLab 0 request (retries cfg) manager Nothing + if successStatus (responseStatus resp) + then do + moreResults <- liftIO $ parseBSMany (responseBody resp) + let numPages = totalPages resp + accum' = accum ++ moreResults + if numPages == i + then return (Right accum') + else go (i + 1) accum' + else return (Left (responseStatus resp)) + +tryGitLab :: + -- | the current retry count + Int -> + -- | the GitLab request + Request -> + -- | maximum number of retries permitted + Int -> + -- | HTTP manager + Manager -> + -- | the exception to report if maximum retries met + Maybe HttpException -> + IO (Response BSL.ByteString) +tryGitLab i request maxRetries manager lastException + | i == maxRetries = error (show lastException) + | otherwise = + httpLbs request manager + `Exception.catch` \ex -> tryGitLab (i + 1) request maxRetries manager (Just ex) + +totalPages :: Response a -> Int +totalPages resp = + let hdrs = responseHeaders resp + in findPages hdrs + where + findPages [] = 1 -- error "cannot find X-Total-Pages in header" + findPages (("X-Total-Pages", bs) : _) = + case readMaybe (T.unpack (T.decodeUtf8 bs)) of + Just s -> s + Nothing -> error "cannot find X-Total-Pages in header" + findPages (_ : xs) = findPages xs + +successStatus :: Status -> Bool +successStatus (Status n _msg) = + n >= 200 && n <= 226 + +parseBSMany :: FromJSON a => BSL.ByteString -> IO [a] +parseBSMany bs = + case eitherDecode bs of + Left s -> Exception.throwIO $ GitLabUserSearchException s + Right xs -> return xs diff --git a/src/Shift/CLI.hs b/src/Shift/CLI.hs index 50724c5..eee8e7d 100644 --- a/src/Shift/CLI.hs +++ b/src/Shift/CLI.hs @@ -32,7 +32,11 @@ data ShiftOptions = ShiftOptions _soToRef :: Maybe String, _soGitHubOwner :: Maybe String, _soGitHubRepository :: Maybe String, - _soGitHubToken :: Maybe String + _soGitHubToken :: Maybe String, + _soGitLabOwner :: Maybe String, + _soGitLabRepository :: Maybe String, + _soGitLabServer :: Maybe String, + _soGitLabToken :: Maybe String } deriving (Show, Eq) @@ -41,7 +45,7 @@ data ShiftCommand | ServeCommand {_scPort :: Maybe Int} deriving (Show, Eq) -data HostingType = GitHubType | GitType deriving (Show, Eq, Enum) +data HostingType = GitHubType | GitLabType | GitType deriving (Show, Eq, Enum) shiftOptions :: Parser ShiftOptions shiftOptions = @@ -93,6 +97,34 @@ shiftOptions = <> help "GitHub access token" ) ) + <*> optional + ( strOption + ( long "gitlab-owner" + <> metavar "USERNAME" + <> help "Username who owns the repository" + ) + ) + <*> optional + ( strOption + ( long "gitlab-repository" + <> metavar "REPOSITORY" + <> help "Name of the repository" + ) + ) + <*> optional + ( strOption + ( long "gitlab-server" + <> metavar "TOKEN" + <> help "GitLab Server URL" + ) + ) + <*> optional + ( strOption + ( long "gitlab-token" + <> metavar "TOKEN" + <> help "GitLab access token" + ) + ) shiftCommand :: Parser ShiftCommand shiftCommand = @@ -124,6 +156,7 @@ shiftCommand = hostingType :: ReadM HostingType hostingType = eitherReader $ \case "github" -> Right GitHubType + "gitlab" -> Right GitLabType "git" -> Right GitType x -> Left $ "`" ++ x ++ "` is not a supported hosting type" diff --git a/src/Shift/Git.hs b/src/Shift/Git.hs index 84dbcdd..c0c9afa 100644 --- a/src/Shift/Git.hs +++ b/src/Shift/Git.hs @@ -37,6 +37,7 @@ import Data.Tuple (swap) import Data.Versions (versioning) import Data.Void (Void) import GitHub.Auth (Auth (OAuth)) +import GitLab (GitLabServerConfig (token, url), defaultGitLabServer) import Network.HTTP.Client (newManager) import Network.HTTP.Client.TLS (tlsManagerSettings) import Shift.CLI @@ -60,6 +61,13 @@ renderToNode opts = withRepo ".git" $ \repo -> do GitHubType -> do state <- initGitHubState + fst + <$> runReaderT + (runStateT (mapM (renderDiff repo) pairedTags) state) + opts + GitLabType -> do + state <- initGitLabState + fst <$> runReaderT (runStateT (mapM (renderDiff repo) pairedTags) state) @@ -89,6 +97,29 @@ renderToNode opts = withRepo ".git" $ \repo -> do _gcsOwner = cs repositoryOwner, _gcsRepository = cs repositoryName } + initGitLabState = do + manager <- newManager tlsManagerSettings + + server <- (opts ^. soGitLabServer) `orError` SEMissingGitLabServer + token' <- (opts ^. soGitLabToken) `orError` SEMissingGitLabToken + repositoryOwner <- (opts ^. soGitLabOwner) `orError` SEMissingGitLabOwner + repositoryName <- + (opts ^. soGitLabRepository) + `orError` SEMissingGitLabRepository + + pure + GitLabClientState + { _glcsCache = def, + _glcsServerCfg = + ( defaultGitLabServer + { url = cs server, + token = cs token' + } + ), + _glcsHttpManager = manager, + _glcsOwner = cs repositoryOwner, + _glcsRepository = cs repositoryName + } pairedTagsFromOpts :: HashAlgorithm hash => ShiftOptions -> Git hash -> IO [(TagRef, TagRef)] pairedTagsFromOpts opts repo = case opts of diff --git a/src/Shift/Types.hs b/src/Shift/Types.hs index 34340c4..69f745d 100644 --- a/src/Shift/Types.hs +++ b/src/Shift/Types.hs @@ -31,6 +31,7 @@ import Data.Git ) import Data.HashMap.Strict (HashMap, insert, lookup) import Data.HashSet (HashSet) +import Data.Maybe (fromMaybe) import Data.String.Conversions (cs) import Data.Text (Text) import qualified Data.Vector as V @@ -46,6 +47,8 @@ import GitHub.Data.Search ) import GitHub.Endpoints.Repos.Commits (commitR) import GitHub.UserSearch (searchUsersR, urHtmlUrl, urLogin) +import GitLab (GitLabServerConfig (url), User (user_username, user_web_url), runGitLab) +import GitLab.UserSearch as GLUS (searchUsers) import Network.HTTP.Client (Manager) import Shift.CLI (ShiftOptions) import Shift.Utilities (orThrow) @@ -70,6 +73,10 @@ data ShiftException | SEMissingGitHubToken | SEMissingGitHubOwner | SEMissingGitHubRepository + | SEMissingGitLabToken + | SEMissingGitLabOwner + | SEMissingGitLabServer + | SEMissingGitLabRepository deriving (Show) instance Exception ShiftException @@ -212,7 +219,7 @@ instance ClientState GitHubClientState where case lookup email cache of Just hit -> pure hit Nothing -> do - result <- (lookupUserOnGitHub email) + result <- lookupUserOnGitHub email result2 <- case result of Nothing -> lookupUserOnGitHubCommit ref @@ -243,7 +250,7 @@ lookupUserOnGitHub :: lookupUserOnGitHub email = do results <- executeRequest_ $ searchUsersR email - case (searchResultTotalCount results == 1) of + case searchResultTotalCount results == 1 of True -> liftIO $ do let user = V.head $ searchResultResults results @@ -262,6 +269,60 @@ executeRequest_ x = do orThrow result +data GitLabClientState = GitLabClientState + { _glcsCache :: RepositoryCache, + _glcsServerCfg :: GitLabServerConfig, + _glcsHttpManager :: Manager, + _glcsOwner :: Text, + _glcsRepository :: Text + } + +makeLenses ''GitLabClientState + +instance ClientState GitLabClientState where + getRefURL ref = do + serverCfg <- gets (view glcsServerCfg) + owner <- gets (view glcsOwner) + repositoryName <- gets (view glcsRepository) + + pure . Just . mconcat $ + [ url serverCfg, + "/", + owner, + "/", + repositoryName, + "/-/commit/", + cs . show $ ref + ] + + getAuthorInfo person _ = do + serverCfg <- gets (view glcsServerCfg) + + let email = cs $ personEmail person + + cache <- gets (view $ glcsCache . rcAuthorInfos) + + case lookup email cache of + Just hit -> pure hit + Nothing -> do + users <- liftIO $ runGitLab serverCfg $ GLUS.searchUsers email + + case users of + (user : _) -> do + let result = + Just + ( user_username user, + fromMaybe (url serverCfg <> "/" <> user_username user) (user_web_url user) + ) + + assign (glcsCache . rcAuthorInfos) (insert email result cache) + + pure result + _ -> do + assign (glcsCache . rcAuthorInfos) (insert email Nothing cache) + + pure Nothing + makeLenses ''ConventionalCommit makeLenses ''ChangeReport makeLenses ''BreakingChange -- GitLab