;;; magithub-ci.el --- Show CI status as a magit-status header -*- lexical-binding: t; -*- ;; Copyright (C) 2016 Sean Allred ;; Author: Sean Allred ;; Keywords: tools ;; This program 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 of the License, or ;; (at your option) any later version. ;; This program 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. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;;; Commentary: ;; Provide the CI status of "origin" in the Magit status buffer. ;;; Code: (require 'magit) (require 'magit-section) (require 'magit-popup) (require 'dash) (require 'magithub-core) (require 'magithub-cache) (defconst magithub-ci-status-symbol-alist '(("✔" . success) ("✖" . failure) ; also means `error'... gross ("●" . pending)) "Because hub 2.3 is silly and does silly things. Reference: https://github.com/github/hub/blob/master/commands/ci_status.go#L107") (defconst magithub-ci-status-regex (rx bos (group any) (* any) "\t" (group (* any)) "\t" (? (group (* any))) eos)) (defvar magithub-ci-urls nil "An alist mapping of repositories to CI urls.") (defun magithub-ci-enabled-p () "Non-nil if CI is enabled for this repository. If magithub.ci.enabled is not set, CI is considered to be enabled." (when (member (magit-get "magithub" "ci" "enabled") '(nil "yes")) t)) (defun magithub-ci-disable () "Disable CI for this repository." (magit-set "no" "magithub" "ci" "enabled")) (defun magithub-ci-enable () "Enable CI for this repository." (magit-set "yes" "magithub" "ci" "enabled")) (defun magithub-maybe-insert-ci-status-header () "If this is a GitHub repository, insert the CI status header." (when (and (magithub-ci-enabled-p) (magithub-usable-p)) (magithub-insert-ci-status-header))) (defun magithub-ci-toggle () "Toggle CI integration." (interactive) (if (magithub-ci-enabled-p) (magithub-ci-disable) (magithub-ci-enable)) (when (derived-mode-p 'magit-status-mode) (magit-refresh))) (magit-define-popup-action 'magithub-dispatch-popup ?~ "Toggle CI for this repository" #'magithub-ci-toggle ?`) (defun magithub-ci-status () "One of 'success, 'error, 'failure, 'pending, or 'no-status." (let ((same-commit (string-equal (magit-rev-parse "HEAD") (magithub-ci-status-current-commit)))) (unless same-commit (magithub-cache-clear (magithub-repo-id) :ci-status)) (if (eq (magithub-cache-value (magithub-repo-id) :ci-status) 'success) 'success (magithub-cache (magithub-repo-id) :ci-status '(magithub-ci-status--internal))))) (defun magithub-ci-status-current-commit (&optional new-value) "The commit our cached value corresponds to." (let ((keys (list "magithub" "ci" "lastCommit"))) (if new-value (apply #'magit-set new-value keys) (apply #'magit-get keys)))) (defun magithub-ci-update-urls (statuses) "Updates `magithub-ci-urls' according to STATUSES. See also `magithub-repo-id'." (let* ((id (magithub-repo-id)) (repo-urls (assoc id magithub-ci-urls)) (urls (mapcar (lambda (s) (plist-get s :url)) statuses))) (if repo-urls (setcdr repo-urls urls) (add-to-list 'magithub-ci-urls (cons id urls)) urls))) (defun magithub-ci-status--parse-2.2.8 (output) "Backwards compatibility for old versions of hub. See `magithub-ci-status--parse'." (--when-let (cdr (s-match (rx bos (group (+ (any alpha space))) (? ": " (group (+ (not (any " "))))) eos) output)) (let ((status (list :status (intern (replace-regexp-in-string "\s" "-" (car it))) :url (cadr it)))) (magithub-ci-update-urls (list status)) status))) (defun magithub-ci-status--internal (&optional for-commit) "One of 'success, 'error, 'failure, 'pending, or 'no-status." (let* ((current-commit (magit-rev-parse "HEAD")) (last-commit (or for-commit current-commit)) (output (magithub--command-output "ci-status" `("-v" ,last-commit)))) (--if-let (if (magithub-hub-version-at-least "2.3") (car (magithub-ci-status--parse output)) (magithub-ci-status--parse-2.2.8 (car output))) (prog1 (or (plist-get it :status) 'no-status) (if (not (or for-commit (plist-get it :status))) (let ((last-commit (magithub-ci-status--last-commit))) (unless (string-equal current-commit last-commit) (magithub-ci-status--internal last-commit)) (magithub-ci-status-current-commit current-commit)) (magithub-ci-status-current-commit current-commit))) (beep) (setq magithub-hub-error (message (concat "Hub didn't have any recognizable output for \"ci-status\"!\n" "Are you connected to the internet?\n" "Consider submitting an issue to github/hub."))) 'internal-error))) (defun magithub-ci-status--parse (output) "Parse a string OUTPUT into a list of statuses. The first status will be an `overall' status." (let ((statuses (mapcar #'magithub-ci-status--parse-line output)) (get-status (lambda (status) (lambda (s) (eq (plist-get s :status) status))))) (magithub-ci-update-urls statuses) (cons (list :check 'overall :status (cond ((-all? (funcall get-status 'success) statuses) 'success) ((-some? (funcall get-status 'pending) statuses) 'pending) ((-some? (funcall get-status 'error) statuses) 'error) ((-some? (funcall get-status 'failure) statuses) 'failure))) statuses))) (defun magithub-ci-status--parse-line (line) "Parse a single LINE of status into a status plist." (--if-let (cdr (s-match magithub-ci-status-regex line)) (list :status (cdr (assoc (car it) magithub-ci-status-symbol-alist)) :url (car (cddr it)) :check (cadr it)) (if (string= line "no-status") 'no-status (if (string= line "") 'no-output)))) (defun magithub-ci-status--last-commit () "Find the commit considered to have the current CI status. Right now, this finds the most recent commit without [ci skip] or [skip ci] in the commit message. See the following resources: - https://docs.travis-ci.com/user/customizing-the-build#Skipping-a-build - https://circleci.com/docs/skip-a-build/" (let* ((args '("--invert-grep" "--grep=\\[ci skip\\]" "--grep=\\[skip ci\\]" "--format=oneline" "--max-count=1")) (output (magit-git-lines "log" args))) (car (split-string (car output))))) (defvar magithub-ci-status-alist '((no-status . "None") (error . "Error") (internal-error . magithub-ci--hub-error-string) (failure . "Failure") (pending . "Pending") (success . "Success"))) (defun magithub-ci--hub-error-string () "Internal error string." (format "Internal error!\n%s" magithub-hub-error)) (defface magithub-ci-no-status '((((class color)) :inherit magit-dimmed)) "Face used when CI status is `no-status'." :group 'magithub-faces) (defface magithub-ci-error '((((class color)) :inherit magit-signature-untrusted)) "Face used when CI status is `error'." :group 'magithub-faces) (defface magithub-ci-pending '((((class color)) :inherit magit-signature-untrusted)) "Face used when CI status is `pending'." :group 'magithub-faces) (defface magithub-ci-success '((((class color)) :inherit magit-signature-good)) "Face used when CI status is `success'." :group 'magithub-faces) (defface magithub-ci-failure '((((class color)) :inherit magit-signature-bad)) "Face used when CI status is `'" :group 'magithub-faces) (defface magithub-ci-unknown '((((class color)) :inherit magit-signature-untrusted)) "Face used when CI status is `unknown'." :group 'magithub-faces) (defun magithub-ci-visit () "Browse the CI. Sets up magithub.ci.url if necessary." (interactive) (let* ((urls (cdr (assoc (magithub-repo-id) magithub-ci-urls))) (target-url (if (= 1 (length urls)) (car urls) (when urls (completing-read "CI Dashboard URL: " urls))))) (when (or (null target-url) (string= "" target-url)) (user-error "No CI URL detected")) (browse-url target-url))) (defvar magit-magithub-ci-status-section-map (let ((map (make-sparse-keymap))) (define-key map [remap magit-visit-thing] #'magithub-ci-visit) (define-key map [remap magit-refresh] #'magithub-ci-refresh) map) "Keymap for `magithub-ci-status' header section.") (defun magithub-ci-refresh () "Invalidate the CI cache and refresh the buffer." (interactive) (magithub-cache-clear (magithub-repo-id) :ci-status) (when (derived-mode-p 'magit-status-mode) (magit-refresh))) (defun magithub-insert-ci-status-header () (let* ((status (magithub-ci-status)) (face (intern (format "magithub-ci-%s" (symbol-name status)))) (status-val (cdr (assq status magithub-ci-status-alist)))) (magit-insert-section (magithub-ci-status) (insert (format "%-10s" "CI: ")) (insert (propertize (cond ((stringp status-val) status-val) ((functionp status-val) (funcall status-val)) (t (format "%S" status-val))) 'face (if (facep face) face 'magithub-ci-unknown))) (insert ?\n)))) (magithub--deftoggle magithub-toggle-ci-status-header magit-status-headers-hook #'magithub-maybe-insert-ci-status-header "the CI header") (when (executable-find magithub-hub-executable) (magithub-toggle-ci-status-header)) (provide 'magithub-ci) ;;; magithub-ci.el ends here