diff --git a/app/Main.hs b/app/Main.hs index 8de875d..56c42d5 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -11,6 +11,7 @@ import qualified Brick.Widgets.List as L import Control.Concurrent.QSem import Control.Concurrent.STM (retry) import Control.Monad +import Control.Monad.Logger (LogLevel(..)) import Data.Function import Data.String.Interpolate import qualified Data.Text.IO as T @@ -135,6 +136,8 @@ main = do , _appColorMode = V.FullColor , _appLogs = mempty + + , _appLogLevelFilter = LevelInfo } diff --git a/app/Sauron/Actions.hs b/app/Sauron/Actions.hs index 3514565..1a09564 100644 --- a/app/Sauron/Actions.hs +++ b/app/Sauron/Actions.hs @@ -49,6 +49,23 @@ refresh bc item@(PaginatedWorkflowsNode _) (findRepoParent -> Just (RepoNode (En liftIO $ void $ async $ liftIO $ runReaderT (fetchWorkflows owner name item) bc refresh bc item@(PaginatedBranchesNode _) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name)}))) = liftIO $ void $ async $ liftIO $ runReaderT (fetchBranches owner name item) bc +refresh bc item@(OverallBranchesNode _) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name)}))) = + liftIO $ void $ async $ liftIO $ runReaderT (fetchOverallBranches owner name item) bc +refresh bc item@(PaginatedYourBranchesNode _) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name), _state}))) = + readTVarIO _state >>= \case + Fetched (Repo {..}) -> liftIO $ void $ async $ liftIO $ runReaderT (fetchYourBranches owner name repoDefaultBranch item) bc + Fetching (Just (Repo {..})) -> liftIO $ void $ async $ liftIO $ runReaderT (fetchYourBranches owner name repoDefaultBranch item) bc + _ -> return () +refresh bc item@(PaginatedActiveBranchesNode _) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name), _state}))) = + readTVarIO _state >>= \case + Fetched (Repo {..}) -> liftIO $ void $ async $ liftIO $ runReaderT (fetchActiveBranches owner name repoDefaultBranch item) bc + Fetching (Just (Repo {..})) -> liftIO $ void $ async $ liftIO $ runReaderT (fetchActiveBranches owner name repoDefaultBranch item) bc + _ -> return () +refresh bc item@(PaginatedStaleBranchesNode _) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name), _state}))) = + readTVarIO _state >>= \case + Fetched (Repo {..}) -> liftIO $ void $ async $ liftIO $ runReaderT (fetchStaleBranches owner name repoDefaultBranch item) bc + Fetching (Just (Repo {..})) -> liftIO $ void $ async $ liftIO $ runReaderT (fetchStaleBranches owner name repoDefaultBranch item) bc + _ -> return () refresh bc item@(PaginatedNotificationsNode _) _parents = liftIO $ void $ async $ liftIO $ runReaderT (fetchNotifications item) bc refresh bc item@(PaginatedReposNode (EntityData {})) _parents = @@ -71,6 +88,8 @@ refresh bc item@(SingleJobNode (EntityData {_state})) parents@(findRepoParent -> liftIO $ void $ startJobHealthCheckIfNeeded bc item parents refresh bc item@(SingleBranchNode _) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name)}))) = liftIO $ void $ async $ liftIO $ runReaderT (fetchBranchCommits owner name item) bc +refresh bc item@(SingleBranchWithInfoNode _) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name)}))) = + liftIO $ void $ async $ liftIO $ runReaderT (fetchBranchWithInfoCommits owner name item) bc refresh bc (SingleCommitNode (EntityData {_static=commit, _state})) (findRepoParent -> Just (RepoNode (EntityData {_static=(owner, name)}))) = liftIO $ void $ async $ liftIO $ runReaderT (fetchCommitDetails owner name (commitSha commit) _state) bc refresh _ (SingleNotificationNode _) _ = return () diff --git a/app/Sauron/Actions/Util.hs b/app/Sauron/Actions/Util.hs index 1cac431..da50a49 100644 --- a/app/Sauron/Actions/Util.hs +++ b/app/Sauron/Actions/Util.hs @@ -24,11 +24,13 @@ import Control.Monad.IO.Class import Control.Monad.Logger (LogLevel(..)) import Control.Monad.Reader import Data.Aeson (FromJSON) +import qualified Data.ByteString as BS import qualified Data.List as L import qualified Data.Text as T import Data.Time import GitHub import Network.HTTP.Client (Response, responseBody, responseHeaders) +import Network.HTTP.Types (EscapeItem(..)) import Network.HTTP.Types.Header (hContentLength) import Relude import Sauron.Types @@ -66,13 +68,29 @@ findRepoParent elems = viaNonEmpty head [x | SomeNode x@(RepoNode _) <- toList e requestToUrl :: Request k a -> Text requestToUrl req = case req of - Query paths _queryString -> pathsToUrl paths - PagedQuery paths _queryString _fetchCount -> pathsToUrl paths + Query paths queryString -> pathsToUrl paths <> formatQueryString queryString + PagedQuery paths queryString _fetchCount -> pathsToUrl paths <> formatQueryString queryString Command _method paths _body -> pathsToUrl paths where pathsToUrl :: [Text] -> Text pathsToUrl = ("/" <>) . T.intercalate "/" + formatQueryString :: QueryString -> Text + formatQueryString queryParams = + if null queryParams + then "" + else "?" <> T.intercalate "&" (map formatParam queryParams) + + formatParam :: (BS.ByteString, [EscapeItem]) -> Text + formatParam (key, values) = keyText <> "=" <> valuesText + where + keyText = decodeUtf8 key + valuesText = T.intercalate "," $ map formatEscapeItem values + + formatEscapeItem :: EscapeItem -> Text + formatEscapeItem (QE s) = decodeUtf8 s -- QE is already query-escaped + formatEscapeItem (QN s) = decodeUtf8 s + githubWithLogging :: (MonadReader BaseContext m, MonadIO m, FromJSON a) => Request k a -> m (Either Error a) githubWithLogging request = fmap responseBody <$> githubWithLoggingResponse request @@ -84,12 +102,15 @@ githubWithLogging' bc request = fmap responseBody <$> githubWithLogging'' bc req githubWithLogging'' :: (MonadIO m, FromJSON a) => BaseContext -> Request k a -> m (Either Error (Response a)) githubWithLogging'' (BaseContext {..}) request = do + startTime <- liftIO getCurrentTime result <- liftIO $ executeRequestWithMgrAndRes manager auth request - logResult eventChan request result + endTime <- liftIO getCurrentTime + let duration = diffUTCTime endTime startTime + logResult eventChan request result (Just duration) return result -logResult :: (MonadIO m) => BChan AppEvent -> Request k a -> Either Error (Response b) -> m () -logResult eventChan request result = do +logResult :: (MonadIO m) => BChan AppEvent -> Request k a -> Either Error (Response b) -> Maybe NominalDiffTime -> m () +logResult eventChan request result maybeDuration = do now <- liftIO getCurrentTime let url = requestToUrl request let level = case result of Left _ -> LevelError; _ -> LevelInfo @@ -97,10 +118,10 @@ logResult eventChan request result = do Left err -> "Failed: " <> url <> " - " <> show err Right response -> let sizeInfo = case getResponseSize response of - Nothing -> " " <> show (responseHeaders response) + Nothing -> "" -- " " <> show (responseHeaders response) Just size -> " (" <> show size <> " bytes)" in (url <> sizeInfo) - let logEntry = LogEntry now level msg + let logEntry = LogEntry now level msg maybeDuration liftIO $ writeBChan eventChan (LogEntryAdded logEntry) where getResponseSize :: Response a -> Maybe Int diff --git a/app/Sauron/Event.hs b/app/Sauron/Event.hs index b18097f..fe0a27a 100644 --- a/app/Sauron/Event.hs +++ b/app/Sauron/Event.hs @@ -9,6 +9,7 @@ import Brick.Forms import Brick.Widgets.Edit (handleEditorEvent) import Brick.Widgets.List import Control.Monad +import Control.Monad.Logger (LogLevel(..)) import Control.Monad.IO.Unlift import Data.Function import qualified Data.Sequence as Seq @@ -46,6 +47,7 @@ appEvent _s (AppEvent (LogEntryAdded logEntry)) = do modify (appLogs %~ (Seq.|> logEntry)) + -- Handle modal events appEvent s@(_appModal -> Just modalState) e = case e of VtyEvent ev -> case modalState of @@ -86,10 +88,28 @@ appEvent s@(_appModal -> Just modalState) e = case e of (V.EvKey (V.KChar 'q') [V.MCtrl]) -> do modify (appModal .~ Nothing) liftIO $ atomically $ writeTVar (_appModalVariable s) Nothing + (V.EvKey (V.KChar 'c') []) -> do + modify (appLogs .~ Seq.empty) (V.EvKey (V.KChar 'v') [V.MCtrl]) -> vScrollPage (viewportScroll LogModalContent) Down (V.EvKey (V.KChar 'v') [V.MMeta]) -> vScrollPage (viewportScroll LogModalContent) Up (V.EvKey (V.KChar 'n') [V.MCtrl]) -> vScrollBy (viewportScroll LogModalContent) 1 (V.EvKey (V.KChar 'p') [V.MCtrl]) -> vScrollBy (viewportScroll LogModalContent) (-1) + (V.EvKey V.KUp []) -> vScrollBy (viewportScroll LogModalContent) (-1) + (V.EvKey V.KDown []) -> vScrollBy (viewportScroll LogModalContent) 1 + (V.EvKey V.KPageUp []) -> vScrollPage (viewportScroll LogModalContent) Up + (V.EvKey V.KPageDown []) -> vScrollPage (viewportScroll LogModalContent) Down + (V.EvKey (V.KChar 'd') []) -> do + modify (appLogLevelFilter .~ LevelDebug) + vScrollToEnd (viewportScroll LogModalContent) + (V.EvKey (V.KChar 'i') []) -> do + modify (appLogLevelFilter .~ LevelInfo) + vScrollToEnd (viewportScroll LogModalContent) + (V.EvKey (V.KChar 'w') []) -> do + modify (appLogLevelFilter .~ LevelWarn) + vScrollToEnd (viewportScroll LogModalContent) + (V.EvKey (V.KChar 'e') []) -> do + modify (appLogLevelFilter .~ LevelError) + vScrollToEnd (viewportScroll LogModalContent) _ -> return () -- No other interactions for LogModal _ -> return () @@ -177,8 +197,10 @@ appEvent s (VtyEvent e) = case e of liftIO $ atomically $ writeTVar (_appModalVariable s) (Just (ZoomModalState (SomeNode variableEl))) V.EvKey (V.KChar 'l') [V.MCtrl] -> do - modify (appModal .~ Just LogModalState) + modify (appModal ?~ LogModalState) liftIO $ atomically $ writeTVar (_appModalVariable s) (Just LogModalState) + -- Scroll to the bottom of the log modal content to show latest logs + vScrollToEnd (viewportScroll LogModalContent) V.EvKey c [] | c `elem` [V.KEsc, exitKey] -> do -- Cancel everything and wait for cleanups @@ -194,7 +216,7 @@ appEvent _s (MouseDown (ListRow _i) V.BScrollUp _ _) = do appEvent _s (MouseDown (ListRow _i) V.BScrollDown _ _) = do vScrollBy (viewportScroll MainList) 1 appEvent _s (MouseDown (ListRow n) V.BLeft _ _) = do - modify (appMainList %~ (listMoveTo n)) + modify (appMainList %~ listMoveTo n) appEvent _ _ = return () diff --git a/app/Sauron/Event/Helpers.hs b/app/Sauron/Event/Helpers.hs index 9d9b7ed..b71edb5 100644 --- a/app/Sauron/Event/Helpers.hs +++ b/app/Sauron/Event/Helpers.hs @@ -67,6 +67,9 @@ getPaginationInfo (SomeNode (PaginatedPullsNode (EntityData {..}))) = Just _page getPaginationInfo (SomeNode (PaginatedWorkflowsNode (EntityData {..}))) = Just _pageInfo getPaginationInfo (SomeNode (PaginatedReposNode (EntityData {..}))) = Just _pageInfo getPaginationInfo (SomeNode (PaginatedBranchesNode (EntityData {..}))) = Just _pageInfo +getPaginationInfo (SomeNode (PaginatedYourBranchesNode (EntityData {..}))) = Just _pageInfo +getPaginationInfo (SomeNode (PaginatedActiveBranchesNode (EntityData {..}))) = Just _pageInfo +getPaginationInfo (SomeNode (PaginatedStaleBranchesNode (EntityData {..}))) = Just _pageInfo getPaginationInfo (SomeNode (PaginatedNotificationsNode (EntityData {..}))) = Just _pageInfo getPaginationInfo _ = Nothing diff --git a/app/Sauron/Event/Open.hs b/app/Sauron/Event/Open.hs index b74bf74..ccdb0f9 100644 --- a/app/Sauron/Event/Open.hs +++ b/app/Sauron/Event/Open.hs @@ -33,12 +33,17 @@ getNodeUrl (PaginatedIssuesNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just getNodeUrl (PaginatedPullsNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/pulls") getNodeUrl (PaginatedWorkflowsNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/actions") getNodeUrl (PaginatedBranchesNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/branches") +getNodeUrl (OverallBranchesNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/branches") +getNodeUrl (PaginatedYourBranchesNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/branches/yours") +getNodeUrl (PaginatedActiveBranchesNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/branches/active") +getNodeUrl (PaginatedStaleBranchesNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/branches/stale") getNodeUrl (PaginatedNotificationsNode _) _ = Just "https://github.com/notifications" getNodeUrl (SingleIssueNode (EntityData {_static=(Issue {issueHtmlUrl=(Just url)})})) _parents = Just (toString $ getUrl url) getNodeUrl (SinglePullNode (EntityData {_static=(Issue {issueHtmlUrl=(Just url)})})) _parents = Just (toString $ getUrl url) getNodeUrl (SingleWorkflowNode (EntityData {_static=workflowRun})) _ = Just (toString $ getUrl $ workflowRunHtmlUrl workflowRun) getNodeUrl (SingleJobNode (EntityData {_state=(fetchableCurrent -> Just (job, _))})) _ = Just (toString $ getUrl $ jobHtmlUrl job) getNodeUrl (SingleBranchNode (EntityData {_static=branch})) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/tree/" <> toString (branchName branch)) +getNodeUrl (SingleBranchWithInfoNode (EntityData {_static=(branchInfo, _columnWidths)})) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/tree/" <> toString (branchWithInfoBranchName branchInfo)) getNodeUrl (SingleCommitNode (EntityData {_static=commit})) (findRepoBaseUrl -> Just repoBaseUrl) = Just (repoBaseUrl <> "/commit/" <> toString (untagName (commitSha commit))) getNodeUrl (RepoNode _) (findRepoBaseUrl -> Just repoBaseUrl) = Just repoBaseUrl getNodeUrl _ _ = Nothing diff --git a/app/Sauron/Expanding.hs b/app/Sauron/Expanding.hs index c390e2e..24962de 100644 --- a/app/Sauron/Expanding.hs +++ b/app/Sauron/Expanding.hs @@ -25,12 +25,17 @@ getExpandedList = V.fromList . concatMap expandNodes . V.toList PaginatedWorkflowsNode (EntityData {..}) -> expandTyped _children PaginatedReposNode (EntityData {..}) -> expandTyped _children PaginatedBranchesNode (EntityData {..}) -> expandTyped _children + OverallBranchesNode (EntityData {..}) -> expandWrapped _children + PaginatedYourBranchesNode (EntityData {..}) -> expandTyped _children + PaginatedActiveBranchesNode (EntityData {..}) -> expandTyped _children + PaginatedStaleBranchesNode (EntityData {..}) -> expandTyped _children PaginatedNotificationsNode (EntityData {..}) -> expandTyped _children SingleIssueNode (EntityData {..}) -> expandChildless _children SinglePullNode (EntityData {..}) -> expandChildless _children SingleWorkflowNode (EntityData {..}) -> expandTyped _children SingleJobNode (EntityData {..}) -> expandTyped _children SingleBranchNode (EntityData {..}) -> expandTyped _children + SingleBranchWithInfoNode (EntityData {..}) -> expandTyped _children SingleCommitNode (EntityData {..}) -> expandChildless _children SingleNotificationNode (EntityData {..}) -> expandChildless _children JobLogGroupNode (EntityData {..}) -> expandTyped _children diff --git a/app/Sauron/Fetch.hs b/app/Sauron/Fetch.hs index 0ddb9bb..84a5710 100644 --- a/app/Sauron/Fetch.hs +++ b/app/Sauron/Fetch.hs @@ -19,7 +19,12 @@ module Sauron.Fetch ( , fetchIssueCommentsAndEvents , fetchBranches + , fetchOverallBranches + , fetchYourBranches + , fetchActiveBranches + , fetchStaleBranches , fetchBranchCommits + , fetchBranchWithInfoCommits , fetchCommitDetails , fetchNotifications @@ -35,11 +40,13 @@ import Control.Exception.Safe (bracketOnError_) import Control.Monad (foldM) import Control.Monad.Catch (MonadMask) import Control.Monad.IO.Class +import Control.Monad.Logger (LogLevel(..)) +import qualified Data.List as List import qualified Data.Map.Strict as Map import Data.String.Interpolate import qualified Data.Text as T import Data.Time.Calendar (fromGregorian) -import Data.Time.Clock (UTCTime(..)) +import Data.Time.Clock (UTCTime(..), getCurrentTime) import qualified Data.Vector as V import GitHub import Network.HTTP.Conduit hiding (Proxy) @@ -48,7 +55,9 @@ import Relude import Sauron.Actions.Util import Sauron.Fetch.Core import Sauron.Fetch.ParseJobLogs +import qualified Sauron.GraphQL as GraphQL import Sauron.Types +import Sauron.UI.BranchWithInfo (formatCommitTimeText, formatPRInfoText, formatCheckStatusWithWidth, formatAheadBehindWithWidth) import UnliftIO.Async @@ -142,6 +151,147 @@ fetchBranches owner name (PaginatedBranchesNode (EntityData {..})) = do (writeTVar _children =<<) $ forM (V.toList branches) $ \branch@(Branch {..}) -> SingleBranchNode <$> makeEmptyElem bc branch ("/tree/" <> branchName) (_depth + 1) +fetchBranchesWithFilter :: ( + MonadReader BaseContext m, MonadIO m, MonadMask m + ) + => Name Owner + -> Name Repo + -> Maybe Text + -> TVar (Fetchable (V.Vector BranchWithInfo)) + -> TVar [Node Variable SingleBranchWithInfoT] + -> TVar PageInfo + -> Int + -> Text + -> ([BranchWithInfo] -> [BranchWithInfo]) + -> Text + -> m () +fetchBranchesWithFilter owner name repoDefaultBranch stateVar childrenVar pageInfoVar depth' logPrefix filterFn logSuffix = do + bc <- ask + + bracketOnError_ (atomically $ markFetching stateVar) + (atomically $ writeTVar stateVar (Errored $ logPrefix <> " fetch failed with exception.")) $ do + case getAuthToken bc of + Nothing -> liftIO $ do + logToModal bc LevelError (logPrefix <> ": No auth token available") Nothing + atomically $ writeTVar stateVar (Errored "No auth token available for GraphQL query") + Just authToken -> do + liftIO $ do + logToModal bc LevelInfo (logPrefix <> ": Querying GraphQL for " <> toPathPart owner <> "/" <> toPathPart name) Nothing + -- Read current page info to determine pagination + currentPageInfo <- readTVarIO pageInfoVar + let currentPage = pageInfoCurrentPage currentPageInfo + + -- Fetch a substantial number of branches to enable client-side filtering and pagination + -- We fetch more than we need to allow for filtering (many branches may be filtered out) + let branchesToFetch = max 100 (currentPage * pageSize * 3) -- Fetch at least 100, or enough for 3 pages worth + + -- Fetch branches with commit info using GraphQL + result <- GraphQL.queryBranchesWithInfos (\msg -> logToModal bc LevelDebug msg Nothing) authToken (toPathPart owner) (toPathPart name) repoDefaultBranch branchesToFetch + case result of + Left err -> atomically $ do + writeTVar stateVar (Errored $ toText err) + writeTVar childrenVar [] + Right branchesWithCommits -> do + -- Apply the provided filter function and sort by date + let allFilteredBranches = GraphQL.sortBranchesByDate $ filterFn branchesWithCommits + let totalBranches = length allFilteredBranches + let totalPages = max 1 $ (totalBranches + pageSize - 1) `div` pageSize -- Ceiling division, at least 1 page + + -- Calculate the slice for the current page + let startIdx = (currentPage - 1) * pageSize + let currentPageBranches = take pageSize $ drop startIdx allFilteredBranches + + let newPageInfo = PageInfo { + pageInfoCurrentPage = currentPage + , pageInfoFirstPage = if totalPages > 0 then Just 1 else Nothing + , pageInfoPrevPage = if currentPage > 1 then Just (currentPage - 1) else Nothing + , pageInfoNextPage = if currentPage < totalPages then Just (currentPage + 1) else Nothing + , pageInfoLastPage = if totalPages > 0 then Just totalPages else Nothing + } + + -- Calculate column widths based on all branches in current page + currentTime <- getCurrentTime + let columnWidths = calculateColumnWidths currentTime currentPageBranches + + -- Store only the current page's branches in the node state + atomically $ do + writeTVar pageInfoVar newPageInfo + writeTVar stateVar (Fetched (V.fromList currentPageBranches)) + (writeTVar childrenVar =<<) $ forM currentPageBranches $ \branchInfo -> + SingleBranchWithInfoNode <$> makeEmptyElem bc (branchInfo, columnWidths) ("/tree/" <> branchWithInfoBranchName branchInfo) (depth' + 1) + logToModal bc LevelInfo (logPrefix <> ": Processing complete, found " <> show (case result of + Left _ -> 0 + Right branchesWithCommits -> length $ filterFn branchesWithCommits) <> " " <> logSuffix) Nothing + where + getAuthToken :: BaseContext -> Maybe Text + getAuthToken bc = case auth bc of + OAuth token -> Just $ decodeUtf8 token + _ -> Nothing -- Only OAuth tokens supported for now + + calculateColumnWidths :: UTCTime -> [BranchWithInfo] -> ColumnWidths + calculateColumnWidths _ [] = ColumnWidths 0 0 0 0 -- Default widths for empty list + calculateColumnWidths currentTime branches = ColumnWidths { + cwCommitTime = fromMaybe 0 $ viaNonEmpty List.maximum $ map (T.length . formatCommitTimeText currentTime) branches + , cwCheckStatus = fromMaybe 0 $ viaNonEmpty List.maximum $ map (snd . formatCheckStatusWithWidth) branches + , cwAheadBehind = fromMaybe 0 $ viaNonEmpty List.maximum $ map (snd . formatAheadBehindWithWidth) branches + , cwPRInfo = fromMaybe 0 $ viaNonEmpty List.maximum $ map (T.length . formatPRInfoText) branches + } + +fetchYourBranches :: ( + MonadReader BaseContext m, MonadIO m, MonadMask m + ) => Name Owner -> Name Repo -> Maybe Text -> Node Variable PaginatedYourBranchesT -> m () +fetchYourBranches owner name repoDefaultBranch (PaginatedYourBranchesNode (EntityData {..})) = do + bc <- ask + liftIO (getUserName bc) >>= \case + Nothing -> liftIO $ do + logToModal bc LevelError "fetchYourBranches: Could not get current user name" Nothing + atomically $ writeTVar _state (Errored "Could not get current user name") + Just userName -> + fetchBranchesWithFilter owner name repoDefaultBranch _state _children _pageInfo _depth "fetchYourBranches" + (GraphQL.filterBranchesByAuthor userName) "your branches" + where + getUserName :: BaseContext -> IO (Maybe Text) + getUserName bc = do + -- Get the current authenticated user's name from GitHub API + result <- runReaderT (withGithubApiSemaphore (githubWithLogging userInfoCurrentR)) bc + case result of + Left _err -> return Nothing + Right user -> return $ Just $ toPathPart $ userLogin user + +fetchActiveBranches :: ( + MonadReader BaseContext m, MonadIO m, MonadMask m + ) => Name Owner -> Name Repo -> Maybe Text -> Node Variable PaginatedActiveBranchesT -> m () +fetchActiveBranches owner name repoDefaultBranch (PaginatedActiveBranchesNode (EntityData {..})) = + fetchBranchesWithFilter owner name repoDefaultBranch _state _children _pageInfo _depth "fetchActiveBranches" + (GraphQL.filterBranchesByActivity 90) "active branches" + +fetchStaleBranches :: ( + MonadReader BaseContext m, MonadIO m, MonadMask m + ) => Name Owner -> Name Repo -> Maybe Text -> Node Variable PaginatedStaleBranchesT -> m () +fetchStaleBranches owner name repoDefaultBranch (PaginatedStaleBranchesNode (EntityData {..})) = + fetchBranchesWithFilter owner name repoDefaultBranch _state _children _pageInfo _depth "fetchStaleBranches" + (GraphQL.filterBranchesByInactivity 90) "stale branches" + +fetchOverallBranches :: ( + MonadReader BaseContext m, MonadIO m + ) => Name Owner -> Name Repo -> Node Variable OverallBranchesT -> m () +fetchOverallBranches _owner _name (OverallBranchesNode (EntityData {..})) = do + bc <- ask + -- Create categorized branch sections similar to GitHub's interface + categorizedChildren <- atomically $ do + yourBranchesEd <- makeEmptyElem bc () "your-branches" (_depth + 1) + activeBranchesEd <- makeEmptyElem bc () "active-branches" (_depth + 1) + staleBranchesEd <- makeEmptyElem bc () "stale-branches" (_depth + 1) + return [ + SomeNode (PaginatedYourBranchesNode yourBranchesEd) + , SomeNode (PaginatedActiveBranchesNode activeBranchesEd) + , SomeNode (PaginatedStaleBranchesNode staleBranchesEd) + ] + + atomically $ do + writeTVar _state (Fetched ()) + writeTVar _children categorizedChildren + fetchNotifications :: ( MonadReader BaseContext m, MonadIO m, MonadMask m ) => Node Variable PaginatedNotificationsT -> m () @@ -174,6 +324,25 @@ fetchBranchCommits owner name (SingleBranchNode (EntityData {_static=branch, ..} (writeTVar _children =<<) $ forM (V.toList commits) $ \commit@(Commit {..}) -> SingleCommitNode <$> makeEmptyElem bc commit ("/commit/" <> T.pack (toString (untagName commitSha))) (_depth + 1) +fetchBranchWithInfoCommits :: ( + MonadReader BaseContext m, MonadIO m, MonadMask m + ) => Name Owner -> Name Repo -> Node Variable SingleBranchWithInfoT -> m () +fetchBranchWithInfoCommits owner name (SingleBranchWithInfoNode (EntityData {_static=(branchInfo, _columnWidths), ..})) = do + bc <- ask + case branchWithInfoCommitOid branchInfo of + Nothing -> atomically $ writeTVar _state (Errored "No commit OID available for branch with info") + Just commitOid -> do + bracketOnError_ (atomically $ markFetching _state) + (atomically $ writeTVar _state (Errored "Branch commits fetch failed with exception.")) $ + withGithubApiSemaphore (githubWithLogging (commitsWithOptionsForR owner name (FetchAtLeast 10) [CommitQuerySha commitOid])) >>= \case + Left err -> atomically $ do + writeTVar _state (Errored (show err)) + writeTVar _children [] + Right commits -> atomically $ do + writeTVar _state (Fetched commits) + (writeTVar _children =<<) $ forM (V.toList commits) $ \commit@(Commit {..}) -> + SingleCommitNode <$> makeEmptyElem bc commit ("/commit/" <> T.pack (toString (untagName commitSha))) (_depth + 1) + fetchCommitDetails :: ( MonadReader BaseContext m, MonadIO m, MonadMask m ) => Name Owner -> Name Repo -> Name Commit -> TVar (Fetchable Commit) -> m () @@ -325,7 +494,7 @@ fetchJobLogs :: ( MonadReader BaseContext m, MonadIO m, MonadMask m ) => Name Owner -> Name Repo -> Job -> Node Variable SingleJobT -> m () fetchJobLogs owner name (Job {jobId, jobSteps}) (SingleJobNode (EntityData {..})) = do - BaseContext {auth, manager} <- ask + bc@(BaseContext {auth, manager}) <- ask bracketOnError_ (atomically $ markFetching _state) (atomically $ writeTVar _state (Errored "Job logs fetch failed with exception.")) $ do -- First, get the download URI @@ -339,8 +508,6 @@ fetchJobLogs owner name (Job {jobId, jobSteps}) (SingleJobNode (EntityData {..}) let parsedLogs = parseJobLogs (T.splitOn "\n" (decodeUtf8 logs)) -- traceM [i|parsedLogs: #{parsedLogs}|] - - bc <- ask children' <- liftIO $ atomically $ do mapM (createJobStepNode bc (_depth + 1) parsedLogs) (V.toList jobSteps) @@ -369,35 +536,6 @@ fetchJob owner name jobId (SingleJobNode (EntityData {_state})) = do -- * Util -makeEmptyElem :: BaseContext -> NodeStatic a -> Text -> Int -> STM (EntityData Variable a) -makeEmptyElem (BaseContext {getIdentifierSTM}) typ' urlSuffix' depth' = do - stateVar <- newTVar NotFetched - ident' <- getIdentifierSTM - toggledVar <- newTVar False - childrenVar <- newTVar [] - searchVar <- newTVar $ SearchNone - pageInfoVar <- newTVar emptyPageInfo - healthCheckVar <- newTVar NotFetched - healthCheckThreadVar <- newTVar Nothing - return $ EntityData { - _static = typ' - , _state = stateVar - - , _urlSuffix = urlSuffix' - - , _toggled = toggledVar - , _children = childrenVar - - , _search = searchVar - , _pageInfo = pageInfoVar - - , _healthCheck = healthCheckVar - , _healthCheckThread = healthCheckThreadVar - - , _depth = depth' - , _ident = ident' -} - createJobStepNode :: BaseContext -> Int -> [JobLogGroup] -> JobStep -> STM (Node Variable 'JobLogGroupT) createJobStepNode bc depth' allLogs jobStep = do let stepTitle = untagName (jobStepName jobStep) diff --git a/app/Sauron/Fetch/Core.hs b/app/Sauron/Fetch/Core.hs index 96497c3..6100b5e 100644 --- a/app/Sauron/Fetch/Core.hs +++ b/app/Sauron/Fetch/Core.hs @@ -1,16 +1,24 @@ {-# LANGUAGE TypeFamilies #-} module Sauron.Fetch.Core ( - fetchPaginated'', - pageSize + fetchPaginated'' + , pageSize + + , makeEmptyElem + + , logToModal + , withLogToModal ) where +import Brick.BChan (writeBChan) import Control.Exception.Safe (bracketOnError_) import Control.Monad.Catch (MonadMask) import Control.Monad.IO.Class +import Control.Monad.Logger import Control.Monad.Reader import Data.Aeson import qualified Data.List as L +import Data.Time import GitHub import Network.HTTP.Client (responseBody) import Network.HTTP.Types.URI (QueryItem, parseQuery) @@ -58,3 +66,47 @@ fetchPaginated'' mkReq pageInfoVar stateVar cb = do } cb $ Right (responseBody x, pgInfo) + +makeEmptyElem :: BaseContext -> NodeStatic a -> Text -> Int -> STM (EntityData Variable a) +makeEmptyElem (BaseContext {getIdentifierSTM}) typ' urlSuffix' depth' = do + stateVar <- newTVar NotFetched + ident' <- getIdentifierSTM + toggledVar <- newTVar False + childrenVar <- newTVar [] + searchVar <- newTVar $ SearchNone + pageInfoVar <- newTVar emptyPageInfo + healthCheckVar <- newTVar NotFetched + healthCheckThreadVar <- newTVar Nothing + return $ EntityData { + _static = typ' + , _state = stateVar + + , _urlSuffix = urlSuffix' + + , _toggled = toggledVar + , _children = childrenVar + + , _search = searchVar + , _pageInfo = pageInfoVar + + , _healthCheck = healthCheckVar + , _healthCheckThread = healthCheckThreadVar + + , _depth = depth' + , _ident = ident' +} + +logToModal :: MonadIO m => BaseContext -> LogLevel -> Text -> Maybe NominalDiffTime -> m () +logToModal bc level msg maybeDuration = do + now <- liftIO getCurrentTime + let logEntry = LogEntry now level msg maybeDuration + liftIO $ writeBChan (eventChan bc) (LogEntryAdded logEntry) + +withLogToModal :: MonadIO m => BaseContext -> LogLevel -> Text -> m a -> m a +withLogToModal bc level msg action = do + startTime <- liftIO getCurrentTime + result <- action + endTime <- liftIO getCurrentTime + let duration = diffUTCTime endTime startTime + logToModal bc level msg (Just duration) + return result diff --git a/app/Sauron/Fix.hs b/app/Sauron/Fix.hs index b87b5a9..fb1a9b9 100644 --- a/app/Sauron/Fix.hs +++ b/app/Sauron/Fix.hs @@ -40,12 +40,17 @@ fixNode item@(PaginatedPullsNode ed) = fixTypedNode item ed fixNode item@(PaginatedWorkflowsNode ed) = fixTypedNode item ed fixNode item@(PaginatedReposNode ed) = fixTypedNode item ed fixNode item@(PaginatedBranchesNode ed) = fixTypedNode item ed +fixNode item@(OverallBranchesNode ed) = fixWrappedNode item ed +fixNode item@(PaginatedYourBranchesNode ed) = fixTypedNode item ed +fixNode item@(PaginatedActiveBranchesNode ed) = fixTypedNode item ed +fixNode item@(PaginatedStaleBranchesNode ed) = fixTypedNode item ed fixNode item@(PaginatedNotificationsNode ed) = fixTypedNode item ed fixNode item@(SingleIssueNode ed) = fixChildlessNode item ed fixNode item@(SinglePullNode ed) = fixChildlessNode item ed fixNode item@(SingleWorkflowNode ed) = fixTypedNode item ed fixNode item@(SingleJobNode ed) = fixTypedNode item ed fixNode item@(SingleBranchNode ed) = fixTypedNode item ed +fixNode item@(SingleBranchWithInfoNode ed) = fixTypedNode item ed fixNode item@(SingleCommitNode ed) = fixChildlessNode item ed fixNode item@(SingleNotificationNode ed) = fixChildlessNode item ed fixNode item@(JobLogGroupNode ed) = fixTypedNode item ed diff --git a/app/Sauron/GraphQL.hs b/app/Sauron/GraphQL.hs new file mode 100644 index 0000000..186883a --- /dev/null +++ b/app/Sauron/GraphQL.hs @@ -0,0 +1,346 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} + +module Sauron.GraphQL ( + queryBranchesWithInfos + , sortBranchesByDate + , filterBranchesByAuthor + , filterBranchesByActivity + , filterBranchesByInactivity + ) where + +import Control.Exception.Safe (try) +import Data.Aeson +import Data.String.Interpolate +import qualified Data.Text as T +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime) +import Data.Time.Format (parseTimeM, defaultTimeLocale) +import Network.HTTP.Conduit (responseTimeoutMicro) +import Network.HTTP.Simple +import Relude +import Sauron.Types hiding (PageInfo) +import System.IO.Unsafe (unsafePerformIO) + + +githubGraphQLEndpoint :: String +githubGraphQLEndpoint = "https://api.github.com/graphql" + +getBranchesQuery :: Text +getBranchesQuery = [i| + query GetBranchesWithCommits($owner: String!, $name: String!, $first: Int!, $defaultBranch: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { + name + target { + oid + } + } + refs(refPrefix: "refs/heads/", first: $first) { + nodes { + name + target { + ... on Commit { + oid + author { + name + email + date + user { + login + } + } + committedDate + statusCheckRollup { + state + } + } + } + compare(headRef: $defaultBranch) { + aheadBy + behindBy + } + } + pageInfo { + hasNextPage + endCursor + } + } + pullRequests(states: [OPEN, CLOSED, MERGED], first: 100) { + nodes { + number + title + url + headRefName + state + } + } + } + } + |] + +data BranchResponse = BranchResponse { + data' :: Maybe RepositoryData + , errors :: Maybe [GraphQLError] + } deriving (Show, Generic) +instance FromJSON BranchResponse where + parseJSON = withObject "BranchResponse" $ \o -> BranchResponse + <$> o .:? "data" + <*> o .:? "errors" + +data RepositoryData = RepositoryData { + repository :: Maybe Repository + } deriving (Show, Generic) +instance FromJSON RepositoryData + +data Repository = Repository { + refs :: Maybe Refs + , defaultBranchRef :: Maybe DefaultBranchRef + , pullRequests :: Maybe PullRequests + } deriving (Show, Generic) +instance FromJSON Repository + +data DefaultBranchRef = DefaultBranchRef { + defaultBranchName :: Maybe Text + , defaultBranchTarget :: Maybe Target + } deriving (Show, Generic) +instance FromJSON DefaultBranchRef where + parseJSON = withObject "DefaultBranchRef" $ \o -> DefaultBranchRef + <$> o .:? "name" + <*> o .:? "target" + +data Refs = Refs { + nodes :: Maybe [RefNode] + , pageInfo :: Maybe PageInfo + } deriving (Show, Generic) +instance FromJSON Refs + +data PullRequests = PullRequests { + prNodes :: Maybe [GraphQLPullRequestWithHead] + } deriving (Show, Generic) +instance FromJSON PullRequests where + parseJSON = withObject "PullRequests" $ \o -> PullRequests <$> o .:? "nodes" + +data GraphQLPullRequestWithHead = GraphQLPullRequestWithHead { + prWithHeadNumber :: Maybe Int + , prWithHeadTitle :: Maybe Text + , prWithHeadUrl :: Maybe Text + , prWithHeadRefName :: Maybe Text + , prWithHeadState :: Maybe Text + } deriving (Show, Generic) +instance FromJSON GraphQLPullRequestWithHead where + parseJSON = withObject "GraphQLPullRequestWithHead" $ \o -> GraphQLPullRequestWithHead + <$> o .:? "number" + <*> o .:? "title" + <*> o .:? "url" + <*> o .:? "headRefName" + <*> o .:? "state" + +data RefNode = RefNode { + name :: Text + , target :: Maybe Target + , branchCompare :: Maybe BranchComparison + } deriving (Show, Generic) +instance FromJSON RefNode where + parseJSON = withObject "RefNode" $ \o -> RefNode + <$> o .: "name" + <*> o .:? "target" + <*> o .:? "compare" + +data BranchComparison = BranchComparison { + aheadBy :: Maybe Int + , behindBy :: Maybe Int + } deriving (Show, Generic) +instance FromJSON BranchComparison + +data Target = Target { + oid :: Maybe Text + , author :: Maybe Author + , committedDate :: Maybe Text + , statusCheckRollup :: Maybe StatusCheckRollup + } deriving (Show, Generic) +instance FromJSON Target + +data StatusCheckRollup = StatusCheckRollup { + statusState :: Maybe Text + } deriving (Show, Generic) +instance FromJSON StatusCheckRollup where + parseJSON = withObject "StatusCheckRollup" $ \o -> StatusCheckRollup <$> o .:? "state" + +data Author = Author { + authorName :: Maybe Text + , email :: Maybe Text + , date :: Maybe Text + , user :: Maybe User + } deriving (Show, Generic) +instance FromJSON Author where + parseJSON = withObject "Author" $ \o -> Author + <$> o .:? "name" + <*> o .:? "email" + <*> o .:? "date" + <*> o .:? "user" + +data User = User { login :: Maybe Text } + deriving (Show, Generic) +instance FromJSON User + +data PageInfo = PageInfo { + hasNextPage :: Maybe Bool + , endCursor :: Maybe Text + } deriving (Show, Generic) +instance FromJSON PageInfo + +data GraphQLError = GraphQLError { message :: Text } deriving (Show, Generic) +instance FromJSON GraphQLError + +-- GraphQL variables for the query +data BranchVariables = BranchVariables + { owner :: Text + , repositoryName :: Text -- 'name' is a reserved keyword + , first :: Int + , defaultBranch :: Text + } deriving (Show, Generic) +instance ToJSON BranchVariables where + toJSON (BranchVariables owner' name' first' defaultBranch') = object [ + "owner" .= owner' + , "name" .= name' + , "first" .= first' + , "defaultBranch" .= defaultBranch' + ] + +data GraphQLRequest = GraphQLRequest { + query :: Text + , variables :: BranchVariables + } deriving (Show, Generic) +instance ToJSON GraphQLRequest + + +queryBranchesWithInfos :: MonadIO m => (Text -> IO ()) -> Text -> Text -> Text -> Maybe Text -> Int -> m (Either Text [BranchWithInfo]) +queryBranchesWithInfos debugFn authToken owner' repoName repoDefaultBranch first' = liftIO $ do + debugFn $ "GraphQL query for " <> owner' <> "/" <> repoName <> " (first " <> show first' <> ")" + + let defaultBranch = fromMaybe "main" repoDefaultBranch + let requestPayload = GraphQLRequest { + query = getBranchesQuery + , variables = BranchVariables owner' repoName first' defaultBranch + } + + result :: Either SomeException BranchResponse <- try $ do + initialRequest <- parseRequest githubGraphQLEndpoint + let httpRequest = setRequestMethod "POST" + $ setRequestHeader "Authorization" ["Bearer " <> encodeUtf8 authToken] + $ setRequestHeader "Content-Type" ["application/json"] + $ setRequestHeader "User-Agent" ["sauron-app"] + $ setRequestResponseTimeout (responseTimeoutMicro (30 * 1000000)) + $ setRequestBodyJSON requestPayload + $ initialRequest + + getResponseBody <$> httpJSON httpRequest + + case result of + Left (ex :: SomeException) -> do + debugFn $ "GraphQL HTTP request failed: " <> T.pack (show ex) + return $ Left $ "HTTP request failed: " <> T.pack (show ex) + Right body -> do + debugFn "Processing GraphQL response body" + case (data' body, errors body) of + (Just repoData, Nothing) -> do + case (repository repoData >>= refs >>= nodes, repository repoData >>= pullRequests >>= prNodes) of + (Just refNodes, maybePullRequests) -> do + let pullRequestList = fromMaybe [] maybePullRequests + debugFn $ "Found " <> show (length refNodes) <> " branches and " <> show (length pullRequestList) <> " pull requests" + return $ Right $ mapMaybe (refNodeToBranchWithComparison defaultBranch pullRequestList) refNodes + (Nothing, _) -> do + debugFn "No branches found in response" + return $ Right [] + (_, Just errs) -> do + debugFn $ "GraphQL errors: " <> T.intercalate ", " (map message errs) + return $ Left $ T.intercalate ", " (map message errs) + (Nothing, Nothing) -> do + debugFn "No data returned from GitHub" + return $ Left "No data returned from GitHub" + +refNodeToBranchWithComparison :: Text -> [GraphQLPullRequestWithHead] -> RefNode -> Maybe BranchWithInfo +refNodeToBranchWithComparison defaultBranchName pullRequests refNode = do + let branchName = name refNode + target' <- target refNode + let prInfo = findPullRequestForBranch branchName pullRequests + let compareInfo = branchCompare refNode + + -- Extract ahead/behind counts from the GraphQL comparison + let (aheadCount, behindCount) = case compareInfo of + Just comparison -> (aheadBy comparison, behindBy comparison) + Nothing -> + -- Fallback: if this is the default branch, it's up to date + if branchName == defaultBranchName + then (Just 0, Just 0) + else (Nothing, Nothing) + + return $ BranchWithInfo { + branchWithInfoBranchName = branchName + , branchWithInfoCommitOid = oid target' + , branchWithInfoCommitAuthor = author target' >>= user >>= login + , branchWithInfoAuthorEmail = author target' >>= email + , branchWithInfoCommitDate = committedDate target' + , branchWithInfoCheckStatus = statusCheckRollup target' >>= statusState + , branchWithInfoAssociatedPR = prInfo + , branchWithInfoAheadBy = aheadCount + , branchWithInfoBehindBy = behindCount + } + +findPullRequestForBranch :: Text -> [GraphQLPullRequestWithHead] -> Maybe GraphQLPullRequest +findPullRequestForBranch branchName pullRequests = + case find (\pr -> prWithHeadRefName pr == Just branchName) pullRequests of + Just prWithHead -> Just $ GraphQLPullRequest { + prNumber = prWithHeadNumber prWithHead + , prTitle = prWithHeadTitle prWithHead + , prUrl = prWithHeadUrl prWithHead + , prState = prWithHeadState prWithHead + } + Nothing -> Nothing + +filterBranchesByAuthor :: Text -> [BranchWithInfo] -> [BranchWithInfo] +filterBranchesByAuthor currentUser branches = + filter (\branch -> branchWithInfoCommitAuthor branch == Just currentUser) branches + +sortBranchesByDate :: [BranchWithInfo] -> [BranchWithInfo] +sortBranchesByDate branches = sortBy (comparing (Down . commitDateUtc)) branches + where + commitDateUtc :: BranchWithInfo -> Maybe UTCTime + commitDateUtc branch = branchWithInfoCommitDate branch >>= parseISODate + + parseISODate :: Text -> Maybe UTCTime + parseISODate dateStr = parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" (toString dateStr) + +-- Filter branches that have activity within the specified number of days +filterBranchesByActivity :: Int -> [BranchWithInfo] -> [BranchWithInfo] +filterBranchesByActivity daysCutoff branches = unsafePerformIO $ do + currentTime <- getCurrentTime + return $ filter (isBranchActive currentTime daysCutoff) branches + where + isBranchActive :: UTCTime -> Int -> BranchWithInfo -> Bool + isBranchActive currentTime days branch = + case branchWithInfoCommitDate branch of + Nothing -> False + Just dateStr -> + case parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" (toString dateStr) of + Nothing -> False + Just commitTime -> + let cutoffTime = addUTCTime (fromIntegral (-days * 24 * 60 * 60)) currentTime + in commitTime > cutoffTime + +-- Filter branches that have NO activity within the specified number of days (stale branches) +filterBranchesByInactivity :: Int -> [BranchWithInfo] -> [BranchWithInfo] +filterBranchesByInactivity daysCutoff branches = unsafePerformIO $ do + currentTime <- getCurrentTime + return $ filter (isBranchStale currentTime daysCutoff) branches + where + isBranchStale :: UTCTime -> Int -> BranchWithInfo -> Bool + isBranchStale currentTime days branch = + case branchWithInfoCommitDate branch of + Nothing -> True -- No commit date means it's probably stale + Just dateStr -> + case parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" (toString dateStr) of + Nothing -> True -- Can't parse date, consider stale + Just commitTime -> + let cutoffTime = addUTCTime (fromIntegral (-days * 24 * 60 * 60)) currentTime + in commitTime <= cutoffTime diff --git a/app/Sauron/Setup/Common.hs b/app/Sauron/Setup/Common.hs index b665179..54ba635 100644 --- a/app/Sauron/Setup/Common.hs +++ b/app/Sauron/Setup/Common.hs @@ -91,6 +91,7 @@ newRepoNode nsName repoVar healthCheckVar hcThread repoDepth getIdentifier = do , _ident = workflowsIdentifier } :: Node Variable PaginatedWorkflowsT + -- Traditional flat branches list branchesVar <- newTVarIO NotFetched branchesToggledVar <- newTVarIO False branchesChildrenVar <- newTVarIO [] @@ -113,8 +114,31 @@ newRepoNode nsName repoVar healthCheckVar hcThread repoDepth getIdentifier = do , _ident = branchesIdentifier } :: Node Variable PaginatedBranchesT + -- New GitHub-style categorized branches + categorizedBranchesStateVar <- newTVarIO NotFetched + categorizedBranchesToggledVar <- newTVarIO False + categorizedBranchesChildrenVar <- newTVarIO [] + categorizedBranchesSearchVar <- newTVarIO SearchNone + categorizedBranchesPageInfoVar <- newTVarIO $ PageInfo 1 Nothing Nothing Nothing Nothing + categorizedBranchesHealthCheckVar <- newTVarIO NotFetched + categorizedBranchesHealthCheckThreadVar <- newTVarIO Nothing + categorizedBranchesIdentifier <- liftIO getIdentifier + let categorizedBranchesChild = OverallBranchesNode $ EntityData { + _static = () + , _state = categorizedBranchesStateVar + , _urlSuffix = "branches/categorized" + , _toggled = categorizedBranchesToggledVar + , _children = categorizedBranchesChildrenVar + , _search = categorizedBranchesSearchVar + , _pageInfo = categorizedBranchesPageInfoVar + , _healthCheck = categorizedBranchesHealthCheckVar + , _healthCheckThread = categorizedBranchesHealthCheckThreadVar + , _depth = repoDepth + 1 + , _ident = categorizedBranchesIdentifier + } :: Node Variable OverallBranchesT + repoIdentifier <- liftIO getIdentifier - childrenVar <- newTVarIO [SomeNode issuesChild, SomeNode pullsChild, SomeNode workflowsChild, SomeNode branchesChild] + childrenVar <- newTVarIO [SomeNode issuesChild, SomeNode pullsChild, SomeNode workflowsChild, SomeNode branchesChild, SomeNode categorizedBranchesChild] searchVar <- newTVarIO SearchNone pageInfoVar <- newTVarIO emptyPageInfo repoHealthCheckThreadVar <- newTVarIO hcThread diff --git a/app/Sauron/Types.hs b/app/Sauron/Types.hs index b92a1e2..4316487 100644 --- a/app/Sauron/Types.hs +++ b/app/Sauron/Types.hs @@ -1,5 +1,6 @@ {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE RankNTypes #-} @@ -19,6 +20,7 @@ import Brick.Widgets.Edit (Editor) import qualified Brick.Widgets.List as L import Control.Concurrent.QSem import Control.Monad.Logger (LogLevel(..)) +import Data.Aeson import Data.String.Interpolate import Data.Text () import Data.Time @@ -57,6 +59,10 @@ data Node f (a :: NodeTyp) where PaginatedWorkflowsNode :: EntityData f 'PaginatedWorkflowsT -> Node f 'PaginatedWorkflowsT PaginatedReposNode :: EntityData f 'PaginatedReposT -> Node f 'PaginatedReposT PaginatedBranchesNode :: EntityData f 'PaginatedBranchesT -> Node f 'PaginatedBranchesT + OverallBranchesNode :: EntityData f 'OverallBranchesT -> Node f 'OverallBranchesT + PaginatedYourBranchesNode :: EntityData f 'PaginatedYourBranchesT -> Node f 'PaginatedYourBranchesT + PaginatedActiveBranchesNode :: EntityData f 'PaginatedActiveBranchesT -> Node f 'PaginatedActiveBranchesT + PaginatedStaleBranchesNode :: EntityData f 'PaginatedStaleBranchesT -> Node f 'PaginatedStaleBranchesT PaginatedNotificationsNode :: EntityData f 'PaginatedNotificationsT -> Node f 'PaginatedNotificationsT SingleIssueNode :: EntityData f 'SingleIssueT -> Node f 'SingleIssueT @@ -64,6 +70,7 @@ data Node f (a :: NodeTyp) where SingleWorkflowNode :: EntityData f 'SingleWorkflowT -> Node f 'SingleWorkflowT SingleJobNode :: EntityData f 'SingleJobT -> Node f 'SingleJobT SingleBranchNode :: EntityData f 'SingleBranchT -> Node f 'SingleBranchT + SingleBranchWithInfoNode :: EntityData f 'SingleBranchWithInfoT -> Node f 'SingleBranchWithInfoT SingleCommitNode :: EntityData f 'SingleCommitT -> Node f 'SingleCommitT SingleNotificationNode :: EntityData f 'SingleNotificationT -> Node f 'SingleNotificationT JobLogGroupNode :: EntityData f 'JobLogGroupT -> Node f 'JobLogGroupT @@ -77,6 +84,10 @@ data NodeTyp = | PaginatedWorkflowsT | PaginatedReposT | PaginatedBranchesT + | OverallBranchesT + | PaginatedYourBranchesT + | PaginatedActiveBranchesT + | PaginatedStaleBranchesT | PaginatedNotificationsT | SingleIssueT @@ -84,6 +95,7 @@ data NodeTyp = | SingleWorkflowT | SingleJobT | SingleBranchT + | SingleBranchWithInfoT | SingleCommitT | SingleNotificationT | JobLogGroupT @@ -99,6 +111,10 @@ instance Show (Node f a) where show (PaginatedWorkflowsNode (EntityData {..})) = [i|PaginatedWorkflowsNode<#{_ident}>|] show (PaginatedReposNode (EntityData {..})) = [i|PaginatedReposNode<#{_ident}>|] show (PaginatedBranchesNode (EntityData {..})) = [i|PaginatedBranchesNode<#{_ident}>|] + show (OverallBranchesNode (EntityData {..})) = [i|OverallBranchesNode<#{_ident}>|] + show (PaginatedYourBranchesNode (EntityData {..})) = [i|PaginatedYourBranchesNode<#{_ident}>|] + show (PaginatedActiveBranchesNode (EntityData {..})) = [i|PaginatedActiveBranchesNode<#{_ident}>|] + show (PaginatedStaleBranchesNode (EntityData {..})) = [i|PaginatedStaleBranchesNode<#{_ident}>|] show (PaginatedNotificationsNode (EntityData {..})) = [i|PaginatedNotificationsNode<#{_ident}>|] show (SingleIssueNode (EntityData {..})) = [i|SingleIssueNode<#{_ident}>|] @@ -106,6 +122,7 @@ instance Show (Node f a) where show (SingleWorkflowNode (EntityData {..})) = [i|SingleWorkflowNode<#{_ident}>|] show (SingleJobNode (EntityData {..})) = [i|SingleJobNode<#{_ident}>|] show (SingleBranchNode (EntityData {..})) = [i|SingleBranchNode<#{_ident}>|] + show (SingleBranchWithInfoNode (EntityData {..})) = [i|SingleBranchWithInfoNode<#{_ident}>|] show (SingleCommitNode (EntityData {..})) = [i|SingleCommitNode<#{_ident}>|] show (SingleNotificationNode (EntityData {..})) = [i|SingleNotificationNode<#{_ident}>|] show (JobLogGroupNode (EntityData {..})) = [i|JobLogGroupNode<#{_ident}>|] @@ -142,12 +159,17 @@ type family NodeStatic a where NodeStatic PaginatedWorkflowsT = () NodeStatic PaginatedReposT = Name User NodeStatic PaginatedBranchesT = () + NodeStatic OverallBranchesT = () + NodeStatic PaginatedYourBranchesT = () + NodeStatic PaginatedActiveBranchesT = () + NodeStatic PaginatedStaleBranchesT = () NodeStatic PaginatedNotificationsT = () NodeStatic SingleIssueT = Issue NodeStatic SinglePullT = Issue NodeStatic SingleWorkflowT = WorkflowRun NodeStatic SingleJobT = () NodeStatic SingleBranchT = Branch + NodeStatic SingleBranchWithInfoT = (BranchWithInfo, ColumnWidths) NodeStatic SingleCommitT = Commit NodeStatic SingleNotificationT = Notification NodeStatic JobLogGroupT = JobLogGroup @@ -160,12 +182,17 @@ type family NodeState a where NodeState PaginatedWorkflowsT = WithTotalCount WorkflowRun NodeState PaginatedReposT = SearchResult Repo NodeState PaginatedBranchesT = V.Vector Branch + NodeState OverallBranchesT = () + NodeState PaginatedYourBranchesT = V.Vector BranchWithInfo + NodeState PaginatedActiveBranchesT = V.Vector BranchWithInfo + NodeState PaginatedStaleBranchesT = V.Vector BranchWithInfo NodeState PaginatedNotificationsT = V.Vector Notification NodeState SingleIssueT = V.Vector (Either IssueEvent IssueComment) NodeState SinglePullT = V.Vector (Either IssueEvent IssueComment) NodeState SingleWorkflowT = WithTotalCount Job NodeState SingleJobT = (Job, [JobLogGroup]) NodeState SingleBranchT = V.Vector Commit + NodeState SingleBranchWithInfoT = V.Vector Commit NodeState SingleCommitT = Commit NodeState SingleNotificationT = () NodeState JobLogGroupT = () @@ -178,12 +205,17 @@ type family NodeChildType f a where NodeChildType f PaginatedWorkflowsT = Node f SingleWorkflowT NodeChildType f PaginatedReposT = Node f RepoT NodeChildType f PaginatedBranchesT = Node f SingleBranchT + NodeChildType f OverallBranchesT = SomeNode f + NodeChildType f PaginatedYourBranchesT = Node f SingleBranchWithInfoT + NodeChildType f PaginatedActiveBranchesT = Node f SingleBranchWithInfoT + NodeChildType f PaginatedStaleBranchesT = Node f SingleBranchWithInfoT NodeChildType f PaginatedNotificationsT = Node f SingleNotificationT NodeChildType f SingleIssueT = () NodeChildType f SinglePullT = () NodeChildType f SingleWorkflowT = Node f SingleJobT NodeChildType f SingleJobT = Node f JobLogGroupT NodeChildType f SingleBranchT = Node f SingleCommitT + NodeChildType f SingleBranchWithInfoT = Node f SingleCommitT NodeChildType f SingleCommitT = () NodeChildType f SingleNotificationT = () NodeChildType f JobLogGroupT = Node f JobLogGroupT @@ -218,6 +250,7 @@ getExistentialChildrenWrapped node = case node of -- These types have SomeNode children HeadingNode ed -> readTVar (_children ed) RepoNode ed -> readTVar (_children ed) + OverallBranchesNode ed -> readTVar (_children ed) -- These types have specific GADT constructor children, so wrap them PaginatedIssuesNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) @@ -225,10 +258,14 @@ getExistentialChildrenWrapped node = case node of PaginatedWorkflowsNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) PaginatedReposNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) PaginatedBranchesNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) + PaginatedYourBranchesNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) + PaginatedActiveBranchesNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) + PaginatedStaleBranchesNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) PaginatedNotificationsNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) SingleWorkflowNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) SingleJobNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) SingleBranchNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) + SingleBranchWithInfoNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) JobLogGroupNode ed -> fmap (fmap SomeNode) (readTVar (_children ed)) -- These are leaf nodes with no meaningful children @@ -249,18 +286,57 @@ entityDataL f (PaginatedPullsNode ed) = PaginatedPullsNode <$> f ed entityDataL f (PaginatedWorkflowsNode ed) = PaginatedWorkflowsNode <$> f ed entityDataL f (PaginatedReposNode ed) = PaginatedReposNode <$> f ed entityDataL f (PaginatedBranchesNode ed) = PaginatedBranchesNode <$> f ed +entityDataL f (OverallBranchesNode ed) = OverallBranchesNode <$> f ed +entityDataL f (PaginatedYourBranchesNode ed) = PaginatedYourBranchesNode <$> f ed +entityDataL f (PaginatedActiveBranchesNode ed) = PaginatedActiveBranchesNode <$> f ed +entityDataL f (PaginatedStaleBranchesNode ed) = PaginatedStaleBranchesNode <$> f ed entityDataL f (PaginatedNotificationsNode ed) = PaginatedNotificationsNode <$> f ed entityDataL f (SingleIssueNode ed) = SingleIssueNode <$> f ed entityDataL f (SinglePullNode ed) = SinglePullNode <$> f ed entityDataL f (SingleWorkflowNode ed) = SingleWorkflowNode <$> f ed entityDataL f (SingleJobNode ed) = SingleJobNode <$> f ed entityDataL f (SingleBranchNode ed) = SingleBranchNode <$> f ed +entityDataL f (SingleBranchWithInfoNode ed) = SingleBranchWithInfoNode <$> f ed entityDataL f (SingleCommitNode ed) = SingleCommitNode <$> f ed entityDataL f (SingleNotificationNode ed) = SingleNotificationNode <$> f ed entityDataL f (JobLogGroupNode ed) = JobLogGroupNode <$> f ed entityDataL f (HeadingNode ed) = HeadingNode <$> f ed entityDataL f (RepoNode ed) = RepoNode <$> f ed +-- * Data types beyond "github" package + +data GraphQLPullRequest = GraphQLPullRequest { + prNumber :: Maybe Int + , prTitle :: Maybe Text + , prUrl :: Maybe Text + , prState :: Maybe Text + } deriving (Show, Eq, Generic) +instance FromJSON GraphQLPullRequest where + parseJSON = withObject "GraphQLPullRequest" $ \o -> GraphQLPullRequest + <$> o .:? "number" + <*> o .:? "title" + <*> o .:? "url" + <*> o .:? "state" + +data BranchWithInfo = BranchWithInfo { + branchWithInfoBranchName :: Text + , branchWithInfoCommitOid :: Maybe Text + , branchWithInfoCommitAuthor :: Maybe Text + , branchWithInfoAuthorEmail :: Maybe Text + , branchWithInfoCommitDate :: Maybe Text + , branchWithInfoCheckStatus :: Maybe Text + , branchWithInfoAssociatedPR :: Maybe GraphQLPullRequest + , branchWithInfoAheadBy :: Maybe Int + , branchWithInfoBehindBy :: Maybe Int + } deriving (Show, Eq) + +data ColumnWidths = ColumnWidths { + cwCommitTime :: Int + , cwCheckStatus :: Int + , cwAheadBehind :: Int + , cwPRInfo :: Int + } deriving (Show, Eq) + -- * Misc data SortBy = @@ -369,6 +445,7 @@ data LogEntry = LogEntry { _logEntryTimestamp :: UTCTime , _logEntryLevel :: LogLevel , _logEntryMessage :: Text + , _logEntryDuration :: Maybe NominalDiffTime } deriving (Show, Eq) -- * Overall app state @@ -437,6 +514,8 @@ data AppState = AppState { , _appColorMode :: V.ColorMode , _appLogs :: Seq LogEntry + + , _appLogLevelFilter :: LogLevel } diff --git a/app/Sauron/UI.hs b/app/Sauron/UI.hs index 1ec494f..0b155c8 100644 --- a/app/Sauron/UI.hs +++ b/app/Sauron/UI.hs @@ -16,6 +16,7 @@ import Data.String.Interpolate import Relude import Sauron.Types import Sauron.UI.Branch () +import Sauron.UI.BranchWithInfo () import Sauron.UI.Commit () import Sauron.UI.Issue () import Sauron.UI.Job () @@ -53,12 +54,17 @@ drawNodeLine appState node = case node of PaginatedWorkflowsNode ed -> drawLine appState ed PaginatedReposNode ed -> drawLine appState ed PaginatedBranchesNode ed -> drawLine appState ed + OverallBranchesNode ed -> drawLine appState ed + PaginatedYourBranchesNode ed -> drawLine appState ed + PaginatedActiveBranchesNode ed -> drawLine appState ed + PaginatedStaleBranchesNode ed -> drawLine appState ed PaginatedNotificationsNode ed -> drawLine appState ed SingleIssueNode ed -> drawLine appState ed SinglePullNode ed -> drawLine appState ed SingleWorkflowNode ed -> drawLine appState ed SingleJobNode ed -> drawLine appState ed SingleBranchNode ed -> drawLine appState ed + SingleBranchWithInfoNode ed -> drawLine appState ed SingleCommitNode ed -> drawLine appState ed SingleNotificationNode ed -> drawLine appState ed JobLogGroupNode ed -> drawLine appState ed @@ -72,12 +78,17 @@ drawNodeInner appState node = case node of PaginatedWorkflowsNode ed -> drawInner appState ed PaginatedReposNode ed -> drawInner appState ed PaginatedBranchesNode ed -> drawInner appState ed + OverallBranchesNode ed -> drawInner appState ed + PaginatedYourBranchesNode ed -> drawInner appState ed + PaginatedActiveBranchesNode ed -> drawInner appState ed + PaginatedStaleBranchesNode ed -> drawInner appState ed PaginatedNotificationsNode ed -> drawInner appState ed SingleIssueNode ed -> drawInner appState ed SinglePullNode ed -> drawInner appState ed SingleWorkflowNode ed -> drawInner appState ed SingleJobNode ed -> drawInner appState ed SingleBranchNode ed -> drawInner appState ed + SingleBranchWithInfoNode ed -> drawInner appState ed SingleCommitNode ed -> drawInner appState ed SingleNotificationNode ed -> drawInner appState ed JobLogGroupNode ed -> drawInner appState ed diff --git a/app/Sauron/UI/Branch.hs b/app/Sauron/UI/Branch.hs index 2f447c5..72e82ca 100644 --- a/app/Sauron/UI/Branch.hs +++ b/app/Sauron/UI/Branch.hs @@ -2,9 +2,7 @@ {-# LANGUAGE MultiParamTypeClasses #-} {-# OPTIONS_GHC -fno-warn-orphans #-} -module Sauron.UI.Branch ( - branchLine - ) where +module Sauron.UI.Branch () where import Brick import Data.String.Interpolate @@ -18,16 +16,17 @@ import Sauron.UI.Statuses (fetchableQuarterCircleSpinner) instance ListDrawable Fixed 'SingleBranchT where drawLine appState (EntityData {_static=branch, _state, ..}) = - branchLine _toggled branch appState _state + simpleBranchLine _toggled branch appState _state drawInner _ _ = Nothing -branchLine :: Bool -> Branch -> AppState -> Fetchable (V.Vector Commit) -> Widget n -branchLine toggled' (Branch {branchName, branchCommit}) appState fetchableState = vBox [line1, line2] +simpleBranchLine :: Bool -> Branch -> AppState -> Fetchable (V.Vector Commit) -> Widget n +simpleBranchLine toggled' (Branch {branchName, branchCommit}) appState fetchableState = vBox [line1, line2] where line1 = hBox [ withAttr openMarkerAttr $ str (if toggled' then "[-] " else "[+] ") - , withAttr branchAttr $ str $ toString branchName + , hLimitPercent 40 $ withAttr branchAttr $ + padRight Max $ txt branchName , fetchableQuarterCircleSpinner (_appAnimationCounter appState) fetchableState , padLeft Max $ case fetchableState of Fetched commits -> str [i|(#{V.length commits} commits)|] diff --git a/app/Sauron/UI/BranchWithInfo.hs b/app/Sauron/UI/BranchWithInfo.hs new file mode 100644 index 0000000..251b558 --- /dev/null +++ b/app/Sauron/UI/BranchWithInfo.hs @@ -0,0 +1,120 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Sauron.UI.BranchWithInfo ( + formatCommitTimeText, + formatPRInfoText, + formatCheckStatusWithWidth, + formatAheadBehindWithWidth +) where + +import Brick +import qualified Data.Text as T +import Data.Time.Clock (UTCTime, diffUTCTime) +import Data.Time.Format (parseTimeM, defaultTimeLocale) +import qualified Data.Vector as V +import GitHub +import Relude +import Sauron.Types +import Sauron.UI.AttrMap +import Sauron.UI.Statuses (fetchableQuarterCircleSpinner) +import Sauron.UI.Util.TimeDiff (timeFromNow) + + +instance ListDrawable Fixed 'SingleBranchWithInfoT where + drawLine appState (EntityData {_static=(branchInfo, columnWidths), _state, ..}) = + let branchCommit = BranchCommit (fromMaybe "" (branchWithInfoCommitOid branchInfo)) + (URL "") -- placeholder URL + branch = Branch (branchWithInfoBranchName branchInfo) branchCommit + in branchLineWithInfo _toggled branch branchInfo columnWidths appState _state + + drawInner _ _ = Nothing + +branchLineWithInfo :: Bool -> Branch -> BranchWithInfo -> ColumnWidths -> AppState -> Fetchable (V.Vector Commit) -> Widget n +branchLineWithInfo toggled' (Branch {branchName, branchCommit}) branchData columnWidths appState fetchableState = vBox [line1, line2] + where + + line1 = hBox [ + withAttr openMarkerAttr $ str (if toggled' then "[-] " else "[+] ") + , withAttr branchAttr $ txt branchName + , fetchableQuarterCircleSpinner (_appAnimationCounter appState) fetchableState + , padLeft Max infoColumns + ] + + infoColumns = hBox [ + fixedWidth (cwCommitTime columnWidths) $ txt $ formatCommitTimeText (_appNow appState) branchData + , str spacing + , fixedWidth (cwCheckStatus columnWidths) $ formatCheckStatus branchData + , str spacing + , fixedWidth (cwAheadBehind columnWidths) $ formatAheadBehind branchData + , str spacing + , fixedWidth (cwPRInfo columnWidths) $ txt $ formatPRInfoText branchData + , str spacing + ] + where + fixedWidth w widget = hLimit w $ padRight Max widget + spacing = " " + + line2 = padRight Max $ padLeft (Pad 4) $ hBox [ + str "Latest commit " + , withAttr hashAttr $ str $ take 7 $ toString $ branchCommitSha branchCommit + ] + + formatCheckStatus :: BranchWithInfo -> Widget n + formatCheckStatus = fst . formatCheckStatusWithWidth + + formatAheadBehind :: BranchWithInfo -> Widget n + formatAheadBehind = fst . formatAheadBehindWithWidth + +-- * Widget + width helper functions + +formatCheckStatusWithWidth :: BranchWithInfo -> (Widget n, Int) +formatCheckStatusWithWidth branchInfo = + case branchWithInfoCheckStatus branchInfo of + Nothing -> let text = "No checks" in (str text, length text) + Just "SUCCESS" -> let widget = hBox [withAttr greenCheckAttr $ str "✓", str " Checks"] in (widget, 8) -- ✓ Checks = 8 chars + Just "FAILURE" -> let widget = hBox [withAttr redXAttr $ str "✗", str " Failed"] in (widget, 8) -- ✗ Failed = 8 chars + Just "PENDING" -> let widget = hBox [withAttr queuedAttr $ str "●", str " Running"] in (widget, 9) -- ● Running = 9 chars + Just status -> let text = toString status in (str text, T.length status) + +formatAheadBehindWithWidth :: BranchWithInfo -> (Widget n, Int) +formatAheadBehindWithWidth branchInfo = (widget, T.length totalText) + where + ahead = fromMaybe 0 (branchWithInfoAheadBy branchInfo) + behind = fromMaybe 0 (branchWithInfoBehindBy branchInfo) + aheadWidget = if ahead > 0 + then hBox [withAttr greenCheckAttr $ str "↑", str $ " " <> show ahead] + else str "↑ 0" + behindWidget = if behind > 0 + then hBox [withAttr queuedAttr $ str "↓", str $ " " <> show behind] + else str "↓ 0" + widget = hBox [aheadWidget, str " ", behindWidget] + -- Calculate width: "↑ X ↓ Y" where X and Y are numbers + aheadText = if ahead > 0 then "↑ " <> show ahead else "↑ 0" + behindText = if behind > 0 then "↓ " <> show behind else "↓ 0" + totalText = aheadText <> " " <> behindText + +formatCommitTimeText :: UTCTime -> BranchWithInfo -> Text +formatCommitTimeText currentTime branchInfo = + case branchWithInfoCommitDate branchInfo of + Nothing -> "Unknown" + Just dateStr -> + case parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" (toString dateStr) :: Maybe UTCTime of + Nothing -> T.take 10 dateStr -- Fallback to raw date + Just commitTime -> toText $ timeFromNow (diffUTCTime currentTime commitTime) + + +formatPRInfoText :: BranchWithInfo -> Text +formatPRInfoText branchInfo = + case branchWithInfoAssociatedPR branchInfo of + Nothing -> "No PR" + Just pr -> case prNumber pr of + Nothing -> "PR: Unknown" + Just num -> + let stateText = case prState pr of + Just "OPEN" -> "" -- Don't show state for open PRs + Just "MERGED" -> " (merged)" + Just "CLOSED" -> " (closed)" + _ -> "" + in "PR #" <> show num <> stateText diff --git a/app/Sauron/UI/Modals/LogModal.hs b/app/Sauron/UI/Modals/LogModal.hs index 325648f..1c97ea6 100644 --- a/app/Sauron/UI/Modals/LogModal.hs +++ b/app/Sauron/UI/Modals/LogModal.hs @@ -15,21 +15,29 @@ import Sauron.UI.AttrMap (errorLogAttr, warningLogAttr, infoLogAttr, debugLogAtt renderLogModal :: AppState -> ModalState Fixed -> Widget ClickableName -renderLogModal appState LogModalState = - vBox [ - hCenter $ withAttr boldText $ str "Application Logs" +renderLogModal appState LogModalState = vBox [ + hCenter $ withAttr boldText $ str ("Application Logs - Filter: " <> filterText) , hBorder -- Scrollable content area with log entries , padBottom Max $ withVScrollBars OnRight $ withVScrollBarHandles $ viewport LogModalContent Vertical $ - vBox (renderLogEntries (appState ^. appNow) (appState ^. appLogs)) + vBox (renderLogEntries (appState ^. appNow) filteredLogs) , hBorder - , hCenter $ withAttr hotkeyMessageAttr $ str "Press [Esc] or [Ctrl+Q] to close" + , hCenter $ withAttr hotkeyMessageAttr $ str "Press [Esc] or [Ctrl+Q] to close, [c] to clear logs, [d/i/w/e] to filter levels" ] & border & withAttr normalAttr & hLimitPercent 80 & vLimitPercent 90 & centerLayer + where + currentFilter = appState ^. appLogLevelFilter + filteredLogs = filterLogsByLevel currentFilter (appState ^. appLogs) + filterText = case currentFilter of + LevelDebug -> "All" + LevelInfo -> "Info" + LevelWarn -> "Warn" + LevelError -> "Error" + LevelOther t -> toString t renderLogModal _ _ = str "Invalid modal state" -- This should never happen renderLogEntries :: UTCTime -> Seq LogEntry -> [Widget ClickableName] @@ -39,13 +47,16 @@ renderLogEntries currentTime logs = else toList $ fmap (renderLogEntry currentTime) logs renderLogEntry :: UTCTime -> LogEntry -> Widget ClickableName -renderLogEntry _currentTime (LogEntry timestamp level message) = +renderLogEntry _currentTime (LogEntry timestamp level message maybeDuration) = hBox [ - withAttr levelAttr $ str (formatLevel level) + withAttr timeAttr $ str (formatLogTime timestamp) , str " " - , withAttr timeAttr $ str (formatLogTime timestamp) + , withAttr levelAttr $ str (formatLevel level) , str " " , txtWrap message + , case maybeDuration of + Nothing -> emptyWidget + Just duration -> hBox [str " ", formatDuration duration] ] where levelAttr = case level of @@ -55,14 +66,35 @@ renderLogEntry _currentTime (LogEntry timestamp level message) = LevelDebug -> debugLogAttr _ -> normalAttr - timeAttr = normalAttr + timeAttr = debugLogAttr -- Use a muted color for timestamps + + formatDuration :: NominalDiffTime -> Widget ClickableName + formatDuration duration = + let durationMs = round (duration * 1000) :: Integer + durationAttr + | durationMs < 100 = debugLogAttr -- Fast (< 100ms) - gray + | durationMs < 1000 = infoLogAttr -- Normal (100-1000ms) - white + | durationMs < 5000 = warningLogAttr -- Slow (1-5s) - yellow + | otherwise = errorLogAttr -- Very slow (>5s) - red + durationText = show durationMs <> "ms" + in withAttr durationAttr $ str durationText formatLevel :: LogLevel -> String formatLevel LevelError = "[ERROR]" -formatLevel LevelWarn = "[WARN] " -formatLevel LevelInfo = "[INFO] " +formatLevel LevelWarn = "[WARN]" +formatLevel LevelInfo = "[INFO]" formatLevel LevelDebug = "[DEBUG]" formatLevel (LevelOther t) = "[" <> toString t <> "]" formatLogTime :: UTCTime -> String -formatLogTime = formatTime defaultTimeLocale "%H:%M:%S" +formatLogTime = formatTime defaultTimeLocale "%H:%M:%S.%06q" + +filterLogsByLevel :: LogLevel -> Seq LogEntry -> Seq LogEntry +filterLogsByLevel filterLevel = Seq.filter (\logEntry -> logLevelPriority (_logEntryLevel logEntry) >= logLevelPriority filterLevel) + where + logLevelPriority :: LogLevel -> Int + logLevelPriority LevelError = 4 + logLevelPriority LevelWarn = 3 + logLevelPriority LevelInfo = 2 + logLevelPriority LevelDebug = 1 + logLevelPriority (LevelOther _) = 0 diff --git a/app/Sauron/UI/Modals/ZoomModal.hs b/app/Sauron/UI/Modals/ZoomModal.hs index 75fb4fe..66025fc 100644 --- a/app/Sauron/UI/Modals/ZoomModal.hs +++ b/app/Sauron/UI/Modals/ZoomModal.hs @@ -70,6 +70,14 @@ generateModalTitle (SomeNode inner) = "Workflow Runs" PaginatedBranchesNode _ -> "Branches" + OverallBranchesNode _ -> + "Branches" + PaginatedYourBranchesNode _ -> + "Your Branches" + PaginatedActiveBranchesNode _ -> + "Active Branches" + PaginatedStaleBranchesNode _ -> + "Stale Branches" PaginatedNotificationsNode _ -> "Notifications" SingleIssueNode (EntityData {_static = Issue {issueNumber = IssueNumber num, issueTitle}}) -> @@ -85,6 +93,8 @@ generateModalTitle (SomeNode inner) = _ -> "Job" SingleBranchNode (EntityData {_static = Branch {branchName}}) -> "Branch: " <> T.unpack branchName + SingleBranchWithInfoNode (EntityData {_static = (branchInfo, _columnWidths)}) -> + "Branch: " <> T.unpack (branchWithInfoBranchName branchInfo) SingleCommitNode (EntityData {_static = Commit {commitGitCommit = GitCommit {gitCommitMessage}}}) -> "Commit: " <> T.unpack (T.take 50 gitCommitMessage) <> if T.length gitCommitMessage > 50 then "..." else "" SingleNotificationNode (EntityData {_static = Notification {notificationSubject = Subject {subjectTitle}}}) -> diff --git a/app/Sauron/UI/Pagination.hs b/app/Sauron/UI/Pagination.hs index ce51768..88e76a5 100644 --- a/app/Sauron/UI/Pagination.hs +++ b/app/Sauron/UI/Pagination.hs @@ -68,12 +68,54 @@ instance ListDrawable Fixed 'PaginatedWorkflowsT where instance ListDrawable Fixed 'PaginatedBranchesT where drawLine appState ed@(EntityData {..}) = case _state of - Fetched branches -> paginatedHeading ed appState "Branches" (countWidget _pageInfo branches) + Fetched branches -> paginatedHeading ed appState "All Branches" (countWidget _pageInfo branches) Fetching maybeBranches -> case maybeBranches of - Just branches -> paginatedHeading ed appState "Branches" (countWidget _pageInfo branches <+> str " " <+> getQuarterCircleSpinner (_appAnimationCounter appState)) - Nothing -> paginatedHeading ed appState "Branches" (str "(" <+> getQuarterCircleSpinner (_appAnimationCounter appState) <+> str ")") - NotFetched -> paginatedHeading ed appState "Branches" (str [i|(not fetched)|]) - Errored err -> paginatedHeading ed appState "Branches" (str [i|(error fetching: #{err})|]) + Just branches -> paginatedHeading ed appState "All Branches" (countWidget _pageInfo branches <+> str " " <+> getQuarterCircleSpinner (_appAnimationCounter appState)) + Nothing -> paginatedHeading ed appState "All Branches" (str "(" <+> getQuarterCircleSpinner (_appAnimationCounter appState) <+> str ")") + NotFetched -> paginatedHeading ed appState "All Branches" (str [i|(not fetched)|]) + Errored err -> paginatedHeading ed appState "All Branches" (str [i|(error fetching: #{err})|]) + + drawInner _ _ = Nothing + +instance ListDrawable Fixed 'OverallBranchesT where + drawLine _appState (EntityData {_static=(), _toggled}) = hBox $ catMaybes [ + Just $ withAttr openMarkerAttr $ str (if _toggled then "[-] " else "[+] ") + , Just (hBox [str "GitHub-style Branches"]) + , Just (padLeft Max (str " ")) + ] + + drawInner _ _ = Nothing + +instance ListDrawable Fixed 'PaginatedYourBranchesT where + drawLine appState ed@(EntityData {..}) = case _state of + Fetched branches -> paginatedHeading ed appState "Your branches" (countWidget _pageInfo branches) + Fetching maybeBranches -> case maybeBranches of + Just branches -> paginatedHeading ed appState "Your branches" (countWidget _pageInfo branches <+> str " " <+> getQuarterCircleSpinner (_appAnimationCounter appState)) + Nothing -> paginatedHeading ed appState "Your branches" (str "(" <+> getQuarterCircleSpinner (_appAnimationCounter appState) <+> str ")") + NotFetched -> paginatedHeading ed appState "Your branches" (str [i|(not fetched)|]) + Errored err -> paginatedHeading ed appState "Your branches" (str [i|(error fetching: #{err})|]) + + drawInner _ _ = Nothing + +instance ListDrawable Fixed 'PaginatedActiveBranchesT where + drawLine appState ed@(EntityData {..}) = case _state of + Fetched branches -> paginatedHeading ed appState "Active branches" (countWidget _pageInfo branches) + Fetching maybeBranches -> case maybeBranches of + Just branches -> paginatedHeading ed appState "Active branches" (countWidget _pageInfo branches <+> str " " <+> getQuarterCircleSpinner (_appAnimationCounter appState)) + Nothing -> paginatedHeading ed appState "Active branches" (str "(" <+> getQuarterCircleSpinner (_appAnimationCounter appState) <+> str ")") + NotFetched -> paginatedHeading ed appState "Active branches" (str [i|(not fetched)|]) + Errored err -> paginatedHeading ed appState "Active branches" (str [i|(error fetching: #{err})|]) + + drawInner _ _ = Nothing + +instance ListDrawable Fixed 'PaginatedStaleBranchesT where + drawLine appState ed@(EntityData {..}) = case _state of + Fetched branches -> paginatedHeading ed appState "Stale branches" (countWidget _pageInfo branches) + Fetching maybeBranches -> case maybeBranches of + Just branches -> paginatedHeading ed appState "Stale branches" (countWidget _pageInfo branches <+> str " " <+> getQuarterCircleSpinner (_appAnimationCounter appState)) + Nothing -> paginatedHeading ed appState "Stale branches" (str "(" <+> getQuarterCircleSpinner (_appAnimationCounter appState) <+> str ")") + NotFetched -> paginatedHeading ed appState "Stale branches" (str [i|(not fetched)|]) + Errored err -> paginatedHeading ed appState "Stale branches" (str [i|(error fetching: #{err})|]) drawInner _ _ = Nothing diff --git a/app/Sauron/UI/TopBox.hs b/app/Sauron/UI/TopBox.hs index d39c2a1..9e9cf01 100644 --- a/app/Sauron/UI/TopBox.hs +++ b/app/Sauron/UI/TopBox.hs @@ -110,6 +110,9 @@ isSearchable' (SomeNode (PaginatedPullsNode {})) = True isSearchable' (SomeNode (PaginatedWorkflowsNode {})) = False isSearchable' (SomeNode (PaginatedReposNode {})) = True isSearchable' (SomeNode (PaginatedBranchesNode {})) = True +isSearchable' (SomeNode (PaginatedYourBranchesNode {})) = True +isSearchable' (SomeNode (PaginatedActiveBranchesNode {})) = True +isSearchable' (SomeNode (PaginatedStaleBranchesNode {})) = True isSearchable' (SomeNode (PaginatedNotificationsNode {})) = True isSearchable' _ = False diff --git a/package.yaml b/package.yaml index 8a9c7df..9fda571 100644 --- a/package.yaml +++ b/package.yaml @@ -34,6 +34,7 @@ dependencies: - http-types - microlens - monad-logger +- morpheus-graphql-client - mtl - network-uri - pandoc-types diff --git a/sauron.cabal b/sauron.cabal index d852992..b839b3e 100644 --- a/sauron.cabal +++ b/sauron.cabal @@ -41,6 +41,7 @@ executable sauron Sauron.Fetch.ParseJobLogs Sauron.Filter Sauron.Fix + Sauron.GraphQL Sauron.HealthCheck Sauron.HealthCheck.Common Sauron.HealthCheck.Job @@ -58,6 +59,7 @@ executable sauron Sauron.UI.Border Sauron.UI.BottomBar Sauron.UI.Branch + Sauron.UI.BranchWithInfo Sauron.UI.Commit Sauron.UI.Event Sauron.UI.Issue @@ -121,6 +123,7 @@ executable sauron , microlens , microlens-th , monad-logger + , morpheus-graphql-client , mtl , network-uri , optparse-applicative @@ -177,6 +180,7 @@ test-suite test , http-types , microlens , monad-logger + , morpheus-graphql-client , mtl , network-uri , pandoc-types