diff --git a/.github/workflows/github-actions-image.yaml b/.github/workflows/github-actions-image.yaml new file mode 100644 index 0000000..8e4bce0 --- /dev/null +++ b/.github/workflows/github-actions-image.yaml @@ -0,0 +1,56 @@ +name: Github Action Docker Image + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build-github-action-image: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Increase swap + run: | + free -h + + sudo swapoff /mnt/swapfile + sudo fallocate -l 12G /mnt/swapfile + sudo mkswap /mnt/swapfile + sudo swapon /mnt/swapfile + + free -h + + - name: Install Nix + uses: cachix/install-nix-action@v12 + with: + extra_nix_config: | + trusted-public-keys = hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ= cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= + substituters = https://hydra.iohk.io https://cache.nixos.org/ + + - name: Login to Cachix + uses: cachix/cachix-action@v8 + with: + name: octopod + signingKey: ${{ secrets.CACHIX_SIGNING_KEY }} + + - name: Build Github Action Docker Image + run: | + nix-build -A compaRESTGithubAction -j auto -o image + + - name: Log into Docker Hub + if: github.ref == 'refs/heads/master' + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Upload Image to Docker Hub + if: github.ref == 'refs/heads/master' + run: | + docker load -i image + docker push typeable/comparest-github-action:latest diff --git a/.github/workflows/haskell.yml b/.github/workflows/linux-binary.yaml similarity index 95% rename from .github/workflows/haskell.yml rename to .github/workflows/linux-binary.yaml index 0fe162f..7c7befb 100644 --- a/.github/workflows/haskell.yml +++ b/.github/workflows/linux-binary.yaml @@ -1,4 +1,4 @@ -name: Haskell CI +name: Linux binary on: push: @@ -27,7 +27,7 @@ jobs: free -h sudo swapoff /mnt/swapfile - sudo fallocate -l 12G /mnt/swapfile + sudo fallocate -l 13G /mnt/swapfile sudo mkswap /mnt/swapfile sudo swapon /mnt/swapfile diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..b6fce0e --- /dev/null +++ b/action.yaml @@ -0,0 +1,47 @@ +name: compaREST +author: Typeable +description: API reliability checker +inputs: + GITHUB_TOKEN: + description: "The GitHub access token (e.g. secrets.GITHUB_TOKEN) used to create or update the comment. This defaults to {{ github.token }}." + default: "${{ github.token }}" + required: false + repo: + description: The owner of the repo in which to post the comment. + default: "${{ github.repository }}" + required: false + pull_request: + description: The pull request in which to post the comment. + default: "${{ github.event.pull_request.number }}" + required: false + project_name: + description: The name of the project to which the API pertains. + required: true + footer: + description: A footer that can be appended to the comment. + required: false + default: "" + old: + description: The path to old specification of the API. + required: true + new: + description: The path to new specification of the API. + required: true +runs: + using: "docker" + image: "typeable/comparest-github-action:latest" + env: + GITHUB_TOKEN: "${{ inputs.GITHUB_TOKEN }}" + REPO: "${{ inputs.repo }}" + PR_NUMBER: "${{ inputs.pull_request }}" + PROJECT_NAME: "${{ inputs.project_name }}" + FOOTER: "${{ inputs.footer }}" + ROOT: "/github/workspace" + OLD: "${{ inputs.old }}" + NEW: "${{ inputs.new }}" + pre-entrypoint: "/bin/pre" + entrypoint: "/bin/run" + +branding: + icon: crosshair + color: gray-dark diff --git a/app/Main.hs b/app/Main.hs index 223fd63..76151a6 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -14,6 +14,7 @@ import OpenAPI.Checker.Run import System.Exit import System.IO import Text.Pandoc hiding (report) +import Text.Pandoc.Builder main :: IO () main = do @@ -47,7 +48,7 @@ main = do } (report, status) = runReport reportConfig (a, b) case mode opts of - Just _ -> either handler pure <=< runExceptT $ write report + Just _ -> either handler pure <=< runExceptT $ write $ doc report Nothing -> pure () when (signalExitCode opts) $ case status of diff --git a/default.nix b/default.nix index ec74316..86de6d2 100644 --- a/default.nix +++ b/default.nix @@ -1,33 +1,71 @@ { sources ? import ./nix/sources.nix , haskellNix ? import sources.haskellNix { inherit system; } -, pkgs ? import haskellNix.sources.nixpkgs-2105 (haskellNix.nixpkgsArgs // {inherit system;}) +, pkgs ? import haskellNix.sources.nixpkgs-2105 (haskellNix.nixpkgsArgs // { inherit system; }) , system ? builtins.currentSystem +, nix-filter ? import sources.nix-filter }: let hsPkgs = pkgs.haskell-nix.stackProject { - src = ./.; + src = nix-filter { + root = ./.; + name = "compaREST"; + include = [ + ./stack.yaml + ./stack.yaml.lock + ./openapi-diff.cabal + ]; + }; modules = [ { dontStrip = false; dontPatchELF = false; enableDeadCodeElimination = true; + packages.openapi-diff.src = nix-filter { + root = ./.; + name = "compaREST-src"; + include = with nix-filter; [ + (./openapi-diff.cabal) + (inDirectory ./test) + (inDirectory ./src) + (inDirectory ./app) + (inDirectory ./github-action) + ./awsm-css/dist/awsm.min.css + ./LICENSE + ]; + }; } ]; }; - compaREST-static = pkgs.runCommand "compaREST-static" { } '' - mkdir $out - cp ${hsPkgs.projectCross.musl64.hsPkgs.openapi-diff.components.exes.openapi-diff + "/bin/openapi-diff"} $out/compaREST + staticify = name: drv: pkgs.runCommand name { } '' + mkdir -p $out/bin + cp ${drv + "/bin"}/* $out/bin - ${pkgs.nukeReferences}/bin/nuke-refs $out/compaREST + ${pkgs.nukeReferences}/bin/nuke-refs $out/bin/* ''; + compaREST = pkgs.dockerTools.buildImage { name = "compaREST"; - contents = [compaREST-static]; + contents = [ (staticify "compaREST-static" hsPkgs.projectCross.musl64.hsPkgs.openapi-diff.components.exes.openapi-diff) ]; config = { - Entrypoint = [ "/compaREST" ]; + Entrypoint = [ "/bin/openapi-diff" ]; }; }; -in compaREST + compaRESTGithubAction = + let + action = staticify "compaREST-github-action-static" hsPkgs.projectCross.musl64.hsPkgs.openapi-diff.components.exes.comparest-github-action; + wrapped = pkgs.runCommand "wrapped-compaREST-github-action" { buildInputs = [ pkgs.makeWrapper ]; } '' + makeWrapper ${action}/bin/comparest-github-action $out/bin/pre --add-flags "pre" + makeWrapper ${action}/bin/comparest-github-action $out/bin/run --add-flags "run" + ''; + in + pkgs.dockerTools.buildImage { + name = "typeable/comparest-github-action"; + tag = "latest"; + contents = [ wrapped pkgs.cacert ]; + }; + +in +{ inherit compaREST compaRESTGithubAction; } diff --git a/github-action/CompaREST/GitHub/API.hs b/github-action/CompaREST/GitHub/API.hs new file mode 100644 index 0000000..2fd526b --- /dev/null +++ b/github-action/CompaREST/GitHub/API.hs @@ -0,0 +1,55 @@ +module CompaREST.GitHub.API + ( mapComment, + createOrUpdateComment, + ) +where + +import CompaREST.GitHub.Action.Config +import Control.Monad +import Control.Monad.Freer +import Control.Monad.Freer.GitHub +import Control.Monad.Freer.Reader +import Data.Foldable +import Data.Proxy +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Vector as V +import GitHub +import qualified GitHub as GH + +findComment :: Members '[GitHub, Reader Config] effs => Eff effs (Maybe GH.IssueComment) +findComment = do + Config {..} <- ask + comments <- sendGitHub $ GH.commentsR repoOwner repoName issue GH.FetchAll + htmlComment <- getHTMLComment + let tryStripPrefix :: GH.IssueComment -> Maybe GH.IssueComment + tryStripPrefix c@GH.IssueComment {issueCommentBody = (T.stripSuffix htmlComment -> Just b)} = + Just $ c {GH.issueCommentBody = b} + tryStripPrefix _ = Nothing + pure . (V.!? 0) $ V.mapMaybe tryStripPrefix comments + +mapComment :: Members '[GitHub, Reader Config] effs => (Text -> Text) -> Eff effs () +mapComment f = do + findComment + >>= traverse_ + ( \comment -> do + Config {..} <- ask + htmlComment <- getHTMLComment + sendGitHub $ editCommentR repoOwner repoName (GH.mkId Proxy $ GH.issueCommentId comment) ((<> htmlComment) . f $ GH.issueCommentBody comment) + pure () + ) + +createOrUpdateComment :: Members '[GitHub, Reader Config] effs => Text -> Eff effs () +createOrUpdateComment body' = do + Config {..} <- ask + htmlComment <- getHTMLComment + let body = body' <> htmlComment + void $ + findComment >>= \case + Just comment -> sendGitHub $ editCommentR repoOwner repoName (GH.mkId Proxy $ GH.issueCommentId comment) body + Nothing -> sendGitHub $ createCommentR repoOwner repoName issue body + +getHTMLComment :: Member (Reader Config) effs => Eff effs Text +getHTMLComment = do + name <- asks projectName + pure $ "\n\n" diff --git a/github-action/CompaREST/GitHub/Action/Config.hs b/github-action/CompaREST/GitHub/Action/Config.hs new file mode 100644 index 0000000..1761170 --- /dev/null +++ b/github-action/CompaREST/GitHub/Action/Config.hs @@ -0,0 +1,43 @@ +module CompaREST.GitHub.Action.Config + ( Config (..), + ) +where + +import Data.Proxy +import Data.Text (Text) +import qualified Data.Text as T +import qualified GitHub as GH + +import System.Envy + +data Config = Config + { githubToken :: GH.Auth + , repoOwner :: GH.Name GH.Owner + , repoName :: GH.Name GH.Repo + , issue :: GH.IssueNumber + , projectName :: Text + , footerText :: Text + , root :: FilePath + } + +instance FromEnv Config where + fromEnv _ = do + token <- GH.OAuth <$> env "GITHUB_TOKEN" + (owner, repo) <- + T.split (== '/') <$> env "REPO" >>= \case + [owner, name] -> pure (owner, name) + _ -> fail "malformed repo" + issue <- GH.IssueNumber <$> env "PR_NUMBER" + projectName <- env "PROJECT_NAME" + footerText <- env "FOOTER" + root <- envMaybe "ROOT" .!= "." + pure $ + Config + { githubToken = token + , repoOwner = GH.mkName Proxy owner + , repoName = GH.mkName Proxy repo + , issue = issue + , projectName = projectName + , footerText = footerText + , root = root + } diff --git a/github-action/Control/Monad/Freer/GitHub.hs b/github-action/Control/Monad/Freer/GitHub.hs new file mode 100644 index 0000000..6579746 --- /dev/null +++ b/github-action/Control/Monad/Freer/GitHub.hs @@ -0,0 +1,27 @@ +module Control.Monad.Freer.GitHub + ( GitHub (..), + runGitHub, + sendGitHub, + ) +where + +import Control.Monad.Freer +import Control.Monad.Freer.Error +import Control.Monad.IO.Class +import Data.Aeson +import GitHub hiding (Error) +import qualified GitHub as GH + +data GitHub r where + SendGHRequest :: FromJSON x => Request 'RW x -> GitHub x + +runGitHub :: (Member (Error GH.Error) effs, MonadIO (Eff effs)) => Auth -> Eff (GitHub ': effs) ~> Eff effs +runGitHub auth = + interpret + ( \(SendGHRequest req) -> + liftIO (executeRequest auth req) + >>= either throwError pure + ) + +sendGitHub :: (FromJSON x, Member GitHub effs) => Request 'RW x -> Eff effs x +sendGitHub req = send $ SendGHRequest req diff --git a/github-action/Main.hs b/github-action/Main.hs new file mode 100644 index 0000000..5ba7b6c --- /dev/null +++ b/github-action/Main.hs @@ -0,0 +1,87 @@ +module Main (main) where + +import CompaREST.GitHub.API +import CompaREST.GitHub.Action.Config +import Control.Exception +import Control.Monad.Freer +import Control.Monad.Freer.Error +import Control.Monad.Freer.GitHub +import Control.Monad.Freer.Reader +import Data.Text (Text) +import qualified Data.Yaml.Aeson as Yaml +import qualified GitHub as GH +import OpenAPI.Checker.Run +import System.Environment +import System.Envy (decodeEnv) +import System.FilePath (()) +import Text.Pandoc (runPure) +import Text.Pandoc.Builder +import Text.Pandoc.Options +import Text.Pandoc.Writers + +main :: IO () +main = do + cfg <- decodeEnv >>= either error pure + getArgs >>= \case + ["pre"] -> runPre cfg + ["run"] -> do + oldFile <- getEnv "OLD" + newFile <- getEnv "NEW" + runRun cfg (root cfg oldFile) (root cfg newFile) + _ -> error "Invalid arguments." + +runner :: Config -> Eff '[GitHub, Error GH.Error, Reader Config, IO] a -> IO a +runner cfg = + runM @IO . runReader cfg + . flip (handleError @GH.Error) (error . displayException) + . runGitHub (githubToken cfg) + +runPre :: Config -> IO () +runPre cfg = + runner cfg $ + mapComment + (markdown (header 4 "⏳ Report might not be accurate. Attempting to update." <> horizontalRule) <>) + +runRun :: Config -> FilePath -> FilePath -> IO () +runRun cfg old' new' = runner cfg $ do + old <- Yaml.decodeFileThrow old' + new <- Yaml.decodeFileThrow new' + let reportConfig = + ReportConfig + { treeStyle = FoldingBlockquotesTreeStyle + , reportMode = All + } + (report, status) = runReport reportConfig (old, new) + + summaryDetail s d = + rawHtml "
" + <> rawHtml "" + <> s + <> rawHtml "" + <> d + <> rawHtml "
" + where + rawHtml = rawBlock "html" + + message = + header 3 (text $ "⛄ compaREST – " <> projectName cfg) + <> if old == new + then header 1 "✅ The API did not change" + else + header + 1 + ( case status of + BreakingChanges -> "⚠️ Breaking changes found!" + NoBreakingChanges -> "No breaking changes found ✨" + OnlyUnsupportedChanges -> "🤷 Couldn't determine compatibility" + ) + <> summaryDetail (plain "ℹ️ Details") report + messageBody = markdown message <> "\n\n" <> footerText cfg + createOrUpdateComment messageBody + +markdown :: Blocks -> Text +markdown = + either (error . displayException) id + . runPure + . writeHtml5String def + . doc diff --git a/nix/sources.json b/nix/sources.json index a17dbc5..4275e52 100644 --- a/nix/sources.json +++ b/nix/sources.json @@ -5,10 +5,10 @@ "homepage": "https://input-output-hk.github.io/haskell.nix", "owner": "input-output-hk", "repo": "haskell.nix", - "rev": "8c587f90d2986f87a8212c3469521db512bc7ed9", - "sha256": "0a4mz47ccvl1nl2p08zbn33i1vmak4bjr4z6wqzzflnq90hg5a0s", + "rev": "1992b910e9b4d880e1f4639f4fe7266bceb9a5ad", + "sha256": "15w3wixjnb52f7cj3s94fy0zc0l1gc94p3n2wyq3qqxj10hvirfw", "type": "tarball", - "url": "https://github.com/input-output-hk/haskell.nix/archive/8c587f90d2986f87a8212c3469521db512bc7ed9.tar.gz", + "url": "https://github.com/input-output-hk/haskell.nix/archive/1992b910e9b4d880e1f4639f4fe7266bceb9a5ad.tar.gz", "url_template": "https://github.com///archive/.tar.gz" }, "niv": { @@ -22,5 +22,17 @@ "type": "tarball", "url": "https://github.com/nmattia/niv/archive/e0ca65c81a2d7a4d82a189f1e23a48d59ad42070.tar.gz", "url_template": "https://github.com///archive/.tar.gz" + }, + "nix-filter": { + "branch": "master", + "description": "a small self-container source filtering lib", + "homepage": "", + "owner": "numtide", + "repo": "nix-filter", + "rev": "3c9e33ed627e009428197b07216613206f06ed80", + "sha256": "19w142crrkywxynmyw4rhz4nglrg64yjawfkw3j91qwkwbfjds84", + "type": "tarball", + "url": "https://github.com/numtide/nix-filter/archive/3c9e33ed627e009428197b07216613206f06ed80.tar.gz", + "url_template": "https://github.com///archive/.tar.gz" } } diff --git a/openapi-diff.cabal b/openapi-diff.cabal index 3018741..cbbf8e9 100644 --- a/openapi-diff.cabal +++ b/openapi-diff.cabal @@ -38,7 +38,6 @@ common common-options -fconstraint-solver-iterations=0 default-language: Haskell2010 build-depends: base >= 4.12.0.0 && < 4.16 - , bytestring , text default-extensions: ApplicativeDo @@ -111,6 +110,7 @@ library , file-embed , data-default , ordered-containers + , bytestring hs-source-dirs: src exposed-modules: Data.HList , OpenAPI.Checker.Behavior @@ -167,6 +167,7 @@ executable openapi-diff , aeson , containers , doctemplates + , pandoc-types ghc-options: -threaded -rtsopts -with-rtsopts=-N @@ -174,6 +175,29 @@ executable openapi-diff FormatHeuristic OpenAPI.Checker.Options +executable comparest-github-action + import: common-options + hs-source-dirs: github-action + main-is: Main.hs + build-depends: + openapi-diff + , pandoc + , yaml + , aeson + , github + , freer-simple + , vector + , pandoc-types + , envy + , filepath + ghc-options: -threaded + -rtsopts + -with-rtsopts=-N + other-modules: + Control.Monad.Freer.GitHub + CompaREST.GitHub.API + CompaREST.GitHub.Action.Config + test-suite openapi-diff-test import: common-options type: exitcode-stdio-1.0 @@ -193,6 +217,7 @@ test-suite openapi-diff-test , pandoc , data-default , lens + , pandoc-types ghc-options: -threaded -rtsopts -with-rtsopts=-N diff --git a/src/OpenAPI/Checker/Report.hs b/src/OpenAPI/Checker/Report.hs index 22ffcfd..73a7503 100644 --- a/src/OpenAPI/Checker/Report.hs +++ b/src/OpenAPI/Checker/Report.hs @@ -104,7 +104,7 @@ data ReportTreeStyle = HeadersTreeStyle | FoldingBlockquotesTreeStyle twoRowTable :: [(Inlines, Inlines)] -> Blocks twoRowTable x = simpleTable (para . fst <$> x) [para . snd <$> x] -generateReport :: ReportConfig -> ReportInput -> (Pandoc, ReportStatus) +generateReport :: ReportConfig -> ReportInput -> (Blocks, ReportStatus) generateReport cfg inp = let schemaIssuesPresent = not $ P.null $ schemaIssues inp @@ -116,7 +116,6 @@ generateReport cfg inp = OnlyErrors -> False builder = buildReport cfg report = - doc $ header 1 "Summary" <> twoRowTable (when' diff --git a/src/OpenAPI/Checker/Run.hs b/src/OpenAPI/Checker/Run.hs index ac32e64..3fb4db0 100644 --- a/src/OpenAPI/Checker/Run.hs +++ b/src/OpenAPI/Checker/Run.hs @@ -11,6 +11,7 @@ import OpenAPI.Checker.Paths import OpenAPI.Checker.Report import OpenAPI.Checker.Subtree import OpenAPI.Checker.Validate.OpenApi () +import Text.Pandoc.Builder runChecker :: (OpenApi, OpenApi) -> CheckerOutput runChecker (client, server) = @@ -26,5 +27,5 @@ runChecker (client, server) = } run p c = either id mempty . runCompatFormula . checkCompatibility Root HNil $ toPC p c -runReport :: ReportConfig -> (OpenApi, OpenApi) -> (Pandoc, ReportStatus) +runReport :: ReportConfig -> (OpenApi, OpenApi) -> (Blocks, ReportStatus) runReport cfg = generateReport cfg . segregateIssues . runChecker diff --git a/stack.yaml b/stack.yaml index 5d01ff5..84830be 100644 --- a/stack.yaml +++ b/stack.yaml @@ -1,8 +1,11 @@ -resolver: lts-18.2 +resolver: lts-18.4 extra-deps: - open-union-0.4.0.0 - type-fun-0.1.3 + - github-0.26 + - base16-bytestring-0.1.1.7 + - http-link-header-1.0.3.1 apply-ghc-options: everything rebuild-ghc-options: true diff --git a/stack.yaml.lock b/stack.yaml.lock index c0bbcb3..daa599e 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -18,9 +18,30 @@ packages: sha256: b977d42525f0b4223959918d18e7905085283e937493c395077b608cd19b42c1 original: hackage: type-fun-0.1.3 +- completed: + hackage: github-0.26@sha256:a9d4046325c3eb28cdc7bef2c3f5bb213328caeae0b7dce6f51de655f0bffaa1,7162 + pantry-tree: + size: 7511 + sha256: b71aab2984b268030c9e2617043575681134c1fe60dffbd5596e659c0a3e9aec + original: + hackage: github-0.26 +- completed: + hackage: base16-bytestring-0.1.1.7@sha256:0021256a9628971c08da95cb8f4d0d72192f3bb8a7b30b55c080562d17c43dd3,2231 + pantry-tree: + size: 454 + sha256: 2dc99cd67293b09204098202d0bdcdcf89d2b8c69ce6c8f4e31deadbdab2a7f9 + original: + hackage: base16-bytestring-0.1.1.7 +- completed: + hackage: http-link-header-1.0.3.1@sha256:9917c26635a46eff5d36c5344a4ae42fad2b5de79c0e300363bddd5eec66facd,1937 + pantry-tree: + size: 990 + sha256: 155feaf856daf5b55491048c8c4726ba765a8fe79263ebdbfc82f6c385a15715 + original: + hackage: http-link-header-1.0.3.1 snapshots: - completed: - size: 585392 - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/18/2.yaml - sha256: 7abb45c0cc5eb349448b66d8753655542d45d387ad26970419282eab3d860724 - original: lts-18.2 + size: 585817 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/18/4.yaml + sha256: ea3a318eafa9e9cc56bfbe46099fd0d54d32641ab7bbe1d182ed8f5de39f804c + original: lts-18.4 diff --git a/test/Spec/Golden/TraceTree.hs b/test/Spec/Golden/TraceTree.hs index 927d951..7ce1cc8 100644 --- a/test/Spec/Golden/TraceTree.hs +++ b/test/Spec/Golden/TraceTree.hs @@ -1,5 +1,5 @@ module Spec.Golden.TraceTree - ( tests + ( tests, ) where @@ -14,6 +14,7 @@ import OpenAPI.Checker.Run import OpenAPI.Checker.Validate.OpenApi () import Spec.Golden.Extra import Test.Tasty (TestTree, testGroup) +import Text.Pandoc.Builder import Text.Pandoc.Class import Text.Pandoc.Options import Text.Pandoc.Writers @@ -37,7 +38,7 @@ tests = do "report.md" ("a.yaml", "b.yaml") Yaml.decodeFileThrow - (runPandoc . writeMarkdown def {writerExtensions = githubMarkdownExtensions} . fst . runReport def) + (runPandoc . writeMarkdown def {writerExtensions = githubMarkdownExtensions} . doc . fst . runReport def) return $ testGroup "Golden tests" [traceTreeTests, reportTests]