Release 2.5.1 (#271)

* feat: Support for custom authentication provider functions (#270)
* feat: Support for adding "draft" notes to the review, and publishing them, either individually or all at once. Addresses feature request #223.
* feat: Lets users select + checkout a merge request directly within Neovim, without exiting to the terminal
* fix: Checks that the remote feature branch exists and is up-to-date before creating a MR, starting a review, or opening the MR summary (#278)
* docs: We require some state from Diffview, this shows how to load that state prior to installing w/ Packer. Fixes #94.

This is a #MINOR release.

---------

Co-authored-by: Jakub F. Bortlík <jakub.bortlik@proton.me>
Co-authored-by: sunfuze <sunfuze.1989@gmail.com>
Co-authored-by: Patrick Pichler <mail@patrickpichler.dev>
This commit is contained in:
Harrison (Harry) Cramer
2024-04-22 16:56:27 -04:00
committed by GitHub
parent f10c4ebb8f
commit cf6ccddce3
42 changed files with 2830 additions and 1149 deletions

View File

@@ -25,9 +25,9 @@ To view these help docs and to get more detailed help information, please run `:
1. Install Go 1. Install Go
2. Add configuration (see Installation section) 2. Add configuration (see Installation section)
3. Checkout your feature branch: `git checkout feature-branch` 5. Run `:lua require("gitlab").choose_merge_request()`
4. Open Neovim
5. Run `:lua require("gitlab").review()` to open the reviewer pane This will checkout the branch locally, and open the plugin's reviewer pane.
For more detailed information about the Lua APIs please run `:h gitlab.nvim.api` For more detailed information about the Lua APIs please run `:h gitlab.nvim.api`
@@ -56,20 +56,25 @@ return {
And with Packer: And with Packer:
```lua ```lua
use { use {
'harrisoncramer/gitlab.nvim', "harrisoncramer/gitlab.nvim",
requires = { requires = {
"MunifTanjim/nui.nvim", "MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim", "nvim-lua/plenary.nvim",
"sindrets/diffview.nvim", "sindrets/diffview.nvim"
"stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers.
"nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree.
}, },
run = function() require("gitlab.server").build(true) end, build = function()
config = function() require("gitlab.server").build()
require("gitlab").setup() end,
end, branch = "develop",
} config = function()
require("diffview") -- We require some global state from diffview
local gitlab = require("gitlab")
gitlab.setup()
end,
}
``` ```
## Connecting to Gitlab ## Connecting to Gitlab
@@ -92,6 +97,18 @@ gitlab_url=https://my-personal-gitlab-instance.com/
The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file. The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file.
In case even more control over the auth config is needed, there is the possibility to override the `auth_provider` settings field. It should be
a function that returns the `token` as well as the `gitlab_url` value, and a nilable error. If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default.
Here an example how to use a custom `auth_provider`:
```lua
require("gitlab").setup({
auth_provider = function()
return "my_token", "https://custom.gitlab.instance.url", nil
end,
}
```
For more settings, please see `:h gitlab.nvim.connecting-to-gitlab` For more settings, please see `:h gitlab.nvim.connecting-to-gitlab`
## Configuring the Plugin ## Configuring the Plugin
@@ -103,7 +120,10 @@ require("gitlab").setup({
port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically
log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server
config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section
debug = { go_request = false, go_response = false }, -- Which values to log debug = {
go_request = false,
go_response = false,
},
attachment_dir = nil, -- The local directory for files (see the "summary" section) attachment_dir = nil, -- The local directory for files (see the "summary" section)
reviewer_settings = { reviewer_settings = {
diffview = { diffview = {
@@ -150,6 +170,7 @@ require("gitlab").setup({
toggle_resolved_discussions = "R", -- Open or close all resolved discussions toggle_resolved_discussions = "R", -- Open or close all resolved discussions
toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions
keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling
publish_draft = "P", -- Publishes the currently focused note/comment
toggle_resolved = "p" -- Toggles the resolved status of the whole discussion toggle_resolved = "p" -- Toggles the resolved status of the whole discussion
position = "left", -- "top", "right", "bottom" or "left" position = "left", -- "top", "right", "bottom" or "left"
open_in_browser = "b" -- Jump to the URL of the current note/discussion open_in_browser = "b" -- Jump to the URL of the current note/discussion
@@ -160,9 +181,14 @@ require("gitlab").setup({
unresolved = '-', -- Symbol to show next to unresolved discussions unresolved = '-', -- Symbol to show next to unresolved discussions
tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file
toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name"
draft_mode = false, -- Whether comments are posted as drafts as part of a review
toggle_draft_mode = "D" -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately)
winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua)
-- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar.
}, },
choose_merge_request = {
open_reviewer = true, -- Open the reviewer window automatically after switching merge requests
},
info = { -- Show additional fields in the summary view info = { -- Show additional fields in the summary view
enabled = true, enabled = true,
horizontal = false, -- Display metadata to the left of the summary rather than underneath horizontal = false, -- Display metadata to the left of the summary rather than underneath
@@ -246,6 +272,7 @@ you need to set them up yourself. Here's what I'm using:
```lua ```lua
local gitlab = require("gitlab") local gitlab = require("gitlab")
local gitlab_server = require("gitlab.server") local gitlab_server = require("gitlab.server")
vim.keymap.set("n", "glb", gitlab.choose_merge_request)
vim.keymap.set("n", "glr", gitlab.review) vim.keymap.set("n", "glr", gitlab.review)
vim.keymap.set("n", "gls", gitlab.summary) vim.keymap.set("n", "gls", gitlab.summary)
vim.keymap.set("n", "glA", gitlab.approve) vim.keymap.set("n", "glA", gitlab.approve)
@@ -266,6 +293,8 @@ vim.keymap.set("n", "glrd", gitlab.delete_reviewer)
vim.keymap.set("n", "glp", gitlab.pipeline) vim.keymap.set("n", "glp", gitlab.pipeline)
vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glo", gitlab.open_in_browser)
vim.keymap.set("n", "glM", gitlab.merge) vim.keymap.set("n", "glM", gitlab.merge)
vim.keymap.set("n", "glu", gitlab.copy_mr_url)
vim.keymap.set("n", "glP", gitlab.publish_all_drafts)
``` ```
For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api` For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api`

View File

@@ -8,6 +8,7 @@ syntax match ChevronDown ""
syntax match ChevronRight "" syntax match ChevronRight ""
syntax match Resolved /\s✓\s\?/ syntax match Resolved /\s✓\s\?/
syntax match Unresolved /\s-\s\?/ syntax match Unresolved /\s-\s\?/
syntax match Pencil //
highlight link Username GitlabUsername highlight link Username GitlabUsername
highlight link Date GitlabDate highlight link Date GitlabDate
@@ -15,5 +16,6 @@ highlight link ChevronDown GitlabChevron
highlight link ChevronRight GitlabChevron highlight link ChevronRight GitlabChevron
highlight link Resolved GitlabResolved highlight link Resolved GitlabResolved
highlight link Unresolved GitlabUnresolved highlight link Unresolved GitlabUnresolved
highlight link Pencil GitlabDraft
let b:current_syntax = "gitlab" let b:current_syntax = "gitlab"

View File

@@ -23,7 +23,7 @@ func updateAssigneesErr(pid interface{}, mergeRequest int, opt *gitlab.UpdateMer
func TestAssigneeHandler(t *testing.T) { func TestAssigneeHandler(t *testing.T) {
t.Run("Updates assignees", func(t *testing.T) { t.Run("Updates assignees", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}})
server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees})
data := serveRequest(t, server, request, AssigneeUpdateResponse{}) data := serveRequest(t, server, request, AssigneeUpdateResponse{})
assert(t, data.SuccessResponse.Message, "Assignees updated") assert(t, data.SuccessResponse.Message, "Assignees updated")
assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.SuccessResponse.Status, http.StatusOK)
@@ -31,7 +31,7 @@ func TestAssigneeHandler(t *testing.T) {
t.Run("Disallows non-PUT method", func(t *testing.T) { t.Run("Disallows non-PUT method", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/assignee", nil) request := makeRequest(t, http.MethodPost, "/mr/assignee", nil)
server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Status, http.StatusMethodNotAllowed) assert(t, data.Status, http.StatusMethodNotAllowed)
assert(t, data.Details, "Invalid request type") assert(t, data.Details, "Invalid request type")
@@ -40,7 +40,7 @@ func TestAssigneeHandler(t *testing.T) {
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}})
server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesErr}) server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Status, http.StatusInternalServerError) assert(t, data.Status, http.StatusInternalServerError)
assert(t, data.Message, "Could not modify merge request assignees") assert(t, data.Message, "Could not modify merge request assignees")
@@ -49,7 +49,7 @@ func TestAssigneeHandler(t *testing.T) {
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}})
server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesNon200}) server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesNon200})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Status, http.StatusSeeOther) assert(t, data.Status, http.StatusSeeOther)
assert(t, data.Message, "Could not modify merge request assignees") assert(t, data.Message, "Could not modify merge request assignees")

View File

@@ -40,6 +40,7 @@ type Client struct {
*gitlab.LabelsService *gitlab.LabelsService
*gitlab.AwardEmojiService *gitlab.AwardEmojiService
*gitlab.UsersService *gitlab.UsersService
*gitlab.DraftNotesService
} }
/* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ /* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */
@@ -116,6 +117,7 @@ func initGitlabClient() (error, *Client) {
LabelsService: client.Labels, LabelsService: client.Labels,
AwardEmojiService: client.AwardEmoji, AwardEmojiService: client.AwardEmoji,
UsersService: client.Users, UsersService: client.Users,
DraftNotesService: client.DraftNotes,
} }
} }

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"crypto/sha1"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -11,28 +10,8 @@ import (
) )
type PostCommentRequest struct { type PostCommentRequest struct {
Comment string `json:"comment"` Comment string `json:"comment"`
FileName string `json:"file_name"` PositionData
NewLine *int `json:"new_line,omitempty"`
OldLine *int `json:"old_line,omitempty"`
HeadCommitSHA string `json:"head_commit_sha"`
BaseCommitSHA string `json:"base_commit_sha"`
StartCommitSHA string `json:"start_commit_sha"`
Type string `json:"type"`
LineRange *LineRange `json:"line_range,omitempty"`
}
/* LineRange represents the range of a note. */
type LineRange struct {
StartRange *LinePosition `json:"start"`
EndRange *LinePosition `json:"end"`
}
/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */
type LinePosition struct {
Type string `json:"type"`
OldLine int `json:"old_line"`
NewLine int `json:"new_line"`
} }
type DeleteCommentRequest struct { type DeleteCommentRequest struct {
@@ -53,6 +32,15 @@ type CommentResponse struct {
Discussion *gitlab.Discussion `json:"discussion"` Discussion *gitlab.Discussion `json:"discussion"`
} }
/* CommentWithPosition is a comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based comments. */
type CommentWithPosition struct {
PositionData PositionData
}
func (comment CommentWithPosition) GetPositionData() PositionData {
return comment.PositionData
}
/* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ /* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */
func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) { func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -133,46 +121,10 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) {
/* If we are leaving a comment on a line, leave position. Otherwise, /* If we are leaving a comment on a line, leave position. Otherwise,
we are leaving a note (unlinked comment) */ we are leaving a note (unlinked comment) */
var friendlyName = "Note"
if postCommentRequest.FileName != "" {
friendlyName = "Comment"
opt.Position = &gitlab.PositionOptions{
PositionType: &postCommentRequest.Type,
StartSHA: &postCommentRequest.StartCommitSHA,
HeadSHA: &postCommentRequest.HeadCommitSHA,
BaseSHA: &postCommentRequest.BaseCommitSHA,
NewPath: &postCommentRequest.FileName,
OldPath: &postCommentRequest.FileName,
NewLine: postCommentRequest.NewLine,
OldLine: postCommentRequest.OldLine,
}
if postCommentRequest.LineRange != nil { if postCommentRequest.FileName != "" {
friendlyName = "Multiline Comment" commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData}
shaFormat := "%x_%d_%d" opt.Position = buildCommentPosition(commentWithPositionData)
startFilenameSha := fmt.Sprintf(
shaFormat,
sha1.Sum([]byte(postCommentRequest.FileName)),
postCommentRequest.LineRange.StartRange.OldLine,
postCommentRequest.LineRange.StartRange.NewLine,
)
endFilenameSha := fmt.Sprintf(
shaFormat,
sha1.Sum([]byte(postCommentRequest.FileName)),
postCommentRequest.LineRange.EndRange.OldLine,
postCommentRequest.LineRange.EndRange.NewLine,
)
opt.Position.LineRange = &gitlab.LineRangeOptions{
Start: &gitlab.LinePositionOptions{
Type: &postCommentRequest.LineRange.StartRange.Type,
LineCode: &startFilenameSha,
},
End: &gitlab.LinePositionOptions{
Type: &postCommentRequest.LineRange.EndRange.Type,
LineCode: &endFilenameSha,
},
}
}
} }
discussion, res, err := a.client.CreateMergeRequestDiscussion(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) discussion, res, err := a.client.CreateMergeRequestDiscussion(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
@@ -190,7 +142,7 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := CommentResponse{ response := CommentResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{
Message: fmt.Sprintf("%s created successfully", friendlyName), Message: "Comment created successfully",
Status: http.StatusOK, Status: http.StatusOK,
}, },
Comment: discussion.Notes[0], Comment: discussion.Notes[0],

82
cmd/comment_helpers.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"crypto/sha1"
"fmt"
"github.com/xanzy/go-gitlab"
)
/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */
type LinePosition struct {
Type string `json:"type"`
OldLine int `json:"old_line"`
NewLine int `json:"new_line"`
}
/* LineRange represents the range of a note. */
type LineRange struct {
StartRange *LinePosition `json:"start"`
EndRange *LinePosition `json:"end"`
}
/* PositionData represents the position of a comment or note (relative to a file diff) */
type PositionData struct {
FileName string `json:"file_name"`
NewLine *int `json:"new_line,omitempty"`
OldLine *int `json:"old_line,omitempty"`
HeadCommitSHA string `json:"head_commit_sha"`
BaseCommitSHA string `json:"base_commit_sha"`
StartCommitSHA string `json:"start_commit_sha"`
Type string `json:"type"`
LineRange *LineRange `json:"line_range,omitempty"`
}
/* RequestWithPosition is an interface that abstracts the handling of position data for a comment or a draft comment */
type RequestWithPosition interface {
GetPositionData() PositionData
}
/* buildCommentPosition takes a comment or draft comment request and builds the position data necessary for a location-based comment */
func buildCommentPosition(commentWithPositionData RequestWithPosition) *gitlab.PositionOptions {
positionData := commentWithPositionData.GetPositionData()
opt := &gitlab.PositionOptions{
PositionType: &positionData.Type,
StartSHA: &positionData.StartCommitSHA,
HeadSHA: &positionData.HeadCommitSHA,
BaseSHA: &positionData.BaseCommitSHA,
NewPath: &positionData.FileName,
OldPath: &positionData.FileName,
NewLine: positionData.NewLine,
OldLine: positionData.OldLine,
}
if positionData.LineRange != nil {
shaFormat := "%x_%d_%d"
startFilenameSha := fmt.Sprintf(
shaFormat,
sha1.Sum([]byte(positionData.FileName)),
positionData.LineRange.StartRange.OldLine,
positionData.LineRange.StartRange.NewLine,
)
endFilenameSha := fmt.Sprintf(
shaFormat,
sha1.Sum([]byte(positionData.FileName)),
positionData.LineRange.EndRange.OldLine,
positionData.LineRange.EndRange.NewLine,
)
opt.LineRange = &gitlab.LineRangeOptions{
Start: &gitlab.LinePositionOptions{
Type: &positionData.LineRange.StartRange.Type,
LineCode: &startFilenameSha,
},
End: &gitlab.LinePositionOptions{
Type: &positionData.LineRange.EndRange.Type,
LineCode: &endFilenameSha,
},
}
}
return opt
}

View File

@@ -25,12 +25,16 @@ func TestPostComment(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{}) request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{})
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
data := serveRequest(t, server, request, CommentResponse{}) data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Note created successfully") assert(t, data.SuccessResponse.Message, "Comment created successfully")
assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.SuccessResponse.Status, http.StatusOK)
}) })
t.Run("Creates a new comment", func(t *testing.T) { t.Run("Creates a new comment", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{FileName: "some_file.txt"}) request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{
PositionData: PositionData{
FileName: "some_file.txt",
},
})
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
data := serveRequest(t, server, request, CommentResponse{}) data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Comment created successfully") assert(t, data.SuccessResponse.Message, "Comment created successfully")
@@ -39,15 +43,17 @@ func TestPostComment(t *testing.T) {
t.Run("Creates a new multiline comment", func(t *testing.T) { t.Run("Creates a new multiline comment", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{ request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{
FileName: "some_file.txt", PositionData: PositionData{
LineRange: &LineRange{ FileName: "some_file.txt",
StartRange: &LinePosition{}, /* These would have real data */ LineRange: &LineRange{
EndRange: &LinePosition{}, StartRange: &LinePosition{}, /* These would have real data */
EndRange: &LinePosition{},
},
}, },
}) })
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
data := serveRequest(t, server, request, CommentResponse{}) data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Multiline Comment created successfully") assert(t, data.SuccessResponse.Message, "Comment created successfully")
assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.SuccessResponse.Status, http.StatusOK)
}) })

306
cmd/draft_notes.go Normal file
View File

@@ -0,0 +1,306 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/xanzy/go-gitlab"
)
/* The data coming from the client when creating a draft note is the same,
as when they are creating a normal comment, but the Gitlab
endpoints + resources we handle are different */
type PostDraftNoteRequest struct {
Comment string `json:"comment"`
PositionData
}
type UpdateDraftNoteRequest struct {
Note string `json:"note"`
Position gitlab.PositionOptions
}
type DraftNotePublishRequest struct {
Note int `json:"note,omitempty"`
PublishAll bool `json:"publish_all"`
}
type DraftNoteResponse struct {
SuccessResponse
DraftNote *gitlab.DraftNote `json:"draft_note"`
}
type ListDraftNotesResponse struct {
SuccessResponse
DraftNotes []*gitlab.DraftNote `json:"draft_notes"`
}
/* DraftNoteWithPosition is a draft comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based draft comments. */
type DraftNoteWithPosition struct {
PositionData PositionData
}
func (draftNote DraftNoteWithPosition) GetPositionData() PositionData {
return draftNote.PositionData
}
/* draftNoteHandler creates, edits, and deletes draft notes */
func (a *api) draftNoteHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
a.listDraftNotes(w, r)
case http.MethodPost:
a.postDraftNote(w, r)
case http.MethodPatch:
a.updateDraftNote(w, r)
case http.MethodDelete:
a.deleteDraftNote(w, r)
default:
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch, http.MethodGet))
handleError(w, InvalidRequestError{}, "Expected DELETE, GET, POST or PATCH", http.StatusMethodNotAllowed)
}
}
func (a *api) draftNotePublisher(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var draftNotePublishRequest DraftNotePublishRequest
err = json.Unmarshal(body, &draftNotePublishRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
var res *gitlab.Response
if draftNotePublishRequest.PublishAll {
res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId)
} else {
if draftNotePublishRequest.Note == 0 {
handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest)
return
}
res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note)
}
if err != nil {
handleError(w, err, "Could not publish draft note(s)", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Draft note(s) published",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* postDraftNote creates a draft note */
func (a *api) postDraftNote(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var postDraftNoteRequest PostDraftNoteRequest
err = json.Unmarshal(body, &postDraftNoteRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
opt := gitlab.CreateDraftNoteOptions{
Note: &postDraftNoteRequest.Comment,
// TODO: Support posting replies as drafts and rendering draft replies in the discussion tree
// instead of the notes tree
// InReplyToDiscussionID *string `url:"in_reply_to_discussion_id,omitempty" json:"in_reply_to_discussion_id,omitempty"`
}
if postDraftNoteRequest.FileName != "" {
draftNoteWithPosition := DraftNoteWithPosition{postDraftNoteRequest.PositionData}
opt.Position = buildCommentPosition(draftNoteWithPosition)
}
draftNote, res, err := a.client.CreateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
if err != nil {
handleError(w, err, "Could not create draft note", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not create draft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := DraftNoteResponse{
SuccessResponse: SuccessResponse{
Message: "Draft note created successfully",
Status: http.StatusOK,
},
DraftNote: draftNote,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* deleteDraftNote deletes a draft note */
func (a *api) deleteDraftNote(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/")
id, err := strconv.Atoi(suffix)
if err != nil {
handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest)
return
}
res, err := a.client.DeleteDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id)
if err != nil {
handleError(w, err, "Could not delete draft note", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not delete draft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Draft note deleted",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* updateDraftNote edits the text of a draft comment */
func (a *api) updateDraftNote(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/")
id, err := strconv.Atoi(suffix)
if err != nil {
handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var updateDraftNoteRequest UpdateDraftNoteRequest
err = json.Unmarshal(body, &updateDraftNoteRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
if updateDraftNoteRequest.Note == "" {
handleError(w, errors.New("Draft note text missing"), "Must provide draft note text", http.StatusBadRequest)
return
}
opt := gitlab.UpdateDraftNoteOptions{
Note: &updateDraftNoteRequest.Note,
Position: &updateDraftNoteRequest.Position,
}
draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt)
if err != nil {
handleError(w, err, "Could not update draft note", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not update draft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := DraftNoteResponse{
SuccessResponse: SuccessResponse{
Message: "Draft note updated",
Status: http.StatusOK,
},
DraftNote: draftNote,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* listDraftNotes lists all draft notes for the currently authenticated user */
func (a *api) listDraftNotes(w http.ResponseWriter, r *http.Request) {
opt := gitlab.ListDraftNotesOptions{}
draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
if err != nil {
handleError(w, err, "Could not get draft notes", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft/comment"}, "Could not get draft notes", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := ListDraftNotesResponse{
SuccessResponse: SuccessResponse{
Message: "Draft notes fetched successfully",
Status: http.StatusOK,
},
DraftNotes: draftNotes,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

197
cmd/draft_notes_test.go Normal file
View File

@@ -0,0 +1,197 @@
package main
import (
"errors"
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
func listDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) {
return []*gitlab.DraftNote{}, makeResponse(http.StatusOK), nil
}
func listDraftNotesErr(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) {
return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error")
}
func TestListDraftNotes(t *testing.T) {
t.Run("Lists all draft notes", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotes})
data := serveRequest(t, server, request, ListDraftNotesResponse{})
assert(t, data.SuccessResponse.Message, "Draft notes fetched successfully")
assert(t, data.SuccessResponse.Status, http.StatusOK)
})
t.Run("Handles error", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotesErr})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Could not get draft notes")
assert(t, data.Status, http.StatusInternalServerError)
assert(t, data.Details, "Some error")
})
}
func createDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil
}
func createDraftNoteErr(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error")
}
func TestPostDraftNote(t *testing.T) {
t.Run("Posts new draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{})
server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNote})
data := serveRequest(t, server, request, DraftNoteResponse{})
assert(t, data.SuccessResponse.Message, "Draft note created successfully")
assert(t, data.SuccessResponse.Status, http.StatusOK)
})
t.Run("Handles errors on draft note creation", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{})
server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNoteErr})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Could not create draft note")
assert(t, data.Status, http.StatusInternalServerError)
assert(t, data.Details, "Some error")
})
}
func deleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return makeResponse(http.StatusOK), nil
}
func deleteDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return makeResponse(http.StatusInternalServerError), errors.New("Something went wrong")
}
func TestDeleteDraftNote(t *testing.T) {
t.Run("Deletes draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote})
data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "Draft note deleted")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles error", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNoteErr})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Could not delete draft note")
assert(t, data.Status, http.StatusInternalServerError)
})
t.Run("Handles bad ID", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/abc", nil)
server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Could not parse draft note ID")
assert(t, data.Status, http.StatusBadRequest)
})
}
func updateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil
}
func TestEditDraftNote(t *testing.T) {
t.Run("Edits draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: "Some new note", Position: gitlab.PositionOptions{}})
server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote})
data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "Draft note updated")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles bad ID", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/abc", nil)
server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Could not parse draft note ID")
assert(t, data.Status, http.StatusBadRequest)
})
t.Run("Handles empty note", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: ""})
server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Must provide draft note text")
assert(t, data.Status, http.StatusBadRequest)
})
}
func publishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return makeResponse(http.StatusOK), nil
}
func publishDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return nil, errors.New("Some error")
}
func TestPublishDraftNote(t *testing.T) {
t.Run("Should publish a draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{Note: 3, PublishAll: false})
server, _ := createRouterAndApi(fakeClient{
publishDraftNote: publishDraftNote,
})
data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "Draft note(s) published")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles bad/missing ID", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false})
server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNote})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Must provide Note ID")
assert(t, data.Status, http.StatusBadRequest)
})
t.Run("Handles error", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false, Note: 3})
server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNoteErr})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Could not publish draft note(s)")
assert(t, data.Status, http.StatusInternalServerError)
})
}
func publishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return makeResponse(http.StatusOK), nil
}
func publishAllDraftNotesErr(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return nil, errors.New("Some error")
}
func TestPublishAllDraftNotes(t *testing.T) {
t.Run("Should publish all draft notes", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true})
server, _ := createRouterAndApi(fakeClient{
publishAllDraftNotes: publishAllDraftNotes,
})
data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "Draft note(s) published")
assert(t, data.Status, http.StatusOK)
})
t.Run("Should handle an error", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true})
server, _ := createRouterAndApi(fakeClient{
publishAllDraftNotes: publishAllDraftNotesErr,
})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Could not publish draft note(s)")
assert(t, data.Status, http.StatusInternalServerError)
})
}

View File

@@ -23,7 +23,7 @@ func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsO
func TestInfoHandler(t *testing.T) { func TestInfoHandler(t *testing.T) {
t.Run("Returns normal information", func(t *testing.T) { t.Run("Returns normal information", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil) request := makeRequest(t, http.MethodGet, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo})
data := serveRequest(t, server, request, InfoResponse{}) data := serveRequest(t, server, request, InfoResponse{})
assert(t, data.Info.Title, "Some Title") assert(t, data.Info.Title, "Some Title")
assert(t, data.SuccessResponse.Message, "Merge requests retrieved") assert(t, data.SuccessResponse.Message, "Merge requests retrieved")
@@ -32,21 +32,21 @@ func TestInfoHandler(t *testing.T) {
t.Run("Disallows non-GET method", func(t *testing.T) { t.Run("Disallows non-GET method", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/info", nil) request := makeRequest(t, http.MethodPost, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodGet) checkBadMethod(t, *data, http.MethodGet)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil) request := makeRequest(t, http.MethodGet, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoErr}) server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not get project info") checkErrorFromGitlab(t, *data, "Could not get project info")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil) request := makeRequest(t, http.MethodGet, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoNon200}) server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoNon200})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not get project info", "/mr/info") checkNon200(t, *data, "Could not get project info", "/mr/info")
}) })

View File

@@ -5,9 +5,10 @@ import (
) )
func main() { func main() {
log.SetFlags(0)
gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd) gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd)
if err != nil { if err != nil {
log.Fatalf("Failure initializing plugin with `git` commands: %v", err) log.Fatalf("Failure initializing plugin: %v", err)
} }
err, client := initGitlabClient() err, client := initGitlabClient()

55
cmd/merge_requests.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/xanzy/go-gitlab"
)
type ListMergeRequestResponse struct {
SuccessResponse
MergeRequests []*gitlab.MergeRequest `json:"merge_requests"`
}
func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed)
return
}
options := gitlab.ListProjectMergeRequestsOptions{
Scope: gitlab.Ptr("all"),
State: gitlab.Ptr("opened"),
}
mergeRequests, _, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options)
if err != nil {
handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError)
return
}
if len(mergeRequests) == 0 {
handleError(w, errors.New("No merge requests found"), "No merge requests found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
response := ListMergeRequestResponse{
SuccessResponse: SuccessResponse{
Message: "Merge requests fetched successfully",
Status: http.StatusOK,
},
MergeRequests: mergeRequests,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,45 @@
package main
import (
"errors"
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
func listProjectMergeRequests200(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil
}
func listProjectMergeRequestsEmpty(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
return []*gitlab.MergeRequest{}, &gitlab.Response{}, nil
}
func listProjectMergeRequestsErr(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
return nil, nil, errors.New("Some error")
}
func TestMergeRequestHandler(t *testing.T) {
t.Run("Should fetch merge requests", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/merge_requests", nil)
server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequests200})
data := serveRequest(t, server, request, ListMergeRequestResponse{})
assert(t, data.Message, "Merge requests fetched successfully")
assert(t, data.Status, http.StatusOK)
})
t.Run("Should handle an error", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/merge_requests", nil)
server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsErr})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "Failed to list merge requests")
assert(t, data.Status, http.StatusInternalServerError)
})
t.Run("Should handle not having any merge requests with 404", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/merge_requests", nil)
server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsEmpty})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Message, "No merge requests found")
assert(t, data.Status, http.StatusNotFound)
})
}

View File

@@ -8,11 +8,11 @@ import (
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
func acceptAndMergeFn(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { func acceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil
} }
func acceptAndMergeFnErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { func acceptMergeRequestErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return nil, nil, errors.New("Some error from Gitlab") return nil, nil, errors.New("Some error from Gitlab")
} }
@@ -23,7 +23,7 @@ func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptM
func TestAcceptAndMergeHandler(t *testing.T) { func TestAcceptAndMergeHandler(t *testing.T) {
t.Run("Accepts and merges a merge request", func(t *testing.T) { t.Run("Accepts and merges a merge request", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest})
data := serveRequest(t, server, request, SuccessResponse{}) data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "MR merged successfully") assert(t, data.Message, "MR merged successfully")
assert(t, data.Status, http.StatusOK) assert(t, data.Status, http.StatusOK)
@@ -31,21 +31,21 @@ func TestAcceptAndMergeHandler(t *testing.T) {
t.Run("Disallows non-POST methods", func(t *testing.T) { t.Run("Disallows non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/merge", AcceptMergeRequestRequest{}) request := makeRequest(t, http.MethodGet, "/mr/merge", AcceptMergeRequestRequest{})
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodPost) checkBadMethod(t, *data, http.MethodPost)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFnErr}) server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequestErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not merge MR") checkErrorFromGitlab(t, *data, "Could not merge MR")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200}) server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptAndMergeNon200})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not merge MR", "/mr/merge") checkNon200(t, *data, "Could not merge MR", "/mr/merge")
}) })

View File

@@ -134,6 +134,8 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
m.HandleFunc("/mr/label", a.withMr(a.labelHandler)) m.HandleFunc("/mr/label", a.withMr(a.labelHandler))
m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler)) m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler))
m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler)) m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler))
m.HandleFunc("/mr/draft_notes/", a.withMr(a.draftNoteHandler))
m.HandleFunc("/mr/draft_notes/publish", a.withMr(a.draftNotePublisher))
m.HandleFunc("/pipeline", a.pipelineHandler) m.HandleFunc("/pipeline", a.pipelineHandler)
m.HandleFunc("/pipeline/trigger/", a.pipelineHandler) m.HandleFunc("/pipeline/trigger/", a.pipelineHandler)
@@ -143,6 +145,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
m.HandleFunc("/job", a.jobHandler) m.HandleFunc("/job", a.jobHandler)
m.HandleFunc("/project/members", a.projectMembersHandler) m.HandleFunc("/project/members", a.projectMembersHandler)
m.HandleFunc("/shutdown", a.shutdownHandler) m.HandleFunc("/shutdown", a.shutdownHandler)
m.HandleFunc("/merge_requests", a.mergeRequestsHandler)
m.Handle("/ping", http.HandlerFunc(pingHandler)) m.Handle("/ping", http.HandlerFunc(pingHandler))

View File

@@ -19,10 +19,10 @@ The FakeHandlerClient is used to create a fake gitlab client for testing our han
type fakeClient struct { type fakeClient struct {
createMrFn func(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) createMrFn func(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
getMergeRequestFn func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) getMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
updateMergeRequestFn func(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) updateMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
acceptAndMergeFn func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) acceptMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
unapprorveMergeRequestFn func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) unapproveMergeRequest func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error)
getMergeRequestDiffVersions func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) getMergeRequestDiffVersions func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error)
approveMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) approveMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error)
@@ -41,6 +41,13 @@ type fakeClient struct {
listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error)
deleteMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) deleteMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
currentUser func(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) currentUser func(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error)
createDraftNote func(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
listDraftNotes func(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error)
deleteDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
updateDraftNote func(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
publishAllDraftNotes func(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
publishDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
listProjectMergeRequests func(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error)
} }
type Author struct { type Author struct {
@@ -58,19 +65,19 @@ func (f fakeClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeR
} }
func (f fakeClient) AcceptMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { func (f fakeClient) AcceptMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return f.acceptAndMergeFn(pid, mergeRequestIID, opt, options...) return f.acceptMergeRequest(pid, mergeRequestIID, opt, options...)
} }
func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return f.getMergeRequestFn(pid, mergeRequestIID, opt, options...) return f.getMergeRequest(pid, mergeRequestIID, opt, options...)
} }
func (f fakeClient) UpdateMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { func (f fakeClient) UpdateMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return f.updateMergeRequestFn(pid, mergeRequestIID, opt, options...) return f.updateMergeRequest(pid, mergeRequestIID, opt, options...)
} }
func (f fakeClient) UnapproveMergeRequest(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { func (f fakeClient) UnapproveMergeRequest(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return f.unapprorveMergeRequestFn(pid, mergeRequestIID, options...) return f.unapproveMergeRequest(pid, mergeRequestIID, options...)
} }
func (f fakeClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { func (f fakeClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) {
@@ -141,19 +148,47 @@ func (f fakeClient) DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeReq
return f.deleteMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, awardID) return f.deleteMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, awardID)
} }
func (f fakeClient) CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
return f.createDraftNote(pid, mergeRequestIID, opt)
}
func (f fakeClient) ListDraftNotes(pid interface{}, mergeRequestIID int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) {
return f.listDraftNotes(pid, mergeRequestIID, opt)
}
func (f fakeClient) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) { func (f fakeClient) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) {
return f.currentUser() return f.currentUser()
} }
/* This middleware function needs to return an ID for the rest of the handlers */
func (f fakeClient) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil
}
func (f fakeClient) CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) { func (f fakeClient) CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) {
return &gitlab.AwardEmoji{}, &gitlab.Response{}, nil return &gitlab.AwardEmoji{}, &gitlab.Response{}, nil
} }
func (f fakeClient) UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
return f.updateDraftNote(pid, mergeRequest, note, opt)
}
func (f fakeClient) DeleteDraftNote(pid interface{}, mergeRequestIID int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return f.deleteDraftNote(pid, mergeRequestIID, note)
}
func (f fakeClient) PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return f.publishDraftNote(pid, mergeRequest, note)
}
func (f fakeClient) PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return f.publishAllDraftNotes(pid, mergeRequest)
}
/* This middleware function needs to return an ID for the rest of the handlers */
func (f fakeClient) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
if f.listProjectMergeRequests == nil {
return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil
} else {
return f.listProjectMergeRequests(pid, opt)
}
}
/* The assert function is a helper function used to check two comparables */ /* The assert function is a helper function used to check two comparables */
func assert[T comparable](t *testing.T, got T, want T) { func assert[T comparable](t *testing.T, got T, want T) {
t.Helper() t.Helper()

View File

@@ -49,6 +49,12 @@ type ClientInterface interface {
CreateMergeRequestDiscussion(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) CreateMergeRequestDiscussion(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error)
UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error)
DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
AddMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) AddMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error)
RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error)

View File

@@ -51,9 +51,9 @@ QUICK START *gitlab.nvim.quick-start*
1. Install Go 1. Install Go
2. Add configuration (see Installation section) 2. Add configuration (see Installation section)
3. Checkout your feature branch: `git checkout feature-branch` 5. Run `:lua require("gitlab").choose_merge_request()`
4. Open Neovim
5. Run `:lua require("gitlab").review()` to open the reviewer pane This will checkout the branch locally, and up the plugin's reviewer pane.
INSTALLATION *gitlab.nvim.installation* INSTALLATION *gitlab.nvim.installation*
@@ -78,20 +78,25 @@ With Lazy:
< <
And with Packer: And with Packer:
>lua >lua
use { use {
'harrisoncramer/gitlab.nvim', "harrisoncramer/gitlab.nvim",
requires = { requires = {
"MunifTanjim/nui.nvim", "MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim", "nvim-lua/plenary.nvim",
"sindrets/diffview.nvim", "sindrets/diffview.nvim"
"stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers.
"nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree.
}, },
run = function() require("gitlab.server").build(true) end, build = function()
config = function() require("gitlab.server").build()
require("gitlab").setup() end,
end, branch = "develop",
} config = function()
require("diffview") -- We require some global state from diffview
local gitlab = require("gitlab")
gitlab.setup()
end,
}
< <
CONNECTING TO GITLAB *gitlab.nvim.connecting-to-gitlab* CONNECTING TO GITLAB *gitlab.nvim.connecting-to-gitlab*
@@ -122,6 +127,22 @@ directory that holds your `.gitlab.nvim` file.
The `connection_settings` block in the `state.lua` file will be used to The `connection_settings` block in the `state.lua` file will be used to
configure your connection to Gitlab. configure your connection to Gitlab.
In case even more control over the auth config is needed, there is the
possibility to override the `auth_provider` settings field. It should be
a function that returns the `token` as well as the `gitlab_url` value and
a nilable error value.
If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default.
Here an example how to use a custom `auth_provider`:
>lua
require("gitlab").setup({
auth_provider = function()
return "my_token", "https://custom.gitlab.instance.url", nil
end,
}
<
CONFIGURING THE PLUGIN *gitlab.nvim.configuring-the-plugin* CONFIGURING THE PLUGIN *gitlab.nvim.configuring-the-plugin*
@@ -175,6 +196,7 @@ you call this function with no values the defaults will be used:
toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions
keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling
toggle_resolved = "p" -- Toggles the resolved status of the whole discussion toggle_resolved = "p" -- Toggles the resolved status of the whole discussion
publish_draft = "P", -- Publishes the currently focused note/comment
position = "left", -- "top", "right", "bottom" or "left" position = "left", -- "top", "right", "bottom" or "left"
open_in_browser = "b" -- Jump to the URL of the current note/discussion open_in_browser = "b" -- Jump to the URL of the current note/discussion
copy_node_url = "u", -- Copy the URL of the current node to clipboard copy_node_url = "u", -- Copy the URL of the current node to clipboard
@@ -184,9 +206,14 @@ you call this function with no values the defaults will be used:
unresolved = '-', -- Symbol to show next to unresolved discussions unresolved = '-', -- Symbol to show next to unresolved discussions
tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file
toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name"
draft_mode = false, -- Whether comments are posted as drafts as part of a review
toggle_draft_mode = "D" -- Toggle between draft mode and regular mode, where comments are posted immediately
winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua)
-- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar.
}, },
choose_merge_request = {
open_reviewer = true, -- Open the reviewer window automatically after switching merge requests
},
info = { -- Show additional fields in the summary view info = { -- Show additional fields in the summary view
enabled = true, enabled = true,
horizontal = false, -- Display metadata to the left of the summary rather than underneath horizontal = false, -- Display metadata to the left of the summary rather than underneath
@@ -297,6 +324,16 @@ code block with prefilled code from the visual selection.
Just like the summary, all the different kinds of comments are saved via the Just like the summary, all the different kinds of comments are saved via the
`settings.popup.perform_action` keybinding. `settings.popup.perform_action` keybinding.
DRAFT NOTES *gitlab.nvim.draft-comments*
When you publish a "draft" of any of the above resources (configurable via the
`state.settings.comments.default_to_draft` setting) the comment will be added
to a review. You may publish all draft comments via the `gitlab.publish_all_drafts()`
function, and you can publish an individual comment or note by pressing the
`state.settings.discussion_tree.publish_draft` keybinding.
Draft notes do not support editing, replying, or emojis.
TEMPORARY REGISTERS *gitlab.nvim.temp-registers* TEMPORARY REGISTERS *gitlab.nvim.temp-registers*
While writing a note/comment/suggestion/reply, you may need to interrupt the While writing a note/comment/suggestion/reply, you may need to interrupt the
@@ -364,7 +401,7 @@ These labels will be visible in the summary panel, as long as you provide the
SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics* SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics*
By default when reviewing files, you will see diagnostics for comments that By default when reviewing files, you will see diagnostics for comments that
have been added to a review. These are the default settings: have been added to a review. These are the default settings:
>lua >lua
discussion_signs = { discussion_signs = {
@@ -379,7 +416,7 @@ have been added to a review. These are the default settings:
}, },
}, },
When the cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show()` When the cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show()`
You can also jump to discussion tree for the given comment: You can also jump to discussion tree for the given comment:
>lua >lua
@@ -527,6 +564,7 @@ in normal mode):
vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glo", gitlab.open_in_browser)
vim.keymap.set("n", "glM", gitlab.merge) vim.keymap.set("n", "glM", gitlab.merge)
vim.keymap.set("n", "glu", gitlab.copy_mr_url) vim.keymap.set("n", "glu", gitlab.copy_mr_url)
vim.keymap.set("n", "glP", gitlab.publish_all_drafts)
< <
TROUBLESHOOTING *gitlab.nvim.troubleshooting* TROUBLESHOOTING *gitlab.nvim.troubleshooting*
@@ -567,6 +605,21 @@ default arguments outlined under "Configuring the Plugin".
require("gitlab").setup({ port = 8392 }) require("gitlab").setup({ port = 8392 })
require("gitlab").setup({ discussion_tree = { blacklist = { "some_bot"} } }) require("gitlab").setup({ discussion_tree = { blacklist = { "some_bot"} } })
<
*gitlab.nvim.choose_merge_request*
gitlab.choose_merge_request({opts}) ~
Choose a merge request from a list of those open in your current project to review.
This command will automatically check out that branch locally, and optionally
open the reviewer pane. This is the default behavior.
>lua
require("gitlab").choose_merge_request()
require("gitlab").choose_merge_request({ open_reviewer = false })
<
Parameters: ~
• {opts}: (table|nil) Keyword arguments to configure the checkout.
• {open_reviewer}: (boolean) Whether to open the reviewer after
switching branches. True by default.
< <
*gitlab.nvim.review* *gitlab.nvim.review*
gitlab.review() ~ gitlab.review() ~
@@ -708,6 +761,14 @@ Once the discussion tree is open, a number of different keybindings are availabl
for interacting with different discussions. Please see the `settings.discussion_tree` for interacting with different discussions. Please see the `settings.discussion_tree`
section of the setup call for more information about different keybindings. section of the setup call for more information about different keybindings.
*gitlab.nvim.publish_all_drafts*
gitlab.publish_all_drafts() ~
Publishes all unpublished draft notes. Used to finish a review and make all notes and
comments visible.
>lua
require("gitlab").publish_all_drafts()
<
*gitlab.nvim.add_assignee* *gitlab.nvim.add_assignee*
gitlab.add_assignee() ~ gitlab.add_assignee() ~
@@ -829,6 +890,7 @@ execute and passed the data as an argument.
• "pipeline": Information about the current branch's • "pipeline": Information about the current branch's
pipeline. Returns and object with `latest_pipeline` and pipeline. Returns and object with `latest_pipeline` and
`jobs` as fields. `jobs` as fields.
• "draft_notes": The current user's unpublished notes
• {refresh}: (bool) Whether to re-fetch the data from Gitlab • {refresh}: (bool) Whether to re-fetch the data from Gitlab
or use the cached data locally, if available. or use the cached data locally, if available.
• {cb}: (function) The callback function that runs after all of the • {cb}: (function) The callback function that runs after all of the

View File

@@ -1,126 +1,46 @@
-- This module is responsible for creating new comments --- This module is responsible for creating new comments
-- in the reviewer's buffer. The reviewer will pass back --- in the reviewer's buffer. The reviewer will pass back
-- to this module the data required to make the API calls --- to this module the data required to make the API calls
local Popup = require("nui.popup") local Popup = require("nui.popup")
local Layout = require("nui.layout")
local state = require("gitlab.state") local state = require("gitlab.state")
local job = require("gitlab.job") local job = require("gitlab.job")
local u = require("gitlab.utils") local u = require("gitlab.utils")
local git = require("gitlab.git") local git = require("gitlab.git")
local discussions = require("gitlab.actions.discussions") local discussions = require("gitlab.actions.discussions")
local draft_notes = require("gitlab.actions.draft_notes")
local miscellaneous = require("gitlab.actions.miscellaneous") local miscellaneous = require("gitlab.actions.miscellaneous")
local reviewer = require("gitlab.reviewer") local reviewer = require("gitlab.reviewer")
local Location = require("gitlab.reviewer.location") local Location = require("gitlab.reviewer.location")
local M = {}
-- Popup creation is wrapped in a function so that it is performed *after* user local M = {
-- configuration has been merged with default configuration, not when this file is being current_win = nil,
-- required. start_line = nil,
local function create_comment_popup() end_line = nil,
return Popup(u.create_popup_state("Comment", state.settings.popup.comment)) }
end
-- This function will open a comment popup in order to create a comment on the changed/updated ---Fires the API that sends the comment data to the Go server, called when you "confirm" creation
-- line in the current MR ---via the M.settings.popup.perform_action keybinding
M.create_comment = function()
local has_clean_tree = git.has_clean_tree()
local is_modified = vim.api.nvim_buf_get_option(0, "modified")
if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then
u.notify(
"Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.",
vim.log.levels.WARN
)
return
end
local comment_popup = create_comment_popup()
comment_popup:mount()
state.set_popup_keymaps(comment_popup, function(text)
M.confirm_create_comment(text)
end, miscellaneous.attach_file, miscellaneous.editable_popup_opts)
end
---Create multiline comment for the last selection.
M.create_multiline_comment = function()
if not u.check_visual_mode() then
return
end
local comment_popup = create_comment_popup()
local start_line, end_line = u.get_visual_selection_boundaries()
comment_popup:mount()
state.set_popup_keymaps(comment_popup, function(text)
M.confirm_create_comment(text, { start_line = start_line, end_line = end_line })
end, miscellaneous.attach_file, miscellaneous.editable_popup_opts)
end
---Create comment prepopulated with gitlab suggestion
---https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html
M.create_comment_suggestion = function()
if not u.check_visual_mode() then
return
end
local comment_popup = create_comment_popup()
local start_line, end_line = u.get_visual_selection_boundaries()
local current_line = vim.api.nvim_win_get_cursor(0)[1]
local range = end_line - start_line
local backticks = "```"
local selected_lines = u.get_lines(start_line, end_line)
for line in ipairs(selected_lines) do
if string.match(line, "^```$") then
backticks = "````"
break
end
end
local suggestion_start
if start_line == current_line then
suggestion_start = backticks .. "suggestion:-0+" .. range
elseif end_line == current_line then
suggestion_start = backticks .. "suggestion:-" .. range .. "+0"
else
-- This should never happen afaik
u.notify("Unexpected suggestion position", vim.log.levels.ERROR)
return
end
suggestion_start = suggestion_start
local suggestion_lines = {}
table.insert(suggestion_lines, suggestion_start)
vim.list_extend(suggestion_lines, selected_lines)
table.insert(suggestion_lines, backticks)
comment_popup:mount()
vim.api.nvim_buf_set_lines(comment_popup.bufnr, 0, -1, false, suggestion_lines)
state.set_popup_keymaps(comment_popup, function(text)
if range > 0 then
M.confirm_create_comment(text, { start_line = start_line, end_line = end_line })
else
M.confirm_create_comment(text, nil)
end
end, miscellaneous.attach_file, miscellaneous.editable_popup_opts)
end
M.create_note = function()
local note_popup = Popup(u.create_popup_state("Note", state.settings.popup.note))
note_popup:mount()
state.set_popup_keymaps(note_popup, function(text)
M.confirm_create_comment(text, nil, true)
end, miscellaneous.attach_file, miscellaneous.editable_popup_opts)
end
---This function (settings.popup.perform_action) will send the comment to the Go server
---@param text string comment text ---@param text string comment text
---@param visual_range LineRange | nil range of visual selection or nil ---@param visual_range LineRange | nil range of visual selection or nil
---@param unlinked boolean | nil if true, the comment is not linked to a line ---@param unlinked boolean | nil if true, the comment is not linked to a line
M.confirm_create_comment = function(text, visual_range, unlinked) local confirm_create_comment = function(text, visual_range, unlinked)
if text == nil then if text == nil then
u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR)
return return
end end
local is_draft = M.draft_popup and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr))
if unlinked then if unlinked then
local body = { comment = text } local body = { comment = text }
job.run_job("/mr/comment", "POST", body, function(data) local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment"
u.notify("Note created!", vim.log.levels.INFO) job.run_job(endpoint, "POST", body, function(data)
discussions.add_discussion({ data = data, unlinked = true }) u.notify(is_draft and "Draft note created!" or "Note created!", vim.log.levels.INFO)
if is_draft then
draft_notes.add_draft_note({ draft_note = data.draft_note, unlinked = true })
else
discussions.add_discussion({ data = data, unlinked = true })
end
discussions.refresh() discussions.refresh()
end) end)
return return
@@ -153,11 +73,194 @@ M.confirm_create_comment = function(text, visual_range, unlinked)
line_range = location_data.line_range, line_range = location_data.line_range,
} }
job.run_job("/mr/comment", "POST", body, function(data) local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment"
u.notify("Comment created!", vim.log.levels.INFO) job.run_job(endpoint, "POST", body, function(data)
discussions.add_discussion({ data = data, unlinked = false }) u.notify(is_draft and "Draft comment created!" or "Comment created!", vim.log.levels.INFO)
if is_draft then
draft_notes.add_draft_note({ draft_note = data.draft_note, unlinked = false })
else
discussions.add_discussion({ data = data, has_position = true })
end
discussions.refresh() discussions.refresh()
end) end)
end end
---@class LayoutOpts
---@field ranged boolean
---@field unlinked boolean
---This function sets up the layout and popups needed to create a comment, note and
---multi-line comment. It also sets up the basic keybindings for switching between
---window panes, and for the non-primary sections.
---@param opts LayoutOpts|nil
---@return NuiLayout
local function create_comment_layout(opts)
if opts == nil then
opts = {}
end
M.current_win = vim.api.nvim_get_current_win()
M.comment_popup = Popup(u.create_popup_state("Comment", state.settings.popup.comment))
M.draft_popup = Popup(u.create_box_popup_state("Draft", false))
M.start_line, M.end_line = u.get_visual_selection_boundaries()
local internal_layout = Layout.Box({
Layout.Box(M.comment_popup, { grow = 1 }),
Layout.Box(M.draft_popup, { size = 3 }),
}, { dir = "col" })
local layout = Layout({
position = "50%",
relative = "editor",
size = {
width = "50%",
height = "55%",
},
}, internal_layout)
local popup_opts = {
action_before_close = true,
action_before_exit = false,
}
miscellaneous.set_cycle_popups_keymaps({ M.comment_popup, M.draft_popup })
local range = opts.ranged and { start_line = M.start_line, end_line = M.end_line } or nil
local unlinked = opts.unlinked or false
state.set_popup_keymaps(M.draft_popup, function()
local text = u.get_buffer_text(M.comment_popup.bufnr)
confirm_create_comment(text, range, unlinked)
vim.api.nvim_set_current_win(M.current_win)
end, miscellaneous.toggle_bool, popup_opts)
state.set_popup_keymaps(M.comment_popup, function(text)
confirm_create_comment(text, range, unlinked)
vim.api.nvim_set_current_win(M.current_win)
end, miscellaneous.attach_file, popup_opts)
vim.schedule(function()
local draft_mode = state.settings.discussion_tree.draft_mode
vim.api.nvim_buf_set_lines(M.draft_popup.bufnr, 0, -1, false, { u.bool_to_string(draft_mode) })
end)
return layout
end
--- This function will open a comment popup in order to create a comment on the changed/updated
--- line in the current MR
M.create_comment = function()
local has_clean_tree, err = git.has_clean_tree()
if err ~= nil then
return
end
local is_modified = vim.api.nvim_buf_get_option(0, "modified")
if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then
u.notify(
"Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.",
vim.log.levels.WARN
)
return
end
if not M.sha_exists() then
return
end
local layout = create_comment_layout()
layout:mount()
end
--- This function will open a multi-line comment popup in order to create a multi-line comment
--- on the changed/updated line in the current MR
M.create_multiline_comment = function()
if not u.check_visual_mode() then
return
end
if not M.sha_exists() then
return
end
local layout = create_comment_layout({ ranged = true, unlinked = false })
layout:mount()
end
--- This function will open a a popup to create a "note" (e.g. unlinked comment)
--- on the changed/updated line in the current MR
M.create_note = function()
local layout = create_comment_layout({ ranged = false, unlinked = true })
layout:mount()
end
---Given the current visually selected area of text, builds text to fill in the
---comment popup with a suggested change
---@return LineRange|nil
---@return integer
local build_suggestion = function()
local current_line = vim.api.nvim_win_get_cursor(0)[1]
M.start_line, M.end_line = u.get_visual_selection_boundaries()
local range_length = M.end_line - M.start_line
local backticks = "```"
local selected_lines = u.get_lines(M.start_line, M.end_line)
for line in ipairs(selected_lines) do
if string.match(line, "^```$") then
backticks = "````"
break
end
end
local suggestion_start
if M.start_line == current_line then
suggestion_start = backticks .. "suggestion:-0+" .. range_length
elseif M.end_line == current_line then
suggestion_start = backticks .. "suggestion:-" .. range_length .. "+0"
else
--- This should never happen afaik
u.notify("Unexpected suggestion position", vim.log.levels.ERROR)
return nil, 0
end
suggestion_start = suggestion_start
local suggestion_lines = {}
table.insert(suggestion_lines, suggestion_start)
vim.list_extend(suggestion_lines, selected_lines)
table.insert(suggestion_lines, backticks)
return suggestion_lines, range_length
end
--- This function will open a a popup to create a suggestion comment
--- on the changed/updated line in the current MR
--- See: https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html
M.create_comment_suggestion = function()
if not u.check_visual_mode() then
return
end
if not M.sha_exists() then
return
end
local suggestion_lines, range_length = build_suggestion()
local layout = create_comment_layout({ ranged = range_length > 0, unlinked = false })
layout:mount()
vim.schedule(function()
if suggestion_lines then
vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines)
end
end)
end
---Checks to see whether you are commenting on a valid buffer. The Diffview plugin names non-existent
---buffers as 'null'
---@return boolean
M.sha_exists = function()
if vim.fn.expand("%") == "diffview://null" then
u.notify("This file does not exist, please comment on the other buffer", vim.log.levels.ERROR)
return false
end
return true
end
return M return M

