github.scm 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. ;;; GNU Guix --- Functional package management for GNU
  2. ;;; Copyright © 2016 Ben Woodcroft <donttrustben@gmail.com>
  3. ;;; Copyright © 2017-2020, 2022 Ludovic Courtès <ludo@gnu.org>
  4. ;;; Copyright © 2018 Eric Bavier <bavier@member.fsf.org>
  5. ;;; Copyright © 2019 Arun Isaac <arunisaac@systemreboot.net>
  6. ;;; Copyright © 2019 Efraim Flashner <efraim@flashner.co.il>
  7. ;;; Copyright © 2022 Maxime Devos <maximedevos@telenet.be>
  8. ;;; Copyright © 2022 Hartmut Goebel <h.goebel@crazy-compilers.com>
  9. ;;;
  10. ;;; This file is part of GNU Guix.
  11. ;;;
  12. ;;; GNU Guix is free software; you can redistribute it and/or modify it
  13. ;;; under the terms of the GNU General Public License as published by
  14. ;;; the Free Software Foundation; either version 3 of the License, or (at
  15. ;;; your option) any later version.
  16. ;;;
  17. ;;; GNU Guix is distributed in the hope that it will be useful, but
  18. ;;; WITHOUT ANY WARRANTY; without even the implied warranty of
  19. ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  20. ;;; GNU General Public License for more details.
  21. ;;;
  22. ;;; You should have received a copy of the GNU General Public License
  23. ;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
  24. (define-module (guix import github)
  25. #:use-module (ice-9 match)
  26. #:use-module (srfi srfi-1)
  27. #:use-module (srfi srfi-26)
  28. #:use-module (srfi srfi-34)
  29. #:use-module (srfi srfi-71)
  30. #:use-module (guix utils)
  31. #:use-module (guix i18n)
  32. #:use-module (guix diagnostics)
  33. #:use-module ((guix ui) #:select (display-hint))
  34. #:use-module ((guix download) #:prefix download:)
  35. #:use-module ((guix git-download) #:prefix download:)
  36. #:autoload (guix build download) (open-connection-for-uri)
  37. #:use-module (json)
  38. #:use-module (guix packages)
  39. #:use-module (guix upstream)
  40. #:use-module (guix http-client)
  41. #:use-module (web uri)
  42. #:export (%github-api %github-updater))
  43. ;; For tests.
  44. (define %github-api (make-parameter "https://api.github.com"))
  45. (define (find-extension url)
  46. "Return the extension of the archive e.g. '.tar.gz' given a URL, or
  47. false if none is recognized"
  48. (find (lambda (x) (string-suffix? x url))
  49. (list ".tar.gz" ".tar.bz2" ".tar.xz" ".zip" ".tar"
  50. ".tgz" ".tbz" ".love")))
  51. (define (updated-github-url old-package new-version)
  52. ;; Return a url for the OLD-PACKAGE with NEW-VERSION. If no source url in
  53. ;; the OLD-PACKAGE is a GitHub url, then return false.
  54. (define (updated-url url)
  55. (if (string-prefix? "https://github.com/" url)
  56. (let ((ext (or (find-extension url) ""))
  57. (name (package-upstream-name old-package))
  58. (version (package-version old-package))
  59. (prefix (string-append "https://github.com/"
  60. (github-user-slash-repository url)))
  61. (repo (github-repository url)))
  62. (cond
  63. ((string-suffix? (string-append "/tarball/v" version) url)
  64. (string-append prefix "/tarball/v" new-version))
  65. ((string-suffix? (string-append "/tarball/" version) url)
  66. (string-append prefix "/tarball/" new-version))
  67. ((string-suffix? (string-append "/archive/v" version ext) url)
  68. (string-append prefix "/archive/v" new-version ext))
  69. ((string-suffix? (string-append "/archive/" version ext) url)
  70. (string-append prefix "/archive/" new-version ext))
  71. ((string-suffix? (string-append "/archive/" name "-" version ext)
  72. url)
  73. (string-append prefix "/archive/" name "-" new-version ext))
  74. ((string-suffix? (string-append "/releases/download/v" version "/"
  75. name "-" version ext)
  76. url)
  77. (string-append prefix "/releases/download/v" new-version "/" name
  78. "-" new-version ext))
  79. ((string-suffix? (string-append "/releases/download/" version "/"
  80. name "-" version ext)
  81. url)
  82. (string-append prefix "/releases/download/" new-version "/" name
  83. "-" new-version ext))
  84. ((string-suffix? (string-append "/releases/download/" version "/"
  85. repo "-" version ext)
  86. url)
  87. (string-append prefix "/releases/download/" new-version "/" repo
  88. "-" new-version ext))
  89. ((string-suffix? (string-append "/releases/download/" repo "-"
  90. version "/" repo "-" version ext)
  91. url)
  92. (string-append prefix "/releases/download/" repo "-" new-version "/"
  93. repo "-" new-version ext))
  94. (#t #f))) ; Some URLs are not recognised.
  95. #f))
  96. (match (package-source old-package)
  97. ((? origin? origin)
  98. (let ((source-uri (origin-uri origin))
  99. (fetch-method (origin-method origin)))
  100. (cond
  101. ((eq? fetch-method download:url-fetch)
  102. (match source-uri
  103. ((? string?)
  104. (updated-url source-uri))
  105. ((source-uri ...)
  106. (find updated-url source-uri))))
  107. ((and (eq? fetch-method download:git-fetch)
  108. (string-prefix? "https://github.com/"
  109. (download:git-reference-url source-uri)))
  110. (download:git-reference-url source-uri))
  111. (else #f))))
  112. (_ #f)))
  113. (define (github-package? package)
  114. "Return true if PACKAGE is a package from GitHub, else false."
  115. (->bool (updated-github-url package "dummy")))
  116. (define (github-repository url)
  117. "Return a string e.g. bedtools2 of the name of the repository, from a string
  118. URL of the form 'https://github.com/arq5x/bedtools2/archive/v2.24.0.tar.gz'"
  119. (match (string-split (uri-path (string->uri url)) #\/)
  120. ((_ owner project . rest)
  121. (string-append (basename project ".git")))))
  122. (define (github-user-slash-repository url)
  123. "Return a string e.g. arq5x/bedtools2 of the owner and the name of the
  124. repository separated by a forward slash, from a string URL of the form
  125. 'https://github.com/arq5x/bedtools2/archive/v2.24.0.tar.gz'"
  126. (match (string-split (uri-path (string->uri url)) #\/)
  127. ((_ owner project . rest)
  128. (string-append owner "/" (basename project ".git")))))
  129. (define %github-token
  130. ;; Token to be passed to Github.com to avoid the 60-request per hour
  131. ;; limit, or #f.
  132. (make-parameter (getenv "GUIX_GITHUB_TOKEN")))
  133. (define %rate-limit-reset-time
  134. ;; Time (seconds since the Epoch, UTC) when the rate limit for GitHub
  135. ;; requests will be reset, or #f if the rate limit hasn't been reached.
  136. #f)
  137. (define (update-rate-limit-reset-time! headers)
  138. "Update the rate limit reset time based on HEADERS, the HTTP response
  139. headers."
  140. (match (assq-ref headers 'x-ratelimit-reset)
  141. ((= string->number (? number? reset))
  142. (set! %rate-limit-reset-time reset)
  143. reset)
  144. (_
  145. ;; This shouldn't happen.
  146. (warning
  147. (G_ "GitHub HTTP response lacks 'X-RateLimit-Reset' header~%"))
  148. 0)))
  149. (define (request-rate-limit-reached?)
  150. "Return true if the rate limit has been reached."
  151. (and %rate-limit-reset-time
  152. (match (< (car (gettimeofday)) %rate-limit-reset-time)
  153. (#t #t)
  154. (#f
  155. (set! %rate-limit-reset-time #f)
  156. #f))))
  157. (define (fetch-releases-or-tags url)
  158. "Fetch the list of \"releases\" or, if it's empty, the list of tags for the
  159. repository at URL. Return the corresponding JSON dictionaries (alists),
  160. or #f if the information could not be retrieved.
  161. We look at both /releases and /tags because the \"release\" feature of GitHub
  162. is little used; often, people simply provide a tag. What's confusing is that
  163. tags show up in the \"Releases\" tab of the web UI. For instance,
  164. 'https://github.com/aconchillo/guile-json/releases' shows a number of
  165. \"releases\" (really: tags), whereas
  166. 'https://api.github.com/repos/aconchillo/guile-json/releases' returns the
  167. empty list."
  168. (define release-url
  169. (string-append (%github-api) "/repos/"
  170. (github-user-slash-repository url)
  171. "/releases"))
  172. (define tag-url
  173. (string-append (%github-api) "/repos/"
  174. (github-user-slash-repository url)
  175. "/tags"))
  176. (define headers
  177. ;; Ask for version 3 of the API as suggested at
  178. ;; <https://developer.github.com/v3/>.
  179. `((Accept . "application/vnd.github.v3+json")
  180. (user-agent . "GNU Guile")
  181. ,@(if (%github-token)
  182. `((Authorization . ,(string-append "token " (%github-token))))
  183. '())))
  184. (and (not (request-rate-limit-reached?))
  185. (guard (c ((and (http-get-error? c)
  186. (= 404 (http-get-error-code c)))
  187. (warning (G_ "~a is unreachable (~a)~%")
  188. (uri->string (http-get-error-uri c))
  189. (http-get-error-code c))
  190. '#()) ;return an empty release set
  191. ((and (http-get-error? c)
  192. (= 403 (http-get-error-code c)))
  193. ;; See
  194. ;; <https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting>.
  195. (match (assq-ref (http-get-error-headers c)
  196. 'x-ratelimit-remaining)
  197. (#f
  198. (raise c))
  199. ((? (compose zero? string->number))
  200. (let ((reset (update-rate-limit-reset-time!
  201. (http-get-error-headers c))))
  202. (warning (G_ "GitHub rate limit exceeded; \
  203. disallowing requests for ~a seconds~%")
  204. (- reset (car (gettimeofday))))
  205. (display-hint (G_ "You can raise the rate limit by
  206. setting the @env{GUIX_GITHUB_TOKEN} environment variable to a token obtained
  207. from @url{https://github.com/settings/tokens} with your GitHub account.
  208. Alternatively, you can wait until your rate limit is reset, or use the
  209. @code{generic-git} updater instead."))
  210. #f)) ;bail out
  211. (_
  212. (raise c)))))
  213. (let ((release-uri (string->uri release-url)))
  214. (call-with-port (open-connection-for-uri release-uri)
  215. (lambda (connection)
  216. (let* ((result (json->scm
  217. (http-fetch release-uri
  218. #:port connection
  219. #:keep-alive? #t
  220. #:headers headers))))
  221. (match result
  222. (#()
  223. ;; We got the empty list, presumably because the user didn't use GitHub's
  224. ;; "release" mechanism, but hopefully they did use Git tags.
  225. (json->scm (http-fetch tag-url
  226. #:port connection
  227. #:keep-alive? #t
  228. #:headers headers)))
  229. (x x)))))))))
  230. (define* (latest-released-version url package-name #:key (version #f))
  231. "Return the newest released version and its tag given a string URL like
  232. 'https://github.com/arq5x/bedtools2/archive/v2.24.0.tar.gz' and the name of
  233. the package e.g. 'bedtools2'. Return #f (two values) if there are no
  234. releases.
  235. Optionally include a VERSION string to fetch a specific version."
  236. (define (pre-release? x)
  237. (assoc-ref x "prerelease"))
  238. ;; This procedure returns (version . tag) pair, or #f
  239. ;; if RELEASE doesn't seyem to correspond to a version.
  240. (define (release->version release)
  241. (let ((tag (or (assoc-ref release "tag_name") ;a "release"
  242. (assoc-ref release "name"))) ;a tag
  243. (name-length (string-length package-name)))
  244. (cond
  245. ;; some tags include the name of the package e.g. "fdupes-1.51"
  246. ;; so remove these
  247. ((and (< name-length (string-length tag))
  248. (string=? (string-append package-name "-")
  249. (substring tag 0 (+ name-length 1))))
  250. (cons (substring tag (+ name-length 1)) tag))
  251. ;; some tags start with a "v" e.g. "v0.25.0"
  252. ;; or with the word "version" e.g. "version.2.1"
  253. ;; where some are just the version number
  254. ((string-prefix? "version" tag)
  255. (cons (if (char-set-contains? char-set:digit (string-ref tag 7))
  256. (substring tag 7)
  257. (substring tag 8)) tag))
  258. ((string-prefix? "v" tag)
  259. (cons (substring tag 1) tag))
  260. ;; Finally, reject tags that don't start with a digit:
  261. ;; they may not represent a release.
  262. ((and (not (string-null? tag))
  263. (char-set-contains? char-set:digit
  264. (string-ref tag 0)))
  265. (cons tag tag))
  266. (else #f))))
  267. (match (and=> (fetch-releases-or-tags url) vector->list)
  268. (#f (values #f #f))
  269. (json
  270. (let ((releases (filter-map release->version
  271. (match (remove pre-release? json)
  272. (() json) ; keep everything
  273. (releases releases)))))
  274. (match (if version
  275. ;; Find matching release version.
  276. (filter (match-lambda
  277. ((candidate-version . tag)
  278. (string=? version candidate-version)))
  279. releases)
  280. ;; Sort releases descending.
  281. (sort releases
  282. (lambda (x y) (version>? (car x) (car y)))))
  283. (((latest-version . tag) . _) (values latest-version tag))
  284. (() (values #f #f)))))))
  285. (define* (import-release pkg #:key (version #f))
  286. "Return an <upstream-source> for the latest release of PKG.
  287. Optionally include a VERSION string to fetch a specific version."
  288. (define (github-uri uri)
  289. (match uri
  290. ((? string? url)
  291. url) ;surely a github.com URL
  292. ((? download:git-reference? ref)
  293. (download:git-reference-url ref))
  294. ((urls ...)
  295. (find (cut string-contains <> "github.com") urls))))
  296. (let* ((original-uri (origin-uri (package-source pkg)))
  297. (source-uri (github-uri original-uri))
  298. (name (package-name pkg))
  299. (newest-version version-tag
  300. (latest-released-version source-uri name
  301. #:version version)))
  302. (if newest-version
  303. (upstream-source
  304. (package name)
  305. (version newest-version)
  306. (urls (if (download:git-reference? original-uri)
  307. (download:git-reference
  308. (inherit original-uri)
  309. (commit version-tag))
  310. (list (updated-github-url pkg newest-version)))))
  311. #f))) ; On GitHub but no proper releases
  312. (define %github-updater
  313. (upstream-updater
  314. (name 'github)
  315. (description "Updater for GitHub packages")
  316. (pred github-package?)
  317. (import import-release)))