Forgejo, AGit, and Pull Request Templates
I've raised a few PRs against the Guix Codeberg repository recently, and each time I've done so with Forgejo's agit workflow. This workflow is pretty nice, and allows me to raise a PR entirely from within Emacs. To do that, I've been using this code in my Emacs config to add an extra option to the magit-push transient to use the agit flow to push to the upstream branch:
(transient-define-suffix magit-push-current-agit (source target args)
:if #'magit-get-current-branch
:description (lambda () (concat (magit-push--upstream-description) " (agit)"))
(interactive
(let ((source (or (magit-get-current-branch)
(user-error "No branch is checked out"))))
(list source
(magit-get-upstream-branch source)
(magit-push-arguments))))
(magit-push-refspecs
(magit-get "branch" source "remote")
(concat source ":"
;; Forgejo uses /refs/for/{branch} to target. Locally,
;; setting the upstream branch sets the "merge" config item
;; to refs/heads/{branch}, so we can easily construct what we need
(s-replace "/heads/" "/for/"
(magit-get "branch" (magit-get-current-branch) "merge"))
"/" source)
`(,@args
,@(if (or (member "--force" args)
(member "--force-with-lease" args))
'("-o" "force-push=true")
'()))))
(transient-insert-suffix 'magit-push
'(1 0)
(list "a" #'magit-push-current-agit))
Once I've set an upstream branch, this makes it easy to push to Codeberg. Either P a to create a new PR, or P - f a to update an existing PR.
One downside to the agit workflow is that I completely bypass the usual PR template that the Guix project has set up in .forgejo/pull_request_template.md. Instead, it just uses the commit message of the last commit that's being pushed. That's often fine, but I'd like to add at least some of my own commentary. With my existing Magit action I would usually have to go and open the PR to change the description to say what I wanted to say.
I've always been a bit uncomfortable about this, particularly because I think it's rude to just ignore the template.
My discomfort finally got strong enough that I solved this problem tonight, with this change to the final if in the above code snippet:
,@(if (or (member "--force" args)
(member "--force-with-lease" args))
'("-o" "force-push=true")
(let ((info (cz/read-pull-request-from-buffer
(concat (magit-gitdir) "../.forgejo/pull_request_template.md"))))
`("-o" ,(concat "title={base64}" (base64-encode-string (car info) t))
"-o" ,(concat "description={base64}" (base64-encode-string (cdr info) t)))))
On a force push, we don't do anything (because we're just changing the commits on an existing PR). If we're not force pushing then we're creating a new PR, so we prompt for the title and description, base64 encode them, and send them through as options on the push.
The cz/read-pull-request-from-buffer function, and its associated mode, are not quite so easy. They read out the pull request template, then go into a recursive-edit in a markdown-mode buffer with the template's contents (after skipping the Forgejo header). Technically I'm not using the template correctly, because I should extract the title from the template and add it to the template, but I don't care.
At some point I might extract this out of my config and make a proper package for it. Let me know if that would be helpful to you - it might motivate me to actually do it!
The full relevant section of my config now looks like this:
(define-derived-mode cz/agit-pull-request-mode markdown-mode "AGit PR")
(defvar-local cz/agit-pull-request-title nil)
(defvar-local cz/agit-pull-request-description nil)
(defun cz/agit-pull-request-submit ()
(interactive)
(when (y-or-n-p "Are you sure you want to submit this pull request? ")
(setq-local cz/agit-pull-request-title
(save-excursion
(beginning-of-buffer)
(buffer-substring-no-properties
(+ (point) 2)
(line-end-position))))
(setq-local cz/agit-pull-request-description
(save-excursion
(beginning-of-buffer)
(next-line)
(buffer-substring-no-properties
(point)
(point-max))))
(exit-recursive-edit)))
(keymap-set cz/agit-pull-request-mode-map "C-c C-c" #'cz/agit-pull-request-submit)
(defun cz/agit-pull-request-cancel ()
(interactive)
(abort-recursive-edit))
(keymap-set cz/agit-pull-request-mode-map "C-c C-k" #'cz/agit-pull-request-cancel)
(defun cz/read-pull-request-from-buffer (template-file)
(if (get-buffer "*Pull Request*")
(user-error "Other pull request already open - aborting this one.")
(let ((buffer (get-buffer-create "*Pull Request*")))
(with-current-buffer buffer
(insert "# ")
(save-excursion
(insert "\n\n")
(when (file-exists-p template-file)
(let ((start (point)))
(insert-file-contents-literally template-file)
(beginning-of-buffer)
(save-match-data
(re-search-forward "^---")
(re-search-forward "^---")
(next-line)
(beginning-of-line))
(delete-region start (point)))))
(cz/agit-pull-request-mode)
(pop-to-buffer (current-buffer)
'(display-buffer-same-window)))
(unwind-protect
(progn
(recursive-edit)
(with-current-buffer buffer
(if cz/agit-pull-request-title
(cons cz/agit-pull-request-title
cz/agit-pull-request-description)
(user-error "No pull request information"))))
(with-current-buffer buffer
(if (get-buffer-window)
(quit-window 'kill (get-buffer-window))
(kill-buffer)))))))
;; Define a command for an AGit flow to upstream (this is suitable for
;; opening PRs on Codeberg)
(transient-define-suffix magit-push-current-agit (source target args)
:if #'magit-get-current-branch
:description (lambda () (concat (magit-push--upstream-description) " (agit)"))
(interactive
(let ((source (or (magit-get-current-branch)
(user-error "No branch is checked out"))))
(list source
(magit-get-upstream-branch source)
(magit-push-arguments))))
(magit-push-refspecs
(magit-get "branch" source "remote")
(concat source ":"
;; Forgejo uses /refs/for/{branch} to target. Locally,
;; setting the upstream branch sets the "merge" config item
;; to refs/heads/{branch}, so we can easily construct what we need
(s-replace "/heads/" "/for/"
(magit-get "branch" (magit-get-current-branch) "merge"))
"/" source)
`(,@args
,@(if (or (member "--force" args)
(member "--force-with-lease" args))
'("-o" "force-push=true")
(let ((info (cz/read-pull-request-from-buffer
(concat (magit-gitdir) "../.forgejo/pull_request_template.md"))))
`("-o" ,(concat "title={base64}" (base64-encode-string (car info) t))
"-o" ,(concat "description={base64}" (base64-encode-string (cdr info) t))))))))
(transient-insert-suffix 'magit-push
'(1 0)
(list "a" #'magit-push-current-agit))
I'm confident that this code can be improved, but I think it'll solve my problem for now.