-
Notifications
You must be signed in to change notification settings - Fork 80
/
Copy pathAPI.purs
331 lines (290 loc) · 14.7 KB
/
API.purs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
module Registry.API where
import Registry.Prelude
import Control.Monad.Except as Except
import Data.Argonaut as Json
import Data.Array as Array
import Data.Array.NonEmpty as NEA
import Data.Generic.Rep as Generic
import Data.Map as Map
import Effect.Aff as Aff
import Effect.Ref as Ref
import Foreign.Dhall as Dhall
import Foreign.GitHub (IssueNumber)
import Foreign.GitHub as GitHub
import Foreign.Licensee as Licensee
import Foreign.Object as Object
import Foreign.SPDX as SPDX
import Foreign.SemVer as SemVer
import Foreign.Tar as Tar
import Foreign.Tmp as Tmp
import Node.Buffer as Buffer
import Node.ChildProcess as NodeProcess
import Node.Crypto.Hash as Hash
import Node.FS.Aff as FS
import Node.Process as Env
import Registry.PackageName (PackageName)
import Registry.PackageName as PackageName
import Registry.PackageUpload as Upload
import Registry.RegistryM (Env, RegistryM, closeIssue, comment, commitToTrunk, readPackagesMetadata, runRegistryM, throwWithComment, updatePackagesMetadata, uploadPackage)
import Registry.Schema (Manifest, Metadata, Operation(..), Repo(..), addVersionToMetadata, mkNewMetadata)
import Registry.Scripts.LegacyImport as LegacyImport
import Registry.Scripts.LegacyImport.Bowerfile as Bowerfile
import Sunde as Process
import Text.Parsing.StringParser as StringParser
main :: Effect Unit
main = launchAff_ $ do
eventPath <- liftEffect $ Env.lookupEnv "GITHUB_EVENT_PATH"
octokit <- liftEffect GitHub.mkOctokit
packagesMetadata <- do
packageList <- try (FS.readdir metadataDir) >>= case _ of
Right list -> pure list
Left err -> do
error $ show err
FS.mkdir metadataDir
pure []
packagesArray <- for packageList \rawPackageName -> do
packageName <- case PackageName.parse rawPackageName of
Right p -> pure p
Left err -> Aff.throwError $ Aff.error $ StringParser.printParserError err
let metadataPath = metadataFile packageName
metadataStr <- FS.readTextFile UTF8 metadataPath
metadata <- case fromJson metadataStr of
Left err -> Aff.throwError $ Aff.error $ "Error while parsing json from " <> metadataPath <> " : " <> err
Right r -> pure r
pure $ packageName /\ metadata
liftEffect $ Ref.new $ Map.fromFoldable packagesArray
readOperation (unsafePartial fromJust eventPath) >>= case _ of
-- If the issue body is not just a JSON string, then we don't consider it
-- to be an attempted operation and it is presumably just an issue on the
-- registry repository.
NotJson ->
pure unit
MalformedJson issue err -> runRegistryM (mkEnv octokit packagesMetadata issue) do
comment $ Array.fold
[ "The JSON input for this package update is malformed:"
, newlines 2
, "```" <> err <> "```"
, newlines 2
, "You can try again by commenting on this issue with a corrected payload."
]
DecodedOperation issue op ->
runRegistryM (mkEnv octokit packagesMetadata issue) (runOperation op)
data OperationDecoding
= NotJson
| MalformedJson IssueNumber String
| DecodedOperation IssueNumber Operation
derive instance eqOperationDecoding :: Eq OperationDecoding
derive instance genericOperationDecoding :: Generic.Generic OperationDecoding _
instance showOperationDecoding :: Show OperationDecoding where
show = genericShow
readOperation :: FilePath -> Aff OperationDecoding
readOperation eventPath = do
fileContents <- FS.readTextFile UTF8 eventPath
GitHub.Event { issueNumber, body } <- case fromJson fileContents of
Left err ->
-- If we don't receive a valid event path or the contents can't be decoded
-- then this is a catastrophic error and we exit the workflow.
Aff.throwError $ Aff.error $ "Error while parsing json from " <> eventPath <> " : " <> err
Right event ->
pure event
pure $ case Json.jsonParser body of
Left _err ->
NotJson
Right json -> case Json.decodeJson json of
Left err -> MalformedJson issueNumber (Json.printJsonDecodeError err)
Right op -> DecodedOperation issueNumber op
-- TODO: test all the points where the pipeline could throw, to show that we are implementing
-- all the necessary checks
runOperation :: Operation -> RegistryM Unit
runOperation operation = case operation of
-- TODO handle addToPackageSet
Addition { packageName, fromBower, newRef, newPackageLocation } -> do
-- check that we don't have a metadata file for that package
ifM (liftAff $ FS.exists $ metadataFile packageName)
-- if the metadata file already exists then we steer this to be an Update instead
(runOperation $ Update { packageName, fromBower, updateRef: newRef })
do
addOrUpdate { packageName, fromBower, ref: newRef } $ mkNewMetadata newPackageLocation
Update { packageName, fromBower, updateRef } -> do
ifM (liftAff $ FS.exists $ metadataFile packageName)
do
metadata <- readPackagesMetadata >>= \packages -> case Map.lookup packageName packages of
Nothing -> throwWithComment "Couldn't read metadata file for your package"
Just m -> pure m
addOrUpdate { packageName, fromBower, ref: updateRef } metadata
(throwWithComment "Metadata file should exist. Did you mean to create an Addition?")
Unpublish _ -> throwWithComment "Unpublish not implemented! Ask us for help!" -- TODO
metadataDir :: FilePath
metadataDir = "../metadata"
metadataFile :: PackageName -> FilePath
metadataFile packageName = metadataDir <> "/" <> PackageName.print packageName <> ".json"
addOrUpdate :: { fromBower :: Boolean, ref :: String, packageName :: PackageName } -> Metadata -> RegistryM Unit
addOrUpdate { ref, fromBower, packageName } metadata = do
-- let's get a temp folder to do our stuffs
tmpDir <- liftEffect $ Tmp.mkTmpDir
-- fetch the repo and put it in the tempdir, returning the name of its toplevel dir
folderName <- case metadata.location of
Git _ -> do
-- TODO: Support non-GitHub packages. Remember subdir when implementing this. (See #15)
throwWithComment "Packages are only allowed to come from GitHub for now. See #15"
GitHub { owner, repo, subdir } -> do
-- TODO: Support subdir. In the meantime, we verify subdir is not present. (See #16)
when (isJust subdir) $ throwWithComment "`subdir` is not supported for now. See #16"
let tarballName = ref <> ".tar.gz"
let absoluteTarballPath = tmpDir <> "/" <> tarballName
let archiveUrl = "https://github.com/" <> owner <> "/" <> repo <> "/archive/" <> tarballName
log $ "Fetching tarball from GitHub: " <> archiveUrl
wget archiveUrl absoluteTarballPath
log $ "Tarball downloaded in " <> absoluteTarballPath
liftEffect (Tar.getToplevelDir absoluteTarballPath) >>= case _ of
Nothing ->
throwWithComment "Could not find a toplevel dir in the tarball!"
Just dir -> do
log "Extracting the tarball..."
liftEffect $ Tar.extract { cwd: tmpDir, filename: absoluteTarballPath }
pure dir
let absoluteFolderPath = tmpDir <> "/" <> folderName
let manifestPath = absoluteFolderPath <> "/purs.json"
log $ "Package extracted in " <> absoluteFolderPath
-- If we're importing from Bower then we need to convert the Bowerfile
-- to a Registry Manifest
when fromBower do
liftAff (try (readJsonFile (absoluteFolderPath <> "/bower.json"))) >>= case _ of
Left err ->
throwWithComment $ "Error while reading Bowerfile: " <> Aff.message err
Right (Left err) ->
throwWithComment $ "Could not decode Bowerfile: " <> Json.printJsonDecodeError err
Right (Right bowerfile) -> do
let
printErrors =
Json.stringifyWithIndent 2 <<< Json.encodeJson <<< NEA.toArray
manifestFields =
Bowerfile.toManifestFields bowerfile
runManifest =
Except.runExceptT <<< Except.mapExceptT (liftAff <<< map (lmap printErrors))
semVer <- case SemVer.parseSemVer ref of
Nothing -> throwWithComment $ "Not a valid SemVer version: " <> ref
Just result -> pure result
runManifest (LegacyImport.toManifest packageName metadata.location semVer manifestFields) >>= case _ of
Left err ->
throwWithComment $ "Unable to convert Bowerfile to a manifest: " <> err
Right manifest ->
liftAff $ writeJsonFile manifestPath manifest
-- Try to read the manifest, typechecking it
manifest :: Manifest <- liftAff (try $ FS.readTextFile UTF8 manifestPath) >>= case _ of
Left _err -> throwWithComment $ "Manifest not found at " <> manifestPath
Right manifestStr -> do
liftAff (Dhall.jsonToDhallManifest manifestStr) >>= case _ of
Left err ->
throwWithComment $ "Could not type-check Manifest file: " <> err
Right _ -> case fromJson manifestStr of
Left err -> throwWithComment $ "Could not convert Manifest to JSON: " <> err
Right res -> pure res
runChecks { metadata, manifest, absoluteFolderPath }
-- After we pass all the checks it's time to do side effects and register the package
log "Packaging the tarball to upload..."
-- We need the version number to upload the package
let newVersion = manifest.version
let newDirname = PackageName.print packageName <> "-" <> SemVer.printSemVer newVersion
liftAff $ FS.rename absoluteFolderPath (tmpDir <> "/" <> newDirname)
let tarballPath = tmpDir <> "/" <> newDirname <> ".tar.gz"
liftEffect $ Tar.create { cwd: tmpDir, folderName: newDirname, archiveName: tarballPath }
log "Hashing the tarball..."
hash <- liftAff $ sha256sum tarballPath
log $ "Hash: " <> hash
log "Uploading package to the storage backend..."
let uploadPackageInfo = { name: packageName, version: newVersion }
uploadPackage uploadPackageInfo tarballPath
log $ "Adding the new version " <> SemVer.printSemVer newVersion <> " to the package metadata file (hashes, etc)"
log $ "Hash for ref " <> show ref <> " was " <> show hash
let newMetadata = addVersionToMetadata newVersion { hash, ref } metadata
let metadataFilePath = metadataFile packageName
liftAff $ FS.writeTextFile UTF8 metadataFilePath (Json.stringifyWithIndent 2 $ Json.encodeJson newMetadata)
updatePackagesMetadata manifest.name newMetadata
commitToTrunk packageName metadataFilePath >>= case _ of
Left _err ->
comment "Package uploaded, but metadata not synced with the registry repository.\n\ncc @purescript/packaging"
Right _ -> do
comment "Package successfully uploaded to the registry! :tada: :rocket:"
closeIssue
-- Optional steps that we'll try and that won't fail the pipeline on error:
-- TODO: handle addToPackageSet: we'll try to add it to the latest set and build (see #156)
-- TODO: upload docs to pursuit (see #154)
runChecks :: { metadata :: Metadata, manifest :: Manifest, absoluteFolderPath :: String } -> RegistryM Unit
runChecks { metadata, manifest, absoluteFolderPath } = do
-- TODO: collect all errors and return them at once. Note: some of the checks
-- are going to fail while parsing from JSON, so we should move them here if we
-- want to handle everything together
log "Checking that the SPDX license in the manifest corresponds to the one in the package"
licenseFromLicensee <- liftAff $ Licensee.detect absoluteFolderPath
case licenseFromLicensee of
Left err -> throwWithComment $ "Could not find a license in the package: " <> err
Right li -> when (Array.notElem (SPDX.print manifest.license) li) (throwWithComment $ "License from the manifest does not match the license detected by licensee. In manifest: " <> SPDX.print manifest.license <> ". Deteceted in package: " <> show li <> ".")
log "Checking that the Manifest includes the `lib` target"
libTarget <- case Object.lookup "lib" manifest.targets of
Nothing -> throwWithComment "Didn't find `lib` target in the Manifest!"
Just a -> pure a
log "Checking that `lib` target only includes `src`"
when (libTarget.sources /= [ "src/**/*.purs" ]) do
throwWithComment "The `lib` target only allows the following `sources`: `src/**/*.purs`"
log "Check that version is unique"
let prettyVersion = SemVer.printSemVer manifest.version
case Object.lookup prettyVersion metadata.releases of
Nothing -> pure unit
Just info -> throwWithComment $ "You tried to upload a version that already exists: " <> show prettyVersion <> "\nIts metadata is: " <> show info
log "Check that the version does not contain any build metadata"
when (SemVer.build manifest.version /= []) do
throwWithComment "Package version should not contain any build-metadata."
log "Check that all dependencies are contained in the registry"
packages <- readPackagesMetadata
let lookupPackage = flip Map.lookup packages <=< (hush <<< PackageName.parse)
let
pkgNotInRegistry name = case lookupPackage name of
Nothing -> Just name
Just _p -> Nothing
let pkgsNotInRegistry = Array.catMaybes $ map pkgNotInRegistry $ Object.keys libTarget.dependencies
unless (Array.null pkgsNotInRegistry) do
throwWithComment $ "Some dependencies of your package were not found in the Registry: " <> show pkgsNotInRegistry
fromJson :: forall a. Json.DecodeJson a => String -> Either String a
fromJson = Json.jsonParser >=> (lmap Json.printJsonDecodeError <<< Json.decodeJson)
sha256sum :: String -> Aff String
sha256sum filepath = do
fileBuffer <- FS.readFile filepath
liftEffect do
newHash <- Hash.createHash Hash.SHA256
fileHash <- Hash.update newHash fileBuffer
digest <- Hash.digest fileHash
Buffer.toString Hex digest
wget :: String -> String -> RegistryM Unit
wget url path = do
let cmd = "wget"
let stdin = Nothing
let args = [ "-O", path, url ]
result <- liftAff $ Process.spawn { cmd, stdin, args } NodeProcess.defaultSpawnOptions
case result.exit of
NodeProcess.Normally 0 -> pure unit
_ -> throwWithComment $ "Error while fetching tarball: " <> result.stderr
mkEnv :: GitHub.Octokit -> Ref (Map PackageName Metadata) -> IssueNumber -> Env
mkEnv octokit packagesMetadata issue =
{ comment: GitHub.createComment octokit issue
, closeIssue: GitHub.closeIssue octokit issue
, commitToTrunk: pushToMaster
, uploadPackage: Upload.upload
, packagesMetadata
}
pushToMaster :: PackageName -> FilePath -> Aff (Either String Unit)
pushToMaster packageName path = Except.runExceptT do
runGit [ "config", "user.name", "PacchettiBotti" ]
runGit [ "config", "user.email", "<[email protected]>" ]
runGit [ "add", path ]
runGit [ "commit", "-m", "Update metadata for package " <> PackageName.print packageName ]
runGit [ "push", "origin", "master" ]
where
runGit args = ExceptT do
result <- Process.spawn { cmd: "git", args, stdin: Nothing } NodeProcess.defaultSpawnOptions
case result.exit of
NodeProcess.Normally 0 -> do
info result.stdout
info result.stderr
pure $ Right unit
_ -> pure $ Left result.stderr