View File

@@ -0,0 +1,281 @@
-- This module contains code shared between at least two modules. This includes
-- actions common to multiple tree types, as well as general utility functions
-- that are specific to actions (like jumping to a file or opening a URL)
local List = require("gitlab.utils.list")
local u = require("gitlab.utils")
local reviewer = require("gitlab.reviewer")
local indicators_common = require("gitlab.indicators.common")
local common_indicators = require("gitlab.indicators.common")
local state = require("gitlab.state")
local M = {}
---Build note header from note
---@param note Note|DraftNote
---@return string
M.build_note_header = function(note)
if note.note then
return "@" .. state.USER.username .. " " .. ""
end
return "@" .. note.author.username .. " " .. u.time_since(note.created_at)
end
M.switch_can_edit_bufs = function(bool, ...)
local bufnrs = { ... }
---@param v integer
for _, v in ipairs(bufnrs) do
u.switch_can_edit_buf(v, bool)
vim.api.nvim_set_option_value("filetype", "gitlab", { buf = v })
end
end
---Takes in a chunk of text separated by new line characters and returns a lua table
---@param content string
---@return table
M.build_content = function(content)
local description_lines = u.lines_into_table(content)
table.insert(description_lines, "")
return description_lines
end
M.add_empty_titles = function()
local draft_notes = require("gitlab.actions.draft_notes")
local discussions = require("gitlab.actions.discussions")
local linked, unlinked, drafts =
List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions)),
List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.unlinked_discussions)),
List.new(u.ensure_table(state.DRAFT_NOTES))
local position_drafts = drafts:filter(function(note)
return draft_notes.has_position(note)
end)
local non_positioned_drafts = drafts:filter(function(note)
return not draft_notes.has_position(note)
end)
local fields = {
{
bufnr = discussions.linked_bufnr,
count = #linked + #position_drafts,
title = "No Discussions for this MR",
},
{
bufnr = discussions.unlinked_bufnr,
count = #unlinked + #non_positioned_drafts,
title = "No Notes (Unlinked Discussions) for this MR",
},
}
for _, v in ipairs(fields) do
if v.bufnr ~= nil then
M.switch_can_edit_bufs(true, v.bufnr)
local ns_id = vim.api.nvim_create_namespace("GitlabNamespace")
vim.cmd("highlight default TitleHighlight guifg=#787878")
-- Set empty title if applicable
if v.count == 0 then
vim.api.nvim_buf_set_lines(v.bufnr, 0, 1, false, { v.title })
local linnr = 1
vim.api.nvim_buf_set_extmark(
v.bufnr,
ns_id,
linnr - 1,
0,
{ end_row = linnr - 1, end_col = string.len(v.title), hl_group = "TitleHighlight" }
)
end
end
end
end
---@param tree NuiTree
M.get_url = function(tree)
local current_node = tree:get_node()
local note_node = M.get_note_node(tree, current_node)
if note_node == nil then
return
end
local url = note_node.url
if url == nil then
u.notify("Could not get URL of note", vim.log.levels.ERROR)
return
end
return url
end
---@param tree NuiTree
M.open_in_browser = function(tree)
local url = M.get_url(tree)
if url ~= nil then
u.open_in_browser(url)
end
end
---@param tree NuiTree
M.copy_node_url = function(tree)
local url = M.get_url(tree)
if url == nil then
vim.fn.setreg("+", url)
u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO)
end
end
-- For developers!
M.print_node = function(tree)
local current_node = tree:get_node()
vim.print(current_node)
end
---Check if type of node is note or note body
---@param node NuiTree.Node?
---@return boolean
M.is_node_note = function(node)
if node and (node.type == "note_body" or node.type == "note") then
return true
else
return false
end
end
---Get root node
---@param tree NuiTree
---@param node NuiTree.Node?
---@return NuiTree.Node?
M.get_root_node = function(tree, node)
if not node then
return nil
end
if node.type == "note_body" or node.type == "note" and not node.is_root then
local parent_id = node:get_parent_id()
return M.get_root_node(tree, tree:get_node(parent_id))
elseif node.is_root then
return node
end
end
---Get note node
---@param tree NuiTree
---@param node NuiTree.Node?
---@return NuiTree.Node?
M.get_note_node = function(tree, node)
if not node then
return nil
end
if node.type == "note_body" then
local parent_id = node:get_parent_id()
if parent_id == nil then
return node
end
return M.get_note_node(tree, tree:get_node(parent_id))
elseif node.type == "note" then
return node
end
end
---Takes a node and returns the line where the note is positioned in the new SHA. If
---the line is not in the new SHA, returns nil
---@param node NuiTree.Node
---@return number|nil
local function get_new_line(node)
---@type GitlabLineRange|nil
local range = node.range
if range == nil then
return node.new_line
end
local _, start_new_line = common_indicators.parse_line_code(range.start.line_code)
return start_new_line
end
---Takes a node and returns the line where the note is positioned in the old SHA. If
---the line is not in the old SHA, returns nil
---@param node NuiTree.Node
---@return number|nil
local function get_old_line(node)
---@type GitlabLineRange|nil
local range = node.range
if range == nil then
return node.old_line
end
local start_old_line, _ = common_indicators.parse_line_code(range.start.line_code)
return start_old_line
end
---@param id string|integer
---@return integer|nil
M.get_line_number = function(id)
---@type Discussion|DraftNote|nil
local d_or_n
d_or_n = List.new(state.DISCUSSION_DATA.discussions or {}):find(function(d)
return d.id == id
end) or List.new(state.DRAFT_NOTES or {}):find(function(d)
return d.id == id
end)
if d_or_n == nil then
return
end
local first_note = indicators_common.get_first_note(d_or_n)
return (indicators_common.is_new_sha(d_or_n) and first_note.position.new_line or first_note.position.old_line) or 1
end
---@param root_node NuiTree.Node
---@return integer|nil
M.get_line_number_from_node = function(root_node)
if root_node.range then
local start_old_line, start_new_line = common_indicators.parse_line_code(root_node.range.start.line_code)
return root_node.old_line and start_old_line or start_new_line
else
return M.get_line_number(root_node.id)
end
end
-- This function (settings.discussion_tree.jump_to_reviewer) will jump the cursor to the reviewer's location associated with the note. The implementation depends on the reviewer
M.jump_to_reviewer = function(tree, callback)
local node = tree:get_node()
local root_node = M.get_root_node(tree, node)
if root_node == nil then
u.notify("Could not get discussion node", vim.log.levels.ERROR)
return
end
local line_number = M.get_line_number_from_node(root_node)
if line_number == nil then
u.notify("Could not get line number", vim.log.levels.ERROR)
return
end
reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil)
callback()
end
-- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab
M.jump_to_file = function(tree)
local node = tree:get_node()
local root_node = M.get_root_node(tree, node)
if root_node == nil then
u.notify("Could not get discussion node", vim.log.levels.ERROR)
return
end
if root_node.file_name == nil then
u.notify("This comment was not left on a particular location", vim.log.levels.WARN)
return
end
vim.cmd.tabnew()
local line_number = get_new_line(root_node) or get_old_line(root_node)
if line_number == nil then
line_number = 1
end
local bufnr = vim.fn.bufnr(root_node.file_name)
if bufnr ~= -1 then
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 " .. root_node.file_name)
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
end
return M

