forge-llm/forge-llm.el
2025-03-16 19:30:03 -03:00

551 lines
21 KiB
EmacsLisp

;;; forge-llm.el --- LLM integration for generating PR descriptions in Forge -*- lexical-binding: t -*-
;; Copyright (C) 2025 Roger Gonzalez
;; Author: Roger Gonzalez <roger@rogs.me>
;; Maintainer: Roger Gonzalez <roger@rogs.me>
;; Version: 0.1.0
;; Package-Requires: ((emacs "25.1") (forge "0.3.0") (llm "0.16.1"))
;; Keywords: convenience, forge, git, llm, github, gitlab, pull-request
;; URL: https://gitlab.com/rogs/forge-llm
;; 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 <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;; forge-llm provides LLM integration for Magit's Forge, allowing you to generate
;; high-quality Pull Request descriptions automatically using an LLM (Language
;; Learning Model) like OpenAI's GPT models or other compatible providers.
;;
;; Features:
;; - Automatically detect and use repository PR templates
;; - Generate PR descriptions based on git diffs between branches
;; - Insert generated descriptions at point or view them in a separate buffer
;; - Customize prompt templates and LLM parameters
;;
;; Usage:
;;
;; 1. Set up an LLM provider by customizing `forge-llm-llm-provider` (this depends
;; on the `llm` package, see its documentation for details).
;;
;; 2. Call `forge-llm-setup` to integrate with Forge's PR creation buffers.
;;
;; 3. When creating a PR using Forge (e.g., via `forge-create-pullreq`), you can:
;; - Use C-c C-g to generate a PR description in a separate buffer
;; - Use C-c C-p to generate and insert a PR description at point
;; - Use C-c C-t to insert the PR template at point
;;
;; Example configuration:
;;
;; (use-package forge-llm
;; :after forge
;; :config
;; (setq forge-llm-llm-provider
;; (llm-openai-make-provider :key "your-api-key"))
;; (forge-llm-setup))
;;
;;; Code:
(require 'forge)
(require 'llm)
;;; Customization Options
(defgroup forge-llm nil
"LLM integration for Forge."
:group 'forge
:prefix "forge-llm-")
(defcustom forge-llm-pr-template-paths
'(".github/PULL_REQUEST_TEMPLATE.md"
".github/pull_request_template.md"
"docs/pull_request_template.md"
".gitlab/merge_request_templates/default.md")
"List of possible paths for PR/MR templates relative to repo root.
These paths are checked in order when looking for a PR template."
:type '(repeat string)
:group 'forge-llm)
(defcustom forge-llm-llm-provider nil
"LLM provider to use.
Can be a provider object or a function that returns a provider object.
This is required for generating PR descriptions. You can use providers
from the `llm` package such as `llm-openai-make-provider`."
:type '(choice
(sexp :tag "LLM provider")
(function :tag "Function that returns an LLM provider"))
:group 'forge-llm)
(defcustom forge-llm-temperature nil
"Temperature for LLM responses (controls randomness).
If nil, the default temperature of the LLM provider will be used.
Higher values (e.g., 0.8) make output more random, while lower
values (e.g., 0.2) make it more focused and deterministic."
:type '(choice (const :tag "Use provider default" nil)
(float :tag "Custom temperature"))
:group 'forge-llm)
(defcustom forge-llm-max-tokens nil
"Maximum number of tokens for LLM responses.
If nil, the default max tokens of the LLM provider will be used.
Increase this value if you need longer PR descriptions."
:type '(choice (const :tag "Use provider default" nil)
(integer :tag "Custom max tokens"))
:group 'forge-llm)
(defcustom forge-llm-pr-description-prompt
"Please help write a professional Pull Request description based on the following information.
INSTRUCTIONS:
1. Follow the PR template structure exactly - do not modify, add or remove sections
2. Fill each section with relevant content based on the git diff
3. Mark sections as 'N/A' if they don't apply to this specific PR
4. Focus on the logic and functionality changes, not on test implementation details
5. Be clear and concise, but provide enough detail for reviewers to understand the changes
6. Use bullet points for lists of changes where appropriate
%s
Git diff:
```
%s
```
Please generate the complete PR description, ready to submit."
"Prompt used to generate a PR description with the LLM.
This will be formatted with the PR template (or default template) and git diff.
You can customize this prompt to better suit your project's requirements."
:type 'string
:group 'forge-llm)
(defcustom forge-llm-default-pr-template
"## Description
<!-- Provide a clear and concise description of the changes -->
## Type of change
<!-- What types of changes does your code introduce? Put an 'x' in all boxes that apply -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
## How Has This Been Tested?
<!-- Describe how you tested your changes -->
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have added tests that prove my fix is effective or my feature works
- [ ] New and existing tests pass with my changes"
"Default PR template to use when no template is found in the repository.
This template will be used if no PR template is found in the repository."
:type 'string
:group 'forge-llm)
;;; Variables
(defvar-local forge-llm--pr-template-path nil
"Path to the PR template for the current repository.")
(defvar forge-llm--active-request nil
"The active LLM request, if any.")
;;; Core Functions
;;;###autoload
(defun forge-llm-setup ()
"Set up forge-llm integration with Forge's new-pullreq buffer.
This adds key bindings for PR description generation to Forge's
post mode and should be called once during initialization."
(interactive)
(add-hook 'forge-post-mode-hook #'forge-llm-setup-pullreq-hook)
(message "forge-llm has been set up successfully"))
;;;###autoload
(defun forge-llm-setup-all ()
"Set up all forge-llm integrations.
Currently, this just calls `forge-llm-setup`, but is provided for
future extensions."
(interactive)
(forge-llm-setup) ; Set up the basic PR branch info
(message "All forge-llm integrations have been set up"))
(defun forge-llm-setup-pullreq-hook ()
"Hook function to set up forge-llm in a new-pullreq buffer.
Adds key bindings for PR description generation."
;; Only add our keybinding if this is a pull request post
(when (and buffer-file-name
(string-match-p "new-pullreq" buffer-file-name))
(local-set-key (kbd "C-c C-g") #'forge-llm-generate-pr-description)
(local-set-key (kbd "C-c C-p") #'forge-llm-generate-pr-description-at-point)
(local-set-key (kbd "C-c C-t") #'forge-llm-insert-template-at-point)
;; Set up Doom Emacs keybindings if Doom is detected
(forge-llm--setup-doom-keybindings)))
(defun forge-llm--setup-doom-keybindings ()
"Set up Doom Emacs keybindings for forge-llm.
This adds SPC m g, SPC m p, and SPC m t bindings if Doom Emacs is detected."
(when (and (boundp 'doom-version)
(fboundp 'map!)
(fboundp 'doom-load-envvars-file))
(eval '(map! :map forge-post-mode-map
:after forge-llm
:localleader
:desc "Generate PR description" "g" #'forge-llm-generate-pr-description
:desc "Generate PR at point" "p" #'forge-llm-generate-pr-description-at-point
:desc "Insert PR template" "t" #'forge-llm-insert-template-at-point))))
;;; Utility Functions
(defun forge-llm--get-repo-root ()
"Find the git repository root directory.
Returns the path to the repository root or nil if not in a repository."
(let* ((default-directory (file-name-directory
(directory-file-name
(file-name-directory
(or buffer-file-name default-directory)))))
(repo-root (locate-dominating-file default-directory ".git")))
repo-root))
(defun forge-llm--get-branch-info ()
"Get the head and base branch information.
Returns a cons cell (head . base) or nil if information is not available."
(let ((head (and (boundp 'forge--buffer-head-branch) forge--buffer-head-branch))
(base (and (boundp 'forge--buffer-base-branch) forge--buffer-base-branch)))
(cons head base)))
(defun forge-llm--get-git-diff (repo-root head base)
"Get the git diff between HEAD and BASE branches.
Truncate the diff if it's too large.
REPO-ROOT is the repository root directory.
Returns the diff as a string, or nil if the diff cannot be generated."
(when (and repo-root head base)
(let ((default-directory repo-root)
diff-output)
(condition-case err
(with-temp-buffer
(when (zerop (call-process "git" nil t nil "diff" base head))
(setq diff-output (buffer-string))))
(error
(message "Error generating git diff: %s" err)
(setq diff-output nil)))
;; If diff is too large, trim it
(when (and diff-output (> (length diff-output) 12000))
(setq diff-output (substring diff-output 0 12000))
(setq diff-output (concat diff-output "\n\n... [diff truncated due to size] ...")))
diff-output)))
(defun forge-llm--get-provider ()
"Return the LLM provider to use.
If `forge-llm-llm-provider' is a function, call it to get the provider.
Otherwise, return the value directly.
Returns the provider or nil if not configured."
(if (functionp forge-llm-llm-provider)
(funcall forge-llm-llm-provider)
forge-llm-llm-provider))
;;; PR Template Handling
(defun forge-llm-find-pr-template ()
"Find PR template for the current repository.
Updates `forge-llm--pr-template-path' with the found template path.
Returns the template content or nil if not found."
(interactive)
(let* ((repo-root (forge-llm--get-repo-root))
found-template
template-content)
(when repo-root
(let ((default-directory repo-root))
;; Try to find the template
(setq found-template
(cl-find-if #'file-exists-p forge-llm-pr-template-paths))
;; Read the template content if found
(when found-template
(condition-case err
(with-temp-buffer
(insert-file-contents (expand-file-name found-template repo-root))
(setq template-content (buffer-string)))
(error
(message "Error reading template %s: %s"
found-template
(error-message-string err))
(setq template-content nil))))))
;; Store the path for future reference
(setq forge-llm--pr-template-path
(when found-template (expand-file-name found-template repo-root)))
(if found-template
(message "Found PR template: %s" forge-llm--pr-template-path)
(message "No PR template found in repository"))
template-content))
(defun forge-llm--get-pr-template ()
"Get the PR template content.
Returns the found template or default template if none found."
(or (forge-llm-find-pr-template)
forge-llm-default-pr-template))
(defun forge-llm-insert-template-at-point ()
"Insert PR template at the current point in buffer.
This is useful when you want to manually write your PR description
following the repository's template."
(interactive)
(if (not (derived-mode-p 'forge-post-mode))
(message "Not in a Forge pull request buffer")
(let ((template-content (forge-llm--get-pr-template)))
(when (and template-content (not (string-empty-p template-content)))
(insert template-content)
(message "PR template inserted")))))
;;; LLM Stream Handling
(defun forge-llm--stream-insert-response (msg buffer)
"Insert streaming LLM response.
MSG is the response text.
BUFFER is the target buffer."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(let ((inhibit-read-only t))
(erase-buffer)
(insert msg)
;; If markdown-mode is available, set the buffer mode
(when (require 'markdown-mode nil t)
(markdown-mode))))))
(defun forge-llm--stream-update-status (status buffer &optional error-msg)
"Update status of the streaming response.
STATUS is one of `success`, `error`.
BUFFER is the target buffer.
ERROR-MSG is the error message, if any."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(let ((inhibit-read-only t))
(goto-char (point-max))
(insert "\n\n")
(pcase status
('error (insert (format "Error: %s" error-msg))))
;; Set buffer to markdown-mode
(when (require 'markdown-mode nil t)
(markdown-mode))))))
(defun forge-llm-cancel-request ()
"Cancel the active LLM request, if any.
This is useful when the generation is taking too long or you want
to abort for any other reason."
(interactive)
(when forge-llm--active-request
(llm-cancel-request forge-llm--active-request)
(setq forge-llm--active-request nil)
(message "LLM request canceled")))
;;; PR Description Generation
(defun forge-llm--prepare-prompt ()
"Prepare the LLM prompt for PR description generation.
Returns a cons cell with (prompt . debug-info) or nil if preparation fails."
(if-let ((provider (forge-llm--get-provider)))
(let* ((branch-info (forge-llm--get-branch-info))
(head (car branch-info))
(base (cdr branch-info))
(repo-root (forge-llm--get-repo-root))
(diff-output (forge-llm--get-git-diff repo-root head base))
(template-content (forge-llm--get-pr-template))
(debug-info nil))
;; Create debug info for logging
(setq debug-info
(cond
((not repo-root)
"Failed to find git repository root")
((not (and head base))
"Failed to get branch information")
((not diff-output)
"Failed to generate git diff")
((not template-content)
"Failed to get PR template")
(t
(format "Using %s, comparing %s → %s"
(if (equal template-content forge-llm-default-pr-template)
"default template (no repository template found)"
(format "repository template at %s" forge-llm--pr-template-path))
base
head))))
;; Create prompt with template and diff
(when (and diff-output template-content)
(let* ((pr-section (format "PR template:\n%s" template-content))
(formatted-prompt (format forge-llm-pr-description-prompt
pr-section
(or diff-output "[No diff available]"))))
;; Log prompt to debug buffer
(with-current-buffer (get-buffer-create "*forge-llm-debug-prompt*")
(let ((inhibit-read-only t))
(erase-buffer)
(insert "=== PR DESCRIPTION PROMPT ===\n\n")
(insert formatted-prompt)
(goto-char (point-min))))
;; Return the prepared prompt and debug info
(cons formatted-prompt debug-info))))
;; No provider configured
(cons nil "No LLM provider configured. Set `forge-llm-llm-provider' first")))
(defun forge-llm--start-llm-request (prompt output-fn complete-fn error-fn)
"Start an LLM request with the given PROMPT.
OUTPUT-FN is called with each response chunk.
COMPLETE-FN is called when the request completes.
ERROR-FN is called if the request fails."
(if-let ((provider (forge-llm--get-provider)))
(progn
;; Create LLM prompt object
(let ((llm-prompt (llm-make-simple-chat-prompt prompt)))
;; Set LLM parameters
(when forge-llm-temperature
(setf (llm-chat-prompt-temperature llm-prompt) forge-llm-temperature))
(when forge-llm-max-tokens
(setf (llm-chat-prompt-max-tokens llm-prompt) forge-llm-max-tokens))
;; Cancel any existing request
(when forge-llm--active-request
(llm-cancel-request forge-llm--active-request)
(setq forge-llm--active-request nil))
;; Start new streaming request
(setq forge-llm--active-request
(llm-chat-streaming
provider llm-prompt
;; Partial callback
output-fn
;; Complete callback
(lambda (full-response)
(funcall complete-fn full-response)
(setq forge-llm--active-request nil))
;; Error callback
(lambda (err-msg)
(funcall error-fn err-msg)
(setq forge-llm--active-request nil))))))
(user-error "No LLM provider configured. Set `forge-llm-llm-provider' first")))
(defun forge-llm-generate-pr-description ()
"Generate a PR description based on the current git diff and PR template.
Only works in Forge pull request buffers.
Displays the generated description in a separate buffer."
(interactive)
(if (not (derived-mode-p 'forge-post-mode))
(message "Not in a Forge pull request buffer")
(let* ((prompt-info (forge-llm--prepare-prompt))
(prompt (car prompt-info))
(debug-info (cdr prompt-info)))
(if prompt
(progn
(message "Generating PR description with LLM... %s" debug-info)
(let ((buffer (get-buffer-create "*forge-llm-output*")))
;; Initialize output buffer
(with-current-buffer buffer
(let ((inhibit-read-only t))
(erase-buffer)
(insert "# Generating PR Description\n\n")
(insert debug-info "\n\n")
;; Enable markdown mode right away if available
(when (require 'markdown-mode nil t)
(markdown-mode))
(display-buffer buffer)))
;; Start the LLM request
(forge-llm--start-llm-request
prompt
;; Output function
(lambda (partial-response)
(forge-llm--stream-insert-response partial-response buffer))
;; Complete function
(lambda (_full-response)
(forge-llm--stream-update-status 'success buffer))
;; Error function
(lambda (err-msg)
(forge-llm--stream-update-status 'error buffer err-msg)))))
;; Handle prompt preparation failure
(message "Failed to prepare LLM prompt: %s" debug-info)))))
(defun forge-llm-generate-pr-description-at-point ()
"Generate a PR description and insert at current point.
Only works in Forge pull request buffers.
The description is generated based on the git diff between branches."
(interactive)
(if (not (derived-mode-p 'forge-post-mode))
(message "Not in a Forge pull request buffer")
(let* ((prompt-info (forge-llm--prepare-prompt))
(prompt (car prompt-info))
(debug-info (cdr prompt-info))
(current-point (point)))
(if prompt
(progn
(message "Generating PR description at point with LLM... %s" debug-info)
;; Delete everything after point
(delete-region current-point (point-max))
;; Insert a placeholder
(insert "\n\n[Generating PR description...]\n\n")
(let ((placeholder-start current-point)
(placeholder-end (point)))
;; Start the LLM request
(forge-llm--start-llm-request
prompt
;; Output function
(lambda (partial-response)
(save-excursion
(let ((inhibit-read-only t))
;; Replace placeholder with response
(delete-region placeholder-start placeholder-end)
(goto-char placeholder-start)
(insert "\n\n")
(insert partial-response)
(setq placeholder-end (point)))))
;; Complete function
(lambda (_full-response)
(save-excursion
(goto-char placeholder-end)
(insert "\n\n"))
(message "PR description generation complete"))
;; Error function
(lambda (err-msg)
(save-excursion
(goto-char placeholder-end)
(insert (format "\n\nError: %s" err-msg)))))))
;; Handle prompt preparation failure
(message "Failed to prepare LLM prompt: %s" debug-info)))))
(provide 'forge-llm)
;;; forge-llm.el ends here