@@ -10,12 +10,18 @@ import Colog
1010import Control.AppRequestable
1111import Control.Concurrent
1212import Control.GithubCloner
13+import Control.HasGithubStatus
1314import Control.Monad.Except
1415import Control.Monad.IO.Unlift
1516import Control.Monad.Reader
1617import Control.Task.Scheduler
18+import Crypto.PubKey.RSA.Read
1719import Data.Aeson
1820import 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
1925import Data.IORef
2026import Data.Map (Map)
2127import qualified Data.Map as M
@@ -24,14 +30,18 @@ import Data.Submission.Query
2430import Data.Tasks.Build
2531import Data.Tasks.StatusUpdate
2632import qualified Data.Text as T
33+import Data.Time.Clock
2734import Data.Yaml
2835import GHC.Generics
29−import GitHub
36+import GitHub hiding (Accept)
3037import GitHub.App.Auth
3138import GitHub.App.Request
39+import GitHub.Auth (Auth (OAuth))
3240import GitHub.Data.Name
3341import 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
3545import Network.Wai.Handler.Warp
3646import Servant
3747import Servant.GitHub.Webhook
@@ -40,9 +50,8 @@ import Server.Config
4050import Server.Html
4151import Server.Schema
4252import Squeal.PostgreSQL
43−import System.IO
4453import Text.Blaze.Html
45−import UnliftIO.Exception (try)
54+import UnliftIO.Exception (catch, displayException, handleAny, throwIO, try)
4655
4756type API =
4857 "github-webhook"
@@ -50,8 +59,8 @@ type API =
5059 :> GitHubSignedReqBody '[JSON] InstallationEvent
5160 :> Post '[JSON] ()
5261 :<|> GitHubEvent '[ 'WebhookPushEvent]
53− :> GitHubSignedReqBody '[JSON] PushEvent
54− :> Post '[JSON] ()
62+ :> GitHubSignedReqBody '[JSON] PushEvent
63+ :> Post '[JSON] ()
5564 )
5665 :<|> "submission"
5766 :> Capture "user name" String
@@ -64,18 +73,20 @@ type API =
6473 :> Capture "sha" String
6574 :> "restart"
6675 :> Post '[HTML] Markup
76+ :<|> "results" :> Capture "task name" String :> Get '[CSV, HTML] [Score]
6777
68−webhookInstallation :: MonadIO m => RepoWebhookEvent -> ((), InstallationEvent) -> m ()
78+webhookInstallation :: WithLog env Message m => RepoWebhookEvent -> ((), InstallationEvent) -> m ()
6979webhookInstallation _ ((), ev) =
70− liftIO $ do
71− print ev
72− hFlush stdout
73−
74−pendingStatus :: NewStatus
75−pendingStatus = NewStatus StatusPending Nothing Nothing Nothing
80+ logInfo . T.pack . show $ ev
7681
7782webhookPushEvent ::
78− (StaticPQ m, WithLog env Message m, MonadUnliftIO m, HasTasks m) =>
83+ ( StaticPQ m,
84+ MonadReader (ServerData m) m,
85+ MonadUnliftIO m,
86+ HasTasks m,
87+ MonadError ServerError m,
88+ WithLog env Message m
89+ ) =>
7990 RepoWebhookEvent ->
8091 ((), PushEvent) ->
8192 m ()
@@ -83,30 +94,42 @@ webhookPushEvent _ ((), ev) = do
8394 let repo = evPushRepository ev
8495 user = T.unpack . whUserLogin . evPushSender $ ev
8596 fullRepoName = whRepoFullName repo
86− [owner, repoName] = T.splitOn "/" fullRepoName
87− Just sha = evPushHeadSha ev
97+ (owner, repoName) <- case T.splitOn "/" fullRepoName of
98+ [a, b] -> return (a, b)
99+ _ -> throwError err422
100+ sha <- maybe (throwError err422) return $ evPushHeadSha ev
88101 tasks <- getTasks
89102 let task =
90103 M.lookupMax $
91104 M.takeWhileAntitone (repoName >) tasks
92105 case task of
93106 Just (name, TaskConfig {..}) | T.take (T.length name) repoName == name -> do
94− scheduleTask $ StatusUpdate (N owner) (N repoName) (N sha) pendingStatus
95− createSubmission $
96− Submission
97− user
98− (T.unpack fullRepoName)
99− (T.unpack name)
100− (T.unpack sha)
101− (Jsonb BuildScheduled)
102− scheduleTask $ Build prebuild dockerfile (N owner) (N repoName) (N sha)
103− liftIO $ do
104− print ev
105− hFlush stdout
107+ nowTime <- liftIO getCurrentTime
108+ if nowTime > deadline
109+ then do
110+ deadlineHasPassedStatus (N owner) (N repoName) (N sha)
111+ createSubmission $
112+ Submission
113+ user
114+ (T.unpack fullRepoName)
115+ (T.unpack name)
116+ (T.unpack sha)
117+ (Jsonb PastDeadline)
118+ else do
119+ schedulePendingStatus (N owner) (N repoName) (N sha)
120+ createSubmission $
121+ Submission
122+ user
123+ (T.unpack fullRepoName)
124+ (T.unpack name)
125+ (T.unpack sha)
126+ (Jsonb BuildScheduled)
127+ scheduleTask $ Build prebuild dockerfile (N owner) (N repoName) (N sha) timeoutMinutes
128+ logInfo . T.pack . show $ ev
106129 _ -> return ()
107130
108131retestSubmission ::
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) =>
110133 String ->
111134 String ->
112135 String ->
@@ -125,9 +148,9 @@ retestSubmission owner repoName sha = do
125148 M.takeWhileAntitone (repoName' >) tasks
126149 case task of
127150 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')
129152 updateSubmissionStatus fullRepoName sha BuildScheduled
130− scheduleTask $ Build prebuild dockerfile (N owner') (N repoName') (N sha')
153+ scheduleTask $ Build prebuild dockerfile (N owner') (N repoName') (N sha') timeoutMinutes
131154 _ -> return ()
132155 redirectToSubmission owner repoName sha
133156
@@ -145,13 +168,21 @@ newtype ServerM (schema :: SchemasType) a
145168 MonadUnliftIO
146169 )
147170
171+instance MonadError ServerError (ServerM schema) where
172+ throwError = throwIO
173+ catchError = catch
174+
148175server :: ServerT API (ServerM Schema)
149−server = (webhookInstallation :<|> webhookPushEvent) :<|> getSubmissionR :<|> retestSubmission
176+server = (webhookInstallation :<|> webhookPushEvent) :<|> getSubmissionR :<|> retestSubmission :<|> getResults
150177
151−runServer :: IO ()
152−runServer = do
153− c@Config {..} <- getConfig
178+runServer :: FilePath -> IO ()
179+runServer config = do
180+ c@Config {..} <- getConfig config
154181 print c
182+ pem <- BS.readFile appPkPemPath
183+ appPkPem <- case readRsaPem pem of
184+ Right k -> return k
185+ Left e -> error (show e)
155186 auth <- mkInstallationAuth (AppAuth appId appPkPem) installationId
156187 ts <- newIORef mempty
157188 pool <- createConnectionPool databseUrl 1 0.5 10
@@ -161,29 +192,38 @@ runServer = do
161192 let context =
162193 gitPolyHubKey (return webhookSecret)
163194 :. EmptyContext
164− serverData = ServerData
165− { githubAppAuth = auth,
166− githubUserName = GithubUserName githubUsername,
167− githubAccessToken = GithubAccessToken personalAccessToken,
168− tasks = ts,
169− logger = simpleMessageAction,
170− baseUrl = baseSiteUrl,
171− dockerUrl = T.pack cfgDockerUrl
172− }
195+ serverData =
196+ ServerData
197+ { githubAppAuth = auth,
198+ tasks = ts,
199+ logger = cfilter ((logSeverity <=) . msgSeverity) richMessageAction,
200+ baseUrl = baseSiteUrl,
201+ dockerUrl = T.pack cfgDockerUrl,
202+ context = githubContext
203+ }
173204 repeatIfNotEmpty n f = f >>= \m -> do
174205 when (m == 0) $ liftIO $ threadDelay n
175206 repeatIfNotEmpty n f
176− _ <-
177− forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
207+ printingErrors =
208+ handleAny (logError . T.pack . displayException >=> const (return 0))
209+ usingConnectionPool pool
210+ . (`runReaderT` serverData)
211+ . unApp
212+ . handleAny (error . displayException)
213+ $ makeAllRunnable
214+ replicateM_ builderCount
215+ $ forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
178216 . usingConnectionPool pool
179217 . (`runReaderT` serverData)
180218 . unApp
181− $ runTasks @'[Build] 1
219+ . printingErrors
220+ $ runTasks @'[Build] 1
182221 _ <-
183222 forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
184223 . usingConnectionPool pool
185224 . (`runReaderT` serverData)
186225 . unApp
226+ . printingErrors
187227 $ runTasks @'[StatusUpdate] 5
188228 run port $ serveWithContext (Proxy @API) context $
189229 hoistServerWithContext
@@ -201,12 +241,11 @@ updateTasks ref path =
201241data ServerData m
202242 = ServerData
203243 { githubAppAuth :: !InstallationAuth,
204− githubUserName :: !GithubUserName,
205− githubAccessToken :: !GithubAccessToken,
206244 tasks :: IORef TasksConfig,
207245 logger :: LogAction m Message,
208− baseUrl :: String,
209− dockerUrl :: T.Text
246+ baseUrl :: T.Text,
247+ dockerUrl :: T.Text,
248+ context :: T.Text
210249 }
211250
212251newtype PolyGitHubKey = PolyGitHubKey (forall result. GitHubKey result)
@@ -217,11 +256,18 @@ gitPolyHubKey k = PolyGitHubKey (Servant.GitHub.Webhook.gitHubKey k)
217256instance HasContextEntry '[PolyGitHubKey] (GitHubKey result) where
218257 getContextEntry (PolyGitHubKey x :. _) = x
219258
220−instance MonadReader (ServerData n) m => GithubCloner m where
221−
222− getGithubAccessToken = asks githubAccessToken
223−
224− getGithubUserName = asks githubUserName
259+instance (MonadReader (ServerData n) m, MonadIO m, MonadError ServerError m) => GithubCloner m where
260+ getGithubInstallationToken =
261+ either
262+ (\e -> throwError err500 {errBody = BSCL.pack . displayException $ e})
263+ return
264+ <=< runExceptT
265+ $ do
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."
225271
226272instance (MonadIO m, MonadReader (ServerData n) m) => AppRequestable m where
227273 appRequest req = do
@@ -234,6 +280,71 @@ instance MonadReader (ServerData n) m => MonadHasBaseUrl m where
234280instance (MonadIO m, MonadReader (ServerData n) m) => HasTasks m where
235281 getTasks = asks tasks >>= liftIO . readIORef
236282
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
285+ h <- asks baseUrl
286+ c <- asks context
287+ scheduleTask $
288+ StatusUpdate
289+ nOwner
290+ nRepo
291+ nCommit
292+ ( NewStatus
293+ StatusPending
294+ (Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha)
295+ (Just "⌛")
296+ (Just c)
297+ )
298+ scheduleTestedStatus TestResult {..} nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
299+ h <- asks baseUrl
300+ c <- asks context
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 " ❌"
304+ status =
305+ StatusUpdate
306+ nOwner
307+ nRepo
308+ nCommit
309+ NewStatus
310+ { newStatusState =
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
315+ }
316+ scheduleTask status
317+ scheduleFailedStatus _ nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
318+ h <- asks baseUrl
319+ c <- asks context
320+ let status =
321+ StatusUpdate
322+ nOwner
323+ nRepo
324+ nCommit
325+ NewStatus
326+ { newStatusState = StatusError,
327+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
328+ newStatusDescription = Just "Build failed ☠️",
329+ newStatusContext = Just c
330+ }
331+ scheduleTask status
332+ deadlineHasPassedStatus nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
333+ h <- asks baseUrl
334+ c <- asks context
335+ let status =
336+ StatusUpdate
337+ nOwner
338+ nRepo
339+ nCommit
340+ NewStatus
341+ { newStatusState = StatusError,
342+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
343+ newStatusDescription = Just "The deadline has passed ⏰",
344+ newStatusContext = Just c
345+ }
346+ scheduleTask status
347+
237348instance (MonadReader (ServerData n) m) => HasDockerInfo m where
238349 getDockerUrl = asks dockerUrl
239350
@@ -242,8 +353,9 @@ type TasksConfig = Map T.Text TaskConfig
242353data TaskConfig
243354 = TaskConfig
244355 { prebuild :: String,
245− dockerfile :: String
246− -- build :: T.Text
356+ dockerfile :: String,
357+ timeoutMinutes :: Int,
358+ deadline :: UTCTime
247359 }
248360 deriving stock (Show, Eq, Generic)
249361 deriving anyclass (FromJSON, ToJSON)
@@ -252,7 +364,6 @@ class HasTasks m where
252364 getTasks :: m TasksConfig
253365
254366instance HasLog (ServerData m) Message m where
255−
256367 getLogAction :: ServerData m -> LogAction m Message
257368 getLogAction = logger
258369 {-# INLINE getLogAction #-}
@@ -260,3 +371,11 @@ instance HasLog (ServerData m) Message m where
260371 setLogAction :: LogAction m Message -> ServerData m -> ServerData m
261372 setLogAction newLogAction env = env {logger = newLogAction}
262373 {-# INLINE setLogAction #-}
374+
375+data CSV
376+
377+instance Accept CSV where
378+ contentType Proxy = "text" // "csv"
379+
380+instance Csv.ToRecord a => MimeRender CSV [a] where
381+ mimeRender Proxy = Csv.encode