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