View File

@@ -7,6 +7,7 @@ local job = require("gitlab.job")
local u = require("gitlab.utils") local u = require("gitlab.utils")
local git = require("gitlab.git") local git = require("gitlab.git")
local state = require("gitlab.state") local state = require("gitlab.state")
local common = require("gitlab.actions.common")
local miscellaneous = require("gitlab.actions.miscellaneous") local miscellaneous = require("gitlab.actions.miscellaneous")
---@class Mr ---@class Mr
@@ -42,6 +43,10 @@ end
--- continue working on it. --- continue working on it.
---@param args? Mr ---@param args? Mr
M.start = function(args) M.start = function(args)
if not git.current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then
return
end
if M.started then if M.started then
vim.ui.select({ "Yes", "No" }, { prompt = "Continue your previous MR?" }, function(choice) vim.ui.select({ "Yes", "No" }, { prompt = "Continue your previous MR?" }, function(choice)
if choice == "Yes" then if choice == "Yes" then
@@ -82,7 +87,10 @@ M.pick_target = function(mr)
end end
local function make_template_path(t) local function make_template_path(t)
local base_dir = git.base_dir() local base_dir, err = git.base_dir()
if err ~= nil then
return
end
return base_dir return base_dir
.. state.settings.file_separator .. state.settings.file_separator
.. ".gitlab" .. ".gitlab"
@@ -202,7 +210,7 @@ M.open_confirmation_popup = function(mr)
M.layout_visible = false M.layout_visible = false
end end
local description_lines = mr.description and M.build_description_lines(mr.description) or { "" } local description_lines = mr.description and common.build_content(mr.description) or { "" }
local delete_branch = u.get_first_non_nil_value({ mr.delete_branch, state.settings.create_mr.delete_branch }) local delete_branch = u.get_first_non_nil_value({ mr.delete_branch, state.settings.create_mr.delete_branch })
local squash = u.get_first_non_nil_value({ mr.squash, state.settings.create_mr.squash }) local squash = u.get_first_non_nil_value({ mr.squash, state.settings.create_mr.squash })
@@ -234,18 +242,6 @@ M.open_confirmation_popup = function(mr)
end) end)
end end
---Builds a lua list of strings that contain the MR description
M.build_description_lines = function(template_content)
local description_lines = {}
for line in u.split_by_new_lines(template_content) do
table.insert(description_lines, line)
end
-- TODO: @harrisoncramer Same as in lua/gitlab/actions/summary.lua:114
table.insert(description_lines, "")
return description_lines
end
---Prompts for interactive selection of a new target among remote-tracking branches ---Prompts for interactive selection of a new target among remote-tracking branches
M.select_new_target = function() M.select_new_target = function()
local bufnr = vim.api.nvim_get_current_buf() local bufnr = vim.api.nvim_get_current_buf()

