In Emacs, when I’m writing a commit message for a repository backed by version control, and I type #123
the bug-reference package overlays that #123
with links to the remote issue. I can then “click” on #123
and jump to the issue at the remote repository; a convenient feature!
However that convenience comes with a cost, namely those terse references do two things:
- create low-level lock-in
- increase the risk of information loss
If I were to change the host of that repository or transfer ownership, the #123
becomes disconnected from what it once referenced.
I prefer, instead, to use full Uniform Resource Locators (URLs 📖). This way there is no ambiguity about what I’m referencing. Unless of course the remote service breaks links or goes away.
Adding further nuance, due to the nature of my work, I’m often referencing other repository’s issues and pull requests during the writing of a commit.
Enter Automation
I’ve been playing with Completion at Point Functions (CaPFs 📖) and decided the explore automatically creating those URLs.
See Completion at Point Function (CAPF) for Org-Mode Links.
The end goal is to have a full URL.
For example https://github.com/samvera/hyrax/issues/6056
.
I broke this into two steps:
- Create a CaPF for finding the project
- Create a CaPF for replacing the project and issue with the URL
I settled on the following feature:
Given that I have typed “/hyr”
When I then type {{{kbd(TAB)}}}
Then auto-complete options should include “/hyrax”
I went a step further in my implementation, when I select the project completion candidate I append a # to that. I end up with /hyrax#
and the cursor is right after the #
character. From which I then have my second CaPF.
Given that the text before <point> is "/hyrax#123"
When I type {{{kbd(TAB)}}}
Then auto-complete will convert "/hyrax#123"
to "https://github.com/samvera/hyrax/issues/123"
Code
Create a CaPF for finding the project
First let’s look at the part for finding a project. I do this via jf/version-control/project-capf.
(defun jf/version-control/project-capf ()
"Complete project links."
;; While I'm going to replace "/project" I want to make
;; sure that I don't have any odd hits (for example
;; "/path/to/file")
(when (looking-back "[^[:word:]]/[[:word:][:digit:]_\-]+"
(jf/capf-max-bounds))
(let ((right (point))
(left (save-excursion
;; First check for the project
(search-backward-regexp
"/[[:word:][:digit:]_\-]+"
(jf/capf-max-bounds) t)
(point))))
(list left right
(jf/version-control/known-project-names)
:exit-function
(lambda (text _status)
(delete-char (- (length text)))
(insert text "#"))
:exclusive 'no))))
The above function looks backwards from point, using jf/capf-max-bounds
as the bounds of how far back to look. If there’s a match the function then gets the left and right boundaries and calls jf/version-control/known-project-names
to get a list of all possible projects that I have on my machine.
The jf/capf-max-bounds function ensures that we don’t attempt to look at a position outside of the buffer. See the below definition:
(cl-defun jf/capf-max-bounds (&key (window-size 40))
"Return the max bounds for `point' based on given WINDOW-SIZE."
(let ((boundary (- (point) window-size)))
(if (> 0 boundary) (point-min) boundary)))
The jf/version-control/known-project-names leverages the projectile package to provides a list of known projects.
I’ve been working at moving away from projectile but the projectile-known-projects
variable just works, so I’m continuing my dependency on projectile. I want to migrate towards the built-in project package, but there are a few points that I haven’t resolved.
(cl-defun jf/version-control/known-project-names (&key (prefix "/"))
"Return a list of project, prepending PREFIX to each."
(mapcar (lambda (proj)
(concat prefix (f-base proj)))
projectile-known-projects))
I then add jf/version-control/project-capf
to the completion-at-point-functions
variable.
I also need to incorporate that elsewhere, based on various modes. But that’s a different exercise.
(add-to-list 'completion-at-point-functions #'jf/version-control/project-capf)
The above code delivers on the first feature; namely auto completion for projects that sets me up to deliver on the second feature.
Create a CaPF for replacing the project and issue with the URL
The jf/version-control/issue-capf function below builds on jf/version-control/project-capf
convention, working then from having an issue number appended to the text.
(defun jf/version-control/issue-capf ()
"Complete project issue links."
;; While I'm going to replace "/project" I want to make sure that I don't
;; have any odd hits (for example /path/to/file)
(when (looking-back "[^[:word:]]/[[:word:][:digit:]_\-]+#[[:digit:]]+"
(jf/capf-max-bounds))
(let ((right (point))
(left (save-excursion
(search-backward-regexp
"/[[:word:][:digit:]_\-]+#[[:digit:]]+"
(jf/capf-max-bounds) t)
(point))))
(list left right
(jf/version-control/text)
:exit-function
#'jf/version-control/unfurl-issue-to-url
:exclusive 'no))))
I continue to leverage jf/capf-max-bounds
querying for all matching version control text within the buffer (via jf/version-control/text):
(defun jf/version-control/text ()
"Find all matches for project and issue."
(s-match-strings-all "/[[:word:][:digit:]_\-]+#[[:digit:]]+" (buffer-string)))
Once we have a match, I use jf/version-control/unfurl-issue-to-url to convert the text into a URL.
I had originally tried to get #123
to automatically unfurl the issue URL for the current project. But I set that aside as it wasn’t quite working.
(defun jf/version-control/unfurl-issue-to-url (text _status)
"Unfurl the given TEXT to a URL.
Ignoring _STATUS."
(delete-char (- (length text)))
(let* ((parts (s-split "#" text))
(issue (cadr parts))
(project (or (car parts) (cdr (project-current)))))
(insert (format
(jf/version-control/unfurl-project-as-issue-url-template project)
issue))))
That function relies on jf/version-control/unfurl-project-as-issue-url-template which takes a project and determines the correct template for the project.
(cl-defun jf/version-control/unfurl-project-as-issue-url-template (project &key (prefix "/"))
"Return the issue URL template for the given PROJECT.
Use the provided PREFIX to help compare against
`projectile-known-projects'."
(let* ((project-path
(car (seq-filter
(lambda (el)
(or
(s-ends-with? (concat project prefix) el)
(s-ends-with? project el)))
projectile-known-projects)))
(remote
(s-trim (shell-command-to-string
(format
"cd %s && git remote get-url origin"
project-path)))))
(s-replace ".git" "/issues/%s" remote)))
And last, I add jf/version-control/issue-capf
to my list of completion-at-point-functions
.
(add-to-list 'completion-at-point-functions #'jf/version-control/issue-capf)
Conclusion
While demonstrating these functions to a co-worker, I said the following:
“The purpose of these URL unfurling functions is to make it easier to minimize the risk of losing information that might be helpful in understanding how we got here.”
In other words, information is scattered across many places, and verbose URLs are more likely to be relevant than terse short-hand references.
A future refactor would be to use the bug-reference
logic to create the template; but what I have works because I mostly work on Github projects and it’s time to ship it. Also, these CaPFs are available in other contexts, which helps with writing more expressive inline comments.