@@ -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 build (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 build (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,44 +168,62 @@ 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
158189 _ <- forkIO $ do
159190 updateTasks ts tasksPath
160− threadDelay $ 10 * 60 * (10 ^ (6 :: Int))
191+ threadDelay $ 5 * 60 * (10 ^ (6 :: Int))
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− }
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+ }
172204 repeatIfNotEmpty n f = f >>= \m -> do
173205 when (m == 0) $ liftIO $ threadDelay n
174206 repeatIfNotEmpty n f
175− _ <-
176− 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))
177216 . usingConnectionPool pool
178217 . (`runReaderT` serverData)
179218 . unApp
180− $ runTasks @'[Build] 1
219+ . printingErrors
220+ $ runTasks @'[Build] 1
181221 _ <-
182222 forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
183223 . usingConnectionPool pool
184224 . (`runReaderT` serverData)
185225 . unApp
226+ . printingErrors
186227 $ runTasks @'[StatusUpdate] 5
187228 run port $ serveWithContext (Proxy @API) context $
188229 hoistServerWithContext
@@ -200,11 +241,11 @@ updateTasks ref path =
200241data ServerData m
201242 = ServerData
202243 { githubAppAuth :: !InstallationAuth,
203− githubUserName :: !GithubUserName,
204− githubAccessToken :: !GithubAccessToken,
205244 tasks :: IORef TasksConfig,
206245 logger :: LogAction m Message,
207− baseUrl :: String
246+ baseUrl :: T.Text,
247+ dockerUrl :: T.Text,
248+ context :: T.Text
208249 }
209250
210251newtype PolyGitHubKey = PolyGitHubKey (forall result. GitHubKey result)
@@ -215,11 +256,18 @@ gitPolyHubKey k = PolyGitHubKey (Servant.GitHub.Webhook.gitHubKey k)
215256instance HasContextEntry '[PolyGitHubKey] (GitHubKey result) where
216257 getContextEntry (PolyGitHubKey x :. _) = x
217258
218−instance MonadReader (ServerData n) m => GithubCloner m where
219−
220− getGithubAccessToken = asks githubAccessToken
221−
222− 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."
223271
224272instance (MonadIO m, MonadReader (ServerData n) m) => AppRequestable m where
225273 appRequest req = do
@@ -232,12 +280,82 @@ instance MonadReader (ServerData n) m => MonadHasBaseUrl m where
232280instance (MonadIO m, MonadReader (ServerData n) m) => HasTasks m where
233281 getTasks = asks tasks >>= liftIO . readIORef
234282
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+
348+instance (MonadReader (ServerData n) m) => HasDockerInfo m where
349+ getDockerUrl = asks dockerUrl
350+
235351type TasksConfig = Map T.Text TaskConfig
236352
237353data TaskConfig
238354 = TaskConfig
239355 { prebuild :: String,
240− build :: String
356+ dockerfile :: String,
357+ timeoutMinutes :: Int,
358+ deadline :: UTCTime
241359 }
242360 deriving stock (Show, Eq, Generic)
243361 deriving anyclass (FromJSON, ToJSON)
@@ -246,7 +364,6 @@ class HasTasks m where
246364 getTasks :: m TasksConfig
247365
248366instance HasLog (ServerData m) Message m where
249−
250367 getLogAction :: ServerData m -> LogAction m Message
251368 getLogAction = logger
252369 {-# INLINE getLogAction #-}
@@ -254,3 +371,11 @@ instance HasLog (ServerData m) Message m where
254371 setLogAction :: LogAction m Message -> ServerData m -> ServerData m
255372 setLogAction newLogAction env = env {logger = newLogAction}
256373 {-# 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