;;; ghub-graphql.el --- access Github API using GrapthQL -*- lexical-binding: t -*-
|
|
;; Copyright (C) 2016-2018 Jonas Bernoulli
|
|
;; Author: Jonas Bernoulli <jonas@bernoul.li>
|
;; Homepage: https://github.com/magit/ghub
|
|
;; This file is not part of GNU Emacs.
|
|
;; This file is free software; you can redistribute it and/or modify
|
;; it under the terms of the GNU General Public License as published by
|
;; the Free Software Foundation; either version 3, or (at your option)
|
;; any later version.
|
|
;; This file is distributed in the hope that it will be useful,
|
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
|
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
;; GNU General Public License for more details.
|
|
;; For a copy of the GPL see https://www.gnu.org/licenses/gpl.txt.
|
|
;;; Code:
|
|
(require 'dash)
|
(require 'ghub)
|
(require 'graphql)
|
(require 'treepy)
|
|
(eval-when-compile
|
(require 'subr-x))
|
|
;;; Api
|
|
(cl-defun ghub-graphql (graphql &optional variables
|
&key username auth host
|
silent
|
callback errorback value extra)
|
"Make a GraphQL request using GRAPHQL and VARIABLES.
|
Return the response as a JSON-like alist. Even if the response
|
contains `errors', do not raise an error. GRAPHQL is a GraphQL
|
string. VARIABLES is a JSON-like alist. The other arguments
|
behave as for `ghub-request' (which see)."
|
(cl-assert (stringp graphql))
|
(cl-assert (not (stringp variables)))
|
(ghub-request "POST" "/graphql" nil :payload
|
(json-encode `(("query" . ,graphql)
|
,@(and variables `(("variables" ,@variables)))))
|
:silent silent
|
:username username :auth auth :host host
|
:callback callback :errorback errorback
|
:extra extra :value value))
|
|
(cl-defun ghub-graphql-rate-limit (&key username auth host)
|
"Return rate limit information."
|
(let-alist (ghub-graphql
|
"query { rateLimit { limit cost remaining resetAt }}"
|
nil :username username :auth auth :host host)
|
.data.rateLimit))
|
|
(cl-defun ghub--repository-id (owner name &key username auth host)
|
"Return the id of the repository specified by OWNER, NAME and HOST."
|
(let-alist (ghub-graphql
|
"query ($owner:String!, $name:String!) {
|
repository(owner:$owner, name:$name) { id }
|
}"
|
`((owner . ,owner)
|
(name . ,name))
|
:username username :auth auth :host host)
|
.data.repository.id))
|
|
;;; Api (drafts)
|
|
(defconst ghub-fetch-repository
|
'(query
|
(repository
|
[(owner $owner String!)
|
(name $name String!)]
|
name
|
id
|
createdAt
|
updatedAt
|
nameWithOwner
|
description
|
(defaultBranchRef name)
|
isArchived
|
isFork
|
isLocked
|
isMirror
|
isPrivate
|
hasIssuesEnabled
|
hasWikiEnabled
|
(licenseInfo name)
|
(stargazers totalCount)
|
(watchers totalCount)
|
(assignableUsers [(:edges t)]
|
id
|
login
|
name)
|
(issues [(:edges t)
|
(:singular issue number)
|
(orderBy ((field . UPDATED_AT) (direction . DESC)))]
|
number
|
state
|
(author login)
|
title
|
createdAt
|
updatedAt
|
closedAt
|
locked
|
(milestone id)
|
body
|
(assignees [(:edges t)]
|
id)
|
(comments [(:edges t)]
|
databaseId
|
(author login)
|
createdAt
|
updatedAt
|
body)
|
(labels [(:edges t)]
|
id))
|
(labels [(:edges t)
|
(:singular label id)]
|
id
|
name
|
color
|
description)
|
(pullRequests [(:edges t)
|
(:singular pullRequest number)
|
(orderBy ((field . UPDATED_AT) (direction . DESC)))]
|
number
|
state
|
(author login)
|
title
|
createdAt
|
updatedAt
|
closedAt
|
mergedAt
|
locked
|
maintainerCanModify
|
isCrossRepository
|
(milestone id)
|
body
|
(baseRef name
|
(repository nameWithOwner))
|
(headRef name
|
(repository (owner login)
|
nameWithOwner))
|
(assignees [(:edges t)]
|
id)
|
(comments [(:edges t)]
|
databaseId
|
(author login)
|
createdAt
|
updatedAt
|
body)
|
(labels [(:edges t)]
|
id)))))
|
|
(cl-defun ghub-fetch-repository (owner name callback
|
&optional until
|
&key username auth host forge)
|
"Asynchronously fetch forge data about the specified repository.
|
Once all data has been collected, CALLBACK is called with the
|
data as the only argument."
|
(ghub--graphql-vacuum ghub-fetch-repository
|
`((owner . ,owner)
|
(name . ,name))
|
callback until
|
:narrow '(repository)
|
:username username
|
:auth auth
|
:host host
|
:forge forge))
|
|
(cl-defun ghub-fetch-issue (owner name number callback
|
&optional until
|
&key username auth host forge)
|
"Asynchronously fetch forge data about the specified issue.
|
Once all data has been collected, CALLBACK is called with the
|
data as the only argument."
|
(ghub--graphql-vacuum (ghub--graphql-prepare-query
|
ghub-fetch-repository
|
`(repository issues (issue . ,number)))
|
`((owner . ,owner)
|
(name . ,name))
|
callback until
|
:narrow '(repository issue)
|
:username username
|
:auth auth
|
:host host
|
:forge forge))
|
|
(cl-defun ghub-fetch-pullreq (owner name number callback
|
&optional until
|
&key username auth host forge)
|
"Asynchronously fetch forge data about the specified pull-request.
|
Once all data has been collected, CALLBACK is called with the
|
data as the only argument."
|
(ghub--graphql-vacuum (ghub--graphql-prepare-query
|
ghub-fetch-repository
|
`(repository pullRequests (pullRequest . ,number)))
|
`((owner . ,owner)
|
(name . ,name))
|
callback until
|
:narrow '(repository pullRequest)
|
:username username
|
:auth auth
|
:host host
|
:forge forge))
|
|
;;; Internal
|
|
(cl-defstruct (ghub--graphql-req
|
(:include ghub--req)
|
(:constructor ghub--make-graphql-req)
|
(:copier nil))
|
(query nil :read-only t)
|
(variables nil :read-only t)
|
(until nil :read-only t)
|
(pages 0 :read-only nil))
|
|
(cl-defun ghub--graphql-vacuum (query variables callback
|
&optional until
|
&key narrow username auth host forge)
|
"Make a GraphQL request using QUERY and VARIABLES.
|
See Info node `(ghub)GraphQL Support'."
|
(unless host
|
(setq host (ghub--host forge)))
|
(unless (or username (stringp auth) (eq auth 'none))
|
(setq username (ghub--username host forge)))
|
(ghub--graphql-retrieve
|
(ghub--make-graphql-req
|
:url (url-generic-parse-url (concat "https://" host "/graphql"))
|
:method "POST"
|
:headers (ghub--headers nil host auth username forge)
|
:handler 'ghub--graphql-handle-response
|
:query query
|
:variables variables
|
:until until
|
:callback (if narrow
|
(lambda (data)
|
(let ((path narrow) key)
|
(while (setq key (pop path))
|
(setq data (cdr (assq key data)))))
|
(funcall callback data))
|
callback))))
|
|
(cl-defun ghub--graphql-retrieve (req &optional lineage cursor)
|
(let ((p (cl-incf (ghub--graphql-req-pages req))))
|
(when (> p 1)
|
(message "Fetching page %s..." p)))
|
(ghub--retrieve
|
(let ((json-false nil))
|
(ghub--encode-payload
|
`((query . ,(ghub--graphql-encode
|
(ghub--graphql-prepare-query
|
(ghub--graphql-req-query req)
|
lineage cursor)))
|
(variables . ,(ghub--graphql-req-variables req)))))
|
req))
|
|
(defun ghub--graphql-prepare-query (query &optional lineage cursor)
|
(when lineage
|
(setq query (ghub--graphql-narrow-query query lineage cursor)))
|
(let ((loc (ghub--alist-zip query))
|
variables)
|
(cl-block nil
|
(while t
|
(let ((node (treepy-node loc)))
|
(when (vectorp node)
|
(let ((alist (cl-coerce node 'list))
|
vars)
|
(when (assq :edges alist)
|
(push (list 'first 100) vars)
|
(setq loc (treepy-up loc))
|
(setq node (treepy-node loc))
|
(setq loc (treepy-replace
|
loc `(,(car node)
|
,(cadr node)
|
(pageInfo endCursor hasNextPage)
|
(edges (node ,@(cddr node))))))
|
(setq loc (treepy-down loc))
|
(setq loc (treepy-next loc)))
|
(dolist (elt alist)
|
(cond ((keywordp (car elt)))
|
((= (length elt) 3)
|
(push (list (nth 0 elt)
|
(nth 1 elt)) vars)
|
(push (list (nth 1 elt)
|
(nth 2 elt)) variables))
|
((= (length elt) 2)
|
(push elt vars))))
|
(setq loc (treepy-replace loc (cl-coerce vars 'vector))))))
|
(if (treepy-end-p loc)
|
(let ((node (copy-sequence (treepy-node loc))))
|
(when variables
|
(push (cl-coerce variables 'vector)
|
(cdr node)))
|
(cl-return node))
|
(setq loc (treepy-next loc)))))))
|
|
(defun ghub--graphql-handle-response (status req)
|
(let ((buffer (current-buffer)))
|
(unwind-protect
|
(progn
|
(set-buffer-multibyte t)
|
(let* ((headers (ghub--handle-response-headers status req))
|
(payload (ghub--handle-response-payload req))
|
(payload (ghub--handle-response-error status payload req))
|
(err (plist-get status :error))
|
(errors (cdr (assq 'errors payload)))
|
(errors (and errors
|
(cons 'ghub-graphql-error errors)))
|
(data (assq 'data payload))
|
(value (ghub--req-value req)))
|
(setf (ghub--req-value req) value)
|
(if (or err errors)
|
(if-let ((errorback (ghub--req-errorback req)))
|
(funcall errorback (or err errors) headers status req)
|
(ghub--signal-error (or err errors)))
|
(ghub--graphql-walk-response value data req))))
|
(when (buffer-live-p buffer)
|
(kill-buffer buffer)))))
|
|
(defun ghub--graphql-walk-response (loc data req)
|
(if (not loc)
|
(setf (ghub--req-value req)
|
(setq loc (ghub--alist-zip data)))
|
(setq data (ghub--graphql-narrow-data data (ghub--graphql-lineage loc)))
|
(setf (alist-get 'edges data)
|
(append (alist-get 'edges (treepy-node loc))
|
(or (alist-get 'edges data)
|
(error "BUG: Expected new nodes"))))
|
(setq loc (treepy-replace loc data)))
|
(cl-block nil
|
(while t
|
(when (eq (car-safe (treepy-node loc)) 'edges)
|
(setq loc (treepy-up loc))
|
(pcase-let ((`(,key . ,val) (treepy-node loc)))
|
(let-alist val
|
(let* ((cursor (and .pageInfo.hasNextPage
|
.pageInfo.endCursor))
|
(until (cdr (assq (intern (format "%s-until" key))
|
(ghub--graphql-req-until req))))
|
(nodes (mapcar #'cdar .edges))
|
(nodes (if until
|
(--take-while
|
(or (string> (cdr (assq 'updatedAt it)) until)
|
(setq cursor nil))
|
nodes)
|
nodes)))
|
(if cursor
|
(progn
|
(setf (ghub--req-value req) loc)
|
(ghub--graphql-retrieve req
|
(ghub--graphql-lineage loc)
|
cursor)
|
(cl-return))
|
(setq loc (treepy-replace loc (cons key nodes))))))))
|
(if (not (treepy-end-p loc))
|
(setq loc (treepy-next loc))
|
(funcall (ghub--req-callback req)
|
(treepy-root loc))
|
(cl-return)))))
|
|
(defun ghub--graphql-lineage (loc)
|
(let (lineage)
|
(while (treepy-up loc)
|
(push (car (treepy-node loc)) lineage)
|
(setq loc (treepy-up loc)))
|
lineage))
|
|
(defun ghub--graphql-narrow-data (data lineage)
|
(let (key)
|
(while (setq key (pop lineage))
|
(if (consp (car lineage))
|
(progn (pop lineage)
|
(setf data (cadr data)))
|
(setq data (assq key (cdr data))))))
|
data)
|
|
(defun ghub--graphql-narrow-query (query lineage cursor)
|
(if (consp (car lineage))
|
(let* ((child (cddr query))
|
(alist (cl-coerce (cadr query) 'list))
|
(single (cdr (assq :singular alist))))
|
`(,(car single)
|
,(vector (list (cadr single) (cdr (car lineage))))
|
,@(if (cdr lineage)
|
(ghub--graphql-narrow-query child (cdr lineage) cursor)
|
child)))
|
(let* ((child (or (assq (car lineage) (cdr query))
|
(cl-find-if (lambda (c)
|
(and (listp c)
|
(vectorp (cadr c))
|
(eq (cadr (assq :singular
|
(cl-coerce (cadr c)
|
'list)))
|
(car lineage))))
|
(cdr query))))
|
(object (car query))
|
(args (and (vectorp (cadr query))
|
(cadr query))))
|
`(,object
|
,@(and args (list args))
|
,(cond ((cdr lineage)
|
(ghub--graphql-narrow-query child (cdr lineage) cursor))
|
(cursor
|
`(,(car child)
|
,(vconcat `((after ,cursor))
|
(cadr child))
|
,@(cddr child)))
|
(t
|
child))))))
|
|
(defun ghub--graphql-encode (g)
|
(if (symbolp g)
|
(symbol-name g)
|
(let* ((object (car g))
|
(args (and (vectorp (cadr g))
|
(cl-coerce (cadr g) 'list)))
|
(fields (if args (cddr g) (cdr g))))
|
(concat
|
(graphql--encode-object object)
|
(and args
|
(format " (\n%s)"
|
(mapconcat (pcase-lambda (`(,key ,val))
|
(graphql--encode-argument key val))
|
args ",\n")))
|
(and fields
|
(format " {\n%s\n}"
|
(mapconcat #'ghub--graphql-encode fields "\n")))))))
|
|
(defun ghub--alist-zip (root)
|
(let ((branchp (lambda (elt) (and (listp elt) (listp (cdr elt)))))
|
(make-node (lambda (_ children) children)))
|
(treepy-zipper branchp #'identity make-node root)))
|
|
;;; _
|
(provide 'ghub-graphql)
|
;;; ghub-graphql.el ends here
|