View File

@@ -9,6 +9,7 @@ local labels = state.dependencies.labels
local project_members = state.dependencies.project_members local project_members = state.dependencies.project_members
local revisions = state.dependencies.revisions local revisions = state.dependencies.revisions
local latest_pipeline = state.dependencies.latest_pipeline local latest_pipeline = state.dependencies.latest_pipeline
local draft_notes = state.dependencies.draft_notes
M.data = function(resources, cb) M.data = function(resources, cb)
if type(resources) ~= "table" or type(cb) ~= "function" then if type(resources) ~= "table" or type(cb) ~= "function" then
@@ -23,6 +24,7 @@ M.data = function(resources, cb)
project_members = project_members, project_members = project_members,
revisions = revisions, revisions = revisions,
pipeline = latest_pipeline, pipeline = latest_pipeline,
draft_notes = draft_notes,
} }
local api_calls = {} local api_calls = {}

View File

@@ -79,9 +79,11 @@
---@field moji string ---@field moji string
---@class WinbarTable ---@class WinbarTable
---@field name string ---@field view_type string
---@field resolvable_discussions number ---@field resolvable_discussions number
---@field resolved_discussions number ---@field resolved_discussions number
---@field inline_draft_notes number
---@field unlinked_draft_notes number
---@field resolvable_notes number ---@field resolvable_notes number
---@field resolved_notes number ---@field resolved_notes number
---@field help_keymap string ---@field help_keymap string
@@ -120,3 +122,14 @@
---@field old_line integer | nil ---@field old_line integer | nil
---@field new_line integer | nil ---@field new_line integer | nil
---@field line_range ReviewerRangeInfo|nil ---@field line_range ReviewerRangeInfo|nil
---@class DraftNote
---@field note string
---@field id integer
---@field author_id integer
---@field merge_request_id integer
---@field resolve_discussion boolean
---@field discussion_id string -- This will always be ""
---@field commit_id string -- This will always be ""
---@field line_code string
---@field position NotePosition

