Release 2.5.1 (#271)

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

This is a #MINOR release.

---------

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

View File

@@ -25,9 +25,9 @@ To view these help docs and to get more detailed help information, please run `:
1. Install Go
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',
use {
"harrisoncramer/gitlab.nvim",
requires = {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim",
"sindrets/diffview.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()
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`

View File

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

View File

@@ -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")

View File

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

View File

@@ -1,7 +1,6 @@
package main
import (
"crypto/sha1"
"encoding/json"
"fmt"
"io"
@@ -12,27 +11,7 @@ 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"`
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
View File

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

View File

@@ -25,12 +25,16 @@ func TestPostComment(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{})
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{
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
View File

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

197
cmd/draft_notes_test.go Normal file
View File

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

View File

@@ -23,7 +23,7 @@ func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsO
func TestInfoHandler(t *testing.T) {
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")
})

View File

@@ -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
View File

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

View File

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

View File

@@ -8,11 +8,11 @@ import (
"github.com/xanzy/go-gitlab"
)
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")
})

View File

@@ -134,6 +134,8 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
m.HandleFunc("/mr/label", a.withMr(a.labelHandler))
m.HandleFunc("/mr/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))

View File

@@ -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()

View File

@@ -49,6 +49,12 @@ type ClientInterface interface {
CreateMergeRequestDiscussion(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error)
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)

View File

@@ -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*
@@ -79,17 +79,22 @@ With Lazy:
And with Packer:
>lua
use {
'harrisoncramer/gitlab.nvim',
"harrisoncramer/gitlab.nvim",
requires = {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim",
"sindrets/diffview.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,
build = function()
require("gitlab.server").build()
end,
branch = "develop",
config = function()
require("gitlab").setup()
require("diffview") -- We require some global state from diffview
local gitlab = require("gitlab")
gitlab.setup()
end,
}
<
@@ -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
@@ -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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ local job = require("gitlab.job")
local u = require("gitlab.utils")
local 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()

View File

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

View File

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

View File

@@ -1,149 +1,22 @@
local state = require("gitlab.state")
-- This module contains tree code specific to the discussion tree, that
-- is not used in the draft notes tree
local u = require("gitlab.utils")
local 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

View File

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

View File

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

View File

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

View File

@@ -143,10 +143,7 @@ M.see_logs = function()
return
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)

View File

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

View File

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

View File

@@ -12,3 +12,4 @@ vim.api.nvim_set_hl(0, "GitlabDirectoryIcon", u.get_colors_for_group(discussion.
vim.api.nvim_set_hl(0, "GitlabFileName", u.get_colors_for_group(discussion.file_name))
vim.api.nvim_set_hl(0, "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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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