Skip to content

Commit

Permalink
Feat: Remove Requirement for Dotfile (#84)
Browse files Browse the repository at this point in the history
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.
harrisoncramer authored Nov 12, 2023
1 parent 38df51b commit 80b597e
Showing 8 changed files with 215 additions and 144 deletions.
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <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
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.

162 changes: 93 additions & 69 deletions cmd/client.go
Original file line number Diff line number Diff line change
@@ -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)
if len(os.Args) < 6 {
return errors.New("Must provide gitlab url, port, auth token, debug settings, and log path")
}
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)
gitlabInstance := os.Args[1]
if gitlabInstance == "" {
return errors.New("GitLab instance URL cannot be empty")
}

_, 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!")
authToken := os.Args[3]
if authToken == "" {
return errors.New("Auth token cannot be empty")
}

projectId := os.Args[1]
gitlabInstance := os.Args[2]
authToken := os.Args[4]
debugSettings := os.Args[5]

/* 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
}
67 changes: 67 additions & 0 deletions cmd/git.go
Original file line number Diff line number Diff line change
@@ -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
}
47 changes: 15 additions & 32 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -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)
}

if branchName == "main" || branchName == "master" {
log.Fatalf("Cannot run on %s branch", branchName)
log.Fatalf("Failed to get git namespace, project, branch, or url: %v", err)
}

/* 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")
}
9 changes: 5 additions & 4 deletions lua/gitlab/async.lua
Original file line number Diff line number Diff line change
@@ -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
5 changes: 1 addition & 4 deletions lua/gitlab/init.lua
Original file line number Diff line number Diff line change
@@ -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,
4 changes: 0 additions & 4 deletions lua/gitlab/server.lua
Original file line number Diff line number Diff line change
@@ -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)
45 changes: 22 additions & 23 deletions lua/gitlab/state.lua
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 80b597e

Please sign in to comment.