Feat: Remove Requirement for Dotfile (#84)

This MR removes the requirement for a dotfile (the dotfile is now optional and will override the configuration provided via environment variables). The requirement for providing a project ID is also eliminated, by parsing the namespace and project name from the SSH or HTTPS remote, and then using that to query Gitlab for a matching project.
This commit is contained in:
Harrison (Harry) Cramer
2023-11-11 23:51:11 -05:00
committed by GitHub
parent 38df51bfbc
commit 80b597e56a
8 changed files with 216 additions and 145 deletions

View File

@@ -66,18 +66,20 @@ use {
## Project Configuration ## 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 <a href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token">auth token</a> 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 auth_token=your_gitlab_token
gitlab_url=https://my-personal-gitlab-instance.com/ 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 ```bash
export GITLAB_TOKEN="your_gitlab_token" export GITLAB_TOKEN="your_gitlab_token"
export GITLAB_URL="https://my-personal-gitlab-instance.com/"
``` ```
## Configuring the Plugin ## Configuring the Plugin
@@ -139,17 +141,17 @@ First, check out the branch that you want to review locally.
git checkout feature-branch 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 ### 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 ```lua
require("gitlab").summary() 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 ### Reviewing Diffs
@@ -162,7 +164,7 @@ require("gitlab").create_multiline_comment()
require("gitlab").create_comment_suggestion() 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 ### 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. 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. 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.

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os" "os"
@@ -18,7 +19,6 @@ type Client struct {
mergeId int mergeId int
gitlabInstance string gitlabInstance string
authToken string authToken string
logPath string
git *gitlab.Client git *gitlab.Client
} }
@@ -27,85 +27,32 @@ type DebugSettings struct {
GoResponse bool `json:"go_response"` GoResponse bool `json:"go_response"`
} }
var requestLogger retryablehttp.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) { /* This will parse and validate the project settings and then initialize the Gitlab client */
logPath := os.Args[len(os.Args)-1] func (c *Client) initGitlabClient() error {
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if len(os.Args) < 6 {
if err != nil { return errors.New("Must provide gitlab url, port, auth token, debug settings, and log path")
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)
} }
_, err = file.Write([]byte("\n-- RESPONSE --\n")) //nolint:all gitlabInstance := os.Args[1]
_, err = file.Write(res) //nolint:all if gitlabInstance == "" {
_, err = file.Write([]byte("\n")) //nolint:all return errors.New("GitLab instance URL cannot be empty")
}
/* 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!")
} }
projectId := os.Args[1] authToken := os.Args[3]
gitlabInstance := os.Args[2] if authToken == "" {
authToken := os.Args[4] return errors.New("Auth token cannot be empty")
debugSettings := os.Args[5] }
/* Parse debug settings and initialize logger handlers */
debugSettings := os.Args[4]
var debugObject DebugSettings var debugObject DebugSettings
err := json.Unmarshal([]byte(debugSettings), &debugObject) err := json.Unmarshal([]byte(debugSettings), &debugObject)
if err != nil { if err != nil {
return fmt.Errorf("Could not parse debug settings: %w, %s", err, debugSettings) return fmt.Errorf("Could not parse debug settings: %w, %s", err, debugSettings)
} }
logPath := os.Args[len(os.Args)-1] var apiCustUrl = fmt.Sprintf(gitlabInstance + "/api/v4")
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")
gitlabOptions := []gitlab.ClientOptionFunc{ gitlabOptions := []gitlab.ClientOptionFunc{
gitlab.WithBaseURL(apiCustUrl), gitlab.WithBaseURL(apiCustUrl),
@@ -125,13 +72,40 @@ func (c *Client) init(branchName string) error {
return fmt.Errorf("Failed to create client: %v", err) 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{ options := gitlab.ListProjectMergeRequestsOptions{
Scope: gitlab.String("all"), Scope: gitlab.String("all"),
State: gitlab.String("opened"), State: gitlab.String("opened"),
SourceBranch: &branchName, SourceBranch: &branchName,
} }
mergeRequests, _, err := git.MergeRequests.ListProjectMergeRequests(c.projectId, &options) mergeRequests, _, err := c.git.MergeRequests.ListProjectMergeRequests(c.projectId, &options)
if err != nil { if err != nil {
return fmt.Errorf("Failed to list merge requests: %w", err) 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.mergeId = mergeIdInt
c.git = git
return nil 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) 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
}

67
cmd/git.go Normal file
View File

@@ -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
}

View File

@@ -7,29 +7,24 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/exec"
"strings"
"time" "time"
) )
func main() { func main() {
branchName, err := getCurrentBranch() url, namespace, projectName, branchName, err := ExtractGitInfo()
if err != nil { 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 var c Client
if err := c.initGitlabClient(); err != nil {
if err := c.init(branchName); err != nil {
log.Fatalf("Failed to initialize Gitlab client: %v", err) 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 := http.NewServeMux()
m.Handle("/ping", http.HandlerFunc(PingHandler)) m.Handle("/ping", http.HandlerFunc(PingHandler))
m.Handle("/mr/summary", withGitlabContext(http.HandlerFunc(SummaryHandler), c)) 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("/pipeline", withGitlabContext(http.HandlerFunc(PipelineHandler), c))
m.Handle("/job", withGitlabContext(http.HandlerFunc(JobHandler), c)) m.Handle("/job", withGitlabContext(http.HandlerFunc(JobHandler), c))
port := os.Args[3] port := os.Args[2]
if port == "" { if port == "" {
// port was not specified // port was not specified
port = "0" port = "0"
@@ -59,7 +54,7 @@ func main() {
fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err)
os.Exit(1) os.Exit(1)
} }
listner_port := listener.Addr().(*net.TCPAddr).Port listenerPort := listener.Addr().(*net.TCPAddr).Port
errCh := make(chan error) errCh := make(chan error)
go func() { go func() {
@@ -69,10 +64,10 @@ func main() {
go func() { go func() {
for i := 0; i < 10; i++ { 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 { if resp.StatusCode == 200 && err == nil {
/* This print is detected by the Lua code and used to fetch project information */ /* 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 return
} }
// Wait for healthcheck to pass - at most 1 sec. // 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) fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err)
os.Exit(1) 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 { 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)) 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")
}

View File

@@ -3,7 +3,6 @@
local server = require("gitlab.server") local server = require("gitlab.server")
local job = require("gitlab.job") local job = require("gitlab.job")
local state = require("gitlab.state") local state = require("gitlab.state")
local u = require("gitlab.utils")
local M = {} local M = {}
@@ -48,9 +47,11 @@ M.sequence = function(dependencies, cb)
local handler = async:new() local handler = async:new()
handler:init(cb) handler:init(cb)
if not state.is_gitlab_project then -- Sets configuration for plugin, if not already set
u.notify("The gitlab.nvim state was not set. Do you have a .gitlab.nvim file configured?", vim.log.levels.ERROR) if not state.initialized then
return if not state.setPluginConfiguration() then
return
end
end end
if state.go_server_running then if state.go_server_running then

View File

@@ -21,10 +21,7 @@ return {
args = {} args = {}
end end
server.build() -- Builds the Go binary if it doesn't exist server.build() -- Builds the Go binary if it doesn't exist
state.setPluginConfiguration() -- Sets configuration from `.gitlab.nvim` file state.merge_settings(args) -- Sets keymaps and other settings from setup function
if not state.merge_settings(args) then -- Sets keymaps and other settings from setup function
return
end
require("gitlab.colors") -- Sets colors require("gitlab.colors") -- Sets colors
reviewer.init() reviewer.init()
end, end,

View File

@@ -12,8 +12,6 @@ M.start = function(callback)
local parsed_port = nil local parsed_port = nil
local callback_called = false local callback_called = false
local command = state.settings.bin local command = state.settings.bin
.. " "
.. state.settings.project_id
.. " " .. " "
.. state.settings.gitlab_url .. state.settings.gitlab_url
.. " " .. " "
@@ -47,8 +45,6 @@ M.start = function(callback)
if parsed_port ~= nil and not callback_called then if parsed_port ~= nil and not callback_called then
callback() callback()
callback_called = true callback_called = true
elseif not callback_called then
u.notify("Failed to parse server port", vim.log.levels.ERROR)
end end
end, end,
on_stderr = function(_, errors) on_stderr = function(_, errors)

View File

@@ -92,40 +92,39 @@ M.print_settings = function()
u.P(M.settings) u.P(M.settings)
end 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() M.setPluginConfiguration = function()
if M.initialized then
return true
end
local config_file_path = vim.fn.getcwd() .. "/.gitlab.nvim" local config_file_path = vim.fn.getcwd() .. "/.gitlab.nvim"
local config_file_content = u.read_file(config_file_path) 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_properties = {}
if config_file_content ~= nil then
local file = assert(io.open(config_file_path, "r")) local file = assert(io.open(config_file_path, "r"))
local properties = {} for line in file:lines() do
for line in file:lines() do for key, value in string.gmatch(line, "(.-)=(.-)$") do
for key, value in string.gmatch(line, "(.-)=(.-)$") do file_properties[key] = value
properties[key] = value end
end end
end end
M.settings.project_id = properties.project_id M.settings.auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN")
M.settings.auth_token = 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"
M.settings.gitlab_url = properties.gitlab_url or "https://gitlab.com"
if M.settings.auth_token == nil then if M.settings.auth_token == nil then
error("Missing authentication token for Gitlab") vim.notify(
end "Missing authentication token for Gitlab, please provide it as an environment variable or in the .gitlab.nvim file",
vim.log.levels.ERROR
if M.settings.project_id == nil then )
error("Missing project ID in .gitlab.nvim file.") return false
end
if type(tonumber(M.settings.project_id)) ~= "number" then
error("The .gitlab.nvim project file's 'project_id' must be number")
end end
M.initialized = true
return true return true
end end