• removes the <esc> keybinding for popups which was causing folks to lose their changes
• deprecates the backup register.
• updates go-gitlab to latest in order to get "drafts" functionality
• fixes issues with labels not deleting correctly
• creates a new data() function to get data from the plugin directly, see :h gitlab.nvim.data
• fixes issues with line values not being computed directly, blocking jumps to/from discussion tree

This is a #MINOR release.
This commit is contained in:
Harrison (Harry) Cramer
2024-04-07 21:45:19 -04:00
committed by GitHub
parent 12c4acb297
commit 36f512cd6d
19 changed files with 299 additions and 177 deletions

View File

@@ -115,7 +115,6 @@ require("gitlab").setup({
}, },
help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc)
popup = { -- The popup for comment creation, editing, and replying popup = { -- The popup for comment creation, editing, and replying
exit = "<Esc>",
perform_action = "<leader>s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_action = "<leader>s", -- Once in normal mode, does action (like saving comment or editing description, etc)
perform_linewise_action = "<leader>l", -- Once in normal mode, does the linewise action (see logs for this job, etc) perform_linewise_action = "<leader>l", -- Once in normal mode, does the linewise action (see logs for this job, etc)
width = "40%", width = "40%",
@@ -128,7 +127,6 @@ require("gitlab").setup({
pipeline = nil, pipeline = nil,
reply = nil, reply = nil,
squash_message = nil, squash_message = nil,
backup_register = nil,
}, },
discussion_tree = { -- The discussion tree that holds all comments discussion_tree = { -- The discussion tree that holds all comments
auto_open = true, -- Automatically open when the reviewer is opened auto_open = true, -- Automatically open when the reviewer is opened

View File

@@ -86,10 +86,6 @@ func GetCurrentBranchNameFromNativeGitCmd() (res string, e error) {
branchName := strings.TrimSpace(string(output)) branchName := strings.TrimSpace(string(output))
if branchName == "main" || branchName == "master" {
return "", fmt.Errorf("Cannot run on %s branch", branchName)
}
return branchName, nil return branchName, nil
} }

View File

@@ -99,7 +99,7 @@ func (a *api) updateLabels(w http.ResponseWriter, r *http.Request) {
return return
} }
var labels = gitlab.Labels(labelUpdateRequest.Labels) var labels = gitlab.LabelOptions(labelUpdateRequest.Labels)
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
Labels: &labels, Labels: &labels,
}) })

View File

