From bf37b1eae7941e5a3b1f2838dc20b691bf332db0 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 22 Oct 2022 19:28:19 +0000 Subject: [PATCH] Rebased all commits, v0.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit added a new file First major commit Successful POST of new comment ๐Ÿš€ Updated README Updated README ๐Ÿ“• Added more infrastructure Creating async job More setup Getting arguments from Neovim -> Lua -> Golang Moved commands Added getProjectInfo command Adding make comment command Setting up arguments for MakeComment command Removed extraneous comments Setup basic function for adding comments Lint fixes Handling bad requests correctly Better formatting Printing success message Adding utility table print Set comment from popup UI Added mappings for closing and sending text Moved popup into separate file Added comment Cleaned up code and added approve command Initialize project information Removed extraneous import Don't initialize project in non-gitlab directories Setup approve command Set up revoke and approve commands correctly Cleaned up redundant code Moved get current branch command Reorganization of the code First attempt to add step installing Go binary Adjusted path to binary Added install bin check Do Lua method Fixed install step Tweaked binPath + bin Added basic readme information ๐Ÿ“— Removed .luarc.json file Adding diffview command Added string_starts function Made base branch configurable Added note to readme Fixed readme Added diffview dep to readme Update README.md Update README.md Update README.md Update README.md Renamed files Set up developer workflow Updated README Removed dev note Refactor and moving around files Fixed ft/after mappings Setup read command Added read summary command Got rid of filetype bindings and set up commands Set correct filetype for comment buffer Added read() command to README Updated review -> summary Fixed issue with diffview buffers Added command for getting and showing all comments (out of order) Better error message Adding more code to handle showing comments Added ability to jump from comment to specific changed buffer line Initial refactor Added simple comment action Fixed error message More cleanup Fixed bug with M.PROJECT_ID Leaving comment refactor Fixed comment Cleaned up old code Added missing exit command Check gitlab repo status before initialization Better help strings Added ListDiscussions command Added Go code Darkened metadata, filtered out non-real discussions Removed dummy log Sort the discussions by most recent activity Grab hash of current discussion Wired up reply action in Lua code Moved to NUI Table Adding basic jump-to-file ability More tweaks Allow jump anywhere in the tree Ability to reply directly in the buffer window Jump to location in file Don't jump if no refresh is set Cleaned up mappings + other code Get rid of gitlab CLI dependency Fixed discussions bug Don't initialize client on main/master branches Moved comment into separate module Moved lua modules into separate files Modularized library and state Slightly better error/exception handling Added license file Updated readme Moved into todo.md file Added todo file Standardized naming conventions (snake_case in Lua, camelCase in Go) Moved common popup state into utils folder Cleaned up keymapping functions Note on install Changing bin path Updated README Chnaged from success to info Redirect output to /dev/null on build Checking install code Removed print statement Slight reorganization Setting up delete comment Set up confirmation modal Passing in node ID to delete_comment Functioning comment deletion Added delete_comment command Updated README Furhter modularized discussion code Cleaned up and refactored reply code Update README.md Added ability to edit comments Updated todos Fixed main/master base branch issue Set up keybinding rules Updated todo.md Removed diffview dependency Slight cleanup ๐Ÿงน Trying something out... Trying something for the binary... Trying again Fixed install for non-lazy users Update README.md Update README.md Update README.md Update README.md Update README.md --- .gitignore | 2 + LICENSE.txt | 21 ++++ README.md | 171 ++++++++++++++++++++++++++++ cmd/approve.go | 18 +++ cmd/client.go | 68 +++++++++++ cmd/comment.go | 116 +++++++++++++++++++ cmd/delete_comment.go | 29 +++++ cmd/edit_comment.go | 46 ++++++++ cmd/info.go | 46 ++++++++ cmd/list_discussions.go | 60 ++++++++++ cmd/main.go | 76 +++++++++++++ cmd/reply.go | 41 +++++++ cmd/revoke.go | 18 +++ cmd/star.go | 17 +++ go.mod | 17 +++ go.sum | 43 +++++++ lua/gitlab/approve.lua | 18 +++ lua/gitlab/comment.lua | 187 ++++++++++++++++++++++++++++++ lua/gitlab/discussions.lua | 207 +++++++++++++++++++++++++++++++++ lua/gitlab/init.lua | 79 +++++++++++++ lua/gitlab/keymaps.lua | 22 ++++ lua/gitlab/revoke.lua | 17 +++ lua/gitlab/state.lua | 29 +++++ lua/gitlab/summary.lua | 27 +++++ lua/gitlab/utils/init.lua | 226 +++++++++++++++++++++++++++++++++++++ makefile | 2 + todo.md | 11 ++ 27 files changed, 1614 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 cmd/approve.go create mode 100644 cmd/client.go create mode 100644 cmd/comment.go create mode 100644 cmd/delete_comment.go create mode 100644 cmd/edit_comment.go create mode 100644 cmd/info.go create mode 100644 cmd/list_discussions.go create mode 100644 cmd/main.go create mode 100644 cmd/reply.go create mode 100644 cmd/revoke.go create mode 100644 cmd/star.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lua/gitlab/approve.lua create mode 100644 lua/gitlab/comment.lua create mode 100644 lua/gitlab/discussions.lua create mode 100644 lua/gitlab/init.lua create mode 100644 lua/gitlab/keymaps.lua create mode 100644 lua/gitlab/revoke.lua create mode 100644 lua/gitlab/state.lua create mode 100644 lua/gitlab/summary.lua create mode 100644 lua/gitlab/utils/init.lua create mode 100644 makefile create mode 100644 todo.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2824744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +.luarc.json diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..ce8e0f5 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright 2023 Harrison Cramer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..36f6077 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# gitlab.nvim + +This Neovim plugin is designed to make it easy to review Gitlab MRs from within the editor. This means you can do things like: + +- Create, edit, and delete comments on an MR +- Reply to exisiting comments +- Read MR summaries +- Approve an MR +- Revoke approval for an MR + +https://user-images.githubusercontent.com/32515581/233739969-216dad6e-fa77-417f-9d2d-5e875ab2fb40.mp4 + +## Requirements + +- Go +- nui.nvim +- nvim-notify + +## Installation + +You'll need to have an environment variable available in your shell that you use to authenticate with Gitlab's API. It should look like this: + +```bash +export GITLAB_TOKEN="your_gitlab_token" +``` + +Then install the plugin. Here's what it looks like with Lazy: + +```lua +return { + "harrisoncramer/gitlab.nvim", + dependencies = { + "rcarriga/nvim-notify", + "MunifTanjim/nui.nvim" + }, + config = function() + local gitlab = require("gitlab") + gitlab.setup({ project_id = 3 }) -- This can be found under the project details section of your Gitlab repository. + end, +} +``` + +By default, the tool will look for and interact with MRs against a "main" branch. You can configure this by passing in the `base_branch` option: + +```lua +require('gitlab').setup({ project_id = 3, base_branch = 'master' }) +``` + +The first time you call the setup function the Go binary will be built. + +## Usage + +First, check out the branch that you want to review locally. + +The `summary` command will pull down the MR description into a buffer so that you can read it: + +```lua +require("gitlab").summary() +``` + +The `approve` command will approve the merge request for the current branch: + +```lua +require("gitlab").approve() +``` + +The `revoke` command will revoke approval for the merge request for the current branch: + +```lua +require("gitlab").revoke() +``` + +The `comment` command will open up a NUI popover that will allow you to create a Gitlab comment on the current line. To send the comment, use `s` while the comment popup is open: + +```lua +require("gitlab").comment() +``` + +### Discussions + +Gitlab groups threads of notes together into "disucssions." To get a list of all the discussions for the current MR, use the `list_discussions` command. This command will open up a split view of all the comments on the current merge request. You can jump to the comment location by using the `o` key in the tree buffer, and you can reply to a thread by using the `r` keybinding in the tree buffer: + +```lua +require("gitlab").list_discussions() +``` + +Within the discussion tree, there are several functions that you can call, however, it's better to use the keybindings provided in the setup function. If you want to call them manually, they are: + +```lua +require("gitlab").delete_comment() +require("gitlab").edit_comment() +require("gitlab").reply() +``` + +## Keybindings + +This plugin does not create any keybindings outside of the plugin-specific buffers by default. These are the default keybindings in those plugin buffers: + +```lua +{ + popup = { -- The popup for comment creation, editing, and replying + exit = "", + perform_action = "s", -- Once in normal mode, does action + }, + discussion_tree = { -- The discussion tree that holds all comments + jump_to_location = "o", + edit_comment = "e", + delete_comment = "dd", + reply_to_comment = "r", + toggle_node = "t", + }, + dialogue = { -- The confirmation dialogue for deleting comments + focus_next = { "j", "", "" }, + focus_prev = { "k", "", "" }, + close = { "", "" }, + submit = { "", "" }, + } +} +``` + +To override the defaults, pass a keymaps table into the setup function with any keybindings you'd like to change: + +```lua +local gitlab = require("gitlab") +gitlab.setup({ + project_id = 36091024, + keymaps = { + popup = { + exit = "q" + }, + discussion_tree = { + reply_to_comment = "r" + } + } +}) +``` + +The plugin does not set up any keybindings outside of these buffers, you need to set them up yourself. Here's what I'm using: + +```lua +local gitlab = require("gitlab") +vim.keymap.set("n", "gls", gitlab.summary) +vim.keymap.set("n", "glA", gitlab.approve) +vim.keymap.set("n", "glR", gitlab.revoke) +vim.keymap.set("n", "glc", gitlab.create_comment) +vim.keymap.set("n", "gld", gitlab.list_discussions) +``` + +## Diff Views + +This plugin does not provide you with a diff view out of the box for viewing changes. That is already handled by other plugins. I highly recommend using Diffview to see which files have changed in an MR. This is the function that I'm using to accomplish this: + +```lua +-- Review changes against develop (will break if no develop branch present) +vim.keymap.set("n", "gR", function() + local isDiff = vim.fn.getwinvar(nil, "&diff") + local bufName = vim.api.nvim_buf_get_name(0) + if isDiff ~= 0 or u.string_starts(bufName, "diff") then + vim.cmd.tabclose() + vim.cmd.tabprev() + else + vim.cmd.DiffviewOpen("main") + end +end) +``` + +Which looks like this in my editor: + +Screenshot 2023-04-21 at 6 37 39 PM + +This is useful if you plan to leave comments on the diff, because this plugin currently only supports leaving comments on lines that have been added or modified. I'm currenly working on adding functionality to allow users to leave comments on any lines, including those that have been deleted or untouched. diff --git a/cmd/approve.go b/cmd/approve.go new file mode 100644 index 0000000..d08534f --- /dev/null +++ b/cmd/approve.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" +) + +func (c *Client) Approve() error { + + _, _, err := c.git.MergeRequestApprovals.ApproveMergeRequest(c.projectId, c.mergeId, nil, nil) + + if err != nil { + return fmt.Errorf("Approving MR failed: %w", err) + } + + fmt.Println("Success! Approved MR.") + + return nil +} diff --git a/cmd/client.go b/cmd/client.go new file mode 100644 index 0000000..e0099c5 --- /dev/null +++ b/cmd/client.go @@ -0,0 +1,68 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strconv" + + "github.com/xanzy/go-gitlab" +) + +type Client struct { + command string + projectId string + mergeId int + git *gitlab.Client +} + +/* 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) < 3 { + return errors.New("Must provide command and projectId") + } + + command, projectId := os.Args[1], os.Args[2] + c.command = command + c.projectId = projectId + + if projectId == "" { + return errors.New("Must provide projectId") + } + + git, err := gitlab.NewClient(os.Getenv("GITLAB_TOKEN")) + if err != nil { + return fmt.Errorf("Failed to create client: %v", err) + } + + options := gitlab.ListMergeRequestsOptions{ + State: gitlab.String("opened"), + SourceBranch: &branchName, + } + + mergeRequests, _, err := git.MergeRequests.ListMergeRequests(&options) + if err != nil { + return fmt.Errorf("Failed to list merge requests: %w", err) + } + + if len(mergeRequests) == 0 { + return errors.New("No merge requests found") + } + + mergeId := strconv.Itoa(mergeRequests[0].IID) + mergeIdInt, err := strconv.Atoi(mergeId) + if err != nil { + return err + } + + c.mergeId = mergeIdInt + c.git = git + + return nil +} + +func (c *Client) Usage(command string) { + fmt.Printf("Usage: gitlab-nvim %s ...args", command) + os.Exit(1) +} diff --git a/cmd/comment.go b/cmd/comment.go new file mode 100644 index 0000000..29164a4 --- /dev/null +++ b/cmd/comment.go @@ -0,0 +1,116 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strconv" + "time" + + "github.com/xanzy/go-gitlab" +) + +const mrVersionsUrl = "https://gitlab.com/api/v4/projects/%s/merge_requests/%d/versions" + +type MRVersion struct { + ID int `json:"id"` + HeadCommitSHA string `json:"head_commit_sha"` + BaseCommitSHA string `json:"base_commit_sha"` + StartCommitSHA string `json:"start_commit_sha"` + CreatedAt time.Time `json:"created_at"` + MergeRequestID int `json:"merge_request_id"` + State string `json:"state"` + RealSize string `json:"real_size"` +} + +func (c *Client) Comment() error { + if len(os.Args) < 6 { + c.Usage("comment") + } + + lineNumber, fileName, comment := os.Args[3], os.Args[4], os.Args[5] + if lineNumber == "" || fileName == "" || comment == "" { + c.Usage("comment") + } + + lineNumberInt, err := strconv.Atoi(lineNumber) + if err != nil { + return fmt.Errorf("Not a valid line number: %w", err) + } + + err, response := getMRVersions(c.projectId, c.mergeId) + if err != nil { + log.Fatalf("Error making diff thread: %s", err) + } + defer response.Body.Close() + + body, err := ioutil.ReadAll(response.Body) + + var diffVersionInfo []MRVersion + err = json.Unmarshal(body, &diffVersionInfo) + + time := time.Now() + + options := gitlab.CreateMergeRequestDiscussionOptions{ + Body: &comment, + CreatedAt: &time, + Position: &gitlab.NotePosition{ + PositionType: "text", + BaseSHA: diffVersionInfo[0].BaseCommitSHA, + HeadSHA: diffVersionInfo[0].HeadCommitSHA, + StartSHA: diffVersionInfo[0].StartCommitSHA, + NewPath: fileName, + OldPath: fileName, + NewLine: lineNumberInt, + }, + } + + _, _, err = c.git.Discussions.CreateMergeRequestDiscussion(c.projectId, c.mergeId, &options) + + if err != nil { + return fmt.Errorf("Could not leave comment: %w", err) + } + + fmt.Println("Left Comment: " + comment[0:min(len(comment), 25)] + "...") + + return nil +} + +func min(a int, b int) int { + if a < b { + return a + } + return b +} + +/* Gets the latest merge request revision data */ +func getMRVersions(projectId string, mergeId int) (e error, response *http.Response) { + + gitlabToken := os.Getenv("GITLAB_TOKEN") + url := fmt.Sprintf(mrVersionsUrl, projectId, mergeId) + + req, err := http.NewRequest(http.MethodGet, url, nil) + + req.Header.Add("PRIVATE-TOKEN", gitlabToken) + + if err != nil { + return err, nil + } + + client := &http.Client{} + response, err = client.Do(req) + + if err != nil { + return err, nil + } + + if response.StatusCode != 200 { + return errors.New("Non-200 status code: " + response.Status), nil + } + + return nil, response +} diff --git a/cmd/delete_comment.go b/cmd/delete_comment.go new file mode 100644 index 0000000..507ca19 --- /dev/null +++ b/cmd/delete_comment.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "os" + "strconv" +) + +func (c *Client) DeleteComment() error { + discussionId, noteId := os.Args[3], os.Args[4] + if discussionId == "" || noteId == "" { + c.Usage("deleteComment") + } + + noteIdInt, err := strconv.Atoi(noteId) + if err != nil { + return fmt.Errorf("Could not convert noteId to int: %w", err) + } + + _, err = c.git.Discussions.DeleteMergeRequestDiscussionNote(c.projectId, c.mergeId, discussionId, noteIdInt) + + if err != nil { + return fmt.Errorf("Could not delete comment: %w", err) + } + + fmt.Println("Deleted comment") + + return nil +} diff --git a/cmd/edit_comment.go b/cmd/edit_comment.go new file mode 100644 index 0000000..a4e20b5 --- /dev/null +++ b/cmd/edit_comment.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + + "github.com/xanzy/go-gitlab" +) + +func (c *Client) EditComment() error { + if len(os.Args) < 6 { + return errors.New("Must provide commentId, noteId, and edited text") + } + + discussionId, noteId, newCommentText := os.Args[3], os.Args[4], os.Args[5] + if discussionId == "" || newCommentText == "" { + return errors.New("Must provide commentId, noteId, and edited text") + } + + options := gitlab.UpdateMergeRequestDiscussionNoteOptions{ + Body: gitlab.String(newCommentText), + } + + noteIdInt, err := strconv.Atoi(noteId) + if err != nil { + return errors.New("Not a valid noteId") + } + + gitlabNote, _, err := c.git.Discussions.UpdateMergeRequestDiscussionNote(c.projectId, c.mergeId, discussionId, noteIdInt, &options) + + if err != nil { + return fmt.Errorf("Failed to edit comment: %w", err) + } + + note, err := json.Marshal(gitlabNote) + if err != nil { + return fmt.Errorf("Failed to marshal note: %w", err) + } + + fmt.Println(string(note)) + + return nil +} diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..268c0d2 --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" +) + +const mrUrl = "https://gitlab.com/api/v4/projects/%s/merge_requests/%d" + +func (c *Client) Info() error { + + url := fmt.Sprintf(mrUrl, c.projectId, c.mergeId) + req, err := http.NewRequest(http.MethodGet, url, nil) + + if err != nil { + return fmt.Errorf("Failed to build read request: %w", err) + } + + req.Header.Set("PRIVATE-TOKEN", os.Getenv("GITLAB_TOKEN")) + req.Header.Set("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + + if err != nil { + return fmt.Errorf("Failed to make info request: %w", err) + } + + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return errors.New(fmt.Sprintf("Recieved non-200 response: %d", res.StatusCode)) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("Failed to parse read response: %w", err) + } + + /* This response is parsed into a table in our Lua code */ + fmt.Println(string(body)) + + return nil +} diff --git a/cmd/list_discussions.go b/cmd/list_discussions.go new file mode 100644 index 0000000..1cb1a70 --- /dev/null +++ b/cmd/list_discussions.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "sort" + + "encoding/json" + + "github.com/xanzy/go-gitlab" +) + +type SortableDiscussions []*gitlab.Discussion + +func (n SortableDiscussions) Len() int { + return len(n) +} + +func (d SortableDiscussions) Less(i int, j int) bool { + iTime := d[i].Notes[len(d[i].Notes)-1].CreatedAt + jTime := d[j].Notes[len(d[j].Notes)-1].CreatedAt + return iTime.After(*jTime) + +} + +func (n SortableDiscussions) Swap(i, j int) { + n[i], n[j] = n[j], n[i] +} + +func (c *Client) ListDiscussions() error { + + mergeRequestDiscussionOptions := gitlab.ListMergeRequestDiscussionsOptions{ + Page: 1, + PerPage: 250, + } + discussions, _, err := c.git.Discussions.ListMergeRequestDiscussions(c.projectId, c.mergeId, &mergeRequestDiscussionOptions, nil) + + if err != nil { + return fmt.Errorf("Listing discussions failed: %w", err) + } + + var realDiscussions []*gitlab.Discussion + for i := 0; i < len(discussions); i++ { + notes := discussions[i].Notes + for j := 0; j < len(notes); j++ { + if notes[j].Type == gitlab.NoteTypeValue("DiffNote") { + realDiscussions = append(realDiscussions, discussions[i]) + break + } + } + } + + sortedDiscussions := SortableDiscussions(realDiscussions) + sort.Sort(sortedDiscussions) + + discussionsOutput, err := json.Marshal(sortedDiscussions) + + fmt.Println(string(discussionsOutput)) + + return nil +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..d616fcc --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +const ( + star = "star" + info = "info" + approve = "approve" + revoke = "revoke" + comment = "comment" + deleteComment = "deleteComment" + editComment = "editComment" + reply = "reply" + listDiscussions = "listDiscussions" +) + +func main() { + + branchName, err := getCurrentBranch() + errCheck(err) + if branchName == "main" || branchName == "master" { + return + } + + var c Client + errCheck(c.Init(branchName)) + + switch c.command { + case star: + errCheck(c.Star()) + case approve: + errCheck(c.Approve()) + case revoke: + errCheck(c.Revoke()) + case comment: + errCheck(c.Comment()) + case deleteComment: + errCheck(c.DeleteComment()) + case editComment: + errCheck(c.EditComment()) + case info: + errCheck(c.Info()) + case reply: + errCheck(c.Reply()) + case listDiscussions: + errCheck(c.ListDiscussions()) + default: + c.Usage("command") + } +} + +func errCheck(err error) { + if err != nil { + log.Fatalf("Failure: %s", err) + os.Exit(1) + } +} + +/* 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 + +} diff --git a/cmd/reply.go b/cmd/reply.go new file mode 100644 index 0000000..875f3cd --- /dev/null +++ b/cmd/reply.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/xanzy/go-gitlab" +) + +func (c *Client) Reply() error { + + if len(os.Args) < 5 { + return errors.New("Must provide discussionId and reply text") + } + + discussionId, reply := os.Args[3], os.Args[4] + + now := time.Now() + options := gitlab.AddMergeRequestDiscussionNoteOptions{ + Body: gitlab.String(reply), + CreatedAt: &now, + } + + gitlabNote, _, err := c.git.Discussions.AddMergeRequestDiscussionNote(c.projectId, c.mergeId, discussionId, &options) + + if err != nil { + return fmt.Errorf("Could not leave reply: %w", err) + } + + output, err := json.Marshal(gitlabNote) + if err != nil { + return fmt.Errorf("Could not marshal note: %w", err) + } + + fmt.Println(string(output)) + + return nil +} diff --git a/cmd/revoke.go b/cmd/revoke.go new file mode 100644 index 0000000..8a1d135 --- /dev/null +++ b/cmd/revoke.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" +) + +func (c *Client) Revoke() error { + + _, err := c.git.MergeRequestApprovals.UnapproveMergeRequest(c.projectId, c.mergeId, nil, nil) + + if err != nil { + return fmt.Errorf("Revoking approval failed: %w", err) + } + + fmt.Println("Success! Revoked MR approval.") + + return nil +} diff --git a/cmd/star.go b/cmd/star.go new file mode 100644 index 0000000..c8d873c --- /dev/null +++ b/cmd/star.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "log" +) + +func (c *Client) Star() error { + project, _, err := c.git.Projects.StarProject(c.projectId) + if err != nil { + return fmt.Errorf("Starring project failed: %w", err) + } + + log.Printf("Success! Starred project: %s", project.Name) + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1305ae8 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module gitlab.com/harrisoncramer/gitlab.nvim + +go 1.19 + +require github.com/xanzy/go-gitlab v0.83.0 + +require ( + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.2 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.29.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bc150bb --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/xanzy/go-gitlab v0.83.0 h1:37p0MpTPNbsTMKX/JnmJtY8Ch1sFiJzVF342+RvZEGw= +github.com/xanzy/go-gitlab v0.83.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM= +google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/lua/gitlab/approve.lua b/lua/gitlab/approve.lua new file mode 100644 index 0000000..4107b75 --- /dev/null +++ b/lua/gitlab/approve.lua @@ -0,0 +1,18 @@ +local u = require("gitlab.utils") +local state = require("gitlab.state") +local Job = require("plenary.job") +local M = {} + +-- Approves the current merge request +M.approve = function() + if u.base_invalid() then return end + Job:new({ + command = state.BIN, + args = { "approve", state.PROJECT_ID }, + on_stdout = u.print_success, + on_stderr = u.print_error + }):start() +end + + +return M diff --git a/lua/gitlab/comment.lua b/lua/gitlab/comment.lua new file mode 100644 index 0000000..6e18c05 --- /dev/null +++ b/lua/gitlab/comment.lua @@ -0,0 +1,187 @@ +local Menu = require("nui.menu") +local NuiTree = require("nui.tree") +local Job = require("plenary.job") +local state = require("gitlab.state") +local u = require("gitlab.utils") +local keymaps = require("gitlab.keymaps") +local Popup = require("nui.popup") +local M = {} + +local commentPopup = Popup(u.create_popup_state("Comment", "40%", "60%")) +local editPopup = Popup(u.create_popup_state("Edit Comment", "80%", "80%")) + +M.create_comment = function() + if u.base_invalid() then return end + commentPopup:mount() + keymaps.set_popup_keymaps(commentPopup, M.confirm_create_comment) +end + +-- Sends the comment to Gitlab +M.confirm_create_comment = function(text) + if u.base_invalid() then return end + local relative_file_path = u.get_relative_file_path() + local current_line_number = u.get_current_line_number() + Job:new({ + command = state.BIN, + args = { + "comment", + state.PROJECT_ID, + current_line_number, + relative_file_path, + text, + }, + -- TODO: Render the tree after comment creation. Refresh? + on_stdout = u.print_success, + on_stderr = u.print_error + }):start() +end + +M.delete_comment = function() + local menu = Menu({ + position = "50%", + size = { + width = 25, + }, + border = { + style = "single", + text = { + top = "Delete Comment?", + top_align = "center", + }, + }, + win_options = { + winhighlight = "Normal:Normal,FloatBorder:Normal", + }, + }, { + lines = { + Menu.item("Confirm"), + Menu.item("Cancel"), + }, + max_width = 20, + keymap = { + focus_next = state.keymaps.dialogue.focus_next, + focus_prev = state.keymaps.dialogue.focus_prev, + close = state.keymaps.dialogue.close, + submit = state.keymaps.dialogue.submit, + }, + on_submit = function(item) + if item.text == "Confirm" then + local note_id + local node = state.tree:get_node() + if node.is_note then + note_id = node:get_id() + end + local parentId = node:get_parent_id() + while (parentId ~= nil) do + node = state.tree:get_node(parentId) + parentId = node:get_parent_id() + if node.is_note then + note_id = node:get_id() + end + end + local discussion_id = node:get_id() + discussion_id = string.sub(discussion_id, 2) -- Remove the "-" at the start + note_id = string.sub(note_id, 2) -- Remove the "-" at the start + Job:new({ + command = state.BIN, + args = { + "deleteComment", + state.PROJECT_ID, + discussion_id, + note_id, + }, + on_stdout = function(_, line) + vim.schedule(function() + if line ~= nil and line ~= "" then + require("notify")(line, "info") + state.tree:remove_node("-" .. note_id) + local discussion_node = state.tree:get_node("-" .. discussion_id) + if not discussion_node:has_children() then + state.tree:remove_node("-" .. discussion_id) + end + state.tree:render() + end + end) + end, + on_stderr = u.print_error + }):start() + end + end, + }) + menu:mount() +end + + +M.edit_comment = function() + if u.base_invalid() then return end + local node = state.tree:get_node() + if node.is_discussion then return end + if node.is_body then + local parentId = node:get_parent_id() + node = state.tree:get_node(parentId) -- Get the node for the comment + end + + editPopup:mount() + + local note_id = string.sub(node:get_id(), 2) -- Remove the "-" at the start + local discussion_id = node:get_parent_id() + discussion_id = string.sub(discussion_id, 2) -- Remove the "-" at the start + + state.ACTIVE_DISCUSSION = discussion_id + state.ACTIVE_NOTE = note_id + + local lines = {} + local childrenIds = node:get_child_ids() + for _, value in ipairs(childrenIds) do + local line = state.tree:get_node(value).text + table.insert(lines, line) + end + + local currentBuffer = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) + keymaps.set_popup_keymaps(editPopup, M.send_edits) +end + +M.send_edits = function(text) + Job:new({ + command = state.BIN, + args = { + "editComment", + state.PROJECT_ID, + state.ACTIVE_DISCUSSION, + state.ACTIVE_NOTE, + text, + }, + on_stdout = function(_, line) + local note = vim.json.decode(line) + if note == nil then + require("notify")("There was an issue editing the note", "error") + return + end + + vim.schedule(function() + local node = state.tree:get_node("-" .. state.ACTIVE_NOTE) + + local childrenIds = node:get_child_ids() + for _, value in ipairs(childrenIds) do + state.tree:remove_node(value) + end + + local newNoteTextNodes = {} + for bodyLine in note.body:gmatch("[^\n]+") do + table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {})) + end + + state.tree:set_nodes(newNoteTextNodes, "-" .. state.ACTIVE_NOTE) + + state.tree:render() + local buf = vim.api.nvim_get_current_buf() + u.darken_metadata(buf, '๏‘ ') + require("notify")("Edited comment!") + end) + end, + on_stderr = u.print_error + }):start() +end + +return M diff --git a/lua/gitlab/discussions.lua b/lua/gitlab/discussions.lua new file mode 100644 index 0000000..3cf3e3e --- /dev/null +++ b/lua/gitlab/discussions.lua @@ -0,0 +1,207 @@ +local u = require("gitlab.utils") +local NuiTree = require("nui.tree") +local state = require("gitlab.state") +local Job = require("plenary.job") +local Popup = require("nui.popup") +local keymaps = require("gitlab.keymaps") + +local M = {} + +local replyPopup = Popup(u.create_popup_state("Reply", "80%", "80%")) + +M.reply = function() + if u.base_invalid() then return end + replyPopup:mount() + keymaps.set_popup_keymaps(replyPopup, M.send_reply) +end + +M.send_reply = function(text) + Job:new({ + command = state.BIN, + args = { + "reply", + state.PROJECT_ID, + state.ACTIVE_DISCUSSION, + text, + }, + on_stdout = function(_, line) + local note = vim.json.decode(line) + if note == nil then + require("notify")("There was an issue creating the note", "error") + return + end + + local note_node = M.build_note(note) + note_node:expand() + + state.tree:add_node(note_node, "-" .. state.ACTIVE_DISCUSSION) + vim.schedule(function() + state.tree:render() + local buf = vim.api.nvim_get_current_buf() + u.darken_metadata(buf, '๏‘ ') + require("notify")("Sent reply!") + end) + end, + on_stderr = u.print_error + }):start() +end + +-- Places all of the discussions into a readable list +M.list_discussions = function() + if u.base_invalid() then return end + Job:new({ + command = state.BIN, + args = { "listDiscussions", state.PROJECT_ID }, + on_stdout = function(_, line) + local discussions = vim.json.decode(line) + M.discussions = discussions + vim.schedule(function() + vim.cmd.tabnew() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_command("vsplit") + vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown') + vim.api.nvim_set_current_buf(buf) + if discussions == nil then + require("notify")("No discussions found for this MR", "warn") + else + local allDiscussions = {} + for i, discussion in ipairs(discussions) do + local discussionChildren = {} + for _, note in ipairs(discussion.notes) do + local note_node = M.build_note(note) + if i == 1 then + note_node:expand() + end + table.insert(discussionChildren, note_node) + end + local discussionNode = NuiTree.Node({ + text = discussion.id, + id = discussion.id, + is_discussion = true + }, + discussionChildren) + if i == 1 then + discussionNode:expand() + end + table.insert(allDiscussions, discussionNode) + end + state.tree = NuiTree({ nodes = allDiscussions, bufnr = buf }) + + M.set_tree_keymaps(buf) + + state.tree:render() + vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown') + u.darken_metadata(buf, '๏‘ ') + if not is_refresh then + M.jump_to_file() + end + end + end) + end, + on_stderr = u.print_error, + }):start() +end + +M.jump_to_file = function() + local node = state.tree:get_node() + if node == nil then return end + + local childrenIds = node:get_child_ids() + -- We have selected a note node + if node.file_name ~= nil then + u.jump_to_file(node.file_name, node.line_number) + elseif node.is_body then + local parentId = node:get_parent_id() + local parent = state.tree:get_node(parentId) + if parent == nil then return end + u.jump_to_file(parent.file_name, parent.line_number) + else + local firstChild = state.tree:get_node(childrenIds[1]) + if firstChild == nil then return end + u.jump_to_file(firstChild.file_name, firstChild.line_number) + end +end + +M.set_tree_keymaps = function(buf) + -- Jump to file location where comment was left + vim.keymap.set('n', state.keymaps.discussion_tree.jump_to_location, function() + M.jump_to_file() + end, { buffer = true }) + + vim.keymap.set('n', state.keymaps.discussion_tree.edit_comment, function() + require("gitlab.comment").edit_comment() + end, { buffer = true }) + + vim.keymap.set('n', state.keymaps.discussion_tree.delete_comment, function() + require("gitlab.comment").delete_comment() + end) + + -- Expand/collapse the current node + vim.keymap.set('n', state.keymaps.discussion_tree.toggle_node, function() + local node = state.tree:get_node() + if node == nil then return end + local children = node:get_child_ids() + if node == nil then return end + if node:is_expanded() then + node:collapse() + for _, child in ipairs(children) do + state.tree:get_node(child):collapse() + end + else + for _, child in ipairs(children) do + state.tree:get_node(child):expand() + end + node:expand() + end + + + state.tree:render() + u.darken_metadata(buf, '๏‘ ') + end, + { buffer = true }) + + vim.keymap.set('n', 'r', function() + local node = state.tree:get_node() + if node == nil then return end + + -- Get closest discussion parent + if node.is_body then + local parentId = node:get_parent_id() + local parent = state.tree:get_node(parentId) + if parent == nil then return end + parentId = parent:get_parent_id() + parent = state.tree:get_node(parentId) + if parent == nil then return end + node = parent + elseif node.is_note then + local parentId = node:get_parent_id() + local parent = state.tree:get_node(parentId) + if parent == nil then return end + node = parent + end + + state.ACTIVE_DISCUSSION = node.id + M.reply() + end, { buffer = true }) +end + +M.build_note = function(note) + local noteTextNodes = {} + for bodyLine in note.body:gmatch("[^\n]+") do + table.insert(noteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {})) + end + local noteHeader = "@" .. + note.author.username .. " on " .. u.format_date(note.created_at) + local note_node = NuiTree.Node( + { + text = noteHeader, + id = note.id, + file_name = note.position.new_path, + line_number = note.position.new_line, + is_note = true + }, noteTextNodes) + + return note_node +end + +return M diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua new file mode 100644 index 0000000..bc9a1f6 --- /dev/null +++ b/lua/gitlab/init.lua @@ -0,0 +1,79 @@ +local Job = require("plenary.job") +local state = require("gitlab.state") +local discussions = require("gitlab.discussions") +local summary = require("gitlab.summary") +local keymaps = require("gitlab.keymaps") +local comment = require("gitlab.comment") +local approve = require("gitlab.approve") +local revoke = require("gitlab.revoke") +local u = require("gitlab.utils") + +-- Root Module Scope +local M = {} +M.summary = summary.summary +M.approve = approve.approve +M.revoke = revoke.revoke +M.create_comment = comment.create_comment +M.list_discussions = discussions.list_discussions +M.edit_comment = comment.edit_comment +M.delete_comment = comment.delete_comment +M.reply = discussions.reply + +-- Builds the Go binary, initializes the plugin, fetches MR info +local projectData = {} +local function current_file_path() + local path = debug.getinfo(1, 'S').source:sub(2) + return vim.fn.fnamemodify(path, ':p') +end + +M.setup = function(args) + local file_path = current_file_path() + local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h") + + state.BIN_PATH = parent_dir + state.BIN = parent_dir .. "/bin" + + local binExists = io.open(state.BIN, "r") + if not binExists or args.dev == true then + local command = string.format("cd %s && make", state.BIN_PATH) + local installCode = os.execute(command .. "> /dev/null") + if installCode ~= 0 then + require("notify")("Could not install gitlab.nvim! Do you have Go installed?", "error") + return + end + end + + if args.project_id == nil then + error("No project ID provided!") + end + state.PROJECT_ID = args.project_id + + if args.base_branch ~= nil then + state.BASE_BRANCH = args.base_branch + end + + if u.is_gitlab_repo() then + Job:new({ + command = state.BIN, + args = { "info", state.PROJECT_ID }, + on_stdout = function(_, line) + table.insert(projectData, line) + end, + on_stderr = u.print_error, + on_exit = function() + if projectData[1] ~= nil then + local parsed_ok, data = pcall(vim.json.decode, projectData[1]) + if parsed_ok ~= true then + require("notify")("Failed calling setup. Could not get project data.", "error") + else + state.INFO = data + end + end + end, + }):start() + end + + keymaps.set_keymap_keys(args.keymaps) +end + +return M diff --git a/lua/gitlab/keymaps.lua b/lua/gitlab/keymaps.lua new file mode 100644 index 0000000..a29c801 --- /dev/null +++ b/lua/gitlab/keymaps.lua @@ -0,0 +1,22 @@ +local u = require("gitlab.utils") +local state = require("gitlab.state") +local M = {} + +M.set_popup_keymaps = function(popup, action) + vim.keymap.set('n', state.keymaps.popup.exit, function() u.exit(popup) end, { buffer = true }) + vim.keymap.set('n', ':', '', { buffer = true }) + if action ~= nil then + vim.keymap.set('n', state.keymaps.popup.perform_action, function() + local text = u.get_buffer_text(popup.bufnr) + popup:unmount() + action(text) + end, { buffer = true }) + end +end + +M.set_keymap_keys = function(keyTable) + if keyTable == nil then return end + state.keymaps = u.merge_tables(state.keymaps, keyTable) +end + +return M diff --git a/lua/gitlab/revoke.lua b/lua/gitlab/revoke.lua new file mode 100644 index 0000000..8dd48a3 --- /dev/null +++ b/lua/gitlab/revoke.lua @@ -0,0 +1,17 @@ +local u = require("gitlab.utils") +local state = require("gitlab.state") +local M = {} +local Job = require("plenary.job") + +-- Revokes approval for the current merge request +M.revoke = function() + if u.base_invalid() then return end + Job:new({ + command = state.BIN, + args = { "revoke", state.PROJECT_ID }, + on_stdout = u.print_success, + on_stderr = u.print_error + }):start() +end + +return M diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua new file mode 100644 index 0000000..863f1f7 --- /dev/null +++ b/lua/gitlab/state.lua @@ -0,0 +1,29 @@ +local M = {} + +M.BIN_PATH = nil +M.BIN = nil +M.PROJECT_ID = nil +M.ACTIVE_DISCUSSION = nil +M.ACTIVE_NOTE = nil +M.BASE_BRANCH = "main" +M.keymaps = { + popup = { + exit = "", + perform_action = "s", + }, + discussion_tree = { + jump_to_location = "o", + edit_comment = "e", + delete_comment = "dd", + reply_to_comment = "r", + toggle_node = "t", + }, + dialogue = { + focus_next = { "j", "", "" }, + focus_prev = { "k", "", "" }, + close = { "", "" }, + submit = { "", "" }, + } +} + +return M diff --git a/lua/gitlab/summary.lua b/lua/gitlab/summary.lua new file mode 100644 index 0000000..735af24 --- /dev/null +++ b/lua/gitlab/summary.lua @@ -0,0 +1,27 @@ +local state = require("gitlab.state") +local Popup = require("nui.popup") +local u = require("gitlab.utils") +local keymaps = require("gitlab.keymaps") +local summaryPopup = Popup(u.create_popup_state("Loading Summary...", "80%", "80%")) +local M = {} + +M.summary = function() + if u.base_invalid() then return end + summaryPopup:mount() + local currentBuffer = vim.api.nvim_get_current_buf() + local title = state.INFO.title + local description = state.INFO.description + local lines = {} + for line in description:gmatch("[^\n]+") do + table.insert(lines, line) + table.insert(lines, "") + end + vim.schedule(function() + vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) + vim.api.nvim_buf_set_option(currentBuffer, "modifiable", false) + summaryPopup.border:set_text("top", title, "center") + keymaps.set_popup_keymaps(summaryPopup) + end) +end + +return M diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua new file mode 100644 index 0000000..6dd11ad --- /dev/null +++ b/lua/gitlab/utils/init.lua @@ -0,0 +1,226 @@ +local state = require("gitlab.state") + +local function get_git_root() + local output = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null') + if vim.v.shell_error == 0 then + return vim.fn.substitute(output, '\n', '', '') + else + return nil + end +end + +local function get_relative_file_path() + local git_root = get_git_root() + if git_root ~= nil then + local current_file = vim.fn.expand('%:p') + return vim.fn.substitute(current_file, git_root .. '/', '', '') + else + return nil + end +end + +local get_current_line_number = function() + return vim.api.nvim_call_function('line', { '.' }) +end + +function P(...) + local objects = {} + for i = 1, select("#", ...) do + local v = select(i, ...) + table.insert(objects, vim.inspect(v)) + end + + print(table.concat(objects, "\n")) + return ... +end + +local function get_buffer_text(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local text = table.concat(lines, "\n") + return text +end + +local feature_branch_exists = function(base_branch) + local is_git_branch = io.popen("git rev-parse --is-inside-work-tree 2>/dev/null"):read("*a") + if is_git_branch == "true\n" then + for line in io.popen("git branch 2>/dev/null"):lines() do + line = line:gsub("%s+", "") + if line == base_branch then + return true + end + end + end + return false +end + +local string_starts = function(str, start) + return str:sub(1, #start) == start +end + +local press_enter = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", false, true, true), "n", false) +end + +local base_invalid = function() + local current_branch_raw = io.popen("git rev-parse --abbrev-ref HEAD"):read("*a") + local current_branch = string.gsub(current_branch_raw, "\n", "") + + if current_branch == "main" or current_branch == "master" then + require("notify")('On ' .. current_branch .. ' branch, no MRs available', "error") + return true + end + + local base = state.BASE_BRANCH + local hasBaseBranch = feature_branch_exists(base) + if not hasBaseBranch then + require("notify")('No base branch. If this is a Gitlab repository, please check your setup function!', "error") + return true + end +end + +local format_date = function(date_string) + local year, month, day, hour, min, sec = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)") + local date = os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec }) + + -- Format date into human-readable string without leading zeros + local formatted_date = os.date("%A, %B %e at %l:%M %p", date) + return formatted_date +end + +local add_comment_sign = function(line_number) + local bufnr = vim.api.nvim_get_current_buf() + vim.cmd("sign define piet text=๏Š– texthl=Substitute") + vim.fn.sign_place(0, "piet", "piet", bufnr, { lnum = line_number }) +end + +local function is_gitlab_repo() + local current_dir = vim.fn.getcwd() + + -- check if it contains a .git folder + local git_dir = current_dir .. "/.git" + if vim.fn.isdirectory(git_dir) == 0 then + return false + end + + local git_cmd = 'git remote get-url origin' + local handle = io.popen(git_cmd) + local result = handle:read("*a") + handle:close() + + -- check if the remote URL is a Gitlab URL + if string.match(result, "gitlab%.com") then + return true + else + return false + end +end + + +local function jump_to_file(filename, line_number) + vim.api.nvim_command("wincmd l") + local bufnr = vim.fn.bufnr(filename) + if bufnr ~= -1 then + -- Buffer is already open, switch to it + vim.cmd("buffer " .. bufnr) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) + return + end + + -- If buffer is not already open, open it + vim.cmd("edit " .. filename) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) +end + +local function find_value_by_id(tbl, id) + for i = 1, #tbl do + if tbl[i].id == tonumber(id) then + return tbl[i] + end + end + return nil +end + +vim.cmd("highlight Gray guifg=#888888") +local function darken_metadata(bufnr, regex) + local num_lines = vim.api.nvim_buf_line_count(bufnr) + for i = 0, num_lines - 1 do + local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] + if string.match(line, regex) then + vim.api.nvim_buf_add_highlight(bufnr, -1, 'Gray', i, 0, -1) + end + end +end + +local function print_success(_, line) + if line ~= nil and line ~= "" then + require("notify")(line, "info") + end +end + +local function print_error(_, line) + if line ~= nil and line ~= "" then + require("notify")(line, "error") + end +end + +local function exit(popup) + popup:unmount() +end + +local create_popup_state = function(title, width, height) + return { + buf_options = { + filetype = 'markdown' + }, + enter = true, + focusable = true, + border = { + style = "rounded", + text = { + top = title + }, + }, + position = "50%", + size = { + width = width, + height = height, + }, + } +end + +local M = {} +M.merge_tables = function(defaults, overrides) + local result = {} + + for key, value in pairs(defaults) do + if type(value) == "table" then + result[key] = M.merge_tables(value, overrides[key] or {}) + else + result[key] = overrides[key] or value + end + end + + return result +end + + + +M.get_relative_file_path = get_relative_file_path +M.get_current_line_number = get_current_line_number +M.get_buffer_text = get_buffer_text +M.feature_branch_exists = feature_branch_exists +M.press_enter = press_enter +M.string_starts = string_starts +M.base_invalid = base_invalid +M.format_date = format_date +M.add_comment_sign = add_comment_sign +M.jump_to_file = jump_to_file +M.find_value_by_id = find_value_by_id +M.is_gitlab_repo = is_gitlab_repo +M.darken_metadata = darken_metadata +M.print_success = print_success +M.print_error = print_error +M.create_popup_state = create_popup_state +M.exit = exit +M.P = P +return M diff --git a/makefile b/makefile new file mode 100644 index 0000000..71b607f --- /dev/null +++ b/makefile @@ -0,0 +1,2 @@ +compile: + cd cmd && go build -o bin && mv bin ../bin diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..281c08e --- /dev/null +++ b/todo.md @@ -0,0 +1,11 @@ +## Todo + +- [x] Install for non-Lazy users +- [ ] Fix comments on non-changed file lines +- [x] Delete, Edit Comments +- [ ] Auto-Pick buffer when Cycling Through Comments +- [x] Clean up Camel Case to Snake Case +- [x] Common Popup State +- [x] Silence make command on build (don't rebuild every time) +- [x] Fix gitlab.nvim vs. gitlab namespacing issue +- [x] Allow customization of keybindings