File diff suppressed because it is too large Load Diff

View File

@@ -1,149 +1,22 @@
local state = require("gitlab.state") -- This module contains tree code specific to the discussion tree, that
-- is not used in the draft notes tree
local u = require("gitlab.utils") local u = require("gitlab.utils")
local common = require("gitlab.actions.common")
local state = require("gitlab.state")
local NuiTree = require("nui.tree") local NuiTree = require("nui.tree")
local NuiLine = require("nui.line")
local M = {} local M = {}
local attach_uuid = function(str)
return { text = str, id = u.uuid() }
end
---Create path node
---@param relative_path string
---@param full_path string
---@param child_nodes NuiTree.Node[]?
---@return NuiTree.Node
local function create_path_node(relative_path, full_path, child_nodes)
return NuiTree.Node({
text = relative_path,
path = full_path,
id = full_path,
type = "path",
icon = "",
icon_hl = "GitlabDirectoryIcon",
text_hl = "GitlabDirectory",
}, child_nodes or {})
end
---Create file name node
---@param file_name string
---@param full_file_path string
---@param child_nodes NuiTree.Node[]?
---@return NuiTree.Node
local function create_file_name_node(file_name, full_file_path, child_nodes)
local icon, icon_hl = u.get_icon(file_name)
return NuiTree.Node({
text = file_name,
file_name = full_file_path,
id = full_file_path,
type = "file_name",
icon = icon,
icon_hl = icon_hl,
text_hl = "GitlabFileName",
}, child_nodes or {})
end
---Sort list of nodes (in place) of type "path" or "file_name"
---@param nodes NuiTree.Node[]
local function sort_nodes(nodes)
table.sort(nodes, function(node1, node2)
if node1.type == "path" and node2.type == "path" then
return node1.path < node2.path
elseif node1.type == "file_name" and node2.type == "file_name" then
return node1.file_name < node2.file_name
elseif node1.type == "path" and node2.type == "file_name" then
return true
else
return false
end
end)
end
---Merge path nodes which have only single path child
---@param node NuiTree.Node
local function flatten_nodes(node)
if node.type ~= "path" then
return
end
for _, child in ipairs(node.__children) do
flatten_nodes(child)
end
if #node.__children == 1 and node.__children[1].type == "path" then
local child = node.__children[1]
node.__children = child.__children
node.id = child.id
node.path = child.path
node.text = node.text .. u.path_separator .. child.text
end
sort_nodes(node.__children)
end
---Build note header from note.
---@param note Note
---@return string
M.build_note_header = function(note)
return "@" .. note.author.username .. " " .. u.time_since(note.created_at)
end
---Build note node body
---@param note Note
---@param resolve_info table?
---@return string
---@return NuiTree.Node[]
local function build_note_body(note, resolve_info)
local text_nodes = {}
for bodyLine in u.split_by_new_lines(note.body) do
local line = attach_uuid(bodyLine)
table.insert(
text_nodes,
NuiTree.Node({
new_line = (type(note.position) == "table" and note.position.new_line),
old_line = (type(note.position) == "table" and note.position.old_line),
text = line.text,
id = line.id,
type = "note_body",
}, {})
)
end
local resolve_symbol = ""
if resolve_info ~= nil and resolve_info.resolvable then
resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved
or state.settings.discussion_tree.unresolved
end
local noteHeader = M.build_note_header(note) .. " " .. resolve_symbol
return noteHeader, text_nodes
end
---Build note node
---@param note Note
---@param resolve_info table?
---@return NuiTree.Node
---@return string
---@return NuiTree.Node[]
M.build_note = function(note, resolve_info)
local text, text_nodes = build_note_body(note, resolve_info)
local note_node = NuiTree.Node({
text = text,
id = note.id,
file_name = (type(note.position) == "table" and note.position.new_path),
new_line = (type(note.position) == "table" and note.position.new_line),
old_line = (type(note.position) == "table" and note.position.old_line),
url = state.INFO.web_url .. "#note_" .. note.id,
type = "note",
}, text_nodes)
return note_node, text, text_nodes
end
---Create nodes for NuiTree from discussions ---Create nodes for NuiTree from discussions
---@param items Discussion[] ---@param items Discussion[]
---@param unlinked boolean? False or nil means that discussions are linked to code lines ---@param unlinked boolean? False or nil means that discussions are linked to code lines
---@return NuiTree.Node[] ---@return NuiTree.Node[]
M.add_discussions_to_table = function(items, unlinked) M.add_discussions_to_table = function(items, unlinked)
local t = {} local t = {}
if items == vim.NIL then
items = {}
end
for _, discussion in ipairs(items) do for _, discussion in ipairs(items) do
local discussion_children = {} local discussion_children = {}
@@ -206,10 +79,85 @@ M.add_discussions_to_table = function(items, unlinked)
return t return t
end end
return M.create_node_list_by_file_name(t)
end
---Create path node
---@param relative_path string
---@param full_path string
---@param child_nodes NuiTree.Node[]?
---@return NuiTree.Node
local function create_path_node(relative_path, full_path, child_nodes)
return NuiTree.Node({
text = relative_path,
path = full_path,
id = full_path,
type = "path",
icon = "",
icon_hl = "GitlabDirectoryIcon",
text_hl = "GitlabDirectory",
}, child_nodes or {})
end
---Sort list of nodes (in place) of type "path" or "file_name"
---@param nodes NuiTree.Node[]
local function sort_nodes(nodes)
table.sort(nodes, function(node1, node2)
if node1.type == "path" and node2.type == "path" then
return node1.path < node2.path
elseif node1.type == "file_name" and node2.type == "file_name" then
return node1.file_name < node2.file_name
elseif node1.type == "path" and node2.type == "file_name" then
return true
else
return false
end
end)
end
---Merge path nodes which have only single path child
---@param node NuiTree.Node
local function flatten_nodes(node)
if node.type ~= "path" then
return
end
for _, child in ipairs(node.__children) do
flatten_nodes(child)
end
if #node.__children == 1 and node.__children[1].type == "path" then
local child = node.__children[1]
node.__children = child.__children
node.id = child.id
node.path = child.path
node.text = node.text .. u.path_separator .. child.text
end
sort_nodes(node.__children)
end
---Create file name node
---@param file_name string
---@param full_file_path string
---@param child_nodes NuiTree.Node[]?
---@return NuiTree.Node
local function create_file_name_node(file_name, full_file_path, child_nodes)
local icon, icon_hl = u.get_icon(file_name)
return NuiTree.Node({
text = file_name,
file_name = full_file_path,
id = full_file_path,
type = "file_name",
icon = icon,
icon_hl = icon_hl,
text_hl = "GitlabFileName",
}, child_nodes or {})
end
local create_disscussions_by_file_name = function(node_list)
-- Create all the folder and file name nodes. -- Create all the folder and file name nodes.
local discussion_by_file_name = {} local discussion_by_file_name = {}
local top_level_path_to_node = {} local top_level_path_to_node = {}
for _, node in ipairs(t) do
for _, node in ipairs(node_list) do
local path = "" local path = ""
local parent_node = nil local parent_node = nil
local path_parts = u.split_path(node.file_name) local path_parts = u.split_path(node.file_name)
@@ -274,13 +222,280 @@ M.add_discussions_to_table = function(items, unlinked)
end end
end end
return discussion_by_file_name
end
M.create_node_list_by_file_name = function(node_list)
-- Create all the folder and file name nodes.
local discussion_by_file_name = create_disscussions_by_file_name(node_list)
-- Flatten empty folders -- Flatten empty folders
for _, node in ipairs(discussion_by_file_name) do for _, node in ipairs(discussion_by_file_name) do
flatten_nodes(node) flatten_nodes(node)
end end
sort_nodes(discussion_by_file_name) sort_nodes(discussion_by_file_name)
return discussion_by_file_name return discussion_by_file_name
end end
local attach_uuid = function(str)
return { text = str, id = u.uuid() }
end
---Build note node body
---@param note Note|DraftNote
---@param resolve_info table?
---@return string
---@return NuiTree.Node[]
local function build_note_body(note, resolve_info)
local text_nodes = {}
for bodyLine in u.split_by_new_lines(note.body or note.note) do
local line = attach_uuid(bodyLine)
table.insert(
text_nodes,
NuiTree.Node({
new_line = (type(note.position) == "table" and note.position.new_line),
old_line = (type(note.position) == "table" and note.position.old_line),
text = line.text,
id = line.id,
type = "note_body",
}, {})
)
end
local resolve_symbol = ""
if resolve_info ~= nil and resolve_info.resolvable then
resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved
or state.settings.discussion_tree.unresolved
end
local noteHeader = common.build_note_header(note) .. " " .. resolve_symbol
return noteHeader, text_nodes
end
---Build note node
---@param note Note|DraftNote
---@param resolve_info table?
---@return NuiTree.Node
---@return string
---@return NuiTree.Node[]
M.build_note = function(note, resolve_info)
local text, text_nodes = build_note_body(note, resolve_info)
local note_node = NuiTree.Node({
text = text,
is_draft = note.note ~= nil,
id = note.id,
file_name = (type(note.position) == "table" and note.position.new_path),
new_line = (type(note.position) == "table" and note.position.new_line),
old_line = (type(note.position) == "table" and note.position.old_line),
url = state.INFO.web_url .. "#note_" .. note.id,
type = "note",
}, text_nodes)
return note_node, text, text_nodes
end
---Inspired by default func https://github.com/MunifTanjim/nui.nvim/blob/main/lua/nui/tree/util.lua#L38
M.nui_tree_prepare_node = function(node)
if not node.text then
error("missing node.text")
end
local texts = node.text
if type(node.text) ~= "table" or node.text.content then
texts = { node.text }
end
local lines = {}
for i, text in ipairs(texts) do
local line = NuiLine()
line:append(string.rep(" ", node._depth - 1))
if i == 1 and node:has_children() then
line:append(node:is_expanded() and "" or "")
if node.icon then
line:append(node.icon .. " ", node.icon_hl)
end
else
line:append(" ")
end
line:append(text, node.text_hl)
local note_id = tostring(node.is_root and node.root_note_id or node.id)
local e = require("gitlab.emoji")
---@type Emoji[]
local emojis = state.DISCUSSION_DATA.emojis[note_id]
local placed_emojis = {}
if emojis ~= nil then
for _, v in ipairs(emojis) do
local icon = e.emoji_map[v.name]
if icon ~= nil and not u.contains(placed_emojis, icon.moji) then
line:append(" ")
line:append(icon.moji)
table.insert(placed_emojis, icon.moji)
end
end
end
table.insert(lines, line)
end
return lines
end
---@class ToggleNodesOptions
---@field toggle_resolved boolean Whether to toggle resolved discussions.
---@field toggle_unresolved boolean Whether to toggle unresolved discussions.
---@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed.
---This function (settings.discussion_tree.toggle_nodes) expands/collapses all nodes and their children according to the opts.
---@param tree NuiTree
---@param winid integer
---@param unlinked boolean
---@param opts ToggleNodesOptions
M.toggle_nodes = function(winid, tree, unlinked, opts)
local current_node = tree:get_node()
if current_node == nil then
return
end
local root_node = common.get_root_node(tree, current_node)
for _, node in ipairs(tree:get_nodes()) do
if opts.toggle_resolved then
if
(unlinked and state.unlinked_discussion_tree.resolved_expanded)
or (not unlinked and state.discussion_tree.resolved_expanded)
then
M.collapse_recursively(tree, node, root_node, opts.keep_current_open, true)
else
M.expand_recursively(tree, node, true)
end
end
if opts.toggle_unresolved then
if
(unlinked and state.unlinked_discussion_tree.unresolved_expanded)
or (not unlinked and state.discussion_tree.unresolved_expanded)
then
M.collapse_recursively(tree, node, root_node, opts.keep_current_open, false)
else
M.expand_recursively(tree, node, false)
end
end
end
-- Reset states of resolved discussions after toggling
if opts.toggle_resolved then
if unlinked then
state.unlinked_discussion_tree.resolved_expanded = not state.unlinked_discussion_tree.resolved_expanded
else
state.discussion_tree.resolved_expanded = not state.discussion_tree.resolved_expanded
end
end
-- Reset states of unresolved discussions after toggling
if opts.toggle_unresolved then
if unlinked then
state.unlinked_discussion_tree.unresolved_expanded = not state.unlinked_discussion_tree.unresolved_expanded
else
state.discussion_tree.unresolved_expanded = not state.discussion_tree.unresolved_expanded
end
end
tree:render()
M.restore_cursor_position(winid, tree, current_node, root_node)
end
---Restore cursor position to the original node if possible
M.restore_cursor_position = function(winid, tree, original_node, root_node)
local _, line_number = tree:get_node("-" .. tostring(original_node.id))
-- If current_node is has been collapsed, get line number of root node instead
if line_number == nil and root_node then
_, line_number = tree:get_node("-" .. tostring(root_node.id))
end
if line_number ~= nil then
vim.api.nvim_win_set_cursor(winid, { line_number, 0 })
end
end
---This function (settings.discussion_tree.expand_recursively) expands a node and its children.
---@param tree NuiTree
---@param node NuiTree.Node
---@param is_resolved boolean If true, expand resolved discussions. If false, expand unresolved discussions.
M.expand_recursively = function(tree, node, is_resolved)
if node == nil then
return
end
if common.is_node_note(node) and common.get_root_node(tree, node).resolved == is_resolved then
node:expand()
end
local children = node:get_child_ids()
for _, child in ipairs(children) do
M.expand_recursively(tree, tree:get_node(child), is_resolved)
end
end
---This function (settings.discussion_tree.collapse_recursively) collapses a node and its children.
---@param tree NuiTree
---@param node NuiTree.Node
---@param current_root_node NuiTree.Node The root node of the current node.
---@param keep_current_open boolean If true, the current node stays open, even if it should otherwise be collapsed.
---@param is_resolved boolean If true, collapse resolved discussions. If false, collapse unresolved discussions.
M.collapse_recursively = function(tree, node, current_root_node, keep_current_open, is_resolved)
if node == nil then
return
end
local root_node = common.get_root_node(tree, node)
if common.is_node_note(node) and root_node.resolved == is_resolved then
if keep_current_open and root_node == current_root_node then
return
end
node:collapse()
end
local children = node:get_child_ids()
for _, child in ipairs(children) do
M.collapse_recursively(tree, tree:get_node(child), current_root_node, keep_current_open, is_resolved)
end
end
-- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children
M.toggle_node = function(tree)
local node = tree:get_node()
if node == nil then
return
end
-- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments
if node.type == "note_body" then
node = tree:get_node(node:get_parent_id())
end
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()
if common.is_node_note(node) then
for _, child in ipairs(children) do
tree:get_node(child):collapse()
end
end
else
if common.is_node_note(node) then
for _, child in ipairs(children) do
tree:get_node(child):expand()
end
end
node:expand()
end
tree:render()
end
return M return M

View File

