github.scm 15 KB

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