diff --git a/.github/workflows/update-gomod2nix.yaml b/.github/workflows/update-gomod2nix.yaml
new file mode 100644
index 00000000..952a45a8
--- /dev/null
+++ b/.github/workflows/update-gomod2nix.yaml
@@ -0,0 +1,30 @@
+name: Update gomod2nix
+
+on:
+ push:
+ branches:
+ - develop
+ paths:
+ - go.mod
+ - go.sum
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: cachix/install-nix-action@v27
+ with:
+ nix_path: nixpkgs=channel:nixos-unstable
+
+ - name: Generate gomod2nix.toml
+ run: nix run github:nix-community/gomod2nix -- generate
+
+ - name: Commit updated gomod2nix.toml
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "chore: update gomod2nix.toml"
+ file_pattern: gomod2nix.toml
diff --git a/README.md b/README.md
index 12ca0cb7..4b12b1fe 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ With Lazy:
dependencies = {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim",
- "sindrets/diffview.nvim",
+ "dlyongemallo/diffview.nvim", -- Maintained fork of "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.
},
@@ -59,7 +59,7 @@ And with pckr.nvim:
requires = {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim",
- "sindrets/diffview.nvim",
+ "dlyongemallo/diffview.nvim", -- Maintained fork of "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.
},
@@ -73,6 +73,8 @@ And with pckr.nvim:
Add `branch = "develop",` to your configuration if you want to use the (possibly unstable) development version of `gitlab.nvim`.
+`gitlab.nvim` uses the `diffview.nvim` plugin for showing the diffs in a MR. We recommend using [dlyongemallo's](https://github.com/dlyongemallo/diffview.nvim) fork which is the de-facto maintained version of the plugin with many fixes and improvements (e.g., marking files as viewed).
+
## Contributing
Contributions to the plugin are welcome. Please read [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before you start working on a pull request.
@@ -140,6 +142,7 @@ glrd Delete reviewer
glA Approve MR
glR Revoke MR approval
glM Merge the feature branch to the target branch and close MR
+glm Set MR to merge automatically when the pipeline succeeds
glC Create a new MR for currently checked-out feature branch
glc Chose MR for review
glS Start review for the currently checked-out branch
diff --git a/cmd/app/client.go b/cmd/app/client.go
index 30e9c827..0663d2d0 100644
--- a/cmd/app/client.go
+++ b/cmd/app/client.go
@@ -32,6 +32,7 @@ type Client struct {
gitlab.UsersServiceInterface
gitlab.DraftNotesServiceInterface
gitlab.ProjectMarkdownUploadsServiceInterface
+ gitlab.GraphQLInterface
}
/* NewClient parses and validates the project settings and initializes the Gitlab client. */
@@ -100,6 +101,7 @@ func NewClient() (*Client, error) {
client.Users,
client.DraftNotes,
client.ProjectMarkdownUploads,
+ client.GraphQL,
}, nil
}
diff --git a/cmd/app/list_discussions.go b/cmd/app/list_discussions.go
index d0f3fe76..74a9a333 100644
--- a/cmd/app/list_discussions.go
+++ b/cmd/app/list_discussions.go
@@ -2,6 +2,7 @@ package app
import (
"net/http"
+ "slices"
"sort"
"sync"
"time"
@@ -90,18 +91,16 @@ func (a discussionsListerService) ServeHTTP(w http.ResponseWriter, r *http.Reque
},
}
- discussions, res, err := a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions)
+ it, hasErr := gitlab.Scan(func(p gitlab.PaginationOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) {
+ return a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions, p)
+ })
+ discussions := slices.Collect(it)
- if err != nil {
+ if err := hasErr(); err != nil {
handleError(w, err, "Could not list discussions", http.StatusInternalServerError)
return
}
- if res.StatusCode >= 300 {
- handleError(w, GenericError{r.URL.Path}, "Could not list discussions", res.StatusCode)
- return
- }
-
/* Filter out any discussions started by a blacklisted user
and system discussions, then return them sorted by created date */
var unlinkedDiscussions []*gitlab.Discussion
@@ -124,7 +123,7 @@ func (a discussionsListerService) ServeHTTP(w http.ResponseWriter, r *http.Reque
/* Collect IDs in order to fetch emojis */
var noteIds []int64
- for _, discussion := range discussions {
+ for _, discussion := range slices.Concat(linkedDiscussions, unlinkedDiscussions) {
for _, note := range discussion.Notes {
noteIds = append(noteIds, note.ID)
}
diff --git a/cmd/app/list_discussions_test.go b/cmd/app/list_discussions_test.go
index 5d43f1d9..ab142a4c 100644
--- a/cmd/app/list_discussions_test.go
+++ b/cmd/app/list_discussions_test.go
@@ -127,17 +127,6 @@ func TestListDiscussions(t *testing.T) {
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not list discussions")
})
- t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
- request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}})
- svc := middleware(
- discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}},
- withMr(testProjectData, fakeMergeRequestLister{}),
- withPayloadValidation(methodToPayload{http.MethodPost: newPayload[DiscussionsRequest]}),
- withMethodCheck(http.MethodPost),
- )
- data, _ := getFailData(t, svc, request)
- checkNon200(t, data, "Could not list discussions", "/mr/discussions/list")
- })
t.Run("Handles error from emoji service", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}})
svc := middleware(
diff --git a/cmd/app/merge_mr.go b/cmd/app/merge_mr.go
index f4f2e878..eb839622 100644
--- a/cmd/app/merge_mr.go
+++ b/cmd/app/merge_mr.go
@@ -8,6 +8,7 @@ import (
)
type AcceptMergeRequestRequest struct {
+ AutoMerge bool `json:"auto_merge"`
DeleteBranch bool `json:"delete_branch"`
SquashMessage string `json:"squash_message"`
Squash bool `json:"squash"`
@@ -27,6 +28,7 @@ func (a mergeRequestAccepterService) ServeHTTP(w http.ResponseWriter, r *http.Re
payload := r.Context().Value(payload("payload")).(*AcceptMergeRequestRequest)
opts := gitlab.AcceptMergeRequestOptions{
+ AutoMerge: &payload.AutoMerge,
Squash: &payload.Squash,
ShouldRemoveSourceBranch: &payload.DeleteBranch,
}
@@ -47,7 +49,13 @@ func (a mergeRequestAccepterService) ServeHTTP(w http.ResponseWriter, r *http.Re
return
}
- response := SuccessResponse{Message: "MR merged successfully"}
+ var message string
+ if payload.AutoMerge {
+ message = "MR set to be merged when all checks pass"
+ } else {
+ message = "MR merged successfully"
+ }
+ response := SuccessResponse{Message: message}
w.WriteHeader(http.StatusOK)
diff --git a/cmd/app/mergeability_checks.go b/cmd/app/mergeability_checks.go
new file mode 100644
index 00000000..b64db6ef
--- /dev/null
+++ b/cmd/app/mergeability_checks.go
@@ -0,0 +1,83 @@
+package app
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ gitlab "gitlab.com/gitlab-org/api/client-go"
+)
+
+type MergeabilityCheck struct {
+ Identifier string `json:"identifier"`
+ Status string `json:"status"`
+}
+
+type MergeabilityChecksResponse struct {
+ SuccessResponse
+ MergeabilityChecks []*MergeabilityCheck `json:"mergeability_checks"`
+}
+
+type mergeabilityChecksGraphQLResponse struct {
+ Data struct {
+ Project struct {
+ MergeRequest struct {
+ MergeabilityChecks []*MergeabilityCheck `json:"mergeabilityChecks"`
+ } `json:"mergeRequest"`
+ } `json:"project"`
+ } `json:"data"`
+}
+
+const mergeabilityChecksQuery = `
+query GetMergeabilityChecks($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ mergeabilityChecks {
+ identifier
+ status
+ }
+ }
+ }
+}
+`
+
+type mergeabilityChecksService struct {
+ data
+ client gitlab.GraphQLInterface
+}
+
+func (a mergeabilityChecksService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ checks, err := a.fetchMergeabilityChecks()
+ if err != nil {
+ handleError(w, err, "Could not get mergeability checks", http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ response := MergeabilityChecksResponse{
+ SuccessResponse: SuccessResponse{Message: "Mergeability checks retrieved"},
+ MergeabilityChecks: checks,
+ }
+
+ err = json.NewEncoder(w).Encode(response)
+ if err != nil {
+ handleError(w, err, "Could not encode response", http.StatusInternalServerError)
+ }
+}
+
+func (a mergeabilityChecksService) fetchMergeabilityChecks() ([]*MergeabilityCheck, error) {
+ var response mergeabilityChecksGraphQLResponse
+
+ _, err := a.client.Do(gitlab.GraphQLQuery{
+ Query: mergeabilityChecksQuery,
+ Variables: map[string]any{
+ "projectPath": a.gitInfo.ProjectPath(),
+ "iid": fmt.Sprintf("%d", a.projectInfo.MergeId),
+ },
+ }, &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch mergeability checks: %w", err)
+ }
+
+ return response.Data.Project.MergeRequest.MergeabilityChecks, nil
+}
diff --git a/cmd/app/mergeability_checks_test.go b/cmd/app/mergeability_checks_test.go
new file mode 100644
index 00000000..45ccef55
--- /dev/null
+++ b/cmd/app/mergeability_checks_test.go
@@ -0,0 +1,121 @@
+package app
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
+ gitlab "gitlab.com/gitlab-org/api/client-go"
+)
+
+type fakeGraphQLClient struct {
+ err error
+ jsonData []byte
+}
+
+func (f fakeGraphQLClient) Do(query gitlab.GraphQLQuery, response any, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+ if f.err != nil {
+ return nil, f.err
+ }
+
+ // Actually unmarshal JSON into the response struct
+ if err := json.Unmarshal(f.jsonData, response); err != nil {
+ return nil, err
+ }
+
+ // if resp, ok := response.(mergeabilityChecksGraphQLResponse); ok {
+ // resp.Data.Project.MergeRequest.MergeabilityChecks = f.checks
+ // }
+
+ return makeResponse(http.StatusOK), nil
+}
+
+var testMergeabilityData = data{
+ projectInfo: &ProjectInfo{MergeId: 123},
+ gitInfo: &git.GitData{
+ BranchName: "feature-branch",
+ Namespace: "test-namespace",
+ ProjectName: "test-project",
+ },
+}
+
+func TestMergeabilityChecksHandler(t *testing.T) {
+ t.Run("Returns mergeability checks", func(t *testing.T) {
+ request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil)
+ client := fakeGraphQLClient{
+ jsonData: []byte(`{
+ "data": {
+ "project": {
+ "mergeRequest": {
+ "mergeabilityChecks": [
+ {"identifier": "CI_MUST_PASS", "status": "SUCCESS"},
+ {"identifier": "CONFLICT", "status": "FAILED"}
+ ]
+ }
+ }
+ }
+ }`),
+ }
+ svc := middleware(
+ mergeabilityChecksService{testMergeabilityData, client},
+ withMethodCheck(http.MethodGet),
+ )
+
+ res := httptest.NewRecorder()
+ svc.ServeHTTP(res, request)
+
+ var data MergeabilityChecksResponse
+ err := json.Unmarshal(res.Body.Bytes(), &data)
+ assert(t, err, nil)
+
+ assert(t, data.Message, "Mergeability checks retrieved")
+ assert(t, len(data.MergeabilityChecks), 2)
+ assert(t, data.MergeabilityChecks[0].Identifier, "CI_MUST_PASS")
+ assert(t, data.MergeabilityChecks[0].Status, "SUCCESS")
+ assert(t, data.MergeabilityChecks[1].Identifier, "CONFLICT")
+ assert(t, data.MergeabilityChecks[1].Status, "FAILED")
+ })
+
+ t.Run("Returns empty list when there are no checks", func(t *testing.T) {
+ request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil)
+ client := fakeGraphQLClient{
+ jsonData: []byte(`{
+ "data": {
+ "project": {
+ "mergeRequest": {
+ "mergeabilityChecks": []
+ }
+ }
+ }
+ }`),
+ }
+ svc := middleware(
+ mergeabilityChecksService{testMergeabilityData, client},
+ withMethodCheck(http.MethodGet),
+ )
+
+ res := httptest.NewRecorder()
+ svc.ServeHTTP(res, request)
+
+ var data MergeabilityChecksResponse
+ err := json.Unmarshal(res.Body.Bytes(), &data)
+ assert(t, err, nil)
+
+ assert(t, data.Message, "Mergeability checks retrieved")
+ assert(t, len(data.MergeabilityChecks), 0)
+ })
+
+ t.Run("Handles errors from Gitlab client", func(t *testing.T) {
+ request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil)
+ client := fakeGraphQLClient{err: errorFromGitlab}
+ svc := middleware(
+ mergeabilityChecksService{testMergeabilityData, client},
+ withMethodCheck(http.MethodGet),
+ )
+ data, _ := getFailData(t, svc, request)
+ assert(t, data.Message, "Could not get mergeability checks")
+ assert(t, data.Details, "failed to fetch mergeability checks: "+errorFromGitlab.Error())
+ })
+}
diff --git a/cmd/app/server.go b/cmd/app/server.go
index 5fff24ed..f143904a 100644
--- a/cmd/app/server.go
+++ b/cmd/app/server.go
@@ -134,6 +134,11 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownSer
withMr(d, gitlabClient),
withMethodCheck(http.MethodGet),
))
+ m.HandleFunc("/mr/info/mergeability", middleware(
+ mergeabilityChecksService{d, gitlabClient},
+ withMr(d, gitlabClient),
+ withMethodCheck(http.MethodGet),
+ ))
m.HandleFunc("/mr/assignee", middleware(
assigneesService{d, gitlabClient},
withMr(d, gitlabClient),
diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt
index 2db9f633..b95b55aa 100644
--- a/doc/gitlab.nvim.txt
+++ b/doc/gitlab.nvim.txt
@@ -9,7 +9,7 @@ Table of Contents *gitlab.nvim.table-of-contents*
- Connecting to Gitlab |gitlab.nvim.connecting-to-gitlab|
- Configuring the Plugin |gitlab.nvim.configuring-the-plugin|
- Usage |gitlab.nvim.usage|
- - The Summary view |gitlab.nvim.the-summary-view|
+ - The Summary view |gitlab.nvim.summary-view|
- Reviewing an MR |gitlab.nvim.reviewing-an-mr|
- Temporary registers |gitlab.nvim.temp-registers|
- Discussions and Notes |gitlab.nvim.discussions-and-notes|
@@ -64,7 +64,7 @@ With Lazy:
dependencies = {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim",
- "sindrets/diffview.nvim",
+ "dlyongemallo/diffview.nvim", -- Maintained fork of "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.
},
@@ -81,7 +81,7 @@ And with pckr.nvim:
requires = {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim",
- "sindrets/diffview.nvim",
+ "dlyongemallo/diffview.nvim", -- Maintained fork of "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.
},
@@ -144,7 +144,10 @@ Here is the default setup function. All of these values are optional, and if
you call this function with no values the defaults will be used:
>lua
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
+ server = {
+ binary = nil, -- The path to the server binary. If omitted or nil, the server will be built
+ 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 = {
@@ -179,6 +182,7 @@ you call this function with no values the defaults will be used:
approve = "glA", -- Approve MR
revoke = "glR", -- Revoke MR approval
merge = "glM", -- Merge the feature branch to the target branch and close MR
+ set_auto_merge = "glm", -- Set MR to merge automatically when the pipeline succeeds
create_mr = "glC", -- Create a new MR for currently checked-out feature branch
choose_merge_request = "glc", -- Chose MR for review (if necessary check out the feature branch)
start_review = "glS", -- Start review for the currently checked-out branch
@@ -308,10 +312,45 @@ you call this function with no values the defaults will be used:
"pipeline",
"branch",
"target_branch",
+ "auto_merge",
"delete_branch",
"squash",
"labels",
"web_url",
+ "mergeability_checks", -- See more detailed configuration below
+ },
+ -- Settings for the mergeability checks in the summary view
+ -- https://docs.gitlab.com/api/graphql/reference/#mergeabilitycheckidentifier
+ mergeability_checks = {
+ -- Symbols for individual check statuses. Set values to `false` to hide checks with given status from summary
+ statuses = {
+ SUCCESS = "✅",
+ CHECKING = "🔁",
+ FAILED = "❌",
+ WARNING = "⚠️",
+ INACTIVE = "💤",
+ },
+ -- Descriptions for individual checks. Set values to `false` to hide given checks from summary
+ checks = {
+ CI_MUST_PASS = "Pipeline must succeed",
+ COMMITS_STATUS = "Source branch exists and contains commits",
+ CONFLICT = "Merge conflicts must be resolved",
+ DISCUSSIONS_NOT_RESOLVED = "Open threads must be resolved",
+ DRAFT_STATUS = "Merge request must not be draft",
+ JIRA_ASSOCIATION_MISSING = "Title or description references a Jira issue",
+ LOCKED_LFS_FILES = "All LFS files must be unlocked",
+ LOCKED_PATHS = "All paths must be unlocked",
+ MERGE_REQUEST_BLOCKED = "Merge request is not blocked",
+ MERGE_TIME = "Merge is not blocked due to a scheduled merge time",
+ NEED_REBASE = "Merge request must be rebased, fast-forward merge is not possible",
+ NOT_APPROVED = "All required approvals must be given",
+ NOT_OPEN = "Merge request must be open",
+ REQUESTED_CHANGES = "Change requests must be approved by the requesting user",
+ SECURITY_POLICY_PIPELINE_CHECK = "Security policy pipeline must succeed",
+ SECURITY_POLICY_VIOLATIONS = "Security policies are satisfied",
+ STATUS_CHECKS_MUST_PASS = "External status checks pass",
+ TITLE_REGEX = "Title matches the expected regex",
+ },
},
},
discussion_signs = {
@@ -382,7 +421,7 @@ Then open Neovim. To begin, try running the `summary` command or the `review`
command.
-THE SUMMARY VIEW *gitlab.nvim.the-summary-view*
+THE SUMMARY VIEW *gitlab.nvim.summary-view*
The `summary` action will open the MR title and description:
>lua
@@ -678,7 +717,7 @@ by a |motion|.
Either the operator or the motion can be preceded by a count, so that `3sj` is
equivalent to `s3j`, and they both create a comment for the current line and
three more lines downwards. Similarly, both `2s`|ap| and `s2`|ap| create a suggestion
-for two "outer" paragraphs.
+for two "outer" paragraphs.
The operators force |linewise| visual selection, so they work correctly even
if the motion itself works |characterwise| (e.g., |i(| for selecting the inner
@@ -1025,18 +1064,25 @@ Copies the URL of the current MR to system clipboard.
*gitlab.nvim.merge*
gitlab.merge({opts}) ~
-Merges the merge request into the target branch. When run without any
-arguments, the `merge` action will respect the "Squash commits" and "Delete
-source branch" settings set by `require("gitlab").create_mr()` or set in
-Gitlab online. You can see the current settings in the Summary view, see
-|gitlab.nvim.the-summary-view|.
+Merges a mergeable merge request into the target branch. The behaviour can be
+configured with the `opts` parameter. By default, the `merge` action respects
+the "Auto-merge" setting, and the "Squash commits" and "Delete source branch"
+settings set by `require("gitlab").create_mr()` (or in Gitlab's "Edit merge
+request" page). You can see the current settings in the Summary view, see
+|gitlab.nvim.summary-view|.
>lua
require("gitlab").merge()
- require("gitlab").merge({ squash = false, delete_branch = true })
+ require("gitlab").merge({
+ auto_merge = true, squash = false, delete_branch = false
+ })
<
Parameters: ~
• {opts}: (table|nil) Keyword arguments that can be used to override
- default behavior.
+ individual current settings.
+ • {auto_merge}: (bool) If true, the merge request is set to merge
+ automatically when the pipeline succeeds. If false, an immediate
+ merge is attempted. Currently, the plugin doesn't allow
+ cancelling the auto-merge.
• {delete_branch}: (bool) If true, the source branch will be
deleted.
• {squash}: (bool) If true, the commits will be squashed. If
@@ -1084,7 +1130,7 @@ execute and passed the data as an argument.
with each resource as a key-value pair, with the key being it's
type.
- *gitlab.nvim.refresh_data*
+ *gitlab.nvim.refresh_data*
gitlab.refresh_data() ~
Fetches discussion tree data from Gitlab and refreshes the tree views. It can
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..8f8b8f30
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1770770419,
+ "narHash": "sha256-iKZMkr6Cm9JzWlRYW/VPoL0A9jVKtZYiU4zSrVeetIs=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "6c5e707c6b5339359a9a9e215c5e66d6d802fd7a",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-25.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..2563bb7e
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,56 @@
+{
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
+ flake-utils.url = "github:numtide/flake-utils";
+ gomod2nix = {
+ url = "github:nix-community/gomod2nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs = { self, flake-utils, nixpkgs, gomod2nix }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ pkgs = import nixpkgs {
+ inherit system;
+ overlays = [ gomod2nix.overlays.default ];
+ };
+ gitlab-nvim-server = pkgs.buildGoApplication {
+ pname = "gitlab.nvim-server";
+ version = "git";
+ src = ./.;
+ modules = ./gomod2nix.toml;
+ subPackages = [ "cmd" ];
+ postInstall = ''
+ cp -r ${./cmd/config} $out/bin/config
+ mv $out/bin/cmd $out/bin/gitlab.nvim
+ '';
+ };
+ gitlab-nvim = pkgs.vimUtils.buildVimPlugin {
+ name = "gitlab.nvim";
+ src = ./.;
+ doCheck = false;
+ };
+ in
+ {
+ formatter = pkgs.nixpkgs-fmt;
+ packages.gitlab-nvim-server = gitlab-nvim-server;
+ packages.gitlab-nvim = gitlab-nvim;
+ packages.default = gitlab-nvim;
+ devShells.default = pkgs.mkShell {
+ packages = with pkgs; [
+ git
+ go
+ go-tools
+ gomod2nix
+ golangci-lint
+ luajitPackages.busted
+ luajitPackages.luacheck
+ luajitPackages.luarocks
+ neovim
+ stylua
+ ];
+ };
+ }
+ );
+}
diff --git a/gomod2nix.toml b/gomod2nix.toml
new file mode 100644
index 00000000..cb1c49f7
--- /dev/null
+++ b/gomod2nix.toml
@@ -0,0 +1,66 @@
+schema = 3
+
+[mod]
+ [mod.'github.com/gabriel-vasile/mimetype']
+ version = 'v1.4.3'
+ hash = 'sha256-EDmlRi3av27dq/ISVTglv08z4yZzMQ/SxL1c46EJro0='
+
+ [mod.'github.com/go-playground/locales']
+ version = 'v0.14.1'
+ hash = 'sha256-BMJGAexq96waZn60DJXZfByRHb8zA/JP/i6f/YrW9oQ='
+
+ [mod.'github.com/go-playground/universal-translator']
+ version = 'v0.18.1'
+ hash = 'sha256-2/B2qP51zfiY+k8G0w0D03KXUc7XpWj6wKY7NjNP/9E='
+
+ [mod.'github.com/go-playground/validator/v10']
+ version = 'v10.22.1'
+ hash = 'sha256-EsgeltH0ow6saxLvTFVtIyHVqWI3Fiu1AE2Qmnsmowg='
+
+ [mod.'github.com/google/go-cmp']
+ version = 'v0.7.0'
+ hash = 'sha256-JbxZFBFGCh/Rj5XZ1vG94V2x7c18L8XKB0N9ZD5F2rM='
+
+ [mod.'github.com/google/go-querystring']
+ version = 'v1.2.0'
+ hash = 'sha256-F/Ve4oDaEqho8RryvdGSRR22/DbYHWZQa6M60n6oSYM='
+
+ [mod.'github.com/hashicorp/go-cleanhttp']
+ version = 'v0.5.2'
+ hash = 'sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ='
+
+ [mod.'github.com/hashicorp/go-retryablehttp']
+ version = 'v0.7.8'
+ hash = 'sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80='
+
+ [mod.'github.com/leodido/go-urn']
+ version = 'v1.4.0'
+ hash = 'sha256-Q6kplWkY37Tzy6GOme3Wut40jFK4Izun+ij/BJvcEu0='
+
+ [mod.'gitlab.com/gitlab-org/api/client-go']
+ version = 'v1.17.0'
+ hash = 'sha256-PdVbuFXCp/TphltAkpJG3YNXhtqHnhhyel9KLUX/xz0='
+
+ [mod.'golang.org/x/crypto']
+ version = 'v0.19.0'
+ hash = 'sha256-Vi6vY/eWNlYQ9l3Y+gA+X2+h2CmzEOrBRVFO/cnrPWc='
+
+ [mod.'golang.org/x/net']
+ version = 'v0.21.0'
+ hash = 'sha256-LfiqMpPtqvW/eLkfx6Ebr5ksqKbQli6uq06c/+XrBsw='
+
+ [mod.'golang.org/x/oauth2']
+ version = 'v0.34.0'
+ hash = 'sha256-5eqpGGxJ7FJsPmfRek6roeGmkWHBMJaWYXyz8gXJsS4='
+
+ [mod.'golang.org/x/sys']
+ version = 'v0.39.0'
+ hash = 'sha256-dxTBu/JAWUkPbjFIXXRFdhQWyn+YyEpIC+tWqGo0Y6U='
+
+ [mod.'golang.org/x/text']
+ version = 'v0.32.0'
+ hash = 'sha256-9PXtWBKKY9rG4AgjSP4N+I1DhepXhy8SF/vWSIDIoWs='
+
+ [mod.'golang.org/x/time']
+ version = 'v0.14.0'
+ hash = 'sha256-fVjpq0ieUHVEOTSElDVleMWvfdcqojZchqdUXiC7NnY='
diff --git a/lua-test.sh b/lua-test.sh
index 6cf83dc9..8eafc9de 100755
--- a/lua-test.sh
+++ b/lua-test.sh
@@ -10,7 +10,7 @@ PLUGINS_FOLDER="tests/plugins"
PLUGINS=(
"https://github.com/MunifTanjim/nui.nvim"
"https://github.com/nvim-lua/plenary.nvim"
- "https://github.com/sindrets/diffview.nvim"
+ "https://github.com/dlyongemallo/diffview.nvim"
)
if ! command -v luarocks >/dev/null 2>&1; then
@@ -41,4 +41,4 @@ done
# Run tests
echo "Running tests with Neovim..."
-nvim -u NONE -U NONE -N -i NONE -l tests/init.lua "$@"
+LC_TIME=en_US.UTF-8 nvim -u NONE -U NONE -N -i NONE -l tests/init.lua "$@"
diff --git a/lua/gitlab/actions/approvals.lua b/lua/gitlab/actions/approvals.lua
index 12b3e09d..739f00df 100644
--- a/lua/gitlab/actions/approvals.lua
+++ b/lua/gitlab/actions/approvals.lua
@@ -6,8 +6,10 @@ local M = {}
local refresh_status_state = function(data)
u.notify(data.message, vim.log.levels.INFO)
- state.load_new_state("info", function()
- require("gitlab.actions.summary").update_summary_details()
+ state.load_new_state("mergeability", function()
+ state.load_new_state("info", function()
+ require("gitlab.actions.summary").update_summary_details()
+ end)
end)
end
diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua
index 03202a2b..086cd1e6 100644
--- a/lua/gitlab/actions/comment.lua
+++ b/lua/gitlab/actions/comment.lua
@@ -349,6 +349,10 @@ M.can_create_comment = function(must_be_visual)
return false
end
+ if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then
+ return false
+ end
+
-- Check we're in visual mode for code suggestions and multiline comments
if must_be_visual and not u.check_visual_mode() then
return false
diff --git a/lua/gitlab/actions/data.lua b/lua/gitlab/actions/data.lua
index 55aa7948..1959bf52 100644
--- a/lua/gitlab/actions/data.lua
+++ b/lua/gitlab/actions/data.lua
@@ -6,6 +6,7 @@ local M = {}
local user = state.dependencies.user
local info = state.dependencies.info
local labels = state.dependencies.labels
+local mergeability = state.dependencies.mergeability
local project_members = state.dependencies.project_members
local revisions = state.dependencies.revisions
local latest_pipeline = state.dependencies.latest_pipeline
@@ -21,6 +22,7 @@ M.data = function(resources, cb)
info = info,
user = user,
labels = labels,
+ mergeability = mergeability,
project_members = project_members,
revisions = revisions,
pipeline = latest_pipeline,
diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua
index dc0526e3..eb1d6c4c 100644
--- a/lua/gitlab/actions/discussions/init.lua
+++ b/lua/gitlab/actions/discussions/init.lua
@@ -55,6 +55,9 @@ end
---Makes API call to get the discussion data, stores it in the state, and calls the callback
---@param callback function|nil
M.load_discussions = function(callback)
+ local git = require("gitlab.git")
+ local ahead, behind = git.get_ahead_behind(git.get_current_branch(), git.get_remote_branch())
+ state.ahead_behind = { ahead, behind }
state.discussion_tree.last_updated = nil
state.load_new_state("discussion_data", function(data)
if not state.DISCUSSION_DATA then
diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua
index 719c48f7..6dcd6011 100644
--- a/lua/gitlab/actions/discussions/winbar.lua
+++ b/lua/gitlab/actions/discussions/winbar.lua
@@ -94,6 +94,8 @@ local function content()
resolved_notes = resolved_notes,
non_resolvable_notes = non_resolvable_notes,
help_keymap = state.settings.keymaps.help,
+ ahead = state.ahead_behind[1],
+ behind = state.ahead_behind[2],
updated = updated,
}
@@ -158,7 +160,7 @@ end
M.make_winbar = function(t)
local discussions_focused = M.current_view_type == "discussions"
local discussion_text = add_drafts_and_resolvable(
- "Inline Comments:",
+ "Comments:",
t.resolvable_discussions,
t.resolved_discussions,
t.inline_draft_notes,
@@ -190,15 +192,18 @@ M.make_winbar = function(t)
local separator = "%#Comment#|"
local end_section = "%="
local updated = "%#Text#" .. t.updated
+ local ahead_behind = M.get_ahead_behind(t.ahead, t.behind)
local help = "%#Comment#Help: " .. (t.help_keymap and t.help_keymap:gsub(" ", "") .. " " or "unmapped")
return string.format(
- " %s %s %s %s %s %s %s %s %s %s %s",
+ " %s %s %s %s %s %s %s %s %s %s %s %s %s",
discussion_text,
separator,
notes_text,
end_section,
updated,
separator,
+ ahead_behind,
+ separator,
sort_method,
separator,
mode,
@@ -254,6 +259,16 @@ M.get_mode = function()
end
end
+---@param ahead number|nil
+---@param behind number|nil
+M.get_ahead_behind = function(ahead, behind)
+ local a = ahead == nil and "?" or tostring(ahead)
+ local b = behind == nil and "?" or tostring(behind)
+ a = ((a == "?" or a == "0") and "%#Comment#" or "%#WarningMsg#") .. a
+ b = ((b == "?" or b == "0") and "%#Comment#" or "%#WarningMsg#") .. b
+ return a .. "↑ " .. b .. "↓"
+end
+
---Toggles the current view type (or sets it to `override`) and then updates the view.
---@param override "discussions"|"notes" Defines the view type to select.
M.switch_view_type = function(override)
diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua
index 1f0e0e1d..9538678e 100755
--- a/lua/gitlab/actions/draft_notes/init.lua
+++ b/lua/gitlab/actions/draft_notes/init.lua
@@ -11,6 +11,8 @@ local List = require("gitlab.utils.list")
local u = require("gitlab.utils")
local state = require("gitlab.state")
+local branch_not_in_sync_comment = " (even if local branch not in sync with remote)"
+
local M = {}
---Re-fetches all draft notes (and non-draft notes) and re-renders the relevant views
@@ -64,8 +66,10 @@ end
-- This function will trigger a popup prompting you to publish the current draft comment
M.publish_draft = function(tree)
+ local branch_in_sync = git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR)
+ local sync_comment = branch_in_sync and "" or branch_not_in_sync_comment
vim.ui.select({ "Confirm", "Cancel" }, {
- prompt = "Publish current draft comment?",
+ prompt = string.format("Publish current draft comment%s?", sync_comment),
}, function(choice)
if choice == "Confirm" then
M.confirm_publish_draft(tree)
@@ -75,8 +79,10 @@ end
-- This function will trigger a popup prompting you to publish all draft notes
M.publish_all_drafts = function()
+ local branch_in_sync = git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR)
+ local sync_comment = branch_in_sync and "" or branch_not_in_sync_comment
vim.ui.select({ "Confirm", "Cancel" }, {
- prompt = "Publish all drafts?",
+ prompt = string.format("Publish all drafts%s?", sync_comment),
}, function(choice)
if choice == "Confirm" then
M.confirm_publish_all_drafts()
@@ -86,9 +92,6 @@ end
---Publishes all draft notes and comments. Re-renders all discussion views.
M.confirm_publish_all_drafts = function()
- if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then
- return
- end
local body = { publish_all = true }
job.run_job("/mr/draft_notes/publish", "POST", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
@@ -108,9 +111,6 @@ end
---and re-render it.
---@param tree NuiTree
M.confirm_publish_draft = function(tree)
- if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then
- return
- end
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)
diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua
index 81eed3f6..5e0e5478 100644
--- a/lua/gitlab/actions/merge.lua
+++ b/lua/gitlab/actions/merge.lua
@@ -12,19 +12,23 @@ local function create_squash_message_popup()
end
---@class MergeOpts
+---@field auto_merge boolean?
---@field delete_branch boolean?
---@field squash boolean?
---@field squash_message string?
---@param opts MergeOpts
M.merge = function(opts)
- local merge_body = { squash = state.INFO.squash, delete_branch = state.INFO.delete_branch }
- if opts then
- merge_body.squash = opts.squash ~= nil and opts.squash
- merge_body.delete_branch = opts.delete_branch ~= nil and opts.delete_branch
+ local merge_body = {
+ auto_merge = state.INFO.merge_when_pipeline_succeeds,
+ squash = state.INFO.squash,
+ delete_branch = state.INFO.force_remove_source_branch,
+ }
+ for key, val in pairs(opts or {}) do
+ merge_body[key] = val
end
- if state.INFO.detailed_merge_status ~= "mergeable" then
+ if state.INFO.detailed_merge_status ~= "mergeable" and not merge_body.auto_merge then
u.notify(string.format("MR not mergeable, currently '%s'", state.INFO.detailed_merge_status), vim.log.levels.ERROR)
return
end
diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua
index 87afc832..651b7a47 100644
--- a/lua/gitlab/actions/summary.lua
+++ b/lua/gitlab/actions/summary.lua
@@ -8,10 +8,12 @@ local job = require("gitlab.job")
local common = require("gitlab.actions.common")
local u = require("gitlab.utils")
local popup = require("gitlab.popup")
-local List = require("gitlab.utils.list")
local state = require("gitlab.state")
local miscellaneous = require("gitlab.actions.miscellaneous")
+-- No-break space used in summary details to make matching different parts of the line more robust
+local nbsp = " "
+
local M = {
layout_visible = false,
layout = nil,
@@ -108,6 +110,28 @@ M.update_details_popup = function(bufnr, info_lines)
M.color_details(bufnr) -- Color values in details popup
end
+---Return the mergeability checks statuses and descriptions
+---@return string[]
+local make_mergeability_checks = function()
+ local lines = {}
+ for _, check in ipairs(state.MERGEABILITY) do
+ local status = state.settings.mergeability_checks.statuses[check.status]
+ if status == nil then
+ u.notify(string.format("Unknown mergeability check status: %s", check.status), vim.log.levels.ERROR)
+ end
+ if status then
+ local description = state.settings.mergeability_checks.checks[check.identifier]
+ if description == nil then
+ u.notify(string.format("Unknown mergeability check identifier: %s", check.identifier), vim.log.levels.ERROR)
+ end
+ if description then
+ table.insert(lines, status .. " " .. description)
+ end
+ end
+ end
+ return lines
+end
+
-- Builds a lua list of strings that contain metadata about the current MR. Only builds the
-- lines that users include in their state.settings.info.fields list.
M.build_info_lines = function()
@@ -124,6 +148,7 @@ M.build_info_lines = function()
branch = { title = "Branch", content = info.source_branch },
labels = { title = "Labels", content = table.concat(info.labels, ", ") },
target_branch = { title = "Target Branch", content = info.target_branch },
+ auto_merge = { title = "Auto-merge", content = (info.merge_when_pipeline_succeeds and "Yes" or "No") },
delete_branch = {
title = "Delete Source Branch",
content = (info.force_remove_source_branch and "Yes" or "No"),
@@ -132,7 +157,7 @@ M.build_info_lines = function()
pipeline = {
title = "Pipeline Status",
content = function()
- local pipeline = state.INFO.pipeline
+ local pipeline = info.head_pipeline ~= vim.NIL and info.head_pipeline or info.pipeline
if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then
return ""
end
@@ -140,6 +165,7 @@ M.build_info_lines = function()
end,
},
web_url = { title = "MR URL", content = info.web_url },
+ mergeability_checks = { title = "Mergeability checks", content = make_mergeability_checks },
}
local longest_used = ""
@@ -147,33 +173,41 @@ M.build_info_lines = function()
if v == "merge_status" then
v = "detailed_merge_status"
end -- merge_status was deprecated, see https://gitlab.com/gitlab-org/gitlab/-/issues/3169#note_1162532204
- local title = options[v].title
- if string.len(title) > string.len(longest_used) then
- longest_used = title
+ if options[v] == nil then
+ u.notify(string.format("Invalid field in settings.info.fields: '%s'", v), vim.log.levels.ERROR)
+ else
+ local title = options[v].title
+ if vim.fn.strcharlen(title) > vim.fn.strcharlen(longest_used) then
+ longest_used = title
+ end
end
end
local function row_offset(row)
- local offset = string.len(longest_used) - string.len(row)
- return string.rep(" ", offset + 3)
+ local offset = vim.fn.strcharlen(longest_used) - vim.fn.strcharlen(row)
+ return string.rep(nbsp, offset + 3)
end
- return List.new(state.settings.info.fields):map(function(v)
+ local result = {}
+ for _, v in ipairs(state.settings.info.fields) do
if v == "merge_status" then
v = "detailed_merge_status"
end
local row = options[v]
- local line = "* " .. row.title .. row_offset(row.title)
- if type(row.content) == "function" then
- local content = row.content()
- if content ~= nil then
- line = line .. row.content()
+ local title_prefix = "* " .. row.title .. row_offset(row.title)
+ local content = type(row.content) == "function" and row.content() or row.content
+ if type(content) == "table" then
+ -- Multi-line content
+ local padding = string.rep(nbsp, vim.fn.strcharlen(title_prefix)) -- no-break space
+ for i, line in ipairs(#content > 0 and content or { "" }) do
+ table.insert(result, (i == 1 and title_prefix or padding) .. line)
end
else
- line = line .. row.content
+ -- Single-line content
+ table.insert(result, title_prefix .. (content or ""))
end
- return line
- end)
+ end
+ return result
end
-- This function will PUT the new description to the Go server
@@ -260,24 +294,57 @@ end
M.color_details = function(bufnr)
local details_namespace = vim.api.nvim_create_namespace("Details")
- for i, v in ipairs(state.settings.info.fields) do
- if v == "labels" then
- local line_content = u.get_line_content(bufnr, i)
+ for i, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do
+ if line:match("^* Labels") then
for j, label in ipairs(state.LABELS) do
- local start_idx, end_idx = line_content:find(label.Name)
+ local start_idx, end_idx = line:find(label.Name, 1, true)
if start_idx ~= nil and end_idx ~= nil then
vim.cmd("highlight " .. "label" .. j .. " guifg=white")
vim.api.nvim_set_hl(0, ("label" .. j), { fg = label.Color })
- vim.api.nvim_buf_add_highlight(bufnr, details_namespace, ("label" .. j), i - 1, start_idx - 1, end_idx)
+ vim.hl.range(bufnr, details_namespace, ("label" .. j), { i - 1, start_idx - 1 }, { i - 1, end_idx })
end
end
- elseif v == "delete_branch" or v == "squash" or v == "draft" or v == "conflicts" then
- local line_content = u.get_line_content(bufnr, i)
- local start_idx, end_idx = line_content:find("%S-$")
- if start_idx ~= nil and end_idx ~= nil then
- vim.api.nvim_set_hl(0, "boolean", { link = "Constant" })
- vim.api.nvim_buf_add_highlight(bufnr, details_namespace, "boolean", i - 1, start_idx - 1, end_idx)
- end
+ elseif line:match("^* Status") then
+ local status = line:match("[^" .. nbsp .. "]-$")
+ local hl = ({
+ blocked_status = "DiagnosticError",
+ broken_status = "DiagnosticError",
+ checking = "DiagnosticInfo",
+ ci_must_pass = "DiagnosticWarn",
+ ci_still_running = "DiagnosticInfo",
+ discussions_not_resolved = "DiagnosticWarn",
+ draft_status = "Comment",
+ external_status_checks = "DiagnosticHint",
+ mergeable = "DiagnosticOK",
+ not_approved = "DiagnosticWarn",
+ not_open = "NonText",
+ policies_denied = "DiagnosticError",
+ unchecked = "NonText",
+ })[status] or "Normal"
+ local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
+ vim.hl.range(bufnr, details_namespace, hl, { i - 1, start_idx - 1 }, { i - 1, end_idx })
+ elseif line:match("^* Branch") or line:match("^* Target Branch") then
+ local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
+ vim.hl.range(bufnr, details_namespace, "Title", { i - 1, start_idx - 1 }, { i - 1, end_idx })
+ elseif line:match("^* Pipeline") then
+ local status = line:match("[^" .. nbsp .. "]-$")
+ local hl = ({
+ canceled = "DiagnosticWarn",
+ created = "DiagnosticInfo",
+ failed = "DiagnosticError",
+ manual = "DiagnosticHint",
+ pending = "DiagnosticWarn",
+ running = "DiagnosticInfo",
+ skipped = "Comment",
+ success = "DiagnosticOK",
+ unknown = "NonText",
+ })[status] or "Normal"
+ local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
+ vim.hl.range(bufnr, details_namespace, hl, { i - 1, start_idx - 1 }, { i - 1, end_idx })
+ elseif line:match(nbsp .. "No$") or line:match(nbsp .. "Yes$") then
+ local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
+ vim.api.nvim_set_hl(0, "boolean", { link = "Constant" })
+ vim.hl.range(bufnr, details_namespace, "boolean", { i - 1, start_idx - 1 }, { i - 1, end_idx })
end
end
end
diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua
index e1403249..4b8dfa6c 100644
--- a/lua/gitlab/annotations.lua
+++ b/lua/gitlab/annotations.lua
@@ -92,6 +92,8 @@
---@field resolved_notes number
---@field non_resolvable_notes number
---@field help_keymap string
+---@field ahead number|nil -- Number of commits local is ahead of remote
+---@field behind number|nil -- Number of commits local is behind remote
---@field updated string
---
---@class SignTable
@@ -146,7 +148,7 @@
--- Plugin Settings
---
---@class Settings
----@field port? number -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically
+---@field server ServerSettings
---@field remote_branch "origin" | string -- The remote, "origin" by default
---@field log_path? string -- Log path for the Go server
---@field string? any -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section
@@ -159,11 +161,16 @@
---@field discussion_tree? DiscussionSettings -- Settings for the popup windows
---@field choose_merge_request? ChooseMergeRequestSettings -- Default settings when choosing a merge request
---@field info? InfoSettings -- Settings for the "info" or "summary" view
+---@field mergeability_checks? MergeabilityChecksSettings -- Settings for the mergeability checks in the "summary" view
---@field discussion_signs? DiscussionSigns -- The settings for discussion signs/diagnostics
---@field pipeline? PipelineSettings -- The settings for the pipeline popup
---@field create_mr? CreateMrSettings -- The settings when creating an MR
---@field colors? ColorSettings --- Colors settings for the plugin
+---@class ServerSettings
+---@field port? number -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically
+---@field binary? string -- The path to the server binary. If omitted or nil, the server will be built
+
---@class DiscussionSigns: table
---@field enabled? boolean -- Show diagnostics for gitlab comments in the reviewer
---@field skip_resolved_discussion? boolean -- Show diagnostics for resolved discussions
@@ -252,7 +259,38 @@
---@class InfoSettings
---@field horizontal? boolean -- Display metadata to the left of the summary rather than underneath
----@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "delete_branch" | "squash" | "labels")[]
+---@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "auto_merge" | "delete_branch" | "squash" | "labels" | "web_url" | "mergeability_checks")[]
+
+---@class MergeabilityChecksSettings
+---@field statuses MergeabilityStatuses
+---@field checks MergeabilityChecks
+
+---@class MergeabilityStatuses
+---@field SUCCESS string|false
+---@field CHECKING string|false
+---@field FAILED string|false
+---@field WARNING string|false
+---@field INACTIVE string|false
+
+---@class MergeabilityChecks
+---@field CI_MUST_PASS string|false
+---@field COMMITS_STATUS string|false
+---@field CONFLICT string|false
+---@field DISCUSSIONS_NOT_RESOLVED string|false
+---@field DRAFT_STATUS string|false
+---@field JIRA_ASSOCIATION_MISSING string|false
+---@field LOCKED_LFS_FILES string|false
+---@field LOCKED_PATHS string|false
+---@field MERGE_REQUEST_BLOCKED string|false
+---@field MERGE_TIME string|false
+---@field NEED_REBASE string|false
+---@field NOT_APPROVED string|false
+---@field NOT_OPEN string|false
+---@field REQUESTED_CHANGES string|false
+---@field SECURITY_POLICY_PIPELINE_CHECK string|false
+---@field SECURITY_POLICY_VIOLATIONS string|false
+---@field STATUS_CHECKS_MUST_PASS string|false
+---@field TITLE_REGEX string|false
---@class DiscussionSettings: table
---@field expanders? ExpanderOpts -- Customize the expander icons in the discussion tree
diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua
index ba42546e..ae139df5 100644
--- a/lua/gitlab/git.lua
+++ b/lua/gitlab/git.lua
@@ -74,14 +74,17 @@ M.fetch_remote_branch = function(remote_branch)
return true
end
----Determines whether the tracking branch is ahead of or behind the current branch, and warns the user if so
----@param current_branch string
----@param remote_branch string
----@param log_level number
----@return boolean
-M.get_ahead_behind = function(current_branch, remote_branch, log_level)
+---Determines whether the tracking branch is ahead of or behind the current branch and returns the
+---number of ahead and behind commits or nil values in case of errors.
+---@param current_branch string|nil
+---@param remote_branch string|nil
+---@return integer|nil ahead, integer|nil behind
+M.get_ahead_behind = function(current_branch, remote_branch)
+ if current_branch == nil or remote_branch == nil then
+ return nil, nil
+ end
if not M.fetch_remote_branch(remote_branch) then
- return false
+ return nil, nil
end
local u = require("gitlab.utils")
@@ -89,39 +92,16 @@ M.get_ahead_behind = function(current_branch, remote_branch, log_level)
run_system({ "git", "rev-list", "--left-right", "--count", current_branch .. "..." .. remote_branch })
if err ~= nil or result == nil then
u.notify("Could not determine if branch is up-to-date: " .. err, vim.log.levels.ERROR)
- return false
+ return nil, nil
end
local ahead, behind = result:match("(%d+)%s+(%d+)")
if ahead == nil or behind == nil then
- u.notify("Error parsing ahead/behind information.", vim.log.levels.ERROR)
- return false
- end
-
- ahead = tonumber(ahead)
- behind = tonumber(behind)
-
- if ahead > 0 and behind == 0 then
- u.notify(string.format("There are local changes that haven't been pushed to %s yet", remote_branch), log_level)
- return false
- end
- if behind > 0 and ahead == 0 then
- u.notify(string.format("There are remote changes on %s that haven't been pulled yet", remote_branch), log_level)
- return false
- end
-
- if ahead > 0 and behind > 0 then
- u.notify(
- string.format(
- "Your branch and the remote %s have diverged. You need to pull, possibly rebase, and then push.",
- remote_branch
- ),
- log_level
- )
- return false
+ u.notify("Error parsing ahead/behind information", vim.log.levels.ERROR)
+ return nil, nil
end
- return true -- Checks passed, branch is up-to-date
+ return tonumber(ahead), tonumber(behind)
end
---Return the name of the current branch or nil if it can't be retrieved
@@ -184,16 +164,35 @@ end
---@return boolean
M.check_current_branch_up_to_date_on_remote = function(log_level)
local current_branch = M.get_current_branch()
- if current_branch == nil then
+ local remote_branch = M.get_remote_branch()
+ local ahead, behind = M.get_ahead_behind(current_branch, remote_branch)
+ if ahead == nil or behind == nil then
return false
end
- local remote_branch = M.get_remote_branch()
- if remote_branch == nil then
+ local u = require("gitlab.utils")
+
+ if ahead > 0 and behind == 0 then
+ u.notify(string.format("There are local changes that haven't been pushed to %s yet", remote_branch), log_level)
+ return false
+ end
+ if behind > 0 and ahead == 0 then
+ u.notify(string.format("There are remote changes on %s that haven't been pulled yet", remote_branch), log_level)
return false
end
- return M.get_ahead_behind(current_branch, remote_branch, log_level)
+ if ahead > 0 and behind > 0 then
+ u.notify(
+ string.format(
+ "Your branch and the remote %s have diverged. You need to pull, possibly rebase, and then push.",
+ remote_branch
+ ),
+ log_level
+ )
+ return false
+ end
+
+ return true -- Checks passed, branch is up-to-date
end
---Warns user if the current MR is in a bad state (closed, has conflicts, merged)
diff --git a/lua/gitlab/health.lua b/lua/gitlab/health.lua
index a1ae8be5..46f58e21 100644
--- a/lua/gitlab/health.lua
+++ b/lua/gitlab/health.lua
@@ -31,7 +31,7 @@ M.check = function(return_results)
package = "plenary",
},
{
- name = "sindrets/diffview.nvim",
+ name = "dlyongemallo/diffview.nvim",
package = "diffview",
},
}
diff --git a/lua/gitlab/hunks.lua b/lua/gitlab/hunks.lua
index cbedf0f4..a8231a98 100644
--- a/lua/gitlab/hunks.lua
+++ b/lua/gitlab/hunks.lua
@@ -102,6 +102,7 @@ local parse_hunks_and_diff = function(base_sha)
"--minimal",
"--unified=0",
"--no-color",
+ "--no-ext-diff",
base_sha,
"--",
reviewer.get_current_file_oldpath(),
diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua
index 8c52c73b..e788a518 100644
--- a/lua/gitlab/init.lua
+++ b/lua/gitlab/init.lua
@@ -22,6 +22,7 @@ local health = require("gitlab.health")
local user = state.dependencies.user
local info = state.dependencies.info
+local mergeability = state.dependencies.mergeability
local labels_dep = state.dependencies.labels
local project_members = state.dependencies.project_members
local latest_pipeline = state.dependencies.latest_pipeline
@@ -44,8 +45,8 @@ local function setup(args)
return
end
- server.build() -- Builds the Go binary if it doesn't exist
state.merge_settings(args) -- Merges user settings with default settings
+ server.build() -- Builds the Go binary if it doesn't exist
state.set_global_keymaps() -- Sets keymaps that are not bound to a specific buffer
require("gitlab.colors") -- Sets colors
reviewer.init()
@@ -62,6 +63,7 @@ return {
setup = setup,
summary = async.sequence({
u.merge(info, { refresh = true }),
+ u.merge(mergeability, { refresh = true }),
labels_dep,
}, summary.summary),
approve = async.sequence({ info }, approvals.approve),
diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua
index 128591be..81cb5d66 100644
--- a/lua/gitlab/job.lua
+++ b/lua/gitlab/job.lua
@@ -4,9 +4,11 @@ local Job = require("plenary.job")
local u = require("gitlab.utils")
local M = {}
-M.run_job = function(endpoint, method, body, callback)
+M.run_job = function(endpoint, method, body, callback, on_error_callback)
local state = require("gitlab.state")
- local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s", state.settings.port) .. endpoint }
+ local port = state.settings.server and state.settings.server.port
+ local args =
+ { "--noproxy", "localhost", "-s", "-X", (method or "POST"), string.format("localhost:%s%s", port, endpoint) }
if body ~= nil then
local encoded_body = vim.json.encode(body)
@@ -16,7 +18,8 @@ M.run_job = function(endpoint, method, body, callback)
-- This handler will handle all responses from the Go server. Anything with a successful
-- status will call the callback (if it is supplied for the job). Otherwise, it will print out the
- -- success message or error message and details from the Go server.
+ -- success message or error message and details from the Go server and run the on_error_callback
+ -- (if supplied for the job).
local stderr = {}
Job:new({
command = "curl",
@@ -53,6 +56,9 @@ M.run_job = function(endpoint, method, body, callback)
-- Handle error case
local message = string.format("%s: %s", data.message, data.details)
u.notify(message, vim.log.levels.ERROR)
+ if on_error_callback then
+ on_error_callback(data)
+ end
end
end, 0)
end,
diff --git a/lua/gitlab/popup.lua b/lua/gitlab/popup.lua
index ecee5331..caa55f6d 100644
--- a/lua/gitlab/popup.lua
+++ b/lua/gitlab/popup.lua
@@ -166,7 +166,9 @@ M.set_up_autocommands = function(popup, layout, previous_window, opts)
if previous_window ~= nil then
popup:on("BufHidden", function()
vim.schedule(function()
- vim.api.nvim_set_current_win(previous_window)
+ if vim.api.nvim_win_is_valid(previous_window) then
+ vim.api.nvim_set_current_win(previous_window)
+ end
end)
end)
end
diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua
index d9ff6305..f4f99c99 100644
--- a/lua/gitlab/server.lua
+++ b/lua/gitlab/server.lua
@@ -12,8 +12,19 @@ local M = {}
-- tag than the Lua code (exposed via the /version endpoint) then shuts down the server, rebuilds it, and
-- restarts the server again.
M.build_and_start = function(callback)
- M.build(false)
+ if M.build(false) == false then
+ return
+ end
+
+ -- When the user provides their own binary, skip the version check and rebuild cycle.
+ -- The user is responsible for keeping their binary up to date.
+ local user_provided_binary = state.settings.server.binary_provided
+
M.start(function()
+ if user_provided_binary then
+ callback()
+ return
+ end
M.get_version(function(version)
if version.plugin_version ~= version.binary_version then
M.shutdown(function()
@@ -30,7 +41,11 @@ end
-- Starts the Go server and call the callback provided
M.start = function(callback)
- local port = tonumber(state.settings.port) or 0
+ if state.settings.port ~= nil and state.settings.server.port == nil then
+ state.settings.server.port = state.settings.port
+ u.notify("The setting `port` has been renamed `server.port`", vim.log.levels.WARN)
+ end
+ local port = tonumber(state.settings.server.port) or 0
local parsed_port = nil
local callback_called = false
@@ -51,7 +66,7 @@ M.start = function(callback)
settings = settings:gsub('"', '\\"')
end
- local command = string.format('"%s" "%s"', state.settings.bin, settings)
+ local command = string.format('"%s" "%s"', state.settings.server.binary, settings)
local job_id = vim.fn.jobstart(command, {
on_stdout = function(_, data)
@@ -61,7 +76,7 @@ M.start = function(callback)
port = line:match("Server started on port:%s+(%d+)")
if port ~= nil then
parsed_port = port
- state.settings.port = port
+ state.settings.server.port = port
break
end
end
@@ -104,27 +119,62 @@ end
-- Builds the Go binary with the current Git tag.
M.build = function(override)
- local file_path = u.current_file_path()
- local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h")
+ state.settings.root_path = u.get_root_path()
+
+ -- If the user provided a path to the server, don't build it.
+ if state.settings.server.binary ~= nil then
+ state.settings.server.binary_provided = true
+ local binary_exists = vim.loop.fs_stat(state.settings.server.binary)
+ if binary_exists == nil then
+ u.notify(
+ string.format("The user-provided server path (%s) does not exist.", state.settings.server.binary),
+ vim.log.levels.ERROR
+ )
+ return false
+ end
+ return
+ end
- local bin_name = u.is_windows() and "bin.exe" or "bin"
- state.settings.root_path = parent_dir
- state.settings.bin = parent_dir .. u.path_separator .. "cmd" .. u.path_separator .. bin_name
+ -- If the user did not provide a path, we build it and place it in either the data path, or the
+ -- first writable path we find in the runtime.
+ local datapath = vim.fn.stdpath("data")
+ local runtimepath = vim.api.nvim_list_runtime_paths()
+ table.insert(runtimepath, 1, datapath)
+
+ local bin_name = u.is_windows() and "server.exe" or "server"
+ local bin_folder
+ for _, path in ipairs(runtimepath) do
+ local ok, err = vim.loop.fs_access(path, "w")
+ if err == nil and ok ~= nil and ok then
+ bin_folder = path .. u.path_separator .. "gitlab.nvim" .. u.path_separator .. "bin"
+ if vim.fn.mkdir(bin_folder, "p") == 1 then
+ state.settings.server.binary = bin_folder .. u.path_separator .. bin_name
+ break
+ end
+ end
+ end
+
+ if state.settings.server.binary == nil then
+ u.notify("Could not find a writable folder in the runtime path to save the server to.", vim.log.levels.ERROR)
+ return
+ end
if not override then
- local binary_exists = vim.loop.fs_stat(state.settings.bin)
+ local binary_exists = vim.loop.fs_stat(state.settings.server.binary)
if binary_exists ~= nil then
return
end
end
- local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait()
+ local version_output = vim
+ .system({ "git", "describe", "--tags", "--always" }, { cwd = state.settings.root_path })
+ :wait()
local version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown"
local ldflags = string.format("-X main.Version=%s", version)
local res = vim
.system(
- { "go", "build", "-ldflags", ldflags, "-o", bin_name },
+ { "go", "build", "-buildvcs=false", "-ldflags", ldflags, "-o", state.settings.server.binary },
{ cwd = state.settings.root_path .. u.path_separator .. "cmd" }
)
:wait()
@@ -133,6 +183,12 @@ M.build = function(override)
u.notify(string.format("Failed to install with status code %d:\n%s", res.code, res.stderr), vim.log.levels.ERROR)
return false
end
+
+ local Path = require("plenary.path")
+ local src = Path:new(state.settings.root_path .. u.path_separator .. "cmd" .. u.path_separator .. "config")
+ local dest = Path:new(bin_folder .. u.path_separator .. "config")
+ src:copy({ destination = dest, recursive = true, override = true })
+
u.notify("Installed successfully!", vim.log.levels.INFO)
return true
end
@@ -179,13 +235,13 @@ M.get_version = function(callback)
u.notify("Gitlab server not running", vim.log.levels.ERROR)
return nil
end
- local file_path = u.current_file_path()
- local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h")
+ local parent_dir = u.get_root_path()
local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait()
local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown"
- local args = { "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.port) }
+ local args =
+ { "--noproxy", "localhost", "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.server.port) }
-- We call the "/version" endpoint here instead of through the regular jobs pattern because earlier versions of the plugin
-- may not have it. We handle a 404 as an "unknown" version error.
diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua
index d67e0c06..3a6df9b5 100644
--- a/lua/gitlab/state.lua
+++ b/lua/gitlab/state.lua
@@ -3,16 +3,17 @@
-- 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 List = require("gitlab.utils.list")
-local M = {}
-
-M.emoji_map = nil
+local M = {
+ emoji_map = nil,
+ ahead_behind = { nil, 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 git = require("gitlab.git")
local base_path, err = M.settings.config_path, nil
if base_path == nil then
base_path, err = git.base_dir()
@@ -45,7 +46,10 @@ end
M.settings = {
auth_provider = M.default_auth_provider,
file_separator = u.path_separator,
- port = nil, -- choose random port
+ server = {
+ binary = nil,
+ port = nil,
+ },
debug = {
request = false,
response = false,
@@ -81,6 +85,7 @@ M.settings = {
approve = "glA",
revoke = "glR",
merge = "glM",
+ set_auto_merge = "glm",
create_mr = "glC",
choose_merge_request = "glc",
start_review = "glS",
@@ -214,10 +219,41 @@ M.settings = {
"pipeline",
"branch",
"target_branch",
+ "auto_merge",
"delete_branch",
"squash",
"labels",
"web_url",
+ "mergeability_checks",
+ },
+ },
+ mergeability_checks = {
+ statuses = {
+ SUCCESS = "✅",
+ CHECKING = "🔁",
+ FAILED = "❌",
+ WARNING = "⚠️",
+ INACTIVE = "💤",
+ },
+ checks = {
+ CI_MUST_PASS = "Pipeline must succeed",
+ COMMITS_STATUS = "Source branch exists and contains commits",
+ CONFLICT = "Merge conflicts must be resolved",
+ DISCUSSIONS_NOT_RESOLVED = "Open threads must be resolved",
+ DRAFT_STATUS = "Merge request must not be draft",
+ JIRA_ASSOCIATION_MISSING = "Title or description references a Jira issue",
+ LOCKED_LFS_FILES = "All LFS files must be unlocked",
+ LOCKED_PATHS = "All paths must be unlocked",
+ MERGE_REQUEST_BLOCKED = "Merge request is not blocked",
+ MERGE_TIME = "Merge is not blocked due to a scheduled merge time",
+ NEED_REBASE = "Merge request must be rebased, fast-forward merge is not possible",
+ NOT_APPROVED = "All required approvals must be given",
+ NOT_OPEN = "Merge request must be open",
+ REQUESTED_CHANGES = "Change requests must be approved by the requesting user",
+ SECURITY_POLICY_PIPELINE_CHECK = "Security policy pipeline must succeed",
+ SECURITY_POLICY_VIOLATIONS = "Security policies are satisfied",
+ STATUS_CHECKS_MUST_PASS = "External status checks pass",
+ TITLE_REGEX = "Title matches the expected regex",
},
},
discussion_signs = {
@@ -389,6 +425,12 @@ M.set_global_keymaps = function()
end, { desc = "Merge MR", nowait = keymaps.global.merge_nowait })
end
+ if keymaps.global.set_auto_merge then
+ vim.keymap.set("n", keymaps.global.set_auto_merge, function()
+ require("gitlab").merge({ auto_merge = true })
+ end, { desc = "Set MR to auto-merge", nowait = keymaps.global.set_auto_merge_nowait })
+ end
+
if keymaps.global.copy_mr_url then
vim.keymap.set("n", keymaps.global.copy_mr_url, function()
require("gitlab").copy_mr_url()
@@ -467,6 +509,12 @@ M.dependencies = {
state = "INFO",
refresh = false,
},
+ mergeability = {
+ endpoint = "/mr/info/mergeability",
+ key = "mergeability_checks",
+ state = "MERGEABILITY",
+ refresh = false,
+ },
latest_pipeline = {
endpoint = "/pipeline",
key = "latest_pipeline",
diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua
index 09054c9c..0b239630 100644
--- a/lua/gitlab/utils/init.lua
+++ b/lua/gitlab/utils/init.lua
@@ -309,8 +309,8 @@ end
M.get_longest_string = function(list)
local longest = 0
for _, v in pairs(list) do
- if string.len(v) > longest then
- longest = string.len(v)
+ if vim.fn.strcharlen(v) > longest then
+ longest = vim.fn.strcharlen(v)
end
end
return longest
@@ -504,9 +504,10 @@ M.read_file = function(file_path, opts)
return file_contents
end
-M.current_file_path = function()
+-- Returns the root path of the plugin (four levels up from this file: lua/gitlab/utils/init.lua)
+M.get_root_path = function()
local path = debug.getinfo(1, "S").source:sub(2)
- return vim.fn.fnamemodify(path, ":p")
+ return vim.fn.fnamemodify(path, ":p:h:h:h:h")
end
local random = math.random
@@ -666,6 +667,8 @@ end
M.open_in_browser = function(url)
if vim.fn.has("mac") == 1 then
vim.fn.jobstart({ "open", url })
+ elseif vim.fn.has("win32") == 1 then
+ vim.fn.jobstart({ "cmd", "/c", "start", url })
elseif vim.fn.has("unix") == 1 then
vim.fn.jobstart({ "xdg-open", url })
else