;;; forge-gitlab.el --- Gitlab support -*- lexical-binding:t -*- ;; Copyright (C) 2018-2026 Jonas Bernoulli ;; Author: Jonas Bernoulli ;; Maintainer: Jonas Bernoulli ;; SPDX-License-Identifier: GPL-3.0-or-later ;; 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 of the License, ;; 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. ;; ;; You should have received a copy of the GNU General Public License ;; along with this file. If not, see . ;;; Code: (require 'forge-client) (require 'forge-issue) (require 'forge-pullreq) ;;; Class (defclass forge-gitlab-repository (forge-repository) ((issues-url-format :initform "https://%h/%o/%n/issues") (issue-url-format :initform "https://%h/%o/%n/issues/%i") (issue-post-url-format :initform "https://%h/%o/%n/issues/%i#note_%I") (pullreqs-url-format :initform "https://%h/%o/%n/merge_requests") (pullreq-url-format :initform "https://%h/%o/%n/merge_requests/%i") (pullreq-post-url-format :initform "https://%h/%o/%n/merge_requests/%i#note_%I") (commit-url-format :initform "https://%h/%o/%n/commit/%r") (branch-url-format :initform "https://%h/%o/%n/commits/%r") (remote-url-format :initform "https://%h/%o/%n") (blob-url-format :initform "https://%h/%o/%n/-/blob/%r/%f") (create-issue-url-format :initform "https://%h/%o/%n/issues/new") (create-pullreq-url-format :initform "https://%h/%o/%n/merge_requests/new") (pullreq-refspec :initform "+refs/merge-requests/*/head:refs/pullreqs/*"))) ;;; Pull ;;;; Repository (cl-defmethod forge--pull ((repo forge-gitlab-repository) &optional callback since) (cl-assert (not (and since (forge-get-repository repo nil :tracked?)))) (setq forge--mode-line-buffer (current-buffer)) (forge--msg repo t nil "Pulling REPO") (let ((buffer (current-buffer)) (value nil) (step nil) (skip (cond ((oref repo selective-p) '(assignees forks labels issues pullreqs)) ((magit-get-boolean "forge.omitExpensive") '(assignees forks labels))))) (named-let step (data) (cond ((not value) (when data (setq value data) (let-alist value (unless .issues_enabled (cl-pushnew 'issues skip)) (unless .merge_requests_enabled (cl-pushnew 'pullreqs skip))))) ((push (cons step data) value))) (cl-flet ((fetchp (sym) (unless (or (memq sym skip) (assq sym value)) (setq step sym) t))) (cond ((not value) (forge--fetch-repository repo #'step)) ((fetchp 'assignees) (forge--fetch-assignees repo #'step)) ((fetchp 'forks) (forge--fetch-forks repo #'step)) ((fetchp 'labels) (forge--fetch-labels repo #'step)) ((fetchp 'issues) (forge--fetch-issues repo #'step since)) ((fetchp 'pullreqs) (forge--fetch-pullreqs repo #'step since)) (t (forge--msg repo t t "Pulling REPO") (forge--msg repo t nil "Storing REPO") (let-alist value (closql-with-transaction (forge-db) (forge--update-repository repo value) (forge--update-assignees repo .assignees) (forge--update-labels repo .labels) (forge--update-issues repo .issues) (forge--update-pullreqs repo .pullreqs) (oset repo condition :tracked))) (forge--msg repo t t "Storing REPO") (cond ((oref repo selective-p)) (callback (funcall callback)) ((forge--maybe-git-fetch repo buffer))))))))) (cl-defmethod forge--fetch-repository ((repo forge-gitlab-repository) callback) (forge--glab-get repo "/projects/:project" nil :callback callback)) (cl-defmethod forge--update-repository ((repo forge-gitlab-repository) data) (let-alist data (oset repo created .created_at) (oset repo updated .last_activity_at) (oset repo pushed nil) (oset repo parent .forked_from_project.path_with_namespace) (oset repo description .description) (oset repo homepage nil) (oset repo default-branch .default_branch) (oset repo archived-p .archived) (oset repo fork-p (and .forked_from_project.path_with_namespace t)) (oset repo locked-p nil) (oset repo mirror-p .mirror) (oset repo private-p (equal .visibility "private")) (oset repo issues-p .issues_enabled) (oset repo wiki-p .wiki_enabled) (oset repo stars .star_count) (oset repo watchers .star_count))) ;;;; Topics (cl-defmethod forge--pull-topic ((repo forge-gitlab-repository) _topic &key callback _errorback) (forge--pull repo callback)) ; TODO Pull only the one topic. ;;;; Issues (cl-defmethod forge--fetch-issues ((repo forge-gitlab-repository) callback since) (letrec (( cb (let (val cur cnt pos) (lambda (&optional v) (cond ((and (not pos) v) (setq val v) (setq cur v) (setq pos 1) (setq cnt (length val)) (forge--msg nil nil nil "Pulling issue %s/%s" pos cnt) (forge--fetch-issue-posts repo cur cb)) ((setq cur (cdr cur)) (incf pos) (forge--msg nil nil nil "Pulling issue %s/%s" pos cnt) (forge--fetch-issue-posts repo cur cb)) (t (forge--msg repo t t "Pulling REPO issues") (funcall callback val))))))) (forge--msg repo t nil "Pulling REPO issues") (forge--glab-get repo "/projects/:project/issues" `((per_page . 100) (order_by . "updated_at") ,@(and$ (or since (oref repo issues-until)) `((updated_after . ,$)))) :unpaginate t :callback cb))) (cl-defmethod forge--fetch-issue-posts ((repo forge-gitlab-repository) cur cb) (forge--glab-get repo (let-alist (car cur) (format "/projects/%s/issues/%s/notes" .project_id .iid)) '((per_page . 100)) :unpaginate t :callback (lambda (value) (setf (alist-get 'notes (car cur)) value) (funcall cb)))) (cl-defmethod forge--update-issues ((repo forge-gitlab-repository) data) (dolist (v data) (forge--update-issue repo v))) (cl-defmethod forge--update-issue ((repo forge-gitlab-repository) data) (closql-with-transaction (forge-db) (let-alist data (let* ((issue-id (forge--object-id 'forge-issue repo .iid)) (issue (forge-issue :id issue-id :their-id .iid :number .iid :slug (format "#%s" .iid) :repository (oref repo id) ;; Gitlab doesn't make a distinction between completed ;; and unplanned issues. Treat them all as completed. :state (pcase-exhaustive .state ("closed" 'completed) ("opened" 'open)) :author .author.username :title .title :created .created_at :updated .updated_at ;; `.closed_at' may be nil even though the issues is ;; closed. In such cases use 1, so that this slot ;; at least can serve as a boolean. :closed (or .closed_at (and (equal .state "closed") 1)) :locked-p .discussion_locked :milestone .milestone.iid :body (forge--sanitize-string .description)))) (closql-insert (forge-db) issue t) (unless (magit-get-boolean "forge.omitExpensive") (forge--set-connections repo issue 'assignees .assignees) (forge--set-connections repo issue 'labels .labels)) (dolist (c .notes) (let-alist c (let ((post (forge-issue-post :id (forge--object-id issue-id .id) :issue issue-id :number .id :author .author.username :created .created_at :updated .updated_at :body (forge--sanitize-string .body)))) (closql-insert (forge-db) post t)))) (let ((until (oref repo issues-until))) (when (or (not until) (string> .updated_at until)) (oset repo issues-until .updated_at))) issue)))) ;;;; Pullreqs (cl-defmethod forge--fetch-pullreqs ((repo forge-gitlab-repository) callback since) (letrec (( cb (let (val cur cnt pos) (lambda (&optional v) (cond ((and (not pos) v) (setq val v) (setq cur v) (setq pos 1) (setq cnt (length val)) (forge--msg nil nil nil "Pulling pullreq %s/%s" pos cnt) (forge--fetch-pullreq-posts repo cur cb)) ((not pos) (forge--msg repo t t "Pulling REPO pullreqs") (funcall callback val)) ((not (assq 'source_project (car cur))) (forge--fetch-pullreq-source-repo repo cur cb)) ((not (assq 'target_project (car cur))) (forge--fetch-pullreq-target-repo repo cur cb)) ((setq cur (cdr cur)) (incf pos) (forge--msg nil nil nil "Pulling pullreq %s/%s" pos cnt) (forge--fetch-pullreq-posts repo cur cb)) (t (forge--msg repo t t "Pulling REPO pullreqs") (funcall callback val))))))) (forge--msg repo t nil "Pulling REPO pullreqs") (forge--glab-get repo "/projects/:project/merge_requests" `((per_page . 100) (order_by . "updated_at") ,@(and$ (or since (oref repo pullreqs-until)) `((updated_after . ,$)))) :unpaginate t :callback cb))) (cl-defmethod forge--fetch-pullreq-posts ((repo forge-gitlab-repository) cur cb) (forge--glab-get repo (let-alist (car cur) (format "/projects/%s/merge_requests/%s/notes" .target_project_id .iid)) '((per_page . 100)) :unpaginate t :callback (lambda (value) (setf (alist-get 'notes (car cur)) value) (funcall cb)))) (cl-defmethod forge--fetch-pullreq-source-repo ((repo forge-gitlab-repository) cur cb) ;; If the fork no longer exists, then `.source_project_id' is nil. ;; This will lead to difficulties later on but there is nothing we ;; can do about it. (let-alist (car cur) (if .source_project_id (forge--glab-get repo (format "/projects/%s" .source_project_id) nil :errorback (lambda (_err _headers _status _req) (setf (alist-get 'source_project (car cur)) nil) (funcall cb)) :callback (lambda (value) (setf (alist-get 'source_project (car cur)) value) (funcall cb))) (setf (alist-get 'source_project (car cur)) nil) (funcall cb)))) (cl-defmethod forge--fetch-pullreq-target-repo ((repo forge-gitlab-repository) cur cb) (let-alist (car cur) (forge--glab-get repo (format "/projects/%s" .target_project_id) nil :errorback (lambda (_err _headers _status _req) (setf (alist-get 'target_project (car cur)) nil) (funcall cb)) :callback (lambda (value) (setf (alist-get 'target_project (car cur)) value) (funcall cb))))) (cl-defmethod forge--update-pullreqs ((repo forge-gitlab-repository) data) (dolist (v data) (forge--update-pullreq repo v))) (cl-defmethod forge--update-pullreq ((repo forge-gitlab-repository) data) (closql-with-transaction (forge-db) (let-alist data (let* ((pullreq-id (forge--object-id 'forge-pullreq repo .iid)) (pullreq (forge-pullreq :id pullreq-id :their-id .iid :number .iid :slug (format "!%s" .iid) :repository (oref repo id) :state (pcase-exhaustive .state ("merged" 'merged) ("closed" 'rejected) ("opened" 'open)) :author .author.username :title .title :created .created_at :updated .updated_at ;; `.merged_at' and `.closed_at' may both be nil even ;; though the pullreq is merged or otherwise closed. ;; In such cases use 1, so that these slots at least ;; can serve as booleans. :closed (or .closed_at (and (member .state '("closed" "merged")) 1)) :merged (or .merged_at (and (equal .state "merged") 1)) :draft-p .draft :locked-p .discussion_locked :editable-p .allow_maintainer_to_push :cross-repo-p (not (equal .source_project_id .target_project_id)) :base-ref .target_branch :base-rev .diff_refs.start_sha :base-repo .target_project.path_with_namespace :head-ref .source_branch :head-rev .diff_refs.head_sha :head-user .source_project.owner.username :head-repo .source_project.path_with_namespace :milestone .milestone.iid :body (forge--sanitize-string .description)))) (closql-insert (forge-db) pullreq t) (unless (magit-get-boolean "forge.omitExpensive") (forge--set-connections repo pullreq 'assignees .assignees) (forge--set-connections repo pullreq 'review-requests .reviewers) (forge--set-connections repo pullreq 'labels .labels)) (dolist (c .notes) (let-alist c (let ((post (forge-pullreq-post :id (forge--object-id pullreq-id .id) :pullreq pullreq-id :number .id :author .author.username :created .created_at :updated .updated_at :body (forge--sanitize-string .body)))) (closql-insert (forge-db) post t)))) (let ((until (oref repo pullreqs-until))) (when (or (not until) (string> .updated_at until)) (oset repo pullreqs-until .updated_at))) pullreq)))) ;;;; Other ;; The extend of the documentation for "GET /projects/:id/users" is ;; "Get the users list of a project." I don't know what that means, ;; but it stands to reason that this must at least overlap with the ;; set of users that can be assigned to topics. (cl-defmethod forge--fetch-assignees ((repo forge-gitlab-repository) callback) (forge--glab-get repo "/projects/:project/users" '((per_page . 100)) :unpaginate t :callback callback)) (cl-defmethod forge--update-assignees ((repo forge-gitlab-repository) data) (oset repo assignees (with-slots (id) repo (mapcar (lambda (row) (let-alist row ;; For other forges we don't need to store `id' ;; but here we do because that's what has to be ;; used when assigning issues. (list (forge--object-id id .id) .username .name .id))) data)))) (cl-defmethod forge--fetch-forks ((repo forge-gitlab-repository) callback) (forge--glab-get repo "/projects/:project/forks" '((per_page . 100) (simple . t)) :unpaginate t :callback callback)) (cl-defmethod forge--update-forks ((repo forge-gitlab-repository) data) (oset repo forks (with-slots (id) repo (mapcar (lambda (row) (let-alist row (nconc (forge--repository-ids (eieio-object-class repo) (oref repo githost) .namespace.path .path) (list .namespace.path .path)))) data)))) (cl-defmethod forge--fetch-labels ((repo forge-gitlab-repository) callback) (forge--glab-get repo "/projects/:project/labels" '((per_page . 100)) :unpaginate t :callback callback)) (cl-defmethod forge--update-labels ((repo forge-gitlab-repository) data) (oset repo labels (with-slots (id) repo (mapcar (lambda (row) (let-alist row ;; We should use the label's `id' instead of its ;; `name' but a topic's `labels' field is a list ;; of names instead of a list of ids or an alist. ;; As a result of this we cannot recognize when ;; a label is renamed and a topic continues to be ;; tagged with the old label name until it itself ;; is modified somehow. Additionally it leads to ;; name conflicts between group and project ;; labels. See #160. Also see the comment in ;; `forge--set-connections'. (list (forge--object-id id .name) .name (downcase .color) .description))) ;; For now simply remove one of the duplicates. (cl-delete-duplicates data :key (##alist-get 'name %) :test #'equal))))) ;;;; Notifications ;; The closest to notifications that Gitlab provides are "events" as ;; described at https://docs.gitlab.com/ee/api/events.html. This ;; allows us to see the last events that took place, but that is not ;; good enough because we are mostly interested in events we haven't ;; looked at yet. Gitlab doesn't make a distinction between unread ;; and read events, so this is rather useless and we don't use it for ;; the time being. ;;; Mutations (cl-defmethod forge--submit-create-issue ((_ forge-gitlab-repository) repo) (forge--glab-post repo "/projects/:project/issues" (pcase-let ((`(,title . ,body) (forge--post-buffer-text))) `((title . ,title) (description . ,body))) :callback (forge--post-submit-callback) :errorback (forge--post-submit-errorback))) (cl-defmethod forge--submit-create-pullreq ((_ forge-gitlab-repository) base-repo) (pcase-let* ((`(,title . ,body) (forge--post-buffer-text)) (`(,_base-remote . ,base-branch) (magit-split-branch-name forge--buffer-base-branch)) (`(,head-remote . ,head-branch) (magit-split-branch-name forge--buffer-head-branch)) (head-repo (forge-get-repository :stub head-remote))) (forge--glab-post head-repo "/projects/:project/merge_requests" `((title . ,(if forge--buffer-draft-p (concat "Draft: " title) title)) (description . ,body) ;; ,@(and (not (equal head-remote base-remote)) (target_project_id . ,(oref base-repo forge-id)) (target_branch . ,base-branch) (source_branch . ,head-branch) (allow_collaboration . t)) :callback (forge--post-submit-callback) :errorback (forge--post-submit-errorback)))) (cl-defmethod forge--submit-create-post ((_ forge-gitlab-repository) (topic forge-topic)) (forge--glab-post topic (if (forge-issue-p topic) "/projects/:project/issues/:number/notes" "/projects/:project/merge_requests/:number/notes") `((body . ,(string-trim (buffer-str)))) :callback (forge--post-submit-callback) :errorback (forge--post-submit-errorback))) (cl-defmethod forge--submit-edit-post ((_ forge-gitlab-repository) (post forge-post)) (forge--glab-put post (cl-etypecase post (forge-pullreq "/projects/:project/merge_requests/:number") (forge-issue "/projects/:project/issues/:number") (forge-issue-post "/projects/:project/issues/:topic/notes/:number") (forge-pullreq-post "/projects/:project/merge_requests/:topic/notes/:number")) (if (cl-typep post 'forge-topic) (pcase-let ((`(,title . ,body) (forge--post-buffer-text))) `((title . ,title) ;; Keep Gitlab from claiming that the user changed ;; the description when that isn't true. The same ;; isn't necessary for the title; for that, Gitlab ;; performs the necessary check itself. ,@(and (not (equal body (oref post body))) `((description . ,body))))) `((body . ,(string-trim (buffer-str))))) :callback (forge--post-submit-callback) :errorback (forge--post-submit-errorback))) (cl-defmethod forge--set-topic-field ((_repo forge-gitlab-repository) (topic forge-topic) field value) (forge--glab-put topic (cl-typecase topic (forge-pullreq "/projects/:project/merge_requests/:number") (forge-issue "/projects/:project/issues/:number")) `((,field . ,(if (and value (listp value)) (vconcat value) value))) :callback (forge--set-field-callback topic))) (cl-defmethod forge--set-topic-title ((repo forge-gitlab-repository) (topic forge-topic) title) (forge--set-topic-field repo topic 'title title)) (cl-defmethod forge--set-topic-state ((repo forge-gitlab-repository) (topic forge-topic) state) (forge--set-topic-field repo topic 'state_event (pcase-exhaustive state ;; Merging isn't done through here. ('completed "close") ('unplanned "close") ('rejected "close") ('open "reopen")))) (cl-defmethod forge--set-topic-draft ((repo forge-gitlab-repository) (topic forge-topic) value) (forge--mutate-field topic mergeRequestSetDraft ((projectPath (oref repo slug)) (iid (number-to-string (oref topic number))) (draft value)))) (cl-defmethod forge--set-topic-labels ((repo forge-gitlab-repository) (topic forge-topic) labels) (forge--set-topic-field repo topic 'labels (string-join labels ","))) (cl-defmethod forge--set-topic-assignees ((repo forge-gitlab-repository) (topic forge-topic) assignees) (let ((users (mapcar #'cdr (oref repo assignees)))) (cl-typecase topic (forge-pullreq ; Can only be assigned to a single user. (forge--set-topic-field repo topic 'assignee_id (or (caddr (assoc (car assignees) users)) 0))) (forge-issue (forge--set-topic-field repo topic 'assignee_ids (or (mapcar (##caddr (assoc % users)) assignees) 0)))))) (cl-defmethod forge--set-topic-review-requests ((repo forge-gitlab-repository) (topic forge-pullreq) reviewers) (let ((users (mapcar #'cdr (oref repo assignees)))) (forge--set-topic-field repo topic 'reviewer_ids (or (mapcar (##caddr (assoc % users)) reviewers) 0)))) (cl-defmethod forge--delete-comment ((_ forge-gitlab-repository) (post forge-post)) (forge--glab-delete post (cl-etypecase post (forge-pullreq-post "/projects/:project/merge_requests/:topic/notes/:number") (forge-issue-post "/projects/:project/issues/:topic/notes/:number"))) (closql-delete post) (forge-refresh-buffer)) (cl-defmethod forge--topic-template-files ((repo forge-gitlab-repository) (_ (subclass forge-issue))) (forge--topic-template-files-1 repo "md" ".gitlab/issue_templates")) (cl-defmethod forge--topic-template-files ((repo forge-gitlab-repository) (_ (subclass forge-pullreq))) (forge--topic-template-files-1 repo "md" ".gitlab/merge_request_templates")) (cl-defmethod forge--fork-repository ((repo forge-gitlab-repository) fork _all) (with-slots (name apihost) repo (forge--glab-post repo "/projects/:project/fork" (and (not (equal fork (ghub--username repo))) `((namespace . ,fork))) :noerror t) (ghub-wait (format "/projects/%s%%2F%s" (string-replace "/" "%2F" fork) name) nil :auth 'forge :host apihost :forge 'gitlab))) (cl-defmethod forge--merge-pullreq ((_repo forge-gitlab-repository) (topic forge-topic) hash method) (forge--glab-put topic "/projects/:project/merge_requests/:number/merge" `((squash . ,(eq method 'squash)) ,@(and hash `((sha . ,hash)))))) ;;; Wrappers (cl-defun forge--glab-get (obj resource &optional params &key query payload headers silent unpaginate noerror reader host callback errorback) (declare (indent defun)) (ghub-request "GET" (if obj (forge--format-resource obj resource) resource) params :forge 'gitlab :host (or host (oref (forge-get-repository obj) apihost)) :auth 'forge :query query :payload payload :headers headers :silent silent :unpaginate unpaginate :noerror noerror :reader reader :callback callback :errorback (or errorback (and callback t)))) (cl-defun forge--glab-put (obj resource &optional params &key query payload headers silent unpaginate noerror reader host callback errorback) (declare (indent defun)) (ghub-request "PUT" (if obj (forge--format-resource obj resource) resource) params :forge 'gitlab :host (or host (oref (forge-get-repository obj) apihost)) :auth 'forge :query query :payload payload :headers headers :silent silent :unpaginate unpaginate :noerror noerror :reader reader :callback callback :errorback (or errorback (and callback t)))) (cl-defun forge--glab-post (obj resource &optional params &key query payload headers silent unpaginate noerror reader host callback errorback) (declare (indent defun)) (ghub-request "POST" (forge--format-resource obj resource) params :forge 'gitlab :host (or host (oref (forge-get-repository obj) apihost)) :auth 'forge :query query :payload payload :headers headers :silent silent :unpaginate unpaginate :noerror noerror :reader reader :callback callback :errorback (or errorback (and callback t)))) (cl-defun forge--glab-delete (obj resource &optional params &key query payload headers silent unpaginate noerror reader host callback errorback) (declare (indent defun)) (ghub-request "DELETE" (forge--format-resource obj resource) params :forge 'gitlab :host (or host (oref (forge-get-repository obj) apihost)) :auth 'forge :query query :payload payload :headers headers :silent silent :unpaginate unpaginate :noerror noerror :reader reader :callback callback :errorback (or errorback (and callback t)))) ;;; _ ;; Local Variables: ;; read-symbol-shorthands: ( ;; ("and$" . "cond-let--and$") ;; ("thread$" . "cond-let--thread$") ;; ("when$" . "cond-let--when$") ;; ("and-let*" . "cond-let--and-let*") ;; ("and-let" . "cond-let--and-let") ;; ("if-let*" . "cond-let--if-let*") ;; ("if-let" . "cond-let--if-let") ;; ("when-let*" . "cond-let--when-let*") ;; ("when-let" . "cond-let--when-let") ;; ("while-let*" . "cond-let--while-let*") ;; ("while-let" . "cond-let--while-let") ;; ("buffer-string" . "buffer-string") ;; ("buffer-str" . "forge--buffer-substring-no-properties")) ;; End: (provide 'forge-gitlab) ;;; forge-gitlab.el ends here