@@ -12,12 +12,17 @@ import (
type RetriggerPipelineResponse struct { type RetriggerPipelineResponse struct {
SuccessResponse SuccessResponse
Pipeline *gitlab.Pipeline LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"`
} }
type GetJobsResponse struct { type PipelineWithJobs struct {
Jobs []*gitlab.Job `json:"jobs"`
LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"`
}
type GetPipelineAndJobsResponse struct {
SuccessResponse SuccessResponse
Jobs []*gitlab.Job Pipeline PipelineWithJobs `json:"latest_pipeline"`
} }
/* /*
@@ -27,7 +32,7 @@ about a given job in a pipeline, see the jobHandler function
func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
a.GetJobs(w, r) a.GetPipelineAndJobs(w, r)
case http.MethodPost: case http.MethodPost:
a.RetriggerPipeline(w, r) a.RetriggerPipeline(w, r)
default: default:
@@ -37,18 +42,29 @@ func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
func (a *api) GetJobs(w http.ResponseWriter, r *http.Request) { func (a *api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
id := strings.TrimPrefix(r.URL.Path, "/pipeline/") pipeline, res, err := a.client.GetLatestPipeline(a.projectInfo.ProjectId, &gitlab.GetLatestPipelineOptions{
idInt, err := strconv.Atoi(id) Ref: &a.gitInfo.BranchName,
})
if err != nil { if err != nil {
handleError(w, err, "Could not convert pipeline ID to integer", http.StatusBadRequest) handleError(w, err, fmt.Sprintf("Gitlab failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError)
return return
} }
jobs, res, err := a.client.ListPipelineJobs(a.projectInfo.ProjectId, idInt, &gitlab.ListJobsOptions{}) if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("Could not get latest pipeline for %s branch", a.gitInfo.BranchName), res.StatusCode)
return
}
if pipeline == nil {
handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), res.StatusCode)
return
}
jobs, res, err := a.client.ListPipelineJobs(a.projectInfo.ProjectId, pipeline.ID, &gitlab.ListJobsOptions{})
if err != nil { if err != nil {
handleError(w, err, "Could not get pipeline jobs", http.StatusInternalServerError) handleError(w, err, "Could not get pipeline jobs", http.StatusInternalServerError)
@@ -61,12 +77,15 @@ func (a *api) GetJobs(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := GetJobsResponse{ response := GetPipelineAndJobsResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{
Status: http.StatusOK, Status: http.StatusOK,
Message: "Pipeline jobs retrieved", Message: "Pipeline retrieved",
},
Pipeline: PipelineWithJobs{
LatestPipeline: pipeline,
Jobs: jobs,
}, },
Jobs: jobs,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
@@ -78,7 +97,7 @@ func (a *api) GetJobs(w http.ResponseWriter, r *http.Request) {
func (a *api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { func (a *api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
id := strings.TrimPrefix(r.URL.Path, "/pipeline/") id := strings.TrimPrefix(r.URL.Path, "/pipeline/trigger/")
idInt, err := strconv.Atoi(id) idInt, err := strconv.Atoi(id)
if err != nil { if err != nil {
@@ -104,7 +123,7 @@ func (a *api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) {
Message: "Pipeline retriggered", Message: "Pipeline retriggered",
Status: http.StatusOK, Status: http.StatusOK,
}, },
Pipeline: pipeline, LatestPipeline: pipeline,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -32,54 +32,79 @@ func retryPipelineBuildNon200(pid interface{}, pipeline int, options ...gitlab.R
return nil, makeResponse(http.StatusSeeOther), nil return nil, makeResponse(http.StatusSeeOther), nil
} }
func getLatestPipeline200(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) {
return &gitlab.Pipeline{ID: 1}, makeResponse(http.StatusOK), nil
}
func TestPipelineHandler(t *testing.T) { func TestPipelineHandler(t *testing.T) {
t.Run("Gets all pipeline jobs", func(t *testing.T) { t.Run("Gets all pipeline jobs", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) request := makeRequest(t, http.MethodGet, "/pipeline", nil)
server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobs}) server, _ := createRouterAndApi(fakeClient{
data := serveRequest(t, server, request, GetJobsResponse{}) listPipelineJobs: listPipelineJobs,
assert(t, data.SuccessResponse.Message, "Pipeline jobs retrieved") getLatestPipeline: getLatestPipeline200,
})
data := serveRequest(t, server, request, GetPipelineAndJobsResponse{})
assert(t, data.SuccessResponse.Message, "Pipeline retrieved")
assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.SuccessResponse.Status, http.StatusOK)
}) })
t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/pipeline/1", nil) request := makeRequest(t, http.MethodPatch, "/pipeline", nil)
server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobs}) server, _ := createRouterAndApi(fakeClient{
listPipelineJobs: listPipelineJobs,
getLatestPipeline: getLatestPipeline200,
})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodGet, http.MethodPost) checkBadMethod(t, *data, http.MethodGet, http.MethodPost)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) request := makeRequest(t, http.MethodGet, "/pipeline", nil)
server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobsErr}) server, _ := createRouterAndApi(fakeClient{
listPipelineJobs: listPipelineJobsErr,
getLatestPipeline: getLatestPipeline200,
})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not get pipeline jobs") checkErrorFromGitlab(t, *data, "Could not get pipeline jobs")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) request := makeRequest(t, http.MethodGet, "/pipeline", nil)
server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobsNon200}) server, _ := createRouterAndApi(fakeClient{
listPipelineJobs: listPipelineJobsNon200,
getLatestPipeline: getLatestPipeline200,
})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline")
}) })
t.Run("Retriggers pipeline", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/1", nil)
server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuild})
data := serveRequest(t, server, request, GetJobsResponse{})
assert(t, data.SuccessResponse.Message, "Pipeline retriggered")
assert(t, data.SuccessResponse.Status, http.StatusOK)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil)
server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuildErr}) server, _ := createRouterAndApi(fakeClient{
retryPipelineBuild: retryPipelineBuildErr,
getLatestPipeline: getLatestPipeline200,
})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") checkErrorFromGitlab(t, *data, "Could not retrigger pipeline")
}) })
t.Run("Retriggers pipeline", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil)
server, _ := createRouterAndApi(fakeClient{
retryPipelineBuild: retryPipelineBuild,
getLatestPipeline: getLatestPipeline200,
})
data := serveRequest(t, server, request, GetPipelineAndJobsResponse{})
assert(t, data.SuccessResponse.Message, "Pipeline retriggered")
assert(t, data.SuccessResponse.Status, http.StatusOK)
})
t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil)
server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuildNon200}) server, _ := createRouterAndApi(fakeClient{
retryPipelineBuild: retryPipelineBuildNon200,
getLatestPipeline: getLatestPipeline200,
})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline") checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline")
}) })

View File

@@ -132,11 +132,12 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler)) m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler))
m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler)) m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler))
m.HandleFunc("/pipeline", a.pipelineHandler)
m.HandleFunc("/pipeline/trigger/", a.pipelineHandler)
m.HandleFunc("/users/me", a.meHandler) m.HandleFunc("/users/me", a.meHandler)
m.HandleFunc("/attachment", a.attachmentHandler) m.HandleFunc("/attachment", a.attachmentHandler)
m.HandleFunc("/create_mr", a.createMr) m.HandleFunc("/create_mr", a.createMr)
m.HandleFunc("/job", a.jobHandler) m.HandleFunc("/job", a.jobHandler)
m.HandleFunc("/pipeline/", a.pipelineHandler)
m.HandleFunc("/project/members", a.projectMembersHandler) m.HandleFunc("/project/members", a.projectMembersHandler)
m.HandleFunc("/shutdown", a.shutdownHandler) m.HandleFunc("/shutdown", a.shutdownHandler)

View File

@@ -35,6 +35,7 @@ type fakeClient struct {
listAllProjectMembers func(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) listAllProjectMembers func(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error)
retryPipelineBuild func(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) retryPipelineBuild func(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error)
listPipelineJobs func(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) listPipelineJobs func(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error)
getLatestPipeline func(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error)
getTraceFile func(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) getTraceFile func(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error)
listLabels func(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) listLabels func(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error)
listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error)
@@ -120,6 +121,10 @@ func (f fakeClient) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitl
return f.listPipelineJobs(pid, pipelineID, opts, options...) return f.listPipelineJobs(pid, pipelineID, opts, options...)
} }
func (f fakeClient) GetLatestPipeline(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) {
return f.getLatestPipeline(pid, opts, options...)
}
func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) {
return f.getTraceFile(pid, jobID, options...) return f.getTraceFile(pid, jobID, options...)
} }

View File

@@ -53,6 +53,7 @@ type ClientInterface interface {
ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error)
RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error)
ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error)
GetLatestPipeline(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error)
GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error)
ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error)
ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error)

View File

@@ -146,7 +146,6 @@ you call this function with no values the defaults will be used:
}, },
help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc)
popup = { -- The popup for comment creation, editing, and replying popup = { -- The popup for comment creation, editing, and replying
exit = "<Esc>",
perform_action = "<leader>s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_action = "<leader>s", -- Once in normal mode, does action (like saving comment or editing description, etc)
perform_linewise_action = "<leader>l", -- Once in normal mode, does the linewise action (see logs for this job, etc) perform_linewise_action = "<leader>l", -- Once in normal mode, does the linewise action (see logs for this job, etc)
width = "40%", width = "40%",
@@ -159,7 +158,6 @@ you call this function with no values the defaults will be used:
pipeline = nil, pipeline = nil,
reply = nil, reply = nil,
squash_message = nil, squash_message = nil,
backup_register = nil,
}, },
discussion_tree = { -- The discussion tree that holds all comments discussion_tree = { -- The discussion tree that holds all comments
auto_open = true, -- Automatically open when the reviewer is opened auto_open = true, -- Automatically open when the reviewer is opened
@@ -305,22 +303,6 @@ code block with prefilled code from the visual selection.
Just like the summary, all the different kinds of comments are saved via the Just like the summary, all the different kinds of comments are saved via the
`settings.popup.perform_action` keybinding. `settings.popup.perform_action` keybinding.
BACKUP REGISTER *gitlab.nvim.backup-register*
Sometimes, the action triggered by `settings.popup.perform_action` can fail.
To prevent losing your carefully crafted note/comment/suggestion you can set
`settings.popup.backup_register` to a writable register (see |registers|) to
which the contents of the popup window will be saved just before the action is
performed. A practical setting is `settings.popup.backup_register = "+"` which
saves to the system clipboard (see |quoteplus|). This lets you easily apply
the action on Gitlab in a browser, if it keeps failing in `gitlab.nvim`.
If you experience such problems, please first read the
|gitlab.nvim.troubleshooting| section. If it does not help, see if there are
any relevant known <https://github.com/harrisoncramer/gitlab.nvim/issues>. If
there are none, please open one and provide any error messages that
`gitlab.nvim` may be showing.
DISCUSSIONS AND NOTES *gitlab.nvim.discussions-and-notes* DISCUSSIONS AND NOTES *gitlab.nvim.discussions-and-notes*
Gitlab groups threads of comments together into "discussions." Gitlab groups threads of comments together into "discussions."
@@ -751,4 +733,38 @@ Merges the merge request into the target branch
>lua >lua
require("gitlab").merge() require("gitlab").merge()
gitlab.data({ opts }, cb) *gitlab.nvim.data*
The data function can be used to integrate `gitlab.nvim` with other plugins and tooling, by fetching
raw data about the current MR, including the summary information (title, description, etc);
reviewers, assignees, pipeline status.
>lua
require("gitlab").data({
{ type = "info", refresh = false },
{ type = "user", refresh = false } }, function (data)
vim.print("The info data is: ", data.info)
vim.print("The user data is: ", data.user)
end)
If the resources have not yet been fetched from Gitlab, this function will
perform API calls for them. Once the data has been fetched, the callback will
execute and passed the data as an argument.
Parameters: ~
• {resources} (table) A list of resource blocks to fetch.
• {resource} (table) A resource to fetch, such as job information, etc.
• {resource.type}: (string) The type of resource, either: "user"
"labels", "project_members", "pipeline," or "revisions"." The types are:
• {user}: Information about the currently authenticated user
• {labels}: The labels available in the current project
• {project_members}: The list of current project members
• {revisions}: Revision information about the MR
• {pipeline}: Information about the current branch's pipeline. Returns
and object with `latest_pipeline` and `jobs` as fields.
• {resource.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
resources have been fetched. Will be passed a table with the data,
with each resource as a key-value pair, with the key being it's type.
vim:tw=78:ts=8:noet:ft=help:norl: vim:tw=78:ts=8:noet:ft=help:norl:

6
go.mod
View File

@@ -2,13 +2,15 @@ module gitlab.com/harrisoncramer/gitlab.nvim
go 1.19 go 1.19
require github.com/xanzy/go-gitlab v0.93.2 require (
github.com/hashicorp/go-retryablehttp v0.7.2
github.com/xanzy/go-gitlab v0.102.0
)
require ( require (
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.2 // indirect
golang.org/x/net v0.8.0 // indirect golang.org/x/net v0.8.0 // indirect
golang.org/x/oauth2 v0.6.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect

6
go.sum
View File

@@ -19,10 +19,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/xanzy/go-gitlab v0.83.0 h1:37p0MpTPNbsTMKX/JnmJtY8Ch1sFiJzVF342+RvZEGw= github.com/xanzy/go-gitlab v0.102.0 h1:ExHuJ1OTQ2yt25zBMMj0G96ChBirGYv8U7HyUiYkZ+4=
github.com/xanzy/go-gitlab v0.83.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= github.com/xanzy/go-gitlab v0.102.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI=
github.com/xanzy/go-gitlab v0.93.2 h1:kNNf3BYNYn/Zkig0B89fma12l36VLcYSGu7OnaRlRDg=
github.com/xanzy/go-gitlab v0.93.2/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=

View File

@@ -196,10 +196,6 @@ M.add_title = function(mr)
mr.title = value mr.title = value
end, end,
}) })
input:map("n", "<Esc>", function()
input:unmount()
end, { noremap = true })
input:mount() input:mount()
end end

View File

@@ -0,0 +1,45 @@
local u = require("gitlab.utils")
local async = require("gitlab.async")
local state = require("gitlab.state")
local M = {}
local user = state.dependencies.user
local info = state.dependencies.info
local labels = state.dependencies.labels
local project_members = state.dependencies.project_members
local revisions = state.dependencies.revisions
local latest_pipeline = state.dependencies.latest_pipeline
M.data = function(resources, cb)
if type(resources) ~= "table" or type(cb) ~= "function" then
u.notify("The data function must be passed a resources table and a callback function", vim.log.levels.ERROR)
return
end
local all_resources = {
info = info,
user = user,
labels = labels,
project_members = project_members,
revisions = revisions,
pipeline = latest_pipeline,
}
local api_calls = {}
for _, resource in ipairs(resources) do
local api_call = all_resources[resource.type]
table.insert(api_calls, u.merge(api_call, { refresh = resource.refresh }))
end
-- TODO: Build an async "parallel" that fetches the resources
-- in parallel where possible to speed up this API
return async.sequence(api_calls, function()
local data = {}
for k, v in pairs(all_resources) do
data[k] = state[v.state]
end
cb(data)
end)()
end
return M

View File

@@ -398,10 +398,6 @@ local function get_new_line(node)
return node.new_line return node.new_line
end end
if range.start.new_line ~= nil then
return range.start.new_line
end
local _, start_new_line = common.parse_line_code(range.start.line_code) local _, start_new_line = common.parse_line_code(range.start.line_code)
return start_new_line return start_new_line
end end
@@ -417,10 +413,6 @@ local function get_old_line(node)
return node.old_line return node.old_line
end end
if range.start.old_line ~= nil then
return range.start.old_line
end
local start_old_line, _ = common.parse_line_code(range.start.line_code) local start_old_line, _ = common.parse_line_code(range.start.line_code)
return start_old_line return start_old_line
end end

View File

@@ -15,18 +15,11 @@ M.delete_label = function()
end end
local refresh_label_state = function(labels) local refresh_label_state = function(labels)
state.INFO.labels = List.new(labels):reduce(function(agg, label) state.INFO.labels = labels
return agg .. "," .. label
end, "")
end end
local get_current_labels = function() local get_current_labels = function()
local label_string = state.INFO.labels return state.INFO.labels
local current_labels = {}
for value in label_string:gmatch("[^,]+") do
table.insert(current_labels, value)
end
return current_labels
end end
local get_all_labels = function() local get_all_labels = function()
@@ -45,16 +38,11 @@ M.add_popup = function(type)
if not choice then if not choice then
return return
end end
local label_string = state.INFO.labels table.insert(current_labels, choice)
local new_labels = {} local body = { labels = current_labels }
for value in label_string:gmatch("[^,]+") do
table.insert(new_labels, value)
end
table.insert(new_labels, choice)
local body = { labels = new_labels }
job.run_job("/mr/" .. type, "PUT", body, function(data) job.run_job("/mr/" .. type, "PUT", body, function(data)
u.notify(data.message, vim.log.levels.INFO) u.notify(data.message, vim.log.levels.INFO)
refresh_label_state(data.labels) refresh_label_state(data.labels)
end) end)
end) end)

View File

@@ -7,12 +7,12 @@ local job = require("gitlab.job")
local u = require("gitlab.utils") local u = require("gitlab.utils")
local M = { local M = {
pipeline_jobs = nil, pipeline_jobs = nil,
latest_pipeline = nil,
pipeline_popup = nil, pipeline_popup = nil,
} }
local function get_pipeline() local function get_latest_pipeline()
local pipeline = state.INFO.head_pipeline or state.INFO.pipeline local pipeline = state.PIPELINE and state.PIPELINE.latest_pipeline
if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then
u.notify("Pipeline not found", vim.log.levels.WARN) u.notify("Pipeline not found", vim.log.levels.WARN)
return return
@@ -20,97 +20,93 @@ local function get_pipeline()
return pipeline return pipeline
end end
M.get_pipeline_status = function() local function get_pipeline_jobs()
local pipeline = get_pipeline() M.latest_pipeline = get_latest_pipeline()
if pipeline == nil then if not M.latest_pipeline then
return nil return
end end
return string.format("%s (%s)", state.settings.pipeline[pipeline.status], pipeline.status) return u.reverse(type(state.PIPELINE.jobs) == "table" and state.PIPELINE.jobs or {})
end end
-- The function will render the Pipeline state in a popup -- The function will render the Pipeline state in a popup
M.open = function() M.open = function()
local pipeline = get_pipeline() M.pipeline_jobs = get_pipeline_jobs()
if not pipeline then M.latest_pipeline = get_latest_pipeline()
if M.latest_pipeline == nil then
return return
end end
job.run_job("/pipeline/" .. pipeline.id, "GET", nil, function(data) local width = string.len(M.latest_pipeline.web_url) + 10
local pipeline_jobs = u.reverse(type(data.Jobs) == "table" and data.Jobs or {}) local height = 6 + #M.pipeline_jobs + 3
M.pipeline_jobs = pipeline_jobs
local width = string.len(pipeline.web_url) + 10 local pipeline_popup =
local height = 6 + #pipeline_jobs + 3 Popup(u.create_popup_state("Loading Pipeline...", state.settings.popup.pipeline, width, height, 60))
M.pipeline_popup = pipeline_popup
pipeline_popup:mount()
local pipeline_popup = local bufnr = vim.api.nvim_get_current_buf()
Popup(u.create_popup_state("Loading Pipeline...", state.settings.popup.pipeline, width, height, 60)) vim.opt_local.wrap = false
M.pipeline_popup = pipeline_popup
pipeline_popup:mount()
local bufnr = vim.api.nvim_get_current_buf() local lines = {}
vim.opt_local.wrap = false
local lines = {} u.switch_can_edit_buf(bufnr, true)
table.insert(lines, "Status: " .. M.get_pipeline_status(false))
table.insert(lines, "")
table.insert(lines, string.format("Last Run: %s", u.time_since(M.latest_pipeline.created_at)))
table.insert(lines, string.format("Url: %s", M.latest_pipeline.web_url))
table.insert(lines, string.format("Triggered By: %s", M.latest_pipeline.source))
u.switch_can_edit_buf(bufnr, true) table.insert(lines, "")
table.insert(lines, "Status: " .. M.get_pipeline_status()) table.insert(lines, "Jobs:")
table.insert(lines, "")
table.insert(lines, string.format("Last Run: %s", u.time_since(pipeline.created_at)))
table.insert(lines, string.format("Url: %s", pipeline.web_url))
table.insert(lines, string.format("Triggered By: %s", pipeline.source))
table.insert(lines, "") local longest_title = u.get_longest_string(u.map(M.pipeline_jobs, function(v)
table.insert(lines, "Jobs:") return v.name
end))
local longest_title = u.get_longest_string(u.map(pipeline_jobs, function(v) local function row_offset(name)
return v.name local offset = longest_title - string.len(name)
end)) local res = string.rep(" ", offset + 5)
return res
end
local function row_offset(name) for _, pipeline_job in ipairs(M.pipeline_jobs) do
local offset = longest_title - string.len(name) local offset = row_offset(pipeline_job.name)
local res = string.rep(" ", offset + 5) local row = string.format(
return res "%s%s %s (%s)",
pipeline_job.name,
offset,
state.settings.pipeline[pipeline_job.status] or "*",
pipeline_job.status or ""
)
table.insert(lines, row)
end
vim.schedule(function()
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
M.color_status(M.latest_pipeline.status, bufnr, lines[1], 1)
for i, pipeline_job in ipairs(M.pipeline_jobs) do
M.color_status(pipeline_job.status, bufnr, lines[7 + i], 7 + i)
end end
for _, pipeline_job in ipairs(pipeline_jobs) do pipeline_popup.border:set_text("top", "Pipeline Status", "center")
local offset = row_offset(pipeline_job.name) state.set_popup_keymaps(pipeline_popup, M.retrigger, M.see_logs)
local row = string.format( u.switch_can_edit_buf(bufnr, false)
"%s%s %s (%s)",
pipeline_job.name,
offset,
state.settings.pipeline[pipeline_job.status] or "*",
pipeline_job.status or ""
)
table.insert(lines, row)
end
vim.schedule(function()
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
M.color_status(pipeline.status, bufnr, lines[1], 1)
for i, pipeline_job in ipairs(pipeline_jobs) do
M.color_status(pipeline_job.status, bufnr, lines[7 + i], 7 + i)
end
pipeline_popup.border:set_text("top", "Pipeline Status", "center")
state.set_popup_keymaps(pipeline_popup, M.retrigger, M.see_logs)
u.switch_can_edit_buf(bufnr, false)
end)
end) end)
end end
M.retrigger = function() M.retrigger = function()
local pipeline = get_pipeline() M.latest_pipeline = get_latest_pipeline()
if not pipeline then if not M.latest_pipeline then
return return
end end
if pipeline.status ~= "failed" then if M.latest_pipeline.status ~= "failed" then
u.notify("Pipeline is not in a failed state!", vim.log.levels.WARN) u.notify("Pipeline is not in a failed state!", vim.log.levels.WARN)
return return
end end
job.run_job("/pipeline/" .. pipeline.id, "POST", nil, function() job.run_job("/pipeline/" .. M.latest_pipeline.id, "POST", nil, function()
u.notify("Pipeline re-triggered!", vim.log.levels.INFO) u.notify("Pipeline re-triggered!", vim.log.levels.INFO)
end) end)
end end
@@ -173,6 +169,42 @@ M.see_logs = function()
end) end)
end end
---Returns the user-defined symbol representing the status
---of the current pipeline. Takes an optional argument to
---colorize the pipeline icon.
---@param wrap_with_color boolean
---@return string
M.get_pipeline_icon = function(wrap_with_color)
M.latest_pipeline = get_latest_pipeline()
if not M.latest_pipeline then
return ""
end
local symbol = state.settings.pipeline[M.latest_pipeline.status]
if not wrap_with_color then
return symbol
end
if M.latest_pipeline.status == "failed" then
return "%#DiagnosticError#" .. symbol
end
if M.latest_pipeline.status == "success" then
return "%#DiagnosticOk#" .. symbol
end
return "%#DiagnosticWarn#" .. symbol
end
---Returns the status of the latest pipeline and the symbol
--representing the status of the current pipeline. Takes an optional argument to
---colorize the pipeline icon.
---@param wrap_with_color boolean
---@return string
M.get_pipeline_status = function(wrap_with_color)
M.latest_pipeline = get_latest_pipeline()
if not M.latest_pipeline then
return ""
end
return string.format("%s (%s)", M.get_pipeline_icon(wrap_with_color), M.latest_pipeline.status)
end
M.color_status = function(status, bufnr, status_line, linnr) M.color_status = function(status, bufnr, status_line, linnr)
local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") local ns_id = vim.api.nvim_create_namespace("GitlabNamespace")
vim.cmd(string.format("highlight default StatusHighlight guifg=%s", state.settings.pipeline[status])) vim.cmd(string.format("highlight default StatusHighlight guifg=%s", state.settings.pipeline[status]))

View File

@@ -8,7 +8,6 @@ local u = require("gitlab.utils")
local List = require("gitlab.utils.list") local List = require("gitlab.utils.list")
local state = require("gitlab.state") local state = require("gitlab.state")
local miscellaneous = require("gitlab.actions.miscellaneous") local miscellaneous = require("gitlab.actions.miscellaneous")
local pipeline = require("gitlab.actions.pipeline")
local M = { local M = {
layout_visible = false, layout_visible = false,
@@ -134,12 +133,16 @@ M.build_info_lines = function()
assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") }, assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") },
reviewers = { title = "Reviewers", content = u.make_readable_list(info.reviewers, "name") }, reviewers = { title = "Reviewers", content = u.make_readable_list(info.reviewers, "name") },
branch = { title = "Branch", content = info.source_branch }, branch = { title = "Branch", content = info.source_branch },
labels = { title = "Labels", content = u.make_comma_separated_readable(info.labels) }, labels = { title = "Labels", content = table.concat(info.labels, ", ") },
target_branch = { title = "Target Branch", content = state.INFO.target_branch }, target_branch = { title = "Target Branch", content = state.INFO.target_branch },
pipeline = { pipeline = {
title = "Pipeline Status", title = "Pipeline Status",
content = function() content = function()
return pipeline.get_pipeline_status() local pipeline = state.INFO.pipeline
if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then
return ""
end
return pipeline.status
end, end,
}, },
} }

View File

@@ -8,6 +8,7 @@ local reviewer = require("gitlab.reviewer")
local discussions = require("gitlab.actions.discussions") local discussions = require("gitlab.actions.discussions")
local merge = require("gitlab.actions.merge") local merge = require("gitlab.actions.merge")
local summary = require("gitlab.actions.summary") local summary = require("gitlab.actions.summary")
local data = require("gitlab.actions.data")
local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers") local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers")
local comment = require("gitlab.actions.comment") local comment = require("gitlab.actions.comment")
local pipeline = require("gitlab.actions.pipeline") local pipeline = require("gitlab.actions.pipeline")
@@ -19,6 +20,7 @@ local user = state.dependencies.user
local info = state.dependencies.info local info = state.dependencies.info
local labels_dep = state.dependencies.labels local labels_dep = state.dependencies.labels
local project_members = state.dependencies.project_members local project_members = state.dependencies.project_members
local latest_pipeline = state.dependencies.latest_pipeline
local revisions = state.dependencies.revisions local revisions = state.dependencies.revisions
return { return {
@@ -34,7 +36,10 @@ return {
emoji.init() -- Read in emojis for lookup purposes emoji.init() -- Read in emojis for lookup purposes
end, end,
-- Global Actions 🌎 -- Global Actions 🌎
summary = async.sequence({ u.merge(info, { refresh = true }), labels_dep }, summary.summary), summary = async.sequence({
u.merge(info, { refresh = true }),
labels_dep,
}, summary.summary),
approve = async.sequence({ info }, approvals.approve), approve = async.sequence({ info }, approvals.approve),
revoke = async.sequence({ info }, approvals.revoke), revoke = async.sequence({ info }, approvals.revoke),
add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer), add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer),
@@ -55,7 +60,7 @@ return {
close_review = function() close_review = function()
reviewer.close() reviewer.close()
end, end,
pipeline = async.sequence({ info }, pipeline.open), pipeline = async.sequence({ latest_pipeline }, pipeline.open),
merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge),
-- Discussion Tree Actions 🌴 -- Discussion Tree Actions 🌴
toggle_discussions = async.sequence({ info, user }, discussions.toggle), toggle_discussions = async.sequence({ info, user }, discussions.toggle),
@@ -65,6 +70,7 @@ return {
reply = async.sequence({ info }, discussions.reply), reply = async.sequence({ info }, discussions.reply),
-- Other functions 🤷 -- Other functions 🤷
state = state, state = state,
data = data.data,
print_settings = state.print_settings, print_settings = state.print_settings,
open_in_browser = async.sequence({ info }, function() open_in_browser = async.sequence({ info }, function()
if state.INFO.web_url == nil then if state.INFO.web_url == nil then

View File

@@ -26,7 +26,6 @@ M.settings = {
attachment_dir = "", attachment_dir = "",
help = "g?", help = "g?",
popup = { popup = {
exit = "<Esc>",
perform_action = "<leader>s", perform_action = "<leader>s",
perform_linewise_action = "<leader>l", perform_linewise_action = "<leader>l",
width = "40%", width = "40%",
@@ -40,7 +39,6 @@ M.settings = {
help = nil, help = nil,
pipeline = nil, pipeline = nil,
squash_message = nil, squash_message = nil,
backup_register = nil,
}, },
discussion_tree = { discussion_tree = {
auto_open = true, auto_open = true,
@@ -270,10 +268,6 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts)
if opts == nil then if opts == nil then
opts = {} opts = {}
end end
vim.keymap.set("n", M.settings.popup.exit, function()
exit(popup, opts)
end, { buffer = popup.bufnr, desc = "Exit popup" })
if action ~= "Help" then -- Don't show help on the help popup if action ~= "Help" then -- Don't show help on the help popup
vim.keymap.set("n", M.settings.help, function() vim.keymap.set("n", M.settings.help, function()
local help = require("gitlab.actions.help") local help = require("gitlab.actions.help")
@@ -283,9 +277,6 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts)
if action ~= nil then if action ~= nil then
vim.keymap.set("n", M.settings.popup.perform_action, function() vim.keymap.set("n", M.settings.popup.perform_action, function()
local text = u.get_buffer_text(popup.bufnr) local text = u.get_buffer_text(popup.bufnr)
if M.settings.popup.backup_register ~= nil then
vim.cmd("0,$yank " .. M.settings.popup.backup_register)
end
if opts.action_before_close then if opts.action_before_close then
action(text, popup.bufnr) action(text, popup.bufnr)
exit(popup, opts) exit(popup, opts)
@@ -304,6 +295,13 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts)
linewise_action(text) linewise_action(text)
end, { buffer = popup.bufnr, desc = "Perform linewise action" }) end, { buffer = popup.bufnr, desc = "Perform linewise action" })
end end
vim.api.nvim_create_autocmd("BufUnload", {
buffer = popup.bufnr,
callback = function()
exit(popup, opts)
end,
})
end end
-- Dependencies -- Dependencies
@@ -314,6 +312,7 @@ end
M.dependencies = { M.dependencies = {
user = { endpoint = "/users/me", key = "user", state = "USER", refresh = false }, user = { endpoint = "/users/me", key = "user", state = "USER", refresh = false },
info = { endpoint = "/mr/info", key = "info", state = "INFO", 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 }, labels = { endpoint = "/mr/label", key = "labels", state = "LABELS", refresh = false },
revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false },
project_members = { project_members = {