;;; forge-db.el --- Database implementation -*- 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 'closql) (require 'compat) (require 'eieio) (require 'emacsql) ;; For `closql--db-update-schema': (declare-function forge--object-id "forge-core") (declare-function forge-get-issue "forge-core") (declare-function forge-get-pullreq "forge-core") (declare-function forge-get-repository "forge-core" (demand)) (eval-when-compile (cl-pushnew 'milestone eieio--known-slot-names) ; forge-{issue,pullreq} (cl-pushnew 'number eieio--known-slot-names)) ; forge-{issue,pullreq,...} ;;; Options (defcustom forge-database-file (expand-file-name "forge-database.sqlite" user-emacs-directory) "The file used to store the forge database." :package-version '(forge . "0.1.0") :group 'forge :type 'file) ;;; Core (defclass forge-database (closql-database) ((name :initform "Forge") (object-class :initform 'forge-repository) (file :initform 'forge-database-file) (schemata :initform 'forge--db-table-schemata) (version :initform 15))) (defvar forge--override-connection-class nil) (defun forge-db (&optional livep) (closql-db 'forge-database livep forge--override-connection-class)) (defun forge-sql (sql &rest args) (if (stringp sql) (emacsql (forge-db) (apply #'format sql args)) (apply #'emacsql (forge-db) sql args))) (defun forge-sql1 (sql &rest args) (caar (apply #'forge-sql sql args))) (defun forge-sql-car (sql &rest args) (mapcar #'car (apply #'forge-sql sql args))) (defun forge-sql-cdr (sql &rest args) (mapcar #'cdr (apply #'forge-sql sql args))) (defun forge-connect-database-once () "Try to connect Forge database on first use of `magit-status' only." (remove-hook 'magit-status-mode-hook #'forge-connect-database-once) (forge-db)) (add-hook 'magit-status-mode-hook #'forge-connect-database-once) (defun forge-enable-sql-logging () "Enable logging Forge's SQL queries." (interactive) (let ((conn (oref (forge-db) connection))) (emacsql-enable-debugging conn) (switch-to-buffer-other-window (oref conn log-buffer)))) ;;; Schemata (defconst forge--db-table-schemata '((repository [(class :not-null) (id :not-null :primary-key) forge-id forge owner name apihost githost remote condition created updated pushed parent description homepage default-branch archived-p fork-p locked-p mirror-p private-p issues-p wiki-p stars watchers (assignees :default eieio-unbound) (forks :default eieio-unbound) (issues :default eieio-unbound) (labels :default eieio-unbound) (revnotes :default eieio-unbound) (pullreqs :default eieio-unbound) selective-p worktree (milestones :default eieio-unbound) issues-until pullreqs-until teams (discussion-categories :default eieio-unbound) (discussions :default eieio-unbound) discussions-p discussions-until ]) (assignee [(repository :not-null) (id :not-null :primary-key) login name forge-id] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (discussion [(class :not-null) (id :not-null :primary-key) repository number answer state author title created updated closed status locked-p category body (cards :default eieio-unbound) (edits :default eieio-unbound) (labels :default eieio-unbound) (participants :default eieio-unbound) (posts :default eieio-unbound) (reactions :default eieio-unbound) (timeline :default eieio-unbound) (marks :default eieio-unbound) note their-id slug saved-p] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (discussion-category [(repository :not-null) (id :not-null :primary-key) their-id name emoji answerable-p description] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (discussion-label [(discussion :not-null) (id :not-null)] (:foreign-key [discussion] :references discussion [id] :on-delete :cascade) (:foreign-key [id] :references label [id] :on-delete :cascade)) (discussion-mark [(discussion :not-null) (id :not-null)] (:foreign-key [discussion] :references discussion [id] :on-delete :cascade) (:foreign-key [id] :references mark [id] :on-delete :cascade)) (discussion-post ; aka top-level answer [(class :not-null) (id :not-null :primary-key) their-id number discussion author created updated body (edits :default eieio-unbound) (reactions :default eieio-unbound) (replies :default eieio-unbound)] (:foreign-key [discussion] :references discussion [id] :on-delete :cascade)) (discussion-reply ; aka nested reply to top-level answer [(class :not-null) (id :not-null :primary-key) their-id number post discussion author created updated body (edits :default eieio-unbound) (reactions :default eieio-unbound)] (:foreign-key [post] :references discussion-post [id] :on-delete :cascade) (:foreign-key [discussion] :references discussion [id] :on-delete :cascade)) (fork [(parent :not-null) (id :not-null :primary-key) owner name] (:foreign-key [parent] :references repository [id] :on-delete :cascade)) (issue [(class :not-null) (id :not-null :primary-key) repository number state author title created updated closed status locked-p milestone body (assignees :default eieio-unbound) (cards :default eieio-unbound) (edits :default eieio-unbound) (labels :default eieio-unbound) (participants :default eieio-unbound) (posts :default eieio-unbound) (reactions :default eieio-unbound) (timeline :default eieio-unbound) (marks :default eieio-unbound) note their-id slug saved-p] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (issue-assignee [(issue :not-null) (id :not-null)] (:foreign-key [issue] :references issue [id] :on-delete :cascade)) (issue-label [(issue :not-null) (id :not-null)] (:foreign-key [issue] :references issue [id] :on-delete :cascade) (:foreign-key [id] :references label [id] :on-delete :cascade)) (issue-mark [(issue :not-null) (id :not-null)] (:foreign-key [issue] :references issue [id] :on-delete :cascade) (:foreign-key [id] :references mark [id] :on-delete :cascade)) (issue-post [(class :not-null) (id :not-null :primary-key) issue number author created updated body (edits :default eieio-unbound) (reactions :default eieio-unbound)] (:foreign-key [issue] :references issue [id] :on-delete :cascade)) (label [(repository :not-null) (id :not-null :primary-key) name color description] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (mark [;; For now this is always nil because it seems more useful to ;; share marks between repositories. We cannot omit this slot ;; though because `closql--iref' expects `id' to be the second ;; slot. repository (id :not-null :primary-key) name face description]) (milestone [(repository :not-null) (id :not-null :primary-key) number title created updated due closed description] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (notification [(class :not-null) (id :not-null :primary-key) thread-id repository type topic url title reason last-read updated] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (pullreq [(class :not-null) (id :not-null :primary-key) repository number state author title created updated closed merged status locked-p editable-p cross-repo-p base-ref base-repo head-ref head-user head-repo milestone body (assignees :default eieio-unbound) (cards :default eieio-unbound) (commits :default eieio-unbound) (edits :default eieio-unbound) (labels :default eieio-unbound) (participants :default eieio-unbound) (posts :default eieio-unbound) (reactions :default eieio-unbound) (review-requests :default eieio-unbound) (reviews :default eieio-unbound) (timeline :default eieio-unbound) (marks :default eieio-unbound) note base-rev head-rev draft-p their-id slug saved-p] (:foreign-key [repository] :references repository [id] :on-delete :cascade)) (pullreq-assignee [(pullreq :not-null) (id :not-null)] (:foreign-key [pullreq] :references pullreq [id] :on-delete :cascade)) (pullreq-label [(pullreq :not-null) (id :not-null)] (:foreign-key [pullreq] :references pullreq [id] :on-delete :cascade) (:foreign-key [id] :references label [id] :on-delete :cascade)) (pullreq-mark [(pullreq :not-null) (id :not-null)] (:foreign-key [pullreq] :references pullreq [id] :on-delete :cascade) (:foreign-key [id] :references mark [id] :on-delete :cascade)) (pullreq-post [(class :not-null) (id :not-null :primary-key) pullreq number author created updated body (edits :default eieio-unbound) (reactions :default eieio-unbound)] (:foreign-key [pullreq] :references pullreq [id] :on-delete :cascade)) (pullreq-review-request [(pullreq :not-null) (id :not-null)] (:foreign-key [pullreq] :references pullreq [id] :on-delete :cascade)) (revnote [(class :not-null) (id :not-null :primary-key) repository commit file line author body] (:foreign-key [repository] :references repository [id] :on-delete :cascade)))) (cl-defmethod closql--db-update-schema ((db forge-database)) (let ((version (closql--db-get-version db))) (when (< version (oref-default 'forge-database version)) (forge--backup-database db) (closql-with-transaction db (forge--db-update-schema db version))) (cl-call-next-method))) (defun forge--db-update-schema (db version) (cl-macrolet ((up (to &rest body) `(when (= (1+ version) ,to) (message "Upgrading Forge database from version %s to %s..." version ,to) ,@body (closql--db-set-version db ,to) (message "Upgrading Forge database from version %s to %s...done" version ,to) (setq version ,to)))) (up 3 (emacsql db [:create-table pullreq-review-request $S1] (cdr (assq 'pullreq-review-request forge--db-table-schemata)))) (up 4 (emacsql db [:drop-table notification]) (pcase-dolist (`(,table . ,schema) forge--db-table-schemata) (when (memq table '(notification mark issue-mark pullreq-mark)) (emacsql db [:create-table $i1 $S2] table schema))) (emacsql db [:alter-table issue :add-column marks :default $s1] 'eieio-unbound) (emacsql db [:alter-table pullreq :add-column marks :default $s1] 'eieio-unbound)) (up 5 (emacsql db [:alter-table repository :add-column selective-p :default nil])) (up 6 (emacsql db [:alter-table repository :add-column worktree :default nil])) (up 7 (emacsql db [:alter-table issue :add-column note :default nil]) (emacsql db [:alter-table pullreq :add-column note :default nil]) (emacsql db [:create-table milestone $S1] (cdr (assq 'milestone forge--db-table-schemata))) (emacsql db [:alter-table repository :add-column milestones :default $s1] 'eieio-unbound) (pcase-dolist (`(,repo-id ,issue-id ,milestone) (emacsql db [:select [repository id milestone] :from issue :where (notnull milestone)])) (unless (stringp milestone) (oset (forge-get-issue issue-id) milestone (forge--object-id repo-id (cdar milestone))))) (pcase-dolist (`(,repo-id ,pullreq-id ,milestone) (emacsql db [:select [repository id milestone] :from pullreq :where (notnull milestone)])) (unless (stringp milestone) (oset (forge-get-pullreq pullreq-id) milestone (forge--object-id repo-id (cdar milestone)))))) (up 8 (emacsql db [:alter-table pullreq :add-column base-rev :default nil]) (emacsql db [:alter-table pullreq :add-column head-rev :default nil]) (emacsql db [:alter-table pullreq :add-column draft-p :default nil])) (up 9 (emacsql db [:alter-table pullreq :add-column their-id :default nil]) (emacsql db [:alter-table issue :add-column their-id :default nil])) (up 10 (emacsql db [:alter-table pullreq :add-column slug :default nil]) (emacsql db [:alter-table issue :add-column slug :default nil]) (pcase-dolist (`(,id ,number ,type) (emacsql db [:select [pullreq:id pullreq:number repository:class] :from pullreq :join repository :on (= pullreq:repository repository:id)])) (let ((gitlabp (memq type (append (closql-where-class-in 'forge-gitlab-repository--eieio-childp) nil)))) (emacsql db [:update pullreq :set (= slug $s1) :where (= id $s2)] (format "%s%s" (if gitlabp "!" "#") number) id))) (pcase-dolist (`(,id ,number) (emacsql db [:select [id number] :from issue])) (emacsql db [:update issue :set (= slug $s1) :where (= id $s2)] (format "#%s" number) id))) (up 11 (emacsql db [:drop-table notification]) (emacsql db [:create-table notification $S1] (cdr (assq 'notification forge--db-table-schemata))) (emacsql db [:alter-table pullreq :rename-column unread-p :to status]) (emacsql db [:alter-table issue :rename-column unread-p :to status]) (emacsql db [:alter-table pullreq :add-column saved-p :default nil]) (emacsql db [:alter-table issue :add-column saved-p :default nil])) (up 12 (emacsql db [:drop-table notification]) (emacsql db [:create-table notification $S1] (cdr (assq 'notification forge--db-table-schemata))) (dolist (id (emacsql db [:select id :from issue :where (= state 'closed)])) (emacsql db [:update issue :set (= state 'completed) :where (= id $s1)] id)) (dolist (id (emacsql db [:select id :from issue :where (isnull status)])) (emacsql db [:update issue :set (= state 'done) :where (= id $s1)] id)) (dolist (id (emacsql db [:select id :from pullreq :where (= state 'closed)])) (emacsql db [:update pullreq :set (= state 'rejected) :where (= id $s1)] id)) (dolist (id (emacsql db [:select id :from pullreq :where (isnull status)])) (emacsql db [:update pullreq :set (= state 'done) :where (= id $s1)] id)) (emacsql db [:alter-table repository :add-column issues-until :default nil]) (emacsql db [:alter-table repository :add-column pullreqs-until :default nil])) (up 13 (dolist (id (emacsql db [:select id :from repository :where (isnull issues-until)])) (emacsql db [:update repository :set (= issues-until $s1) :where (= id $s2)] (forge-sql1 [:select [updated] :from issue :where (= repository $s1) :order-by [(desc updated)] :limit 1] id) id)) (dolist (id (emacsql db [:select id :from repository :where (isnull pullreqs-until)])) (emacsql db [:update repository :set (= pullreqs-until $s1) :where (= id $s2)] (forge-sql1 [:select [updated] :from pullreq :where (= repository $s1) :order-by [(desc updated)] :limit 1] id) id)) (emacsql db [:alter-table repository :rename-column sparse-p :to condition]) (pcase-dolist (`(,id ,not-tracked) (emacsql db [:select [id condition] :from repository])) (emacsql db [:update repository :set (= condition $s1) :where (= id $s2)] (if not-tracked :known :tracked) id))) (up 14 (emacsql db [:alter-table repository :add-column teams :default nil])) (up 15 (emacsql db [:create-table discussion $S1] (cdr (assq 'discussion forge--db-table-schemata))) (emacsql db [:create-table discussion-category $S1] (cdr (assq 'discussion-category forge--db-table-schemata))) (emacsql db [:create-table discussion-label $S1] (cdr (assq 'discussion-label forge--db-table-schemata))) (emacsql db [:create-table discussion-mark $S1] (cdr (assq 'discussion-mark forge--db-table-schemata))) (emacsql db [:create-table discussion-post $S1] (cdr (assq 'discussion-post forge--db-table-schemata))) (emacsql db [:create-table discussion-reply $S1] (cdr (assq 'discussion-reply forge--db-table-schemata))) (emacsql db [:alter-table repository :add-column discussion-categories :default 'eieio-unbound]) (emacsql db [:alter-table repository :add-column discussions :default 'eieio-unbound]) (emacsql db [:alter-table repository :add-column discussions-p :default nil]) (emacsql db [:alter-table repository :add-column discussions-until :default nil])) )) (defun forge--backup-database (db) (let ((dst (concat (file-name-sans-extension forge-database-file) (format "-v%s" (caar (emacsql (oref db connection) [:pragma user-version]))) (format-time-string "-%Y%m%d-%H%M") ".sqlite"))) (message "Copying Forge database to %s..." dst) (copy-file forge-database-file dst) (message "Copying Forge database to %s...done" dst))) ;;; _ ;; 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")) ;; End: (provide 'forge-db) ;;; forge-db.el ends here