{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ViewPatterns #-} module Krank.Checkers.IssueTracker ( GitIssue (..), GitServer (..), Localized (..), checkText, extractIssues, gitRepoRe, serverDomain, extractIssuesOnALine, ) where import Control.Exception.Safe (catch) import Data.Aeson ((.:), Value) import qualified Data.Aeson.Types as AesonT import qualified Data.ByteString.Char8 as ByteString import Data.ByteString.Char8 (ByteString) import qualified Data.Map as Map import Data.Text (Text, pack) import qualified Data.Text.Encoding as Text.Encoding import Krank.Types import qualified Network.HTTP.Req as Req import PyF (fmt) import qualified Text.Regex.PCRE.Heavy as RE import Utils.Github (showGithubException) import Utils.Gitlab (showGitlabException) data GitServer = Github | Gitlab GitlabHost deriving (Eq, Show) data IssueStatus = Open | Closed deriving (Eq, Show) data GitIssue = GitIssue { server :: GitServer, owner :: Text, repo :: Text, issueNum :: Int } deriving (Eq, Show) data GitIssueWithStatus = GitIssueWithStatus { gitIssue :: Localized GitIssue, issueStatus :: IssueStatus } deriving (Eq, Show) serverDomain :: GitServer -> Text serverDomain Github = "github.com" serverDomain (Gitlab (GitlabHost h)) = h -- | This regex represents a github/gitlab issue URL gitRepoRe :: RE.Regex -- NOTE: \b at the beginning is really import for performances -- because it dramatically reduces the number of backtracks gitRepoRe = [RE.re|\b(?>https?://)?(?>www\.)?([^/ ]+)/(.*)/([^-][^/]*)(?>/-)?/issues/([0-9]+)|] -- | Extract all issues on one line and returns a list of the raw text associated with an issue extractIssuesOnALine :: ByteString -> [(Int, GitIssue)] extractIssuesOnALine lineContent = map f (RE.scan gitRepoRe lineContent) where f (match, [domain, owner, repo, ByteString.readInt -> Just (issueNo, _)]) = (colNo, GitIssue provider (Text.Encoding.decodeUtf8 owner) (Text.Encoding.decodeUtf8 repo) issueNo) where colNo = 1 + ByteString.length (fst $ ByteString.breakSubstring match lineContent) provider | domain == "github.com" = Github -- TODO: We suppose that all other cases are gitlab -- The only thing we risk here is a query with the wrong -- API to an irrelevant host. | otherwise = Gitlab (GitlabHost $ Text.Encoding.decodeUtf8 domain) -- This case seems impossible, the reasons for pattern match issues are: -- A number of items different than 4 in the list: there is only 4 matching groups in the regex -- An invalid `decimal` conversion. That's impossible either -- because the pattern for the issue number is `[0-9]+` f res = error ("Error: impossible match" <> show res) -- | Extract all issues correctly localized -- Note: we use 'ByteString' internally. This way we do not have to -- care about the possible encoding of the input files. -- In programming world, we mostly use ascii variants. This gives a -- few performance improvement compared to initially converting -- everything to 'Text' and search on it. extractIssues :: -- | Path of the file FilePath -> -- | Content of the file ByteString -> [Localized GitIssue] extractIssues filePath toCheck = concat (zipWith extract [1 ..] (ByteString.lines toCheck)) where extract lineNo lineContent = map f (extractIssuesOnALine lineContent) where f (colNo, gitIssue) = Localized (SourcePos filePath lineNo colNo) gitIssue -- Supports only github for the moment issueUrl :: GitIssue -> Req.Url 'Req.Https issueUrl issue = case server issue of Github -> Req.https "api.github.com" Req./: "repos" Req./: owner issue Req./: repo issue Req./: "issues" Req./~ issueNum issue Gitlab (GitlabHost host) -> Req.https host Req./: "api" Req./: "v4" Req./: "projects" Req./: [fmt|{owner issue}/{repo issue}|] Req./: "issues" Req./~ issueNum issue -- try Issue can fail, on non-2xx HTTP response tryRestIssue :: MonadKrank m => Localized GitIssue -> m Value tryRestIssue locIssue = do let issue = unLocalized locIssue let url = issueUrl issue headers <- headersFor issue krankRunRESTRequest url headers headersFor :: MonadKrank m => GitIssue -> m (Req.Option 'Req.Https) headersFor issue = do mGithubKey <- krankAsks githubKey mGitlabKeys <- krankAsks gitlabKeys case server issue of Github -> case mGithubKey of Just (GithubKey token) -> pure $ Req.oAuth2Token (Text.Encoding.encodeUtf8 token) Nothing -> pure mempty Gitlab host -> case Map.lookup host mGitlabKeys of Just (GitlabKey token) -> pure $ Req.header "PRIVATE-TOKEN" (Text.Encoding.encodeUtf8 token) Nothing -> pure mempty httpExcHandler :: MonadKrank m => GitServer -> Req.HttpException -> m Value httpExcHandler gitServer exc = pure . AesonT.object $ [("error", AesonT.String . pack $ [fmt|{(showGitServerException gitServer exc)}|])] showGitServerException :: GitServer -> Req.HttpException -> Text showGitServerException Github exc = showGithubException exc showGitServerException (Gitlab _) exc = showGitlabException exc restIssue :: MonadKrank m => Localized GitIssue -> m Value restIssue issue = catch (tryRestIssue issue) (httpExcHandler . server . unLocalized $ issue) statusParser :: Value -> Either Text IssueStatus statusParser (AesonT.Object o) = do let state :: AesonT.Result String = AesonT.parse (.: "state") o readState state where readState (AesonT.Success status) = case status of "closed" -> Right Closed -- Both Gitlab and Github use the same keyword for closed "open" -> Right Open -- Github uses the 'open' status "opened" -> Right Open -- Gitlab uses the 'opened' status _ -> Left [fmt|Could not parse status '{status}'|] readState (AesonT.Error _) = Left $ errorParser o statusParser _ = Left "invalid JSON" errorParser :: AesonT.Object -> Text errorParser o = do let err = AesonT.parse (.: "error") o readErr err where readErr (AesonT.Success errText) = pack errText readErr (AesonT.Error _) = "invalid JSON" gitIssuesWithStatus :: MonadKrank m => [Localized GitIssue] -> m [Either (Text, Localized GitIssue) GitIssueWithStatus] gitIssuesWithStatus issues = do statuses <- krankMapConcurrently restIssue issues pure $ zipWith f issues (fmap statusParser statuses) where f issue (Left err) = Left (err, issue) f issue (Right is) = Right $ GitIssueWithStatus issue is issueToLevel :: GitIssueWithStatus -> ViolationLevel issueToLevel i = case issueStatus i of Open -> Info Closed -> Error issueToMessage :: GitIssueWithStatus -> Text issueToMessage i = case issueStatus i of Open -> [fmt|still Open|] Closed -> [fmt|now Closed - You can remove the workaround you used there|] issuePrintUrl :: GitIssue -> Text issuePrintUrl GitIssue {owner, repo, server, issueNum} = [fmt|IssueTracker check for https://{serverDomain server}/{owner}/{repo}/issues/{issueNum}|] checkText :: MonadKrank m => FilePath -> ByteString -> m [Violation] checkText path t = do let issues = extractIssues path t isDryRun <- krankAsks dryRun if isDryRun then pure $ fmap ( \issue -> Violation { checker = issuePrintUrl . unLocalized $ issue, level = Info, message = "Dry run", location = getLocation (issue :: Localized GitIssue) } ) issues else do issuesWithStatus <- gitIssuesWithStatus issues pure $ fmap f issuesWithStatus where f (Left (err, issue)) = Violation { checker = issuePrintUrl . unLocalized $ issue, level = Warning, message = "Error when calling the API:\n" <> err, location = getLocation (issue :: Localized GitIssue) } f (Right issue) = Violation { checker = issuePrintUrl (unLocalized . gitIssue $ issue), level = issueToLevel issue, message = issueToMessage issue, location = getLocation (gitIssue issue :: Localized GitIssue) }