Personal emacs config
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

867 lines
36 KiB

  1. ;;; ghub.el --- minuscule client libraries for Git forge APIs -*- lexical-binding: t -*-
  2. ;; Copyright (C) 2016-2021 Jonas Bernoulli
  3. ;; Author: Jonas Bernoulli <jonas@bernoul.li>
  4. ;; Homepage: https://github.com/magit/ghub
  5. ;; Keywords: tools
  6. ;; SPDX-License-Identifier: GPL-3.0-or-later
  7. ;; This file is not part of GNU Emacs.
  8. ;; This file is free software; you can redistribute it and/or modify
  9. ;; it under the terms of the GNU General Public License as published by
  10. ;; the Free Software Foundation; either version 3, or (at your option)
  11. ;; any later version.
  12. ;; This file is distributed in the hope that it will be useful,
  13. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. ;; GNU General Public License for more details.
  16. ;; For a copy of the GPL see https://www.gnu.org/licenses/gpl.txt.
  17. ;;; Commentary:
  18. ;; Ghub provides basic support for using the APIs of various Git forges
  19. ;; from Emacs packages. Originally it only supported the Github REST
  20. ;; API, but now it also supports the Github GraphQL API as well as the
  21. ;; REST APIs of Gitlab, Gitea, Gogs and Bitbucket.
  22. ;; Ghub abstracts access to API resources using only a handful of basic
  23. ;; functions such as `ghub-get'. These are convenience wrappers around
  24. ;; `ghub-request'. Additional forge-specific wrappers like `glab-put',
  25. ;; `gtea-put', `gogs-post' and `buck-delete' are also available. Ghub
  26. ;; does not provide any resource-specific functions, with the exception
  27. ;; of `FORGE-repository-id'.
  28. ;; When accessing Github, then Ghub handles the creation and storage of
  29. ;; access tokens using a setup wizard to make it easier for users to get
  30. ;; started. The tokens for other forges have to be created manually.
  31. ;; Ghub is intentionally limited to only provide these two essential
  32. ;; features — basic request functions and guided setup — to avoid being
  33. ;; too opinionated, which would hinder wide adoption. It is assumed that
  34. ;; wide adoption would make life easier for users and maintainers alike,
  35. ;; because then all packages that talk to forge APIs could be configured
  36. ;; the same way.
  37. ;; Please consult the manual (info "ghub") for more information.
  38. ;;; Code:
  39. (require 'auth-source)
  40. (require 'cl-lib)
  41. (require 'gnutls)
  42. (require 'json)
  43. (require 'let-alist)
  44. (require 'url)
  45. (require 'url-auth)
  46. (require 'url-http)
  47. (eval-when-compile
  48. (require 'subr-x))
  49. (defvar url-callback-arguments)
  50. (defvar url-http-end-of-headers)
  51. (defvar url-http-extra-headers)
  52. (defvar url-http-response-status)
  53. ;;; Settings
  54. (defconst ghub-default-host "api.github.com"
  55. "The default host that is used if `ghub.host' is not set.")
  56. (defvar ghub-github-token-scopes '(repo)
  57. "The Github API scopes that your private tools need.
  58. You have to manually create or update the token at
  59. https://github.com/settings/tokens. This variable
  60. only serves as documentation.")
  61. (defvar ghub-insecure-hosts nil
  62. "List of hosts that use http instead of https.")
  63. ;;; Request
  64. ;;;; Object
  65. (cl-defstruct (ghub--req
  66. (:constructor ghub--make-req)
  67. (:copier nil))
  68. (url nil :read-only nil)
  69. (forge nil :read-only t)
  70. (silent nil :read-only t)
  71. (method nil :read-only t)
  72. (headers nil :read-only t)
  73. (handler nil :read-only t)
  74. (unpaginate nil :read-only nil)
  75. (noerror nil :read-only t)
  76. (reader nil :read-only t)
  77. (callback nil :read-only t)
  78. (errorback nil :read-only t)
  79. (value nil :read-only nil)
  80. (extra nil :read-only nil))
  81. (defalias 'ghub-req-extra 'ghub--req-extra)
  82. ;;;; API
  83. (define-error 'ghub-error "Ghub/Url Error" 'error)
  84. (define-error 'ghub-http-error "HTTP Error" 'ghub-error)
  85. (defvar ghub-response-headers nil
  86. "The headers returned in response to the last request.
  87. `ghub-request' returns the response body and stores the
  88. response headers in this variable.")
  89. (cl-defun ghub-head (resource &optional params
  90. &key query payload headers
  91. silent unpaginate noerror reader
  92. username auth host
  93. callback errorback extra)
  94. "Make a `HEAD' request for RESOURCE, with optional query PARAMS.
  95. Like calling `ghub-request' (which see) with \"HEAD\" as METHOD."
  96. (ghub-request "HEAD" resource params
  97. :query query :payload payload :headers headers
  98. :silent silent :unpaginate unpaginate
  99. :noerror noerror :reader reader
  100. :username username :auth auth :host host
  101. :callback callback :errorback errorback :extra extra))
  102. (cl-defun ghub-get (resource &optional params
  103. &key query payload headers
  104. silent unpaginate noerror reader
  105. username auth host
  106. callback errorback extra)
  107. "Make a `GET' request for RESOURCE, with optional query PARAMS.
  108. Like calling `ghub-request' (which see) with \"GET\" as METHOD."
  109. (ghub-request "GET" resource params
  110. :query query :payload payload :headers headers
  111. :silent silent :unpaginate unpaginate
  112. :noerror noerror :reader reader
  113. :username username :auth auth :host host
  114. :callback callback :errorback errorback :extra extra))
  115. (cl-defun ghub-put (resource &optional params
  116. &key query payload headers
  117. silent unpaginate noerror reader
  118. username auth host
  119. callback errorback extra)
  120. "Make a `PUT' request for RESOURCE, with optional payload PARAMS.
  121. Like calling `ghub-request' (which see) with \"PUT\" as METHOD."
  122. (ghub-request "PUT" resource params
  123. :query query :payload payload :headers headers
  124. :silent silent :unpaginate unpaginate
  125. :noerror noerror :reader reader
  126. :username username :auth auth :host host
  127. :callback callback :errorback errorback :extra extra))
  128. (cl-defun ghub-post (resource &optional params
  129. &key query payload headers
  130. silent unpaginate noerror reader
  131. username auth host
  132. callback errorback extra)
  133. "Make a `POST' request for RESOURCE, with optional payload PARAMS.
  134. Like calling `ghub-request' (which see) with \"POST\" as METHOD."
  135. (ghub-request "POST" resource params
  136. :query query :payload payload :headers headers
  137. :silent silent :unpaginate unpaginate
  138. :noerror noerror :reader reader
  139. :username username :auth auth :host host
  140. :callback callback :errorback errorback :extra extra))
  141. (cl-defun ghub-patch (resource &optional params
  142. &key query payload headers
  143. silent unpaginate noerror reader
  144. username auth host
  145. callback errorback extra)
  146. "Make a `PATCH' request for RESOURCE, with optional payload PARAMS.
  147. Like calling `ghub-request' (which see) with \"PATCH\" as METHOD."
  148. (ghub-request "PATCH" resource params
  149. :query query :payload payload :headers headers
  150. :silent silent :unpaginate unpaginate
  151. :noerror noerror :reader reader
  152. :username username :auth auth :host host
  153. :callback callback :errorback errorback :extra extra))
  154. (cl-defun ghub-delete (resource &optional params
  155. &key query payload headers
  156. silent unpaginate noerror reader
  157. username auth host
  158. callback errorback extra)
  159. "Make a `DELETE' request for RESOURCE, with optional payload PARAMS.
  160. Like calling `ghub-request' (which see) with \"DELETE\" as METHOD."
  161. (ghub-request "DELETE" resource params
  162. :query query :payload payload :headers headers
  163. :silent silent :unpaginate unpaginate
  164. :noerror noerror :reader reader
  165. :username username :auth auth :host host
  166. :callback callback :errorback errorback :extra extra))
  167. (cl-defun ghub-request (method resource &optional params
  168. &key query payload headers
  169. silent unpaginate noerror reader
  170. username auth host forge
  171. callback errorback value extra)
  172. "Make a request for RESOURCE and return the response body.
  173. Also place the response headers in `ghub-response-headers'.
  174. METHOD is the HTTP method, given as a string.
  175. RESOURCE is the resource to access, given as a string beginning
  176. with a slash.
  177. PARAMS, QUERY, PAYLOAD and HEADERS are alists used to specify
  178. data. The Github API documentation is vague on how data has
  179. to be transmitted and for a particular resource usually just
  180. talks about \"parameters\". Generally speaking when the METHOD
  181. is \"HEAD\" or \"GET\", then they have to be transmitted as a
  182. query, otherwise as a payload.
  183. Use PARAMS to automatically transmit like QUERY or PAYLOAD would
  184. depending on METHOD.
  185. Use QUERY to explicitly transmit data as a query.
  186. Use PAYLOAD to explicitly transmit data as a payload.
  187. Instead of an alist, PAYLOAD may also be a string, in which
  188. case it gets encoded as UTF-8 but is otherwise transmitted as-is.
  189. Use HEADERS for those rare resources that require that the data
  190. is transmitted as headers instead of as a query or payload.
  191. When that is the case, then the API documentation usually
  192. mentions it explicitly.
  193. If SILENT is non-nil, then don't message progress reports and
  194. the like.
  195. If UNPAGINATE is t, then make as many requests as necessary to
  196. get all values. If UNPAGINATE is a natural number, then get
  197. at most that many pages. For any other non-nil value raise
  198. an error.
  199. If NOERROR is non-nil, then do not raise an error if the request
  200. fails and return nil instead. If NOERROR is `return', then
  201. return the error payload instead of nil.
  202. If READER is non-nil, then it is used to read and return from the
  203. response buffer. The default is `ghub--read-json-payload'.
  204. For the very few resources that do not return JSON, you might
  205. want to use `ghub--decode-payload'.
  206. If USERNAME is non-nil, then make a request on behalf of that
  207. user. It is better to specify the user using the Git variable
  208. `github.user' for \"api.github.com\", or `github.HOST.user' if
  209. connecting to a Github Enterprise instance.
  210. Each package that uses `ghub' should use its own token. If AUTH
  211. is nil, then the generic `ghub' token is used instead. This
  212. is only acceptable for personal utilities. A packages that
  213. is distributed to other users should always use this argument
  214. to identify itself, using a symbol matching its name.
  215. Package authors who find this inconvenient should write a
  216. wrapper around this function and possibly for the
  217. method-specific functions as well.
  218. Some symbols have a special meaning. `none' means to make an
  219. unauthorized request. `basic' means to make a password based
  220. request. If the value is a string, then it is assumed to be
  221. a valid token. `basic' and an explicit token string are only
  222. intended for internal and debugging uses.
  223. If HOST is non-nil, then connect to that Github instance. This
  224. defaults to \"api.github.com\". When a repository is connected
  225. to a Github Enterprise instance, then it is better to specify
  226. that using the Git variable `github.host' instead of using this
  227. argument.
  228. If FORGE is `gitlab', then connect to Gitlab.com or, depending
  229. on HOST, to another Gitlab instance. This is only intended for
  230. internal use. Instead of using this argument you should use
  231. function `glab-request' and other `glab-*' functions.
  232. If CALLBACK and/or ERRORBACK is non-nil, then make one or more
  233. asynchronous requests and call CALLBACK or ERRORBACK when
  234. finished. If no error occurred, then call CALLBACK, unless
  235. that is nil.
  236. If an error occurred, then call ERRORBACK, or if that is nil,
  237. then CALLBACK. ERRORBACK can also be t, in which case an error
  238. is signaled instead. NOERROR is ignored for all asynchronous
  239. requests.
  240. Both callbacks are called with four arguments.
  241. 1. For CALLBACK, the combined value of the retrieved pages.
  242. For ERRORBACK, the error that occurred when retrieving the
  243. last page.
  244. 2. The headers of the last page as an alist.
  245. 3. Status information provided by `url-retrieve'. Its `:error'
  246. property holds the same information as ERRORBACK's first
  247. argument.
  248. 4. A `ghub--req' struct, which can be passed to `ghub-continue'
  249. (which see) to retrieve the next page, if any."
  250. (cl-assert (or (booleanp unpaginate) (natnump unpaginate)))
  251. (unless (string-prefix-p "/" resource)
  252. (setq resource (concat "/" resource)))
  253. (unless host
  254. (setq host (ghub--host forge)))
  255. (unless (or username (stringp auth) (eq auth 'none))
  256. (setq username (ghub--username host forge)))
  257. (cond ((not params))
  258. ((member method '("GET" "HEAD"))
  259. (when query
  260. (error "PARAMS and QUERY are mutually exclusive for METHOD %S"
  261. method))
  262. (setq query params))
  263. (t
  264. (when payload
  265. (error "PARAMS and PAYLOAD are mutually exclusive for METHOD %S"
  266. method))
  267. (setq payload params)))
  268. (when (or callback errorback)
  269. (setq noerror t))
  270. (ghub--retrieve
  271. (ghub--encode-payload payload)
  272. (ghub--make-req
  273. :url (url-generic-parse-url
  274. (concat (if (member host ghub-insecure-hosts) "http://" "https://")
  275. (if (and (equal resource "/graphql")
  276. (string-suffix-p "/v3" host))
  277. (substring host 0 -3)
  278. host)
  279. resource
  280. (and query (concat "?" (ghub--url-encode-params query)))))
  281. :forge forge
  282. :silent silent
  283. ;; Encode in case caller used (symbol-name 'GET). #35
  284. :method (encode-coding-string method 'utf-8)
  285. :headers (ghub--headers headers host auth username forge)
  286. :handler 'ghub--handle-response
  287. :unpaginate unpaginate
  288. :noerror noerror
  289. :reader reader
  290. :callback callback
  291. :errorback errorback
  292. :value value
  293. :extra extra)))
  294. (defun ghub-continue (req)
  295. "If there is a next page, then retrieve that.
  296. This function is only intended to be called from callbacks. If
  297. there is a next page, then retrieve that and return the buffer
  298. that the result will be loaded into, or t if the process has
  299. already completed. If there is no next page, then return nil.
  300. Callbacks are called with four arguments (see `ghub-request').
  301. The forth argument is a `ghub--req' struct, intended to be passed
  302. to this function. A callback may use the struct's `extra' slot
  303. to pass additional information to the callback that will be
  304. called after the next request has finished. Use the function
  305. `ghub-req-extra' to get and set the value of this slot."
  306. (and (assq 'next (ghub-response-link-relations req))
  307. (or (ghub--retrieve nil req) t)))
  308. (cl-defun ghub-wait (resource &optional duration
  309. &key username auth host forge)
  310. "Busy-wait up to DURATION seconds for RESOURCE to become available.
  311. DURATION specifies how many seconds to wait at most. It defaults
  312. to 64 seconds. The first attempt is made immediately, the second
  313. after two seconds, and each subsequent attempt is made after
  314. waiting as long again as we already waited between all preceding
  315. attempts combined.
  316. See `ghub-request' for information about the other arguments."
  317. (unless duration
  318. (setq duration 64))
  319. (with-local-quit
  320. (let ((total 0))
  321. (while (not (ghub-request "GET" resource nil
  322. :noerror t
  323. :username username
  324. :auth auth
  325. :host host
  326. :forge forge))
  327. (message "Waited (%3ss of %ss) for %s..." total duration resource)
  328. (if (= total duration)
  329. (error "%s is taking too long to create %s"
  330. (if forge (capitalize (symbol-name forge)) "Github")
  331. resource)
  332. (if (> total 0)
  333. (let ((wait (min total (- duration total))))
  334. (sit-for wait)
  335. (cl-incf total wait))
  336. (sit-for (setq total 2))))))))
  337. (defun ghub-response-link-relations (req &optional headers payload)
  338. "Return an alist of link relations in HEADERS.
  339. If optional HEADERS is nil, then return those that were
  340. previously stored in the variable `ghub-response-headers'.
  341. When accessing a Bitbucket instance then the link relations
  342. are in PAYLOAD instead of HEADERS, making their API merely
  343. RESTish and forcing this function to append those relations
  344. to the value of `ghub-response-headers', for later use when
  345. this function is called with nil for PAYLOAD."
  346. (if (eq (ghub--req-forge req) 'bitbucket)
  347. (if payload
  348. (let* ((page (cl-mapcan (lambda (key)
  349. (when-let ((elt (assq key payload)))
  350. (list elt)))
  351. '(size page pagelen next previous)))
  352. (headers (cons (cons 'link-alist page) headers)))
  353. (if (and req (or (ghub--req-callback req)
  354. (ghub--req-errorback req)))
  355. (setq-local ghub-response-headers headers)
  356. (setq-default ghub-response-headers headers))
  357. page)
  358. (cdr (assq 'link-alist ghub-response-headers)))
  359. (when-let ((rels (cdr (assoc "Link" (or headers ghub-response-headers)))))
  360. (mapcar (lambda (elt)
  361. (pcase-let ((`(,url ,rel) (split-string elt "; ")))
  362. (cons (intern (substring rel 5 -1))
  363. (substring url 1 -1))))
  364. (split-string rels ", ")))))
  365. (cl-defun ghub-repository-id (owner name &key username auth host forge noerror)
  366. "Return the id of the specified repository.
  367. Signal an error if the id cannot be determined."
  368. (let ((fn (cl-case forge
  369. ((nil ghub github) 'ghub--repository-id)
  370. (gitlab 'glab-repository-id)
  371. (gitea 'gtea-repository-id)
  372. (gogs 'gogs-repository-id)
  373. (bitbucket 'buck-repository-id)
  374. (t (intern (format "%s-repository-id" forge))))))
  375. (unless (fboundp fn)
  376. (error "ghub-repository-id: Forge type/abbreviation `%s' is unknown"
  377. forge))
  378. (or (funcall fn owner name :username username :auth auth :host host)
  379. (and (not noerror)
  380. (error "Repository %S does not exist on %S.\n%s%S?"
  381. (concat owner "/" name)
  382. (or host (ghub--host forge))
  383. "Maybe it was renamed and you have to update "
  384. "remote.<remote>.url")))))
  385. ;;;; Internal
  386. (defvar ghub-use-workaround-for-emacs-bug
  387. (and
  388. ;; Note: For build sans gnutls, `libgnutls-version' is -1.
  389. (>= libgnutls-version 30603)
  390. (or (version<= emacs-version "26.2")
  391. (eq system-type 'darwin))
  392. 'force)
  393. "Whether to use a kludge that hopefully works around an Emacs bug.
  394. In Emacs versions before 26.3 there is a bug that often but not
  395. always causes network connections to fail when using TLS1.3. It
  396. appears that even when using Emacs 26.3 the bug still exists but
  397. only on macOS.
  398. The workaround works by binding `gnutls-algorithm-priority' to
  399. \"NORMAL:-VERS-TLS1.3\" in `ghub--retrieve' around the call to
  400. `url-retrieve' or `url-retrieve-synchronously'. If you would
  401. like to use the same kludge for other uses of these functions,
  402. then you have to set this variable globally to the mentioned
  403. value.
  404. This variable controls whether the `ghub' package should use the
  405. kludge.
  406. - If nil, then never use the kludge.
  407. - If `force' then always use the kludge no matter what.
  408. - For any other non-nil value use the kludge, if and only if we
  409. believe that doing so is the correct thing to do.
  410. The default value of this variable is either nil or `forge'. It
  411. is `forge' if using libgnutls >=3.6.3 (the version introducing
  412. TLS1.3); AND also using Emacs < 26.3 and/or macOS (any version).
  413. If the value is any other non-nil value, then `ghub--retrieve'
  414. used the same logic as describe in the previous paragraph, but
  415. every time it is called. (This complication is mostly a historic
  416. accident, which we don't want to change because doing so would
  417. break this kludge for some users who have been relying on it for
  418. a while already.)
  419. For more information see https://github.com/magit/ghub/issues/81
  420. and https://debbugs.gnu.org/cgi/bugreport.cgi?bug=34341.")
  421. (cl-defun ghub--retrieve (payload req)
  422. (let ((url-request-extra-headers
  423. (let ((headers (ghub--req-headers req)))
  424. (if (functionp headers) (funcall headers) headers)))
  425. (url-request-method (ghub--req-method req))
  426. (url-request-data payload)
  427. (url-show-status nil)
  428. (url (ghub--req-url req))
  429. (handler (ghub--req-handler req))
  430. (silent (ghub--req-silent req))
  431. (gnutls-algorithm-priority
  432. (if (and ghub-use-workaround-for-emacs-bug
  433. (or (eq ghub-use-workaround-for-emacs-bug 'force)
  434. (and (not gnutls-algorithm-priority)
  435. (>= libgnutls-version 30603)
  436. (or (version<= emacs-version "26.2")
  437. (eq system-type 'darwin))
  438. (memq (ghub--req-forge req) '(github nil)))))
  439. "NORMAL:-VERS-TLS1.3"
  440. gnutls-algorithm-priority)))
  441. (if (or (ghub--req-callback req)
  442. (ghub--req-errorback req))
  443. (url-retrieve url handler (list req) silent)
  444. (with-current-buffer
  445. (url-retrieve-synchronously url silent)
  446. (funcall handler (car url-callback-arguments) req)))))
  447. (defun ghub--handle-response (status req)
  448. (let ((buffer (current-buffer)))
  449. (unwind-protect
  450. (progn
  451. (set-buffer-multibyte t)
  452. (let* ((unpaginate (ghub--req-unpaginate req))
  453. (headers (ghub--handle-response-headers status req))
  454. (payload (ghub--handle-response-payload req))
  455. (payload (ghub--handle-response-error status payload req))
  456. (value (ghub--handle-response-value payload req))
  457. (prev (ghub--req-url req))
  458. (next (cdr (assq 'next (ghub-response-link-relations
  459. req headers payload)))))
  460. (when (numberp unpaginate)
  461. (cl-decf unpaginate))
  462. (setf (ghub--req-url req)
  463. (url-generic-parse-url next))
  464. (setf (ghub--req-unpaginate req) unpaginate)
  465. (or (and next
  466. unpaginate
  467. (or (eq unpaginate t)
  468. (> unpaginate 0))
  469. (ghub-continue req))
  470. (let ((callback (ghub--req-callback req))
  471. (errorback (ghub--req-errorback req))
  472. (err (plist-get status :error)))
  473. (cond ((and err errorback)
  474. (setf (ghub--req-url req) prev)
  475. (funcall (if (eq errorback t)
  476. 'ghub--errorback
  477. errorback)
  478. err headers status req))
  479. (callback
  480. (funcall callback value headers status req))
  481. (t value))))))
  482. (when (buffer-live-p buffer)
  483. (kill-buffer buffer)))))
  484. (defun ghub--handle-response-headers (status req)
  485. (goto-char (point-min))
  486. (forward-line 1)
  487. (let (headers)
  488. (when (memq url-http-end-of-headers '(nil 0))
  489. (setq url-debug t)
  490. (let ((print-escape-newlines nil))
  491. (error "BUG: missing headers
  492. See https://github.com/magit/ghub/issues/81.
  493. url: %s
  494. headers: %S
  495. status: %S
  496. buffer: %S"
  497. (url-recreate-url (ghub--req-url req))
  498. url-http-end-of-headers
  499. status
  500. (current-buffer))))
  501. (while (re-search-forward "^\\([^:]*\\): \\(.+\\)"
  502. url-http-end-of-headers t)
  503. (push (cons (match-string 1)
  504. (match-string 2))
  505. headers))
  506. (setq headers (nreverse headers))
  507. (goto-char (1+ url-http-end-of-headers))
  508. (if (and req (or (ghub--req-callback req)
  509. (ghub--req-errorback req)))
  510. (setq-local ghub-response-headers headers)
  511. (setq-default ghub-response-headers headers))
  512. headers))
  513. (defun ghub--handle-response-error (status payload req)
  514. (let ((noerror (ghub--req-noerror req))
  515. (err (plist-get status :error)))
  516. (if err
  517. (if noerror
  518. (if (eq noerror 'return)
  519. payload
  520. (setcdr (last err) (list payload))
  521. nil)
  522. (ghub--signal-error err payload req))
  523. payload)))
  524. (defun ghub--signal-error (err &optional payload req)
  525. (pcase-let ((`(,symb . ,data) err))
  526. (if (eq symb 'error)
  527. (if (eq (car-safe data) 'http)
  528. (signal 'ghub-http-error
  529. (let ((code (car (cdr-safe data))))
  530. (list code
  531. (nth 2 (assq code url-http-codes))
  532. (and req (url-filename (ghub--req-url req)))
  533. payload)))
  534. (signal 'ghub-error data))
  535. (signal symb data))))
  536. (defun ghub--errorback (err _headers _status req)
  537. (ghub--signal-error err (nth 3 err) req))
  538. (defun ghub--handle-response-value (payload req)
  539. (setf (ghub--req-value req)
  540. (nconc (ghub--req-value req)
  541. (if-let ((nested (and (eq (ghub--req-forge req) 'bitbucket)
  542. (assq 'values payload))))
  543. (cdr nested)
  544. payload))))
  545. (defun ghub--handle-response-payload (req)
  546. (funcall (or (ghub--req-reader req)
  547. 'ghub--read-json-payload)
  548. url-http-response-status))
  549. (defun ghub--read-json-payload (_status)
  550. (let ((raw (ghub--decode-payload)))
  551. (and raw
  552. (condition-case nil
  553. (let ((json-object-type 'alist)
  554. (json-array-type 'list)
  555. (json-key-type 'symbol)
  556. (json-false nil)
  557. (json-null nil))
  558. (json-read-from-string raw))
  559. (json-readtable-error
  560. `((message
  561. . ,(if (looking-at "<!DOCTYPE html>")
  562. (if (re-search-forward
  563. "<p>\\(?:<strong>\\)?\\([^<]+\\)" nil t)
  564. (match-string 1)
  565. "error description missing")
  566. (string-trim (buffer-substring (point) (point-max)))))
  567. (documentation_url
  568. . "https://github.com/magit/ghub/wiki/Github-Errors")))))))
  569. (defun ghub--decode-payload (&optional _status)
  570. (and (not (eobp))
  571. (decode-coding-string
  572. (buffer-substring-no-properties (point) (point-max))
  573. 'utf-8)))
  574. (defun ghub--encode-payload (payload)
  575. (and payload
  576. (progn
  577. (unless (stringp payload)
  578. ;; Unfortunately `json-encode' may modify the input.
  579. ;; See https://debbugs.gnu.org/cgi/bugreport.cgi?bug=40693.
  580. ;; and https://github.com/magit/forge/issues/267
  581. (setq payload (json-encode (copy-tree payload))))
  582. (encode-coding-string payload 'utf-8))))
  583. (defun ghub--url-encode-params (params)
  584. (mapconcat (lambda (param)
  585. (pcase-let ((`(,key . ,val) param))
  586. (concat (url-hexify-string (symbol-name key)) "="
  587. (if (integerp val)
  588. (number-to-string val)
  589. (url-hexify-string val)))))
  590. params "&"))
  591. ;;; Authentication
  592. ;;;; API
  593. ;;;###autoload
  594. (defun ghub-clear-caches ()
  595. "Clear all caches that might negatively affect Ghub.
  596. If a library that is used by Ghub caches incorrect information
  597. such as a mistyped password, then that can prevent Ghub from
  598. asking the user for the correct information again.
  599. Set `url-http-real-basic-auth-storage' to nil
  600. and call `auth-source-forget+'."
  601. (interactive)
  602. (setq url-http-real-basic-auth-storage nil)
  603. (auth-source-forget+))
  604. ;;;; Internal
  605. (defun ghub--headers (headers host auth username forge)
  606. (push (cons "Content-Type" "application/json") headers)
  607. (if (eq auth 'none)
  608. headers
  609. (unless (or username (stringp auth))
  610. (setq username (ghub--username host forge)))
  611. (lambda ()
  612. (if (eq auth 'basic)
  613. (cons (cons "Authorization" (ghub--basic-auth host username))
  614. headers)
  615. (cons (ghub--auth host auth username forge) headers)))))
  616. (defun ghub--auth (host auth &optional username forge)
  617. (unless username
  618. (setq username (ghub--username host forge)))
  619. (if (eq auth 'basic)
  620. (cl-ecase forge
  621. ((nil gitea gogs bitbucket)
  622. (cons "Authorization" (ghub--basic-auth host username)))
  623. ((github gitlab)
  624. (error "%s does not support basic authentication"
  625. (capitalize (symbol-name forge)))))
  626. (cons (cl-ecase forge
  627. ((nil github gitea gogs bitbucket)
  628. "Authorization")
  629. (gitlab
  630. "Private-Token"))
  631. (if (eq forge 'bitbucket)
  632. ;; For some undocumented reason Bitbucket supports
  633. ;; values of the form "token <token>" only for GET
  634. ;; requests. For PUT requests we have to use basic
  635. ;; authentication. Note that the secret is a token
  636. ;; (aka "app password"), not the actual password.
  637. ;; The documentation fails to mention this little
  638. ;; detail. See #97.
  639. (concat "Basic "
  640. (base64-encode-string
  641. (concat username ":"
  642. (ghub--token host username auth nil forge))
  643. t))
  644. (concat
  645. (and (not (eq forge 'gitlab)) "token ")
  646. (encode-coding-string
  647. (cl-typecase auth
  648. (string auth)
  649. (null (ghub--token host username 'ghub nil forge))
  650. (symbol (ghub--token host username auth nil forge))
  651. (t (signal 'wrong-type-argument
  652. `((or stringp symbolp) ,auth))))
  653. 'utf-8))))))
  654. (defun ghub--basic-auth (host username)
  655. (let ((url (url-generic-parse-url
  656. (if (member host ghub-insecure-hosts) "http://" "https://"))))
  657. (setf (url-user url) username)
  658. (url-basic-auth url t)))
  659. (defun ghub--token (host username package &optional nocreate forge)
  660. (let* ((user (ghub--ident username package))
  661. (token
  662. (or (car (ghub--auth-source-get (list :secret)
  663. :host host :user user))
  664. (progn
  665. ;; Auth-Source caches the information that there is no
  666. ;; value, but in our case that is a situation that needs
  667. ;; fixing so we want to keep trying by invalidating that
  668. ;; information.
  669. ;; The (:max 1) is needed and has to be placed at the
  670. ;; end for Emacs releases before 26.1. #24 #64 #72
  671. (auth-source-forget (list :host host :user user :max 1))
  672. (and (not nocreate)
  673. (error "\
  674. Required %s token (%S for %S) does not exist.
  675. See https://magit.vc/manual/ghub/Getting-Started.html
  676. or (info \"(ghub)Getting Started\") for instructions.
  677. \(The setup wizard no longer exists.)"
  678. (capitalize (symbol-name (or forge 'github)))
  679. user host))))))
  680. (if (functionp token) (funcall token) token)))
  681. (cl-defmethod ghub--host (&optional forge)
  682. (cl-ecase forge
  683. ((nil github)
  684. (or (ignore-errors (car (process-lines "git" "config" "github.host")))
  685. ghub-default-host))
  686. (gitlab
  687. (or (ignore-errors (car (process-lines "git" "config" "gitlab.host")))
  688. (bound-and-true-p glab-default-host)))
  689. (gitea
  690. (or (ignore-errors (car (process-lines "git" "config" "gitea.host")))
  691. (bound-and-true-p gtea-default-host)))
  692. (gogs
  693. (or (ignore-errors (car (process-lines "git" "config" "gogs.host")))
  694. (bound-and-true-p gogs-default-host)))
  695. (bitbucket
  696. (or (ignore-errors (car (process-lines "git" "config" "bitbucket.host")))
  697. (bound-and-true-p buck-default-host)))))
  698. (cl-defmethod ghub--username (host &optional forge)
  699. (let ((var
  700. (cl-ecase forge
  701. ((nil github)
  702. (if (equal host ghub-default-host)
  703. "github.user"
  704. (format "github.%s.user" host)))
  705. (gitlab
  706. (if (equal host "gitlab.com/api/v4")
  707. "gitlab.user"
  708. (format "gitlab.%s.user" host)))
  709. (bitbucket
  710. (if (equal host "api.bitbucket.org/2.0")
  711. "bitbucket.user"
  712. (format "bitbucket.%s.user" host)))
  713. (gitea
  714. (when (zerop (call-process "git" nil nil nil "config" "gitea.host"))
  715. (error "gitea.host is set but always ignored"))
  716. (format "gitea.%s.user" host))
  717. (gogs
  718. (when (zerop (call-process "git" nil nil nil "config" "gogs.host"))
  719. (error "gogs.host is set but always ignored"))
  720. (format "gogs.%s.user" host)))))
  721. (condition-case nil
  722. (car (process-lines "git" "config" var))
  723. (error
  724. (let ((user (read-string
  725. (format "Git variable `%s' is unset. Set to: " var))))
  726. (if (equal user "")
  727. (user-error "The empty string is not a valid username")
  728. (call-process
  729. "git" nil nil nil "config"
  730. (and (eq (read-char-choice
  731. (format
  732. "Set %s=%s [g]lobally (recommended) or [l]ocally? "
  733. var user)
  734. (list ?g ?l))
  735. ?g)
  736. "--global")
  737. var user)
  738. user))))))
  739. (defun ghub--ident (username package)
  740. (format "%s^%s" username package))
  741. (defun ghub--auth-source-get (keys &rest spec)
  742. (declare (indent 1))
  743. (let ((plist (car (apply #'auth-source-search
  744. (append spec (list :max 1))))))
  745. (mapcar (lambda (k)
  746. (plist-get plist k))
  747. keys)))
  748. (when (version< emacs-version "26.2")
  749. ;; Fixed by Emacs commit 60ff8101449eea3a5ca4961299501efd83d011bd.
  750. (advice-add 'auth-source-netrc-parse-next-interesting :around
  751. 'auth-source-netrc-parse-next-interesting@save-match-data)
  752. (defun auth-source-netrc-parse-next-interesting@save-match-data (fn)
  753. "Save match-data for the benefit of caller `auth-source-netrc-parse-one'.
  754. Without wrapping this function in `save-match-data' the caller
  755. won't see the secret from a line that is followed by a commented
  756. line."
  757. (save-match-data (funcall fn))))
  758. (advice-add 'url-http-handle-authentication :around
  759. 'url-http-handle-authentication@unauthorized-bugfix)
  760. (defun url-http-handle-authentication@unauthorized-bugfix (fn proxy)
  761. "If authorization failed then don't try again but fail properly.
  762. For Emacs 27.1 prevent a useful `http' error from being replaced
  763. by a generic one that omits all useful information. For earlier
  764. releases prevent a new request from being made, which would
  765. either result in an infinite loop or (e.g. in the case of `ghub')
  766. the user being asked for their name."
  767. (if (assoc "Authorization" url-http-extra-headers)
  768. t ; Return "success", here also known as "successfully failed".
  769. (funcall fn proxy)))
  770. ;;; _
  771. (provide 'ghub)
  772. (require 'ghub-graphql)
  773. ;;; ghub.el ends here