packages feed

github-tools-0.1.2: src/GitHub/Tools/Changelogs.hs

{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData        #-}
module GitHub.Tools.Changelogs
  ( fetchChangeLog
  , formatChangeLog
  , ChangeLog
  ) where

import           Control.Arrow           (first, second, (&&&))
import qualified Control.Monad.Parallel  as P
import           Data.List               (foldl')
import qualified Data.List               as List
import qualified Data.List.Split         as List
import qualified Data.Map                as Map
import qualified Data.Maybe              as Maybe
import           Data.Text               (Text)
import qualified Data.Text               as Text
import qualified Data.Vector             as V
import           Debug.Trace             (traceIO)
import qualified GitHub
import           Network.HTTP.Client     (newManager)
import           Network.HTTP.Client.TLS (tlsManagerSettings)
import           Text.Read               (readMaybe)
-- import           Text.Groom              (groom)

import           GitHub.Tools.Requests


newtype ChangeLog = ChangeLog { unChangeLog :: [(Text, [Text], [Text])] }


data VersionComponent
  = Number Int
  | Wildcard
  deriving (Ord, Eq)


instance Read VersionComponent where
  readsPrec _ ('x':s) = [(Wildcard, s)]
  readsPrec p input   = map (first Number) . readsPrec p $ input


data Version
  = VersionNumber [VersionComponent]
  | VersionString Text
  deriving (Ord, Eq)


formatChangeLog :: Bool -> ChangeLog -> Text
formatChangeLog wantRoadmap =
  (<> "\n") . foldl' (<>) "" . map formatMilestone . unChangeLog
  where
    prsWord    = if wantRoadmap then "PRs to review" else "Merged PRs"
    issuesWord = if wantRoadmap then "Planned tasks" else "Closed issues"

    formatMilestone (milestone, issues, pulls) =
      "\n\n## " <> milestone
      <> mergedPrs pulls
      <> closedIssues issues

    mergedPrs [] = ""
    mergedPrs pulls =
      foldl' (<>) ("\n\n### " <> prsWord <> ":\n") . itemise $ pulls

    closedIssues [] = ""
    closedIssues issues =
      foldl' (<>) ("\n\n### " <> issuesWord <> ":\n") . itemise $ issues

    itemise = map ("\n- " <>)


data ChangeLogItemKind
  = Issue
  | PullRequest
  deriving Show


data ChangeLogItem = ChangeLogItem
  { clMilestone :: Text
  , clTitle     :: Text
  , clNumber    :: Int
  }


groupByMilestone
  :: (ChangeLogItem -> ([a], [a]) -> ([a], [a]))
  -> [ChangeLogItem]
  -> Map.Map Text ([a], [a])
  -> Map.Map Text ([a], [a])
groupByMilestone add = flip $ foldr (\item group ->
    Map.insert (clMilestone item) (
        case Map.lookup (clMilestone item) group of
          Just old -> add item old
          Nothing  -> add item ([], [])
      ) group)


formatChangeLogItem
  :: ChangeLogItemKind
  -> GitHub.Name GitHub.Owner
  -> GitHub.Name GitHub.Repo
  -> ChangeLogItem
  -> Text
formatChangeLogItem kind ownerName repoName item =
  "[#" <> number <> "](https://github.com/" <> GitHub.untagName ownerName <>
  "/" <> GitHub.untagName repoName <> kindPart <> number <> ") " <>
  clTitle item
  where
    number = Text.pack . show . clNumber $ item
    kindPart =
      case kind of
        Issue       -> "/issues/"
        PullRequest -> "/pull/"


makeChangeLog
  :: Bool
  -> GitHub.Name GitHub.Owner
  -> GitHub.Name GitHub.Repo
  -> [GitHub.SimplePullRequest]
  -> [GitHub.Issue]
  -> ChangeLog
makeChangeLog wantRoadmap ownerName repoName pulls issues =
  ChangeLog
  . reverseIfChangelog
  . sortChangelog
  . Map.foldlWithKey (\changes milestone (msIssues, msPulls) ->
      if milestone == "meta"
        then changes
        else
          ( milestone
          , map (formatChangeLogItem Issue       ownerName repoName) msIssues
          , map (formatChangeLogItem PullRequest ownerName repoName) msPulls
          ) : changes
    ) []
  . groupByMilestone (first  . (:)) changeLogIssues
  . groupByMilestone (second . (:)) changeLogPrs
  $ Map.empty
  where
    reverseIfChangelog l =
      if wantRoadmap
        then l
        else reverse l

    sortChangelog :: [(Text, [Text], [Text])] -> [(Text, [Text], [Text])]
    sortChangelog = List.sortOn $ \(v, _, _) -> parseVersion v

    parseVersion :: Text -> Version
    parseVersion v =
        case Text.unpack v of
            -- Milestones starting with "v" must be versions.
            'v':version ->
                case map readMaybe $ List.splitOn "." version of
                  [Just major, Just minor, Just rev] -> VersionNumber [major, minor, rev]
                  _ -> error $ "invalid version: " <> version
            _ -> VersionString v

    (mergedPrs, openPrs) = List.partition (\case
        GitHub.SimplePullRequest
          { GitHub.simplePullRequestMergedAt = Just _ } -> True
        _                                               -> False
      ) pulls

    selectedPrs =
      if wantRoadmap
        then openPrs
        else mergedPrs

    selectedItems = filter (\case
        GitHub.Issue
          { GitHub.issueMilestone = Just GitHub.Milestone
            { GitHub.milestoneState = state }
          } ->
            if wantRoadmap
              then state == "open"
              else state == "closed"
        _ -> False
      ) issues

    (backlogPrs, backlogIssues) =
      if wantRoadmap
        then
            -- Split into PRs and non-PR issues.
            List.partition (\case
              GitHub.Issue { GitHub.issuePullRequest = Just _ } -> True
              _                                                 -> False
            )
            -- Filter by issues that don't have a milestone.
          . filter (\case
              GitHub.Issue { GitHub.issueMilestone = Nothing } -> True
              _                                                -> False
            )
          $ issues
        else
          ([], [])

    addToBacklog =
      map $ \issue -> ChangeLogItem
        { clMilestone = "Backlog"
        , clTitle     = GitHub.issueTitle issue
        , clNumber    = GitHub.unIssueNumber $ GitHub.issueNumber issue
        }

    backlogPrItems    = addToBacklog backlogPrs
    backlogIssueItems = addToBacklog backlogIssues

    milestoneByIssueId =
      Map.fromList
      . map (GitHub.issueNumber &&& GitHub.milestoneTitle . Maybe.fromJust . GitHub.issueMilestone)
      $ selectedItems

    changeLogIssues :: [ChangeLogItem]
    changeLogIssues = backlogIssueItems ++ Maybe.mapMaybe (\case
        -- filter out PRs
        GitHub.Issue { GitHub.issuePullRequest = Just _ } -> Nothing
        issue -> Just ChangeLogItem
          { clMilestone = GitHub.milestoneTitle . Maybe.fromJust . GitHub.issueMilestone $ issue
          , clTitle     = GitHub.issueTitle issue
          , clNumber    = GitHub.unIssueNumber $ GitHub.issueNumber issue
          }
      ) selectedItems

    changeLogPrs :: [ChangeLogItem]
    changeLogPrs = backlogPrItems ++ Maybe.mapMaybe (\issue -> do
        milestone <- flip Map.lookup milestoneByIssueId . GitHub.simplePullRequestNumber $ issue
        return ChangeLogItem
          { clMilestone = milestone
          , clTitle     = GitHub.simplePullRequestTitle issue
          , clNumber    = GitHub.unIssueNumber $ GitHub.simplePullRequestNumber issue
          }
      ) selectedPrs


fetchChangeLog
  :: Bool
  -> GitHub.Name GitHub.Owner
  -> GitHub.Name GitHub.Repo
  -> Maybe GitHub.Auth
  -> IO ChangeLog
fetchChangeLog wantRoadmap ownerName repoName auth = do
  -- Initialise HTTP manager so we can benefit from keep-alive connections.
  mgr <- newManager tlsManagerSettings

  let fetchPulls state = do
          traceIO $ "Fetching pull requests"
          l <- V.toList <$> request auth mgr (GitHub.pullRequestsForR ownerName repoName state GitHub.FetchAll)
          traceIO $ "Got " <> show (length l) <> " pull requests"
          return l
  let fetchIssues state = do
          traceIO $ "Fetching issues"
          l <- V.toList <$> request auth mgr (GitHub.issuesForRepoR   ownerName repoName state GitHub.FetchAll)
          traceIO $ "Got " <> show (length l) <> " issues"
          return l

  -- issues >>= putStrLn . groom

  (pulls, issues) <- uncurry (P.bindM2 (\p i -> return (p, i))) $
          if wantRoadmap
             then (fetchPulls GitHub.stateOpen  , fetchIssues GitHub.stateOpen  )
             else (fetchPulls GitHub.stateClosed, fetchIssues GitHub.stateClosed)

  traceIO "Formatting changelog/roadmap"
  return $ makeChangeLog wantRoadmap ownerName repoName pulls issues