diff --git a/README.md b/README.md index 2197067..7c6642d 100644 --- a/README.md +++ b/README.md @@ -66,18 +66,20 @@ use { ## Project Configuration -This plugin requires a `.gitlab.nvim` file in the root of the project. Provide this file with values required to connect to your gitlab instance of your repository (gitlab_url is optional, use ONLY for self-hosted instances): +This plugin requires an auth token to connect to Gitlab. The token can be set in the root directory of the project in a `.gitlab.nvim` environment file, or can be set via a shell environment variable called `GITLAB_TOKEN` instead. If both are present, the `.gitlab.nvim` file will take precedence. + +Optionally provide a GITLAB_URL environment variable (or gitlab_url value in the `.gitlab.nvim` file) to connect to a self-hosted Gitlab instance. This is optional, use ONLY for self-hosted instances. ``` -project_id=112415 auth_token=your_gitlab_token gitlab_url=https://my-personal-gitlab-instance.com/ ``` -If you don't want to write your authentication token into a dotfile, you may provide it as a shell variable. For instance in your `.bashrc` or `.zshrc` file: +If you don't want to write these into a dotfile, you may provide them via shell variables. These will be overridden by the dotfile if it is present: ```bash export GITLAB_TOKEN="your_gitlab_token" +export GITLAB_URL="https://my-personal-gitlab-instance.com/" ``` ## Configuring the Plugin @@ -139,17 +141,17 @@ First, check out the branch that you want to review locally. git checkout feature-branch ``` -Then open Neovim. The `project_id` you specify in your configuration file must match the project_id of the Gitlab project your terminal is inside of. +Then open Neovim. To begin, try running the `summary` command or the `review` command. ### Summary -The `summary` action will pull down the MR description into a buffer so that you can read it. To edit the description, use the `settings.popup.perform_action` keybinding. +The `summary` action will open the MR title and description. ```lua require("gitlab").summary() ``` -The upper part of the popup contains the title, which can also be edited and sent via the perform action keybinding in the same manner. +After editing the description or title, you may save your changes via the `settings.popup.perform_action` keybinding. ### Reviewing Diffs @@ -162,7 +164,7 @@ require("gitlab").create_multiline_comment() require("gitlab").create_comment_suggestion() ``` -For suggesting changes you can use `create_comment_suggestion` in visual mode which works similar to `create_multiline_comment` but prefills the comment window with gitlab [suggest changes](https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html) code block with prefilled code from visual selection. +For suggesting changes you can use `create_comment_suggestion` in visual mode which works similar to `create_multiline_comment` but prefills the comment window with Gitlab's [suggest changes](https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html) code block with prefilled code from the visual selection. ### Discussions and Notes @@ -176,7 +178,9 @@ require("gitlab").toggle_discussions() You can jump to the comment's location in the reviewer window by using the `state.settings.discussion_tree.jump_to_reviewer` key, or the actual file with the 'state.settings.discussion_tree.jump_to_file' key. -Within the discussion tree, you can delete/edit/reply to comments with the `state.settings.discussion_tree.delete_comment` `state.settings.discussion_tree.edit_comment` and `state.settings.discussion_tree.reply` keys, and toggle them as resolved with the `state.settings.discussion_tree.toggle_resolved` key. +Within the discussion tree, you can delete/edit/reply to comments with the `state.settings.discussion_tree.SOME_ACTION` keybindings. + +#### Notes If you'd like to create a note in an MR (like a comment, but not linked to a specific line) use the `create_note` action. The same keybindings for delete/edit/reply are available on the note tree. diff --git a/cmd/client.go b/cmd/client.go index f68ec7e..9ef9cf4 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "net/http/httputil" "os" @@ -18,7 +19,6 @@ type Client struct { mergeId int gitlabInstance string authToken string - logPath string git *gitlab.Client } @@ -27,85 +27,32 @@ type DebugSettings struct { GoResponse bool `json:"go_response"` } -var requestLogger retryablehttp.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) { - logPath := os.Args[len(os.Args)-1] +/* This will parse and validate the project settings and then initialize the Gitlab client */ +func (c *Client) initGitlabClient() error { - file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - panic(err) - } - defer file.Close() - - token := r.Header.Get("Private-Token") - r.Header.Set("Private-Token", "REDACTED") - res, err := httputil.DumpRequest(r, true) - if err != nil { - panic(err) - } - r.Header.Set("Private-Token", token) - - _, err = file.Write([]byte("\n-- REQUEST --\n")) //nolint:all - _, err = file.Write(res) //nolint:all - _, err = file.Write([]byte("\n")) //nolint:all -} - -var responseLogger retryablehttp.ResponseLogHook = func(l retryablehttp.Logger, response *http.Response) { - logPath := os.Args[len(os.Args)-1] - - file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - panic(err) - } - defer file.Close() - - res, err := httputil.DumpResponse(response, true) - if err != nil { - panic(err) + if len(os.Args) < 6 { + return errors.New("Must provide gitlab url, port, auth token, debug settings, and log path") } - _, err = file.Write([]byte("\n-- RESPONSE --\n")) //nolint:all - _, err = file.Write(res) //nolint:all - _, err = file.Write([]byte("\n")) //nolint:all -} - -/* This will initialize the client with the token and check for the basic project ID and command arguments */ -func (c *Client) init(branchName string) error { - - if len(os.Args) < 5 { - return errors.New("Must provide project ID, gitlab instance, port, and auth token!") + gitlabInstance := os.Args[1] + if gitlabInstance == "" { + return errors.New("GitLab instance URL cannot be empty") } - projectId := os.Args[1] - gitlabInstance := os.Args[2] - authToken := os.Args[4] - debugSettings := os.Args[5] + authToken := os.Args[3] + if authToken == "" { + return errors.New("Auth token cannot be empty") + } + /* Parse debug settings and initialize logger handlers */ + debugSettings := os.Args[4] var debugObject DebugSettings err := json.Unmarshal([]byte(debugSettings), &debugObject) if err != nil { return fmt.Errorf("Could not parse debug settings: %w, %s", err, debugSettings) } - logPath := os.Args[len(os.Args)-1] - - if projectId == "" { - return errors.New("Project ID cannot be empty") - } - - if gitlabInstance == "" { - return errors.New("GitLab instance URL cannot be empty") - } - - if authToken == "" { - return errors.New("Auth token cannot be empty") - } - - c.gitlabInstance = gitlabInstance - c.projectId = projectId - c.authToken = authToken - c.logPath = logPath - - var apiCustUrl = fmt.Sprintf(c.gitlabInstance + "/api/v4") + var apiCustUrl = fmt.Sprintf(gitlabInstance + "/api/v4") gitlabOptions := []gitlab.ClientOptionFunc{ gitlab.WithBaseURL(apiCustUrl), @@ -125,13 +72,40 @@ func (c *Client) init(branchName string) error { return fmt.Errorf("Failed to create client: %v", err) } + c.gitlabInstance = gitlabInstance + c.authToken = authToken + c.git = git + + return nil +} + +/* This will fetch the project ID and merge request ID using the client */ +func (c *Client) initProjectSettings(remoteUrl string, namespace string, projectName string, branchName string) error { + + idStr := namespace + "/" + projectName + opt := gitlab.GetProjectOptions{} + project, _, err := c.git.Projects.GetProject(idStr, &opt) + + if err != nil { + return fmt.Errorf(fmt.Sprintf("Error getting project at %s", remoteUrl), err) + } + if project == nil { + return fmt.Errorf(fmt.Sprintf("Could not find project at %s", remoteUrl), err) + } + + if project == nil { + return fmt.Errorf("No projects you are a member of contained remote URL %s", remoteUrl) + } + + c.projectId = fmt.Sprint(project.ID) + options := gitlab.ListProjectMergeRequestsOptions{ Scope: gitlab.String("all"), State: gitlab.String("opened"), SourceBranch: &branchName, } - mergeRequests, _, err := git.MergeRequests.ListProjectMergeRequests(c.projectId, &options) + mergeRequests, _, err := c.git.MergeRequests.ListProjectMergeRequests(c.projectId, &options) if err != nil { return fmt.Errorf("Failed to list merge requests: %w", err) } @@ -147,7 +121,6 @@ func (c *Client) init(branchName string) error { } c.mergeId = mergeIdInt - c.git = git return nil } @@ -165,3 +138,54 @@ func (c *Client) handleError(w http.ResponseWriter, err error, message string, s c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } + +var requestLogger retryablehttp.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) { + file := openLogFile() + defer file.Close() + + token := r.Header.Get("Private-Token") + r.Header.Set("Private-Token", "REDACTED") + res, err := httputil.DumpRequest(r, true) + if err != nil { + log.Fatalf("Error dumping request: %v", err) + os.Exit(1) + } + r.Header.Set("Private-Token", token) + + _, err = file.Write([]byte("\n-- REQUEST --\n")) //nolint:all + _, err = file.Write(res) //nolint:all + _, err = file.Write([]byte("\n")) //nolint:all +} + +var responseLogger retryablehttp.ResponseLogHook = func(l retryablehttp.Logger, response *http.Response) { + file := openLogFile() + defer file.Close() + + res, err := httputil.DumpResponse(response, true) + if err != nil { + log.Fatalf("Error dumping response: %v", err) + os.Exit(1) + } + + _, err = file.Write([]byte("\n-- RESPONSE --\n")) //nolint:all + _, err = file.Write(res) //nolint:all + _, err = file.Write([]byte("\n")) //nolint:all +} + +func openLogFile() *os.File { + logFile := os.Args[len(os.Args)-1] + file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + if os.IsNotExist(err) { + log.Printf("Log file %s does not exist", logFile) + } else if os.IsPermission(err) { + log.Printf("Permission denied for log file %s", logFile) + } else { + log.Printf("Error opening log file %s: %v", logFile, err) + } + + os.Exit(1) + } + + return file +} diff --git a/cmd/git.go b/cmd/git.go new file mode 100644 index 0000000..d7ba70b --- /dev/null +++ b/cmd/git.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "os/exec" + "regexp" + "strings" +) + +/* +Extracts information about the current repository and returns +it to the client for initialization. The current directory must be a valid +Gitlab project and the branch must be a feature branch +*/ +func ExtractGitInfo() (string, string, string, string, error) { + + url, err := getProjectUrl() + if err != nil { + return "", "", "", "", fmt.Errorf("Could not get project Url: %v", err) + } + + re := regexp.MustCompile(`(?:[:\/])([^\/]+)\/([^\/]+)\.git$`) + matches := re.FindStringSubmatch(url) + if len(matches) != 3 { + return "", "", "", "", fmt.Errorf("Invalid Git URL format: %s", url) + } + + namespace := matches[1] + projectName := matches[2] + + branch, err := getCurrentBranch() + if err != nil { + return "", "", "", "", fmt.Errorf("Failed to get current branch: %v", err) + } + + return url, namespace, projectName, branch, nil +} + +/* Gets the current branch */ +func getCurrentBranch() (res string, e error) { + gitCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + + output, err := gitCmd.Output() + if err != nil { + return "", fmt.Errorf("Error running git rev-parse: %w", err) + } + + branchName := strings.TrimSpace(string(output)) + + if branchName == "main" || branchName == "master" { + return "", fmt.Errorf("Cannot run on %s branch", branchName) + } + + return branchName, nil +} + +/* Gets the project SSH or HTTPS url */ +func getProjectUrl() (res string, e error) { + cmd := exec.Command("git", "remote", "get-url", "origin") + url, err := cmd.Output() + + if err != nil { + return "", fmt.Errorf("Could not get origin remote") + } + + return strings.TrimSpace(string(url)), nil +} diff --git a/cmd/main.go b/cmd/main.go index ecb01db..f4b7b40 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,29 +7,24 @@ import ( "net" "net/http" "os" - "os/exec" - "strings" "time" ) func main() { - branchName, err := getCurrentBranch() - + url, namespace, projectName, branchName, err := ExtractGitInfo() if err != nil { - log.Fatalf("Failure: Failed to get current branch: %v", err) + log.Fatalf("Failed to get git namespace, project, branch, or url: %v", err) } - if branchName == "main" || branchName == "master" { - log.Fatalf("Cannot run on %s branch", branchName) - } - - /* Initialize Gitlab client */ var c Client - - if err := c.init(branchName); err != nil { + if err := c.initGitlabClient(); err != nil { log.Fatalf("Failed to initialize Gitlab client: %v", err) } + if err := c.initProjectSettings(url, namespace, projectName, branchName); err != nil { + log.Fatalf("Failed to initialize project settings: %v", err) + } + m := http.NewServeMux() m.Handle("/ping", http.HandlerFunc(PingHandler)) m.Handle("/mr/summary", withGitlabContext(http.HandlerFunc(SummaryHandler), c)) @@ -47,7 +42,7 @@ func main() { m.Handle("/pipeline", withGitlabContext(http.HandlerFunc(PipelineHandler), c)) m.Handle("/job", withGitlabContext(http.HandlerFunc(JobHandler), c)) - port := os.Args[3] + port := os.Args[2] if port == "" { // port was not specified port = "0" @@ -59,7 +54,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) os.Exit(1) } - listner_port := listener.Addr().(*net.TCPAddr).Port + listenerPort := listener.Addr().(*net.TCPAddr).Port errCh := make(chan error) go func() { @@ -69,10 +64,10 @@ func main() { go func() { for i := 0; i < 10; i++ { - resp, err := http.Get("http://localhost:" + fmt.Sprintf("%d", listner_port) + "/ping") + resp, err := http.Get("http://localhost:" + fmt.Sprintf("%d", listenerPort) + "/ping") if resp.StatusCode == 200 && err == nil { /* This print is detected by the Lua code and used to fetch project information */ - fmt.Println("Server started on port: ", listner_port) + fmt.Println("Server started on port: ", listenerPort) return } // Wait for healthcheck to pass - at most 1 sec. @@ -85,7 +80,11 @@ func main() { fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) os.Exit(1) } +} +func PingHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "pong") } func withGitlabContext(next http.HandlerFunc, c Client) http.Handler { @@ -94,19 +93,3 @@ func withGitlabContext(next http.HandlerFunc, c Client) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } - -/* Gets the current branch */ -func getCurrentBranch() (res string, e error) { - gitCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - - output, err := gitCmd.Output() - if err != nil { - return "", fmt.Errorf("Error running git rev-parse: %w", err) - } - - return strings.TrimSpace(string(output)), nil -} -func PingHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "pong") -} diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index 71b16a0..8f9b26f 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -3,7 +3,6 @@ local server = require("gitlab.server") local job = require("gitlab.job") local state = require("gitlab.state") -local u = require("gitlab.utils") local M = {} @@ -48,9 +47,11 @@ M.sequence = function(dependencies, cb) local handler = async:new() handler:init(cb) - if not state.is_gitlab_project then - u.notify("The gitlab.nvim state was not set. Do you have a .gitlab.nvim file configured?", vim.log.levels.ERROR) - return + -- Sets configuration for plugin, if not already set + if not state.initialized then + if not state.setPluginConfiguration() then + return + end end if state.go_server_running then diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index be39239..10d4428 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -21,10 +21,7 @@ return { args = {} end server.build() -- Builds the Go binary if it doesn't exist - state.setPluginConfiguration() -- Sets configuration from `.gitlab.nvim` file - if not state.merge_settings(args) then -- Sets keymaps and other settings from setup function - return - end + state.merge_settings(args) -- Sets keymaps and other settings from setup function require("gitlab.colors") -- Sets colors reviewer.init() end, diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index a7f6eb5..6be0baa 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -12,8 +12,6 @@ M.start = function(callback) local parsed_port = nil local callback_called = false local command = state.settings.bin - .. " " - .. state.settings.project_id .. " " .. state.settings.gitlab_url .. " " @@ -47,8 +45,6 @@ M.start = function(callback) if parsed_port ~= nil and not callback_called then callback() callback_called = true - elseif not callback_called then - u.notify("Failed to parse server port", vim.log.levels.ERROR) end end, on_stderr = function(_, errors) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 6e778ec..96257b3 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -92,40 +92,39 @@ M.print_settings = function() u.P(M.settings) end --- Merges `.gitlab.nvim` settings into the state module +-- First reads environment variables into the settings module, +-- then attemps to read a `.gitlab.nvim` configuration file. +-- If after doing this, any variables are missing, alerts the user. +-- The `.gitlab.nvim` configuration file takes precedence. M.setPluginConfiguration = function() + if M.initialized then + return true + end local config_file_path = vim.fn.getcwd() .. "/.gitlab.nvim" local config_file_content = u.read_file(config_file_path) - if config_file_content == nil then - return false - end - M.is_gitlab_project = true - - local file = assert(io.open(config_file_path, "r")) - local properties = {} - for line in file:lines() do - for key, value in string.gmatch(line, "(.-)=(.-)$") do - properties[key] = value + local file_properties = {} + if config_file_content ~= nil then + local file = assert(io.open(config_file_path, "r")) + for line in file:lines() do + for key, value in string.gmatch(line, "(.-)=(.-)$") do + file_properties[key] = value + end end end - M.settings.project_id = properties.project_id - M.settings.auth_token = properties.auth_token or os.getenv("GITLAB_TOKEN") - M.settings.gitlab_url = properties.gitlab_url or "https://gitlab.com" + M.settings.auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN") + M.settings.gitlab_url = file_properties.gitlab_url or os.getenv("GITLAB_URL") or "https://gitlab.com" if M.settings.auth_token == nil then - error("Missing authentication token for Gitlab") - end - - if M.settings.project_id == nil then - error("Missing project ID in .gitlab.nvim file.") - end - - if type(tonumber(M.settings.project_id)) ~= "number" then - error("The .gitlab.nvim project file's 'project_id' must be number") + vim.notify( + "Missing authentication token for Gitlab, please provide it as an environment variable or in the .gitlab.nvim file", + vim.log.levels.ERROR + ) + return false end + M.initialized = true return true end