Skip to content

Commit dfe9252

Browse files
committed
Add a command-line tool to gather statistical data on CLC proposals
1 parent 7ad3ecf commit dfe9252

File tree

1 file changed

+224
-0
lines changed

1 file changed

+224
-0
lines changed

Statistics.hs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#!/usr/bin/env cabal
2+
{- cabal:
3+
build-depends:
4+
base, binary, bytestring, data-default < 0.8, github,
5+
optparse-applicative, text, time, vector
6+
default-language: GHC2021
7+
ghc-options: -Wall -Wno-type-defaults
8+
-}
9+
{-# LANGUAGE ImportQualifiedPost #-}
10+
{-# LANGUAGE OverloadedStrings #-}
11+
{-# LANGUAGE RecordWildCards #-}
12+
13+
import Control.Exception
14+
import Data.Binary
15+
import Data.ByteString qualified as B
16+
import Data.ByteString.Lazy qualified as BL
17+
import Data.Foldable (forM_)
18+
import Data.List (sort)
19+
import Data.Maybe (isNothing, mapMaybe)
20+
import Data.Text (Text)
21+
import Data.Text qualified as T
22+
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime)
23+
import Data.Tuple
24+
import Data.Vector qualified as V
25+
import GitHub qualified as GH
26+
import Options.Applicative
27+
import System.IO
28+
29+
data Config
30+
= Offline FilePath
31+
| Online (Maybe B.ByteString) (Maybe FilePath)
32+
33+
configParser :: Parser Config
34+
configParser = do
35+
let
36+
parseUsername =
37+
optional $
38+
strOption $
39+
long "user"
40+
<> metavar "USERNAME"
41+
<> help "GitHub username to bypass anonymous API rate limit"
42+
parseCacheFile =
43+
optional $
44+
strOption $
45+
long "cache"
46+
<> metavar "FILE"
47+
<> help "File to save cache to"
48+
parseOffline =
49+
strOption $
50+
long "offline"
51+
<> metavar "FILE"
52+
<> help "Work offline using previously saved cache"
53+
54+
Offline <$> parseOffline <|> Online <$> parseUsername <*> parseCacheFile
55+
56+
getPassword :: IO B.ByteString
57+
getPassword = do
58+
hFlush stdout
59+
pass <-
60+
bracket_
61+
(hSetEcho stdin False)
62+
(hSetEcho stdin True)
63+
B.getLine
64+
putChar '\n'
65+
pure pass
66+
67+
getBasicAuth :: B.ByteString -> IO GH.Auth
68+
getBasicAuth username = do
69+
putStr "Launching missiles...\nEnter password to abort: "
70+
password <- getPassword
71+
pure $ GH.BasicAuth username password
72+
73+
getGithubIssues :: Config -> IO (V.Vector GH.Issue)
74+
getGithubIssues (Offline cacheFile) =
75+
decode <$> BL.readFile cacheFile
76+
getGithubIssues (Online mUsername mCacheFile) = do
77+
let githubOrg = "haskell"
78+
githubRepo = "core-libraries-committee"
79+
am <- traverse getBasicAuth mUsername
80+
response <- case am of
81+
Nothing -> GH.github () GH.issuesForRepoR githubOrg githubRepo GH.stateAll GH.FetchAll
82+
Just ba -> GH.github ba GH.issuesForRepoR githubOrg githubRepo GH.stateAll GH.FetchAll
83+
issues <- case response of
84+
Left err -> error $ show err
85+
Right is -> pure is
86+
case mCacheFile of
87+
Nothing -> pure ()
88+
Just cacheFile -> BL.writeFile cacheFile (encode issues)
89+
pure issues
90+
91+
data Issue = Issue
92+
{ issNumber :: !Int
93+
, issTitle :: !Text
94+
, issCreatedAt :: !UTCTime
95+
, issClosedAt :: !(Maybe UTCTime)
96+
, issLabels :: ![Text]
97+
, issComments :: !Int
98+
}
99+
deriving (Eq, Ord, Show)
100+
101+
githubIssueToIssue :: GH.Issue -> Issue
102+
githubIssueToIssue GH.Issue {..} = Issue {..}
103+
where
104+
issNumber = GH.unIssueNumber issueNumber
105+
issTitle = issueTitle
106+
issCreatedAt = issueCreatedAt
107+
issClosedAt = issueClosedAt
108+
issLabels = map (GH.untagName . GH.labelName) $ V.toList issueLabels
109+
issComments = issueComments
110+
111+
computeLifeTimeInDays :: Issue -> Maybe Double
112+
computeLifeTimeInDays Issue {..} = case issClosedAt of
113+
Nothing -> Nothing
114+
Just t -> Just $ realToFrac (diffUTCTime t issCreatedAt) / 86400
115+
116+
computeDaysSinceCreation :: UTCTime -> Issue -> Double
117+
computeDaysSinceCreation currTime Issue {..} =
118+
realToFrac (diffUTCTime currTime issCreatedAt) / 86400
119+
120+
isApproved :: Issue -> Bool
121+
isApproved Issue {..} = "approved" `elem` issLabels
122+
123+
isDeclined :: Issue -> Bool
124+
isDeclined Issue {..} = "declined" `elem` issLabels
125+
126+
isMeta :: Issue -> Bool
127+
isMeta Issue {..} = "meta" `elem` issLabels
128+
129+
isBase :: Int -> Issue -> Bool
130+
isBase n Issue {..} = ("base-4." <> T.pack (show n)) `elem` issLabels
131+
132+
data Stat = Stat
133+
{ statMinIssue :: Issue
134+
, statMinMetric :: Double
135+
, statMinIssue2 :: Issue
136+
, statMinMetric2 :: Double
137+
, statMed :: Int
138+
, statAvg :: Int
139+
, statMaxIssue2 :: Issue
140+
, statMaxMetric2 :: Double
141+
, statMaxIssue :: Issue
142+
, statMaxMetric :: Double
143+
}
144+
deriving (Show)
145+
146+
collectStat :: (Issue -> Maybe Double) -> [Issue] -> Stat
147+
collectStat f is = Stat {..}
148+
where
149+
(statMinIssue, statMinMetric) = getMin is
150+
(statMinIssue2, statMinMetric2) = getMin $ filter (/= statMinIssue) is
151+
152+
statMed = round $ median $ mapMaybe f is
153+
statAvg = round $ average $ mapMaybe f is
154+
155+
(statMaxIssue, statMaxMetric) = getMax is
156+
(statMaxIssue2, statMaxMetric2) = getMax $ filter (/= statMaxIssue) is
157+
158+
getExtremum g = swap . g . mapMaybe (\x -> (,x) <$> f x)
159+
getMin = getExtremum minimum
160+
getMax = getExtremum maximum
161+
162+
median xs = sort xs !! (length xs `quot` 2)
163+
average xs = sum xs / fromIntegral (length xs)
164+
165+
main :: IO ()
166+
main = do
167+
cnf <-
168+
execParser $
169+
info
170+
(configParser <**> helper)
171+
(fullDesc <> header "Collect statistics for CLC proposals")
172+
issues <- getGithubIssues cnf
173+
174+
let proposals =
175+
filter (not . isMeta) . map githubIssueToIssue $
176+
filter (isNothing . GH.issuePullRequest) $
177+
V.toList issues
178+
approvedProposals = filter isApproved proposals
179+
declinedProposals = filter isDeclined proposals
180+
181+
putStrLn $ "Total number of CLC proposals: " ++ show (length proposals)
182+
let startTime = read "2021-10-23 00:00:00 UTC"
183+
currTime <- getCurrentTime
184+
putStrLn $ "Rate of proposals: " ++ show (round (fromIntegral (length proposals) * 86400 * 365.25 / 12 / realToFrac (diffUTCTime currTime startTime))) ++ " per month"
185+
putStrLn $ "Approved proposals: " ++ show (length approvedProposals)
186+
putStrLn $ "Declined proposals: " ++ show (length declinedProposals)
187+
putStrLn ""
188+
189+
let allLifeTime = collectStat computeLifeTimeInDays proposals
190+
approvedLifeTime = collectStat computeLifeTimeInDays approvedProposals
191+
putStrLn $ "Median time from creation to decision: " ++ show (statMed allLifeTime) ++ " days"
192+
putStrLn $ "Average time from creation to decision: " ++ show (statAvg allLifeTime) ++ " days"
193+
putStrLn $ "Median time from creation to approval: " ++ show (statMed approvedLifeTime) ++ " days"
194+
putStrLn $ "Average time from creation to approval: " ++ show (statAvg approvedLifeTime) ++ " days"
195+
putStrLn $ "Fastest approval:\n\t" ++ show (round (statMinMetric approvedLifeTime * 24)) ++ " hours for " ++ show (issTitle (statMinIssue approvedLifeTime))
196+
putStrLn $ "2nd fastest approval:\n\t" ++ show (round (statMinMetric2 approvedLifeTime * 24)) ++ " hours for " ++ show (issTitle (statMinIssue2 approvedLifeTime))
197+
putStrLn $ "2nd slowest approval:\n\t" ++ show (round (statMaxMetric2 approvedLifeTime)) ++ " days for " ++ show (issTitle (statMaxIssue2 approvedLifeTime))
198+
putStrLn $ "Slowest approval:\n\t" ++ show (round (statMaxMetric approvedLifeTime)) ++ " days for " ++ show (issTitle (statMaxIssue approvedLifeTime))
199+
putStrLn ""
200+
201+
let allComments = collectStat (Just . fromIntegral . issComments) proposals
202+
approvedComments = collectStat (Just . fromIntegral . issComments) approvedProposals
203+
putStrLn $ "Total activity: " ++ show (sum (map issComments proposals)) ++ " comments"
204+
putStrLn $ "Median activity per proposal: " ++ show (statMed allComments) ++ " comments"
205+
putStrLn $ "Average activity per proposal: " ++ show (statAvg allComments) ++ " comments"
206+
putStrLn $ "Median activity per approved proposal: " ++ show (statMed approvedComments) ++ " comments"
207+
putStrLn $ "Average activity per approved proposal: " ++ show (statAvg approvedComments) ++ " comments"
208+
putStrLn $ "Least active approved proposal:\n\t" ++ show (round (statMinMetric approvedComments)) ++ " comment for " ++ show (issTitle (statMinIssue approvedComments))
209+
putStrLn $ "2nd least active approved proposal:\n\t" ++ show (round (statMinMetric2 approvedComments)) ++ " comments for " ++ show (issTitle (statMinIssue2 approvedComments))
210+
putStrLn $ "2nd most active:\n\t" ++ show (round (statMaxMetric2 allComments)) ++ " comments for " ++ show (issTitle (statMaxIssue2 allComments))
211+
putStrLn $ "Most active:\n\t" ++ show (round (statMaxMetric allComments)) ++ " comments for " ++ show (issTitle (statMaxIssue allComments))
212+
putStrLn ""
213+
214+
forM_ [16 .. 21] $ \n ->
215+
putStrLn $ "Released in base-4." ++ show n ++ ": " ++ show (length (filter (isBase n) approvedProposals))
216+
putStrLn ""
217+
218+
let openProposals = filter (isNothing . issClosedAt) proposals
219+
putStrLn $ "Open proposals: " ++ show (length openProposals)
220+
let openLifeTime = collectStat (Just . computeDaysSinceCreation currTime) openProposals
221+
putStrLn $ "Median age for open proposals: " ++ show (statMed openLifeTime) ++ " days"
222+
putStrLn $ "Average age for open proposals: " ++ show (statAvg openLifeTime) ++ " days"
223+
putStrLn $ "Newest open proposal:\n\t" ++ show (round (statMinMetric openLifeTime)) ++ " days for " ++ show (issTitle (statMinIssue openLifeTime))
224+
putStrLn $ "Oldest open proposal:\n\t" ++ show (round (statMaxMetric openLifeTime)) ++ " days for " ++ show (issTitle (statMaxIssue openLifeTime))

0 commit comments

Comments
 (0)