@@ -1,6 +1,20 @@
local M = {}
local state = require("gitlab.state")
local List = require("gitlab.utils.list") local List = require("gitlab.utils.list")
local state = require("gitlab.state")
local M = {
bufnr_map = {
discussions = nil,
notes = nil,
},
current_view_type = state.settings.discussion_tree.default_view,
}
M.set_buffers = function(linked_bufnr, unlinked_bufnr)
M.bufnr_map = {
discussions = linked_bufnr,
notes = unlinked_bufnr,
}
end
---@param nodes Discussion[]|UnlinkedDiscussion[]|nil ---@param nodes Discussion[]|UnlinkedDiscussion[]|nil
---@return number, number ---@return number, number
@@ -30,36 +44,131 @@ local get_data = function(nodes)
return total_resolvable, total_resolved return total_resolvable, total_resolved
end end
---@param discussions Discussion[]|nil local function content()
---@param unlinked_discussions UnlinkedDiscussion[]|nil local resolvable_discussions, resolved_discussions = get_data(state.DISCUSSION_DATA.discussions)
---@param file_name string local resolvable_notes, resolved_notes = get_data(state.DISCUSSION_DATA.unlinked_discussions)
local function content(discussions, unlinked_discussions, file_name)
local resolvable_discussions, resolved_discussions = get_data(discussions) local draft_notes = require("gitlab.actions.draft_notes")
local resolvable_notes, resolved_notes = get_data(unlinked_discussions) local inline_draft_notes = List.new(state.DRAFT_NOTES):filter(draft_notes.has_position)
local unlinked_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note)
return not draft_notes.has_position(note)
end)
local t = { local t = {
name = file_name,
resolvable_discussions = resolvable_discussions, resolvable_discussions = resolvable_discussions,
resolved_discussions = resolved_discussions, resolved_discussions = resolved_discussions,
inline_draft_notes = #inline_draft_notes,
unlinked_draft_notes = #unlinked_draft_notes,
resolvable_notes = resolvable_notes, resolvable_notes = resolvable_notes,
resolved_notes = resolved_notes, resolved_notes = resolved_notes,
help_keymap = state.settings.help, help_keymap = state.settings.help,
} }
return state.settings.discussion_tree.winbar(t) return M.make_winbar(t)
end end
---This function updates the winbar ---This function updates the winbar
---@param discussions Discussion[] M.update_winbar = function()
---@param unlinked_discussions UnlinkedDiscussion[]
---@param base_title string
M.update_winbar = function(discussions, unlinked_discussions, base_title)
local d = require("gitlab.actions.discussions") local d = require("gitlab.actions.discussions")
local winId = d.split.winid if d.split == nil then
local c = content(discussions, unlinked_discussions, base_title) return
if vim.wo[winId] then end
vim.wo[winId].winbar = c
local win_id = d.split.winid
if win_id == nil then
return
end
if not vim.api.nvim_win_is_valid(win_id) then
return
end
local c = content()
vim.api.nvim_set_option_value("winbar", c, { scope = "local", win = win_id })
end
---Builds the title string for both sections, using the count of resolvable and draft nodes
---@param base_title string
---@param resolvable_count integer
---@param resolved_count integer
---@param drafts_count integer
---@return string
local add_drafts_and_resolvable = function(base_title, resolvable_count, resolved_count, drafts_count)
if resolvable_count ~= 0 then
base_title = base_title .. string.format(" (%d/%d resolved", resolvable_count, resolved_count)
end
if drafts_count ~= 0 then
if resolvable_count ~= 0 then
base_title = base_title .. string.format("; %d drafts)", drafts_count)
else
base_title = base_title .. string.format(" (%d drafts)", drafts_count)
end
elseif resolvable_count ~= 0 then
base_title = base_title .. ")"
end
return base_title
end
---@param t WinbarTable
M.make_winbar = function(t)
local discussion_title =
add_drafts_and_resolvable("Inline Comments", t.resolvable_discussions, t.resolved_discussions, t.inline_draft_notes)
local notes_title = add_drafts_and_resolvable("Notes", t.resolvable_notes, t.resolved_notes, t.unlinked_draft_notes)
-- Colorize the active tab
if M.current_view_type == "discussions" then
discussion_title = "%#Text#" .. discussion_title
notes_title = "%#Comment#" .. notes_title
elseif M.current_view_type == "notes" then
discussion_title = "%#Comment#" .. discussion_title
notes_title = "%#Text#" .. notes_title
end
local mode = M.get_mode()
-- Join everything together and return it
local separator = "%#Comment#|"
local end_section = "%="
local help = "%#Comment#Help: " .. t.help_keymap:gsub(" ", "<space>") .. " "
return string.format(
" %s %s %s %s %s %s %s",
discussion_title,
separator,
notes_title,
end_section,
mode,
separator,
help
)
end
---Returns a string for the winbar indicating the mode type, live or draft
---@return string
M.get_mode = function()
if state.settings.discussion_tree.draft_mode then
return "%#DiagnosticWarn#Draft Mode"
else
return "%#DiagnosticOK#Live Mode"
end end
end end
---Sets the current view type (if provided an argument)
---and then updates the view
---@param override any
M.switch_view_type = function(override)
if override then
M.current_view_type = override
else
if M.current_view_type == "discussions" then
M.current_view_type = "notes"
elseif M.current_view_type == "notes" then
M.current_view_type = "discussions"
end
end
vim.api.nvim_set_current_buf(M.bufnr_map[M.current_view_type])
M.update_winbar()
end
return M return M

View File

@@ -0,0 +1,239 @@
-- This module is responsible for CRUD operations for the draft notes in the discussion tree.
-- That includes things like editing existing draft notes in the tree, and
-- and deleting them. Normal notes and comments are managed separately,
-- under lua/gitlab/actions/discussions/init.lua
local winbar = require("gitlab.actions.discussions.winbar")
local diagnostics = require("gitlab.indicators.diagnostics")
local common = require("gitlab.actions.common")
local discussion_tree = require("gitlab.actions.discussions.tree")
local job = require("gitlab.job")
local NuiTree = require("nui.tree")
local List = require("gitlab.utils.list")
local u = require("gitlab.utils")
local state = require("gitlab.state")
local M = {}
---@class AddDraftNoteOpts table
---@field draft_note DraftNote
---@field unlinked boolean
---Adds a draft note to the draft notes state, then rebuilds the view
---@param opts AddDraftNoteOpts
M.add_draft_note = function(opts)
local new_draft_notes = u.ensure_table(state.DRAFT_NOTES)
table.insert(new_draft_notes, opts.draft_note)
state.DRAFT_NOTES = new_draft_notes
local discussions = require("gitlab.actions.discussions")
if opts.unlinked then
discussions.rebuild_unlinked_discussion_tree()
else
discussions.rebuild_discussion_tree()
end
winbar.update_winbar()
end
---Tells whether a draft note was left on a particular diff or is an unlinked note
---@param note DraftNote
M.has_position = function(note)
return note.position.new_path ~= nil or note.position.old_path ~= nil
end
---Returns a list of nodes to add to the discussion tree. Can filter and return only unlinked (note) nodes.
---@param unlinked boolean
---@return NuiTree.Node[]
M.add_draft_notes_to_table = function(unlinked)
local draft_notes = List.new(state.DRAFT_NOTES)
local draft_note_nodes = draft_notes
---@param note DraftNote
:filter(function(note)
if unlinked then
return not M.has_position(note)
end
return M.has_position(note)
end)
---@param note DraftNote
:map(function(note)
local _, root_text, root_text_nodes = discussion_tree.build_note(note)
return NuiTree.Node({
range = (type(note.position) == "table" and note.position.line_range or nil),
text = root_text,
type = "note",
is_root = true,
is_draft = true,
id = note.id,
root_note_id = note.id,
file_name = (type(note.position) == "table" and note.position.new_path or nil),
new_line = (type(note.position) == "table" and note.position.new_line or nil),
old_line = (type(note.position) == "table" and note.position.old_line or nil),
resolvable = false,
resolved = false,
url = state.INFO.web_url .. "#note_" .. note.id,
}, root_text_nodes)
end)
return draft_note_nodes
-- TODO: Combine draft_notes and normal discussion nodes in the complex discussion
-- tree. The code for that feature is a clusterfuck so this is difficult
-- if state.settings.discussion_tree.tree_type == "simple" then
-- return draft_note_nodes
-- end
end
---Send edits will actually send the edits to Gitlab and refresh the draft_notes tree
M.send_edits = function(note_id)
return function(text)
local all_notes = List.new(state.DRAFT_NOTES)
local the_note = all_notes:find(function(note)
return note.id == note_id
end)
local body = { note = text, position = the_note.position }
job.run_job(string.format("/mr/draft_notes/%d", note_id), "PATCH", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
local has_position = false
local new_draft_notes = all_notes:map(function(note)
if note.id == note_id then
has_position = M.has_position(note)
note.note = text
end
return note
end)
state.DRAFT_NOTES = new_draft_notes
local discussions = require("gitlab.actions.discussions")
if has_position then
discussions.rebuild_discussion_tree()
else
discussions.rebuild_unlinked_discussion_tree()
end
winbar.update_winbar()
end)
end
end
-- This function will actually send the deletion to Gitlab when you make a selection, and re-render the tree
M.send_deletion = function(tree)
local current_node = tree:get_node()
local note_node = common.get_note_node(tree, current_node)
local root_node = common.get_root_node(tree, current_node)
if note_node == nil or root_node == nil then
u.notify("Could not get note or root node", vim.log.levels.ERROR)
return
end
---@type integer
local note_id = note_node.is_root and root_node.id or note_node.id
job.run_job(string.format("/mr/draft_notes/%d", note_id), "DELETE", nil, function(data)
u.notify(data.message, vim.log.levels.INFO)
local has_position = false
local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note)
if note.id ~= note_id then
return true
else
has_position = M.has_position(note)
return false
end
end)
state.DRAFT_NOTES = new_draft_notes
local discussions = require("gitlab.actions.discussions")
if has_position then
discussions.rebuild_discussion_tree()
else
discussions.rebuild_unlinked_discussion_tree()
end
if state.settings.discussion_signs.enabled and state.DISCUSSION_DATA then
diagnostics.refresh_diagnostics()
end
winbar.update_winbar()
common.add_empty_titles()
end)
end
-- This function will trigger a popup prompting you to publish the current draft comment
M.publish_draft = function(tree)
vim.ui.select({ "Confirm", "Cancel" }, {
prompt = "Publish current draft comment?",
}, function(choice)
if choice == "Confirm" then
M.confirm_publish_draft(tree)
end
end)
end
-- This function will trigger a popup prompting you to publish all draft notes
M.publish_all_drafts = function()
vim.ui.select({ "Confirm", "Cancel" }, {
prompt = "Publish all drafts?",
}, function(choice)
if choice == "Confirm" then
M.confirm_publish_all_drafts()
end
end)
end
---Publishes all draft notes and comments. Re-renders all discussion views.
M.confirm_publish_all_drafts = function()
local body = { publish_all = true }
job.run_job("/mr/draft_notes/publish", "POST", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
state.DRAFT_NOTES = {}
local discussions = require("gitlab.actions.discussions")
discussions.refresh(function()
discussions.rebuild_discussion_tree()
discussions.rebuild_unlinked_discussion_tree()
winbar.update_winbar()
end)
end)
end
---Publishes the current draft note that is being hovered over in the tree,
---and then makes an API call to refresh the relevant data for that tree
---and re-render it.
---@param tree NuiTree
M.confirm_publish_draft = function(tree)
local current_node = tree:get_node()
local note_node = common.get_note_node(tree, current_node)
local root_node = common.get_root_node(tree, current_node)
if note_node == nil or root_node == nil then
u.notify("Could not get note or root node", vim.log.levels.ERROR)
return
end
---@type integer
local note_id = note_node.is_root and root_node.id or note_node.id
local body = { note = note_id, publish_all = false }
job.run_job("/mr/draft_notes/publish", "POST", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
local has_position = false
local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note)
if note.id ~= note_id then
return true
else
has_position = M.has_position(note)
return false
end
end)
state.DRAFT_NOTES = new_draft_notes
local discussions = require("gitlab.actions.discussions")
discussions.refresh(function()
if has_position then
discussions.rebuild_discussion_tree()
else
discussions.rebuild_unlinked_discussion_tree()
end
winbar.update_winbar()
end)
end)
end
return M

View File

@@ -0,0 +1,56 @@
local state = require("gitlab.state")
local reviewer = require("gitlab.reviewer")
local git = require("gitlab.git")
local u = require("gitlab.utils")
local M = {}
---@class SwitchOpts
---@field open_reviewer boolean
---Opens up a select menu that lets you choose a different merge request.
---@param opts SwitchOpts|nil
M.choose_merge_request = function(opts)
local has_clean_tree, clean_tree_err = git.has_clean_tree()
if clean_tree_err ~= nil then
return
elseif has_clean_tree ~= "" then
u.notify("Your local branch has changes, please stash or commit and push", vim.log.levels.ERROR)
return
end
if opts == nil then
opts = state.settings.choose_merge_request
end
vim.ui.select(state.MERGE_REQUESTS, {
prompt = "Choose Merge Request",
format_item = function(mr)
return string.format("%s (%s)", mr.title, mr.author.name)
end,
}, function(choice)
if not choice then
return
end
if reviewer.is_open then
reviewer.close()
end
vim.schedule(function()
local _, branch_switch_err = git.switch_branch(choice.source_branch)
if branch_switch_err ~= nil then
return
end
vim.schedule(function()
require("gitlab.server").restart(function()
if opts.open_reviewer then
require("gitlab").review()
end
end)
end)
end)
end)
end
return M

View File

@@ -143,10 +143,7 @@ M.see_logs = function()
return return
end end
local lines = {} local lines = u.lines_into_table(file)
for line in u.split_by_new_lines(file) do
table.insert(lines, line)
end
if #lines == 0 then if #lines == 0 then
u.notify("Log trace lines could not be parsed", vim.log.levels.ERROR) u.notify("Log trace lines could not be parsed", vim.log.levels.ERROR)

View File

@@ -3,7 +3,9 @@
-- send edits to the description back to Gitlab -- send edits to the description back to Gitlab
local Layout = require("nui.layout") local Layout = require("nui.layout")
local Popup = require("nui.popup") local Popup = require("nui.popup")
local git = require("gitlab.git")
local job = require("gitlab.job") local job = require("gitlab.job")
local common = require("gitlab.actions.common")
local u = require("gitlab.utils") local u = require("gitlab.utils")
local List = require("gitlab.utils.list") local List = require("gitlab.utils.list")
local state = require("gitlab.state") local state = require("gitlab.state")
@@ -28,7 +30,7 @@ M.summary = function()
end end
local title = state.INFO.title local title = state.INFO.title
local description_lines = M.build_description_lines() local description_lines = common.build_content(state.INFO.description)
local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" }
local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines) local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines)
@@ -69,22 +71,8 @@ M.summary = function()
vim.api.nvim_set_current_buf(description_popup.bufnr) vim.api.nvim_set_current_buf(description_popup.bufnr)
end) end)
end
-- Builds a lua list of strings that contain the MR description git.current_branch_up_to_date_on_remote(vim.log.levels.WARN)
M.build_description_lines = function()
local description_lines = {}
local description = state.INFO.description
for line in u.split_by_new_lines(description) do
table.insert(description_lines, line)
end
-- TODO: @harrisoncramer Not sure whether the following line should be here at all. It definitely
-- didn't belong into the for loop, since it inserted an empty line after each line. But maybe
-- there is a purpose for an empty line at the end of the buffer?
table.insert(description_lines, "")
return description_lines
end end
-- Builds a lua list of strings that contain metadata about the current MR. Only builds the -- Builds a lua list of strings that contain metadata about the current MR. Only builds the

View File

@@ -36,8 +36,9 @@ function async:fetch(dependencies, i, argTable)
end end
-- Call the API, set the data, and then call the next API -- Call the API, set the data, and then call the next API
job.run_job(dependency.endpoint, "GET", dependency.body, function(data) local body = dependency.body and dependency.body() or nil
state[dependency.state] = data[dependency.key] job.run_job(dependency.endpoint, dependency.method or "GET", body, function(data)
state[dependency.state] = dependency.key and data[dependency.key] or data
self:fetch(dependencies, i + 1, argTable) self:fetch(dependencies, i + 1, argTable)
end) end)
end end

View File

