@@ -10,12 +10,18 @@ import Colog
10import Control.AppRequestable
10import Control.AppRequestable
11import Control.Concurrent
11import Control.Concurrent
12import Control.GithubCloner
12import Control.GithubCloner
13+import Control.HasGithubStatus
13import Control.Monad.Except
14import Control.Monad.Except
14import Control.Monad.IO.Unlift
15import Control.Monad.IO.Unlift
15import Control.Monad.Reader
16import Control.Monad.Reader
16import Control.Task.Scheduler
17import Control.Task.Scheduler
18+import Crypto.PubKey.RSA.Read
17import Data.Aeson
19import Data.Aeson
18import Data.ByteString
20import Data.ByteString
21+import qualified Data.ByteString as BS
22+import qualified Data.ByteString.Char8 as BSC
23+import qualified Data.ByteString.Lazy.Char8 as BSCL
24+import qualified Data.Csv as Csv
19import Data.IORef
25import Data.IORef
20import Data.Map (Map)
26import Data.Map (Map)
21import qualified Data.Map as M
27import qualified Data.Map as M
@@ -24,14 +30,18 @@ import Data.Submission.Query
24import Data.Tasks.Build
30import Data.Tasks.Build
25import Data.Tasks.StatusUpdate
31import Data.Tasks.StatusUpdate
26import qualified Data.Text as T
32import qualified Data.Text as T
33+import Data.Time.Clock
27import Data.Yaml
34import Data.Yaml
28import GHC.Generics
35import GHC.Generics
29−import GitHub
36+import GitHub hiding (Accept)
30import GitHub.App.Auth
37import GitHub.App.Auth
31import GitHub.App.Request
38import GitHub.App.Request
39+import GitHub.Auth (Auth (OAuth))
32import GitHub.Data.Name
40import GitHub.Data.Name
33import GitHub.Data.Webhooks.Events
41import GitHub.Data.Webhooks.Events
34−import GitHub.Data.Webhooks.Payload
42+import GitHub.Data.Webhooks.Payload hiding (URL (..))
43+import Network.HTTP.Client.TLS (newTlsManager)
44+import Network.HTTP.Media.MediaType
35import Network.Wai.Handler.Warp
45import Network.Wai.Handler.Warp
36import Servant
46import Servant
37import Servant.GitHub.Webhook
47import Servant.GitHub.Webhook
@@ -40,9 +50,8 @@ import Server.Config
40import Server.Html
50import Server.Html
41import Server.Schema
51import Server.Schema
42import Squeal.PostgreSQL
52import Squeal.PostgreSQL
44import Text.Blaze.Html
53import Text.Blaze.Html
45−import UnliftIO.Exception (try)
54+import UnliftIO.Exception (catch, displayException, handleAny, throwIO, try)
48 "github-webhook"
57 "github-webhook"
@@ -50,8 +59,8 @@ type API =
50 :> GitHubSignedReqBody '[JSON] InstallationEvent
59 :> GitHubSignedReqBody '[JSON] InstallationEvent
51 :> Post '[JSON] ()
60 :> Post '[JSON] ()
52 :<|> GitHubEvent '[ 'WebhookPushEvent]
61 :<|> GitHubEvent '[ 'WebhookPushEvent]
53− :> GitHubSignedReqBody '[JSON] PushEvent
62+ :> GitHubSignedReqBody '[JSON] PushEvent
54− :> Post '[JSON] ()
63+ :> Post '[JSON] ()
56 :<|> "submission"
65 :<|> "submission"
57 :> Capture "user name" String
66 :> Capture "user name" String
@@ -64,18 +73,20 @@ type API =
64 :> Capture "sha" String
73 :> Capture "sha" String
65 :> "restart"
74 :> "restart"
66 :> Post '[HTML] Markup
75 :> Post '[HTML] Markup
76+ :<|> "results" :> Capture "task name" String :> Get '[CSV, HTML] [Score]
68−webhookInstallation :: MonadIO m => RepoWebhookEvent -> ((), InstallationEvent) -> m ()
78+webhookInstallation :: WithLog env Message m => RepoWebhookEvent -> ((), InstallationEvent) -> m ()
69webhookInstallation _ ((), ev) =
79webhookInstallation _ ((), ev) =
70− liftIO $ do
80+ logInfo . T.pack . show $ ev
74−pendingStatus :: NewStatus
75−pendingStatus = NewStatus StatusPending Nothing Nothing Nothing
77webhookPushEvent ::
82webhookPushEvent ::
84+ MonadReader (ServerData m) m,
87+ MonadError ServerError m,
78− (StaticPQ m, WithLog env Message m, MonadUnliftIO m, HasTasks m) =>
88+ WithLog env Message m
79 RepoWebhookEvent ->
90 RepoWebhookEvent ->
80 ((), PushEvent) ->
91 ((), PushEvent) ->
@@ -83,30 +94,42 @@ webhookPushEvent _ ((), ev) = do
83 let repo = evPushRepository ev
94 let repo = evPushRepository ev
84 user = T.unpack . whUserLogin . evPushSender $ ev
95 user = T.unpack . whUserLogin . evPushSender $ ev
85 fullRepoName = whRepoFullName repo
96 fullRepoName = whRepoFullName repo
86− [owner, repoName] = T.splitOn "/" fullRepoName
97+ (owner, repoName) <- case T.splitOn "/" fullRepoName of
98+ [a, b] -> return (a, b)
99+ _ -> throwError err422
87− Just sha = evPushHeadSha ev
100+ sha <- maybe (throwError err422) return $ evPushHeadSha ev
88 tasks <- getTasks
101 tasks <- getTasks
89 let task =
102 let task =
90 M.lookupMax $
103 M.lookupMax $
91 M.takeWhileAntitone (repoName >) tasks
104 M.takeWhileAntitone (repoName >) tasks
92 case task of
105 case task of
93 Just (name, TaskConfig {..}) | T.take (T.length name) repoName == name -> do
106 Just (name, TaskConfig {..}) | T.take (T.length name) repoName == name -> do
94− scheduleTask $ StatusUpdate (N owner) (N repoName) (N sha) pendingStatus
107+ nowTime <- liftIO getCurrentTime
108+ if nowTime > deadline
110+ deadlineHasPassedStatus (N owner) (N repoName) (N sha)
114+ (T.unpack fullRepoName)
117+ (Jsonb PastDeadline)
119+ schedulePendingStatus (N owner) (N repoName) (N sha)
95− createSubmission $
120+ createSubmission $
96− Submission
121+ Submission
98− (T.unpack fullRepoName)
123+ (T.unpack fullRepoName)
99− (T.unpack name)
124+ (T.unpack name)
100− (T.unpack sha)
125+ (T.unpack sha)
101− (Jsonb BuildScheduled)
126+ (Jsonb BuildScheduled)
102− scheduleTask $ Build prebuild build (N owner) (N repoName) (N sha)
127+ scheduleTask $ Build prebuild dockerfile (N owner) (N repoName) (N sha) timeoutMinutes
103− liftIO $ do
128+ logInfo . T.pack . show $ ev
106 _ -> return ()
129 _ -> return ()
108retestSubmission ::
131retestSubmission ::
109− (StaticPQ m, WithLog env Message m, MonadUnliftIO m, HasTasks m, MonadHasBaseUrl m) =>
132+ (StaticPQ m, MonadReader (ServerData m) m, MonadUnliftIO m, HasTasks m, MonadHasBaseUrl m) =>
110 String ->
133 String ->
111 String ->
134 String ->
112 String ->
135 String ->
@@ -125,9 +148,9 @@ retestSubmission owner repoName sha = do
125 M.takeWhileAntitone (repoName' >) tasks
148 M.takeWhileAntitone (repoName' >) tasks
126 case task of
149 case task of
127 Just (name, TaskConfig {..}) | T.take (T.length name) repoName' == name -> do
150 Just (name, TaskConfig {..}) | T.take (T.length name) repoName' == name -> do
128− scheduleTask $ StatusUpdate (N owner') (N repoName') (N sha') pendingStatus
151+ schedulePendingStatus (N owner') (N repoName') (N sha')
129 updateSubmissionStatus fullRepoName sha BuildScheduled
152 updateSubmissionStatus fullRepoName sha BuildScheduled
130− scheduleTask $ Build prebuild build (N owner') (N repoName') (N sha')
153+ scheduleTask $ Build prebuild dockerfile (N owner') (N repoName') (N sha') timeoutMinutes
131 _ -> return ()
154 _ -> return ()
132 redirectToSubmission owner repoName sha
155 redirectToSubmission owner repoName sha
@@ -145,44 +168,62 @@ newtype ServerM (schema :: SchemasType) a
145 MonadUnliftIO
168 MonadUnliftIO
171+instance MonadError ServerError (ServerM schema) where
172+ throwError = throwIO
148server :: ServerT API (ServerM Schema)
175server :: ServerT API (ServerM Schema)
149−server = (webhookInstallation :<|> webhookPushEvent) :<|> getSubmissionR :<|> retestSubmission
176+server = (webhookInstallation :<|> webhookPushEvent) :<|> getSubmissionR :<|> retestSubmission :<|> getResults
151−runServer :: IO ()
178+runServer :: FilePath -> IO ()
152−runServer = do
179+runServer config = do
153− c@Config {..} <- getConfig
180+ c@Config {..} <- getConfig config
182+ pem <- BS.readFile appPkPemPath
183+ appPkPem <- case readRsaPem pem of
185+ Left e -> error (show e)
155 auth <- mkInstallationAuth (AppAuth appId appPkPem) installationId
186 auth <- mkInstallationAuth (AppAuth appId appPkPem) installationId
156 ts <- newIORef mempty
187 ts <- newIORef mempty
157 pool <- createConnectionPool databseUrl 1 0.5 10
188 pool <- createConnectionPool databseUrl 1 0.5 10
158 _ <- forkIO $ do
189 _ <- forkIO $ do
159 updateTasks ts tasksPath
190 updateTasks ts tasksPath
160− threadDelay $ 10 * 60 * (10 ^ (6 :: Int))
191+ threadDelay $ 5 * 60 * (10 ^ (6 :: Int))
161 let context =
192 let context =
162 gitPolyHubKey (return webhookSecret)
193 gitPolyHubKey (return webhookSecret)
163 :. EmptyContext
194 :. EmptyContext
164− serverData = ServerData
195+ serverData =
165− { githubAppAuth = auth,
197+ { githubAppAuth = auth,
166− githubUserName = GithubUserName githubUsername,
167− githubAccessToken = GithubAccessToken personalAccessToken,
168− tasks = ts,
198+ tasks = ts,
169− logger = simpleMessageAction,
199+ logger = cfilter ((logSeverity <=) . msgSeverity) richMessageAction,
170− baseUrl = baseSiteUrl
200+ baseUrl = baseSiteUrl,
201+ dockerUrl = T.pack cfgDockerUrl,
202+ context = githubContext
172 repeatIfNotEmpty n f = f >>= \m -> do
204 repeatIfNotEmpty n f = f >>= \m -> do
173 when (m == 0) $ liftIO $ threadDelay n
205 when (m == 0) $ liftIO $ threadDelay n
174 repeatIfNotEmpty n f
206 repeatIfNotEmpty n f
175− _ <-
207+ printingErrors =
208+ handleAny (logError . T.pack . displayException >=> const (return 0))
209+ usingConnectionPool pool
210+ . (`runReaderT` serverData)
212+ . handleAny (error . displayException)
214+ replicateM_ builderCount
176− forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
215+ $ forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
177 . usingConnectionPool pool
216 . usingConnectionPool pool
178 . (`runReaderT` serverData)
217 . (`runReaderT` serverData)
180− $ runTasks @'[Build] 1
220+ $ runTasks @'[Build] 1
182 forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
222 forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
183 . usingConnectionPool pool
223 . usingConnectionPool pool
184 . (`runReaderT` serverData)
224 . (`runReaderT` serverData)
186 $ runTasks @'[StatusUpdate] 5
227 $ runTasks @'[StatusUpdate] 5
187 run port $ serveWithContext (Proxy @API) context $
228 run port $ serveWithContext (Proxy @API) context $
188 hoistServerWithContext
229 hoistServerWithContext
@@ -200,11 +241,11 @@ updateTasks ref path =
200data ServerData m
241data ServerData m
201 = ServerData
242 = ServerData
202 { githubAppAuth :: !InstallationAuth,
243 { githubAppAuth :: !InstallationAuth,
203− githubUserName :: !GithubUserName,
204− githubAccessToken :: !GithubAccessToken,
205 tasks :: IORef TasksConfig,
244 tasks :: IORef TasksConfig,
206 logger :: LogAction m Message,
245 logger :: LogAction m Message,
207− baseUrl :: String
246+ baseUrl :: T.Text,
247+ dockerUrl :: T.Text,
210newtype PolyGitHubKey = PolyGitHubKey (forall result. GitHubKey result)
251newtype PolyGitHubKey = PolyGitHubKey (forall result. GitHubKey result)
@@ -215,11 +256,18 @@ gitPolyHubKey k = PolyGitHubKey (Servant.GitHub.Webhook.gitHubKey k)
215instance HasContextEntry '[PolyGitHubKey] (GitHubKey result) where
256instance HasContextEntry '[PolyGitHubKey] (GitHubKey result) where
216 getContextEntry (PolyGitHubKey x :. _) = x
257 getContextEntry (PolyGitHubKey x :. _) = x
218−instance MonadReader (ServerData n) m => GithubCloner m where
259+instance (MonadReader (ServerData n) m, MonadIO m, MonadError ServerError m) => GithubCloner m where
219−
260+ getGithubInstallationToken =
220− getGithubAccessToken = asks githubAccessToken
261+ either
221−
262+ (\e -> throwError err500 {errBody = BSCL.pack . displayException $ e})
222− getGithubUserName = asks githubUserName
263+ return
266+ iAuth <- asks githubAppAuth
267+ manager <- newTlsManager
268+ liftIO (obtainAccessToken manager iAuth) >>= either throwError return >>= \case
269+ OAuth token -> return $ InstallationToken (BSC.unpack token)
270+ _ -> throwError $ UserError "Not OAuth token. This should never happen."
224instance (MonadIO m, MonadReader (ServerData n) m) => AppRequestable m where
272instance (MonadIO m, MonadReader (ServerData n) m) => AppRequestable m where
225 appRequest req = do
273 appRequest req = do
@@ -232,12 +280,82 @@ instance MonadReader (ServerData n) m => MonadHasBaseUrl m where
232instance (MonadIO m, MonadReader (ServerData n) m) => HasTasks m where
280instance (MonadIO m, MonadReader (ServerData n) m) => HasTasks m where
233 getTasks = asks tasks >>= liftIO . readIORef
281 getTasks = asks tasks >>= liftIO . readIORef
283+instance (MonadReader (ServerData m) m, StaticPQ m, MonadUnliftIO m) => HasGithubStatus m where
284+ schedulePendingStatus nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
294+ (Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha)
298+ scheduleTestedStatus TestResult {..} nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
301+ let total = M.size tests
302+ passed = M.size . M.filter id $ tests
303+ description = T.pack $ show passed <> "/" <> show total <> if passed == total then " ✅" else " ❌"
311+ if total == passed then StatusSuccess else StatusFailure,
312+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
313+ newStatusDescription = Just description,
314+ newStatusContext = Just c
317+ scheduleFailedStatus _ nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
326+ { newStatusState = StatusError,
327+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
328+ newStatusDescription = Just "Build failed ☠️",
329+ newStatusContext = Just c
332+ deadlineHasPassedStatus nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
341+ { newStatusState = StatusError,
342+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
343+ newStatusDescription = Just "The deadline has passed ⏰",
344+ newStatusContext = Just c
348+instance (MonadReader (ServerData n) m) => HasDockerInfo m where
349+ getDockerUrl = asks dockerUrl
235type TasksConfig = Map T.Text TaskConfig
351type TasksConfig = Map T.Text TaskConfig
237data TaskConfig
353data TaskConfig
238 = TaskConfig
354 = TaskConfig
239 { prebuild :: String,
355 { prebuild :: String,
240− build :: String
356+ dockerfile :: String,
357+ timeoutMinutes :: Int,
242 deriving stock (Show, Eq, Generic)
360 deriving stock (Show, Eq, Generic)
243 deriving anyclass (FromJSON, ToJSON)
361 deriving anyclass (FromJSON, ToJSON)
@@ -246,7 +364,6 @@ class HasTasks m where
246 getTasks :: m TasksConfig
364 getTasks :: m TasksConfig
248instance HasLog (ServerData m) Message m where
366instance HasLog (ServerData m) Message m where
250 getLogAction :: ServerData m -> LogAction m Message
367 getLogAction :: ServerData m -> LogAction m Message
251 getLogAction = logger
368 getLogAction = logger
252 {-# INLINE getLogAction #-}
369 {-# INLINE getLogAction #-}
@@ -254,3 +371,11 @@ instance HasLog (ServerData m) Message m where
254 setLogAction :: LogAction m Message -> ServerData m -> ServerData m
371 setLogAction :: LogAction m Message -> ServerData m -> ServerData m
255 setLogAction newLogAction env = env {logger = newLogAction}
372 setLogAction newLogAction env = env {logger = newLogAction}
256 {-# INLINE setLogAction #-}
373 {-# INLINE setLogAction #-}
377+instance Accept CSV where
378+ contentType Proxy = "text" // "csv"
380+instance Csv.ToRecord a => MimeRender CSV [a] where
381+ mimeRender Proxy = Csv.encode