@@ -10,12 +10,17 @@ 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
1924import qualified Data.Csv as Csv
2025import Data.IORef
2126import Data.Map (Map)
@@ -25,14 +30,17 @@ import Data.Submission.Query
2530import Data.Tasks.Build
2631import Data.Tasks.StatusUpdate
2732import qualified Data.Text as T
33+import Data.Time.Clock
2834import Data.Yaml
2935import GHC.Generics
3036import GitHub hiding (Accept)
3137import GitHub.App.Auth
3238import GitHub.App.Request
39+import GitHub.Auth (Auth (OAuth))
3340import GitHub.Data.Name
3441import GitHub.Data.Webhooks.Events
35−import GitHub.Data.Webhooks.Payload
42+import GitHub.Data.Webhooks.Payload hiding (URL (..))
43+import Network.HTTP.Client.TLS (newTlsManager)
3644import Network.HTTP.Media.MediaType
3745import Network.Wai.Handler.Warp
3846import Servant
@@ -42,9 +50,8 @@ import Server.Config
4250import Server.Html
4351import Server.Schema
4452import Squeal.PostgreSQL
45−import System.IO
4653import Text.Blaze.Html
47−import UnliftIO.Exception (try)
54+import UnliftIO.Exception (catch, displayException, handleAny, throwIO, try)
4855
4956type API =
5057 "github-webhook"
@@ -52,8 +59,8 @@ type API =
5259 :> GitHubSignedReqBody '[JSON] InstallationEvent
5360 :> Post '[JSON] ()
5461 :<|> GitHubEvent '[ 'WebhookPushEvent]
55− :> GitHubSignedReqBody '[JSON] PushEvent
56− :> Post '[JSON] ()
62+ :> GitHubSignedReqBody '[JSON] PushEvent
63+ :> Post '[JSON] ()
5764 )
5865 :<|> "submission"
5966 :> Capture "user name" String
@@ -68,17 +75,18 @@ type API =
6875 :> Post '[HTML] Markup
6976 :<|> "results" :> Capture "task name" String :> Get '[CSV, HTML] [Score]
7077
71−webhookInstallation :: MonadIO m => RepoWebhookEvent -> ((), InstallationEvent) -> m ()
78+webhookInstallation :: WithLog env Message m => RepoWebhookEvent -> ((), InstallationEvent) -> m ()
7279webhookInstallation _ ((), ev) =
73− liftIO $ do
74− print ev
75− hFlush stdout
76−
77−pendingStatus :: NewStatus
78−pendingStatus = NewStatus StatusPending Nothing Nothing Nothing
80+ logInfo . T.pack . show $ ev
7981
8082webhookPushEvent ::
81− (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+ ) =>
8290 RepoWebhookEvent ->
8391 ((), PushEvent) ->
8492 m ()
@@ -86,30 +94,42 @@ webhookPushEvent _ ((), ev) = do
8694 let repo = evPushRepository ev
8795 user = T.unpack . whUserLogin . evPushSender $ ev
8896 fullRepoName = whRepoFullName repo
89− [owner, repoName] = T.splitOn "/" fullRepoName
90− 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
91101 tasks <- getTasks
92102 let task =
93103 M.lookupMax $
94104 M.takeWhileAntitone (repoName >) tasks
95105 case task of
96106 Just (name, TaskConfig {..}) | T.take (T.length name) repoName == name -> do
97− scheduleTask $ StatusUpdate (N owner) (N repoName) (N sha) pendingStatus
98− createSubmission $
99− Submission
100− user
101− (T.unpack fullRepoName)
102− (T.unpack name)
103− (T.unpack sha)
104− (Jsonb BuildScheduled)
105− scheduleTask $ Build prebuild dockerfile (N owner) (N repoName) (N sha)
106− liftIO $ do
107− print ev
108− 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
109129 _ -> return ()
110130
111131retestSubmission ::
112− (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) =>
113133 String ->
114134 String ->
115135 String ->
@@ -128,9 +148,9 @@ retestSubmission owner repoName sha = do
128148 M.takeWhileAntitone (repoName' >) tasks
129149 case task of
130150 Just (name, TaskConfig {..}) | T.take (T.length name) repoName' == name -> do
131− scheduleTask $ StatusUpdate (N owner') (N repoName') (N sha') pendingStatus
151+ schedulePendingStatus (N owner') (N repoName') (N sha')
132152 updateSubmissionStatus fullRepoName sha BuildScheduled
133− scheduleTask $ Build prebuild dockerfile (N owner') (N repoName') (N sha')
153+ scheduleTask $ Build prebuild dockerfile (N owner') (N repoName') (N sha') timeoutMinutes
134154 _ -> return ()
135155 redirectToSubmission owner repoName sha
136156
@@ -148,13 +168,21 @@ newtype ServerM (schema :: SchemasType) a
148168 MonadUnliftIO
149169 )
150170
171+instance MonadError ServerError (ServerM schema) where
172+ throwError = throwIO
173+ catchError = catch
174+
151175server :: ServerT API (ServerM Schema)
152176server = (webhookInstallation :<|> webhookPushEvent) :<|> getSubmissionR :<|> retestSubmission :<|> getResults
153177
154−runServer :: IO ()
155−runServer = do
156− c@Config {..} <- getConfig
178+runServer :: FilePath -> IO ()
179+runServer config = do
180+ c@Config {..} <- getConfig config
157181 print c
182+ pem <- BS.readFile appPkPemPath
183+ appPkPem <- case readRsaPem pem of
184+ Right k -> return k
185+ Left e -> error (show e)
158186 auth <- mkInstallationAuth (AppAuth appId appPkPem) installationId
159187 ts <- newIORef mempty
160188 pool <- createConnectionPool databseUrl 1 0.5 10
@@ -164,29 +192,38 @@ runServer = do
164192 let context =
165193 gitPolyHubKey (return webhookSecret)
166194 :. EmptyContext
167− serverData = ServerData
168− { githubAppAuth = auth,
169− githubUserName = GithubUserName githubUsername,
170− githubAccessToken = GithubAccessToken personalAccessToken,
171− tasks = ts,
172− logger = cfilter ((logSeverity <=) . msgSeverity) richMessageAction,
173− baseUrl = baseSiteUrl,
174− dockerUrl = T.pack cfgDockerUrl
175− }
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+ }
176204 repeatIfNotEmpty n f = f >>= \m -> do
177205 when (m == 0) $ liftIO $ threadDelay n
178206 repeatIfNotEmpty n f
179− _ <-
180− 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))
181216 . usingConnectionPool pool
182217 . (`runReaderT` serverData)
183218 . unApp
184− $ runTasks @'[Build] 1
219+ . printingErrors
220+ $ runTasks @'[Build] 1
185221 _ <-
186222 forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
187223 . usingConnectionPool pool
188224 . (`runReaderT` serverData)
189225 . unApp
226+ . printingErrors
190227 $ runTasks @'[StatusUpdate] 5
191228 run port $ serveWithContext (Proxy @API) context $
192229 hoistServerWithContext
@@ -204,12 +241,11 @@ updateTasks ref path =
204241data ServerData m
205242 = ServerData
206243 { githubAppAuth :: !InstallationAuth,
207− githubUserName :: !GithubUserName,
208− githubAccessToken :: !GithubAccessToken,
209244 tasks :: IORef TasksConfig,
210245 logger :: LogAction m Message,
211− baseUrl :: String,
212− dockerUrl :: T.Text
246+ baseUrl :: T.Text,
247+ dockerUrl :: T.Text,
248+ context :: T.Text
213249 }
214250
215251newtype PolyGitHubKey = PolyGitHubKey (forall result. GitHubKey result)
@@ -220,11 +256,18 @@ gitPolyHubKey k = PolyGitHubKey (Servant.GitHub.Webhook.gitHubKey k)
220256instance HasContextEntry '[PolyGitHubKey] (GitHubKey result) where
221257 getContextEntry (PolyGitHubKey x :. _) = x
222258
223−instance MonadReader (ServerData n) m => GithubCloner m where
224−
225− getGithubAccessToken = asks githubAccessToken
226−
227− 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."
228271
229272instance (MonadIO m, MonadReader (ServerData n) m) => AppRequestable m where
230273 appRequest req = do
@@ -237,6 +280,71 @@ instance MonadReader (ServerData n) m => MonadHasBaseUrl m where
237280instance (MonadIO m, MonadReader (ServerData n) m) => HasTasks m where
238281 getTasks = asks tasks >>= liftIO . readIORef
239282
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+
240348instance (MonadReader (ServerData n) m) => HasDockerInfo m where
241349 getDockerUrl = asks dockerUrl
242350
@@ -245,8 +353,9 @@ type TasksConfig = Map T.Text TaskConfig
245353data TaskConfig
246354 = TaskConfig
247355 { prebuild :: String,
248− dockerfile :: String
249− -- build :: T.Text
356+ dockerfile :: String,
357+ timeoutMinutes :: Int,
358+ deadline :: UTCTime
250359 }
251360 deriving stock (Show, Eq, Generic)
252361 deriving anyclass (FromJSON, ToJSON)
@@ -255,7 +364,6 @@ class HasTasks m where
255364 getTasks :: m TasksConfig
256365
257366instance HasLog (ServerData m) Message m where
258−
259367 getLogAction :: ServerData m -> LogAction m Message
260368 getLogAction = logger
261369 {-# INLINE getLogAction #-}