@@ -12,3 +12,4 @@ vim.api.nvim_set_hl(0, "GitlabDirectoryIcon", u.get_colors_for_group(discussion.
vim.api.nvim_set_hl(0, "GitlabFileName", u.get_colors_for_group(discussion.file_name)) vim.api.nvim_set_hl(0, "GitlabFileName", u.get_colors_for_group(discussion.file_name))
vim.api.nvim_set_hl(0, "GitlabResolved", u.get_colors_for_group(discussion.resolved)) vim.api.nvim_set_hl(0, "GitlabResolved", u.get_colors_for_group(discussion.resolved))
vim.api.nvim_set_hl(0, "GitlabUnresolved", u.get_colors_for_group(discussion.unresolved)) vim.api.nvim_set_hl(0, "GitlabUnresolved", u.get_colors_for_group(discussion.unresolved))
vim.api.nvim_set_hl(0, "GitlabDraft", u.get_colors_for_group(discussion.draft))

View File

@@ -1,4 +1,5 @@
local u = require("gitlab.utils") local u = require("gitlab.utils")
local common = require("gitlab.actions.common")
local state = require("gitlab.state") local state = require("gitlab.state")
local M = { local M = {
@@ -70,15 +71,15 @@ M.init_popup = function(tree, bufnr)
vim.api.nvim_create_autocmd({ "CursorHold" }, { vim.api.nvim_create_autocmd({ "CursorHold" }, {
callback = function() callback = function()
local node = tree:get_node() local node = tree:get_node()
if node == nil or not require("gitlab.actions.discussions").is_node_note(node) then if node == nil or not common.is_node_note(node) then
return return
end end
local note_node = require("gitlab.actions.discussions").get_note_node(tree, node) local note_node = common.get_note_node(tree, node)
local root_node = require("gitlab.actions.discussions").get_root_node(tree, node) local root_node = common.get_root_node(tree, node)
local note_id_str = tostring(note_node.is_root and root_node.root_note_id or note_node.id) local note_id_str = tostring(note_node.is_root and root_node.root_note_id or note_node.id)
local emojis = state.DISCUSSION_DATA.emojis
local emojis = require("gitlab.actions.discussions").emojis
local note_emojis = emojis[note_id_str] local note_emojis = emojis[note_id_str]
if note_emojis == nil then if note_emojis == nil then
return return

View File

@@ -1,11 +1,122 @@
local List = require("gitlab.utils.list")
local M = {} local M = {}
M.has_clean_tree = function() ---Runs a system command, captures the output (if it exists) and handles errors
return vim.fn.trim(vim.fn.system({ "git", "status", "--short", "--untracked-files=no" })) == "" ---@param command table
---@return string|nil, string|nil
local run_system = function(command)
local u = require("gitlab.utils")
local result = vim.fn.trim(vim.fn.system(command))
if vim.v.shell_error ~= 0 then
u.notify(result, vim.log.levels.ERROR)
return nil, result
end
return result, nil
end end
---Returns all branches for the current repository
---@return string|nil, string|nil
M.branches = function()
return run_system({ "git", "branch" })
end
---Checks whether the tree has any changes that haven't been pushed to the remote
---@return string|nil, string|nil
M.has_clean_tree = function()
return run_system({ "git", "status", "--short", "--untracked-files=no" })
end
---Gets the base directory of the current project
---@return string|nil, string|nil
M.base_dir = function() M.base_dir = function()
return vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) return run_system({ "git", "rev-parse", "--show-toplevel" })
end
---Switches the current project to the given branch
---@return string|nil, string|nil
M.switch_branch = function(branch)
return run_system({ "git", "checkout", "-q", branch })
end
---Return the name of the current branch
---@return string|nil, string|nil
M.get_current_branch = function()
return run_system({ "git", "branch", "--show-current" })
end
---Return the list of possible merge targets.
---@return table|nil
M.get_all_merge_targets = function()
local current_branch, err = M.get_current_branch()
if not current_branch or err ~= nil then
return
end
return List.new(M.get_all_remote_branches()):filter(function(branch)
return branch ~= current_branch
end)
end
---Return the list of names of all remote-tracking branches or an empty list.
---@return table, string|nil
M.get_all_remote_branches = function()
local all_branches, err = M.branches()
if err ~= nil then
return {}, err
end
if all_branches == nil then
return {}, "Something went wrong getting branches for this repository"
end
local u = require("gitlab.utils")
local lines = u.lines_into_table(all_branches)
return List.new(lines)
:map(function(line)
-- Trim "origin/"
return line:match("origin/(%S+)")
end)
:filter(function(branch)
-- Don't include the HEAD pointer
return not branch:match("^HEAD$")
end)
end
---Return whether something
---@param current_branch string
---@return string|nil, string|nil
M.contains_branch = function(current_branch)
return run_system({ "git", "branch", "-r", "--contains", current_branch })
end
---Returns true if `branch` is up-to-date on remote, false otherwise.
---@param log_level integer
---@return boolean|nil
M.current_branch_up_to_date_on_remote = function(log_level)
local current_branch = M.get_current_branch()
local handle = io.popen("git branch -r --contains " .. current_branch .. " 2>&1")
if not handle then
require("gitlab.utils").notify("Error running 'git branch' command.", vim.log.levels.ERROR)
return nil
end
local remote_branches_with_current_head = {}
for line in handle:lines() do
table.insert(remote_branches_with_current_head, line)
end
handle:close()
local current_head_on_remote = List.new(remote_branches_with_current_head):filter(function(line)
return line == " origin/" .. current_branch
end)
local remote_up_to_date = #current_head_on_remote == 1
if not remote_up_to_date then
require("gitlab.utils").notify(
"You have local commits that are not on origin. Have you forgotten to push?",
log_level
)
end
return remote_up_to_date
end end
return M return M

View File

@@ -5,30 +5,57 @@ local List = require("gitlab.utils.list")
local M = {} local M = {}
---@class NoteWithValues
---@field position NotePosition
---@field resolvable boolean|nil
---@field resolved boolean|nil
---@field created_at string|nil
---@param note NoteWithValues
---@param file string
---@return boolean
local filter_discussions_and_notes = function(note, file)
---Do not include unlinked notes
return note.position ~= nil
and (note.position.new_path == file or note.position.old_path == file)
---Skip resolved discussions if user wants to
and not (state.settings.discussion_signs.skip_resolved_discussion and note.resolvable and note.resolved)
---Skip discussions from old revisions
and not (
state.settings.discussion_signs.skip_old_revision_discussion
and u.from_iso_format_date_to_timestamp(note.created_at)
<= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at)
)
end
---Filter all discussions which are relevant for currently visible signs and diagnostics. ---Filter all discussions which are relevant for currently visible signs and diagnostics.
---@return Discussion[] ---@return Discussion|DraftNote[]
M.filter_placeable_discussions = function(all_discussions) M.filter_placeable_discussions = function()
if type(all_discussions) ~= "table" then local discussions = u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions or {})
return {} if type(discussions) ~= "table" then
discussions = {}
end end
local draft_notes = u.ensure_table(state.DRAFT_NOTES)
if type(draft_notes) ~= "table" then
draft_notes = {}
end
local file = reviewer.get_current_file() local file = reviewer.get_current_file()
if not file then if not file then
return {} return {}
end end
return List.new(all_discussions):filter(function(discussion)
local filtered_discussions = List.new(discussions):filter(function(discussion)
local first_note = discussion.notes[1] local first_note = discussion.notes[1]
return type(first_note.position) == "table" return type(first_note.position) == "table" and filter_discussions_and_notes(first_note, file)
--Do not include unlinked notes
and (first_note.position.new_path == file or first_note.position.old_path == file)
--Skip resolved discussions if user wants to
and not (state.settings.discussion_signs.skip_resolved_discussion and first_note.resolvable and first_note.resolved)
--Skip discussions from old revisions
and not (
state.settings.discussion_signs.skip_old_revision_discussion
and u.from_iso_format_date_to_timestamp(first_note.created_at)
<= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at)
)
end) end)
local filtered_draft_notes = List.new(draft_notes):filter(function(note)
return filter_discussions_and_notes(note, file)
end)
return u.join(filtered_discussions, filtered_draft_notes)
end end
M.parse_line_code = function(line_code) M.parse_line_code = function(line_code)
@@ -37,24 +64,24 @@ M.parse_line_code = function(line_code)
return tonumber(old_line), tonumber(new_line) return tonumber(old_line), tonumber(new_line)
end end
---@param discussion Discussion ---@param d_or_n Discussion|DraftNote
---@return boolean ---@return boolean
M.is_old_sha = function(discussion) M.is_old_sha = function(d_or_n)
local first_note = discussion.notes[1] local first_note = M.get_first_note(d_or_n)
return first_note.position.old_line ~= nil return first_note.position.old_line ~= nil
end end
---@param discussion Discussion ---@param discussion Discussion|DraftNote
---@return boolean ---@return boolean
M.is_new_sha = function(discussion) M.is_new_sha = function(discussion)
return not M.is_old_sha(discussion) return not M.is_old_sha(discussion)
end end
---@param discussion Discussion ---@param d_or_n Discussion|DraftNote
---@return boolean ---@return boolean
M.is_single_line = function(discussion) M.is_single_line = function(d_or_n)
local first_note = discussion.notes[1] local first_note = M.get_first_note(d_or_n)
local line_range = first_note.position.line_range local line_range = first_note.position and first_note.position.line_range
return line_range == nil return line_range == nil
end end
@@ -64,10 +91,10 @@ M.is_multi_line = function(discussion)
return not M.is_single_line(discussion) return not M.is_single_line(discussion)
end end
---@param discussion Discussion ---@param d_or_n Discussion|DraftNote
---@return Note ---@return Note|DraftNote
M.get_first_note = function(discussion) M.get_first_note = function(d_or_n)
return discussion.notes[1] return d_or_n.notes and d_or_n.notes[1] or d_or_n
end end
return M return M

View File

@@ -1,7 +1,7 @@
local u = require("gitlab.utils") local u = require("gitlab.utils")
local diffview_lib = require("diffview.lib") local diffview_lib = require("diffview.lib")
local discussion_tree = require("gitlab.actions.discussions.tree") local indicators_common = require("gitlab.indicators.common")
local common = require("gitlab.indicators.common") local actions_common = require("gitlab.actions.common")
local List = require("gitlab.utils.list") local List = require("gitlab.utils.list")
local state = require("gitlab.state") local state = require("gitlab.state")
local discussion_sign_name = "gitlab_discussion" local discussion_sign_name = "gitlab_discussion"
@@ -24,19 +24,23 @@ local display_opts = {
---Takes some range information and data about a discussion ---Takes some range information and data about a discussion
---and creates a diagnostic to be placed in the reviewer ---and creates a diagnostic to be placed in the reviewer
---@param range_info table ---@param range_info table
---@param discussion Discussion ---@param d_or_n Discussion|DraftNote
---@return Diagnostic ---@return Diagnostic
local function create_diagnostic(range_info, discussion) local function create_diagnostic(range_info, d_or_n)
local message = "" local first_note = indicators_common.get_first_note(d_or_n)
for _, note in ipairs(discussion.notes) do local header = actions_common.build_note_header(first_note)
message = message .. discussion_tree.build_note_header(note) .. "\n" .. note.body .. "\n" local message = header
if d_or_n.notes then
for _, note in ipairs(d_or_n.notes or {}) do
message = message .. actions_common.build_note_header(note) .. "\n" .. note.body .. "\n"
end
end end
local diagnostic = { local diagnostic = {
message = message, message = message,
col = 0, col = 0,
severity = state.settings.discussion_signs.severity, severity = state.settings.discussion_signs.severity,
user_data = { discussion_id = discussion.id, header = discussion_tree.build_note_header(discussion.notes[1]) }, user_data = { discussion_id = d_or_n.id, header = header },
source = "gitlab", source = "gitlab",
code = "gitlab.nvim", code = "gitlab.nvim",
} }
@@ -44,38 +48,37 @@ local function create_diagnostic(range_info, discussion)
end end
---Creates a single line diagnostic ---Creates a single line diagnostic
---@param discussion Discussion ---@param d_or_n Discussion|DraftNote
---@return Diagnostic ---@return Diagnostic
local create_single_line_diagnostic = function(discussion) local create_single_line_diagnostic = function(d_or_n)
local first_note = discussion.notes[1] local linnr = actions_common.get_line_number(d_or_n.id)
local linnr = (common.is_new_sha(discussion) and first_note.position.new_line or first_note.position.old_line) or 1
return create_diagnostic({ return create_diagnostic({
lnum = linnr - 1, lnum = linnr - 1,
}, discussion) }, d_or_n)
end end
---Creates a mutli-line line diagnostic ---Creates a mutli-line line diagnostic
---@param discussion Discussion ---@param d_or_n Discussion|DraftNote
---@return Diagnostic ---@return Diagnostic
local create_multiline_diagnostic = function(discussion) local create_multiline_diagnostic = function(d_or_n)
local first_note = discussion.notes[1] local first_note = indicators_common.get_first_note(d_or_n)
local line_range = first_note.position.line_range local line_range = first_note.position.line_range
if line_range == nil then if line_range == nil then
error("Parsing multi-line comment but note does not contain line range") error("Parsing multi-line comment but note does not contain line range")
end end
local start_old_line, start_new_line = common.parse_line_code(line_range.start.line_code) local start_old_line, start_new_line = indicators_common.parse_line_code(line_range.start.line_code)
if common.is_new_sha(discussion) then if indicators_common.is_new_sha(d_or_n) then
return create_diagnostic({ return create_diagnostic({
lnum = start_new_line - 1, lnum = start_new_line - 1,
end_lnum = first_note.position.new_line - 1, end_lnum = first_note.position.new_line - 1,
}, discussion) }, d_or_n)
else else
return create_diagnostic({ return create_diagnostic({
lnum = start_old_line - 1, lnum = start_old_line - 1,
end_lnum = first_note.position.old_line - 1, end_lnum = first_note.position.old_line - 1,
}, discussion) }, d_or_n)
end end
end end
@@ -106,12 +109,11 @@ local set_diagnostics_in_old_sha = function(namespace, diagnostics, opts)
end end
---Refresh the diagnostics for the currently reviewed file ---Refresh the diagnostics for the currently reviewed file
---@param discussions Discussion[] M.refresh_diagnostics = function()
M.refresh_diagnostics = function(discussions)
local ok, err = pcall(function() local ok, err = pcall(function()
require("gitlab.indicators.signs").clear_signs() require("gitlab.indicators.signs").clear_signs()
M.clear_diagnostics() M.clear_diagnostics()
local filtered_discussions = common.filter_placeable_discussions(discussions) local filtered_discussions = indicators_common.filter_placeable_discussions()
if filtered_discussions == nil then if filtered_discussions == nil then
return return
end end
@@ -133,9 +135,9 @@ end
---@param discussions Discussion[] ---@param discussions Discussion[]
---@return DiagnosticTable[] ---@return DiagnosticTable[]
M.parse_new_diagnostics = function(discussions) M.parse_new_diagnostics = function(discussions)
local new_diagnostics = List.new(discussions):filter(common.is_new_sha) local new_diagnostics = List.new(discussions):filter(indicators_common.is_new_sha)
local single_line = new_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) local single_line = new_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic)
local multi_line = new_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) local multi_line = new_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic)
return u.combine(single_line, multi_line) return u.combine(single_line, multi_line)
end end
@@ -144,9 +146,9 @@ end
---@param discussions Discussion[] ---@param discussions Discussion[]
---@return DiagnosticTable[] ---@return DiagnosticTable[]
M.parse_old_diagnostics = function(discussions) M.parse_old_diagnostics = function(discussions)
local old_diagnostics = List.new(discussions):filter(common.is_old_sha) local old_diagnostics = List.new(discussions):filter(indicators_common.is_old_sha)
local single_line = old_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) local single_line = old_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic)
local multi_line = old_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) local multi_line = old_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic)
return u.combine(single_line, multi_line) return u.combine(single_line, multi_line)
end end

View File

