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:
committed by
GitHub
parent
f10c4ebb8f
commit
cf6ccddce3
65
README.md
65
README.md
@@ -25,9 +25,9 @@ To view these help docs and to get more detailed help information, please run `:
|
||||
|
||||
1. Install Go
|
||||
2. Add configuration (see Installation section)
|
||||
3. Checkout your feature branch: `git checkout feature-branch`
|
||||
4. Open Neovim
|
||||
5. Run `:lua require("gitlab").review()` to open the reviewer pane
|
||||
5. Run `:lua require("gitlab").choose_merge_request()`
|
||||
|
||||
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`
|
||||
|
||||
@@ -56,20 +56,25 @@ return {
|
||||
And with Packer:
|
||||
|
||||
```lua
|
||||
use {
|
||||
'harrisoncramer/gitlab.nvim',
|
||||
requires = {
|
||||
"MunifTanjim/nui.nvim",
|
||||
"nvim-lua/plenary.nvim",
|
||||
"sindrets/diffview.nvim",
|
||||
"stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers.
|
||||
"nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree.
|
||||
},
|
||||
run = function() require("gitlab.server").build(true) end,
|
||||
config = function()
|
||||
require("gitlab").setup()
|
||||
end,
|
||||
}
|
||||
use {
|
||||
"harrisoncramer/gitlab.nvim",
|
||||
requires = {
|
||||
"MunifTanjim/nui.nvim",
|
||||
"nvim-lua/plenary.nvim",
|
||||
"sindrets/diffview.nvim"
|
||||
"stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers.
|
||||
"nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree.
|
||||
},
|
||||
build = function()
|
||||
require("gitlab.server").build()
|
||||
end,
|
||||
branch = "develop",
|
||||
config = function()
|
||||
require("diffview") -- We require some global state from diffview
|
||||
local gitlab = require("gitlab")
|
||||
gitlab.setup()
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
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`
|
||||
|
||||
## 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
|
||||
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
|
||||
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)
|
||||
reviewer_settings = {
|
||||
diffview = {
|
||||
@@ -150,6 +170,7 @@ require("gitlab").setup({
|
||||
toggle_resolved_discussions = "R", -- Open or close all resolved 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
|
||||
publish_draft = "P", -- Publishes the currently focused note/comment
|
||||
toggle_resolved = "p" -- Toggles the resolved status of the whole discussion
|
||||
position = "left", -- "top", "right", "bottom" or "left"
|
||||
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
|
||||
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"
|
||||
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)
|
||||
-- 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
|
||||
enabled = true,
|
||||
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
|
||||
local gitlab = require("gitlab")
|
||||
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", "gls", gitlab.summary)
|
||||
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", "glo", gitlab.open_in_browser)
|
||||
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`
|
||||
|
||||
@@ -8,6 +8,7 @@ syntax match ChevronDown ""
|
||||
syntax match ChevronRight ""
|
||||
syntax match Resolved /\s✓\s\?/
|
||||
syntax match Unresolved /\s-\s\?/
|
||||
syntax match Pencil //
|
||||
|
||||
highlight link Username GitlabUsername
|
||||
highlight link Date GitlabDate
|
||||
@@ -15,5 +16,6 @@ highlight link ChevronDown GitlabChevron
|
||||
highlight link ChevronRight GitlabChevron
|
||||
highlight link Resolved GitlabResolved
|
||||
highlight link Unresolved GitlabUnresolved
|
||||
highlight link Pencil GitlabDraft
|
||||
|
||||
let b:current_syntax = "gitlab"
|
||||
|
||||
@@ -23,7 +23,7 @@ func updateAssigneesErr(pid interface{}, mergeRequest int, opt *gitlab.UpdateMer
|
||||
func TestAssigneeHandler(t *testing.T) {
|
||||
t.Run("Updates assignees", func(t *testing.T) {
|
||||
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{})
|
||||
assert(t, data.SuccessResponse.Message, "Assignees updated")
|
||||
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) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/assignee", nil)
|
||||
server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees})
|
||||
server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
assert(t, data.Status, http.StatusMethodNotAllowed)
|
||||
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) {
|
||||
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{})
|
||||
assert(t, data.Status, http.StatusInternalServerError)
|
||||
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) {
|
||||
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{})
|
||||
assert(t, data.Status, http.StatusSeeOther)
|
||||
assert(t, data.Message, "Could not modify merge request assignees")
|
||||
|
||||
@@ -40,6 +40,7 @@ type Client struct {
|
||||
*gitlab.LabelsService
|
||||
*gitlab.AwardEmojiService
|
||||
*gitlab.UsersService
|
||||
*gitlab.DraftNotesService
|
||||
}
|
||||
|
||||
/* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */
|
||||
@@ -116,6 +117,7 @@ func initGitlabClient() (error, *Client) {
|
||||
LabelsService: client.Labels,
|
||||
AwardEmojiService: client.AwardEmoji,
|
||||
UsersService: client.Users,
|
||||
DraftNotesService: client.DraftNotes,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,28 +10,8 @@ import (
|
||||
)
|
||||
|
||||
type PostCommentRequest struct {
|
||||
Comment string `json:"comment"`
|
||||
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"`
|
||||
}
|
||||
|
||||
/* 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"`
|
||||
Comment string `json:"comment"`
|
||||
PositionData
|
||||
}
|
||||
|
||||
type DeleteCommentRequest struct {
|
||||
@@ -53,6 +32,15 @@ type CommentResponse struct {
|
||||
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) */
|
||||
func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
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 {
|
||||
friendlyName = "Multiline Comment"
|
||||
shaFormat := "%x_%d_%d"
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
if postCommentRequest.FileName != "" {
|
||||
commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData}
|
||||
opt.Position = buildCommentPosition(commentWithPositionData)
|
||||
}
|
||||
|
||||
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)
|
||||
response := CommentResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: fmt.Sprintf("%s created successfully", friendlyName),
|
||||
Message: "Comment created successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Comment: discussion.Notes[0],
|
||||
|
||||
82
cmd/comment_helpers.go
Normal file
82
cmd/comment_helpers.go
Normal 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
|
||||
}
|
||||
@@ -25,12 +25,16 @@ func TestPostComment(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
|
||||
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)
|
||||
})
|
||||
|
||||
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})
|
||||
data := serveRequest(t, server, request, CommentResponse{})
|
||||
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) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{
|
||||
FileName: "some_file.txt",
|
||||
LineRange: &LineRange{
|
||||
StartRange: &LinePosition{}, /* These would have real data */
|
||||
EndRange: &LinePosition{},
|
||||
PositionData: PositionData{
|
||||
FileName: "some_file.txt",
|
||||
LineRange: &LineRange{
|
||||
StartRange: &LinePosition{}, /* These would have real data */
|
||||
EndRange: &LinePosition{},
|
||||
},
|
||||
},
|
||||
})
|
||||
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
306
cmd/draft_notes.go
Normal file
306
cmd/draft_notes.go
Normal 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
197
cmd/draft_notes_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsO
|
||||
func TestInfoHandler(t *testing.T) {
|
||||
t.Run("Returns normal information", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo})
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo})
|
||||
data := serveRequest(t, server, request, InfoResponse{})
|
||||
assert(t, data.Info.Title, "Some Title")
|
||||
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) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/info", nil)
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo})
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkBadMethod(t, *data, http.MethodGet)
|
||||
})
|
||||
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoErr})
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoErr})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkErrorFromGitlab(t, *data, "Could not get project info")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoNon200})
|
||||
server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoNon200})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkNon200(t, *data, "Could not get project info", "/mr/info")
|
||||
})
|
||||
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd)
|
||||
if err != nil {
|
||||
log.Fatalf("Failure initializing plugin with `git` commands: %v", err)
|
||||
log.Fatalf("Failure initializing plugin: %v", err)
|
||||
}
|
||||
|
||||
err, client := initGitlabClient()
|
||||
|
||||
55
cmd/merge_requests.go
Normal file
55
cmd/merge_requests.go
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
45
cmd/merge_requests_test.go
Normal file
45
cmd/merge_requests_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptM
|
||||
func TestAcceptAndMergeHandler(t *testing.T) {
|
||||
t.Run("Accepts and merges a merge request", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest})
|
||||
data := serveRequest(t, server, request, SuccessResponse{})
|
||||
assert(t, data.Message, "MR merged successfully")
|
||||
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) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkBadMethod(t, *data, http.MethodPost)
|
||||
})
|
||||
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFnErr})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequestErr})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkErrorFromGitlab(t, *data, "Could not merge MR")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptAndMergeNon200})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkNon200(t, *data, "Could not merge MR", "/mr/merge")
|
||||
})
|
||||
|
||||
@@ -134,6 +134,8 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
|
||||
m.HandleFunc("/mr/label", a.withMr(a.labelHandler))
|
||||
m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler))
|
||||
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/trigger/", a.pipelineHandler)
|
||||
@@ -143,6 +145,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
|
||||
m.HandleFunc("/job", a.jobHandler)
|
||||
m.HandleFunc("/project/members", a.projectMembersHandler)
|
||||
m.HandleFunc("/shutdown", a.shutdownHandler)
|
||||
m.HandleFunc("/merge_requests", a.mergeRequestsHandler)
|
||||
|
||||
m.Handle("/ping", http.HandlerFunc(pingHandler))
|
||||
|
||||
|
||||
61
cmd/test.go
61
cmd/test.go
@@ -19,10 +19,10 @@ The FakeHandlerClient is used to create a fake gitlab client for testing our han
|
||||
|
||||
type fakeClient struct {
|
||||
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)
|
||||
updateMergeRequestFn 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)
|
||||
unapprorveMergeRequestFn func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
|
||||
getMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, 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)
|
||||
acceptMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *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)
|
||||
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)
|
||||
@@ -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)
|
||||
deleteMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*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 {
|
||||
@@ -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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
@@ -141,19 +148,47 @@ func (f fakeClient) DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeReq
|
||||
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) {
|
||||
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) {
|
||||
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 */
|
||||
func assert[T comparable](t *testing.T, got T, want T) {
|
||||
t.Helper()
|
||||
|
||||
@@ -49,6 +49,12 @@ type ClientInterface interface {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -51,9 +51,9 @@ QUICK START *gitlab.nvim.quick-start*
|
||||
|
||||
1. Install Go
|
||||
2. Add configuration (see Installation section)
|
||||
3. Checkout your feature branch: `git checkout feature-branch`
|
||||
4. Open Neovim
|
||||
5. Run `:lua require("gitlab").review()` to open the reviewer pane
|
||||
5. Run `:lua require("gitlab").choose_merge_request()`
|
||||
|
||||
This will checkout the branch locally, and up the plugin's reviewer pane.
|
||||
|
||||
|
||||
INSTALLATION *gitlab.nvim.installation*
|
||||
@@ -78,20 +78,25 @@ With Lazy:
|
||||
<
|
||||
And with Packer:
|
||||
>lua
|
||||
use {
|
||||
'harrisoncramer/gitlab.nvim',
|
||||
requires = {
|
||||
"MunifTanjim/nui.nvim",
|
||||
"nvim-lua/plenary.nvim",
|
||||
"sindrets/diffview.nvim",
|
||||
"stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers.
|
||||
"nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree.
|
||||
},
|
||||
run = function() require("gitlab.server").build(true) end,
|
||||
config = function()
|
||||
require("gitlab").setup()
|
||||
end,
|
||||
}
|
||||
use {
|
||||
"harrisoncramer/gitlab.nvim",
|
||||
requires = {
|
||||
"MunifTanjim/nui.nvim",
|
||||
"nvim-lua/plenary.nvim",
|
||||
"sindrets/diffview.nvim"
|
||||
"stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers.
|
||||
"nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree.
|
||||
},
|
||||
build = function()
|
||||
require("gitlab.server").build()
|
||||
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*
|
||||
@@ -122,6 +127,22 @@ directory that holds your `.gitlab.nvim` file.
|
||||
The `connection_settings` block in the `state.lua` file will be used to
|
||||
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*
|
||||
|
||||
@@ -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
|
||||
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
|
||||
publish_draft = "P", -- Publishes the currently focused note/comment
|
||||
position = "left", -- "top", "right", "bottom" or "left"
|
||||
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
|
||||
@@ -184,9 +206,14 @@ you call this function with no values the defaults will be used:
|
||||
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
|
||||
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)
|
||||
-- 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
|
||||
enabled = true,
|
||||
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
|
||||
`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*
|
||||
|
||||
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*
|
||||
|
||||
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:
|
||||
>lua
|
||||
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:
|
||||
>lua
|
||||
@@ -527,6 +564,7 @@ in normal mode):
|
||||
vim.keymap.set("n", "glo", gitlab.open_in_browser)
|
||||
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)
|
||||
<
|
||||
|
||||
TROUBLESHOOTING *gitlab.nvim.troubleshooting*
|
||||
@@ -567,6 +605,21 @@ default arguments outlined under "Configuring the Plugin".
|
||||
require("gitlab").setup({ port = 8392 })
|
||||
|
||||
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.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`
|
||||
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.add_assignee() ~
|
||||
|
||||
@@ -829,6 +890,7 @@ execute and passed the data as an argument.
|
||||
• "pipeline": Information about the current branch's
|
||||
pipeline. Returns and object with `latest_pipeline` and
|
||||
`jobs` as fields.
|
||||
• "draft_notes": The current user's unpublished notes
|
||||
• {refresh}: (bool) Whether to re-fetch the data from Gitlab
|
||||
or use the cached data locally, if available.
|
||||
• {cb}: (function) The callback function that runs after all of the
|
||||
|
||||
@@ -1,126 +1,46 @@
|
||||
-- This module is responsible for creating new comments
|
||||
-- in the reviewer's buffer. The reviewer will pass back
|
||||
-- to this module the data required to make the API calls
|
||||
--- This module is responsible for creating new comments
|
||||
--- in the reviewer's buffer. The reviewer will pass back
|
||||
--- to this module the data required to make the API calls
|
||||
local Popup = require("nui.popup")
|
||||
local Layout = require("nui.layout")
|
||||
local state = require("gitlab.state")
|
||||
local job = require("gitlab.job")
|
||||
local u = require("gitlab.utils")
|
||||
local git = require("gitlab.git")
|
||||
local discussions = require("gitlab.actions.discussions")
|
||||
local draft_notes = require("gitlab.actions.draft_notes")
|
||||
local miscellaneous = require("gitlab.actions.miscellaneous")
|
||||
local reviewer = require("gitlab.reviewer")
|
||||
local Location = require("gitlab.reviewer.location")
|
||||
local M = {}
|
||||
|
||||
-- Popup creation is wrapped in a function so that it is performed *after* user
|
||||
-- configuration has been merged with default configuration, not when this file is being
|
||||
-- required.
|
||||
local function create_comment_popup()
|
||||
return Popup(u.create_popup_state("Comment", state.settings.popup.comment))
|
||||
end
|
||||
local M = {
|
||||
current_win = nil,
|
||||
start_line = nil,
|
||||
end_line = nil,
|
||||
}
|
||||
|
||||
-- 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 = 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
|
||||
---Fires the API that sends the comment data to the Go server, called when you "confirm" creation
|
||||
---via the M.settings.popup.perform_action keybinding
|
||||
---@param text string comment text
|
||||
---@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
|
||||
M.confirm_create_comment = function(text, visual_range, unlinked)
|
||||
local confirm_create_comment = function(text, visual_range, unlinked)
|
||||
if text == nil then
|
||||
u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local is_draft = M.draft_popup and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr))
|
||||
if unlinked then
|
||||
local body = { comment = text }
|
||||
job.run_job("/mr/comment", "POST", body, function(data)
|
||||
u.notify("Note created!", vim.log.levels.INFO)
|
||||
discussions.add_discussion({ data = data, unlinked = true })
|
||||
local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment"
|
||||
job.run_job(endpoint, "POST", body, function(data)
|
||||
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()
|
||||
end)
|
||||
return
|
||||
@@ -153,11 +73,194 @@ M.confirm_create_comment = function(text, visual_range, unlinked)
|
||||
line_range = location_data.line_range,
|
||||
}
|
||||
|
||||
job.run_job("/mr/comment", "POST", body, function(data)
|
||||
u.notify("Comment created!", vim.log.levels.INFO)
|
||||
discussions.add_discussion({ data = data, unlinked = false })
|
||||
local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment"
|
||||
job.run_job(endpoint, "POST", body, function(data)
|
||||
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()
|
||||
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
|
||||
|
||||
281
lua/gitlab/actions/common.lua
Normal file
281
lua/gitlab/actions/common.lua
Normal 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
|
||||
@@ -7,6 +7,7 @@ local job = require("gitlab.job")
|
||||
local u = require("gitlab.utils")
|
||||
local git = require("gitlab.git")
|
||||
local state = require("gitlab.state")
|
||||
local common = require("gitlab.actions.common")
|
||||
local miscellaneous = require("gitlab.actions.miscellaneous")
|
||||
|
||||
---@class Mr
|
||||
@@ -42,6 +43,10 @@ end
|
||||
--- continue working on it.
|
||||
---@param args? Mr
|
||||
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
|
||||
vim.ui.select({ "Yes", "No" }, { prompt = "Continue your previous MR?" }, function(choice)
|
||||
if choice == "Yes" then
|
||||
@@ -82,7 +87,10 @@ M.pick_target = function(mr)
|
||||
end
|
||||
|
||||
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
|
||||
.. state.settings.file_separator
|
||||
.. ".gitlab"
|
||||
@@ -202,7 +210,7 @@ M.open_confirmation_popup = function(mr)
|
||||
M.layout_visible = false
|
||||
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 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
|
||||
|
||||
---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
|
||||
M.select_new_target = function()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
|
||||
@@ -9,6 +9,7 @@ local labels = state.dependencies.labels
|
||||
local project_members = state.dependencies.project_members
|
||||
local revisions = state.dependencies.revisions
|
||||
local latest_pipeline = state.dependencies.latest_pipeline
|
||||
local draft_notes = state.dependencies.draft_notes
|
||||
|
||||
M.data = function(resources, cb)
|
||||
if type(resources) ~= "table" or type(cb) ~= "function" then
|
||||
@@ -23,6 +24,7 @@ M.data = function(resources, cb)
|
||||
project_members = project_members,
|
||||
revisions = revisions,
|
||||
pipeline = latest_pipeline,
|
||||
draft_notes = draft_notes,
|
||||
}
|
||||
|
||||
local api_calls = {}
|
||||
|
||||
@@ -79,9 +79,11 @@
|
||||
---@field moji string
|
||||
|
||||
---@class WinbarTable
|
||||
---@field name string
|
||||
---@field view_type string
|
||||
---@field resolvable_discussions number
|
||||
---@field resolved_discussions number
|
||||
---@field inline_draft_notes number
|
||||
---@field unlinked_draft_notes number
|
||||
---@field resolvable_notes number
|
||||
---@field resolved_notes number
|
||||
---@field help_keymap string
|
||||
@@ -120,3 +122,14 @@
|
||||
---@field old_line integer | nil
|
||||
---@field new_line integer | 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
@@ -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 common = require("gitlab.actions.common")
|
||||
local state = require("gitlab.state")
|
||||
local NuiTree = require("nui.tree")
|
||||
local NuiLine = require("nui.line")
|
||||
|
||||
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
|
||||
---@param items Discussion[]
|
||||
---@param unlinked boolean? False or nil means that discussions are linked to code lines
|
||||
---@return NuiTree.Node[]
|
||||
M.add_discussions_to_table = function(items, unlinked)
|
||||
local t = {}
|
||||
if items == vim.NIL then
|
||||
items = {}
|
||||
end
|
||||
for _, discussion in ipairs(items) do
|
||||
local discussion_children = {}
|
||||
|
||||
@@ -206,10 +79,85 @@ M.add_discussions_to_table = function(items, unlinked)
|
||||
return t
|
||||
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.
|
||||
local discussion_by_file_name = {}
|
||||
local top_level_path_to_node = {}
|
||||
for _, node in ipairs(t) do
|
||||
|
||||
for _, node in ipairs(node_list) do
|
||||
local path = ""
|
||||
local parent_node = nil
|
||||
local path_parts = u.split_path(node.file_name)
|
||||
@@ -274,13 +222,280 @@ M.add_discussions_to_table = function(items, unlinked)
|
||||
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
|
||||
for _, node in ipairs(discussion_by_file_name) do
|
||||
flatten_nodes(node)
|
||||
end
|
||||
|
||||
sort_nodes(discussion_by_file_name)
|
||||
|
||||
return discussion_by_file_name
|
||||
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
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
local M = {}
|
||||
local state = require("gitlab.state")
|
||||
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
|
||||
---@return number, number
|
||||
@@ -30,36 +44,131 @@ local get_data = function(nodes)
|
||||
return total_resolvable, total_resolved
|
||||
end
|
||||
|
||||
---@param discussions Discussion[]|nil
|
||||
---@param unlinked_discussions UnlinkedDiscussion[]|nil
|
||||
---@param file_name string
|
||||
local function content(discussions, unlinked_discussions, file_name)
|
||||
local resolvable_discussions, resolved_discussions = get_data(discussions)
|
||||
local resolvable_notes, resolved_notes = get_data(unlinked_discussions)
|
||||
local function content()
|
||||
local resolvable_discussions, resolved_discussions = get_data(state.DISCUSSION_DATA.discussions)
|
||||
local resolvable_notes, resolved_notes = get_data(state.DISCUSSION_DATA.unlinked_discussions)
|
||||
|
||||
local draft_notes = require("gitlab.actions.draft_notes")
|
||||
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 = {
|
||||
name = file_name,
|
||||
resolvable_discussions = resolvable_discussions,
|
||||
resolved_discussions = resolved_discussions,
|
||||
inline_draft_notes = #inline_draft_notes,
|
||||
unlinked_draft_notes = #unlinked_draft_notes,
|
||||
resolvable_notes = resolvable_notes,
|
||||
resolved_notes = resolved_notes,
|
||||
help_keymap = state.settings.help,
|
||||
}
|
||||
|
||||
return state.settings.discussion_tree.winbar(t)
|
||||
return M.make_winbar(t)
|
||||
end
|
||||
|
||||
---This function updates the winbar
|
||||
---@param discussions Discussion[]
|
||||
---@param unlinked_discussions UnlinkedDiscussion[]
|
||||
---@param base_title string
|
||||
M.update_winbar = function(discussions, unlinked_discussions, base_title)
|
||||
M.update_winbar = function()
|
||||
local d = require("gitlab.actions.discussions")
|
||||
local winId = d.split.winid
|
||||
local c = content(discussions, unlinked_discussions, base_title)
|
||||
if vim.wo[winId] then
|
||||
vim.wo[winId].winbar = c
|
||||
if d.split == nil then
|
||||
return
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
---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
|
||||
|
||||
239
lua/gitlab/actions/draft_notes/init.lua
Executable file
239
lua/gitlab/actions/draft_notes/init.lua
Executable 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
|
||||
56
lua/gitlab/actions/merge_requests.lua
Normal file
56
lua/gitlab/actions/merge_requests.lua
Normal 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
|
||||
@@ -143,10 +143,7 @@ M.see_logs = function()
|
||||
return
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
for line in u.split_by_new_lines(file) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
local lines = u.lines_into_table(file)
|
||||
|
||||
if #lines == 0 then
|
||||
u.notify("Log trace lines could not be parsed", vim.log.levels.ERROR)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
-- send edits to the description back to Gitlab
|
||||
local Layout = require("nui.layout")
|
||||
local Popup = require("nui.popup")
|
||||
local git = require("gitlab.git")
|
||||
local job = require("gitlab.job")
|
||||
local common = require("gitlab.actions.common")
|
||||
local u = require("gitlab.utils")
|
||||
local List = require("gitlab.utils.list")
|
||||
local state = require("gitlab.state")
|
||||
@@ -28,7 +30,7 @@ M.summary = function()
|
||||
end
|
||||
|
||||
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 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)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Builds a lua list of strings that contain the MR description
|
||||
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
|
||||
git.current_branch_up_to_date_on_remote(vim.log.levels.WARN)
|
||||
end
|
||||
|
||||
-- Builds a lua list of strings that contain metadata about the current MR. Only builds the
|
||||
|
||||
@@ -36,8 +36,9 @@ function async:fetch(dependencies, i, argTable)
|
||||
end
|
||||
|
||||
-- Call the API, set the data, and then call the next API
|
||||
job.run_job(dependency.endpoint, "GET", dependency.body, function(data)
|
||||
state[dependency.state] = data[dependency.key]
|
||||
local body = dependency.body and dependency.body() or nil
|
||||
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)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -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, "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, "GitlabDraft", u.get_colors_for_group(discussion.draft))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
local u = require("gitlab.utils")
|
||||
local common = require("gitlab.actions.common")
|
||||
local state = require("gitlab.state")
|
||||
|
||||
local M = {
|
||||
@@ -70,15 +71,15 @@ M.init_popup = function(tree, bufnr)
|
||||
vim.api.nvim_create_autocmd({ "CursorHold" }, {
|
||||
callback = function()
|
||||
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
|
||||
end
|
||||
|
||||
local note_node = require("gitlab.actions.discussions").get_note_node(tree, node)
|
||||
local root_node = require("gitlab.actions.discussions").get_root_node(tree, node)
|
||||
local note_node = common.get_note_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 emojis = state.DISCUSSION_DATA.emojis
|
||||
|
||||
local emojis = require("gitlab.actions.discussions").emojis
|
||||
local note_emojis = emojis[note_id_str]
|
||||
if note_emojis == nil then
|
||||
return
|
||||
|
||||
@@ -1,11 +1,122 @@
|
||||
local List = require("gitlab.utils.list")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.has_clean_tree = function()
|
||||
return vim.fn.trim(vim.fn.system({ "git", "status", "--short", "--untracked-files=no" })) == ""
|
||||
---Runs a system command, captures the output (if it exists) and handles errors
|
||||
---@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
|
||||
|
||||
---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()
|
||||
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
|
||||
|
||||
return M
|
||||
|
||||
@@ -5,30 +5,57 @@ local List = require("gitlab.utils.list")
|
||||
|
||||
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.
|
||||
---@return Discussion[]
|
||||
M.filter_placeable_discussions = function(all_discussions)
|
||||
if type(all_discussions) ~= "table" then
|
||||
return {}
|
||||
---@return Discussion|DraftNote[]
|
||||
M.filter_placeable_discussions = function()
|
||||
local discussions = u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions or {})
|
||||
if type(discussions) ~= "table" then
|
||||
discussions = {}
|
||||
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()
|
||||
if not file then
|
||||
return {}
|
||||
end
|
||||
return List.new(all_discussions):filter(function(discussion)
|
||||
|
||||
local filtered_discussions = List.new(discussions):filter(function(discussion)
|
||||
local first_note = discussion.notes[1]
|
||||
return type(first_note.position) == "table"
|
||||
--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)
|
||||
)
|
||||
return type(first_note.position) == "table" and filter_discussions_and_notes(first_note, file)
|
||||
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
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
---@param discussion Discussion
|
||||
---@param d_or_n Discussion|DraftNote
|
||||
---@return boolean
|
||||
M.is_old_sha = function(discussion)
|
||||
local first_note = discussion.notes[1]
|
||||
M.is_old_sha = function(d_or_n)
|
||||
local first_note = M.get_first_note(d_or_n)
|
||||
return first_note.position.old_line ~= nil
|
||||
end
|
||||
|
||||
---@param discussion Discussion
|
||||
---@param discussion Discussion|DraftNote
|
||||
---@return boolean
|
||||
M.is_new_sha = function(discussion)
|
||||
return not M.is_old_sha(discussion)
|
||||
end
|
||||
|
||||
---@param discussion Discussion
|
||||
---@param d_or_n Discussion|DraftNote
|
||||
---@return boolean
|
||||
M.is_single_line = function(discussion)
|
||||
local first_note = discussion.notes[1]
|
||||
local line_range = first_note.position.line_range
|
||||
M.is_single_line = function(d_or_n)
|
||||
local first_note = M.get_first_note(d_or_n)
|
||||
local line_range = first_note.position and first_note.position.line_range
|
||||
return line_range == nil
|
||||
end
|
||||
|
||||
@@ -64,10 +91,10 @@ M.is_multi_line = function(discussion)
|
||||
return not M.is_single_line(discussion)
|
||||
end
|
||||
|
||||
---@param discussion Discussion
|
||||
---@return Note
|
||||
M.get_first_note = function(discussion)
|
||||
return discussion.notes[1]
|
||||
---@param d_or_n Discussion|DraftNote
|
||||
---@return Note|DraftNote
|
||||
M.get_first_note = function(d_or_n)
|
||||
return d_or_n.notes and d_or_n.notes[1] or d_or_n
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
local u = require("gitlab.utils")
|
||||
local diffview_lib = require("diffview.lib")
|
||||
local discussion_tree = require("gitlab.actions.discussions.tree")
|
||||
local common = require("gitlab.indicators.common")
|
||||
local indicators_common = require("gitlab.indicators.common")
|
||||
local actions_common = require("gitlab.actions.common")
|
||||
local List = require("gitlab.utils.list")
|
||||
local state = require("gitlab.state")
|
||||
local discussion_sign_name = "gitlab_discussion"
|
||||
@@ -24,19 +24,23 @@ local display_opts = {
|
||||
---Takes some range information and data about a discussion
|
||||
---and creates a diagnostic to be placed in the reviewer
|
||||
---@param range_info table
|
||||
---@param discussion Discussion
|
||||
---@param d_or_n Discussion|DraftNote
|
||||
---@return Diagnostic
|
||||
local function create_diagnostic(range_info, discussion)
|
||||
local message = ""
|
||||
for _, note in ipairs(discussion.notes) do
|
||||
message = message .. discussion_tree.build_note_header(note) .. "\n" .. note.body .. "\n"
|
||||
local function create_diagnostic(range_info, d_or_n)
|
||||
local first_note = indicators_common.get_first_note(d_or_n)
|
||||
local header = actions_common.build_note_header(first_note)
|
||||
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
|
||||
|
||||
local diagnostic = {
|
||||
message = message,
|
||||
col = 0,
|
||||
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",
|
||||
code = "gitlab.nvim",
|
||||
}
|
||||
@@ -44,38 +48,37 @@ local function create_diagnostic(range_info, discussion)
|
||||
end
|
||||
|
||||
---Creates a single line diagnostic
|
||||
---@param discussion Discussion
|
||||
---@param d_or_n Discussion|DraftNote
|
||||
---@return Diagnostic
|
||||
local create_single_line_diagnostic = function(discussion)
|
||||
local first_note = discussion.notes[1]
|
||||
local linnr = (common.is_new_sha(discussion) and first_note.position.new_line or first_note.position.old_line) or 1
|
||||
local create_single_line_diagnostic = function(d_or_n)
|
||||
local linnr = actions_common.get_line_number(d_or_n.id)
|
||||
return create_diagnostic({
|
||||
lnum = linnr - 1,
|
||||
}, discussion)
|
||||
}, d_or_n)
|
||||
end
|
||||
|
||||
---Creates a mutli-line line diagnostic
|
||||
---@param discussion Discussion
|
||||
---@param d_or_n Discussion|DraftNote
|
||||
---@return Diagnostic
|
||||
local create_multiline_diagnostic = function(discussion)
|
||||
local first_note = discussion.notes[1]
|
||||
local create_multiline_diagnostic = function(d_or_n)
|
||||
local first_note = indicators_common.get_first_note(d_or_n)
|
||||
local line_range = first_note.position.line_range
|
||||
if line_range == nil then
|
||||
error("Parsing multi-line comment but note does not contain line range")
|
||||
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({
|
||||
lnum = start_new_line - 1,
|
||||
end_lnum = first_note.position.new_line - 1,
|
||||
}, discussion)
|
||||
}, d_or_n)
|
||||
else
|
||||
return create_diagnostic({
|
||||
lnum = start_old_line - 1,
|
||||
end_lnum = first_note.position.old_line - 1,
|
||||
}, discussion)
|
||||
}, d_or_n)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,12 +109,11 @@ local set_diagnostics_in_old_sha = function(namespace, diagnostics, opts)
|
||||
end
|
||||
|
||||
---Refresh the diagnostics for the currently reviewed file
|
||||
---@param discussions Discussion[]
|
||||
M.refresh_diagnostics = function(discussions)
|
||||
M.refresh_diagnostics = function()
|
||||
local ok, err = pcall(function()
|
||||
require("gitlab.indicators.signs").clear_signs()
|
||||
M.clear_diagnostics()
|
||||
local filtered_discussions = common.filter_placeable_discussions(discussions)
|
||||
local filtered_discussions = indicators_common.filter_placeable_discussions()
|
||||
if filtered_discussions == nil then
|
||||
return
|
||||
end
|
||||
@@ -133,9 +135,9 @@ end
|
||||
---@param discussions Discussion[]
|
||||
---@return DiagnosticTable[]
|
||||
M.parse_new_diagnostics = function(discussions)
|
||||
local new_diagnostics = List.new(discussions):filter(common.is_new_sha)
|
||||
local single_line = new_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic)
|
||||
local multi_line = new_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic)
|
||||
local new_diagnostics = List.new(discussions):filter(indicators_common.is_new_sha)
|
||||
local single_line = new_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic)
|
||||
local multi_line = new_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic)
|
||||
return u.combine(single_line, multi_line)
|
||||
end
|
||||
|
||||
@@ -144,9 +146,9 @@ end
|
||||
---@param discussions Discussion[]
|
||||
---@return DiagnosticTable[]
|
||||
M.parse_old_diagnostics = function(discussions)
|
||||
local old_diagnostics = List.new(discussions):filter(common.is_old_sha)
|
||||
local single_line = old_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic)
|
||||
local multi_line = old_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic)
|
||||
local old_diagnostics = List.new(discussions):filter(indicators_common.is_old_sha)
|
||||
local single_line = old_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic)
|
||||
local multi_line = old_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic)
|
||||
return u.combine(single_line, multi_line)
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ local emoji = require("gitlab.emoji")
|
||||
local state = require("gitlab.state")
|
||||
local reviewer = require("gitlab.reviewer")
|
||||
local discussions = require("gitlab.actions.discussions")
|
||||
local merge_requests = require("gitlab.actions.merge_requests")
|
||||
local merge = require("gitlab.actions.merge")
|
||||
local summary = require("gitlab.actions.summary")
|
||||
local data = require("gitlab.actions.data")
|
||||
@@ -14,6 +15,7 @@ local comment = require("gitlab.actions.comment")
|
||||
local pipeline = require("gitlab.actions.pipeline")
|
||||
local create_mr = require("gitlab.actions.create_mr")
|
||||
local approvals = require("gitlab.actions.approvals")
|
||||
local draft_notes = require("gitlab.actions.draft_notes")
|
||||
local labels = require("gitlab.actions.labels")
|
||||
|
||||
local user = state.dependencies.user
|
||||
@@ -22,6 +24,9 @@ local labels_dep = state.dependencies.labels
|
||||
local project_members = state.dependencies.project_members
|
||||
local latest_pipeline = state.dependencies.latest_pipeline
|
||||
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 {
|
||||
setup = function(args)
|
||||
@@ -63,15 +68,20 @@ return {
|
||||
pipeline = async.sequence({ latest_pipeline }, pipeline.open),
|
||||
merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge),
|
||||
-- Discussion Tree Actions 🌴
|
||||
toggle_discussions = async.sequence({ info, user }, discussions.toggle),
|
||||
edit_comment = async.sequence({ info }, discussions.edit_comment),
|
||||
delete_comment = async.sequence({ info }, discussions.delete_comment),
|
||||
toggle_discussions = async.sequence({
|
||||
info,
|
||||
user,
|
||||
draft_notes_dep,
|
||||
discussion_data,
|
||||
}, discussions.toggle),
|
||||
toggle_resolved = async.sequence({ info }, discussions.toggle_discussion_resolved),
|
||||
publish_all_drafts = draft_notes.publish_all_drafts,
|
||||
reply = async.sequence({ info }, discussions.reply),
|
||||
-- Other functions 🤷
|
||||
state = state,
|
||||
data = data.data,
|
||||
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()
|
||||
local web_url = u.get_web_url()
|
||||
if web_url ~= nil then
|
||||
|
||||
@@ -12,6 +12,7 @@ local async = require("diffview.async")
|
||||
local diffview_lib = require("diffview.lib")
|
||||
|
||||
local M = {
|
||||
is_open = false,
|
||||
bufnr = nil,
|
||||
tabnr = nil,
|
||||
stored_win = nil,
|
||||
@@ -41,12 +42,17 @@ M.open = function()
|
||||
end
|
||||
|
||||
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
|
||||
diffview_open_command = diffview_open_command .. " --imply-local"
|
||||
end
|
||||
|
||||
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()
|
||||
|
||||
if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then
|
||||
@@ -74,14 +80,17 @@ M.open = function()
|
||||
end
|
||||
end
|
||||
require("diffview.config").user_emitter:on("view_closed", function(_, ...)
|
||||
M.is_open = false
|
||||
on_diffview_closed(...)
|
||||
end)
|
||||
|
||||
if state.settings.discussion_tree.auto_open then
|
||||
local discussions = require("gitlab.actions.discussions")
|
||||
discussions.close()
|
||||
discussions.toggle()
|
||||
require("gitlab").toggle_discussions() -- Fetches data and opens discussions
|
||||
end
|
||||
|
||||
git.current_branch_up_to_date_on_remote(vim.log.levels.WARN)
|
||||
end
|
||||
|
||||
-- Closes the reviewer and cleans up
|
||||
@@ -91,7 +100,7 @@ M.close = function()
|
||||
discussions.close()
|
||||
end
|
||||
|
||||
-- Jumps to the location provided in the reviewer window
|
||||
--- Jumps to the location provided in the reviewer window
|
||||
---@param file_name string
|
||||
---@param line_number number
|
||||
---@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 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)
|
||||
if modification_type == nil then
|
||||
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 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 current_win = vim.fn.win_getid()
|
||||
|
||||
-- Handle cases where user navigates tabs in the middle of making a comment
|
||||
local current_win = require("gitlab.actions.comment").current_win
|
||||
if a_win ~= current_win and b_win ~= current_win then
|
||||
current_win = M.stored_win
|
||||
M.stored_win = nil
|
||||
@@ -220,7 +228,7 @@ end
|
||||
---@return string|nil
|
||||
M.get_current_file = function()
|
||||
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
|
||||
end
|
||||
return view.panel.cur_file.path
|
||||
|
||||
@@ -3,15 +3,51 @@
|
||||
-- This module is also responsible for ensuring that the state of the plugin
|
||||
-- is valid via dependencies
|
||||
|
||||
local git = require("gitlab.git")
|
||||
local u = require("gitlab.utils")
|
||||
local M = {}
|
||||
|
||||
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
|
||||
M.settings = {
|
||||
auth_provider = M.default_auth_provider,
|
||||
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"),
|
||||
config_path = nil,
|
||||
reviewer = "diffview",
|
||||
@@ -56,6 +92,7 @@ M.settings = {
|
||||
delete_comment = "dd",
|
||||
open_in_browser = "b",
|
||||
copy_node_url = "u",
|
||||
publish_draft = "P",
|
||||
reply = "r",
|
||||
toggle_node = "t",
|
||||
add_emoji = "Ea",
|
||||
@@ -72,24 +109,8 @@ M.settings = {
|
||||
unresolved = "-",
|
||||
tree_type = "simple",
|
||||
toggle_tree_type = "i",
|
||||
---@param t WinbarTable
|
||||
winbar = function(t)
|
||||
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,
|
||||
toggle_draft_mode = "D",
|
||||
draft_mode = false,
|
||||
},
|
||||
create_mr = {
|
||||
target = nil,
|
||||
@@ -101,6 +122,9 @@ M.settings = {
|
||||
border = "rounded",
|
||||
},
|
||||
},
|
||||
choose_merge_request = {
|
||||
open_reviewer = true,
|
||||
},
|
||||
info = {
|
||||
enabled = true,
|
||||
horizontal = false,
|
||||
@@ -156,6 +180,7 @@ M.settings = {
|
||||
file_name = "Normal",
|
||||
resolved = "DiagnosticSignOk",
|
||||
unresolved = "DiagnosticSignWarn",
|
||||
draft = "DiffviewNonText",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -218,32 +243,13 @@ M.setPluginConfiguration = function()
|
||||
return true
|
||||
end
|
||||
|
||||
local base_path
|
||||
if M.settings.config_path ~= nil then
|
||||
base_path = M.settings.config_path
|
||||
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
|
||||
local token, url, err = M.settings.auth_provider()
|
||||
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
|
||||
|
||||
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")
|
||||
M.settings.auth_token = token
|
||||
M.settings.gitlab_url = u.trim_slash(url or "https://gitlab.com")
|
||||
|
||||
if M.settings.auth_token == nil then
|
||||
vim.notify(
|
||||
@@ -322,17 +328,65 @@ end
|
||||
-- for each of the actions to occur. This is necessary because some Gitlab behaviors (like
|
||||
-- adding a reviewer) requires some initial state.
|
||||
M.dependencies = {
|
||||
user = { endpoint = "/users/me", key = "user", state = "USER", 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 },
|
||||
user = {
|
||||
endpoint = "/users/me",
|
||||
key = "user",
|
||||
state = "USER",
|
||||
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 = {
|
||||
endpoint = "/project/members",
|
||||
key = "ProjectMembers",
|
||||
state = "PROJECT_MEMBERS",
|
||||
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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
local git = require("gitlab.git")
|
||||
local List = require("gitlab.utils.list")
|
||||
local has_devicons, devicons = pcall(require, "nvim-web-devicons")
|
||||
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.
|
||||
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
|
||||
---@param list table The list to reverse
|
||||
---@return table
|
||||
@@ -493,7 +505,7 @@ M.create_popup_state = function(title, settings, width, height, zindex)
|
||||
end
|
||||
|
||||
---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
|
||||
---@return table
|
||||
M.create_box_popup_state = function(title, enter)
|
||||
@@ -656,52 +668,10 @@ M.make_comma_separated_readable = function(str)
|
||||
return string.gsub(str, ",", ", ")
|
||||
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
|
||||
---@param cb function The callback to perform with the selected branch
|
||||
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
|
||||
return
|
||||
end
|
||||
@@ -738,6 +708,20 @@ M.open_in_browser = function(url)
|
||||
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
|
||||
---@param s string
|
||||
---@return string
|
||||
@@ -745,4 +729,11 @@ M.trim_slash = function(s)
|
||||
return (s:gsub("/+$", ""))
|
||||
end
|
||||
|
||||
M.ensure_table = function(data)
|
||||
if data == vim.NIL or data == nil then
|
||||
return {}
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -21,12 +21,12 @@ end
|
||||
|
||||
---Filters a given list
|
||||
---@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
|
||||
function List:filter(func)
|
||||
local result = List.new()
|
||||
for _, v in ipairs(self) do
|
||||
if func(v) == true then
|
||||
for i, v in ipairs(self) do
|
||||
if func(v, i) == true then
|
||||
table.insert(result, v)
|
||||
end
|
||||
end
|
||||
@@ -63,6 +63,19 @@ function List:slice(first, last, step)
|
||||
return sliced
|
||||
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()
|
||||
local result = {}
|
||||
for _, v in ipairs(self) do
|
||||
|
||||
Reference in New Issue
Block a user