@@ -6,6 +6,7 @@ local emoji = require("gitlab.emoji")
local state = require("gitlab.state") local state = require("gitlab.state")
local reviewer = require("gitlab.reviewer") local reviewer = require("gitlab.reviewer")
local discussions = require("gitlab.actions.discussions") local discussions = require("gitlab.actions.discussions")
local merge_requests = require("gitlab.actions.merge_requests")
local merge = require("gitlab.actions.merge") local merge = require("gitlab.actions.merge")
local summary = require("gitlab.actions.summary") local summary = require("gitlab.actions.summary")
local data = require("gitlab.actions.data") local data = require("gitlab.actions.data")
@@ -14,6 +15,7 @@ local comment = require("gitlab.actions.comment")
local pipeline = require("gitlab.actions.pipeline") local pipeline = require("gitlab.actions.pipeline")
local create_mr = require("gitlab.actions.create_mr") local create_mr = require("gitlab.actions.create_mr")
local approvals = require("gitlab.actions.approvals") local approvals = require("gitlab.actions.approvals")
local draft_notes = require("gitlab.actions.draft_notes")
local labels = require("gitlab.actions.labels") local labels = require("gitlab.actions.labels")
local user = state.dependencies.user local user = state.dependencies.user
@@ -22,6 +24,9 @@ local labels_dep = state.dependencies.labels
local project_members = state.dependencies.project_members local project_members = state.dependencies.project_members
local latest_pipeline = state.dependencies.latest_pipeline local latest_pipeline = state.dependencies.latest_pipeline
local revisions = state.dependencies.revisions local revisions = state.dependencies.revisions
local merge_requests_dep = state.dependencies.merge_requests
local draft_notes_dep = state.dependencies.draft_notes
local discussion_data = state.dependencies.discussion_data
return { return {
setup = function(args) setup = function(args)
@@ -63,15 +68,20 @@ return {
pipeline = async.sequence({ latest_pipeline }, pipeline.open), pipeline = async.sequence({ latest_pipeline }, pipeline.open),
merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge),
-- Discussion Tree Actions 🌴 -- Discussion Tree Actions 🌴
toggle_discussions = async.sequence({ info, user }, discussions.toggle), toggle_discussions = async.sequence({
edit_comment = async.sequence({ info }, discussions.edit_comment), info,
delete_comment = async.sequence({ info }, discussions.delete_comment), user,
draft_notes_dep,
discussion_data,
}, discussions.toggle),
toggle_resolved = async.sequence({ info }, discussions.toggle_discussion_resolved), toggle_resolved = async.sequence({ info }, discussions.toggle_discussion_resolved),
publish_all_drafts = draft_notes.publish_all_drafts,
reply = async.sequence({ info }, discussions.reply), reply = async.sequence({ info }, discussions.reply),
-- Other functions 🤷 -- Other functions 🤷
state = state, state = state,
data = data.data, data = data.data,
print_settings = state.print_settings, print_settings = state.print_settings,
choose_merge_request = async.sequence({ merge_requests_dep }, merge_requests.choose_merge_request),
open_in_browser = async.sequence({ info }, function() open_in_browser = async.sequence({ info }, function()
local web_url = u.get_web_url() local web_url = u.get_web_url()
if web_url ~= nil then if web_url ~= nil then

View File

@@ -12,6 +12,7 @@ local async = require("diffview.async")
local diffview_lib = require("diffview.lib") local diffview_lib = require("diffview.lib")
local M = { local M = {
is_open = false,
bufnr = nil, bufnr = nil,
tabnr = nil, tabnr = nil,
stored_win = nil, stored_win = nil,
@@ -41,12 +42,17 @@ M.open = function()
end end
local diffview_open_command = "DiffviewOpen" local diffview_open_command = "DiffviewOpen"
local has_clean_tree = git.has_clean_tree() local has_clean_tree, err = git.has_clean_tree()
if err ~= nil then
return
end
if state.settings.reviewer_settings.diffview.imply_local and has_clean_tree then if state.settings.reviewer_settings.diffview.imply_local and has_clean_tree then
diffview_open_command = diffview_open_command .. " --imply-local" diffview_open_command = diffview_open_command .. " --imply-local"
end end
vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha))
M.is_open = true
M.tabnr = vim.api.nvim_get_current_tabpage() M.tabnr = vim.api.nvim_get_current_tabpage()
if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then
@@ -74,14 +80,17 @@ M.open = function()
end end
end end
require("diffview.config").user_emitter:on("view_closed", function(_, ...) require("diffview.config").user_emitter:on("view_closed", function(_, ...)
M.is_open = false
on_diffview_closed(...) on_diffview_closed(...)
end) end)
if state.settings.discussion_tree.auto_open then if state.settings.discussion_tree.auto_open then
local discussions = require("gitlab.actions.discussions") local discussions = require("gitlab.actions.discussions")
discussions.close() discussions.close()
discussions.toggle() require("gitlab").toggle_discussions() -- Fetches data and opens discussions
end end
git.current_branch_up_to_date_on_remote(vim.log.levels.WARN)
end end
-- Closes the reviewer and cleans up -- Closes the reviewer and cleans up
@@ -91,7 +100,7 @@ M.close = function()
discussions.close() discussions.close()
end end
-- Jumps to the location provided in the reviewer window --- Jumps to the location provided in the reviewer window
---@param file_name string ---@param file_name string
---@param line_number number ---@param line_number number
---@param new_buffer boolean ---@param new_buffer boolean
@@ -172,6 +181,7 @@ M.get_reviewer_data = function()
local old_line = vim.api.nvim_win_get_cursor(old_win)[1] local old_line = vim.api.nvim_win_get_cursor(old_win)[1]
local is_current_sha_focused = M.is_current_sha_focused() local is_current_sha_focused = M.is_current_sha_focused()
local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha_focused)
if modification_type == nil then if modification_type == nil then
u.notify("Error getting modification type", vim.log.levels.ERROR) u.notify("Error getting modification type", vim.log.levels.ERROR)
@@ -206,9 +216,7 @@ M.is_current_sha_focused = function()
local layout = view.cur_layout local layout = view.cur_layout
local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr)
local a_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) local a_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr)
local current_win = vim.fn.win_getid() local current_win = require("gitlab.actions.comment").current_win
-- Handle cases where user navigates tabs in the middle of making a comment
if a_win ~= current_win and b_win ~= current_win then if a_win ~= current_win and b_win ~= current_win then
current_win = M.stored_win current_win = M.stored_win
M.stored_win = nil M.stored_win = nil
@@ -220,7 +228,7 @@ end
---@return string|nil ---@return string|nil
M.get_current_file = function() M.get_current_file = function()
local view = diffview_lib.get_current_view() local view = diffview_lib.get_current_view()
if not view then if not view or not view.panel or not view.panel.cur_file then
return return
end end
return view.panel.cur_file.path return view.panel.cur_file.path

View File

@@ -3,15 +3,51 @@
-- This module is also responsible for ensuring that the state of the plugin -- This module is also responsible for ensuring that the state of the plugin
-- is valid via dependencies -- is valid via dependencies
local git = require("gitlab.git")
local u = require("gitlab.utils") local u = require("gitlab.utils")
local M = {} local M = {}
M.emoji_map = nil M.emoji_map = nil
---Returns a gitlab token, and a gitlab URL. Used to connect to gitlab.
---@return string|nil, string|nil, string|nil
M.default_auth_provider = function()
local base_path, err = M.settings.config_path, nil
if base_path == nil then
base_path, err = git.base_dir()
end
if err ~= nil then
return "", ""
end
local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim"
local config_file_content = u.read_file(config_file_path, { remove_newlines = true })
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
local auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN")
local gitlab_url = file_properties.gitlab_url or os.getenv("GITLAB_URL")
return auth_token, gitlab_url, err
end
-- These are the default settings for the plugin -- These are the default settings for the plugin
M.settings = { M.settings = {
auth_provider = M.default_auth_provider,
port = nil, -- choose random port port = nil, -- choose random port
debug = { go_request = false, go_response = false }, debug = {
go_request = false,
go_response = false,
},
log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"), log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"),
config_path = nil, config_path = nil,
reviewer = "diffview", reviewer = "diffview",
@@ -56,6 +92,7 @@ M.settings = {
delete_comment = "dd", delete_comment = "dd",
open_in_browser = "b", open_in_browser = "b",
copy_node_url = "u", copy_node_url = "u",
publish_draft = "P",
reply = "r", reply = "r",
toggle_node = "t", toggle_node = "t",
add_emoji = "Ea", add_emoji = "Ea",
@@ -72,24 +109,8 @@ M.settings = {
unresolved = "-", unresolved = "-",
tree_type = "simple", tree_type = "simple",
toggle_tree_type = "i", toggle_tree_type = "i",
---@param t WinbarTable toggle_draft_mode = "D",
winbar = function(t) draft_mode = false,
local discussions_content = t.resolvable_discussions ~= 0
and string.format("Discussions (%d/%d)", t.resolved_discussions, t.resolvable_discussions)
or "Discussions"
local notes_content = t.resolvable_notes ~= 0
and string.format("Notes (%d/%d)", t.resolved_notes, t.resolvable_notes)
or "Notes"
if t.name == "Discussions" then
notes_content = "%#Comment#" .. notes_content
discussions_content = "%#Text#" .. discussions_content
else
discussions_content = "%#Comment#" .. discussions_content
notes_content = "%#Text#" .. notes_content
end
local help = "%#Comment#%=Help: " .. t.help_keymap:gsub(" ", "<space>") .. " "
return " " .. discussions_content .. " %#Comment#| " .. notes_content .. help
end,
}, },
create_mr = { create_mr = {
target = nil, target = nil,
@@ -101,6 +122,9 @@ M.settings = {
border = "rounded", border = "rounded",
}, },
}, },
choose_merge_request = {
open_reviewer = true,
},
info = { info = {
enabled = true, enabled = true,
horizontal = false, horizontal = false,
@@ -156,6 +180,7 @@ M.settings = {
file_name = "Normal", file_name = "Normal",
resolved = "DiagnosticSignOk", resolved = "DiagnosticSignOk",
unresolved = "DiagnosticSignWarn", unresolved = "DiagnosticSignWarn",
draft = "DiffviewNonText",
}, },
}, },
} }
@@ -218,32 +243,13 @@ M.setPluginConfiguration = function()
return true return true
end end
local base_path local token, url, err = M.settings.auth_provider()
if M.settings.config_path ~= nil then if err ~= nil then
base_path = M.settings.config_path return
else
base_path = vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" }))
if vim.v.shell_error ~= 0 then
u.notify(string.format("Could not get base directory: %s", base_path), vim.log.levels.ERROR)
return false
end
end end
local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim" M.settings.auth_token = token
local config_file_content = u.read_file(config_file_path, { remove_newlines = true }) M.settings.gitlab_url = u.trim_slash(url or "https://gitlab.com")
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.auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN")
M.settings.gitlab_url = u.trim_slash(file_properties.gitlab_url or os.getenv("GITLAB_URL") or "https://gitlab.com")
if M.settings.auth_token == nil then if M.settings.auth_token == nil then
vim.notify( vim.notify(
@@ -322,17 +328,65 @@ end
-- for each of the actions to occur. This is necessary because some Gitlab behaviors (like -- for each of the actions to occur. This is necessary because some Gitlab behaviors (like
-- adding a reviewer) requires some initial state. -- adding a reviewer) requires some initial state.
M.dependencies = { M.dependencies = {
user = { endpoint = "/users/me", key = "user", state = "USER", refresh = false }, user = {
info = { endpoint = "/mr/info", key = "info", state = "INFO", refresh = false }, endpoint = "/users/me",
latest_pipeline = { endpoint = "/pipeline", key = "latest_pipeline", state = "PIPELINE", refresh = true }, key = "user",
labels = { endpoint = "/mr/label", key = "labels", state = "LABELS", refresh = false }, state = "USER",
revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, refresh = false,
},
info = {
endpoint = "/mr/info",
key = "info",
state = "INFO",
refresh = false,
},
latest_pipeline = {
endpoint = "/pipeline",
key = "latest_pipeline",
state = "PIPELINE",
refresh = true,
},
labels = {
endpoint = "/mr/label",
key = "labels",
state = "LABELS",
refresh = false,
},
revisions = {
endpoint = "/mr/revisions",
key = "Revisions",
state = "MR_REVISIONS",
refresh = false,
},
draft_notes = {
endpoint = "/mr/draft_notes/",
key = "draft_notes",
state = "DRAFT_NOTES",
refresh = false,
},
project_members = { project_members = {
endpoint = "/project/members", endpoint = "/project/members",
key = "ProjectMembers", key = "ProjectMembers",
state = "PROJECT_MEMBERS", state = "PROJECT_MEMBERS",
refresh = false, refresh = false,
}, },
merge_requests = {
endpoint = "/merge_requests",
key = "merge_requests",
state = "MERGE_REQUESTS",
refresh = false,
},
discussion_data = {
endpoint = "/mr/discussions/list",
state = "DISCUSSION_DATA",
refresh = false,
method = "POST",
body = function()
return {
blacklist = M.settings.discussion_tree.blacklist,
}
end,
},
} }
-- This function clears out all of the previously fetched data. It's used -- This function clears out all of the previously fetched data. It's used

View File

@@ -1,3 +1,4 @@
local git = require("gitlab.git")
local List = require("gitlab.utils.list") local List = require("gitlab.utils.list")
local has_devicons, devicons = pcall(require, "nvim-web-devicons") local has_devicons, devicons = pcall(require, "nvim-web-devicons")
local M = {} local M = {}
@@ -202,6 +203,17 @@ M.split_by_new_lines = function(s)
return s:gmatch("(.-)\n") -- Match 0 or more (as few as possible) characters followed by a new line. return s:gmatch("(.-)\n") -- Match 0 or more (as few as possible) characters followed by a new line.
end end
---Takes a string of lines and returns a table of lines
---@param s string The string to parse
---@return table
M.lines_into_table = function(s)
local lines = {}
for line in M.split_by_new_lines(s) do
table.insert(lines, line)
end
return lines
end
-- Reverses the order of elements in a list -- Reverses the order of elements in a list
---@param list table The list to reverse ---@param list table The list to reverse
---@return table ---@return table
@@ -493,7 +505,7 @@ M.create_popup_state = function(title, settings, width, height, zindex)
end end
---Create view_opts for Box popups used inside popup Layouts ---Create view_opts for Box popups used inside popup Layouts
---@param title string The string to appear on top of the popup ---@param title string|nil The string to appear on top of the popup
---@param enter boolean Whether the pop should be focused after creation ---@param enter boolean Whether the pop should be focused after creation
---@return table ---@return table
M.create_box_popup_state = function(title, enter) M.create_box_popup_state = function(title, enter)
@@ -656,52 +668,10 @@ M.make_comma_separated_readable = function(str)
return string.gsub(str, ",", ", ") return string.gsub(str, ",", ", ")
end end
---Return the name of the current branch
---@return string|nil
M.get_current_branch = function()
local handle = io.popen("git branch --show-current 2>&1")
if handle then
return handle:read()
else
M.notify("Error running 'git branch' command.", vim.log.levels.ERROR)
end
end
---Return the list of names of all remote-tracking branches
M.get_all_merge_targets = function()
local handle = io.popen("git branch -r 2>&1")
if not handle then
M.notify("Error running 'git branch' command.", vim.log.levels.ERROR)
return
end
local current_branch = M.get_current_branch()
if not current_branch then
return
end
local lines = {}
for line in handle:lines() do
table.insert(lines, line)
end
handle:close()
-- Trim "origin/" and don't include the HEAD pointer
local branches = List.new(lines)
:map(function(line)
return line:match("origin/(%S+)")
end)
:filter(function(branch)
return not branch:match("^HEAD$") and branch ~= current_branch
end)
return branches
end
---Select a git branch and perform callback with the branch as an argument ---Select a git branch and perform callback with the branch as an argument
---@param cb function The callback to perform with the selected branch ---@param cb function The callback to perform with the selected branch
M.select_target_branch = function(cb) M.select_target_branch = function(cb)
local all_branch_names = M.get_all_merge_targets() local all_branch_names = git.get_all_merge_targets()
if not all_branch_names then if not all_branch_names then
return return
end end
@@ -738,6 +708,20 @@ M.open_in_browser = function(url)
end end
end end
---Combines two tables
---@param t1 table
---@param t2 table
---@return table
M.join = function(t1, t2)
local res = {}
for _, val in ipairs(t1) do
table.insert(res, val)
end
for _, val in ipairs(t2) do
table.insert(res, val)
end
return res
end
---Trims the trailing slash from a URL ---Trims the trailing slash from a URL
---@param s string ---@param s string
---@return string ---@return string
@@ -745,4 +729,11 @@ M.trim_slash = function(s)
return (s:gsub("/+$", "")) return (s:gsub("/+$", ""))
end end
M.ensure_table = function(data)
if data == vim.NIL or data == nil then
return {}
end
return data
end
return M return M

View File

@@ -21,12 +21,12 @@ end
---Filters a given list ---Filters a given list
---@generic T ---@generic T
---@param func fun(v: T):boolean ---@param func fun(v: T, i: integer):boolean
---@return List<T> @Returns a new list of elements for which func returns true ---@return List<T> @Returns a new list of elements for which func returns true
function List:filter(func) function List:filter(func)
local result = List.new() local result = List.new()
for _, v in ipairs(self) do for i, v in ipairs(self) do
if func(v) == true then if func(v, i) == true then
table.insert(result, v) table.insert(result, v)
end end
end end
@@ -63,6 +63,19 @@ function List:slice(first, last, step)
return sliced return sliced
end end
---Returns true if any of the elements can satisfy the callback
---@generic T
---@param func fun(v: T, i: integer):boolean
---@return List<T> @Returns a boolean
function List:includes(func)
for i, v in ipairs(self) do
if func(v, i) == true then
return true
end
end
return false
end
function List:values() function List:values()
local result = {} local result = {}
for _, v in ipairs(self) do for _, v in ipairs(self) do